heyquilt 0.3.90 → 0.3.107

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/README.md +14 -11
  2. package/RELEASES.md +135 -2
  3. package/bin/quilt.js +45 -12
  4. package/package.json +3 -3
  5. package/src/account.js +28 -0
  6. package/src/agents/claude-code.js +11 -4
  7. package/src/agents/cursor-transcripts.js +11 -4
  8. package/src/ai-vm-signin.js +14 -2
  9. package/src/ai-vm.js +150 -8
  10. package/src/api/agent-auth.js +9 -3
  11. package/src/api/fly-machines.js +72 -15
  12. package/src/api/memory-store.js +142 -6
  13. package/src/api/relay-cluster.js +337 -43
  14. package/src/api/relay-machines.js +35 -3
  15. package/src/api/releases.js +87 -0
  16. package/src/api/routes/agents.js +111 -16
  17. package/src/api/routes/ai-vm.js +146 -19
  18. package/src/api/routes/orgs.js +3 -1
  19. package/src/api/routes/secrets.js +448 -0
  20. package/src/api/routes/workspaces.js +8 -1
  21. package/src/api/sandbox-keys.js +57 -0
  22. package/src/api/secrets-access.js +93 -0
  23. package/src/api/server.js +40 -9
  24. package/src/api/supabase-store.js +121 -11
  25. package/src/autowork.js +3 -15
  26. package/src/catchup.js +1 -1
  27. package/src/clash.js +10 -22
  28. package/src/control.js +18 -15
  29. package/src/fsutil.js +11 -1
  30. package/src/gitstate.js +29 -5
  31. package/src/hooks.js +1 -1
  32. package/src/mcp-proxy.js +7 -2
  33. package/src/mcp.js +22 -25
  34. package/src/merge3.js +94 -12
  35. package/src/pass-source.js +9 -2
  36. package/src/pathrules.js +7 -0
  37. package/src/relay-clash.js +101 -484
  38. package/src/relay-githttp.js +36 -7
  39. package/src/relay-mcp.js +19 -7
  40. package/src/relay-metrics.js +66 -1
  41. package/src/relay-outbound.js +154 -25
  42. package/src/relay-sync.js +133 -136
  43. package/src/relay-turn-sync.js +250 -51
  44. package/src/relay-upstream.js +49 -179
  45. package/src/releases.js +16 -9
  46. package/src/runner.js +3 -2
  47. package/src/secret-import.js +55 -0
  48. package/src/secret-names.js +26 -0
  49. package/src/secret-run.js +80 -0
  50. package/src/secrets-client.js +56 -0
  51. package/src/server.js +71 -44
  52. package/src/session.js +347 -262
  53. package/src/status.js +3 -5
  54. package/src/sync-words.js +1 -1
  55. package/src/turn-sync-plan.js +13 -10
  56. package/src/turn-sync.js +32 -11
  57. package/src/ui/agent-guide.js +4 -4
  58. package/src/ui/app.css +208 -23
  59. package/src/ui/app.js +4 -0
  60. package/src/ui/cloud-ai-model.js +3 -2
  61. package/src/ui/common.js +2 -0
  62. package/src/ui/fileview.js +28 -1
  63. package/src/ui/home.js +32 -33
  64. package/src/ui/loom.css +18 -2
  65. package/src/ui/loom.js +41 -8
  66. package/src/ui/rail-model.js +6 -9
  67. package/src/ui/releases.js +1 -1
  68. package/src/ui/sandbox-keys-model.js +53 -0
  69. package/src/ui/sandbox-keys.js +132 -0
  70. package/src/ui/secret-rules.js +255 -0
  71. package/src/ui/secrets-view.js +162 -0
  72. package/src/ui/secrets.js +283 -0
  73. package/src/ui/session.js +47 -149
  74. package/src/ui/tree.js +23 -18
  75. package/src/ui/workspaces.js +5 -0
  76. package/src/ui-server.js +140 -28
  77. package/src/update-check.js +1 -1
  78. package/src/upstream.js +75 -38
  79. package/src/vault/crypto.js +96 -0
  80. package/src/vault/kms.js +61 -0
  81. package/src/vault/main.js +33 -0
  82. package/src/vault/server.js +279 -0
  83. package/src/vault/sigv4.js +38 -0
  84. package/src/ui/merges.js +0 -158
@@ -1,47 +1,29 @@
1
- // Merge conflicts, automatically (plan §7.3, step 3 onwards; addendum 16.2, 16.3, 16.8). A file
2
- // whose lines GitHub and the session both changed is held (relay-sync.js): inbound doesn't write
3
- // it, outbound keeps the remote version. The two parties resolve it together:
1
+ // Automatic merges on the relay (docs/superpowers/specs/2026-10-18-automatic-merges.md). Where
2
+ // GitHub's commits and the session's work changed the same file, the relay combines them itself
3
+ // (upstream.js combine: lines only one side changed are kept, lines both changed go to the newer
4
+ // edit, an edit beats a deletion, a binary is the newer version) and the file lands at once: nobody
5
+ // is asked to merge, no task, nothing held. What lost is kept (the file's history; the session's
6
+ // version as a blob in the mirror, kept by its keep refs for RESOLVED_KEEP_MS) and whoever wrote it
7
+ // is told: a direct message from the relay to the member whose lines lost, or a line in the
8
+ // activity log for GitHub's. GitHub's checks failing on a commit that carried a merge tell both
9
+ // parties; the file is never held for it.
4
10
  //
5
- // - Who: the session side is the member whose edit it is (the branch's history); the GitHub side
6
- // is the upstream commit's author, a member when their linked GitHub account or noreply address
7
- // says so. The merger is the party online who made the more recent of the two edits (with no
8
- // party online, any agent that may change the file); the reviewer is the other party.
9
- // - Agreement first: both are told, in a direct message, with the base, both versions and the
10
- // upstream commit messages on the clash task.
11
- // - Merge: the merger proposes the merged file (quilt_clash propose, never markers) with the checks
12
- // it ran on exactly that content. The proposal is kept apart from the live file (a blob in the
13
- // mirror), so nothing half-merged reaches any node; agents can't write a held file at all. The
14
- // reviewer accepts (quilt_clash accept), and only then is the file written and pushed. A
15
- // reviewer who doesn't answer in 15 minutes is told it went through, when the owner's policy
16
- // says so (the default); a reviewer who is offline, outside Quilt or the same member isn't waited for.
17
- // - Escalation: a merger with no proposal after 15 minutes hands it to the reviewer, then to any
18
- // agent; with nobody to take it, the people in the session are told. A person's save of the
19
- // file without markers settles it; whoever holds it can keep one side in one action.
20
- // - Recovery: each clash keeps its own base, ours (a blob) and theirs (commits) under the mirror's
21
- // keep refs while it is open and for RESOLVED_KEEP_MS after.
22
- //
23
- // Quilt never runs an AI itself: the merger and reviewer are members working a task.
24
- import crypto from 'node:crypto'
25
- import * as Y from 'yjs'
11
+ // quilt_clash stays registered for a release (older guides call it) and answers that there is
12
+ // nothing to do. Quilt never runs an AI itself.
26
13
  import { syncStateOf, runGit } from './relay-git.js'
27
- import { upstreamsOf, docKey, hasMarkers, fingerprint, RELAY_ORIGIN } from './relay-upstream.js'
28
- import { applyTextDiff } from './textdiff.js'
29
- import { HistoryLog, lineDiff } from './history.js'
30
- import { readTasks, addTask, updateTask } from './tasks.js'
31
- import { addComment, MAX_COMMENT } from './task-comments.js'
32
- import { changeRefusal } from './session-access.js'
33
- import { RELAY_BY, isRelay } from './branches.js'
14
+ import { upstreamsOf, RELAY_ORIGIN } from './relay-upstream.js'
15
+ import { readTasks, updateTask } from './tasks.js'
16
+ import { RELAY_BY } from './branches.js'
34
17
  import { slug } from './github-commit.js'
