@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
package/dist/tui.js ADDED
@@ -0,0 +1,168 @@
1
+ import { openLogCursor, parseLine } from "./log-reader.js";
2
+ import { onDiskHighWater, sessionLogPath, listSessions, readRuntimeDescriptor } from "./store-reader.js";
3
+ import { renderChange } from "./change-view.js";
4
+ import { diskBlobSource, httpBlobSource } from "./blob-source.js";
5
+ const sanitize = (s) => s.replace(/[\x00-\x1f\x7f-\x9f]/g, '�');
6
+ const oneLine = async (ev) => [renderEvent(ev)];
7
+ function makeRenderer(blob, view) {
8
+ return async (ev) => {
9
+ if (ev.type !== 'slipstream.file.changed.v1')
10
+ return [renderEvent(ev)];
11
+ return [...await renderChange(ev, blob, view), ''];
12
+ };
13
+ }
14
+ function snapshotLabel(snap) {
15
+ if (typeof snap !== 'object' || snap === null)
16
+ return '-';
17
+ const s = snap;
18
+ if (s.kind === 'content' && typeof s.sha256 === 'string')
19
+ return sanitize(s.sha256.slice(0, 7));
20
+ if (s.kind === 'absent')
21
+ return 'absent';
22
+ if (s.kind === 'unavailable')
23
+ return `unavailable:${sanitize(String(s.reason))}`;
24
+ return '-';
25
+ }
26
+ export function renderEvent(ev) {
27
+ const d = ev.data;
28
+ const path = typeof d.path === 'string' ? sanitize(d.path) : '-';
29
+ // Every externally-supplied display string is sanitized: a terminal escape in
30
+ // the event type or a reason must never reach the terminal.
31
+ const parts = [ev.seq.toString(), sanitize(ev.type), path];
32
+ if (ev.type === 'slipstream.file.changed.v1') {
33
+ parts.push(snapshotLabel(d.after));
34
+ // Surface the grouping hint the disk/HTTP readers carry in `data`, so a TUI
35
+ // reader sees which task a change belongs to (parity, not a new view).
36
+ if (typeof d.task_hint_id === 'string')
37
+ parts.push(`task/${sanitize(d.task_hint_id)}`);
38
+ }
39
+ else if (ev.type === 'slipstream.task.started.v1') {
40
+ // The declaration boundary: show its task and title in the path slot (a
41
+ // declaration names no path) so a TUI reader can correlate it with the
42
+ // `task/<id>` hint stamped on the changes that follow.
43
+ parts[2] = typeof d.task_id === 'string' ? `task/${sanitize(d.task_id)}` : '-';
44
+ if (typeof d.title === 'string')
45
+ parts.push(sanitize(d.title));
46
+ }
47
+ else if (ev.type === 'slipstream.capture.gap.v1') {
48
+ parts.push(`gap:${sanitize(String(d.reason ?? 'unknown'))}`);
49
+ }
50
+ return parts.join(' · ');
51
+ }
52
+ export async function replayFromDisk(storeDir, id, out, view) {
53
+ const render = view ? makeRenderer(diskBlobSource(storeDir), view) : oneLine;
54
+ const logPath = sessionLogPath(storeDir, id);
55
+ const H = await onDiskHighWater(logPath);
56
+ const cursor = await openLogCursor(logPath, 0n);
57
+ try {
58
+ for (;;) {
59
+ const batch = await cursor.readThrough(H);
60
+ if (!batch.length)
61
+ return;
62
+ for (const ev of batch)
63
+ for (const line of await render(ev))
64
+ out(line);
65
+ }
66
+ }
67
+ finally {
68
+ await cursor.close();
69
+ }
70
+ }
71
+ /** Extract the SSE `data:` payload from a frame by real line delimiters (never a
72
+ * `.`-regex, which would drop a payload containing a literal U+2028/U+2029). */
73
+ export function sseDataLine(frame) {
74
+ for (const line of frame.split('\n')) {
75
+ if (line.startsWith('data: '))
76
+ return line.slice(6);
77
+ }
78
+ return undefined;
79
+ }
80
+ export async function runTui(argv, out) {
81
+ const disk = argv.includes('--disk');
82
+ const store = argFor(argv, '--store');
83
+ const session = argFor(argv, '--session');
84
+ const context = parseContext(argv);
85
+ if (context === undefined) {
86
+ out('usage: --context must be a non-negative integer');
87
+ return;
88
+ }
89
+ const view = argv.includes('--changes')
90
+ ? { context, full: argv.includes('--full') }
91
+ : undefined;
92
+ if (!store) {
93
+ out('usage: slipstream view --store <dir> [--session <id>] [--disk] [--changes] [--context N] [--full]');
94
+ return;
95
+ }
96
+ if (!session) {
97
+ for (const s of await listSessions(store))
98
+ out(`${s.id} durable=${s.durableSeq}${s.removed ? ' (removed)' : ''}`);
99
+ return;
100
+ }
101
+ if (disk) {
102
+ await replayFromDisk(store, session, out, view);
103
+ return;
104
+ }
105
+ await followHttp(store, session, out, view);
106
+ }
107
+ const DEFAULT_CONTEXT = 3;
108
+ function parseContext(argv) {
109
+ const i = argv.indexOf('--context');
110
+ if (i < 0)
111
+ return DEFAULT_CONTEXT;
112
+ const raw = argv[i + 1];
113
+ if (raw === undefined)
114
+ return undefined;
115
+ const n = Number(raw);
116
+ return Number.isInteger(n) && n >= 0 ? n : undefined;
117
+ }
118
+ function argFor(argv, flag) {
119
+ const i = argv.indexOf(flag);
120
+ return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined;
121
+ }
122
+ async function followHttp(store, session, out, view) {
123
+ const desc = await readRuntimeDescriptor(store);
124
+ if (!desc) {
125
+ out('no usable reader (runtime descriptor missing or invalid); try --disk');
126
+ return;
127
+ }
128
+ const render = view ? makeRenderer(httpBlobSource(desc), view) : oneLine;
129
+ const url = new URL(`v1/sessions/${session}/events?after=0&follow=true`, desc.url);
130
+ let res;
131
+ const ac = new AbortController();
132
+ const timer = setTimeout(() => ac.abort(), 5000);
133
+ try {
134
+ res = await fetch(url, {
135
+ headers: { authorization: `Bearer ${desc.token}` }, redirect: 'error',
136
+ signal: ac.signal,
137
+ });
138
+ }
139
+ catch {
140
+ out('reader unavailable (connection failed or stale descriptor); try --disk');
141
+ return;
142
+ }
143
+ finally {
144
+ clearTimeout(timer);
145
+ }
146
+ if (!res.ok || !res.body) {
147
+ out(`reader responded ${res.status}`);
148
+ return;
149
+ }
150
+ const reader = res.body.getReader();
151
+ const decoder = new TextDecoder();
152
+ let acc = '';
153
+ for (;;) {
154
+ const { value, done } = await reader.read();
155
+ if (done)
156
+ break;
157
+ acc += decoder.decode(value, { stream: true });
158
+ let i;
159
+ while ((i = acc.indexOf('\n\n')) >= 0) {
160
+ const frame = acc.slice(0, i);
161
+ acc = acc.slice(i + 2);
162
+ const data = sseDataLine(frame);
163
+ if (data !== undefined)
164
+ for (const line of await render(parseLine(data)))
165
+ out(line);
166
+ }
167
+ }
168
+ }
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "@playola-radio/slipstream",
3
+ "version": "0.1.0",
4
+ "description": "Live, honest stream of the file changes a coding agent makes in a worktree, so you can review as it works.",
5
+ "license": "Apache-2.0",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/playola-radio/slipstream.git"
9
+ },
10
+ "homepage": "https://github.com/playola-radio/slipstream#readme",
11
+ "bugs": {
12
+ "url": "https://github.com/playola-radio/slipstream/issues"
13
+ },
14
+ "type": "module",
15
+ "engines": {
16
+ "node": ">=24"
17
+ },
18
+ "bin": {
19
+ "slipstream": "./dist/cli.js"
20
+ },
21
+ "files": [
22
+ "dist",
23
+ "schemas",
24
+ "contracts/interface/v2/schema.json",
25
+ "LICENSE",
26
+ "THIRD-PARTY-NOTICES.md",
27
+ "README.md",
28
+ "SKILL.md"
29
+ ],
30
+ "publishConfig": {
31
+ "access": "public"
32
+ },
33
+ "scripts": {
34
+ "test": "node --test --test-concurrency=1 'src/**/!(*.os).test.ts'",
35
+ "test:os": "node --test --test-concurrency=1 'src/**/*.os.test.ts'",
36
+ "test:tools": "node --test --test-concurrency=1 'tools/**/*.test.ts'",
37
+ "typecheck": "tsc --noEmit",
38
+ "build": "rm -rf dist && tsc -p tsconfig.build.json",
39
+ "prepack": "npm run build",
40
+ "bench": "node src/bench.ts",
41
+ "slipstream": "node src/cli.ts",
42
+ "live-feed": "node tools/live-feed/cli.ts",
43
+ "identity-probe": "node tools/identity-probe/server.ts",
44
+ "bench:clips": "node src/clip-bench.ts",
45
+ "bench:fd5": "node tools/fd5-bench.ts",
46
+ "qa:daemon": "node tools/qa-daemon.ts",
47
+ "qa:check": "node tools/projection-check.ts acceptance",
48
+ "check:fold-release": "node tools/projection-check.ts fold-release",
49
+ "check:package": "node tools/package-check.ts"
50
+ },
51
+ "dependencies": {
52
+ "@parcel/watcher": "^2.5.1",
53
+ "tree-sitter-wasms": "0.1.13",
54
+ "web-tree-sitter": "0.25.10"
55
+ },
56
+ "devDependencies": {
57
+ "@types/node": "^24.0.0",
58
+ "typescript": "^5.7.0"
59
+ }
60
+ }
@@ -0,0 +1,195 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://slipstream.dev/schemas/projections/clip.v1.json",
4
+ "title": "clip.v1",
5
+ "$comment": "A reader-derived public projection over the immutable before/after blobs of a file.changed event. It is NOT a log event and is never appended; it is a pure function of (before bytes, after bytes, projection_version), computed on demand and cached disposably. Spans reference the raw blob bytes by zero-based half-open byte offsets; a null span means NO corresponding span on that side (created/deleted content), never unreadable content. Unreadable/gone content is a non-ready status or a per-side `unavailable` method, each with an explicit reason. `projection_version` names the complete algorithm and is the cache key. Unknown fields permitted for forward compatibility.",
6
+ "type": "object",
7
+ "required": [
8
+ "change_seq",
9
+ "projection_version",
10
+ "status",
11
+ "clips"
12
+ ],
13
+ "properties": {
14
+ "change_seq": {
15
+ "type": "string",
16
+ "pattern": "^[1-9][0-9]*$",
17
+ "$comment": "The `seq` of the file.changed event this projection is derived from. Stamped on the response; never part of the cache key."
18
+ },
19
+ "projection_version": {
20
+ "const": "clip.v1",
21
+ "$comment": "Names the complete extraction algorithm and is the cache key. A dropped cache recomputes an identical result under the same version."
22
+ },
23
+ "status": {
24
+ "enum": [
25
+ "ready",
26
+ "fallback",
27
+ "skipped",
28
+ "unavailable"
29
+ ],
30
+ "$comment": "`ready` = function-level clips (reserved for a later version). `fallback` = a diffable file rendered as changed byte ranges. `skipped` = no bounded clip could be produced (binary/oversize/no-change/etc.). `unavailable` = a referenced blob's content is gone or was never captured."
31
+ },
32
+ "fallback_reason": {
33
+ "type": "string",
34
+ "$comment": "Required for every status except `ready`. Explains why the projection is not `ready` (e.g. `function-extraction-unavailable`, `timeout`, `overloaded`, `binary`, `oversize`, a snapshot/blob reason)."
35
+ },
36
+ "clips": {
37
+ "type": "array",
38
+ "items": {
39
+ "$ref": "#/$defs/clip"
40
+ }
41
+ }
42
+ },
43
+ "if": {
44
+ "properties": {
45
+ "status": {
46
+ "const": "ready"
47
+ }
48
+ }
49
+ },
50
+ "then": {},
51
+ "else": {
52
+ "required": [
53
+ "fallback_reason"
54
+ ]
55
+ },
56
+ "$defs": {
57
+ "clip": {
58
+ "type": "object",
59
+ "required": [
60
+ "before",
61
+ "after"
62
+ ],
63
+ "properties": {
64
+ "before": {
65
+ "$ref": "#/$defs/side"
66
+ },
67
+ "after": {
68
+ "$ref": "#/$defs/side"
69
+ }
70
+ }
71
+ },
72
+ "side": {
73
+ "type": "object",
74
+ "required": [
75
+ "span",
76
+ "method"
77
+ ],
78
+ "properties": {
79
+ "span": {
80
+ "oneOf": [
81
+ {
82
+ "type": "null"
83
+ },
84
+ {
85
+ "$ref": "#/$defs/span"
86
+ }
87
+ ],
88
+ "$comment": "null means NO corresponding span on this side (created/deleted content), never unreadable content."
89
+ },
90
+ "method": {
91
+ "enum": [
92
+ "changed-range",
93
+ "whole-file",
94
+ "absent",
95
+ "unavailable"
96
+ ],
97
+ "$comment": "`changed-range` = a real diff hunk. `whole-file` = the side could not be diffed (create/delete/diff-too-large/before-unavailable). `absent` = the path did not exist on this side. `unavailable` = the side's content is gone or was not captured."
98
+ },
99
+ "reason": {
100
+ "type": "string",
101
+ "$comment": "Present when the method needs one (e.g. `unavailable` carries the blob/snapshot reason)."
102
+ }
103
+ },
104
+ "allOf": [
105
+ {
106
+ "if": {
107
+ "properties": {
108
+ "method": {
109
+ "const": "unavailable"
110
+ }
111
+ }
112
+ },
113
+ "then": {
114
+ "properties": {
115
+ "span": {
116
+ "type": "null"
117
+ }
118
+ },
119
+ "required": [
120
+ "reason"
121
+ ]
122
+ }
123
+ },
124
+ {
125
+ "if": {
126
+ "properties": {
127
+ "method": {
128
+ "const": "absent"
129
+ }
130
+ }
131
+ },
132
+ "then": {
133
+ "properties": {
134
+ "span": {
135
+ "type": "null"
136
+ }
137
+ }
138
+ }
139
+ },
140
+ {
141
+ "if": {
142
+ "properties": {
143
+ "method": {
144
+ "const": "whole-file"
145
+ }
146
+ }
147
+ },
148
+ "then": {
149
+ "properties": {
150
+ "span": {
151
+ "$ref": "#/$defs/span"
152
+ }
153
+ }
154
+ }
155
+ }
156
+ ]
157
+ },
158
+ "span": {
159
+ "type": "object",
160
+ "required": [
161
+ "byte_start",
162
+ "byte_end",
163
+ "line_start",
164
+ "line_end",
165
+ "truncated"
166
+ ],
167
+ "properties": {
168
+ "byte_start": {
169
+ "type": "integer",
170
+ "minimum": 0,
171
+ "$comment": "Zero-based inclusive start byte offset into the raw blob."
172
+ },
173
+ "byte_end": {
174
+ "type": "integer",
175
+ "minimum": 0,
176
+ "$comment": "Zero-based exclusive end byte offset; the span is [byte_start, byte_end). Always lands on a UTF-8 boundary."
177
+ },
178
+ "line_start": {
179
+ "type": "integer",
180
+ "minimum": 1,
181
+ "$comment": "1-based inclusive first line covered."
182
+ },
183
+ "line_end": {
184
+ "type": "integer",
185
+ "minimum": 1,
186
+ "$comment": "1-based inclusive last line covered. Lines split on \\n (0x0a); a preceding \\r stays in the line (CRLF preserved); a trailing newline does not create a phantom last line."
187
+ },
188
+ "truncated": {
189
+ "type": "boolean",
190
+ "$comment": "True when the per-side line/byte budget clipped this span short of the full changed range."
191
+ }
192
+ }
193
+ }
194
+ }
195
+ }
@@ -0,0 +1,212 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://slipstream.dev/schemas/projections/clip.v2.json",
4
+ "title": "clip.v2",
5
+ "$comment": "Reader-derived projection over immutable blobs and a closed language token. No log event, seq or policy_seq is appended. Spans cover raw UTF-8 bytes on whole-line boundaries. Pairing follows LCS diff correspondence, not semantic identity or move detection. Successful results are deterministic for pinned grammar versions; transient timing/availability outcomes are never cached.",
6
+ "type": "object",
7
+ "required": [
8
+ "change_seq",
9
+ "projection_version",
10
+ "status",
11
+ "clips"
12
+ ],
13
+ "properties": {
14
+ "change_seq": {
15
+ "type": "string",
16
+ "pattern": "^[1-9][0-9]*$",
17
+ "$comment": "The `seq` of the file.changed event this projection is derived from. Stamped on the response; never part of the cache key."
18
+ },
19
+ "projection_version": {
20
+ "const": "clip.v2",
21
+ "$comment": "Names the complete extraction algorithm and is the cache key. A dropped cache recomputes an identical result under the same version."
22
+ },
23
+ "status": {
24
+ "enum": [
25
+ "ready",
26
+ "fallback",
27
+ "skipped",
28
+ "unavailable"
29
+ ],
30
+ "$comment": "ready: every changed segment is function-derived or a corresponding null side; fallback: at least one segment is not reliably enclosed by a function, parsing failed, or a soft timeout retained bounded ranges. Inspect per-side methods/reasons and truncated independently."
31
+ },
32
+ "fallback_reason": {
33
+ "type": "string",
34
+ "$comment": "Required unless ready: unsupported-language, no-enclosing-function, parse-error-in-enclosing-scope, extraction-budget-exhausted, timeout, and B1 availability/budget reasons. timeout is transient even with fallback clips."
35
+ },
36
+ "clips": {
37
+ "type": "array",
38
+ "items": {
39
+ "$ref": "#/$defs/clip"
40
+ }
41
+ }
42
+ },
43
+ "if": {
44
+ "properties": {
45
+ "status": {
46
+ "const": "ready"
47
+ }
48
+ }
49
+ },
50
+ "then": {},
51
+ "else": {
52
+ "required": [
53
+ "fallback_reason"
54
+ ]
55
+ },
56
+ "$defs": {
57
+ "clip": {
58
+ "type": "object",
59
+ "required": [
60
+ "before",
61
+ "after"
62
+ ],
63
+ "properties": {
64
+ "before": {
65
+ "$ref": "#/$defs/side"
66
+ },
67
+ "after": {
68
+ "$ref": "#/$defs/side"
69
+ }
70
+ }
71
+ },
72
+ "side": {
73
+ "type": "object",
74
+ "required": [
75
+ "span",
76
+ "method"
77
+ ],
78
+ "properties": {
79
+ "span": {
80
+ "oneOf": [
81
+ {
82
+ "type": "null"
83
+ },
84
+ {
85
+ "$ref": "#/$defs/span"
86
+ }
87
+ ],
88
+ "$comment": "null means NO corresponding span on this side (created/deleted content), never unreadable content."
89
+ },
90
+ "method": {
91
+ "enum": [
92
+ "function",
93
+ "changed-range",
94
+ "whole-file",
95
+ "absent",
96
+ "unavailable"
97
+ ],
98
+ "$comment": "function: enclosing function rounded out to whole lines; changed-range: bounded diff range (null for no corresponding range); whole-file: no bounded diff; absent/unavailable: B1 dispositions."
99
+ },
100
+ "reason": {
101
+ "type": "string",
102
+ "$comment": "Present when the method needs one (e.g. `unavailable` carries the blob/snapshot reason)."
103
+ }
104
+ },
105
+ "allOf": [
106
+ {
107
+ "if": {
108
+ "properties": {
109
+ "method": {
110
+ "const": "unavailable"
111
+ }
112
+ }
113
+ },
114
+ "then": {
115
+ "properties": {
116
+ "span": {
117
+ "type": "null"
118
+ }
119
+ },
120
+ "required": [
121
+ "reason"
122
+ ]
123
+ }
124
+ },
125
+ {
126
+ "if": {
127
+ "properties": {
128
+ "method": {
129
+ "const": "absent"
130
+ }
131
+ }
132
+ },
133
+ "then": {
134
+ "properties": {
135
+ "span": {
136
+ "type": "null"
137
+ }
138
+ }
139
+ }
140
+ },
141
+ {
142
+ "if": {
143
+ "properties": {
144
+ "method": {
145
+ "const": "whole-file"
146
+ }
147
+ }
148
+ },
149
+ "then": {
150
+ "properties": {
151
+ "span": {
152
+ "$ref": "#/$defs/span"
153
+ }
154
+ }
155
+ }
156
+ },
157
+ {
158
+ "if": {
159
+ "properties": {
160
+ "method": {
161
+ "const": "function"
162
+ }
163
+ }
164
+ },
165
+ "then": {
166
+ "properties": {
167
+ "span": {
168
+ "$ref": "#/$defs/span"
169
+ }
170
+ }
171
+ }
172
+ }
173
+ ]
174
+ },
175
+ "span": {
176
+ "type": "object",
177
+ "required": [
178
+ "byte_start",
179
+ "byte_end",
180
+ "line_start",
181
+ "line_end",
182
+ "truncated"
183
+ ],
184
+ "properties": {
185
+ "byte_start": {
186
+ "type": "integer",
187
+ "minimum": 0,
188
+ "$comment": "Zero-based inclusive start byte offset into the raw blob."
189
+ },
190
+ "byte_end": {
191
+ "type": "integer",
192
+ "minimum": 0,
193
+ "$comment": "Zero-based exclusive end byte offset; the span is [byte_start, byte_end). Always lands on a UTF-8 boundary."
194
+ },
195
+ "line_start": {
196
+ "type": "integer",
197
+ "minimum": 1,
198
+ "$comment": "1-based inclusive first line covered."
199
+ },
200
+ "line_end": {
201
+ "type": "integer",
202
+ "minimum": 1,
203
+ "$comment": "1-based inclusive last line covered. Lines split on \\n (0x0a); a preceding \\r stays in the line (CRLF preserved); a trailing newline does not create a phantom last line."
204
+ },
205
+ "truncated": {
206
+ "type": "boolean",
207
+ "$comment": "True when the per-side line/byte budget clipped this span short of the full changed range."
208
+ }
209
+ }
210
+ }
211
+ }
212
+ }