@heyquilt/seam 0.142.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 (;;) {
@@ -18,7 +18,7 @@ import { TreeReader, decodeNode, EMPTY_TREE } from './tree.js'
18
18
  import { globMatcher } from './claims.js'
19
19
  import { theCas } from './cas.js'
20
20
  import { fastMerge } from './fastmerge.js'
21
- import { diffMarkers, gitMarkers } from './conflict-format.js'
21
+ import { diffMarkers, gitMarkers, findMarkers } from './conflict-format.js'
22
22
 
23
23
  /** How much of a large file is read at a time (10, streaming the cut). */
24
24
  export const READ_PIECE = 1024 * 1024
@@ -590,7 +590,8 @@ function settleRecord (block, m) {
590
590
  /**
591
591
  * The files a move left conflicted (planMerges) that are not settled yet, sorted. A file is settled
592
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).
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).
594
595
  */
595
596
  export function pullConflicts (dir, block) {
596
597
  const c = block.conflicts
@@ -600,7 +601,7 @@ export function pullConflicts (dir, block) {
600
601
  let dropped = false
601
602
  for (const [p, x] of Object.entries(c)) {
602
603
  let still = false
603
- 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}`))) }
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)) }
604
605
  if (!still && x.kind === 'whole') { changed ??= new Set(localChanges(dir, block).map((ch) => ch.path)); still = changed.has(p) }
605
606
  if (still) { open.push(p); continue }
606
607
  for (const t of x.tags || []) fs.rmSync(savedOurs(dir, t), { force: true })
@@ -612,6 +613,34 @@ export function pullConflicts (dir, block) {
612
613
  return open.sort(cmpPath)
613
614
  }
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
+
615
644
  /** Refuses to commit (or move) while `paths` hold a conflict a move left. */
616
645
  function refuseConflicted (open, verb) {
617
646
  if (!open.length) return
@@ -782,7 +811,9 @@ export async function commitFolder (client, dir, block, { message, paths = [], a
782
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.')
783
812
  const changes = localChanges(dir, block, { paths })
784
813
  if (!changes.length) return { nothing: true }
785
- refuseConflicted(pullConflicts(dir, block).filter((p) => changes.some((c) => c.path === p)), 'commit')
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')
786
817
  // A clone (a checkout of a line): the first commit opens a square by itself, titled by the
787
818
  // message and based on the commit the folder holds, so it undoes nothing main gained since.
788
819
  const opened = block.square ? null : await openForCommit(client, dir, block, message)
@@ -820,7 +851,7 @@ export async function commitFolder (client, dir, block, { message, paths = [], a
820
851
  block.at = r.square.head
821
852
  block.head = r.square.head
822
853
  saveBlock(dir, block)
823
- const out = withLocalScan(block, changes, allow, r)
854
+ const out = withLocalMarkers(block, changes, withLocalScan(block, changes, allow, r))
824
855
  if (opened) out.opened = opened
825
856
  return out
826
857
  }
@@ -849,7 +880,9 @@ async function openForCommit (client, dir, block, message) {
849
880
  */
850
881
  export async function pull (client, dir, block) {
851
882
  // As git refuses to pull over unmerged files: the last pull's conflicts are settled first.
852
- refuseConflicted(pullConflicts(dir, block), 'pull again')
883
+ const open = pullConflicts(dir, block)
884
+ refuseMarkersLeft(dir, block, open, 'pull again')
885
+ refuseConflicted(open, 'pull again')
853
886
  if (block.square) {
854
887
  // The square was merged (or abandoned) from elsewhere: the folder goes to its line's tip.
855
888
  const sq = await client.square(block.quilt, block.square)
@@ -884,7 +917,9 @@ export async function toLineTip (client, dir, block, line, tip) {
884
917
  export async function amendFolder (client, dir, block, { message, paths = [], allow = [] }) {
885
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.')
886
919
  const changes = localChanges(dir, block, { paths })
887
- refuseConflicted(pullConflicts(dir, block).filter((p) => changes.some((c) => c.path === p)), 'amend')
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')
888
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 })
889
924
  if (blobs.length) await client.upload(block.quilt, blobs)
890
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' })
@@ -897,7 +932,7 @@ export async function amendFolder (client, dir, block, { message, paths = [], al
897
932
  block.at = r.square.head
898
933
  block.head = r.square.head
899
934
  saveBlock(dir, block)
900
- return withLocalScan(block, changes, allow, r)
935
+ return withLocalMarkers(block, changes, withLocalScan(block, changes, allow, r))
901
936
  }
902
937
 
903
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
@@ -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') {
@@ -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
+ }
@@ -140,7 +140,10 @@ export function describeWrite (r, verb = 'Done') {
140
140
  if (r.conflicted) bits.push(`${r.conflicted} conflicted path${r.conflicted === 1 ? '' : 's'}: ${(r.terms || []).map((t) => `${t.path} (${t.kind})`).join(', ')}`)
141
141
  const reused = (r.combined || []).filter((c) => c.how === 'reuse')
142
142
  if (reused.length) bits.push(`Settled the way it was settled before: ${reused.map((c) => `${c.path} (by ${c.by})`).join(', ')}`)
143
- 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
+ }
144
147
  return bits.join('\n')
145
148
  }
146
149
 
@@ -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.',
@@ -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 },
@@ -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': {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heyquilt/seam",
3
- "version": "0.142.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",