@heyquilt/seam 0.141.0 → 0.143.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/bin/seam.js CHANGED
@@ -20,6 +20,7 @@ Docs: https://seam.heyquilt.com/docs
20
20
  seam login [--no-browser] Sign in with your Seam account (seam.heyquilt.com)
21
21
  seam logout | seam whoami
22
22
  seam computers [list | signout <id or name>] computers signed in to your account (seam login)
23
+ seam handle [<new>] your name in your repos' addresses (<handle>/<repo>); with <new>, change it
23
24
  seam account delete --confirm <your email> delete your Seam account (your patches stay)
24
25
  seam agent new <name> | list | key <name> | revoke <name> | relink [agent:<id>] your agents and their keys
25
26
  seam oauth client new <name> --redirect <uri> | list | revoke <client_id> apps that ask people for access (OAuth 2, PKCE)
@@ -131,6 +132,13 @@ async function main () {
131
132
  if (cmd === 'whoami') { const a = readAccount(); if (!a) { process.exitCode = 1; return console.log('Not signed in.') } return console.log(`${a.account.name} (${a.account.email})`) }
132
133
  if (cmd === 'agent') return agentCommand(argv[0], argv[1])
133
134
  if (cmd === 'computers') return computersCommand(argv[0], argv[1])
135
+ // Your handle (11.2, 2026-10-29): your repos are <handle>/<repo>, on the site and in every command.
136
+ if (cmd === 'handle') {
137
+ const go = signedIn()
138
+ if (!argv[0]) { const me = await go('GET', '/v1/me'); return console.log(me.account?.handle ? `${me.account.handle}: your repos are ${me.account.handle}/<repo>.` : 'Your account has no handle on this Seam server.') }
139
+ const r = await go('PUT', '/v1/me/handle', { handle: argv[0] })
140
+ return console.log(`Your handle is ${r.handle}: your repos are ${r.handle}/<repo> now, and their old addresses stop working.`)
141
+ }
134
142
  if (cmd === 'account') {
135
143
  const i = argv.indexOf('--confirm')
136
144
  if (argv[0] !== 'delete' || i < 0 || !argv[i + 1]) fail('seam account delete --confirm <your email>')
@@ -53,27 +53,40 @@ const keyIdOf = (raw) => 'ed25519:' + crypto.createHash('sha256').update(raw).di
53
53
  * The server's attestation key from SEAM_ATTESTATION_KEY (an Ed25519 private key: PKCS8 PEM, or
54
54
  * base64 of its PKCS8 DER), or a new one for this process when none is given.
55
55
  */
56
- export function attestationKey (secret) {
56
+ export const attestationKey = (secret) => ed25519Key(secret, 'SEAM_ATTESTATION_KEY')
57
+
58
+ /**
59
+ * An Ed25519 signing key from a setting's value (PKCS8 PEM, or base64 of its PKCS8 DER), or a new
60
+ * one for this process when none is given: { id, public_key, ephemeral, sign(text) → base64 }.
61
+ */
62
+ export function ed25519Key (secret, setting) {
57
63
  let key
58
64
  if (secret) {
59
65
  const s = String(secret).trim()
60
- key = s.includes('BEGIN') ? crypto.createPrivateKey(s) : crypto.createPrivateKey({ key: Buffer.from(s, 'base64'), format: 'der', type: 'pkcs8' })
61
- if (key.asymmetricKeyType !== 'ed25519') throw new Error('SEAM_ATTESTATION_KEY is not an Ed25519 private key')
66
+ try {
67
+ key = s.includes('BEGIN') ? crypto.createPrivateKey(s) : crypto.createPrivateKey({ key: Buffer.from(s, 'base64'), format: 'der', type: 'pkcs8' })
68
+ } catch { throw new Error(`${setting} is not an Ed25519 private key (PKCS8 PEM or base64 DER)`) }
69
+ if (key.asymmetricKeyType !== 'ed25519') throw new Error(`${setting} is not an Ed25519 private key`)
62
70
  } else key = crypto.generateKeyPairSync('ed25519').privateKey
63
71
  const raw = crypto.createPublicKey(key).export({ format: 'der', type: 'spki' }).subarray(SPKI_PREFIX.length)
64
72
  return { id: keyIdOf(raw), public_key: raw.toString('base64'), ephemeral: !secret, sign: (text) => crypto.sign(null, Buffer.from(text), key).toString('base64') }
65
73
  }
66
74
 
75
+ /** Whether `sig` (bytes) is an Ed25519 signature of `text` by the raw public key `publicKey` (base64). */
76
+ export function ed25519Verify (publicKey, text, sig) {
77
+ try {
78
+ const pub = crypto.createPublicKey({ key: Buffer.concat([SPKI_PREFIX, Buffer.from(String(publicKey), 'base64')]), format: 'der', type: 'spki' })
79
+ return crypto.verify(null, Buffer.from(text), pub, sig)
80
+ } catch { return false }
81
+ }
82
+
67
83
  /** Whether `a` is a good attestation of `row` under one of `keys` ([{ id, public_key }]). */
68
84
  export function verifyAttestation (a, row, keys) {
69
85
  if (!a) return 'unsigned'
70
86
  if (a.seq !== row.seq || a.kind !== row.kind || a.row_hash !== rowHash(row)) return 'mismatch'
71
87
  const k = keys.find((x) => x.id === a.key_id)
72
88
  if (!k) return 'unknown_key'
73
- try {
74
- const pub = crypto.createPublicKey({ key: Buffer.concat([SPKI_PREFIX, Buffer.from(k.public_key, 'base64')]), format: 'der', type: 'spki' })
75
- if (!crypto.verify(null, Buffer.from(attestationText(a)), pub, Buffer.from(String(a.sig), 'base64'))) return 'bad_signature'
76
- } catch { return 'bad_signature' }
89
+ if (!ed25519Verify(k.public_key, attestationText(a), Buffer.from(String(a.sig), 'base64'))) return 'bad_signature'
77
90
  return a.late ? 'late' : 'ok'
78
91
  }
79
92
 
@@ -188,7 +201,8 @@ export function attestationOps (svc, attester) {
188
201
  * against `keys` (pinned by the caller) or the server's published keys.
189
202
  */
190
203
  export async function verifyQuilt (client, quilt, { after = 0, keys = null } = {}) {
191
- const pinned = keys || (await client.attestationKeys()).keys
204
+ // The published keys also hold the want-ticket key (tickets.js), which never signs a log row.
205
+ const pinned = keys || (await client.attestationKeys()).keys.filter((k) => !k.use || k.use === 'attestation')
192
206
  const rows = []; const atts = []
193
207
  let from = Number(after) || 0
194
208
  for (;;) {
@@ -17,6 +17,8 @@ import { CDC_THRESHOLD } from './fastcdc.js'
17
17
  import { TreeReader, decodeNode, EMPTY_TREE } from './tree.js'
18
18
  import { globMatcher } from './claims.js'
19
19
  import { theCas } from './cas.js'
20
+ import { fastMerge } from './fastmerge.js'
21
+ import { diffMarkers, gitMarkers, findMarkers } from './conflict-format.js'
20
22
 
21
23
  /** How much of a large file is read at a time (10, streaming the cut). */
22
24
  export const READ_PIECE = 1024 * 1024
@@ -174,8 +176,10 @@ function readFileEntry (abs, stat, pointer) {
174
176
  */
175
177
  export function livePatch (dir, block, paths) {
176
178
  const only = paths.map((p) => cleanPrefix(path.relative(dir, path.resolve(p)).split(path.sep).join('/')))
177
- const changed = localChanges(dir, block, { paths })
178
- const seen = new Set(changed.map((c) => c.path))
179
+ // A file a move left conflicted is not shared until it is settled (its markers would go out as work).
180
+ const held = new Set(pullConflicts(dir, block))
181
+ const changed = localChanges(dir, block, { paths }).filter((c) => !held.has(c.path))
182
+ const seen = new Set([...changed.map((c) => c.path), ...held])
179
183
  const changes = changed.map((c) => c.status === 'deleted' ? { path: c.path, op: 'delete' } : { path: c.path, op: 'put', content: bytesOf(c).toString('base64'), mode: c.mode })
180
184
  // A named path that is not a change and not tracked is `clean` only when it is gone from disk (it
181
185
  // may have been shared as added); one still there is ignored or a folder, and is not sent.
@@ -436,7 +440,9 @@ export async function checkout (client, { quilt, square = null, squareRow = null
436
440
 
437
441
  /**
438
442
  * Moves the folder from `block.at` to commit `to`, file by file: paths changed on the server
439
- * are rewritten; a path changed both here and there is left as it is here and reported.
443
+ * are rewritten; a path changed both here and there is merged three ways (planMerges): merged
444
+ * cleanly, or left with conflict markers (or, when it can't be merged line by line, as it is
445
+ * here) and reported, to be settled before it is committed.
440
446
  */
441
447
  export async function syncTo (client, dir, block, to, { square, afterWrite } = {}) {
442
448
  const local = new Map(localChanges(dir, block).map((c) => [c.path, c]))
@@ -444,23 +450,29 @@ export async function syncTo (client, dir, block, to, { square, afterWrite } = {
444
450
  const t = d.files.length ? await listTree(client, block.quilt, to ? `c:${to}` : 'main', block.paths, dir) : { entries: [] }
445
451
  const byPath = new Map(t.entries.filter((e) => !e.unreadable).map((e) => [e.path, e]))
446
452
  const get = await contentsFor(client, block.quilt, to ? `c:${to}` : 'main', d.files.map((f) => byPath.get(f.path)).filter(Boolean), block._codec, t.ticket)
447
- const kept = []
448
453
  let written = 0
454
+ // Paths changed on both sides are merged before anything is written, so the journal's plan
455
+ // holds each merge's outcome (05 "Projection model"; rebuild finishes them).
456
+ const both = d.files.filter((f) => local.has(f.path)).map((f) => ({ path: f.path, old: block.files[f.path] || null, theirs: byPath.get(f.path) || null }))
457
+ const plans = await planMerges(client, block, both, { from: block.at, get, to, oursOf: (p) => oursOfChange(local.get(p)) })
449
458
  // The plan goes to the journal first (05 "Rebuilt, never repaired"), so a move that dies part
450
459
  // way is finished by rebuild instead of leaving the server's files looking like the holder's edits.
451
460
  const journal = openJournal(dir, {
452
461
  op: 'move', from: block.at || null, to, rev: to ? `c:${to}` : 'main',
453
462
  ...(square ? { square: { id: square.id, short: square.short, head: square.head, line: square.line } } : {}),
454
463
  writes: d.files.filter((f) => !local.has(f.path) && byPath.get(f.path)).map((f) => { const e = byPath.get(f.path); return { path: e.path, blob: e.blob, mode: e.mode, size: e.size, ...(e.term ? { term: e.term } : {}), ...(e.read_only ? { read_only: true } : {}), old: block.files[f.path] || null } }),
455
- removes: d.files.filter((f) => !local.has(f.path) && !byPath.get(f.path)).map((f) => ({ path: f.path, old: block.files[f.path] || null }))
464
+ removes: d.files.filter((f) => !local.has(f.path) && !byPath.get(f.path)).map((f) => ({ path: f.path, old: block.files[f.path] || null })),
465
+ merges: plans.map(({ write: _, ours: __, ...m }) => m)
456
466
  })
467
+ const byMerge = new Map(plans.map((m) => [m.path, m]))
457
468
  for (const f of d.files) {
458
- if (local.has(f.path)) { kept.push(f.path); continue }
459
- const e = byPath.get(f.path)
460
- if (!e) {
469
+ const m = byMerge.get(f.path)
470
+ if (m) applyMerge(dir, block, m)
471
+ else if (!byPath.get(f.path)) {
461
472
  removeEntry(dir, f.path, block.files[f.path]?.mode)
462
473
  delete block.files[f.path]; written++
463
474
  } else {
475
+ const e = byPath.get(f.path)
464
476
  block.files[f.path] = await writeEntry(dir, e, await get(e))
465
477
  written++
466
478
  }
@@ -476,7 +488,192 @@ export async function syncTo (client, dir, block, to, { square, afterWrite } = {
476
488
  setStack(block, await stackOf(client, block.quilt, to))
477
489
  saveBlock(dir, block)
478
490
  journal.close()
479
- return { written, kept }
491
+ return { written, ...mergeReport(plans) }
492
+ }
493
+
494
+ const mergeReport = (plans) => ({ merged: plans.filter((m) => m.how === 'merged').map((m) => m.path), conflicted: plans.filter((m) => m.how === 'conflict').map((m) => m.path) })
495
+
496
+ // The ladder steps that merge by themselves (07's auto_combine at its default, as merge.js's AUTO).
497
+ const MERGES_ITSELF = new Set(['lines', 'additive', 'whitespace'])
498
+ const textMode = (m) => m === 'file' || m === 'exec'
499
+ const conflictErr = (msg) => Object.assign(new Error(msg), { code: 'conflicted' })
500
+ /** Where a move keeps the folder's own version of a file it left conflicted (for seam resolve --ours). */
501
+ const savedOurs = (dir, tag) => path.join(dir, BLOCK_DIR, 'conflicts', tag)
502
+ /** The folder's side of a merge from a localChanges entry: { bytes (null: deleted, undefined: not read whole), mode, blob }. */
503
+ const oursOfChange = (c) => c.status === 'deleted' ? { bytes: null } : { bytes: c.bytes, mode: c.mode, blob: c.blob }
504
+ /** A tree entry as the folder's record of it (mtime 0: the file on disk is read again). */
505
+ const recordOf = (e) => e ? { blob: e.mode === 'conflict' ? null : e.blob, mode: e.mode, size: e.size, mtimeMs: 0, ...(e.term ? { term: e.term } : {}) } : null
506
+
507
+ /**
508
+ * Plans the three-way merge of each path changed both in the folder and in a move, as the server
509
+ * merges (07): base = the folder's record (`old`, read at `from`), ours = the disk (`oursOf`),
510
+ * theirs = the move's entry (`theirs`, read with `get`; null when the move deletes it). Writes
511
+ * nothing. Each plan says what the file becomes and that the record moves to theirs, so the
512
+ * file is an edit against the new commit and the next commit drops nothing the move brought
513
+ * (05 "Projection model"). `how`:
514
+ * - 'same': both sides made the same change;
515
+ * - 'merged': merged cleanly; `write` is the merged text, the folder's edit;
516
+ * - 'conflict': the clash with conflict markers in `write` (the folder's own version in `ours`, for
517
+ * seam resolve --ours), or, for a file that can't be merged line by line, no write: the folder's
518
+ * version stays. `conflict` is its block.conflicts entry, kept until it is settled.
519
+ */
520
+ async function planMerges (client, block, both, { from, get, to, oursOf }) {
521
+ if (!both.length) return []
522
+ const bases = both.filter((b) => b.old && b.old.blob && textMode(b.old.mode)).map((b) => ({ path: b.path, blob: b.old.blob, mode: b.old.mode, size: b.old.size }))
523
+ const getBase = bases.length && from ? await contentsFor(client, block.quilt, `c:${from}`, bases, block._codec) : null
524
+ const baseOf = new Map(bases.map((e) => [e.path, e]))
525
+ const idOf = idsOf(block)
526
+ const out = []
527
+ for (const { path: p, old, theirs } of both) {
528
+ const ours = oursOf(p)
529
+ const base = baseOf.has(p) && getBase ? await getBase(baseOf.get(p)) : undefined
530
+ const theirsBytes = theirs && theirs.mode !== 'conflict' ? await get(theirs) : null
531
+ const m = { path: p, old, record: recordOf(theirs) }
532
+ // Both deleted it, or both made it the same.
533
+ if ((!theirs && ours.bytes === null) || (theirs && ours.bytes && ours.blob === theirs.blob && ours.mode === theirs.mode)) { out.push({ ...m, how: 'same' }); continue }
534
+ const prior = block.conflicts?.[p]
535
+ const isBuf = (b) => b == null || Buffer.isBuffer(b)
536
+ const mergeable = (ours.bytes === null || (textMode(ours.mode) && ours.bytes)) && (!theirs || (textMode(theirs.mode) && theirsBytes)) &&
537
+ (!old || (textMode(old.mode) && base !== undefined)) && isBuf(ours.bytes) && isBuf(theirsBytes) && isBuf(base) &&
538
+ isText(ours.bytes) && isText(theirsBytes) && isText(base) && !!(ours.bytes || theirsBytes)
539
+ if (!mergeable) {
540
+ // A file that can't be merged line by line (binary, large, a link, a conflict already):
541
+ // the folder's version stays as it is until seam resolve --ours or --theirs settles it.
542
+ out.push({ ...m, how: 'conflict', conflict: { kind: 'whole', ...(prior?.tags ? { tags: prior.tags } : {}) } })
543
+ continue
544
+ }
545
+ const [o, a, b] = [base, ours.bytes, theirsBytes].map((x) => (x || Buffer.alloc(0)).toString('utf8'))
546
+ const exec = (theirs && old && old.mode === ours.mode ? theirs.mode : ours.mode || theirs?.mode) === 'exec'
547
+ if (ours.bytes && theirsBytes) {
548
+ const r = fastMerge(o, a, b, { allow: 'whitespace' })
549
+ if (!r.conflicts.length && MERGES_ITSELF.has(r.how)) {
550
+ const write = Buffer.from(r.text, 'utf8')
551
+ out.push({ ...m, how: 'merged', write, exec, out: idOf(write) })
552
+ continue
553
+ }
554
+ }
555
+ // A clash, or one side deleted what the other changed: the file with both sides in markers.
556
+ const hex = blobId(Buffer.from(['pull', p, old?.blob || '', ours.blob || '', theirs?.blob || ''].join('\n'))).slice(0, 12)
557
+ const tag = `quilt-conflict ${hex}`
558
+ const kind = ours.bytes && theirsBytes ? 'content' : 'edit_vs_delete'
559
+ const at = `upstream ${String(to || '').slice(0, 12)}`.trim()
560
+ const text = conflictStyle() === 'git' && ours.bytes && theirsBytes
561
+ ? gitMarkers({ ours: { text: a, label: 'yours' }, base: o, theirs: { text: b, label: at }, tag })
562
+ : diffMarkers({ sides: [{ text: ours.bytes ? a : null, label: 'yours', by: 'you' }, { text: theirsBytes ? b : null, label: at, by: 'upstream' }], bases: [o], tag, kind })
563
+ const write = Buffer.from(text, 'utf8')
564
+ out.push({ ...m, how: 'conflict', write, exec, out: idOf(write), ...(ours.bytes ? { ours: ours.bytes } : {}), conflict: { kind, tags: [...(prior?.tags || []), hex], ours: ours.bytes ? hex : null } })
565
+ }
566
+ return out
567
+ }
568
+
569
+ /** Carries out one planMerges plan: the folder's own version saved, the file written, the record and conflict set. */
570
+ function applyMerge (dir, block, m) {
571
+ if (m.ours && m.conflict?.ours) { fs.mkdirSync(path.dirname(savedOurs(dir, m.conflict.ours)), { recursive: true }); fs.writeFileSync(savedOurs(dir, m.conflict.ours), m.ours) }
572
+ if (m.write) {
573
+ const abs = absOf(dir, m.path)
574
+ fs.mkdirSync(path.dirname(abs), { recursive: true })
575
+ const tmpDir = path.join(dir, BLOCK_DIR, 'tmp')
576
+ fs.mkdirSync(tmpDir, { recursive: true })
577
+ const tmp = path.join(tmpDir, `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`)
578
+ fs.writeFileSync(tmp, m.write, { mode: m.exec ? 0o755 : 0o644 })
579
+ fs.renameSync(tmp, abs)
580
+ }
581
+ settleRecord(block, m)
582
+ }
583
+
584
+ function settleRecord (block, m) {
585
+ if (m.record) block.files[m.path] = m.record
586
+ else delete block.files[m.path]
587
+ if (m.conflict) block.conflicts = { ...block.conflicts, [m.path]: m.conflict }
588
+ }
589
+
590
+ /**
591
+ * The files a move left conflicted (planMerges) that are not settled yet, sorted. A file is settled
592
+ * once its markers are gone (edited, deleted, restored), or, for one kept whole, once it matches
593
+ * the folder's record; a settled one leaves block.conflicts (and block.json is saved). Markers of
594
+ * any kind left in it (its tag edited away, git's typed by hand) keep it open (07 ruling 2026-10-29).
595
+ */
596
+ export function pullConflicts (dir, block) {
597
+ const c = block.conflicts
598
+ if (!c) return []
599
+ const open = []
600
+ let changed = null
601
+ let dropped = false
602
+ for (const [p, x] of Object.entries(c)) {
603
+ let still = false
604
+ if (x.tags?.length) { let bytes = null; try { bytes = fs.readFileSync(absOf(dir, p)) } catch {} still = !!bytes && (x.tags.some((t) => bytes.includes(Buffer.from(`quilt-conflict ${t}`))) || !!findMarkers(bytes)) }
605
+ if (!still && x.kind === 'whole') { changed ??= new Set(localChanges(dir, block).map((ch) => ch.path)); still = changed.has(p) }
606
+ if (still) { open.push(p); continue }
607
+ for (const t of x.tags || []) fs.rmSync(savedOurs(dir, t), { force: true })
608
+ delete c[p]
609
+ dropped = true
610
+ }
611
+ if (!Object.keys(c).length) delete block.conflicts
612
+ if (dropped) saveBlock(dir, block)
613
+ return open.sort(cmpPath)
614
+ }
615
+
616
+ /**
617
+ * As the server refuses it (service.js, 07 ruling 2026-10-29): a conflicted file among `paths` (a
618
+ * term from the server, or a conflict a move left) that no longer carries its tag but still holds
619
+ * conflict markers is refused, `markers_left`, naming the file and line. Here too for a sealed
620
+ * quilt, whose server cannot read the file.
621
+ */
622
+ function refuseMarkersLeft (dir, block, paths, verb) {
623
+ for (const p of paths) {
624
+ const term = block.files[p]?.term
625
+ const tags = [...(block.conflicts?.[p]?.tags || []), ...(term ? [term.slice(0, 12)] : [])]
626
+ if (!tags.length) continue
627
+ let bytes = null
628
+ try { bytes = fs.readFileSync(absOf(dir, p)) } catch { continue }
629
+ if (tags.some((t) => bytes.includes(Buffer.from(`quilt-conflict ${t}`)))) continue
630
+ const m = findMarkers(bytes)
631
+ if (m) throw Object.assign(new Error(`${p}:${m.line} still holds conflict markers (${m.text}), so it does not settle the conflict: edit every marker block out, or seam resolve ${p} --ours | --theirs, then ${verb}.`), { code: 'markers_left', path: p, line: m.line })
632
+ }
633
+ }
634
+
635
+ /** 07 ruling 2026-10-29: markers in a file with no conflict are committed as written, with a warning. */
636
+ function withLocalMarkers (block, changes, r) {
637
+ const found = changes.filter((c) => c.bytes && !block.files[c.path]?.term).map((c) => ({ c, m: findMarkers(c.bytes) })).filter((x) => x.m)
638
+ if (!found.length) return r
639
+ const have = new Set((r.warnings || []).filter((w) => w.kind === 'markers_in_file').map((w) => w.path))
640
+ const more = found.filter((x) => !have.has(x.c.path)).map((x) => ({ kind: 'markers_in_file', path: x.c.path, line: x.m.line, local: true }))
641
+ return more.length ? { ...r, warnings: [...(r.warnings || []), ...more] } : r
642
+ }
643
+
644
+ /** Refuses to commit (or move) while `paths` hold a conflict a move left. */
645
+ function refuseConflicted (open, verb) {
646
+ if (!open.length) return
647
+ throw conflictErr(`${open.join(', ')} still ${open.length === 1 ? 'has' : 'have'} the conflict seam pull left: edit ${open.length === 1 ? 'it' : 'each'} to the version you want (no markers), or seam resolve <path> --ours | --theirs, then ${verb}.`)
648
+ }
649
+
650
+ /**
651
+ * seam resolve --ours | --theirs for files a move left conflicted, in the folder: ours puts back
652
+ * the folder's own version from before the move (one kept whole stays as it is), theirs the
653
+ * version the folder moved to. Answers { settled: [paths], took }.
654
+ */
655
+ export async function resolvePulled (client, dir, block, { paths, choose }) {
656
+ const open = new Set(pullConflicts(dir, block))
657
+ const rels = paths.map((p) => cleanPrefix(path.relative(dir, path.resolve(p)).split(path.sep).join('/')))
658
+ const not = rels.filter((p) => !open.has(p))
659
+ if (not.length) throw new Error(`${not.join(', ')}: no conflict seam pull left there (seam status lists them)`)
660
+ if (choose === 'theirs') {
661
+ const entries = rels.filter((p) => block.files[p]).map((p) => ({ path: p, ...block.files[p] }))
662
+ const get = await contentsFor(client, block.quilt, `c:${block.at}`, entries, block._codec)
663
+ for (const e of entries) block.files[e.path] = { ...await writeEntry(dir, e, await get(e)), ...(e.term ? { term: e.term } : {}) }
664
+ for (const p of rels) if (!block.files[p]) { removeEntry(dir, p, 'file'); pruneEmpty(dir, p) }
665
+ } else {
666
+ for (const p of rels) {
667
+ const x = block.conflicts[p]
668
+ if (x.kind === 'whole') continue
669
+ if (x.ours) fs.writeFileSync(absOf(dir, p), fs.readFileSync(savedOurs(dir, x.ours)))
670
+ else { removeEntry(dir, p, 'file'); pruneEmpty(dir, p) }
671
+ }
672
+ }
673
+ for (const p of rels) { for (const t of block.conflicts[p].tags || []) fs.rmSync(savedOurs(dir, t), { force: true }); delete block.conflicts[p] }
674
+ if (!Object.keys(block.conflicts).length) delete block.conflicts
675
+ saveBlock(dir, block)
676
+ return { settled: rels, took: choose }
480
677
  }
481
678
 
482
679
  /** The write journal of a block folder: one plan line, then one `{ done }` line per path. */
@@ -518,30 +715,44 @@ function onDisk (dir, block, p) {
518
715
  return { id: idsOf(block)(bytes), mode, stat }
519
716
  }
520
717
 
718
+ /** The folder's side of a merge read from disk (oursOfChange's shape). */
719
+ function oursOnDisk (dir, block, p) {
720
+ const cur = onDisk(dir, block, p)
721
+ if (!cur) return { bytes: null }
722
+ if (!cur.stat.isFile() && !cur.stat.isSymbolicLink()) return { bytes: undefined, mode: cur.mode, blob: cur.id }
723
+ if (cur.stat.isFile() && cur.stat.size > CDC_THRESHOLD && !block.sealed) return { bytes: undefined, mode: cur.mode, blob: cur.id }
724
+ return { bytes: readFileEntry(absOf(dir, p), cur.stat, false).bytes, mode: cur.mode, blob: cur.id }
725
+ }
726
+
521
727
  /**
522
728
  * block.rebuild (05): finishes a move the journal shows unfinished. A planned path whose file is
523
- * the plan's is recorded; one still as before (or gone) is written now; one that is neither is
524
- * the holder's edit and is kept. Then block.json takes the move's commit and the journal goes.
525
- * On a healthy block it writes nothing. Answers { finished, written, removed, kept, changes }.
729
+ * the plan's is recorded; one still as before (or gone) is written now; one the holder edited
730
+ * before it was written is merged with the move's change (planMerges), as are the planned merges
731
+ * the move did not reach. Then block.json takes the move's commit and the journal goes. On a
732
+ * healthy block it writes nothing. Answers { finished, written, removed, kept (the holder's edits,
733
+ * merged ones too), conflicted, changes }.
526
734
  */
527
735
  export async function rebuild (client, dir, block) {
528
736
  const j = readJournal(dir)
529
- if (!j) return { finished: false, written: [], removed: [], kept: [], changes: localChanges(dir, block).length }
737
+ if (!j) return { finished: false, written: [], removed: [], kept: [], conflicted: [], changes: localChanges(dir, block).length }
530
738
  const { plan, done } = j
531
739
  const same = (cur, rec) => !!(cur && rec && rec.blob && cur.id === rec.blob && cur.mode === rec.mode)
532
740
  const written = []
533
741
  const removed = []
534
742
  const kept = []
743
+ const conflicted = []
535
744
  const toWrite = []
745
+ // Paths the holder changed and the move changes too, still to merge: { path, old, theirs }.
746
+ const redo = []
536
747
  for (const w of plan.writes || []) {
537
748
  const cur = onDisk(dir, block, w.path)
538
749
  if (same(cur, w)) { block.files[w.path] = { blob: w.blob, mode: w.mode, size: cur.stat.size, mtimeMs: cur.stat.mtimeMs, ...(w.term ? { term: w.term } : {}) }; continue }
539
750
  if (done.has(w.path) && w.mode === 'conflict' && cur) { const st = cur.stat; block.files[w.path] = { blob: null, mode: 'conflict', size: st.size, mtimeMs: st.mtimeMs, term: w.term }; continue }
540
751
  if (!done.has(w.path) && (!cur || same(cur, w.old))) { toWrite.push(w); continue }
541
- // Written and edited since, or edited before it was written: the holder's. Its record is the
542
- // one the folder now stands on, so the edit shows against the new commit.
543
- kept.push(w.path)
544
- if (done.has(w.path)) block.files[w.path] = { blob: w.blob, mode: w.mode, size: w.size, mtimeMs: 0 }
752
+ // Written and edited since: the holder's edit, against the new commit.
753
+ if (done.has(w.path)) { kept.push(w.path); block.files[w.path] = { blob: w.blob, mode: w.mode, size: w.size, mtimeMs: 0 }; continue }
754
+ // Edited before it was written: the holder's edit and the move's change are merged.
755
+ redo.push({ path: w.path, old: w.old, theirs: w })
545
756
  }
546
757
  if (toWrite.length) {
547
758
  const get = await contentsFor(client, block.quilt, plan.rev, toWrite, block._codec)
@@ -551,20 +762,35 @@ export async function rebuild (client, dir, block) {
551
762
  const cur = onDisk(dir, block, r.path)
552
763
  if (!cur) { delete block.files[r.path]; if (!done.has(r.path)) removed.push(r.path); continue }
553
764
  if (!done.has(r.path) && same(cur, r.old)) { removeEntry(dir, r.path, r.old?.mode); pruneEmpty(dir, r.path); delete block.files[r.path]; removed.push(r.path); continue }
554
- kept.push(r.path)
765
+ // Removed and made again since: a new file of the holder's. Edited before the remove: merged.
766
+ if (done.has(r.path)) { kept.push(r.path); delete block.files[r.path] } else redo.push({ path: r.path, old: r.old, theirs: null })
767
+ }
768
+ for (const m of plan.merges || []) {
769
+ // A planned merge carried out (its file is the plan's) needs only its record.
770
+ const cur = onDisk(dir, block, m.path)
771
+ if (done.has(m.path) || (m.out && cur && cur.id === m.out)) { settleRecord(block, m); if (m.how === 'merged') kept.push(m.path); else if (m.how === 'conflict') conflicted.push(m.path); continue }
772
+ redo.push({ path: m.path, old: m.old, theirs: m.record ? { path: m.path, ...m.record } : null })
773
+ }
774
+ if (redo.length) {
775
+ const theirs = redo.map((r) => r.theirs).filter((e) => e && e.mode !== 'conflict')
776
+ const get = await contentsFor(client, block.quilt, plan.rev, theirs, block._codec)
777
+ for (const m of await planMerges(client, block, redo, { from: plan.from, get, to: plan.to, oursOf: (p) => oursOnDisk(dir, block, p) })) {
778
+ applyMerge(dir, block, m)
779
+ if (m.how === 'merged') kept.push(m.path); else if (m.how === 'conflict') conflicted.push(m.path)
780
+ }
555
781
  }
556
782
  block.at = plan.to
557
783
  if (plan.square) { block.square = plan.square.id; block.square_short = plan.square.short; block.head = plan.square.head; block.line = plan.square.line }
558
784
  saveBlock(dir, block)
559
785
  fs.rmSync(path.join(dir, JOURNAL_FILE), { force: true })
560
- return { finished: true, to: plan.to, written, removed, kept: kept.sort(cmpPath), changes: localChanges(dir, block).length }
786
+ return { finished: true, to: plan.to, written, removed, kept: kept.sort(cmpPath), conflicted: conflicted.sort(cmpPath), changes: localChanges(dir, block).length }
561
787
  }
562
788
 
563
789
  /** status: the folder's changes, and the square as the server sees it. */
564
790
  export async function status (client, dir, block) {
565
791
  const changes = localChanges(dir, block).map(({ bytes, open, abs, ...c }) => c)
566
792
  const square = block.square ? await client.square(block.quilt, block.square) : null
567
- const conflicted = Object.entries(block.files).filter(([, f]) => f.term).map(([p]) => p)
793
+ const conflicted = [...new Set([...Object.entries(block.files).filter(([, f]) => f.term).map(([p]) => p), ...pullConflicts(dir, block)])].sort(cmpPath)
568
794
  return { dir, quilt: block.quilt, square, line: block.line, on_line: isLineCheckout(block), paths: block.paths, at: block.at, changes, conflicted, stale: !!(square && square.state === 'open' && square.head !== block.head), ...(square && square.state && square.state !== 'open' ? { square_closed: square.state } : {}) }
569
795
  }
570
796
 
@@ -585,6 +811,9 @@ export async function commitFolder (client, dir, block, { message, paths = [], a
585
811
  if (!block.square && !isLineCheckout(block)) throw new Error('This folder holds one revision, not a line; open a square (seam square open) to commit.')
586
812
  const changes = localChanges(dir, block, { paths })
587
813
  if (!changes.length) return { nothing: true }
814
+ const open = pullConflicts(dir, block).filter((p) => changes.some((c) => c.path === p))
815
+ refuseMarkersLeft(dir, block, changes.filter((c) => c.status !== 'deleted').map((c) => c.path), 'commit')
816
+ refuseConflicted(open, 'commit')
588
817
  // A clone (a checkout of a line): the first commit opens a square by itself, titled by the
589
818
  // message and based on the commit the folder holds, so it undoes nothing main gained since.
590
819
  const opened = block.square ? null : await openForCommit(client, dir, block, message)
@@ -622,7 +851,7 @@ export async function commitFolder (client, dir, block, { message, paths = [], a
622
851
  block.at = r.square.head
623
852
  block.head = r.square.head
624
853
  saveBlock(dir, block)
625
- const out = withLocalScan(block, changes, allow, r)
854
+ const out = withLocalMarkers(block, changes, withLocalScan(block, changes, allow, r))
626
855
  if (opened) out.opened = opened
627
856
  return out
628
857
  }
@@ -646,10 +875,14 @@ async function openForCommit (client, dir, block, message) {
646
875
 
647
876
  /**
648
877
  * seam pull: a line checkout moves to the line's tip (uncommitted edits stay; a path changed both
649
- * here and there keeps this folder's version and is reported); a square folder rebases the square
650
- * onto its line and moves there.
878
+ * here and there is merged, or left conflicted and reported: syncTo); a square folder rebases the
879
+ * square onto its line and moves there. Refused while the last pull's conflicts are unsettled.
651
880
  */
652
881
  export async function pull (client, dir, block) {
882
+ // As git refuses to pull over unmerged files: the last pull's conflicts are settled first.
883
+ const open = pullConflicts(dir, block)
884
+ refuseMarkersLeft(dir, block, open, 'pull again')
885
+ refuseConflicted(open, 'pull again')
653
886
  if (block.square) {
654
887
  // The square was merged (or abandoned) from elsewhere: the folder goes to its line's tip.
655
888
  const sq = await client.square(block.quilt, block.square)
@@ -657,19 +890,19 @@ export async function pull (client, dir, block) {
657
890
  const short = block.square_short || block.square
658
891
  const tip = (await client.show(block.quilt, sq.line, { format: 'none' })).commit.id
659
892
  const s = await toLineTip(client, dir, block, sq.line, tip)
660
- return { left: { square: short, state: sq.state }, line: sq.line, at: tip, written: s.written, kept: s.kept }
893
+ return { left: { square: short, state: sq.state }, line: sq.line, at: tip, written: s.written, merged: s.merged, conflicted: s.conflicted }
661
894
  }
662
895
  const r = await client.rebase(block.quilt, block.square, { expect_head: block.head })
663
896
  if (r.noop) return { rebased: false, noop: true, square: r.square }
664
897
  const s = await syncTo(client, dir, block, r.square.head || r.square.base, { square: r.square })
665
- return { rebased: true, square: r.square, result: r, written: s.written, kept: s.kept }
898
+ return { rebased: true, square: r.square, result: r, written: s.written, merged: s.merged, conflicted: s.conflicted }
666
899
  }
667
900
  if (!isLineCheckout(block)) throw new Error('This folder holds one revision, not a line: seam checkout <line or square> moves it.')
668
901
  const to = (await client.show(block.quilt, block.line, { format: 'none' })).commit.id
669
902
  const from = block.at
670
903
  if (to === from) return { noop: true, line: block.line, at: to }
671
904
  const s = await syncTo(client, dir, block, to)
672
- return { line: block.line, from, at: to, written: s.written, kept: s.kept }
905
+ return { line: block.line, from, at: to, written: s.written, merged: s.merged, conflicted: s.conflicted }
673
906
  }
674
907
 
675
908
  /** After a merge: the folder leaves the merged square and stands on the line's new tip. */
@@ -684,6 +917,9 @@ export async function toLineTip (client, dir, block, line, tip) {
684
917
  export async function amendFolder (client, dir, block, { message, paths = [], allow = [] }) {
685
918
  if (!block.square) throw new Error('This folder is a read-only checkout of a line; there is no commit of yours to amend.')
686
919
  const changes = localChanges(dir, block, { paths })
920
+ const open = pullConflicts(dir, block).filter((p) => changes.some((c) => c.path === p))
921
+ refuseMarkersLeft(dir, block, changes.filter((c) => c.status !== 'deleted').map((c) => c.path), 'amend')
922
+ refuseConflicted(open, 'amend')
687
923
  const blobs = changes.filter((c) => c.bytes || c.open).map((c) => c.open ? { kind: 'blob', id: c.blob, size: c.size, open: c.open } : { kind: 'blob', id: c.blob, bytes: block.sealed ? block._codec.seal(c.bytes) : c.bytes })
688
924
  if (blobs.length) await client.upload(block.quilt, blobs)
689
925
  const list = changes.map((c) => c.status === 'deleted' ? { path: c.path, op: 'delete' } : { path: c.path, op: 'put', blob: c.blob, mode: c.mode || 'file' })
@@ -696,7 +932,7 @@ export async function amendFolder (client, dir, block, { message, paths = [], al
696
932
  block.at = r.square.head
697
933
  block.head = r.square.head
698
934
  saveBlock(dir, block)
699
- return withLocalScan(block, changes, allow, r)
935
+ return withLocalMarkers(block, changes, withLocalScan(block, changes, allow, r))
700
936
  }
701
937
 
702
938
  /** The folder's changes as a unified diff against `at` (diff with no revision, in a block). */
@@ -2,8 +2,8 @@
2
2
  // "this square is working on these paths" so nobody else spends work on them. A write under a
3
3
  // claimed pattern from another open square on the same line is refused (412 claim_refused) and the
4
4
  // writer is told who holds it and why. A claim lasts while its square is open: merging, abandoning
5
- // or archiving the square lets go of it. Merges never check claims; overlapping work that was
6
- // written before a claim existed still meets at the merge (07).
5
+ // or archiving the square lets go of it. Merges never check claims, and a claim never refuses work
6
+ // written before it existed: that work commits, and overlapping work still meets at the merge (07).
7
7
  import { QuiltError, squareShort } from './errors.js'
8
8
  import { uuidv7 } from './ids.js'
9
9
 
@@ -200,20 +200,54 @@ export async function liveClaims (store, q, at = Date.now(), idleMs = 0) {
200
200
  return out
201
201
  }
202
202
 
203
+ /** Whether live entry or tree entry `e` holds the same change as `ch` (a put of the same blob and mode, or a delete). */
204
+ const sameChange = (e, ch) => ch.op === 'delete' ? !!e && e.op === 'delete' : !!e && e.op === ch.op && e.blob === ch.blob && (e.mode ?? null) === (ch.mode ?? null)
205
+
206
+ /**
207
+ * 06 "Where a claim is checked" (ruling 2026-10-29): whether square `sq`'s change `ch` was written
208
+ * before claim `c` was granted, so the claim does not refuse it. It was when the square's live work
209
+ * already held the same change from before the claim's `at` (each live entry carries the time it was
210
+ * written, `at`; older rows only the row's), or when the square's head already holds it (`held`:
211
+ * resending it is not a new write).
212
+ */
213
+ async function writtenBefore (ch, c, live, held) {
214
+ if (!ch) return false
215
+ const e = (live?.changes || []).find((x) => x.path === ch.path)
216
+ if (sameChange(e, ch) && Number(e.at ?? live.at ?? Infinity) < Number(c.at || 0)) return true
217
+ if (held) {
218
+ const h = await held(ch.path)
219
+ if (ch.op === 'delete' ? !h : (h && h.blob === ch.blob && h.mode === ch.mode)) return true
220
+ }
221
+ return false
222
+ }
223
+
203
224
  /**
204
225
  * Refuses a write to `paths` from square `sq` when another open square on its line claims one.
205
226
  * Given the `writer` (a commit, not live work, which repeats), the refusal is also written to the
206
- * square's log so the writer hears claim.refused (12) wherever it reads events.
227
+ * square's log so the writer hears claim.refused (12) wherever it reads events. Given the write's
228
+ * `changes`, a path whose change was written before the claim existed is not refused (06): the
229
+ * square's live work held it from before the claim's `at`, or (`held(path)`, the head's entry) the
230
+ * head already has it. `live` is the square's live row when the caller has read it already.
207
231
  */
208
- export async function guardClaims (store, q, sq, paths, at = Date.now(), idleMs = 0, writer = null) {
232
+ export async function guardClaims (store, q, sq, paths, at = Date.now(), idleMs = 0, writer = null, { changes = null, held = null, live } = {}) {
209
233
  if (!paths.length) return
210
234
  // A stacked square writes under its parents' claims (06): it builds on their work.
211
235
  const mine = new Set([sq.id])
212
236
  for (let p = sq; p.parent_square && mine.size <= 10;) { mine.add(p.parent_square); p = await store.getSquare(p.parent_square); if (!p) break }
213
237
  const others = (await liveClaims(store, q, at, idleMs)).filter((c) => !mine.has(c.square_id) && c.line === sq.line)
238
+ const byPath = changes ? new Map(changes.map((ch) => [ch.path, ch])) : null
214
239
  for (const c of others) {
215
240
  const m = globMatcher(c.pattern)
216
- const hit = paths.find((p) => m(p))
241
+ let hit = null
242
+ for (const p of paths) {
243
+ if (!m(p)) continue
244
+ if (byPath) {
245
+ if (live === undefined) live = store.getLive ? await store.getLive(q, sq.id) : null
246
+ if (await writtenBefore(byPath.get(p), c, live, held)) continue
247
+ }
248
+ hit = p
249
+ break
250
+ }
217
251
  if (hit) {
218
252
  if (writer) {
219
253
  const pos = queueOf(await store.claimRequests(q), c).findIndex((r) => r.by_square === sq.id)
package/lib/quilts/cli.js CHANGED
@@ -9,8 +9,8 @@ import { quiltClient } from './client.js'
9
9
  import { runChecks, readRunnerConfig, listenForWork } from './runner.js'
10
10
  import { startDaemon, askDaemon } from './daemon.js'
11
11
  import { rebuild as rebuildBlock, conflictStyle } from './block.js'
12
- import { describeRebuild, describeOpened } from './describe.js'
13
- import { findBlock, checkout, status as blockStatus, commitFolder, amendFolder, syncTo, localDiff, saveBlock, pull, toLineTip, restore, reset, clean, localChanges, bytesOf, livePatch } from './block.js'
12
+ import { describeRebuild, describeOpened, describeBoth } from './describe.js'
13
+ import { findBlock, checkout, status as blockStatus, commitFolder, amendFolder, syncTo, localDiff, saveBlock, pull, toLineTip, restore, reset, clean, localChanges, bytesOf, livePatch, resolvePulled } from './block.js'
14
14
  import { describeLog, describeShow, describeDiff, describeBlame, describeStatus, describeMerge, describeWrite, describeSquare, describeSquareStatus, describeEvents, describeTag, describeRelease, describeImport, describeImportRecord, describeRestore, describeReset, describeClean, describeLive, describeClaims, describeHandoff, describeFinished, describeResolutions, describeLiveSet, describeLiveJoin, describeMergeBase, describeStack, describeStackDiff, describeGrep, describeBisect, describeCheck, describeReviews, describeInterdiff, describeReviewed, describeSquareCheck, describePolicy, describeKeys, describeTokens, describeNewToken, describeBlock, describeBlocks, describeProtections, describeTeams, describeAccess, describeRedacted, describeRedactions, describeMetrics, describeSquarePolicy, describePolicyVersions, describeOrg, describeOrgs, describeOrgLog, describeOrgDomain, describeApp, describeApps, describeAppKey, describeInstall, describeInstalls, describeRequest, describeRequests, describeOrgTeams, describeCheckRuns, describeRerun, describeAncestor, describeContains, describePipeline, describeWatches, describeSearch, describePublicReads } from './describe.js'
15
15
  import { verifyQuilt, describeVerify } from './attest.js'
16
16
  import { confirmWithLocalKey } from './linepolicy.js'
@@ -313,10 +313,10 @@ export async function runSeamCommand (cmd, argv, { cwd = process.cwd(), token, o
313
313
  const moved = r.amended.filter((a) => a.paths.length).map((a) => `${a.paths.join(', ')} into ${a.commit_before.slice(0, 12)}`)
314
314
  return print(r, `${moved.length ? `Absorbed ${moved.join('; ')} (${r.amended.length} commit(s) rewritten).` : 'Nothing to absorb.'}${r.left_live ? ` Still live: ${r.left_paths.join(', ')}.` : ''}`)
315
315
  }
316
- // seam square fold [sq] --into sq:<id> | split [sq] <paths,...> <paths,...> | --by claim [--stacked] [--title t]
316
+ // seam square fold [sq] --into sq:<id> [--no-commit-live] | split [sq] <paths,...> <paths,...> | --by claim [--stacked] [--title t]
317
317
  if (sub === 'fold') {
318
318
  if (!flags['--into']) throw new Error('Fold into which square? seam square fold [sq:from] --into sq:<id>')
319
- const r = await client.fold(needQuilt(), pos[1] || needSquare(), { into: flags['--into'], message })
319
+ const r = await client.fold(needQuilt(), pos[1] || needSquare(), { into: flags['--into'], message, ...(flags['--no-commit-live'] ? { commit_live: false } : {}) })
320
320
  return print(r, describeWrite(r, `Folded ${r.folded.short} into ${r.square.short}:`))
321
321
  }
322
322
  if (sub === 'split') {
@@ -326,7 +326,7 @@ export async function runSeamCommand (cmd, argv, { cwd = process.cwd(), token, o
326
326
  if (by === 'paths' && !groups.length) throw new Error('Split by which paths? seam square split [sq] docs,README.md src (one group per new square), or --by claim')
327
327
  const r = await client.split(needQuilt(), own ? pos[1] : needSquare(), { by, ...(by === 'paths' ? { groups } : {}), ...(flags['--stacked'] ? { stacked: true } : {}), ...(flags['--title'] ? { titles: [flags['--title']] } : {}) })
328
328
  const text = [`Kept in ${r.square.short}: ${r.kept.join(', ') || '(nothing)'}`, ...r.squares.map((q) => `New ${describeSquare(q)}: ${q.paths.join(', ')}`)].join('\n')
329
- if (found && block.square === r.square.id) { const st = await syncTo(client, found.dir, block, r.square.head, { square: r.square }); return print(r, `${text}\n${st.written} file(s) updated in the folder.`) }
329
+ if (found && block.square === r.square.id) { const st = await syncTo(client, found.dir, block, r.square.head, { square: r.square }); return print(r, `${text}\n${st.written} file(s) updated in the folder.` + describeBoth(st)) }
330
330
  return print(r, text)
331
331
  }
332
332
  if (sub === 'show') { const sq = await client.square(needQuilt(), pos[1] || needSquare()); return print(sq, describeSquareStatus(sq)) }
@@ -346,7 +346,7 @@ export async function runSeamCommand (cmd, argv, { cwd = process.cwd(), token, o
346
346
  case 'pull': {
347
347
  if (!found) throw new Error('seam pull works in a block folder: run it in a folder made by seam clone or seam checkout.')
348
348
  const r = await pull(client, found.dir, block)
349
- const kept = r.kept?.length ? `\nKept your own version of ${r.kept.join(', ')} (changed on both sides): compare with seam diff.` : ''
349
+ const kept = describeBoth(r)
350
350
  if (r.noop) return print(r, block.square ? `Already up to date: ${block.square_short || block.square} sits on ${block.line}'s tip.` : `Already up to date with ${block.line}.`)
351
351
  if (r.left) return print(r, `${r.left.square} was ${r.left.state}; the folder is now on ${r.line} (${r.at.slice(0, 12)}, ${r.written} file(s) updated).` + kept)
352
352
  if (r.rebased) return print(r, describeWrite(r.result, 'Rebased') + `\n${r.written} file(s) updated in the folder.` + kept)
@@ -943,7 +943,7 @@ export async function runSeamCommand (cmd, argv, { cwd = process.cwd(), token, o
943
943
  const mine = found && (target === block.square_short || target === block.square)
944
944
  const r = await client.mergeSquare(needQuilt(), target, { from, ...(mine ? { expect_head: block.head } : {}), message })
945
945
  let text = r.noop ? `Already up to date: ${from} is in ${target}.` : describeWrite(r, `Merged ${from} into ${target}:`)
946
- if (mine && !r.noop) { const st = await syncTo(client, found.dir, block, r.square.head, { square: r.square }); text += `\n${st.written} file(s) updated in the folder.` }
946
+ if (mine && !r.noop) { const st = await syncTo(client, found.dir, block, r.square.head, { square: r.square }); text += `\n${st.written} file(s) updated in the folder.` + describeBoth(st) }
947
947
  return print(r, text)
948
948
  }
949
949
  const sqId = pos[0] && pos[0].startsWith('sq:') ? pos[0] : needSquare()
@@ -952,26 +952,26 @@ export async function runSeamCommand (cmd, argv, { cwd = process.cwd(), token, o
952
952
  // Merged from its own folder: the folder moves to the line's new tip, ready for the next change.
953
953
  if (found && sqId === block.square && r.ok && r.merged?.commit && !flags['--dry-run']) {
954
954
  const s = await toLineTip(client, found.dir, block, r.merged.line || into, r.merged.commit)
955
- text += `\nThe folder is now on ${r.merged.line || into} (${s.written} file(s) updated); your next commit opens a new square.`
955
+ text += `\nThe folder is now on ${r.merged.line || into} (${s.written} file(s) updated); your next commit opens a new square.` + describeBoth(s)
956
956
  }
957
957
  // The refused merge left the square rebased onto the tip with its conflicts: bring the folder there.
958
958
  if (found && sqId === block.square && !r.ok && r.head && r.head !== block.head) {
959
959
  const s = await syncTo(client, found.dir, block, r.head, { square: r.square })
960
- text += `\nThe folder now holds the rebased files (${s.written} written)${s.kept.length ? `; kept your own version of ${s.kept.join(', ')}` : ''}.`
960
+ text += `\nThe folder now holds the rebased files (${s.written} written).` + describeBoth(s)
961
961
  }
962
962
  return print(r, text)
963
963
  }
964
964
  case 'rebase': {
965
965
  const r = await client.rebase(needQuilt(), needSquare(), { ...(found ? { expect_head: block.head } : {}), onto: flags['--onto'] })
966
966
  let text = r.noop ? 'Already on the tip.' : describeWrite(r, 'Rebased')
967
- if (found && !r.noop) { const s = await syncTo(client, found.dir, block, r.square.head || r.square.base, { square: r.square }); text += `\n${s.written} file(s) updated in the folder.` }
967
+ if (found && !r.noop) { const s = await syncTo(client, found.dir, block, r.square.head || r.square.base, { square: r.square }); text += `\n${s.written} file(s) updated in the folder.` + describeBoth(s) }
968
968
  return print(r, text)
969
969
  }
970
970
  case 'cherry-pick': {
971
971
  if (!pos[0]) throw new Error('seam cherry-pick <revision>')
972
972
  const r = await client.cherryPick(needQuilt(), needSquare(), { from: pos[0], ...(found ? { expect_head: block.head } : {}) })
973
973
  let text = describeWrite(r, 'Picked')
974
- if (found) { const s = await syncTo(client, found.dir, block, r.square.head, { square: r.square }); text += `\n${s.written} file(s) updated in the folder.` }
974
+ if (found) { const s = await syncTo(client, found.dir, block, r.square.head, { square: r.square }); text += `\n${s.written} file(s) updated in the folder.` + describeBoth(s) }
975
975
  return print(r, text)
976
976
  }
977
977
  case 'revert': {
@@ -988,15 +988,20 @@ export async function runSeamCommand (cmd, argv, { cwd = process.cwd(), token, o
988
988
  const rel = (p) => found ? path.relative(found.dir, path.resolve(cwd, p)).split(path.sep).join('/') : p
989
989
  const r = await client.resolve(needQuilt(), needSquare(), { ...(found ? { expect_head: block.head } : {}), entries: pos.map((p) => ({ path: rel(p), rule })), ...(flags['--no-remember'] ? { remember: false } : {}) })
990
990
  let text = describeWrite(r, 'Resolved')
991
- if (found) { const s = await syncTo(client, found.dir, block, r.square.head, { square: r.square }); text += `\n${s.written} file(s) updated in the folder.` }
991
+ if (found) { const s = await syncTo(client, found.dir, block, r.square.head, { square: r.square }); text += `\n${s.written} file(s) updated in the folder.` + describeBoth(s) }
992
992
  return print(r, text)
993
993
  }
994
994
  const choose = flags['--ours'] ? 'ours' : flags['--theirs'] ? 'theirs' : flags['--base'] ? 'base' : flags['--delete'] ? 'delete' : flags['--suggestion'] ? 'suggestion' : flags['--keep-both'] ? 'keep_both' : flags['--side'] != null ? Number(flags['--side']) : null
995
+ // A conflict seam pull left in this folder is settled here, not on the server.
996
+ if (found && block.conflicts && pos.length && (choose === 'ours' || choose === 'theirs')) {
997
+ const r = await resolvePulled(client, found.dir, block, { paths: pos.map((p) => path.resolve(cwd, p)), choose })
998
+ return print(r, `Settled ${r.settled.join(', ')}: ${choose === 'theirs' ? 'took upstream\'s version' : 'kept your version'}. Commit when ready.`)
999
+ }
995
1000
  if (choose === null || !pos.length) throw new Error('seam resolve <path>... --ours | --theirs | --base | --suggestion | --side n | --delete | --keep-both | --rule <step> [--no-remember] (or edit the file and commit it without the markers)')
996
1001
  const rel = (p) => found ? path.relative(found.dir, path.resolve(cwd, p)).split(path.sep).join('/') : p
997
1002
  const r = await client.resolve(needQuilt(), needSquare(), { ...(found ? { expect_head: block.head } : {}), entries: pos.map((p) => ({ path: rel(p), choose })), ...(flags['--no-remember'] ? { remember: false } : {}) })
998
1003
  let text = describeWrite(r, 'Resolved')
999
- if (found) { const s = await syncTo(client, found.dir, block, r.square.head, { square: r.square }); text += `\n${s.written} file(s) updated in the folder.` }
1004
+ if (found) { const s = await syncTo(client, found.dir, block, r.square.head, { square: r.square }); text += `\n${s.written} file(s) updated in the folder.` + describeBoth(s) }
1000
1005
  return print(r, text)
1001
1006
  }
1002
1007
  case 'resolutions': {
@@ -4,9 +4,10 @@
4
4
  // side's change laid onto the first side, `ontoFirst`) only the clashing regions are marked and the
5
5
  // rest of the file is merged text; a deleted side is an empty side labelled "(deleted by <who>)". Also the `git` format (diff3 markers with the `|||||||` base section, two sides
6
6
  // only). Every block's first line carries the term's tag, so a
7
- // file committed with these markers is still conflicted (service.js, block.js).
7
+ // file committed with these markers is still conflicted (service.js, block.js). findMarkers finds
8
+ // markers of any kind (no tag, an older tag, git's), which never settle a conflict (07 ruling 2026-10-29).
8
9
  import { diff3Merge } from 'node-diff3'
9
- import { hunks, splitLines } from './textdiff.js'
10
+ import { hunks, splitLines, isText } from './textdiff.js'
10
11
 
11
12
  /** `old` → `now` as `-`/`+`/` ` lines (every line of both, in order). */
12
13
  export function lineDiff (old, now) {
@@ -264,3 +265,27 @@ export function resolveMarkers (regions, k) {
264
265
  const lines = regions.flatMap((r) => r.ok || r.sides[k].lines)
265
266
  return lines.length ? lines.join('\n') + '\n' : ''
266
267
  }
268
+
269
+ // A conflict marker line: exactly seven `<` or `>` (Seam's and git's opening and closing lines), or
270
+ // git's `|||||||` base line, then a space or the line's end. `=======`, `+++++++` and `%%%%%%%`
271
+ // alone are not markers: a markdown underline or a diff's own lines look like them.
272
+ const MARKER = /^(?:<{7}|>{7}|\|{7})(?: |$)/
273
+ const NEEDLES = ['<<<<<<<', '>>>>>>>', '|||||||'].map((s) => Buffer.from(s))
274
+
275
+ /**
276
+ * The first conflict marker in a file, as `{ line (1-based), text }`, or null. Markers count only
277
+ * at the start of a line (a CR before the newline is ignored); a file that is not text (binary,
278
+ * over 8 MiB) has none. Whatever their tag (the current term's, an older one's, none), markers in
279
+ * a conflicted file mean it is not settled (07 ruling 2026-10-29; service.js, block.js).
280
+ */
281
+ export function findMarkers (bytes) {
282
+ if (!bytes || !bytes.length) return null
283
+ const buf = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes)
284
+ if (!NEEDLES.some((n) => buf.includes(n)) || !isText(buf)) return null
285
+ const lines = buf.toString('utf8').split('\n')
286
+ for (let i = 0; i < lines.length; i++) {
287
+ const l = lines[i].endsWith('\r') ? lines[i].slice(0, -1) : lines[i]
288
+ if (MARKER.test(l)) return { line: i + 1, text: l.length > 80 ? l.slice(0, 80) + '…' : l }
289
+ }
290
+ return null
291
+ }
@@ -103,6 +103,17 @@ export function describeWarnings (warnings = []) {
103
103
  return said.length ? ['Changed under your code (a warning, not a refusal: check it still fits):', ...said] : []
104
104
  }
105
105
 
106
+ /**
107
+ * What a move of a folder (seam pull, a rebase) did with files changed on both sides: merged into
108
+ * the folder's edits, or left conflicted to settle. `diff` and `resolve` name the commands to use.
109
+ */
110
+ export function describeBoth (r, { diff = 'seam diff', resolve = 'seam resolve <path> --ours | --theirs' } = {}) {
111
+ const out = []
112
+ if (r.merged?.length) out.push(`Merged upstream's changes into your edits to ${r.merged.join(', ')}: compare with ${diff}.`)
113
+ if (r.conflicted?.length) out.push(`Conflict in ${r.conflicted.join(', ')} (changed on both sides): settle each by editing out the conflict markers, or with ${resolve}, then commit.`)
114
+ return out.map((l) => '\n' + l).join('')
115
+ }
116
+
106
117
  export function describeStatus (st) {
107
118
  const lines = []
108
119
  if (st.square) lines.push(describeSquareStatus(st.square, { prefix: 'On square ' }))
@@ -129,7 +140,10 @@ export function describeWrite (r, verb = 'Done') {
129
140
  if (r.conflicted) bits.push(`${r.conflicted} conflicted path${r.conflicted === 1 ? '' : 's'}: ${(r.terms || []).map((t) => `${t.path} (${t.kind})`).join(', ')}`)
130
141
  const reused = (r.combined || []).filter((c) => c.how === 'reuse')
131
142
  if (reused.length) bits.push(`Settled the way it was settled before: ${reused.map((c) => `${c.path} (by ${c.by})`).join(', ')}`)
132
- for (const w of r.warnings || []) bits.push(`Warning: ${w.path}${w.line ? `:${w.line}` : ''} looks like a secret (${w.rule}, ${w.id})${w.local ? ', found on this computer' : ''}`)
143
+ for (const w of r.warnings || []) {
144
+ if (w.kind === 'markers_in_file') bits.push(`Warning: ${w.path}:${w.line} holds conflict markers; it was committed as written${w.local ? ' (found on this computer)' : ''}`)
145
+ else bits.push(`Warning: ${w.path}${w.line ? `:${w.line}` : ''} looks like a secret (${w.rule}, ${w.id})${w.local ? ', found on this computer' : ''}`)
146
+ }
133
147
  return bits.join('\n')
134
148
  }
135
149
 
@@ -613,5 +627,5 @@ export function describePublicReads (r) {
613
627
  /** What block.rebuild did (05 "Rebuilt, never repaired"). */
614
628
  export function describeRebuild (r) {
615
629
  if (!r.finished) return `Nothing to rebuild: the folder is whole${r.changes ? ` (${r.changes} uncommitted change(s), all yours)` : ''}.`
616
- return `Finished a move that stopped part way: ${r.written.length} file(s) written, ${r.removed.length} removed${r.kept.length ? `; kept your edits to ${r.kept.join(', ')}` : ''}. The folder is on ${String(r.to).slice(0, 12)}.`
630
+ return `Finished a move that stopped part way: ${r.written.length} file(s) written, ${r.removed.length} removed${r.kept.length ? `; kept your edits to ${r.kept.join(', ')}` : ''}. The folder is on ${String(r.to).slice(0, 12)}.` + describeBoth({ conflicted: r.conflicted })
617
631
  }
@@ -0,0 +1,43 @@
1
+ // A fast front for merge-smart.js's mergeSmart (whose node-diff3 LCS is quadratic: seconds on a
2
+ // 7,000-line file). A clean line merge comes from the Myers hunks in textdiff.js (`nway`, the
3
+ // same N-way merge the conflict format uses); only a clash goes to mergeSmart's ladder, and then
4
+ // with the lines all three versions share at the start and end cut off, so it reads only the
5
+ // region that differs. Same answer, same `how`, as mergeSmart for a clean merge; a clash is
6
+ // mergeSmart's own answer on the middle, put back between the shared lines.
7
+ import { mergeSmart as mergeSmartWith, STEPS } from './merge-smart.js'
8
+ import { nway } from './conflict-format.js'
9
+
10
+ /** `opts.allow`: the highest ladder step that may settle a clash (07's auto_combine); none: every step. */
11
+ export function fastMerge (base, ours, theirs, opts = {}) {
12
+ const [o, a, b] = [base.split('\n'), ours.split('\n'), theirs.split('\n')]
13
+ const regions = nway(o, [a, b])
14
+ if (regions.every((r) => r.ok)) return { text: regions.flatMap((r) => r.ok).join('\n'), conflicts: [], how: 'lines' }
15
+ const mergeSmart = (x, y, z) => mergeSmartWith(x, y, z, opts)
16
+ // Every clash is two inserts at one place the base had nothing (07's `additive` step): ours
17
+ // then theirs, shared leading lines once. Shared trailing lines are kept on both sides, not
18
+ // once as 07 says: two added functions share their closing `}`, and taking it once dropped
19
+ // one function's closing brace (found by real agents adding functions side by side).
20
+ if ((opts.allow == null || STEPS[opts.allow] >= STEPS.additive) && regions.every((r) => r.ok || (!r.base.length && r.sides.length === 2))) {
21
+ return { text: regions.flatMap((r) => r.ok || additive(r.sides[0], r.sides[1])).join('\n'), conflicts: [], how: 'additive' }
22
+ }
23
+ // Shared lines at both ends, leaving at least one line in each middle.
24
+ const min = Math.min(o.length, a.length, b.length) - 1
25
+ let p = 0
26
+ while (p < min && o[p] === a[p] && o[p] === b[p]) p++
27
+ let s = 0
28
+ while (s < min - p && o[o.length - 1 - s] === a[a.length - 1 - s] && o[o.length - 1 - s] === b[b.length - 1 - s]) s++
29
+ if (!p && !s) return mergeSmart(base, ours, theirs)
30
+ const mid = (x) => x.slice(p, x.length - s).join('\n')
31
+ const m = mergeSmart(mid(o), mid(a), mid(b))
32
+ if (m.text === '') return mergeSmart(base, ours, theirs) // zero lines or one empty line: ambiguous
33
+ return { ...m, text: [...o.slice(0, p), m.text, ...o.slice(o.length - s)].join('\n') }
34
+ }
35
+
36
+ /** Two inserts at one place: ours, then what theirs adds after the leading lines both share. */
37
+ function additive (a, b) {
38
+ let pre = 0
39
+ while (pre < a.length && pre < b.length && a[pre] === b[pre]) pre++
40
+ if (pre === b.length) return a
41
+ if (pre === a.length) return b
42
+ return [...a, ...b.slice(pre)]
43
+ }
@@ -0,0 +1,125 @@
1
+ // The merge ladder seam settles a clash with (plans/quilt-source-control/07, steps 1 and 2): line
2
+ // by line as git does, then two inserts at one place, a one-line import both sides changed,
3
+ // whitespace-only changes, and word by word within the clashing lines. Seam's own copy, so the
4
+ // Quilt app's merges can change without changing how a quilt merges.
5
+ import { diff3Merge } from 'node-diff3'
6
+
7
+ const lines = (s) => s.split('\n')
8
+
9
+ /**
10
+ * A text's line endings: 'crlf' (every line ends \r\n), 'lf' (none does), 'mixed', or null (one line:
11
+ * nothing to tell).
12
+ */
13
+ function eolOf (s) {
14
+ if (typeof s !== 'string' || !s.includes('\n')) return null
15
+ if (!s.includes('\r\n')) return 'lf'
16
+ return s.replace(/\r\n/g, '').includes('\n') ? 'mixed' : 'crlf'
17
+ }
18
+
19
+ /**
20
+ * The merges compare lines, so a side saved with Windows line endings (\r\n) against a base and other
21
+ * side with \n differs on every line: diff3 then reads it as "ours deleted the whole file and inserted
22
+ * it again", the delete loses to theirs' edits and the insert stays, and the file comes out as both
23
+ * versions one after the other (task 7b480c8b07c4fa02: web/lib/docs.js, CRLF old + LF new, every
24
+ * export twice). When the sides' line endings differ, all three are merged with \n and the result is
25
+ * written in the line endings of the side that changed them from the base (ours when both did, or
26
+ * when there is no base to tell), as any other edit to the file would be kept.
27
+ */
28
+ function eolAware (merge) {
29
+ return function (base, ours, theirs, ...rest) {
30
+ const eols = [base, ours, theirs].map(eolOf)
31
+ const seen = new Set(eols.filter(Boolean))
32
+ if (!seen.has('crlf') || seen.size < 2) return merge(base, ours, theirs, ...rest)
33
+ const [eb, eo, et] = eols
34
+ const target = (eo && eo !== eb) ? eo : (et && et !== eb) ? et : (eo || et || eb)
35
+ const lf = (s) => typeof s === 'string' ? s.replace(/\r\n/g, '\n') : s
36
+ const back = (t) => target === 'crlf' && typeof t === 'string' ? t.replace(/\n/g, '\r\n') : t
37
+ const r = merge(lf(base), lf(ours), lf(theirs), ...rest)
38
+ return typeof r === 'string' ? back(r) : { ...r, text: back(r.text) }
39
+ }
40
+ }
41
+
42
+ /** Two blocks inserted at one place, as one: shared leading and trailing lines once, the middles in order. */
43
+ function addedBoth (a, b) {
44
+ let pre = 0
45
+ while (pre < a.length && pre < b.length && a[pre] === b[pre]) pre++
46
+ let post = 0
47
+ while (post < a.length - pre && post < b.length - pre && a[a.length - 1 - post] === b[b.length - 1 - post]) post++
48
+ return [...a.slice(0, pre), ...a.slice(pre, a.length - post), ...b.slice(pre, b.length - post), ...a.slice(a.length - post)]
49
+ }
50
+
51
+ const IMPORT = /^(\s*import\s*\{)([^}]*)(\}\s*from\s*(['"][^'"]+['"]).*)$/
52
+
53
+ /** Both sides changed the same one-line import of the same module: the union of its names, or null. */
54
+ function sameImport (o, a, b) {
55
+ if (o.length !== 1 || a.length !== 1 || b.length !== 1) return null
56
+ const [mo, ma, mb] = [IMPORT.exec(o[0]), IMPORT.exec(a[0]), IMPORT.exec(b[0])]
57
+ if (!mo || !ma || !mb || mo[4] !== ma[4] || ma[4] !== mb[4]) return null
58
+ const names = (m) => m[2].split(',').map((s) => s.trim()).filter(Boolean)
59
+ const [no, na, nb] = [names(mo), names(ma), names(mb)]
60
+ const gone = new Set(no.filter((n) => !na.includes(n) || !nb.includes(n)))
61
+ const all = [...na, ...nb.filter((n) => !na.includes(n))].filter((n) => !gone.has(n))
62
+ return [`${ma[1]} ${all.join(', ')} ${ma[3]}`]
63
+ }
64
+
65
+ const normWs = (l) => l.replace(/\s+/g, ' ').trim()
66
+ const sameIgnoringWs = (a, b) => a.length === b.length && a.every((l, i) => normWs(l) === normWs(b[i]))
67
+ const TOKEN = /\s+|\w+|[^\w\s]/g
68
+
69
+ /** Two blocks of lines merged word by word against their base, or null when the same words changed on both sides. */
70
+ function mergeWords (o, a, b) {
71
+ const tok = (ls) => ls.join('\n').match(TOKEN) || []
72
+ const regions = diff3Merge(tok(a), tok(o), tok(b), { excludeFalseConflicts: true })
73
+ if (regions.some((r) => !r.ok)) return null
74
+ return regions.map((r) => r.ok.join('')).join('').split('\n')
75
+ }
76
+
77
+ /**
78
+ * The clash ladder's mechanical and structural steps (plan §7.3, steps 1 and 2): mergeAdditive, then
79
+ * each region still clashing is tried again ignoring whitespace (a side that only reindented loses
80
+ * to the side that changed something), then word by word within the clashing lines (two edits to
81
+ * different words of one line both land); a file marked merge=union takes both sides, ours first.
82
+ * Returns { text, conflicts, how } with `how` the step that settled it: 'lines', 'whitespace',
83
+ * 'words' or 'union'; `conflicts` lists what no step could settle (text then holds ours there).
84
+ */
85
+ export const mergeSmart = eolAware(function mergeSmart (base, ours, theirs, { union = false, allow = null } = {}) {
86
+ const regions = diff3Merge(lines(ours), lines(base), lines(theirs), { excludeFalseConflicts: true })
87
+ const out = []
88
+ const conflicts = []
89
+ let how = 'lines'
90
+ const rank = { lines: 0, additive: 0.5, whitespace: 1, words: 2, union: 3 }
91
+ const step = (h) => { if (rank[h] > rank[how]) how = h }
92
+ for (const r of regions) {
93
+ if (r.ok) { out.push(...r.ok); continue }
94
+ const c = r.conflict
95
+ const settled = ladder(c, { union, allow })
96
+ if (settled) { out.push(...settled.lines); step(settled.how); continue }
97
+ conflicts.push({ base: c.o, ours: c.a, theirs: c.b })
98
+ out.push(...c.a)
99
+ }
100
+ return { text: out.join('\n'), conflicts, how }
101
+ })
102
+
103
+ // The ladder's steps in order (07's auto_combine): `allow` names the highest that may settle a
104
+ // region (words always runs; a word merge is only ever a suggestion to the caller).
105
+ export const STEPS = { lines: 0, additive: 1, whitespace: 2, words: 3 }
106
+
107
+ /**
108
+ * mergeSmart's steps for one region both sides changed ({ o, a, b }): { lines, how }, or null when
109
+ * none settles it. Without `allow` every step runs and two inserts are reported as 'lines', as the
110
+ * Quilt app's ladder does; with it, steps above it are skipped and they are reported as 'additive'.
111
+ */
112
+ function ladder (c, { union = false, allow = null } = {}) {
113
+ if (union) return { lines: [...c.a, ...c.b], how: 'union' }
114
+ const may = (s) => allow == null || STEPS[s] <= STEPS[allow]
115
+ const both = may('additive') ? (c.o.length === 0 ? addedBoth(c.a, c.b) : sameImport(c.o, c.a, c.b)) : null
116
+ if (both) return { lines: both, how: allow == null ? 'lines' : 'additive' }
117
+ if (may('whitespace')) {
118
+ if (sameIgnoringWs(c.a, c.b)) return { lines: c.a, how: 'whitespace' } // the same change, spaced differently
119
+ if (sameIgnoringWs(c.a, c.o)) return { lines: c.b, how: 'whitespace' } // ours only reindented
120
+ if (sameIgnoringWs(c.b, c.o)) return { lines: c.a, how: 'whitespace' } // theirs only reindented
121
+ }
122
+ const words = mergeWords(c.o, c.a, c.b)
123
+ if (words) return { lines: words, how: 'words' }
124
+ return null
125
+ }
@@ -16,8 +16,8 @@ import { eventLine, describeLog, describeShow, describeDiff, describeBlame, desc
16
16
  import { signWithLocalKey, localKey, makeLocalKey, signingKeyFile } from './signing.js'
17
17
  import { startDaemon, askDaemon } from './daemon.js'
18
18
  import { rebuild as rebuildBlock, conflictStyle } from './block.js'
19
- import { describeRebuild, describeOpened } from './describe.js'
20
- import { status as blockStatus, commitFolder, amendFolder, syncTo, pull, toLineTip, checkout, restore, reset, clean, localChanges, bytesOf, livePatch } from './block.js'
19
+ import { describeRebuild, describeOpened, describeBoth } from './describe.js'
20
+ import { status as blockStatus, commitFolder, amendFolder, syncTo, pull, toLineTip, checkout, restore, reset, clean, localChanges, bytesOf, livePatch, resolvePulled } from './block.js'
21
21
 
22
22
  export const SEAM_TOOLS = ['seam_quilts', 'seam_open_square', 'seam_squares', 'seam_log', 'seam_show', 'seam_diff', 'seam_blame', 'seam_cat', 'seam_ls', 'seam_commit', 'seam_merge', 'seam_rebase', 'seam_revert', 'seam_cherry_pick', 'seam_resolve', 'seam_square_status', 'seam_square_handoff', 'seam_abandon', 'seam_square_merged', 'seam_reopen', 'seam_fold', 'seam_split', 'seam_events', 'seam_events_webhook', 'seam_line_list', 'seam_line_new', 'seam_line_freeze', 'seam_line_unfreeze', 'seam_line_require', 'seam_reviews', 'seam_review', 'seam_tag_list', 'seam_tag', 'seam_release_list', 'seam_release', 'seam_import_git', 'seam_import_remote', 'seam_github', 'seam_github_import', 'seam_import_status', 'seam_import_authors', 'seam_import_poke', 'seam_import_cutover', 'seam_export_remote', 'seam_export_status', 'seam_restore', 'seam_reset', 'seam_clean', 'seam_live', 'seam_live_join', 'seam_live_set', 'seam_bisect', 'seam_grep', 'seam_search', 'seam_describe', 'seam_merge_base', 'seam_stack', 'seam_migrations_dir', 'seam_claims', 'seam_claim', 'seam_unclaim', 'seam_check', 'seam_resolutions', 'seam_forget_resolution', 'seam_sign', 'seam_keys', 'seam_key_add', 'seam_key_remove', 'seam_token_list', 'seam_token_new', 'seam_token_revoke', 'seam_protections', 'seam_protections_set', 'seam_team_set', 'seam_verify', 'seam_redact', 'seam_redactions', 'seam_seal', 'seam_secret_scanning', 'seam_public', 'seam_sealed', 'seam_policy', 'seam_line_policy_versions', 'seam_orgs', 'seam_org_log', 'seam_org_domain', 'seam_apps', 'seam_app', 'seam_app_key', 'seam_app_manager', 'seam_app_install', 'seam_app_request', 'seam_org_member', 'seam_org_team', 'seam_agent_token_new', 'seam_agent_token_list', 'seam_agent_token_revoke', 'seam_agents_make_lines', 'seam_checks', 'seam_check_rerun', 'seam_reparent', 'seam_archive', 'seam_unarchive', 'seam_hide', 'seam_unhide', 'seam_square_edit', 'seam_share', 'seam_unshare', 'seam_export_tarball', 'seam_claim_request', 'seam_claim_withdraw', 'seam_claim_handoff', 'seam_claim_finish', 'seam_blocks', 'seam_block_open', 'seam_block_touch', 'seam_block_close', 'seam_ancestor', 'seam_contains', 'seam_pipeline', 'seam_watch', 'seam_unwatch', 'seam_absorb', 'seam_interdiff', 'seam_apply_suggestion', 'seam_infra_paths', 'seam_owner_idle_hours', 'seam_public_reads', 'seam_export_archive', 'seam_metrics', 'seam_daemon', 'seam_block_rebuild', 'seam_clone', 'seam_pull', 'seam_member_add']
23
23
 
@@ -84,7 +84,7 @@ export const SEAM_DESCRIPTIONS = {
84
84
  seam_block_touch: 'The heartbeat of an open block: keeps it open (blocks untouched for seven days are closed) and moves a live block to the square\'s current head; add and remove change its folders.',
85
85
  seam_clone: 'Checks out a line of a quilt (main by default) into a new folder on this computer, ready to work: the first commit there opens a square by itself. The folder is named after the quilt unless dir is given.',
86
86
  seam_member_add: 'Adds a member to the quilt or changes their level (an admin): email (a person; one with no Seam account yet is invited, and is in the quilt from their first sign-in) or member_id (person:… or agent:…), level read, write or admin (default write; remove takes them out).',
87
- seam_pull: 'In a block folder: brings the line\'s new patches in (uncommitted edits stay); on a square, rebases it onto its line\'s tip; on a square merged from elsewhere, moves the folder to the line\'s tip.',
87
+ seam_pull: 'In a block folder: brings the line\'s new patches in (uncommitted edits stay; a file changed on both sides is merged, or given conflict markers to settle before it is committed); on a square, rebases it onto its line\'s tip; on a square merged from elsewhere, moves the folder to the line\'s tip.',
88
88
  seam_block_rebuild: 'In a block folder: finishes a move of the folder (rebase, checkout of its square) that stopped part way, from the journal in .seam; edits made meanwhile are kept as yours. On a whole folder it writes nothing.',
89
89
  seam_block_close: 'Closes a block (its row on the server). The square, its work and any files on disk are untouched.',
90
90
  seam_token_list: 'The quilt\'s tokens (keys for one quilt, for CI and scripts): id, name, read or write, and whether revoked. Admins only.',
@@ -138,7 +138,7 @@ export const SEAM_DESCRIPTIONS = {
138
138
  seam_events: 'What happened to your work since you last looked: patch.merged (a patch changed files your open square changed, and whether your merge would now conflict), conflict.added (a conflict was kept in your square), square.handoff, square.restacked (your stacked square was moved onto its parent\'s new head or the line, with how many conflicts it now holds), check.requested (you run a check a line names you for: run it on the commit and report with seam_check and its run_key). Pass after (the cursor from the last answer) to get only new ones. Pass wait (seconds, up to 30) to wait for the next one instead of answering at once.',
139
139
  seam_events_webhook: 'Has the quilt server POST your events to a URL (signed with HMAC-SHA256 over <timestamp>.<body>, retried 3 times), so you are woken instead of polling: url, events (default all), secret (made for you if left out). url: "off" removes them all. add: true keeps your other webhooks (up to 5); remove: true removes the one at url.',
140
140
  seam_absorb: 'Puts your square\'s uncommitted live work into the commit of the square that last changed each file (amending it and rewriting the commits above it), so a fix to commit 2 of 5 needs no manual rebase. Files no commit of the square changed stay live.',
141
- seam_fold: 'Folds a square (square) into another square you hold (into): its work becomes one commit there and the square closes. Overlapping changes become conflict markers in the target.',
141
+ seam_fold: 'Folds a square (square) into another square you hold (into): its work becomes one commit there and the square closes. Live work it shared (seam_live_set) is committed on it first and goes along (commit_live: false refuses with live_work instead). Overlapping changes become conflict markers in the target.',
142
142
  seam_split: 'Splits your square: by paths (groups: [[paths], ...]) or by claim (one group per claim on the square that covers a changed path; the claim and its holder go with it). Each group moves to a new square (same base) as one commit; the largest group, and paths no group names, stay in this square. stacked: true chains the new squares on this one in order instead of opening them side by side.',
143
143
  seam_log: 'History, newest first: rev (default main, or your square, or a..b), paths to limit it, limit (default 20). follow: true with one path follows the file through its renames; search: "<text>" keeps only commits that changed how many times the text appears (git log -S); match: "<regex>" keeps commits whose added or removed lines match (git log -G). message (with message_regex, ignore_case) filters on the commit message (git log --grep); author (member id or part of a name), on_behalf_of, task, since and until (an ISO date, or 2h, 3d, 1w) filter on who, what for and when. group_by (author, on_behalf_of, task, tool) gives counts with lines added and removed instead of commits; graph: true names the square each merge brought in; include_hidden: true shows a square\'s commits replaced by an amend. When there is more, pass the answer\'s cursor for the next page. Each patch on main is one line\'s merge. With one path on a line, existed says whether it was ever there and, if it is gone now, last_present is the last patch it was there in.',
144
144
  seam_describe: 'Names a revision (rev, default main) by the nearest tag behind it: v1.0 on the tag itself, v1.0-3-<commit> three commits past it, the commit alone when no tag is behind it.',
@@ -165,7 +165,7 @@ export const SEAM_DESCRIPTIONS = {
165
165
  seam_rebase: 'Moves your square onto the tip of its line (or onto a revision). Conflicts become conflict markers in the square, never a stop.',
166
166
  seam_revert: 'Undoes a patch (or commit): a new square from the tip whose one commit inverts it. Merge that square with seam_merge.',
167
167
  seam_cherry_pick: 'Applies one commit\'s change (from) to your square as a new commit.',
168
- seam_resolve: 'Settles conflicted files in your square by choosing a side: entries [{ path, choose: ours | theirs | base | suggestion | delete | keep_both }]; suggestion takes the word-level merge a conflict offers (its suggestion, shown when the merge is refused); when both sides renamed one file, ours or theirs keeps that side\'s path and keep_both keeps both. Or rule: additive (both inserts, in order), whitespace (the side that changed more than spacing), words (word by word) or union (both sides, ours first). (Or edit the file and commit it without the markers.) The answer is remembered: the same conflict met again in a rebase or merge is settled the same way, unless remember: false.',
168
+ seam_resolve: 'Settles conflicted files in your square by choosing a side: entries [{ path, choose: ours | theirs | base | suggestion | delete | keep_both }]; suggestion takes the word-level merge a conflict offers (its suggestion, shown when the merge is refused); when both sides renamed one file, ours or theirs keeps that side\'s path and keep_both keeps both. Or rule: additive (both inserts, in order), whitespace (the side that changed more than spacing), words (word by word) or union (both sides, ours first). (Or edit the file and commit it without the markers.) In a block folder, choose ours or theirs also settles a conflict seam_pull left there. The answer is remembered: the same conflict met again in a rebase or merge is settled the same way, unless remember: false.',
169
169
  seam_resolutions: 'The remembered resolutions: conflicts settled once that seam settles the same way when they come back, per file, and per clashing region within the same squares (path: only that file\'s; hunks: true lists the regions too). One unused for 90 days is dropped.',
170
170
  seam_forget_resolution: 'Forgets a remembered resolution (key: its key or first 8+ characters), so that conflict is asked about again next time.'
171
171
  }
@@ -304,7 +304,7 @@ export const SEAM_INPUTS = {
304
304
  seam_events: { quilt: QUILT, after: z.number().int().min(0).optional(), wait: z.number().int().min(0).max(30).optional(), kinds: z.array(z.enum(['patch.merged', 'patch.reverted', 'conflict.added', 'square.handoff', 'square.restacked', 'check.requested', 'check.cancelled', 'check.culprit', 'check.verdict', 'watch.triggered', 'policy.needed', 'review.comment', 'review.decision', 'review.requested', 'review.stale', 'claim.granted', 'claim.refused', 'claim.expired', 'claim.handoff', 'export.state'])).optional() },
305
305
  seam_events_webhook: { quilt: QUILT, url: z.string().max(2000), events: z.array(z.enum(['patch.merged', 'patch.reverted', 'conflict.added', 'square.handoff', 'square.restacked', 'check.requested', 'check.cancelled', 'check.culprit', 'check.verdict', 'watch.triggered', 'policy.needed', 'review.comment', 'review.decision', 'review.requested', 'review.stale', 'claim.granted', 'claim.refused', 'claim.expired', 'claim.handoff', 'export.state'])).optional(), secret: z.string().min(16).max(200).optional(), add: z.boolean().optional(), remove: z.boolean().optional() },
306
306
  seam_absorb: { quilt: QUILT, square: SQUARE },
307
- seam_fold: { quilt: QUILT, square: SQUARE, into: z.string().max(100).describe('The square (sq:…) that takes the work'), message: z.string().max(65536).optional() },
307
+ seam_fold: { quilt: QUILT, square: SQUARE, into: z.string().max(100).describe('The square (sq:…) that takes the work'), message: z.string().max(65536).optional(), commit_live: z.boolean().optional().describe('Default true: the square\'s shared live work is committed on it first and folded with it; false refuses (live_work) while it has any') },
308
308
  seam_split: { quilt: QUILT, square: SQUARE, by: z.enum(['paths', 'claim']).optional().describe('paths (the default) or claim'), groups: z.array(z.array(z.string().max(1000)).min(1).max(2000)).min(1).max(20).optional().describe('Paths (files or directories) per new square, for by: paths'), titles: z.array(z.string().max(200)).max(20).optional(), stacked: z.boolean().optional().describe('Chain the new squares on this one in order') },
309
309
  seam_merge: { quilt: QUILT, square: SQUARE, from: z.string().max(200).optional().describe('Merge this square (sq:…) or revision into your square instead of merging your square into a line'), into: z.string().max(100).optional().describe('The line, default main'), message: z.string().max(65536).optional(), dry_run: z.boolean().optional(), on_conflict: z.enum(['rebase', 'leave']).optional(), commit_live: z.boolean().optional().describe('Default true: your square\'s shared live work (seam_live_set) is committed first and merged with it; false merges the committed head as it stands') },
310
310
  seam_rebase: { quilt: QUILT, square: SQUARE, onto: REV },
@@ -327,7 +327,7 @@ export async function runSeamTool (name, args, { client, found = null, hosted =
327
327
  const needQuilt = () => { if (!quilt) throw new Error('Name the quilt (quilt: its slug or id); seam_quilts lists yours.'); return quilt }
328
328
  const needSquare = () => { if (!square) throw new Error('Name the square (square: sq:…); seam_open_square opens one, seam_squares lists them.'); return square }
329
329
  const inFolder = found && block && square === block.square && quilt === block.quilt
330
- const sync = async (head, sq) => { if (inFolder && head && head !== block.head) { const s = await syncTo(client, found.dir, block, head, { square: sq }); return `\nThe folder now holds the square's files (${s.written} updated).` } return '' }
330
+ const sync = async (head, sq) => { if (inFolder && head && head !== block.head) { const s = await syncTo(client, found.dir, block, head, { square: sq }); return `\nThe folder now holds the square's files (${s.written} updated).` + describeBoth(s, { diff: 'seam_diff', resolve: 'seam_resolve (choose ours or theirs)' }) } return '' }
331
331
  try {
332
332
  switch (name) {
333
333
  case 'seam_line_require': {
@@ -503,7 +503,7 @@ export async function runSeamTool (name, args, { client, found = null, hosted =
503
503
  return `${moved.length ? `Absorbed ${moved.join('; ')} (${r.amended.length} commit(s) rewritten).` : 'Nothing to absorb.'}${r.left_live ? ` Still live: ${r.left_paths.join(', ')}.` : ''}` + (r.amended.length ? await sync(r.square.head, r.square) : '')
504
504
  }
505
505
  case 'seam_fold': {
506
- const r = await client.fold(needQuilt(), needSquare(), { into: args.into, message: args.message, ...(inFolder ? { expect_head: block.head } : {}) })
506
+ const r = await client.fold(needQuilt(), needSquare(), { into: args.into, message: args.message, ...(args.commit_live === false ? { commit_live: false } : {}), ...(inFolder ? { expect_head: block.head } : {}) })
507
507
  return describeWrite(r, `Folded ${r.folded.short} into ${r.square.short}:`)
508
508
  }
509
509
  case 'seam_split': {
@@ -592,7 +592,7 @@ export async function runSeamTool (name, args, { client, found = null, hosted =
592
592
  case 'seam_pull': {
593
593
  if (!found) throw new Error('seam_pull works on a block folder: run seam mcp in a folder made by seam clone or seam checkout.')
594
594
  const r = await pull(client, found.dir, block)
595
- const kept = r.kept?.length ? `\nKept your own version of ${r.kept.join(', ')} (changed on both sides): compare with seam_diff.` : ''
595
+ const kept = describeBoth(r, { diff: 'seam_diff', resolve: 'seam_resolve (choose ours or theirs)' })
596
596
  if (r.noop) return block.square ? `Already up to date: ${block.square_short || block.square} sits on ${block.line}'s tip.` : `Already up to date with ${block.line}.`
597
597
  if (r.left) return `${r.left.square} was ${r.left.state}; the folder is now on ${r.line} (${r.at.slice(0, 12)}, ${r.written} file(s) updated).` + kept
598
598
  if (r.rebased) return describeWrite(r.result, 'Rebased') + `\n${r.written} file(s) updated in the folder.` + kept
@@ -695,7 +695,7 @@ export async function runSeamTool (name, args, { client, found = null, hosted =
695
695
  const r = await client.merge(needQuilt(), args.into || block?.line || 'main', { square: needSquare(), ...(inFolder ? { expect_head: block.head } : {}), message: args.message, dry_run: !!args.dry_run, on_conflict: args.on_conflict, ...(args.commit_live === false ? { commit_live: false } : {}) })
696
696
  if (r.ok && inFolder && !args.dry_run && r.merged?.commit) {
697
697
  const st = await toLineTip(client, found.dir, block, r.merged.line, r.merged.commit)
698
- return describeMerge(r) + `\nThe folder is now on ${r.merged.line} (${st.written} file(s) updated); the next commit opens a new square.`
698
+ return describeMerge(r) + `\nThe folder is now on ${r.merged.line} (${st.written} file(s) updated); the next commit opens a new square.` + describeBoth(st, { diff: 'seam_diff', resolve: 'seam_resolve (choose ours or theirs)' })
699
699
  }
700
700
  return describeMerge(r) + (!r.ok && r.head ? await sync(r.head, r.square) : '')
701
701
  }
@@ -716,6 +716,13 @@ export async function runSeamTool (name, args, { client, found = null, hosted =
716
716
  return describeWrite(r, 'Picked') + await sync(r.square.head, r.square)
717
717
  }
718
718
  case 'seam_resolve': {
719
+ // A conflict seam pull left in this folder is settled there, not on the server.
720
+ const choices = new Set(args.entries.map((e) => e.choose))
721
+ if (found && block?.conflicts && choices.size === 1 && (choices.has('ours') || choices.has('theirs')) && args.entries.every((e) => block.conflicts[e.path])) {
722
+ const choose = [...choices][0]
723
+ const r = await resolvePulled(client, found.dir, block, { paths: args.entries.map((e) => path.join(found.dir, e.path)), choose })
724
+ return `Settled ${r.settled.join(', ')}: ${choose === 'theirs' ? 'took upstream\'s version' : 'kept your version'}. Commit when ready.`
725
+ }
719
726
  const r = await client.resolve(needQuilt(), needSquare(), { ...(inFolder ? { expect_head: block.head } : {}), entries: args.entries, ...(args.remember === false ? { remember: false } : {}) })
720
727
  return describeWrite(r, 'Resolved') + await sync(r.square.head, r.square)
721
728
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heyquilt/seam",
3
- "version": "0.141.0",
3
+ "version": "0.143.0",
4
4
  "description": "seam: Seam's command. Git's verbs for source control that runs on the server, built for many agents and people at once.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",