35
18
  import { updateMerge } from './merges.js'
36
- import { isSafeRelPath } from './pathrules.js'
37
19
 
38
- export const CLASH_REVIEW_MS = 15 * 60 * 1000
39
- export const CLASH_MERGER_IDLE_MS = 15 * 60 * 1000
40
20
  export const RESOLVED_KEEP_MS = 30 * 24 * 60 * 60 * 1000
21
+ /** quilt_clash's actions, still accepted (each answers that Quilt merges on its own). */
41
22
  export const CLASH_ACTIONS = ['propose', 'accept', 'keep_session', 'keep_github', 'keep_offline', 'revert', 'status']
23
+ /** The most replaced lines a notice lists. */
24
+ export const LOST_LINES_SHOWN = 12
25
+ export const NOTHING_TO_MERGE = 'Quilt merges on its own now: where GitHub\'s commits and the session changed the same lines, the newer edit is kept, the other lines stay in the file\'s history (quilt_history), and whoever wrote them is told. Nothing to do.'
42
26
 
43
- const sha1 = (s) => crypto.createHash('sha1').update(s).digest('hex')
44
- const isText = (k) => typeof k === 'string' && !k.startsWith('bin:')
45
27
  const clip = (s, n) => (s.length > n ? `${s.slice(0, n - 1)}…` : s)
46
28
 
47
29
  /** Everyone in the session: its members and its owner (who may not be in the members list): [{ id, name, kind, github? }]. */
@@ -51,15 +33,6 @@ export function everyone (room) {
51
33
  return out
52
34
  }
53
35
 
54
- /** Whether `name` (a member, a hosted agent, or a person's AI session) is online in the room now. */
55
- export function isOnline (room, name) {
56
- if (!name) return false
57
- for (const ws of (room.conns ? room.conns.keys() : [])) if (room.names && room.names.get(ws) === name) return true
58
- if (room.awareness) for (const st of room.awareness.getStates().values()) if (st && Array.isArray(st.personas) && st.personas.some((p) => p && p.name === name)) return true
59
- if (room.hostedOnline) for (const h of room.hostedOnline()) if (h.name === name) return true
60
- return false
61
- }
62
-
63
36
  /** A git author as a member: by the agents' or people's noreply address, or the GitHub account a person linked. */
