@hy-sde-org/dsh-internal-urls 0.1.1-rc.2

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.
@@ -0,0 +1,625 @@
1
+ /**
2
+ * Detecting, surfacing, and resolving git merge conflicts through FS-shaped
3
+ * URLs — the `conflict://` protocol. The read tool registers the conflict
4
+ * blocks it surfaces with a per-session history; the agent then resolves one
5
+ * with `write({ path: "conflict://<N>", content })`, or bulk-resolves every
6
+ * registered block with `write({ path: "conflict://*", content })`.
7
+ *
8
+ * Marker shape is strict: only column-0 markers of the exact prefix length
9
+ * followed by either EOL or a single space + label count. Lines that merely
10
+ * start with `<` or `=` never match.
11
+ *
12
+ * Ported from oh-my-pi (`packages/coding-agent/src/tools/conflict-detect.ts`),
13
+ * MIT, adapted to the DeepSeek Harness file-services seam.
14
+ * @module @hy-sde-org/dsh-internal-urls/conflict
15
+ */
16
+ const OURS_PREFIX = '<<<<<<<';
17
+ const BASE_PREFIX = '|||||||';
18
+ const SEPARATOR = '=======';
19
+ const THEIRS_PREFIX = '>>>>>>>';
20
+ /**
21
+ * Scan already-collected file lines for completed conflict blocks.
22
+ * `firstLineNumber` is the 1-indexed line number of `lines[0]` (so a windowed
23
+ * read starting at line 200 passes `firstLineNumber: 200`). Only fully-closed
24
+ * blocks are returned.
25
+ */
26
+ export function scanConflictLines(lines, firstLineNumber) {
27
+ const blocks = [];
28
+ let phase = 'idle';
29
+ let partial = null;
30
+ for (let i = 0; i < lines.length; i++) {
31
+ const line = stripTrailingCr(lines[i] ?? '');
32
+ const ln = firstLineNumber + i;
33
+ const oursLabel = matchMarker(line, OURS_PREFIX);
34
+ if (oursLabel !== null) {
35
+ partial = { startLine: ln, ...oursLabel !== '' ? { oursLabel } : {}, oursLines: [] };
36
+ phase = 'ours';
37
+ continue;
38
+ }
39
+ if (phase === 'idle' || partial === null)
40
+ continue;
41
+ const baseLabel = matchMarker(line, BASE_PREFIX);
42
+ if (baseLabel !== null) {
43
+ if (phase !== 'ours') {
44
+ partial = null;
45
+ phase = 'idle';
46
+ continue;
47
+ }
48
+ partial.baseLine = ln;
49
+ if (baseLabel !== '')
50
+ partial.baseLabel = baseLabel;
51
+ partial.baseLines = [];
52
+ phase = 'base';
53
+ continue;
54
+ }
55
+ if (line === SEPARATOR) {
56
+ if (phase === 'ours' || phase === 'base') {
57
+ partial.separatorLine = ln;
58
+ partial.theirsLines = [];
59
+ phase = 'theirs';
60
+ }
61
+ else {
62
+ partial = null;
63
+ phase = 'idle';
64
+ }
65
+ continue;
66
+ }
67
+ const theirsLabel = matchMarker(line, THEIRS_PREFIX);
68
+ if (theirsLabel !== null) {
69
+ if (phase === 'theirs' && partial.separatorLine !== undefined && partial.theirsLines) {
70
+ blocks.push({
71
+ startLine: partial.startLine,
72
+ separatorLine: partial.separatorLine,
73
+ endLine: ln,
74
+ ...partial.baseLine !== undefined ? { baseLine: partial.baseLine } : {},
75
+ ...partial.oursLabel !== undefined ? { oursLabel: partial.oursLabel } : {},
76
+ ...partial.baseLabel !== undefined ? { baseLabel: partial.baseLabel } : {},
77
+ ...theirsLabel !== '' ? { theirsLabel } : {},
78
+ oursLines: partial.oursLines,
79
+ ...partial.baseLines !== undefined ? { baseLines: partial.baseLines } : {},
80
+ theirsLines: partial.theirsLines,
81
+ });
82
+ }
83
+ partial = null;
84
+ phase = 'idle';
85
+ continue;
86
+ }
87
+ if (phase === 'ours')
88
+ partial.oursLines.push(line);
89
+ else if (phase === 'base' && partial.baseLines)
90
+ partial.baseLines.push(line);
91
+ else if (phase === 'theirs' && partial.theirsLines)
92
+ partial.theirsLines.push(line);
93
+ }
94
+ return blocks;
95
+ }
96
+ /** Return the label after a marker prefix when the line is a valid column-0 marker, or `null` when it isn't. */
97
+ function matchMarker(line, prefix) {
98
+ if (!line.startsWith(prefix))
99
+ return null;
100
+ if (line.length === prefix.length)
101
+ return '';
102
+ if (line.charCodeAt(prefix.length) !== 32 /* space */)
103
+ return null;
104
+ return line.slice(prefix.length + 1);
105
+ }
106
+ /**
107
+ * Scan whole-file content (already decoded text) for conflict blocks. Returns
108
+ * only complete blocks; an unclosed opener at EOF yields none.
109
+ */
110
+ export function scanConflictsInContent(content) {
111
+ return scanConflictLines(content.split('\n'), 1);
112
+ }
113
+ /**
114
+ * Per-session log of conflict regions surfaced by `read`/`:conflicts`. The
115
+ * history is append-only by content identity: re-reading a still-present block
116
+ * reuses its id, so retries never depend on re-reading even after other blocks
117
+ * in the file resolve.
118
+ */
119
+ export class ConflictHistory {
120
+ nextId = 1;
121
+ #entries = new Map();
122
+ register(input) {
123
+ for (const existing of this.#entries.values()) {
124
+ if (existing.absolutePath === input.absolutePath && existing.startLine === input.startLine) {
125
+ const merged = { ...input, id: existing.id };
126
+ this.#entries.set(existing.id, merged);
127
+ return merged;
128
+ }
129
+ }
130
+ const id = this.nextId++;
131
+ const entry = { ...input, id };
132
+ this.#entries.set(id, entry);
133
+ return entry;
134
+ }
135
+ get(id) {
136
+ return this.#entries.get(id);
137
+ }
138
+ /** Every registered entry in id (insertion) order. */
139
+ entries() {
140
+ return [...this.#entries.values()];
141
+ }
142
+ /** Drop a single entry by id; used after a successful resolve. */
143
+ invalidate(id) {
144
+ this.#entries.delete(id);
145
+ }
146
+ /** Drop every entry referencing `absolutePath`. */
147
+ invalidatePath(absolutePath) {
148
+ for (const [id, entry] of this.#entries) {
149
+ if (entry.absolutePath === absolutePath)
150
+ this.#entries.delete(id);
151
+ }
152
+ }
153
+ }
154
+ const CONFLICT_SCOPES = new Set(['ours', 'theirs', 'base']);
155
+ // Accept an optional `<prefix>:` before the scheme so paths like
156
+ // `path/to/file.ts:conflict://3` still resolve; the last `:conflict://` wins.
157
+ const CONFLICT_URI_RE = /^(?:(.+):)?conflict:\/\/(.+)$/;
158
+ /**
159
+ * Parse a `conflict://<N>`, `conflict://<N>/<scope>`, or `conflict://*` URI.
160
+ * Returns `null` for non-conflict paths; throws for a well-formed scheme with
161
+ * an invalid id or scope so the agent gets a clear message.
162
+ */
163
+ export function parseConflictUri(raw) {
164
+ const match = raw.match(CONFLICT_URI_RE);
165
+ if (!match)
166
+ return null;
167
+ const tail = match[2] ?? '';
168
+ const slashIdx = tail.indexOf('/');
169
+ const idPart = slashIdx === -1 ? tail : tail.slice(0, slashIdx);
170
+ const scopePart = slashIdx === -1 ? undefined : tail.slice(slashIdx + 1);
171
+ if (idPart === '*') {
172
+ if (scopePart !== undefined) {
173
+ throw new Error(`Invalid conflict URI '${raw}': wildcard 'conflict://*' does not accept a scope segment. Drop '/${scopePart}' or use a numeric id.`);
174
+ }
175
+ return { id: '*' };
176
+ }
177
+ if (!/^\d+$/.test(idPart)) {
178
+ throw new Error(`Invalid conflict URI '${raw}': must be 'conflict://<N>', 'conflict://<N>/<scope>', or 'conflict://*' where N is a positive integer surfaced by a prior \`read\`.`);
179
+ }
180
+ const id = Number.parseInt(idPart, 10);
181
+ if (!Number.isFinite(id) || id < 1) {
182
+ throw new Error(`Invalid conflict URI '${raw}': id must be ≥ 1.`);
183
+ }
184
+ let scope;
185
+ if (scopePart !== undefined) {
186
+ if (!CONFLICT_SCOPES.has(scopePart)) {
187
+ throw new Error(`Invalid conflict URI '${raw}': scope must be one of 'ours', 'theirs', 'base', or omitted (e.g. 'conflict://${id}/theirs').`);
188
+ }
189
+ scope = scopePart;
190
+ }
191
+ return scope !== undefined ? { id, scope } : { id };
192
+ }
193
+ /** A side of a conflict block that read can render via `conflict://N/<scope>`. */
194
+ export function renderConflictRegion(entry, scope) {
195
+ if (scope === 'ours') {
196
+ return { lines: [...entry.oursLines], startLine: entry.startLine + 1 };
197
+ }
198
+ if (scope === 'theirs') {
199
+ return { lines: [...entry.theirsLines], startLine: entry.separatorLine + 1 };
200
+ }
201
+ if (scope === 'base') {
202
+ if (entry.baseLines === undefined || entry.baseLine === undefined) {
203
+ throw new Error(`Conflict #${entry.id} has no base section (2-way merge). 'conflict://${entry.id}/base' is only valid for diff3 conflicts.`);
204
+ }
205
+ return { lines: [...entry.baseLines], startLine: entry.baseLine + 1 };
206
+ }
207
+ const out = [];
208
+ out.push(`${OURS_PREFIX}${entry.oursLabel ? ` ${entry.oursLabel}` : ''}`);
209
+ out.push(...entry.oursLines);
210
+ if (entry.baseLines !== undefined) {
211
+ out.push(`${BASE_PREFIX}${entry.baseLabel ? ` ${entry.baseLabel}` : ''}`);
212
+ out.push(...entry.baseLines);
213
+ }
214
+ out.push(SEPARATOR);
215
+ out.push(...entry.theirsLines);
216
+ out.push(`${THEIRS_PREFIX}${entry.theirsLabel ? ` ${entry.theirsLabel}` : ''}`);
217
+ return { lines: out, startLine: entry.startLine };
218
+ }
219
+ /**
220
+ * Expand `@ours` / `@theirs` / `@base` / `@both` line tokens against the
221
+ * recorded sections of `entry`. A token only triggers when it is the entire
222
+ * content of a line; other lines pass through verbatim. `@base` throws when
223
+ * the conflict was 2-way (no base recorded).
224
+ */
225
+ export function expandContentTokens(content, entry) {
226
+ const out = [];
227
+ for (const rawLine of content.split('\n')) {
228
+ const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine;
229
+ switch (line) {
230
+ case '@ours':
231
+ out.push(...entry.oursLines);
232
+ break;
233
+ case '@theirs':
234
+ out.push(...entry.theirsLines);
235
+ break;
236
+ case '@base':
237
+ if (!entry.baseLines) {
238
+ throw new Error(`Conflict #${entry.id} has no base section (2-way merge). \`@base\` is only valid for diff3 conflicts.`);
239
+ }
240
+ out.push(...entry.baseLines);
241
+ break;
242
+ case '@both':
243
+ out.push(...entry.oursLines, ...entry.theirsLines);
244
+ break;
245
+ default:
246
+ out.push(rawLine);
247
+ break;
248
+ }
249
+ }
250
+ return out.join('\n');
251
+ }
252
+ /**
253
+ * Splice the conflict region recorded in `entry` out of `originalText` and
254
+ * replace it with `replacement`, markers and all sides included. Locates the
255
+ * recorded marker block by content anchored to the recorded start line, so
256
+ * out-of-band edits earlier in the file that shift line numbers don't break
257
+ * resolution. Replacement lines that exactly echo the adjacent context are
258
+ * dropped (the universal "whole function" paste artifact). Returns the new
259
+ * text and how many echo lines were trimmed at each boundary.
260
+ */
261
+ export function spliceConflict(originalText, entry, replacement) {
262
+ const lines = originalText.split('\n');
263
+ const expected = buildRecordedRegion(entry);
264
+ const match = locateRegion(lines, expected, entry.startLine - 1);
265
+ if (!match) {
266
+ throw new Error(`Conflict #${entry.id} no longer present in '${entry.displayPath}': the recorded marker block can't be located. The file changed since the conflict was registered — re-read it to re-register conflicts.`);
267
+ }
268
+ const trimmed = normalizeTrailingNewline(replacement);
269
+ let replacementLines = trimmed.split('\n').map(stripTrailingCr);
270
+ const echo = trimBoundaryEcho(replacementLines, lines, match, entry);
271
+ replacementLines = echo.lines;
272
+ // CRLF round-trip: recorded sections are LF-normalized, re-apply \r when the
273
+ // matched region used CRLF. The final replacement line only carries \r when
274
+ // another line follows it.
275
+ if (lines[match.startIdx]?.endsWith('\r') === true) {
276
+ const hasFollowingLine = match.endIdx + 1 < lines.length;
277
+ replacementLines = replacementLines.map((l, i) => i < replacementLines.length - 1 || hasFollowingLine ? `${l}\r` : l);
278
+ }
279
+ const next = [...lines.slice(0, match.startIdx), ...replacementLines, ...lines.slice(match.endIdx + 1)];
280
+ return { text: next.join('\n'), trimmedLeading: echo.leading, trimmedTrailing: echo.trailing };
281
+ }
282
+ const MAX_ECHO_LINES = 12;
283
+ /** Reconstruct the recorded marker block as it should appear in the file. */
284
+ function buildRecordedRegion(entry) {
285
+ const out = [];
286
+ out.push(`${OURS_PREFIX}${entry.oursLabel ? ` ${entry.oursLabel}` : ''}`);
287
+ out.push(...entry.oursLines);
288
+ if (entry.baseLines !== undefined) {
289
+ out.push(`${BASE_PREFIX}${entry.baseLabel ? ` ${entry.baseLabel}` : ''}`);
290
+ out.push(...entry.baseLines);
291
+ }
292
+ out.push(SEPARATOR);
293
+ out.push(...entry.theirsLines);
294
+ out.push(`${THEIRS_PREFIX}${entry.theirsLabel ? ` ${entry.theirsLabel}` : ''}`);
295
+ return out;
296
+ }
297
+ function locateRegion(lines, expected, preferredIdx) {
298
+ if (expected.length === 0 || expected.length > lines.length)
299
+ return null;
300
+ if (preferredIdx >= 0 && matchesAt(lines, preferredIdx, expected)) {
301
+ return { startIdx: preferredIdx, endIdx: preferredIdx + expected.length - 1 };
302
+ }
303
+ let best = null;
304
+ let bestDist = Number.POSITIVE_INFINITY;
305
+ const limit = lines.length - expected.length;
306
+ for (let i = 0; i <= limit; i++) {
307
+ if (!matchesAt(lines, i, expected))
308
+ continue;
309
+ const dist = Math.abs(i - preferredIdx);
310
+ if (dist < bestDist) {
311
+ best = { startIdx: i, endIdx: i + expected.length - 1 };
312
+ bestDist = dist;
313
+ }
314
+ }
315
+ return best;
316
+ }
317
+ function matchesAt(lines, startIdx, expected) {
318
+ if (startIdx < 0 || startIdx + expected.length > lines.length)
319
+ return false;
320
+ for (let i = 0; i < expected.length; i++) {
321
+ if (stripTrailingCr(lines[startIdx + i] ?? '') !== expected[i])
322
+ return false;
323
+ }
324
+ return true;
325
+ }
326
+ /** Net `{}`/`()`/`[]` count over lines (string/comment-blind, corroboration only). */
327
+ function delimiterBalance(lines) {
328
+ let balance = 0;
329
+ for (const line of lines) {
330
+ for (let i = 0; i < line.length; i++) {
331
+ const ch = line.charCodeAt(i);
332
+ if (ch === 123 || ch === 40 || ch === 91)
333
+ balance++;
334
+ else if (ch === 125 || ch === 41 || ch === 93)
335
+ balance--;
336
+ }
337
+ }
338
+ return balance;
339
+ }
340
+ function trimBoundaryEcho(replacement, fileLines, match, entry) {
341
+ const oursBalance = delimiterBalance(entry.oursLines);
342
+ const expectedBalance = oursBalance === delimiterBalance(entry.theirsLines) ? oursBalance : null;
343
+ const singleEchoJustified = (lines, without) => expectedBalance !== null
344
+ && delimiterBalance(lines) !== expectedBalance
345
+ && delimiterBalance(without) === expectedBalance;
346
+ let lines = replacement;
347
+ let trailing = 0;
348
+ const after = [];
349
+ for (let i = match.endIdx + 1; i < fileLines.length && after.length < MAX_ECHO_LINES; i++) {
350
+ after.push(stripTrailingCr(fileLines[i] ?? ''));
351
+ }
352
+ for (let k = Math.min(after.length, lines.length - 1); k >= 1; k--) {
353
+ if (!after.slice(0, k).every((line, i) => lines[lines.length - k + i] === line))
354
+ continue;
355
+ if (k >= 2 || singleEchoJustified(lines, lines.slice(0, -1))) {
356
+ trailing = k;
357
+ lines = lines.slice(0, lines.length - k);
358
+ }
359
+ break;
360
+ }
361
+ let leading = 0;
362
+ const before = [];
363
+ for (let i = match.startIdx - 1; i >= 0 && before.length < MAX_ECHO_LINES; i--) {
364
+ before.unshift(stripTrailingCr(fileLines[i] ?? ''));
365
+ }
366
+ for (let k = Math.min(before.length, lines.length - 1); k >= 1; k--) {
367
+ if (!before.slice(before.length - k).every((line, i) => lines[i] === line))
368
+ continue;
369
+ if (k >= 2 || singleEchoJustified(lines, lines.slice(1))) {
370
+ leading = k;
371
+ lines = lines.slice(k);
372
+ }
373
+ break;
374
+ }
375
+ return { lines, leading, trailing };
376
+ }
377
+ function stripTrailingCr(line) {
378
+ return line.endsWith('\r') ? line.slice(0, -1) : line;
379
+ }
380
+ function normalizeTrailingNewline(replacement) {
381
+ if (replacement.endsWith('\r\n'))
382
+ return replacement.slice(0, -2);
383
+ if (replacement.endsWith('\n'))
384
+ return replacement.slice(0, -1);
385
+ return replacement;
386
+ }
387
+ function pickLabel(entries, get) {
388
+ for (const e of entries) {
389
+ const label = get(e);
390
+ if (label && label.trim().length > 0)
391
+ return label;
392
+ }
393
+ return undefined;
394
+ }
395
+ function sectionsEqual(a, b) {
396
+ if (a.length !== b.length)
397
+ return false;
398
+ for (let i = 0; i < a.length; i++)
399
+ if (a[i] !== b[i])
400
+ return false;
401
+ return true;
402
+ }
403
+ const PREVIEW_SIDE_LINES = 6;
404
+ function appendBody(out, section) {
405
+ if (section.length === 0) {
406
+ out.push('(empty)');
407
+ return;
408
+ }
409
+ const shown = section.slice(0, PREVIEW_SIDE_LINES);
410
+ for (const line of shown)
411
+ out.push(line);
412
+ const hidden = section.length - shown.length;
413
+ if (hidden > 0)
414
+ out.push(`… (${hidden} more line${hidden === 1 ? '' : 's'})`);
415
+ }
416
+ /**
417
+ * Build a compact diff-style footer describing the conflicts registered during
418
+ * a read. Appended to the read output so the agent learns the `conflict://<N>`
419
+ * ids and the resolution protocol.
420
+ */
421
+ export function formatConflictWarning(entries) {
422
+ if (entries.length === 0)
423
+ return '';
424
+ const total = entries.length;
425
+ const out = ['', `⚠ ${total} unresolved ${total === 1 ? 'conflict' : 'conflicts'} detected`];
426
+ const oursLabel = pickLabel(entries, e => e.oursLabel);
427
+ const theirsLabel = pickLabel(entries, e => e.theirsLabel);
428
+ const baseLabel = pickLabel(entries, e => (e.baseLines !== undefined ? e.baseLabel : undefined));
429
+ const anyBase = entries.some(e => e.baseLines !== undefined);
430
+ if (oursLabel)
431
+ out.push(`- ours = ${oursLabel}`);
432
+ if (theirsLabel)
433
+ out.push(`- theirs = ${theirsLabel}`);
434
+ if (anyBase)
435
+ out.push(`- base = ${baseLabel ?? '(no label)'}`);
436
+ out.push('NOTICE: Inspect a block by reading `conflict://<N>` (add `/ours` / `/theirs` / `/base` to render a single side), or list every registered block with `conflict://*`. Resolve with `write({ path: "conflict://<N>", content })`; writes replace ONLY the marker block (markers + all sides) — never repeat the lines before/after it.');
437
+ out.push('`content` shorthand: a line that is exactly `@ours` / `@theirs` / `@base` / `@both` expands to that recorded section. `@both` is ours-then-theirs — only for additive conflicts where each side adds something different; NEVER for competing edits of the same lines. Per-id bulk: `write({ path: "conflict://*", content: "1: @ours\\n2: @theirs\\n…" })` resolves each listed id with that side in one call; unlisted ids stay registered.');
438
+ out.push('Resolve each block faithfully: keep one side (`@ours`/`@theirs`) or combine them when both intents apply — never invent content beyond the recorded sides. Resolve several conflicts in one turn by issuing multiple `write` calls at once; ids stay valid as earlier blocks are resolved.');
439
+ for (const entry of entries) {
440
+ const range = entry.startLine === entry.endLine ? `L${entry.startLine}` : `L${entry.startLine}-${entry.endLine}`;
441
+ out.push('', `──── #${entry.id} ${range} ────`);
442
+ const baseEqualsOurs = entry.baseLines !== undefined && sectionsEqual(entry.baseLines, entry.oursLines);
443
+ const baseEqualsTheirs = entry.baseLines !== undefined && sectionsEqual(entry.baseLines, entry.theirsLines);
444
+ const theirsEqualsOurs = sectionsEqual(entry.theirsLines, entry.oursLines);
445
+ out.push('<<< ours');
446
+ appendBody(out, entry.oursLines);
447
+ if (entry.baseLines !== undefined) {
448
+ if (baseEqualsOurs)
449
+ out.push('=== base ≡ ours');
450
+ else if (baseEqualsTheirs)
451
+ out.push('=== base ≡ theirs');
452
+ else {
453
+ out.push('=== base');
454
+ appendBody(out, entry.baseLines);
455
+ }
456
+ }
457
+ if (theirsEqualsOurs)
458
+ out.push('>>> theirs ≡ ours');
459
+ else {
460
+ out.push('>>> theirs');
461
+ appendBody(out, entry.theirsLines);
462
+ }
463
+ }
464
+ return out.join('\n');
465
+ }
466
+ /**
467
+ * Render a one-line-per-block index of every registered conflict. Used by the
468
+ * `<path>:conflicts` read selector to give the agent a cheap overview of a
469
+ * heavily-conflicted file without dumping every body.
470
+ */
471
+ export function formatConflictSummary(entries, displayPath) {
472
+ const lines = [];
473
+ const total = entries.length;
474
+ lines.push(`⚠ ${total} unresolved ${total === 1 ? 'conflict' : 'conflicts'} in ${displayPath || '<file>'}`);
475
+ const oursLabel = pickLabel(entries, e => e.oursLabel);
476
+ const theirsLabel = pickLabel(entries, e => e.theirsLabel);
477
+ const baseLabel = pickLabel(entries, e => (e.baseLines !== undefined ? e.baseLabel : undefined));
478
+ const anyBase = entries.some(e => e.baseLines !== undefined);
479
+ if (oursLabel)
480
+ lines.push(`- ours = ${oursLabel}`);
481
+ if (theirsLabel)
482
+ lines.push(`- theirs = ${theirsLabel}`);
483
+ if (anyBase)
484
+ lines.push(`- base = ${baseLabel ?? '(no label)'}`);
485
+ lines.push('NOTICE: Bulk-resolve with `write({ path: "conflict://*", content })`, or address a single block with `write({ path: "conflict://<N>", content })`. Inspect a block by reading `conflict://<N>` (add `/ours` / `/theirs` / `/base` for a single side).');
486
+ lines.push('`content` shorthand: `@ours` / `@theirs` / `@base` / `@both` lines expand to the recorded sections; `@both` = ours-then-theirs (additive conflicts only). Per-id bulk: `"1: @ours\\n2: @theirs"` resolves each listed id in one call. Writes replace ONLY the marker block — never repeat the surrounding lines.');
487
+ lines.push('');
488
+ const idWidth = String(entries[entries.length - 1]?.id ?? 1).length;
489
+ for (const entry of entries) {
490
+ const range = entry.startLine === entry.endLine ? `L${entry.startLine}` : `L${entry.startLine}-${entry.endLine}`;
491
+ const kind = entry.baseLines !== undefined ? ' (3-way)' : '';
492
+ lines.push(`#${String(entry.id).padStart(idWidth, ' ')} ${range}${kind}`);
493
+ }
494
+ return lines.join('\n');
495
+ }
496
+ /**
497
+ * The `conflict://` protocol handler.
498
+ *
499
+ * Reads:
500
+ * - `conflict://<N>` — full recorded marker block, lines prefixed by file line numbers.
501
+ * - `conflict://<N>/ours|theirs|base` — one side only.
502
+ * - `conflict://*` — summary of every currently-registered block.
503
+ *
504
+ * Writes:
505
+ * - `conflict://<N>` — splice the recorded region, replace with `content`
506
+ * (`@ours`/`@theirs`/`@base`/`@both` line tokens expand).
507
+ * - `conflict://*` — bulk: either one `content` for every registered block,
508
+ * or per-id lines `"N: @side"` resolving each listed id.
509
+ */
510
+ export class ConflictProtocolHandler {
511
+ deps;
512
+ scheme = 'conflict';
513
+ immutable = false;
514
+ constructor(deps) {
515
+ this.deps = deps;
516
+ }
517
+ resolve(url, context) {
518
+ const raw = url.rawHref;
519
+ const parsed = parseConflictUri(raw);
520
+ if (parsed === null) {
521
+ throw new Error(`Invalid conflict URL: '${raw}' — expected conflict://<N>, conflict://<N>/<scope>, or conflict://*`);
522
+ }
523
+ const history = this.deps.historyFor(context?.sessionKey);
524
+ if (parsed.id === '*') {
525
+ const entries = history.entries();
526
+ const content = entries.length === 0
527
+ ? 'No conflicts registered in this session yet. Read the conflicted file first to register its conflicts.'
528
+ : formatConflictSummary(entries, 'all files');
529
+ return Promise.resolve({
530
+ url: url.href,
531
+ content,
532
+ contentType: 'text/plain',
533
+ size: Buffer.byteLength(content, 'utf-8'),
534
+ notes: entries.length === 0 ? [] : [`${entries.length} registered conflict${entries.length === 1 ? '' : 's'}`],
535
+ });
536
+ }
537
+ const entry = history.get(parsed.id);
538
+ if (entry === undefined) {
539
+ const available = history.entries();
540
+ const hint = available.length > 0
541
+ ? ` Registered ids: ${available.map(e => e.id).join(', ')}.`
542
+ : ' No conflicts are registered in this session yet — read the conflicted file first.';
543
+ throw new Error(`Conflict #${parsed.id} is not registered in this session.${hint}`);
544
+ }
545
+ const { lines } = renderConflictRegion(entry, parsed.scope);
546
+ const content = lines.join('\n');
547
+ const scopeNote = parsed.scope !== undefined
548
+ ? `Showing ${parsed.scope} side of conflict #${parsed.id} in ${entry.displayPath}`
549
+ : `Conflict #${parsed.id} in ${entry.displayPath} (file lines ${entry.startLine}-${entry.endLine})`;
550
+ return Promise.resolve({
551
+ url: url.href,
552
+ content,
553
+ contentType: 'text/plain',
554
+ size: Buffer.byteLength(content, 'utf-8'),
555
+ notes: [scopeNote, 'Resolve with `write({ path: "conflict://' + String(parsed.id) + '", content })`. Writes replace ONLY the marker block.'],
556
+ });
557
+ }
558
+ async write(url, content, context) {
559
+ const parsed = parseConflictUri(url.rawHref);
560
+ if (parsed === null) {
561
+ throw new Error(`Invalid conflict URL: '${url.rawHref}' — expected conflict://<N> or conflict://*`);
562
+ }
563
+ if (parsed.scope !== undefined) {
564
+ throw new Error(`Invalid conflict URL: '${url.rawHref}' — a scope segment is read-only. Write to conflict://${parsed.id} to resolve the whole block.`);
565
+ }
566
+ const cw = {
567
+ ...context?.sessionKey !== undefined ? { sessionKey: context.sessionKey } : {},
568
+ ...context?.signal !== undefined ? { signal: context.signal } : {},
569
+ ...context?.sandboxPolicy !== undefined ? { sandboxPolicy: context.sandboxPolicy } : {},
570
+ };
571
+ const history = this.deps.historyFor(cw.sessionKey);
572
+ if (parsed.id === '*') {
573
+ await this.writeBulk(history, content, cw);
574
+ return;
575
+ }
576
+ const entry = history.get(parsed.id);
577
+ if (entry === undefined) {
578
+ const available = history.entries();
579
+ const hint = available.length > 0
580
+ ? ` Registered ids: ${available.map(e => e.id).join(', ')}.`
581
+ : ' No conflicts are registered in this session yet — read the conflicted file first.';
582
+ throw new Error(`Conflict #${parsed.id} is not registered in this session.${hint}`);
583
+ }
584
+ await this.resolveEntry(entry, expandContentTokens(content, entry), cw);
585
+ }
586
+ /** Apply the same (token-expanded) content to every registered block. */
587
+ async writeBulk(history, content, cw) {
588
+ const entries = history.entries();
589
+ if (entries.length === 0) {
590
+ throw new Error('conflict://* has nothing to resolve: no conflicts are registered in this session yet. Read the conflicted file first.');
591
+ }
592
+ // Per-id content lines ("N: @side") override the shared body for listed ids.
593
+ const perId = new Map();
594
+ const sharedLines = [];
595
+ for (const raw of content.split('\n')) {
596
+ const match = /^(\d+):\s*(.*)$/.exec(raw);
597
+ if (match === null) {
598
+ sharedLines.push(raw);
599
+ continue;
600
+ }
601
+ const id = Number.parseInt(match[1] ?? '', 10);
602
+ // Only resolve ids that are actually registered — an unknown id in the
603
+ // per-id content must not be silently ignored.
604
+ if (history.get(id) !== undefined)
605
+ perId.set(id, match[2] ?? '');
606
+ }
607
+ const shared = sharedLines.join('\n');
608
+ for (const entry of entries) {
609
+ if (perId.has(entry.id)) {
610
+ await this.resolveEntry(entry, expandContentTokens(perId.get(entry.id) ?? '', entry), cw);
611
+ }
612
+ else if (shared !== '') {
613
+ await this.resolveEntry(entry, expandContentTokens(shared, entry), cw);
614
+ }
615
+ }
616
+ }
617
+ async resolveEntry(entry, replacementBody, cw) {
618
+ const signal = cw.signal;
619
+ const original = await this.deps.bridge.readFile(entry.absolutePath, signal);
620
+ const splat = spliceConflict(original, entry, replacementBody);
621
+ await this.deps.bridge.writeFile(entry.absolutePath, splat.text, signal, cw.sandboxPolicy);
622
+ this.deps.historyFor(cw.sessionKey).invalidate(entry.id);
623
+ }
624
+ }
625
+ //# sourceMappingURL=conflict.js.map