@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,332 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `display-fold.v1` — the canonical display projection of a session's published
|
|
3
|
+
* records (D1): attributions, evidence, coverage, and gaps. Nothing else. Baseline
|
|
4
|
+
* inventory and task grouping are deliberately outside this contract, so agreement
|
|
5
|
+
* on these four components is never whole-session parity.
|
|
6
|
+
*
|
|
7
|
+
* Pure and I/O-free: a function of already-parsed records, composing the existing
|
|
8
|
+
* public folds (`foldAttributions`, `foldEvidence`) rather than re-deriving them.
|
|
9
|
+
* DISPLAY-FOLD.md is the normative description; the hand-written corpus under
|
|
10
|
+
* contracts/display-fold/v1/ is its executable form.
|
|
11
|
+
*/
|
|
12
|
+
import { foldAttributions, foldEvidence } from "./attribution.js";
|
|
13
|
+
export const DISPLAY_FOLD_CONTRACT = 'display-fold.v1';
|
|
14
|
+
const ATTRIBUTION = 'slipstream.change.attribution.v1';
|
|
15
|
+
const EVIDENCE = 'slipstream.harness.evidence.v1';
|
|
16
|
+
const COVERAGE = 'slipstream.enrichment.coverage.v1';
|
|
17
|
+
const GAP = 'slipstream.capture.gap.v1';
|
|
18
|
+
/** A family this contract interprets, at any version — including a dotted suffix
|
|
19
|
+
* like `v1.0`, which must be refused as unsupported rather than silently treated
|
|
20
|
+
* as an unrelated, unknown type. Only an exact `1` is supported. */
|
|
21
|
+
const CONSUMED_FAMILY = /^slipstream\.(?:file\.changed|change\.attribution|harness\.evidence|enrichment\.coverage|capture\.gap)\.v(.+)$/;
|
|
22
|
+
const SEQ = /^[1-9][0-9]*$/;
|
|
23
|
+
const HARNESSES = ['claude-code', 'codex'];
|
|
24
|
+
const BASES = ['tool-start', 'tool-end', 'record-time'];
|
|
25
|
+
const STATUSES = ['heuristic', 'ambiguous', 'unknown'];
|
|
26
|
+
const ATTRIBUTION_REASONS = ['single-candidate', 'multiple-candidates', 'no-matching-evidence', 'observation-interval-unavailable'];
|
|
27
|
+
const COVERAGE_STATES = ['pending', 'readable', 'degraded', 'unavailable'];
|
|
28
|
+
const ISSUE_KINDS = ['missing', 'inaccessible', 'malformed', 'unsupported', 'discovery-limited'];
|
|
29
|
+
const GAP_REASONS = ['coalesced', 'baseline-unreadable', 'watcher-error', 'restart', 'storage'];
|
|
30
|
+
/** Exact string order by UTF-16 code units — never `localeCompare`, never normalized. */
|
|
31
|
+
function compareStr(a, b) {
|
|
32
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
33
|
+
}
|
|
34
|
+
/** Numeric order of canonical decimal strings of any length. */
|
|
35
|
+
function compareSeq(a, b) {
|
|
36
|
+
return a.length - b.length || compareStr(a, b);
|
|
37
|
+
}
|
|
38
|
+
function compareIdentity(a, b) {
|
|
39
|
+
return compareStr(a.source, b.source) || compareSeq(a.seq, b.seq);
|
|
40
|
+
}
|
|
41
|
+
function compareStrings(a, b) {
|
|
42
|
+
for (let i = 0; i < a.length; i++) {
|
|
43
|
+
const c = compareStr(a[i], b[i]);
|
|
44
|
+
if (c !== 0)
|
|
45
|
+
return c;
|
|
46
|
+
}
|
|
47
|
+
return 0;
|
|
48
|
+
}
|
|
49
|
+
function isObj(v) {
|
|
50
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
51
|
+
}
|
|
52
|
+
function oneOf(v, values) {
|
|
53
|
+
return typeof v === 'string' && values.includes(v);
|
|
54
|
+
}
|
|
55
|
+
function isSeq(v) {
|
|
56
|
+
return typeof v === 'string' && SEQ.test(v);
|
|
57
|
+
}
|
|
58
|
+
function isEvidenceKey(v) {
|
|
59
|
+
return (isObj(v) && oneOf(v.harness, HARNESSES) && typeof v.harness_session_id === 'string' && typeof v.record_id === 'string');
|
|
60
|
+
}
|
|
61
|
+
/** Structural JSON-value equality: object key order is irrelevant, array order is not.
|
|
62
|
+
* Iterative, so a deeply nested field the fold never reads cannot overflow the stack. */
|
|
63
|
+
function jsonEqual(a, b) {
|
|
64
|
+
const pending = [[a, b]];
|
|
65
|
+
for (let pair = pending.pop(); pair; pair = pending.pop()) {
|
|
66
|
+
const [x, y] = pair;
|
|
67
|
+
if (x === y)
|
|
68
|
+
continue;
|
|
69
|
+
if (Array.isArray(x)) {
|
|
70
|
+
if (!Array.isArray(y) || x.length !== y.length)
|
|
71
|
+
return false;
|
|
72
|
+
x.forEach((v, i) => pending.push([v, y[i]]));
|
|
73
|
+
}
|
|
74
|
+
else if (isObj(x) && isObj(y)) {
|
|
75
|
+
const keys = Object.keys(x);
|
|
76
|
+
if (keys.length !== Object.keys(y).length || !keys.every((k) => Object.hasOwn(y, k)))
|
|
77
|
+
return false;
|
|
78
|
+
for (const k of keys)
|
|
79
|
+
pending.push([x[k], y[k]]);
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
function identityOf(r) {
|
|
88
|
+
if (!isObj(r) || typeof r.source !== 'string' || r.source === '' || !isSeq(r.seq))
|
|
89
|
+
return undefined;
|
|
90
|
+
return { source: r.source, seq: r.seq };
|
|
91
|
+
}
|
|
92
|
+
function attributionValid(d) {
|
|
93
|
+
return (isSeq(d.change_seq) &&
|
|
94
|
+
isSeq(d.policy_seq) &&
|
|
95
|
+
oneOf(d.status, STATUSES) &&
|
|
96
|
+
oneOf(d.reason, ATTRIBUTION_REASONS) &&
|
|
97
|
+
Array.isArray(d.evidence_seqs) &&
|
|
98
|
+
d.evidence_seqs.every(isSeq) &&
|
|
99
|
+
(d.excluded_conflicts === undefined || (Array.isArray(d.excluded_conflicts) && d.excluded_conflicts.every(isEvidenceKey))));
|
|
100
|
+
}
|
|
101
|
+
function evidenceProblem(d) {
|
|
102
|
+
const ts = d.timestamp;
|
|
103
|
+
const scope = d.file_scope;
|
|
104
|
+
const scopeValid = isObj(scope) &&
|
|
105
|
+
((scope.kind === 'paths' && Array.isArray(scope.paths) && scope.paths.every((p) => typeof p === 'string')) ||
|
|
106
|
+
(scope.kind === 'unknown' && typeof scope.reason === 'string'));
|
|
107
|
+
if (!isEvidenceKey(d.evidence_key) ||
|
|
108
|
+
typeof d.tool_name !== 'string' ||
|
|
109
|
+
!isObj(ts) ||
|
|
110
|
+
!oneOf(ts.basis, BASES) ||
|
|
111
|
+
typeof ts.at_ms !== 'number' ||
|
|
112
|
+
!Number.isInteger(ts.at_ms) ||
|
|
113
|
+
!scopeValid) {
|
|
114
|
+
return 'invalid-record';
|
|
115
|
+
}
|
|
116
|
+
if (ts.at_ms < 0 || ts.at_ms > Number.MAX_SAFE_INTEGER)
|
|
117
|
+
return 'timestamp-out-of-range';
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
function coverageValid(d) {
|
|
121
|
+
return (oneOf(d.harness, HARNESSES) &&
|
|
122
|
+
oneOf(d.state, COVERAGE_STATES) &&
|
|
123
|
+
(d.issues === undefined ||
|
|
124
|
+
(Array.isArray(d.issues) && d.issues.every((i) => isObj(i) && oneOf(i.kind, ISSUE_KINDS) && typeof i.detail === 'string'))));
|
|
125
|
+
}
|
|
126
|
+
function gapValid(d) {
|
|
127
|
+
const scope = d.scope;
|
|
128
|
+
return (oneOf(d.reason, GAP_REASONS) &&
|
|
129
|
+
isObj(scope) &&
|
|
130
|
+
(scope.kind === 'session' || ((scope.kind === 'directory' || scope.kind === 'path') && typeof scope.path === 'string')) &&
|
|
131
|
+
(d.episode_id === undefined || typeof d.episode_id === 'string'));
|
|
132
|
+
}
|
|
133
|
+
/** What is wrong with an identified record, judged only on the fields this contract interprets. */
|
|
134
|
+
function recordProblem(r) {
|
|
135
|
+
const { type, data } = r;
|
|
136
|
+
if (typeof type !== 'string' || !isObj(data))
|
|
137
|
+
return 'invalid-record';
|
|
138
|
+
switch (type) {
|
|
139
|
+
case ATTRIBUTION:
|
|
140
|
+
return attributionValid(data) ? undefined : 'invalid-record';
|
|
141
|
+
case EVIDENCE:
|
|
142
|
+
return evidenceProblem(data);
|
|
143
|
+
case COVERAGE:
|
|
144
|
+
return coverageValid(data) ? undefined : 'invalid-record';
|
|
145
|
+
case GAP:
|
|
146
|
+
return gapValid(data) ? undefined : 'invalid-record';
|
|
147
|
+
default:
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
function first(items, compare) {
|
|
152
|
+
return items.reduce((min, x) => (min === undefined || compare(x, min) < 0 ? x : min), undefined);
|
|
153
|
+
}
|
|
154
|
+
function projectKey(k) {
|
|
155
|
+
return { harness: k.harness, harness_session_id: k.harness_session_id, record_id: k.record_id };
|
|
156
|
+
}
|
|
157
|
+
function keyTuple(k) {
|
|
158
|
+
return [k.harness, k.harness_session_id, k.record_id];
|
|
159
|
+
}
|
|
160
|
+
function foldAttributionRows(records) {
|
|
161
|
+
const rows = [];
|
|
162
|
+
for (const { seq, source, data } of foldAttributions(records).values()) {
|
|
163
|
+
const row = {
|
|
164
|
+
source,
|
|
165
|
+
change_seq: data.change_seq,
|
|
166
|
+
attribution_seq: seq.toString(),
|
|
167
|
+
policy_seq: data.policy_seq,
|
|
168
|
+
status: data.status,
|
|
169
|
+
reason: data.reason,
|
|
170
|
+
evidence_seqs: [...data.evidence_seqs].sort(compareSeq),
|
|
171
|
+
};
|
|
172
|
+
if (data.excluded_conflicts !== undefined) {
|
|
173
|
+
row.excluded_conflicts = data.excluded_conflicts
|
|
174
|
+
.map((k) => projectKey(k))
|
|
175
|
+
.sort((a, b) => compareStrings(keyTuple(a), keyTuple(b)));
|
|
176
|
+
}
|
|
177
|
+
rows.push(row);
|
|
178
|
+
}
|
|
179
|
+
return rows.sort((a, b) => compareStr(a.source, b.source) || compareSeq(a.change_seq, b.change_seq));
|
|
180
|
+
}
|
|
181
|
+
/** Evidence is folded per source (D8): the same evidence key in two sessions is two invocations. */
|
|
182
|
+
function foldEvidenceRows(records) {
|
|
183
|
+
const bySource = new Map();
|
|
184
|
+
for (const r of records) {
|
|
185
|
+
if (r.type !== EVIDENCE)
|
|
186
|
+
continue;
|
|
187
|
+
const group = bySource.get(r.source);
|
|
188
|
+
if (group)
|
|
189
|
+
group.push(r);
|
|
190
|
+
else
|
|
191
|
+
bySource.set(r.source, [r]);
|
|
192
|
+
}
|
|
193
|
+
const rows = [];
|
|
194
|
+
for (const [source, group] of bySource) {
|
|
195
|
+
for (const inv of foldEvidence(group).values()) {
|
|
196
|
+
rows.push({
|
|
197
|
+
source,
|
|
198
|
+
...projectKey(inv.key),
|
|
199
|
+
evidence_seqs: inv.evidenceSeqs.map(String),
|
|
200
|
+
min_at_ms: inv.minAtMs,
|
|
201
|
+
max_at_ms: inv.maxAtMs,
|
|
202
|
+
known_paths: [...inv.knownPaths],
|
|
203
|
+
conflicted: inv.conflicted,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return rows.sort((a, b) => compareStrings([a.source, ...keyTuple(a)], [b.source, ...keyTuple(b)]));
|
|
208
|
+
}
|
|
209
|
+
/** Highest seq per `(source, harness)` replaces wholesale. No record means health is unknown — no row. */
|
|
210
|
+
function foldCoverageRows(records) {
|
|
211
|
+
const latest = new Map();
|
|
212
|
+
for (const r of records) {
|
|
213
|
+
if (r.type !== COVERAGE)
|
|
214
|
+
continue;
|
|
215
|
+
const k = JSON.stringify([r.source, r.data.harness]);
|
|
216
|
+
const cur = latest.get(k);
|
|
217
|
+
if (!cur || compareSeq(r.seq, cur.seq) > 0)
|
|
218
|
+
latest.set(k, r);
|
|
219
|
+
}
|
|
220
|
+
const rows = [];
|
|
221
|
+
for (const { source, seq, data } of latest.values()) {
|
|
222
|
+
const row = { source, harness: data.harness, seq, state: data.state };
|
|
223
|
+
if (data.issues !== undefined) {
|
|
224
|
+
row.issues = data.issues
|
|
225
|
+
.map((i) => ({ kind: i.kind, detail: i.detail }))
|
|
226
|
+
.sort((a, b) => compareStrings([a.kind, a.detail], [b.kind, b.detail]));
|
|
227
|
+
}
|
|
228
|
+
rows.push(row);
|
|
229
|
+
}
|
|
230
|
+
return rows.sort((a, b) => compareStrings([a.source, a.harness], [b.source, b.harness]));
|
|
231
|
+
}
|
|
232
|
+
/** Published gaps only, as recorded — never inferred, healed, or scored. */
|
|
233
|
+
function foldGapRows(records) {
|
|
234
|
+
const rows = [];
|
|
235
|
+
for (const { source, seq, type, data } of records) {
|
|
236
|
+
if (type !== GAP)
|
|
237
|
+
continue;
|
|
238
|
+
const scope = data.scope;
|
|
239
|
+
const row = {
|
|
240
|
+
source,
|
|
241
|
+
seq,
|
|
242
|
+
reason: data.reason,
|
|
243
|
+
scope: scope.kind === 'session' ? { kind: 'session' } : { kind: scope.kind, path: scope.path },
|
|
244
|
+
};
|
|
245
|
+
if (data.episode_id !== undefined)
|
|
246
|
+
row.episode_id = data.episode_id;
|
|
247
|
+
rows.push(row);
|
|
248
|
+
}
|
|
249
|
+
return rows.sort(compareIdentity);
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Fold parsed records into the canonical display state, or refuse with a
|
|
253
|
+
* distinguishable reason. A refusal carries no state: a partial fold is never
|
|
254
|
+
* presented as good. Precedence is invalid > corrupt > unsupported, and within a
|
|
255
|
+
* class the smallest `(source, seq)` is reported, so the result never depends on
|
|
256
|
+
* delivery order.
|
|
257
|
+
*/
|
|
258
|
+
export function foldDisplay(records) {
|
|
259
|
+
let unidentified = false;
|
|
260
|
+
const invalid = [];
|
|
261
|
+
const conflicts = [];
|
|
262
|
+
const seen = new Map();
|
|
263
|
+
const unique = [];
|
|
264
|
+
for (const r of records) {
|
|
265
|
+
const id = identityOf(r);
|
|
266
|
+
if (!id) {
|
|
267
|
+
unidentified = true;
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
const problem = recordProblem(r);
|
|
271
|
+
if (problem)
|
|
272
|
+
invalid.push({ ...id, reason: problem });
|
|
273
|
+
const k = JSON.stringify([id.source, id.seq]);
|
|
274
|
+
if (!seen.has(k)) {
|
|
275
|
+
seen.set(k, r);
|
|
276
|
+
if (!problem)
|
|
277
|
+
unique.push(r);
|
|
278
|
+
}
|
|
279
|
+
else if (!jsonEqual(seen.get(k), r)) {
|
|
280
|
+
conflicts.push(id);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
const contract = DISPLAY_FOLD_CONTRACT;
|
|
284
|
+
if (unidentified)
|
|
285
|
+
return { contract, result: 'invalid', error: { reason: 'invalid-record' } };
|
|
286
|
+
const bad = first(invalid, (a, b) => compareIdentity(a, b) || compareStr(a.reason, b.reason));
|
|
287
|
+
if (bad)
|
|
288
|
+
return { contract, result: 'invalid', error: { reason: bad.reason, source: bad.source, seq: bad.seq } };
|
|
289
|
+
const conflict = first(conflicts, compareIdentity);
|
|
290
|
+
if (conflict)
|
|
291
|
+
return { contract, result: 'corrupt', error: { reason: 'conflicting-records', ...conflict } };
|
|
292
|
+
const unsupported = first(unique.filter((r) => {
|
|
293
|
+
const version = CONSUMED_FAMILY.exec(r.type)?.[1];
|
|
294
|
+
return version !== undefined && version !== '1';
|
|
295
|
+
}), compareIdentity);
|
|
296
|
+
if (unsupported) {
|
|
297
|
+
const { source, seq, type } = unsupported;
|
|
298
|
+
return { contract, result: 'unsupported', error: { reason: 'unsupported-event-version', source, seq, type } };
|
|
299
|
+
}
|
|
300
|
+
return {
|
|
301
|
+
contract,
|
|
302
|
+
result: 'ok',
|
|
303
|
+
state: {
|
|
304
|
+
attributions: foldAttributionRows(unique),
|
|
305
|
+
evidence: foldEvidenceRows(unique),
|
|
306
|
+
coverage: foldCoverageRows(unique),
|
|
307
|
+
gaps: foldGapRows(unique),
|
|
308
|
+
},
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Canonical serialization: object keys sorted by UTF-16 code units, array order
|
|
313
|
+
* kept, no whitespace, `JSON.stringify` string escaping (lone surrogates escaped),
|
|
314
|
+
* numbers only as safe integers. Anything else is a caller bug and throws rather
|
|
315
|
+
* than being coerced.
|
|
316
|
+
*/
|
|
317
|
+
export function canonicalJson(value) {
|
|
318
|
+
if (value === null || typeof value === 'boolean' || typeof value === 'string')
|
|
319
|
+
return JSON.stringify(value);
|
|
320
|
+
if (typeof value === 'number') {
|
|
321
|
+
if (!Number.isSafeInteger(value))
|
|
322
|
+
throw new TypeError(`canonicalJson: ${value} is not a safe integer`);
|
|
323
|
+
return String(value);
|
|
324
|
+
}
|
|
325
|
+
if (Array.isArray(value))
|
|
326
|
+
return `[${value.map((v) => canonicalJson(v)).join(',')}]`;
|
|
327
|
+
if (isObj(value)) {
|
|
328
|
+
const keys = Object.keys(value).sort(compareStr);
|
|
329
|
+
return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(value[k])}`).join(',')}}`;
|
|
330
|
+
}
|
|
331
|
+
throw new TypeError(`canonicalJson: unsupported ${typeof value}`);
|
|
332
|
+
}
|
package/dist/engine.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { sep } from 'node:path';
|
|
2
|
+
import { snapshotsEqual } from "./snapshot.js";
|
|
3
|
+
/**
|
|
4
|
+
* Per-path serialized capture. Each path compares the freshly-read snapshot
|
|
5
|
+
* against the *last committed* snapshot — never against whatever is on disk
|
|
6
|
+
* when the read runs — so racing writes cannot corrupt the recorded `before`.
|
|
7
|
+
* Notifies arriving while a path is being processed coalesce into a single
|
|
8
|
+
* follow-up cycle; a state skipped that way is surfaced as a capture gap.
|
|
9
|
+
*/
|
|
10
|
+
export function createEngine({ reader, log, now = Date.now }) {
|
|
11
|
+
const committed = new Map();
|
|
12
|
+
const pending = new Map(); // path -> earliest observed_at_ms
|
|
13
|
+
const coalesced = new Set();
|
|
14
|
+
const processing = new Set();
|
|
15
|
+
const inflight = new Set();
|
|
16
|
+
const baselineUnknown = new Set(); // dirs whose baseline scan failed
|
|
17
|
+
const setBaseline = (path, snapshot) => {
|
|
18
|
+
committed.set(path, snapshot);
|
|
19
|
+
};
|
|
20
|
+
const markBaselineUnknown = (relDir) => {
|
|
21
|
+
baselineUnknown.add(relDir);
|
|
22
|
+
};
|
|
23
|
+
// The prior state of a path we never baselined is honestly unknown when its
|
|
24
|
+
// directory could not be scanned; elsewhere, no baseline means it did not
|
|
25
|
+
// exist yet.
|
|
26
|
+
const priorFor = (path) => {
|
|
27
|
+
for (const prefix of baselineUnknown) {
|
|
28
|
+
if (prefix === '' || path === prefix || path.startsWith(prefix + sep)) {
|
|
29
|
+
return { kind: 'unavailable', reason: 'baseline-unknown' };
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return { kind: 'absent' };
|
|
33
|
+
};
|
|
34
|
+
const notify = (path, observedAtMs) => {
|
|
35
|
+
const prior = pending.get(path);
|
|
36
|
+
pending.set(path, prior === undefined ? observedAtMs : Math.min(prior, observedAtMs));
|
|
37
|
+
if (processing.has(path)) {
|
|
38
|
+
coalesced.add(path); // a notify landed while this path was being read
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
processing.add(path);
|
|
42
|
+
// A processing failure (e.g. the log poisoned itself after a write error)
|
|
43
|
+
// must be surfaced, never left as an unhandled rejection that crashes the
|
|
44
|
+
// watcher. The log's own failure is the durable signal; this is the console.
|
|
45
|
+
const task = processLoop(path)
|
|
46
|
+
.catch((err) => {
|
|
47
|
+
console.error(`slipstream: capture processing error for ${path}: ${err.message}`);
|
|
48
|
+
})
|
|
49
|
+
.finally(() => inflight.delete(task));
|
|
50
|
+
inflight.add(task);
|
|
51
|
+
};
|
|
52
|
+
const handleOnce = async (path, observedAtMs, wasCoalesced) => {
|
|
53
|
+
const after = await reader.read(path);
|
|
54
|
+
// Close the observation interval the instant acquisition finishes. A regressed
|
|
55
|
+
// clock (end < start) is recorded truthfully, never clamped into a fabricated
|
|
56
|
+
// interval — interpretation handles the inversion, capture does not lie.
|
|
57
|
+
const endMs = now();
|
|
58
|
+
const before = committed.get(path) ?? priorFor(path);
|
|
59
|
+
if (snapshotsEqual(before, after)) {
|
|
60
|
+
// Nothing to record — but if we coalesced, an intermediate state may have
|
|
61
|
+
// existed and been lost. Surface that honestly rather than silently.
|
|
62
|
+
if (wasCoalesced) {
|
|
63
|
+
await log.append({
|
|
64
|
+
type: 'slipstream.capture.gap.v1',
|
|
65
|
+
occurred_at_ms: observedAtMs,
|
|
66
|
+
data: { scope: { kind: 'path', path }, reason: 'coalesced' },
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
await log.append({
|
|
72
|
+
type: 'slipstream.file.changed.v1',
|
|
73
|
+
occurred_at_ms: observedAtMs,
|
|
74
|
+
data: {
|
|
75
|
+
path,
|
|
76
|
+
before,
|
|
77
|
+
after,
|
|
78
|
+
observation: 'watcher',
|
|
79
|
+
coalesced: wasCoalesced,
|
|
80
|
+
observed_interval_ms: { start_ms: observedAtMs, end_ms: endMs },
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
committed.set(path, after);
|
|
84
|
+
};
|
|
85
|
+
const processLoop = async (path) => {
|
|
86
|
+
try {
|
|
87
|
+
// No `await` may sit between the emptiness check and processing.delete
|
|
88
|
+
// below, or a concurrent notify could be dropped. Both are synchronous.
|
|
89
|
+
for (;;) {
|
|
90
|
+
const observedAtMs = pending.get(path);
|
|
91
|
+
if (observedAtMs === undefined)
|
|
92
|
+
break;
|
|
93
|
+
pending.delete(path);
|
|
94
|
+
const wasCoalesced = coalesced.delete(path);
|
|
95
|
+
await handleOnce(path, observedAtMs, wasCoalesced);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
finally {
|
|
99
|
+
processing.delete(path);
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
const drain = async () => {
|
|
103
|
+
while (inflight.size > 0) {
|
|
104
|
+
await Promise.all([...inflight]);
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
const resetNotifications = () => {
|
|
108
|
+
pending.clear();
|
|
109
|
+
coalesced.clear();
|
|
110
|
+
};
|
|
111
|
+
return { setBaseline, markBaselineUnknown, notify, drain, resetNotifications };
|
|
112
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { realpathSync } from 'node:fs';
|
|
2
|
+
import { pathToFileURL } from 'node:url';
|
|
3
|
+
/**
|
|
4
|
+
* True when this module is the process entrypoint (run directly), false when it
|
|
5
|
+
* is merely imported. Node's ESM loader resolves symlinks for `import.meta.url`,
|
|
6
|
+
* but `process.argv[1]` keeps the path exactly as invoked — so a symlinked or
|
|
7
|
+
* `npm link`-ed launcher must be realpath'd before comparing, or the guard
|
|
8
|
+
* wrongly decides "imported" and the program silently does nothing.
|
|
9
|
+
*/
|
|
10
|
+
export function isMainModule(moduleUrl, argvPath) {
|
|
11
|
+
try {
|
|
12
|
+
return moduleUrl === pathToFileURL(realpathSync(argvPath)).href;
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
}
|
package/dist/event.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The event schema is Slipstream's public interface (CLAUDE.md). Every record in
|
|
3
|
+
* `events.jsonl` is one CloudEvents 1.0 structured JSON object per line. We use
|
|
4
|
+
* the envelope directly — no SDK — and keep Slipstream-specific fields in `data`.
|
|
5
|
+
*
|
|
6
|
+
* Freeze rules for v1 (see schemas/):
|
|
7
|
+
* - `seq` is a contiguous per-session decimal string ("1", "2", ...) expressing
|
|
8
|
+
* commit order. `id` equals `seq` — CloudEvents identity is (source, id), so a
|
|
9
|
+
* second UUID adds nothing.
|
|
10
|
+
* - The version lives in `type` (`slipstream.<name>.v1`), never in `specversion`
|
|
11
|
+
* (which versions CloudEvents itself).
|
|
12
|
+
* - `time` is the observation/occurrence instant (RFC3339, ms precision). It is
|
|
13
|
+
* never used for ordering — `seq` is. It is not durable-completion time.
|
|
14
|
+
* - `session_id` lives inside `data`, not as a top-level extension attribute:
|
|
15
|
+
* CloudEvents extension names cannot contain underscores.
|
|
16
|
+
* - Forward compatibility is a contract, not just syntax: consumers ignore
|
|
17
|
+
* unknown `data` fields and unknown event types (while still advancing their
|
|
18
|
+
* seq cursor), and v1 fields never change meaning. `task_hint_id` arrives as
|
|
19
|
+
* an optional additive `data` field on `file.changed` — no version bump.
|
|
20
|
+
* - `subject` is the one CloudEvents envelope attribute Slipstream sets: the
|
|
21
|
+
* task declaration `slipstream.task.started.v1` carries `subject: "task/<id>"`
|
|
22
|
+
* (ruling D2). It is optional and absent on every other event type.
|
|
23
|
+
*/
|
|
24
|
+
export const SPEC_VERSION = '1.0';
|
|
25
|
+
export const DATA_CONTENT_TYPE = 'application/json';
|
|
26
|
+
export const SOURCE_PREFIX = 'urn:slipstream:session:';
|
|
27
|
+
export function sourceFor(sessionId) {
|
|
28
|
+
return `${SOURCE_PREFIX}${sessionId}`;
|
|
29
|
+
}
|
|
30
|
+
export const EVENT_TYPES = [
|
|
31
|
+
'slipstream.session.started.v1',
|
|
32
|
+
'slipstream.file.baselined.v1',
|
|
33
|
+
'slipstream.capture.baseline.completed.v1',
|
|
34
|
+
'slipstream.file.changed.v1',
|
|
35
|
+
'slipstream.capture.gap.v1',
|
|
36
|
+
'slipstream.session.resumed.v1',
|
|
37
|
+
'slipstream.task.started.v1',
|
|
38
|
+
'slipstream.harness.evidence.v1',
|
|
39
|
+
'slipstream.change.attribution.v1',
|
|
40
|
+
'slipstream.enrichment.configured.v1',
|
|
41
|
+
'slipstream.enrichment.coverage.v1',
|
|
42
|
+
];
|
|
43
|
+
/** The `data` field into which each type mirrors the observation instant (epoch
|
|
44
|
+
* ms). Types absent here carry only the envelope `time` (baseline records are
|
|
45
|
+
* attach-time bookkeeping, not observations of an instant). */
|
|
46
|
+
const AT_MS_FIELD = {
|
|
47
|
+
'slipstream.session.started.v1': 'started_at_ms',
|
|
48
|
+
'slipstream.file.changed.v1': 'observed_at_ms',
|
|
49
|
+
'slipstream.capture.gap.v1': 'observed_at_ms',
|
|
50
|
+
'slipstream.session.resumed.v1': 'resumed_at_ms',
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* Build the full CloudEvents envelope for an input at a given sequence and
|
|
54
|
+
* session. Pure: no I/O, no clock — `seq` and time are supplied by the caller
|
|
55
|
+
* (the log), so ordering and timestamps are never invented here.
|
|
56
|
+
*/
|
|
57
|
+
export function buildEnvelope(input, seq, sessionId) {
|
|
58
|
+
const seqStr = seq.toString();
|
|
59
|
+
const atMsField = AT_MS_FIELD[input.type];
|
|
60
|
+
// The only envelope `subject` Slipstream sets (ruling D2): a task declaration
|
|
61
|
+
// is subject `task/<task_id>`. Every other event omits it — an absent field,
|
|
62
|
+
// never an empty string.
|
|
63
|
+
const subject = input.type === 'slipstream.task.started.v1' ? `task/${input.data.task_id}` : undefined;
|
|
64
|
+
return {
|
|
65
|
+
specversion: SPEC_VERSION,
|
|
66
|
+
id: seqStr,
|
|
67
|
+
source: sourceFor(sessionId),
|
|
68
|
+
type: input.type,
|
|
69
|
+
datacontenttype: DATA_CONTENT_TYPE,
|
|
70
|
+
seq: seqStr,
|
|
71
|
+
time: new Date(input.occurred_at_ms).toISOString(),
|
|
72
|
+
...(subject !== undefined ? { subject } : {}),
|
|
73
|
+
data: {
|
|
74
|
+
session_id: sessionId,
|
|
75
|
+
...input.data,
|
|
76
|
+
...(atMsField ? { [atMsField]: input.occurred_at_ms } : {}),
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { evidenceKeyString, variantSignature } from "./attribution.js";
|
|
2
|
+
const DEFAULT_MAX_PENDING = 256;
|
|
3
|
+
const DEFAULT_MAX_QUEUED_BYTES = 1024 * 1024;
|
|
4
|
+
const EVIDENCE = 'slipstream.harness.evidence.v1';
|
|
5
|
+
export function createEvidenceIngestor(opts) {
|
|
6
|
+
const { append, now } = opts;
|
|
7
|
+
const maxPending = opts.maxPending ?? DEFAULT_MAX_PENDING;
|
|
8
|
+
const maxQueuedBytes = opts.maxQueuedBytes ?? DEFAULT_MAX_QUEUED_BYTES;
|
|
9
|
+
// key -> the variant signatures already durable under it.
|
|
10
|
+
const committed = new Map();
|
|
11
|
+
let tail = Promise.resolve();
|
|
12
|
+
let pending = 0;
|
|
13
|
+
let queuedBytes = 0;
|
|
14
|
+
const record = (keyStr, sig) => {
|
|
15
|
+
let set = committed.get(keyStr);
|
|
16
|
+
if (!set) {
|
|
17
|
+
set = new Set();
|
|
18
|
+
committed.set(keyStr, set);
|
|
19
|
+
}
|
|
20
|
+
set.add(sig);
|
|
21
|
+
};
|
|
22
|
+
const seed = (events) => {
|
|
23
|
+
for (const e of events) {
|
|
24
|
+
if (e.type !== EVIDENCE)
|
|
25
|
+
continue;
|
|
26
|
+
record(evidenceKeyString(e.data.evidence_key), variantSignature(e.data));
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
// Runs one at a time (serialized by the tail), so the committed index is
|
|
30
|
+
// guaranteed to reflect every earlier append: this check cannot race.
|
|
31
|
+
const doIngest = async (evidence) => {
|
|
32
|
+
const keyStr = evidenceKeyString(evidence.evidence_key);
|
|
33
|
+
const sig = variantSignature(evidence);
|
|
34
|
+
if (committed.get(keyStr)?.has(sig))
|
|
35
|
+
return { status: 'duplicate' };
|
|
36
|
+
const event = await append({ type: EVIDENCE, occurred_at_ms: now(), data: evidence });
|
|
37
|
+
record(keyStr, sig);
|
|
38
|
+
return { status: 'appended', seq: BigInt(event.seq) };
|
|
39
|
+
};
|
|
40
|
+
const ingest = (caller) => {
|
|
41
|
+
// Snapshot at the boundary: the caller may mutate its object after we return,
|
|
42
|
+
// but the record we persist (and mirror in memory) must be the fact as ingested.
|
|
43
|
+
const evidence = structuredClone(caller);
|
|
44
|
+
// Admission is synchronous: no `await` sits between the bound check and the
|
|
45
|
+
// reservation, so concurrent callers can never collectively over-admit.
|
|
46
|
+
const size = Buffer.byteLength(JSON.stringify(evidence));
|
|
47
|
+
if (pending + 1 > maxPending || queuedBytes + size > maxQueuedBytes) {
|
|
48
|
+
return Promise.resolve({ status: 'rejected', reason: 'queue-full', retryable: true });
|
|
49
|
+
}
|
|
50
|
+
pending += 1;
|
|
51
|
+
queuedBytes += size;
|
|
52
|
+
const result = tail.then(() => doIngest(evidence));
|
|
53
|
+
tail = result.catch(() => undefined); // keep the chain alive past a failed append
|
|
54
|
+
return result.finally(() => {
|
|
55
|
+
pending -= 1;
|
|
56
|
+
queuedBytes -= size;
|
|
57
|
+
});
|
|
58
|
+
};
|
|
59
|
+
return { ingest, seed };
|
|
60
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { realpathSync } from 'node:fs';
|
|
2
|
+
import { isAbsolute } from 'node:path';
|
|
3
|
+
/**
|
|
4
|
+
* Claude Code identity adapter (fail-closed).
|
|
5
|
+
*
|
|
6
|
+
* Per the SC3 probe (tools/identity-probe/FINDINGS.md): Claude Code exposes a
|
|
7
|
+
* FRESH `CLAUDE_CODE_SESSION_ID` (a real UUID) plus `CLAUDE_PROJECT_DIR` in the
|
|
8
|
+
* subprocess environment at `initialize` — before any tool declaration — and the
|
|
9
|
+
* tool-call `_meta` carries NO identity. So the binding is the env triple,
|
|
10
|
+
* observed once and cached; a session without both vars fails closed on every
|
|
11
|
+
* call rather than guessing from cwd/PID.
|
|
12
|
+
*
|
|
13
|
+
* The worktree is canonicalized with the SAME realpath policy the daemon applies
|
|
14
|
+
* at attach, so a symlinked project dir reports the durable root the daemon stored
|
|
15
|
+
* (idempotent — the daemon re-canonicalizes defensively). realpath runs
|
|
16
|
+
* synchronously here: this is a one-shot capture at subprocess startup.
|
|
17
|
+
*/
|
|
18
|
+
export function claudeIdentity(env) {
|
|
19
|
+
const sessionId = env.CLAUDE_CODE_SESSION_ID;
|
|
20
|
+
if (typeof sessionId !== 'string' || sessionId.length === 0) {
|
|
21
|
+
return { unresolved: 'CLAUDE_CODE_SESSION_ID is absent from the environment' };
|
|
22
|
+
}
|
|
23
|
+
const projectDir = env.CLAUDE_PROJECT_DIR;
|
|
24
|
+
if (typeof projectDir !== 'string' || projectDir.length === 0) {
|
|
25
|
+
return { unresolved: 'CLAUDE_PROJECT_DIR is absent from the environment' };
|
|
26
|
+
}
|
|
27
|
+
// A relative project dir would be resolved against the forwarder's CWD, which is
|
|
28
|
+
// never a binding input; require an absolute path and fail closed otherwise.
|
|
29
|
+
if (!isAbsolute(projectDir)) {
|
|
30
|
+
return { unresolved: `CLAUDE_PROJECT_DIR ${projectDir} is not an absolute path` };
|
|
31
|
+
}
|
|
32
|
+
let worktree = projectDir;
|
|
33
|
+
try {
|
|
34
|
+
worktree = realpathSync(projectDir);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// The path does not resolve on this host; send the raw value. The daemon
|
|
38
|
+
// re-canonicalizes and compares, so an unresolvable path simply fails to match
|
|
39
|
+
// rather than being silently accepted — no honesty loss here.
|
|
40
|
+
}
|
|
41
|
+
return { harness: 'claude-code', harness_session_id: sessionId, worktree };
|
|
42
|
+
}
|