64
37
  export function memberForAuthor (room, { name = '', email = '' }) {
65
38
  const e = String(email || '').toLowerCase()
@@ -71,392 +44,104 @@ export function memberForAuthor (room, { name = '', email = '' }) {
71
44
  return null
72
45
  }
73
46
 
74
- /** The commits between `base` and `head` that changed `rel`, newest first: [{ sha, name, email, ts, subject }]. */
47
+ /** The commits between `base` and `head` that changed `rel`, newest first: [{ sha, name, email, ts, subject }] (ts: commit time). */
75
48
  export async function upstreamCommits (dir, base, head, rel) {
76
49
  const r = await runGit(dir, ['log', '--format=%H%x1f%an%x1f%ae%x1f%ct%x1f%s', `${base}..${head}`, '--', rel])
77
50
  if (!r.ok) return []
78
51
  return r.out.split('\n').filter(Boolean).map((l) => { const [sha, name, email, ct, subject] = l.split('\x1f'); return { sha, name, email, ts: Number(ct) * 1000, subject } })
79
52
  }
80
53
 
81
- /** The session's side: the member who last changed `rel` on the branch (not the relay, not a pull). */
82
- function sessionParty (e, rel, { except = null } = {}) {
83
- // By time, not position: a save soon after the same person's last one folds into that entry where it was (history.js).
84
- let best = null
85
- for (const h of e.doc.getArray('history').toArray()) {
86
- if (h && h.path === rel && !h.pulled && !h.seed && h.by && !isRelay(h.by) && h.by !== except && (!best || (h.ts || 0) >= best.ts)) best = { name: h.by, ts: h.ts || 0 }
87
- }
88
- return best
89
- }
90
-
91
- /** Agents online that may change `rel` on branch `key`: hosted ones on the branch first, then connected agents. */
92
- function agentsFor (room, key, rel) {
93
- const out = []
94
- const ok = (a) => a && a.role && a.role !== 'viewer' && !changeRefusal(a, rel)
95
- for (const h of (room.hostedOnline ? room.hostedOnline() : [])) {
96
- const m = room.meta.members[h.id] || {}
97
- if ((m.kind || 'agent') === 'agent' && !h.chat && ok({ ...m, role: m.role || 'editor' })) out.push(h.name)
98
- }
99
- for (const ws of (room.conns ? room.conns.keys() : [])) {
100
- const a = room.access && room.access.get(ws)
101
- if (a && a.kind === 'agent' && ok(a) && room.names && room.names.get(ws)) out.push(room.names.get(ws))
102
- }
103
- return [...new Set(out)]
104
- }
105
-
106
- /**
107
- * Who merges and who reviews a clash: { merger, reviewer, mode } with mode 'same' (both edits by
108
- * one member: no reviewer), 'two' (the reviewer is a member), 'outside' (the GitHub side is not a
109
- * member), or 'none' (nobody to merge: people are told).
110
- */
111
- export function chooseRoles (room, key, h, { except = [] } = {}) {
112
- const s = h.parties.session
113
- // The other side: GitHub's author (a member, or someone outside Quilt), or a member back from editing offline.
114
- const g = h.parties.github
115
- const off = h.parties.offline
116
- const o = g ? { name: g.member || null, ts: g.ts, outside: !g.member } : off ? { name: off.name, ts: off.ts, outside: false } : null
117
- const om = o && o.name
118
- if (s && om && s.name === om) {
119
- if (!except.includes(s.name) && isOnline(room, s.name)) return { merger: s.name, reviewer: null, mode: 'same' }
120
- const any = agentsFor(room, key, h.path).find((n) => !except.includes(n))
121
- return any ? { merger: any, reviewer: s.name, mode: 'same' } : { merger: null, reviewer: s.name, mode: 'none' }
122
- }
123
- const parties = [s ? { name: s.name, ts: s.ts } : null, om ? { name: om, ts: o.ts } : null].filter(Boolean)
124
- const online = parties.filter((p) => !except.includes(p.name) && isOnline(room, p.name)).sort((a, b) => b.ts - a.ts)
125
- let merger = online[0] ? online[0].name : null
126
- if (!merger) merger = agentsFor(room, key, h.path).find((n) => !except.includes(n)) || null
127
- const reviewer = parties.map((p) => p.name).find((n) => n !== merger) || null
128
- if (!merger) return { merger: null, reviewer, mode: 'none' }
129
- return { merger, reviewer, mode: o && o.outside ? 'outside' : 'two' }
130
- }
131
-
132
- /** Stores `text` as a blob in the mirror (kept by the clash's keep refs): its sha, or null. */
133
- async function storeBlob (dir, text) {
134
- const r = await runGit(dir, ['hash-object', '-w', '--stdin'], { input: Buffer.from(text, 'utf8') })
54
+ /** Stores `buf` (a Buffer or text) as a blob in the mirror: its sha, or null. */
55
+ export async function storeBlob (dir, buf) {
56
+ const r = await runGit(dir, ['hash-object', '-w', '--stdin'], { input: Buffer.isBuffer(buf) ? buf : Buffer.from(String(buf), 'utf8') })
135
57
  return r.ok ? r.out.trim() : null
136
58
  }
137
- async function readBlob (dir, sha) {
138
- const r = await runGit(dir, ['cat-file', 'blob', sha], { buffer: true })
139
- return r.ok ? r.out.toString('utf8') : null
140
- }
141
- async function fileAt (dir, commit, rel) {
142
- const r = await runGit(dir, ['cat-file', 'blob', `${commit}:${rel}`], { buffer: true })
143
- return r.ok ? r.out.toString('utf8') : null
144
- }
145
59
 
146
- const post = (room, taskId, text) => { try { addComment(room.doc, room.taskComments || room.doc.getMap('taskComments'), readTasks(room.tasks || room.doc.getMap('tasks')), { taskId, by: RELAY_BY, text: clip(text, MAX_COMMENT) }, RELAY_ORIGIN) } catch {} }
147
60
  const dm = (room, to, text, kind = 'nudge') => { if (to && room.postChat) room.postChat({ by: RELAY_BY, to, kind, text }) }
148
-
149
- /**
150
- * A file just held for a clash (relay-sync.js): its parties, merger and reviewer, the clash task
151
- * with the thread's material on it, and both parties told. `texts`: { base, ours, theirs } (keys as
152
- * the branch document has them). Mutates `h` (a state.held entry) and returns it.
153
- */
154
- export async function openClash (room, key, rec, h, { dir, texts, now = Date.now() }) {
155
- const e = room.branchDoc(key)
156
- const commits = await upstreamCommits(dir, h.base, h.theirs, h.path)
157
- const top = commits[0] || null
158
- h.parties = {
159
- session: sessionParty(e, h.path),
160
- github: top ? { name: top.name, email: top.email, ts: top.ts, sha: top.sha, subject: top.subject, member: memberForAuthor(room, top) } : null
161
- }
162
- h.binary = !isText(texts.ours) || !isText(texts.theirs)
163
- Object.assign(h, chooseRoles(room, key, h), { assignedAt: now, candidate: null })
164
- if (isText(texts.ours)) h.oursBlob = await storeBlob(dir, texts.ours)
165
- const tasks = room.tasks || room.doc.getMap('tasks')
166
- const sideS = h.parties.session ? h.parties.session.name : 'the session'
167
- const sideG = h.parties.github ? (h.parties.github.member || `${h.parties.github.name} (on GitHub, not in this session)`) : 'GitHub'
168
- const title = clip(`Merge ${h.path} on ${key}: ${sideS} and ${sideG} changed the same lines`, 200)
169
- let task = null
170
- try {
171
- task = addTask(room.doc, tasks, { title, by: RELAY_BY, assignee: h.merger || '', forAi: false, tool: '', files: [h.path] }, RELAY_ORIGIN)
172
- } catch (err) { room.log && room.log(`[${room.name}] ${key}: could not put the clash on ${h.path} on the board: ${err.message}`) }
173
- h.task = task ? task.id : null
174
- if (task) {
175
- const lines = [
176
- `${h.path} is held on \`${key}\`: the session and GitHub changed the same lines. Nothing half-merged reaches anyone; everything else keeps syncing.`,
177
- `- Session side: ${sideS}${h.parties.session ? ` (${new Date(h.parties.session.ts).toISOString().slice(0, 16)}Z)` : ''}`,
178
- `- GitHub side: ${sideG}${commits.length ? `, in ${commits.length} commit${commits.length === 1 ? '' : 's'}: ${commits.slice(0, 5).map((c) => `${c.sha.slice(0, 7)} "${clip(c.subject, 80)}"`).join('; ')}` : ''}`,
179
- `- Merger: ${h.merger || 'nobody yet (no party or agent online)'}; reviewer: ${h.reviewer || (h.mode === 'outside' ? 'none (the GitHub side is outside Quilt: they get a comment on their commit)' : 'none')}.`,
180
- '- Checks: the merger runs the project\'s own checks (its test command) in its own folder or tool, on exactly the content it proposes, and says how they went; a changed proposal needs them again. The relay never runs code or an AI.',
181
- '',
182
- h.binary
183
- ? 'A binary file can\'t be merged: pick one side with quilt_clash keep_session or keep_github.'
184
- : 'Agree on what each change was for first (quilt_message each other). Then the merger proposes the merged file with quilt_clash (action "propose", the full content without conflict markers, and the checks you ran on exactly that content); the reviewer accepts with quilt_clash accept. Only then is it written and pushed. Writing the file directly is refused while it is held.'
185
- ]
186
- post(room, task.id, lines.join('\n'))
187
- if (isText(texts.base) || texts.base === null) {
188
- const b = isText(texts.base) ? texts.base : ''
189
- if (isText(texts.ours)) post(room, task.id, `The session's change (base → session):\n\`\`\`diff\n${lineDiff(b, texts.ours, { max: MAX_COMMENT - 200 })}\n\`\`\``)
190
- if (isText(texts.theirs)) post(room, task.id, `GitHub's change (base → ${h.theirs.slice(0, 7)}):\n\`\`\`diff\n${lineDiff(b, texts.theirs, { max: MAX_COMMENT - 200 })}\n\`\`\``)
191
- }
192
- }
193
- tellRoles(room, key, h, { fresh: true })
194
- room.log && room.log(`[${room.name}] ${key}: clash on ${h.path}: merger ${h.merger || 'none'}, reviewer ${h.reviewer || 'none'} (${h.mode})`)
195
- return h
196
- }
197
-
198
- /** Tells the merger and the reviewer what is theirs to do (or the people, when nobody can merge). */
199
- function tellRoles (room, key, h, { fresh = false, why = '' } = {}) {
200
- const ref = h.task ? ` (task ${h.task})` : ''
201
- if (h.merger) {
202
- const person = (everyone(room).find((m) => m.name === h.merger) || {}).kind !== 'agent'
203
- const how = person
204
- ? `merge it in your editor and save it without conflict markers (that settles it), or have your AI propose it with quilt_clash`
205
- : 'propose the merged file with quilt_clash propose (no markers), with the checks you ran on it'
206
- dm(room, h.merger, `${why ? `${why} ` : ''}You merge ${h.path} on ${key}${ref}: ${h.reviewer ? `${h.reviewer} reviews` : 'nobody needs to review'}.${h.binary ? ' It is a binary file: keep one side with quilt_clash keep_session or keep_github.' : ` ${h.reviewer && h.mode !== 'same' ? `Agree with ${h.reviewer} on intent first, then` : 'Then'} ${how}.`}`)
207
- }
208
- if (h.reviewer && fresh) dm(room, h.reviewer, `${h.path} on ${key} clashes with your work${ref}. ${h.merger ? `${h.merger} merges it; you review the result (quilt_clash accept), and nothing is pushed until you do${h.mode === 'two' ? ' or 15 minutes pass' : ''}.` : 'Nobody is online to merge it yet.'}`)
209
- if (!h.merger && !h.toldNone) {
210
- h.toldNone = Date.now()
211
- if (room.postChat) room.postChat({ by: RELAY_BY, kind: 'nudge', text: `${key} has 1 clash in ${h.path}, waiting for an AI or you${ref}. Everything else keeps syncing.` })
212
- }
213
- }
214
-
215
- /** The held clash entry for `rel` on `key`, or null. */
216
- export const clashOf = (room, key, rel) => syncStateOf(room, key).held.find((x) => x.path === rel && (x.why === 'clash' || x.why === 'checks')) || null
217
-
218
- /** Writes `text` (or deletes, with null) into branch `key` as `by`, recorded in its history, and marks it pending for the next commit. */
219
- function writeResolved (room, key, rel, text, by) {
220
- const e = room.branchDoc(key)
221
- const history = e.historyLog || (e.historyLog = new HistoryLog(e.doc, e.doc.getArray('history'), { origin: RELAY_ORIGIN }))
222
- const before = e.files.get(rel)?.toString()
223
- e.doc.transact(() => {
224
- if (text === null) { e.files.delete(rel); e.blobs.delete(rel); history.record({ by, path: rel, kind: 'deleted', before, after: '' }); return }
225
- e.blobs.delete(rel)
226
- let y = e.files.get(rel)
227
- if (!y) { y = new Y.Text(); e.files.set(rel, y) }
228
- applyTextDiff(y, text)
229
- history.record({ by, path: rel, kind: before === undefined ? 'created' : 'edited', before: before ?? '', after: text })
61
+ const activity = (room, entry) => {
62
+ if (!room.activity || !room.doc) return
63
+ room.doc.transact(() => {
64
+ room.activity.push([entry])
65
+ if (room.activity.length > 300) room.activity.delete(0, room.activity.length - 300)
230
66
  }, RELAY_ORIGIN)
231
- if (room.outbound) room.outbound.note(key, rel)
232
- }
233
-
234
- /** Ends the clash on `rel`: released from held, kept as resolved (for recovery and undo), its task Done. */
235
- export function finishClash (room, key, h, opts) { return finish(room, key, h, opts) }
236
- function finish (room, key, h, { how, by, text = null, now = Date.now(), mergeHow = 'agent' }) {
237
- const state = syncStateOf(room, key)
238
- state.held = state.held.filter((x) => x !== h)
239
- if (h.kind === 'offline' && h.mergeId) {
240
- // The returning member's merge record (merges.js) closes with it.
241
- try { const e = room.branchDoc(key); updateMerge(e.doc, e.doc.getMap('merges'), h.mergeId, { state: 'done', how: mergeHow, resolvedBy: String(by).slice(0, 80) }, RELAY_ORIGIN) } catch {}
242
- // GitHub changed the file while it was held: that comes in now, against the base it was measured from (relay-sync.js).
243
- if (h.upstreamMoved) state.held.push({ path: h.path, why: 'clash', base: h.upstreamMoved.base, theirs: h.upstreamMoved.theirs, head: h.upstreamMoved.theirs, was: null, regions: [], since: now })
244
- }
245
- if (!Array.isArray(state.resolved)) state.resolved = []
246
- state.resolved.push({ path: h.path, kind: h.kind || 'upstream', base: h.base, theirs: h.theirs, oursBlob: h.oursBlob || null, otherBlob: h.otherBlob || null, baseBlob: h.baseBlob || null, candidateBlob: h.candidate ? h.candidate.blob : null, how, by, merger: h.merger, reviewer: h.reviewer, parties: h.parties, at: now, task: h.task })
247
- state.resolved = state.resolved.filter((r) => now - r.at < RESOLVED_KEEP_MS).slice(-100)
248
- if (h.task) {
249
- try { updateTask(room.doc, room.tasks || room.doc.getMap('tasks'), { id: h.task, column: 'done', verified: `Clash on ${h.path} settled (${how}) by ${by}.` }, RELAY_ORIGIN) } catch {}
250
- post(room, h.task, `Settled: ${how} by ${by}. ${text === null ? '' : 'The merged file is in the session and goes to GitHub with the next commit. '}Both sides stay recoverable: the session's version in the history${h.kind === 'offline' ? `, ${h.parties.offline.name}'s in the merge record` : h.theirs ? `, GitHub's in ${h.theirs.slice(0, 7)}` : ''}.`)
251
- }
252
- room.saveMeta()
253
- room.broadcastBranches && room.broadcastBranches()
254
67
  }
255
68
 
256
- /** Sends the merged result to the GitHub side when they are outside Quilt: a comment on their commit (REST metadata). */
257
- async function commentUpstream (room, key, h) {
258
- const g = h.parties && h.parties.github
259
- if (!g || g.member || !g.sha) return false
260
- const rec = upstreamsOf(room)[key]
261
- try {
262
- const [owner, name] = String(rec.repo).replace(/^github\.com\//, '').split('/')
263
- const token = await room.gitToken({ owner, name, slug: rec.repo }, { access: 'write' })
264
- if (!token) return false
265
- const res = await (room.githubFetch || globalThis.fetch)(`https://api.github.com/repos/${owner}/${name}/commits/${g.sha}/comments`, {
266
- method: 'POST',
267
- headers: { 'user-agent': 'quilt-relay', 'x-github-api-version': '2022-11-28', accept: 'application/vnd.github+json', authorization: `Bearer ${token}`, 'content-type': 'application/json' },
268
- body: JSON.stringify({ body: `This commit changed the same lines of \`${h.path}\` as work in a Quilt session on \`${key}\`. ${h.merger} merged the two in the session; the merged file reaches \`${key}\` with the session's next commit.` })
269
- })
270
- return res.ok
271
- } catch { return false }
69
+ /** The replaced lines of `lost` (mergeAuto's), as a list for a notice: at most LOST_LINES_SHOWN. */
70
+ export function lostLines (lost) {
71
+ const all = lost.flatMap((l) => l.lines)
72
+ const shown = all.slice(0, LOST_LINES_SHOWN).map((l) => `- ${clip(l, 200)}`)
73
+ if (all.length > shown.length) shown.push(`- and ${all.length - shown.length} more`)
74
+ return shown.join('\n')
272
75
  }
273
76
 
274
- /** The project's checks as the merger reported them for content `fp`: whether they let the merge through. */
275
- const checksOk = (c, fp) => !!c && c.fp === fp && (c.result === 'passed' || c.result === 'none')
276
-
277
77
  /**
278
- * quilt_clash, for `who` ({ name, kind, access }) on branch `key`. Resolves { ok, text } or throws
279
- * an Error with what to do. Actions:
280
- * - propose { path, content, checks: { result: passed|failed|none, command } }: the merger's merged file.
281
- * - accept { path, force }: the reviewer takes the proposal (force: a person, past failing checks).
282
- * - keep_session / keep_github { path }: whoever holds it, or a person: one side, the other recoverable.
283
- * - revert { path }: the reviewer (or a person) after it went through: their side back.
284
- * - status: the clashes on this branch.
78
+ * The relay just combined `path` on branch `key` itself (upstream.js combine): whoever's lines lost
79
+ * is told, once. `lost`: combine's; `session`: { name, ts } the member who last changed it in the
80
+ * session; `github`: { name, member?, sha } the newest commit of GitHub's that changed it; `upstream`
81
+ * the upstream's name (origin/main). The session's lines lost: a direct message to that member, with
82
+ * the lines; GitHub's: a line in the activity log. `keptIn`: where the losing lines are kept. A notice, never a task.
285
83
  */
286
- export async function clashAction (room, { who, key, action, path: rel = '', content = null, checks = null, force = false }, { now = Date.now() } = {}) {
287
- if (!CLASH_ACTIONS.includes(action)) throw new Error(`action is one of ${CLASH_ACTIONS.join(', ')}`)
288
- const state = syncStateOf(room, key)
289
- const person = who.kind !== 'agent'
290
- if (action === 'status') {
291
- const open = state.held.filter((h) => h.why === 'clash')
292
- if (!open.length) return { ok: true, text: `No clashes on ${key}.` }
293
- return { ok: true, text: open.map((h) => `- ${h.path}: merger ${h.merger || 'nobody yet'}, reviewer ${h.reviewer || 'none'}${h.candidate ? `; proposed by ${h.candidate.by} (checks ${h.candidate.checks ? h.candidate.checks.result : 'not reported'})` : '; no proposal yet'}${h.task ? ` (task ${h.task})` : ''}`).join('\n') }
294
- }
295
- if (action === 'revert') {
296
- const r = [...(state.resolved || [])].reverse().find((x) => x.path === rel)
297
- if (!r) throw new Error(`No settled clash on ${rel} to revert.`)
298
- if (!person && who.name !== r.reviewer) throw new Error(`Only ${r.reviewer || 'a person'} (the reviewer) or a person can revert it.`)
299
- const dir = await mirrorDirOf(room, key)
300
- const reviewerSide = r.parties && r.parties.github && r.parties.github.member === r.reviewer ? 'github' : r.parties && r.parties.offline && r.parties.offline.name === r.reviewer ? 'offline' : 'session'
301
- const text = reviewerSide === 'github' ? await fileAt(dir, r.theirs, rel) : reviewerSide === 'offline' ? (r.otherBlob ? await readBlob(dir, r.otherBlob) : null) : (r.oursBlob ? await readBlob(dir, r.oursBlob) : null)
302
- if (text === null) throw new Error(`That version of ${rel} isn't kept any more.`)
303
- writeResolved(room, key, rel, text, who.name)
304
- r.reverted = { by: who.name, at: now }
305
- room.saveMeta()
306
- if (r.merger && r.merger !== who.name) dm(room, r.merger, `${who.name} reverted your merge of ${rel} on ${key} to ${reviewerSide === 'github' ? 'GitHub\'s' : 'the session\'s'} version.`)
307
- return { ok: true, text: `Reverted ${rel} on ${key} to ${reviewerSide === 'github' ? 'GitHub\'s' : reviewerSide === 'offline' ? 'your offline' : 'the session\'s'} version; it goes to GitHub with the next commit.` }
308
- }
309
- const h = clashOf(room, key, rel)
310
- if (!h) throw new Error(`${rel} isn't held for a clash on ${key}.`)
311
- const dir = await mirrorDirOf(room, key)
312
- if (action === 'keep_session' || action === 'keep_github' || action === 'keep_offline') {
313
- if (h.kind === 'offline' && action === 'keep_github') throw new Error(`${rel} clashes with ${h.parties.offline.name}'s offline edits, not GitHub: keep_session or keep_offline.`)
314
- if (h.kind !== 'offline' && action === 'keep_offline') throw new Error(`${rel} clashes with GitHub: keep_session or keep_github.`)
315
- if (!person && who.name !== h.merger) throw new Error(`${h.merger} holds ${rel}: only they or a person can pick a side.`)
316
- if (action === 'keep_offline') {
317
- const text = h.otherDeleted ? null : h.otherBlob ? await readBlob(dir, h.otherBlob) : null
318
- if (text === null && !h.otherDeleted) throw new Error(`${h.parties.offline.name}'s version isn't kept any more.`)
319
- writeResolved(room, key, rel, text, who.name)
320
- finish(room, key, h, { how: `kept ${h.parties.offline.name}'s version`, by: who.name, text, now, mergeHow: 'mine' })
321
- if (h.reviewer && h.reviewer !== who.name) dm(room, h.reviewer, `${who.name} settled the clash on ${rel} on ${key} by keeping ${h.parties.offline.name}'s version; the session's is recoverable (quilt_clash revert).`)
322
- return { ok: true, text: `Kept ${h.parties.offline.name}'s version of ${rel}; the session's is recoverable.` }
323
- }
324
- if (h.why === 'checks') throw new Error(`${rel} is held because checks failed after its merge, not for a clash: propose a fix, or a person accepts the failure (quilt_clash accept).`)
325
- const text = action === 'keep_github' ? await fileAt(dir, h.theirs, rel) : docKey(room.branchDoc(key), rel)
326
- if (action === 'keep_github') writeResolved(room, key, rel, text, who.name)
327
- else if (room.outbound) room.outbound.note(key, rel)
328
- finish(room, key, h, { how: action === 'keep_github' ? 'kept GitHub\'s version' : 'kept the session\'s version', by: who.name, text, now, mergeHow: action === 'keep_github' ? 'agent' : 'theirs' })
329
- if (h.reviewer && h.reviewer !== who.name) dm(room, h.reviewer, `${who.name} settled the clash on ${rel} on ${key} by keeping ${action === 'keep_github' ? 'GitHub\'s' : 'the session\'s'} version; the other is recoverable (quilt_clash revert).`)
330
- return { ok: true, text: `Kept ${action === 'keep_github' ? 'GitHub\'s' : 'the session\'s'} version of ${rel}; the other side is recoverable.` }
331
- }
332
- if (action === 'propose') {
333
- if (who.name !== h.merger && !person) throw new Error(`${h.merger || 'Another member'} merges ${rel}${h.task ? ` (task ${h.task})` : ''}: you can't propose for it.`)
334
- if (h.binary) throw new Error(`${rel} is binary: keep one side (keep_session or keep_github).`)
335
- if (typeof content !== 'string') throw new Error('Give the merged file\'s full content.')
336
- if (hasMarkers(content)) throw new Error('The proposal still has conflict markers: resolve them first.')
337
- const c = checks && typeof checks === 'object' ? checks : null
338
- if (!c || !['passed', 'failed', 'none'].includes(c.result)) throw new Error('Say how the project\'s checks went on exactly this content: checks.result passed, failed or none (the project has none), and checks.command.')
339
- const fp = sha1(content)
340
- const blob = await storeBlob(dir, content)
341
- h.candidate = { by: who.name, at: now, fp, blob, checks: { result: c.result, command: String(c.command || '').slice(0, 300), fp } }
342
- if (who.name !== h.merger) h.merger = who.name
343
- room.saveMeta()
344
- // Not waited for: the same member, a GitHub side outside Quilt, or a reviewer who is offline.
345
- const wait = h.reviewer && h.reviewer !== who.name && h.mode !== 'same' && isOnline(room, h.reviewer)
346
- if (!wait) {
347
- if (!checksOk(h.candidate.checks, fp)) return { ok: false, text: `Proposed, but the checks failed (${h.candidate.checks.command || 'the project\'s checks'}): fix the merge and propose again, or a person accepts it past them.` }
348
- await accept(room, key, h, { by: who.name, now, dir })
349
- if (h.reviewer && h.reviewer !== who.name) dm(room, h.reviewer, `While you were away, ${who.name} merged ${rel} on ${key} with your work (task ${h.task}); it is pushed. If it isn't right, quilt_clash revert puts your version back.`)
350
- return { ok: true, text: `Merged ${rel}: written to the session; it goes to GitHub with the next commit${h.parties.github && !h.parties.github.member ? '; the GitHub side gets a comment on their commit' : ''}.` }
351
- }
352
- dm(room, h.reviewer, `${who.name} proposed the merge of ${rel} on ${key} (task ${h.task}; checks: ${h.candidate.checks.result}${h.candidate.checks.command ? `, ${h.candidate.checks.command}` : ''}). Review it with quilt_clash status and accept with quilt_clash accept${room.meta.clashTimeout === 'publish' ? '; without an answer in 15 minutes it goes through (the owner\'s setting)' : ''}.`)
353
- if (h.task) post(room, h.task, `${who.name} proposed:\n\`\`\`diff\n${lineDiff(docKey(room.branchDoc(key), rel) || '', content, { max: MAX_COMMENT - 200 })}\n\`\`\``)
354
- return { ok: true, text: `Proposed. ${h.reviewer} reviews it; nothing is written or pushed until then.` }
355
- }
356
- // accept
357
- if (h.candidate && isRelay(h.candidate.by) && checks && ['passed', 'none'].includes(checks.result)) {
358
- // A merge the relay suggested (word by word): whoever accepts it ran the checks on it.
359
- if (!person && who.name !== h.merger && who.name !== h.reviewer) throw new Error(`${h.merger} or ${h.reviewer} accepts the suggested merge of ${rel}.`)
360
- h.candidate.checks = { result: checks.result, command: String(checks.command || '').slice(0, 300), fp: h.candidate.fp, by: who.name }
361
- }
362
- if (h.why === 'checks' && !h.candidate) {
363
- if (!person) throw new Error(`Checks failed after the merge of ${rel}: propose a fix (quilt_clash propose, with the checks passing), or a person accepts the failure.`)
364
- finish(room, key, h, { how: `the failing checks accepted by ${who.name}`, by: who.name, text: null, now })
365
- if (room.outbound) room.outbound.note(key, rel)
366
- return { ok: true, text: `Accepted the failing checks on ${rel}; it syncs to GitHub again.` }
367
- }
368
- if (!h.candidate) throw new Error(`Nothing is proposed for ${rel} yet: ${h.merger || 'the merger'} proposes first.`)
369
- if (who.name === h.candidate.by && !person) throw new Error('The reviewer accepts, not the merger.')
370
- if (isRelay(h.candidate.by) && !checksOk(h.candidate.checks, h.candidate.fp) && !(person && force)) throw new Error(`The relay suggested a word-by-word merge of ${rel}: accept it with checks (how the project's checks went on it), or propose another.`)
371
- if (!person && h.reviewer && who.name !== h.reviewer && !isRelay(h.candidate.by)) throw new Error(`${h.reviewer} reviews ${rel}.`)
372
- if (!checksOk(h.candidate.checks, h.candidate.fp)) {
373
- const overridable = person && force && room.meta.clashOverride !== false
374
- if (!overridable) throw new Error(`The checks on the proposal ${h.candidate.checks.result === 'failed' ? 'failed' : 'aren\'t for this content'}: the merger fixes it and proposes again${person ? ', or accept with force: true to take it as it is' : ''}.`)
84
+ export function tellAutoMerge (room, key, { path: rel, lost = [], session = null, github = null, upstream = 'GitHub', head = '', deleted = null, keptIn = 'the file\'s history (quilt_history)', now = Date.now() }) {
85
+ const at = head ? ` ${head.slice(0, 7)}` : ''
86
+ const ghName = github ? (github.member || github.name || upstream) : upstream
87
+ if (deleted === 'theirs') activity(room, { by: RELAY_BY, path: rel, kind: 'merged', detail: `${upstream}${at} deleted it, but the session changed it: it stays`, branch: key, ts: now })
88
+ if (deleted === 'ours' && session && session.name) dm(room, session.name, `${rel} on ${key}: it was deleted in the session, but ${ghName} changed it on GitHub (${upstream}${at}), so it is back with that change. Delete it again if it should go.`)
89
+ const ours = lost.filter((l) => l.side === 'ours')
90
+ const theirs = lost.filter((l) => l.side === 'theirs')
91
+ if (ours.length && session && session.name) {
92
+ const binary = ours.some((l) => l.binary)
93
+ dm(room, session.name, binary
94
+ ? `Quilt merged ${upstream}${at} into ${key}: you and ${ghName} both changed ${rel}, a binary file, and ${ghName}'s newer version stays. Yours is kept in ${keptIn}.`
95
+ : `Quilt merged ${upstream}${at} into ${rel} on ${key}: where you and ${ghName} both changed the same lines, ${ghName}'s newer edit stays. The lines of yours it replaced are in ${keptIn}:\n${lostLines(ours)}`)
96
+ }
97
+ if (theirs.length) {
98
+ const n = theirs.reduce((k, l) => k + (l.binary ? 1 : l.lines.length), 0)
99
+ activity(room, { by: RELAY_BY, path: rel, kind: 'merged', detail: theirs.some((l) => l.binary) ? `kept the session's newer version over ${upstream}${at}'s` : `kept the session's newer lines over ${n} line${n === 1 ? '' : 's'} of ${upstream}${at}`, branch: key, ts: now })
100
+ }
101
+ }
102
+
103
+ /** A held entry from an older relay (a clash, failing checks): its task Done and its merge record closed, as Quilt merged it itself. */
104
+ export function closeOldClash (room, key, h, { how = 'merged by Quilt', now = Date.now() } = {}) {
105
+ if (h.task) {
106
+ try {
107
+ const t = readTasks(room.tasks || room.doc.getMap('tasks')).find((x) => x.id === h.task)
108
+ if (t && t.column !== 'done') updateTask(room.doc, room.tasks || room.doc.getMap('tasks'), { id: h.task, column: 'done', verified: `${h.path}: ${how}. Quilt merges on its own now.` }, RELAY_ORIGIN)
109
+ } catch {}
375
110
  }
376
- await accept(room, key, h, { by: who.name, now, dir })
377
- return { ok: true, text: `Accepted ${h.candidate.by}'s merge of ${rel}: written to the session; it goes to GitHub with the next commit.` }
378
- }
379
-
380
- async function accept (room, key, h, { by, now, dir }) {
381
- const text = await readBlob(dir, h.candidate.blob)
382
- if (text === null) throw new Error('The proposal isn\'t kept any more: propose again.')
383
- writeResolved(room, key, h.path, text, h.candidate.by)
384
- noteMerged(room, key, h.path, h.parties || {}, { now })
385
- finish(room, key, h, { how: `merged by ${h.candidate.by}, accepted by ${by}`, by, text, now })
386
- // GitHub's side, outside Quilt, hears where its change went. Nothing is pushed here: the merged file goes with the session's next commit.
387
- if (h.parties.github && !h.parties.github.member) commentUpstream(room, key, h).catch(() => {})
111
+ if (h.mergeId) { try { const e = room.branchDoc(key); updateMerge(e.doc, e.doc.getMap('merges'), h.mergeId, { state: 'done', how: 'mine', resolvedBy: 'Quilt' }, RELAY_ORIGIN) } catch {} }
112
+ noteResolved(room, key, { path: h.path, kind: h.kind || 'upstream', base: h.base || null, theirs: h.theirs || null, oursBlob: h.oursBlob || null, otherBlob: h.otherBlob || null, baseBlob: h.baseBlob || null, how, at: now })
388
113
  }
389
114
 
390
- async function mirrorDirOf (room, key) {
391
- const rec = upstreamsOf(room)[key]
392
- const { parseRepo } = await import('./relay-upstream.js')
393
- const repo = parseRepo(`https://${rec.repo}`)
394
- return (await room.mirrors.ensure(repo, { url: room.gitRemoteUrl(repo) })).dir
115
+ /** Keeps a merge's versions on record (state.resolved) for RESOLVED_KEEP_MS, so the mirror keeps their objects (relay-sync.js keptObjects). */
116
+ export function noteResolved (room, key, entry) {
117
+ const state = syncStateOf(room, key)
118
+ if (!Array.isArray(state.resolved)) state.resolved = []
119
+ const now = entry.at || Date.now()
120
+ state.resolved.push({ ...entry, at: now })
121
+ state.resolved = state.resolved.filter((r) => now - r.at < RESOLVED_KEEP_MS).slice(-100)
395
122
  }
396
123
 
397
124
  /**
398
- * The relay's look at the open clashes on `key` (each look, and as people come and go): a proposal
399
- * nobody reviewed in 15 minutes goes through (owner's policy 'publish', the default); a merger with
400
- * no proposal after 15 minutes hands it on (the reviewer, then any agent); a clash nobody could take
401
- * is given to whoever is online now. Returns what changed.
125
+ * quilt_clash, kept for older guides: every action answers that Quilt merges on its own and there
126
+ * is nothing to do. Resolves { ok: true, text }; never throws.
402
127
  */
403
- export async function reviewClashes (room, key, { now = Date.now(), reviewMs = CLASH_REVIEW_MS, idleMs = CLASH_MERGER_IDLE_MS } = {}) {
404
- const state = syncStateOf(room, key)
405
- const out = []
406
- for (const h of [...state.held]) {
407
- if (h.why !== 'clash' || !h.parties) continue
408
- if (h.candidate && h.reviewer && now - h.candidate.at >= reviewMs && room.meta.clashTimeout === 'publish' && checksOk(h.candidate.checks, h.candidate.fp)) {
409
- const dir = await mirrorDirOf(room, key)
410
- await accept(room, key, h, { by: RELAY_BY, now, dir })
411
- dm(room, h.reviewer, `${h.reviewer}, the merge of ${h.path} on ${key} by ${h.candidate.by} went through after 15 minutes without your review (task ${h.task}). If it isn't right, quilt_clash revert puts your version back.`)
412
- out.push({ path: h.path, timedOut: true })
413
- continue
414
- }
415
- if (!h.candidate && h.merger && now - (h.assignedAt || 0) >= idleMs) {
416
- const tried = [...(h.tried || []), h.merger]
417
- const next = h.reviewer && !tried.includes(h.reviewer) && isOnline(room, h.reviewer) ? h.reviewer : agentsFor(room, key, h.path).find((n) => !tried.includes(n)) || null
418
- if (next) {
419
- const was = h.merger
420
- h.tried = tried; h.merger = next; h.assignedAt = now
421
- if (h.reviewer === next) h.reviewer = was
422
- if (h.task) { try { updateTask(room.doc, room.tasks || room.doc.getMap('tasks'), { id: h.task, assignee: next, forAi: false, tool: '' }, RELAY_ORIGIN) } catch {} ; post(room, h.task, `Handed to ${next}: ${was} made no proposal in 15 minutes.`) }
423
- tellRoles(room, key, h, { why: `${was} made no proposal for 15 minutes.` })
424
- out.push({ path: h.path, handedTo: next })
425
- }
426
- continue
427
- }
428
- if (!h.merger) {
429
- const r = chooseRoles(room, key, h)
430
- if (r.merger) {
431
- Object.assign(h, { merger: r.merger, reviewer: r.reviewer || h.reviewer, mode: r.mode, assignedAt: now })
432
- if (h.task) { try { updateTask(room.doc, room.tasks || room.doc.getMap('tasks'), { id: h.task, assignee: r.merger, forAi: false, tool: '' }, RELAY_ORIGIN) } catch {} }
433
- tellRoles(room, key, h, { why: `${r.merger} is online now.` })
434
- out.push({ path: h.path, handedTo: r.merger })
435
- }
436
- }
437
- }
438
- if (out.length) room.saveMeta()
439
- return out
128
+ export async function clashAction (room, { key = '', action = '' } = {}) {
129
+ if (action === 'status') return { ok: true, text: `No clashes${key ? ` on ${key}` : ''}. ${NOTHING_TO_MERGE}` }
130
+ return { ok: true, text: NOTHING_TO_MERGE }
440
131
  }
441
132
 
442
- /** Whether `rel` on branch `key` is held for a clash (agents may not write it; relay-mcp.js, server.js checkChange). */
443
- export const heldForClash = (room, key, rel) => !!(room.meta.sync && room.meta.sync[key] && Array.isArray(room.meta.sync[key].held) && room.meta.sync[key].held.some((h) => h.path === rel && (h.why === 'clash' || h.why === 'checks')))
444
-
445
- export const CLASH_DESCRIPTION = 'Resolve a file held because the session and GitHub changed the same lines (see the clash task). ' +
446
- 'The merger proposes the merged file (action "propose": the full content without conflict markers, and the result of the project\'s checks you ran on exactly that content); the reviewer accepts it ("accept"); only then is it written to the session, to go to GitHub with the next commit. ' +
447
- 'Whoever holds the file (or a person) can keep one side ("keep_session" or "keep_github"); the other stays recoverable. The reviewer can undo a merge that went through ("revert"). "status" lists the clashes on your branch. Writing a held file directly is refused.'
133
+ export const CLASH_DESCRIPTION = 'No longer needed: Quilt merges on its own. Where GitHub\'s commits and the session changed the same lines, the newer edit is kept, the other lines stay in the file\'s history, and whoever wrote them is told. Every action answers that there is nothing to do.'
448
134
  export const clashSchema = (z) => ({
449
- action: z.enum(CLASH_ACTIONS).describe('propose, accept, keep_session, keep_github, revert or status'),
450
- path: z.string().max(1024).optional().describe('The held file'),
451
- content: z.string().max(1024 * 1024).optional().describe('propose: the merged file, in full, without conflict markers'),
452
- checks: z.object({ result: z.enum(['passed', 'failed', 'none']), command: z.string().max(300).optional() }).optional().describe('propose (or accept, for a merge the relay suggested): how the project\'s checks went on exactly this content (none: the project has no checks)'),
453
- force: z.boolean().optional().describe('accept, people only: take the proposal past failing checks')
135
+ action: z.enum(CLASH_ACTIONS).describe('Any: there is nothing to do'),
136
+ path: z.string().max(1024).optional().describe('Ignored'),
137
+ content: z.string().max(1024 * 1024).optional().describe('Ignored'),
138
+ checks: z.object({ result: z.enum(['passed', 'failed', 'none']), command: z.string().max(300).optional() }).optional().describe('Ignored'),
139
+ force: z.boolean().optional().describe('Ignored')
454
140
  })
455
141
 
456
142
  /**
457
- * A merged file (an automatic merge on the way in, or a clash settled) is on record until it is
458
- * pushed: then the commit it went in is, with both parties, so failing checks on that commit can
459
- * pull them back in (plan §7.3, conflicts with no textual overlap).
143
+ * A merged file (combined on the way in) is on record until it is pushed: then the commit it went
144
+ * in is, with both parties, so failing checks on that commit can tell them (noteMergeChecks).
460
145
  */
461
146
  export function noteMerged (room, key, rel, parties, { now = Date.now() } = {}) {
462
147
  const state = syncStateOf(room, key)
@@ -498,100 +183,32 @@ async function failingLines (room, key, sha) {
498
183
 
499
184
  /**
500
185
  * GitHub's checks on `sha` (a check_suite) finished. Failing on a commit that carried merged files:
501
- * the files the failing lines are in (all its merged files when GitHub names none) are held, and
502
- * both parties of their merges are pulled into one task: the merger proposes a fix with the checks
503
- * passing, or a person accepts the failure. Passing on a commit at or after one that held files:
504
- * they are let go of. Returns what changed.
186
+ * both parties of those merges are told, once, with the failing lines GitHub names. Nothing is held:
187
+ * the file keeps syncing, and a fix goes like any edit. Files an older relay held for failing checks
188
+ * are let go of. Returns what changed: [{ path, told: [names] }, { path, released: true }].
505
189
  */
506
190
  export async function noteMergeChecks (room, key, { sha, conclusion }, { now = Date.now() } = {}) {
507
191
  const state = syncStateOf(room, key)
508
192
  const out = []
509
- if (conclusion === 'success') {
510
- const holds = state.held.filter((h) => h.why === 'checks')
511
- if (!holds.length) return out
512
- const dir = await mirrorDirOf(room, key)
513
- for (const h of holds) {
514
- if (h.sha !== sha && !(await runGit(dir, ['merge-base', '--is-ancestor', h.sha, sha])).ok) continue
515
- finish(room, key, h, { how: `checks passed on ${sha.slice(0, 7)}`, by: RELAY_BY, text: null, now })
516
- if (room.outbound) room.outbound.note(key, h.path)
517
- out.push({ path: h.path, released: true })
518
- }
519
- return out
193
+ for (const h of state.held.filter((x) => x.why === 'checks')) {
194
+ state.held = state.held.filter((x) => x !== h)
195
+ closeOldClash(room, key, h, { how: 'let go of: a file is no longer held for failing checks', now })
196
+ if (room.outbound) room.outbound.note(key, h.path)
197
+ out.push({ path: h.path, released: true })
520
198
  }
521
199
  if (conclusion !== 'failure' && conclusion !== 'timed_out') return out
522
200
  const mc = state.mergeCommits && state.mergeCommits[sha]
523
- if (!mc) return out
201
+ if (!mc || mc.told) return out
202
+ mc.told = now
524
203
  const lines = await failingLines(room, key, sha)
525
204
  const named = mc.files.filter((rel) => lines.some((l) => l.path === rel))
526
205
  const files = named.length ? named : mc.files
527
- const parties = mc.parties[mc.files.indexOf(files[0])] || {}
528
- const names = [parties.session && parties.session.name, parties.github && (parties.github.member || null)].filter(Boolean)
529
- const merger = names.find((n) => isOnline(room, n)) || agentsFor(room, key, files[0])[0] || null
530
- const reviewer = names.find((n) => n !== merger) || null
531
- const tasks = room.tasks || room.doc.getMap('tasks')
532
- let task = null
533
- try { task = addTask(room.doc, tasks, { title: clip(`Checks fail after merging ${files.join(', ')} on ${key}`, 200), by: RELAY_BY, assignee: merger || '', forAi: false, tool: '', files }, RELAY_ORIGIN) } catch {}
534
206
  for (const rel of files) {
535
- if (state.held.some((h) => h.path === rel)) continue
536
- state.held.push({ path: rel, why: 'checks', sha, base: sha, theirs: sha, parties, merger, reviewer, mode: reviewer ? 'two' : 'outside', task: task ? task.id : null, since: now, assignedAt: now, candidate: null })
537
- out.push({ path: rel, held: true })
207
+ const parties = mc.parties[mc.files.indexOf(rel)] || {}
208
+ const names = [...new Set([parties.session && parties.session.name, parties.github && (parties.github.member || null)].filter(Boolean))]
209
+ const where = lines.filter((l) => l.path === rel).slice(0, 5).map((l) => `\n- ${rel}:${l.line} ${l.message}`).join('')
210
+ for (const n of names) dm(room, n, `GitHub's checks failed on ${sha.slice(0, 7)}, which carried Quilt's merge of ${rel} on ${key} (your edit and ${names.find((x) => x !== n) || 'one from GitHub'}'s). It is not held: a fix goes in like any edit.${where}`)
211
+ out.push({ path: rel, told: names })
538
212
  }
539
- if (task) post(room, task.id, [`GitHub's checks failed on ${sha.slice(0, 7)}, which carried the merge of ${files.join(', ')}. ${files.length === 1 ? 'It is' : 'They are'} held (not pushed again) until a fix passes the checks, or a person accepts the failure.`, ...lines.filter((l) => files.includes(l.path)).slice(0, 10).map((l) => `- ${l.path}:${l.line} ${l.message}`), '', `Merger: ${merger || 'nobody online yet'}; reviewer: ${reviewer || 'none'}. Propose the fix with quilt_clash propose (with the checks you ran passing).`].join('\n'))
540
- for (const n of names) dm(room, n, `GitHub's checks failed after the merge of ${files.join(', ')} on ${key} (task ${task ? task.id : '?'}): ${n === merger ? 'you fix it' : `${merger || 'an agent'} fixes it and you review`}.`)
541
- room.saveMeta()
542
213
  return out
543
214
  }
544
-
545
- /** For tests: who is told what when a clash has nobody to merge it. */
546
- export const tellClashRoles = tellRoles
547
-
548
- /**
549
- * A member came back with offline edits that clash with the session's (plan §7.1; session.js
550
- * openConflict): the session's version stays, theirs is kept in the mirror, and the clash goes the
551
- * same way as one with GitHub (merger, reviewer, proposal, acceptance). `ours` is the returning
552
- * member's text (null: they deleted it); `base` what they last had.
553
- */
554
- export async function openOfflineClash (room, key, { who, path: rel, mergeId = null, base = null, ours = null, oursDeleted = false, binary = false }, { now = Date.now() } = {}) {
555
- if (!isSafeRelPath(rel)) throw new Error('not a path in the project')
556
- const state = syncStateOf(room, key)
557
- const had = state.held.find((h) => h.path === rel)
558
- if (had) return had
559
- const dir = await mirrorDirOf(room, key)
560
- const e = room.branchDoc(key)
561
- const session = docKey(e, rel)
562
- const h = {
563
- path: rel, why: 'clash', kind: 'offline', since: now, mergeId, base: null, theirs: null,
564
- binary: !!binary || (session !== null && !isText(session)),
565
- baseBlob: typeof base === 'string' ? await storeBlob(dir, base) : null,
566
- otherBlob: typeof ours === 'string' ? await storeBlob(dir, ours) : null,
567
- otherDeleted: !!oursDeleted,
568
- oursBlob: isText(session) ? await storeBlob(dir, session) : null,
569
- was: fingerprint(session),
570
- parties: { session: sessionParty(e, rel, { except: who.name }), github: null, offline: { name: who.name, ts: now } }
571
- }
572
- Object.assign(h, chooseRoles(room, key, h), { assignedAt: now, candidate: null })
573
- state.held.push(h)
574
- const tasks = room.tasks || room.doc.getMap('tasks')
575
- const sideS = h.parties.session ? h.parties.session.name : 'the session'
576
- let task = null
577
- try { task = addTask(room.doc, tasks, { title: clip(`Merge ${rel} on ${key}: ${who.name} edited it offline, and ${sideS} in the session`, 200), by: RELAY_BY, assignee: h.merger || '', forAi: false, tool: '', files: [rel] }, RELAY_ORIGIN) } catch {}
578
- h.task = task ? task.id : null
579
- if (task) {
580
- post(room, task.id, `${rel} is held on \`${key}\`: ${who.name} changed it while offline, and ${sideS} changed the same lines in the session. The session's version stays as it is; ${who.name}'s is kept. Merger: ${h.merger || 'nobody yet'}; reviewer: ${h.reviewer || 'none'}. Propose the merged file with quilt_clash propose; the reviewer accepts. Or keep one side: keep_session, keep_offline.`)
581
- const b = typeof base === 'string' ? base : ''
582
- if (isText(session)) post(room, task.id, `The session's change (base → session):\n\`\`\`diff\n${lineDiff(b, session, { max: MAX_COMMENT - 200 })}\n\`\`\``)
583
- if (typeof ours === 'string') post(room, task.id, `${who.name}'s offline change (base → theirs):\n\`\`\`diff\n${lineDiff(b, ours, { max: MAX_COMMENT - 200 })}\n\`\`\``)
584
- }
585
- tellRoles(room, key, h, { fresh: true })
586
- room.saveMeta()
587
- room.log && room.log(`[${room.name}] ${key}: ${who.name}'s offline edits to ${rel} clash with the session's: merger ${h.merger || 'none'}, reviewer ${h.reviewer || 'none'}`)
588
- return h
589
- }
590
-
591
- /** A merge the relay worked out word by word (merge3.js mergeSmart): suggested, never landed by itself (addendum 16.8). */
592
- export async function suggestCandidate (room, key, h, text, { dir, now = Date.now(), how = 'words' } = {}) {
593
- const blob = await storeBlob(dir, text)
594
- h.candidate = { by: RELAY_BY, at: now, fp: sha1(text), blob, checks: null, generated: how }
595
- if (h.task) post(room, h.task, `The relay can merge ${h.path} ${how === 'words' ? 'word by word' : how}, but a merge like that can change what the code means, so it is only a suggestion: ${h.merger || 'the merger'} or ${h.reviewer || 'the reviewer'} accepts it with quilt_clash accept (with how the project's checks went on it), or proposes another.\n\`\`\`\n${clip(text, MAX_COMMENT - 400)}\n\`\`\``)
596
- return h
597
- }