heyquilt 0.0.1 → 0.3.90

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 (204) hide show
  1. package/LICENSE +85 -0
  2. package/README.md +630 -2
  3. package/RELEASES.md +733 -0
  4. package/assets/logo.svg +1 -0
  5. package/bin/quilt.js +816 -0
  6. package/desktop/main.js +315 -0
  7. package/package.json +163 -7
  8. package/src/account.js +563 -0
  9. package/src/admit-policy.js +32 -0
  10. package/src/agent-join.js +370 -0
  11. package/src/agent-task-workflow.js +176 -0
  12. package/src/agents/actions.js +85 -0
  13. package/src/agents/claude-code.js +180 -0
  14. package/src/agents/common.js +21 -0
  15. package/src/agents/cursor-transcripts.js +170 -0
  16. package/src/agents/cursor.js +404 -0
  17. package/src/agents/index.js +69 -0
  18. package/src/ai-vm-signin.js +98 -0
  19. package/src/ai-vm.js +380 -0
  20. package/src/api/access.js +75 -0
  21. package/src/api/activity.js +181 -0
  22. package/src/api/agent-auth.js +130 -0
  23. package/src/api/agent-placement.js +157 -0
  24. package/src/api/audit.js +83 -0
  25. package/src/api/auth.js +17 -0
  26. package/src/api/domains.js +30 -0
  27. package/src/api/file-paths.js +40 -0
  28. package/src/api/file-store.js +99 -0
  29. package/src/api/fly-machines.js +76 -0
  30. package/src/api/github-app.js +172 -0
  31. package/src/api/http.js +34 -0
  32. package/src/api/invite-email.js +53 -0
  33. package/src/api/issues.js +110 -0
  34. package/src/api/join-text.js +78 -0
  35. package/src/api/mailer.js +47 -0
  36. package/src/api/memory-store.js +1083 -0
  37. package/src/api/org-access.js +54 -0
  38. package/src/api/permissions.js +71 -0
  39. package/src/api/relay-cluster.js +251 -0
  40. package/src/api/relay-machines.js +60 -0
  41. package/src/api/routes/access-types.js +54 -0
  42. package/src/api/routes/agent-invites.js +115 -0
  43. package/src/api/routes/agents.js +157 -0
  44. package/src/api/routes/ai-vm.js +369 -0
  45. package/src/api/routes/github.js +380 -0
  46. package/src/api/routes/grants.js +70 -0
  47. package/src/api/routes/invites.js +188 -0
  48. package/src/api/routes/issues.js +47 -0
  49. package/src/api/routes/join.js +167 -0
  50. package/src/api/routes/members.js +110 -0
  51. package/src/api/routes/orgs.js +177 -0
  52. package/src/api/routes/relay-cluster.js +90 -0
  53. package/src/api/routes/relay.js +76 -0
  54. package/src/api/routes/session-invites.js +119 -0
  55. package/src/api/routes/sessions.js +92 -0
  56. package/src/api/routes/teams.js +132 -0
  57. package/src/api/routes/workspace-agents.js +332 -0
  58. package/src/api/routes/workspace-files.js +277 -0
  59. package/src/api/routes/workspace-invites.js +196 -0
  60. package/src/api/routes/workspaces.js +225 -0
  61. package/src/api/server.js +623 -0
  62. package/src/api/slugs.js +20 -0
  63. package/src/api/supabase-store.js +765 -0
  64. package/src/api/team-access.js +30 -0
  65. package/src/api/tokens.js +31 -0
  66. package/src/api/workspace-access.js +62 -0
  67. package/src/api/workspace-reach.js +60 -0
  68. package/src/autowork-tools.js +166 -0
  69. package/src/autowork.js +387 -0
  70. package/src/blobstore.js +99 -0
  71. package/src/boxes.js +37 -0
  72. package/src/branchdocs.js +235 -0
  73. package/src/branches.js +194 -0
  74. package/src/catchup.js +111 -0
  75. package/src/chat-archive.js +85 -0
  76. package/src/chat-links.js +923 -0
  77. package/src/clash.js +140 -0
  78. package/src/cloud-ai.js +50 -0
  79. package/src/commit-request.js +59 -0
  80. package/src/commit.js +213 -0
  81. package/src/connection.js +569 -0
  82. package/src/control.js +245 -0
  83. package/src/conversation.js +125 -0
  84. package/src/doctor.js +156 -0
  85. package/src/duties.js +303 -0
  86. package/src/editors.js +228 -0
  87. package/src/fsutil.js +87 -0
  88. package/src/git-hook.js +48 -0
  89. package/src/git.js +147 -0
  90. package/src/github-commit.js +320 -0
  91. package/src/gitignore.js +49 -0
  92. package/src/gitstate.js +900 -0
  93. package/src/history.js +307 -0
  94. package/src/hooks.js +371 -0
  95. package/src/hosted-agents.js +24 -0
  96. package/src/hosted-client.js +112 -0
  97. package/src/identity.js +99 -0
  98. package/src/inbox.js +237 -0
  99. package/src/integrations.js +221 -0
  100. package/src/key-cli.js +115 -0
  101. package/src/largefiles.js +91 -0
  102. package/src/legacy.js +25 -0
  103. package/src/mcp-proxy.js +40 -0
  104. package/src/mcp-room.js +32 -0
  105. package/src/mcp.js +1261 -0
  106. package/src/merge3.js +158 -0
  107. package/src/merges.js +148 -0
  108. package/src/pass-source.js +176 -0
  109. package/src/passes.js +64 -0
  110. package/src/pathrules.js +127 -0
  111. package/src/persona.js +101 -0
  112. package/src/presence.js +360 -0
  113. package/src/private-file.js +41 -0
  114. package/src/procs.js +70 -0
  115. package/src/project-rules.js +178 -0
  116. package/src/protocol.js +92 -0
  117. package/src/quiet-warnings.js +9 -0
  118. package/src/relay-ai-vm.js +82 -0
  119. package/src/relay-ask.js +141 -0
  120. package/src/relay-ci.js +111 -0
  121. package/src/relay-clash.js +597 -0
  122. package/src/relay-cluster.js +454 -0
  123. package/src/relay-commit.js +887 -0
  124. package/src/relay-filekey.js +46 -0
  125. package/src/relay-git.js +274 -0
  126. package/src/relay-githttp.js +137 -0
  127. package/src/relay-github.js +109 -0
  128. package/src/relay-lease.js +137 -0
  129. package/src/relay-lfs.js +37 -0
  130. package/src/relay-mcp.js +1679 -0
  131. package/src/relay-metrics.js +75 -0
  132. package/src/relay-outbound.js +1144 -0
  133. package/src/relay-sync.js +719 -0
  134. package/src/relay-turn-sync.js +307 -0
  135. package/src/relay-upstream.js +934 -0
  136. package/src/relay-webhooks.js +111 -0
  137. package/src/releases.js +131 -0
  138. package/src/report.js +129 -0
  139. package/src/runner.js +306 -0
  140. package/src/same-edit.js +18 -0
  141. package/src/server.js +4187 -0
  142. package/src/session-access.js +170 -0
  143. package/src/session-name.js +15 -0
  144. package/src/session.js +7361 -0
  145. package/src/settings.js +46 -0
  146. package/src/setup.js +184 -0
  147. package/src/shell-path.js +46 -0
  148. package/src/status.js +199 -0
  149. package/src/summarize.js +109 -0
  150. package/src/sync-words.js +42 -0
  151. package/src/task-comments.js +92 -0
  152. package/src/tasks.js +670 -0
  153. package/src/textdiff.js +20 -0
  154. package/src/turn-sync-plan.js +142 -0
  155. package/src/turn-sync.js +191 -0
  156. package/src/ui/access-form.js +37 -0
  157. package/src/ui/agent-env-key.js +76 -0
  158. package/src/ui/agent-guide.js +127 -0
  159. package/src/ui/agent-kinds.js +175 -0
  160. package/src/ui/agent-place.js +265 -0
  161. package/src/ui/app.css +2309 -0
  162. package/src/ui/app.js +542 -0
  163. package/src/ui/board.js +465 -0
  164. package/src/ui/boxes.js +76 -0
  165. package/src/ui/branches.js +111 -0
  166. package/src/ui/catchup.js +77 -0
  167. package/src/ui/changes.js +159 -0
  168. package/src/ui/chat.js +266 -0
  169. package/src/ui/cloud-ai-model.js +107 -0
  170. package/src/ui/cloud-ai.js +221 -0
  171. package/src/ui/common.js +431 -0
  172. package/src/ui/csv.js +21 -0
  173. package/src/ui/diffview.js +74 -0
  174. package/src/ui/feed-convs.js +39 -0
  175. package/src/ui/feed.js +174 -0
  176. package/src/ui/files.js +579 -0
  177. package/src/ui/fileview.js +123 -0
  178. package/src/ui/git-rule.js +60 -0
  179. package/src/ui/home.js +1323 -0
  180. package/src/ui/index.html +16 -0
  181. package/src/ui/invite.js +97 -0
  182. package/src/ui/invites.js +79 -0
  183. package/src/ui/loom-model.js +516 -0
  184. package/src/ui/loom.css +349 -0
  185. package/src/ui/loom.js +753 -0
  186. package/src/ui/mark.js +104 -0
  187. package/src/ui/merges.js +158 -0
  188. package/src/ui/rail-model.js +78 -0
  189. package/src/ui/rail.js +173 -0
  190. package/src/ui/releases.js +182 -0
  191. package/src/ui/schedule.js +156 -0
  192. package/src/ui/session.js +3394 -0
  193. package/src/ui/signin.js +75 -0
  194. package/src/ui/sounds.js +110 -0
  195. package/src/ui/tool-logo.js +86 -0
  196. package/src/ui/tree.js +276 -0
  197. package/src/ui/workspaces.js +542 -0
  198. package/src/ui-server.js +1272 -0
  199. package/src/update-check.js +67 -0
  200. package/src/upstream.js +155 -0
  201. package/src/webhooks.js +235 -0
  202. package/src/workspace-cli.js +62 -0
  203. package/src/workspace-tools.js +340 -0
  204. package/index.js +0 -2
@@ -0,0 +1,887 @@
1
+ // quilt_commit, on the relay: a member's finished work committed to GitHub from the session's
2
+ // copy of the files (github-commit.js), for an agent on any tool or a person, from a folder
3
+ // (through its connection) or over HTTP (hosted agents). The relay is always there, so an
4
+ // agent's work is never held up waiting for a person to be online.
5
+ import { parseRepo, upstreamsOf, validToken } from './relay-upstream.js'
6
+ import { RELAY_BY, isRelay } from './branches.js'
7
+ import { DEFAULT_KEY } from './branchdocs.js'
8
+ import { appCredentials, noCredentialsText } from './relay-github.js'
9
+ import crypto from 'node:crypto'
10
+ import { notePublished, notePublishedIds, publishedOf } from './upstream.js'
11
+ import { mayChange } from './session-access.js'
12
+ import { isSafeRelPath, makeIgnore, scopeIgnore, isIgnored } from './pathrules.js'
13
+ import {
14
+ CommitRefused, commitTarget, commitOnGitHub, githubWriter, gitBlobSha, messageFor, noreply, othersEditing, othersInChange, writeProblem,
15
+ AGENT_COMMITS, DEFAULT_AGENT_COMMITS, MAX_COMMIT_FILES, MAX_COMMIT_BYTES, agentPrefix, slug, ruleSetter, gitForEveryoneWords
16
+ } from './github-commit.js'
17
+ import { readTasks, addTask, updateTask } from './tasks.js'
18
+ import { addComment } from './task-comments.js'
19
+
20
+ export { CommitRefused }
21
+
22
+ // The origin of the relay's own writes to the room's record of what was published.
23
+ const RELAY_PUBLISH = 'relay-publish'
24
+
25
+ /** How strict each mode is: a rule over a session is a ceiling, and the strictest holds. */
26
+ export const POLICY_RANK = { off: 0, branches: 1, any: 2 }
27
+
28
+ /**
29
+ * Git for this session (docs/superpowers/specs/2026-10-17-git-for-everyone.md): 'off' (for everyone, people
30
+ * included), or on with what agents may push: 'branches' (their own) or 'any'. A rule over the session (its
31
+ * org's, a team's, its workspace's: server.js refreshPolicy) is a ceiling: the owner's own choice holds only
32
+ * when it is stricter. Neither set: the default (branches).
33
+ */
34
+ export const agentCommitsOf = (room) => {
35
+ const set = room.meta.agentCommitsSet
36
+ const rule = set && AGENT_COMMITS.includes(set.mode) ? set.mode : null
37
+ const own = AGENT_COMMITS.includes(room.meta.agentCommits) ? room.meta.agentCommits : null
38
+ if (rule && own) return POLICY_RANK[own] < POLICY_RANK[rule] ? own : rule
39
+ return rule || own || DEFAULT_AGENT_COMMITS
40
+ }
41
+
42
+ export { ruleSetter }
43
+
44
+ export const POLICY_WORDS = {
45
+ off: 'git is off for everyone here: nobody pushes through Quilt, and agents ask a person to commit (quilt_request_commit)',
46
+ branches: 'git is on for everyone here: people push any branch, agents push to branches of their own (quilt/<agent>/…) and open pull requests; people merge them',
47
+ any: 'git is on for everyone here: people and agents push to any branch, the session\'s own included'
48
+ }
49
+
50
+ const cleanList = (files) => {
51
+ const out = []
52
+ for (const f of Array.isArray(files) ? files : []) {
53
+ const p = String(f || '').trim().replace(/\\/g, '/').replace(/^\.\//, '')
54
+ if (isSafeRelPath(p) && !out.includes(p)) out.push(p)
55
+ }
56
+ return out
57
+ }
58
+
59
+ /**
60
+ * What the relay commits to owner/name with: the session owner's GitHub connection (relay-github.js),
61
+ * or a token the owner set by hand (quilt_github_token). Throws CommitRefused saying what the owner
62
+ * can do when there is neither.
63
+ */
64
+ export async function credentialsFor (room, repo, { actingFor = '' } = {}) {
65
+ const full = `${repo.owner}/${repo.name}`
66
+ const r = await appCredentials(room, full, { access: 'write', actingFor })
67
+ if (r.token) return r.token
68
+ if (validToken(room.meta.githubToken)) return room.meta.githubToken
69
+ tellOwner(room, full, r)
70
+ throw new CommitRefused(noCredentialsText(r, full))
71
+ }
72
+
73
+ // An agent was held up for want of a GitHub connection: the session owner hears it once (a direct
74
+ // message in chat that needs no reply), at most every six hours per repository, with the one click that fixes it.
75
+ const TOLD_MS = 6 * 60 * 60 * 1000
76
+ function tellOwner (room, full, r) {
77
+ const owner = room.meta && room.meta.ownerName
78
+ if (!owner || !room.postChat || !['not-connected', 'not-installed', 'no-access'].includes(r.state)) return
79
+ if (!room.toldConnect) room.toldConnect = new Map()
80
+ const at = room.toldConnect.get(full) || 0
81
+ if (Date.now() - at < TOLD_MS) return
82
+ room.toldConnect.set(full, Date.now())
83
+ const what = r.state === 'not-installed' ? `Quilt isn't installed on ${full} on GitHub` : r.state === 'no-access' ? `no connected GitHub account (@${r.login}) may write to ${full}` : `nobody here has connected GitHub for ${full}`
84
+ room.postChat({ by: RELAY_BY, to: owner, text: `An agent tried to commit, but ${what}. Click Connect GitHub in the commit panel (one click, once): agents then commit and merge on their own, and pushes come in within seconds.`, kind: 'commit' })
85
+ }
86
+
87
+ /** The session branch's repository on GitHub, or throws CommitRefused saying why the relay can't act on it. */
88
+ /**
89
+ * The branch record that says where work on `key` lands on GitHub: the branch's own when the relay
90
+ * follows it there (a member's folder has it as its upstream), otherwise the room's default
91
+ * branch's (a branch an agent made in its folder for a feature has no upstream yet; its work is
92
+ * measured against, and merges into, the default branch). Null when neither is known.
93
+ */
94
+ export function repoRecordFor (room, key) {
95
+ const recs = upstreamsOf(room)
96
+ const k = room.resolveKey ? room.resolveKey(key) : key
97
+ const own = recs[k]
98
+ // An agent's branch (quilt/<agent>/…) always measures against, and merges into, the default
99
+ // branch, even once the relay has a record of the branch itself (its folder pushed it).
100
+ const agents = /^quilt\//.test(k)
101
+ if (own && own.repo && own.ref && !agents) return { key: k, rec: own }
102
+ const d = room.resolveKey ? room.resolveKey(DEFAULT_KEY) : DEFAULT_KEY
103
+ const main = recs[d]
104
+ if (main && main.repo && main.ref) return { key: d, rec: main }
105
+ // No default named yet: main or master, else the room's one followed branch that isn't an agent's.
106
+ for (const name of ['main', 'master']) if (recs[name] && recs[name].repo && recs[name].ref) return { key: name, rec: recs[name] }
107
+ const followed = Object.entries(recs).filter(([n, r]) => r && r.repo && r.ref && !/^quilt\//.test(n))
108
+ if (followed.length === 1) return { key: followed[0][0], rec: followed[0][1] }
109
+ if (own && own.repo && own.ref) return { key: k, rec: own }
110
+ return null
111
+ }
112
+
113
+ export function githubRepoOf (room, key) {
114
+ const found = repoRecordFor(room, key)
115
+ if (!found) throw new CommitRefused(`Quilt doesn't know which GitHub repository \`${room.resolveKey ? room.resolveKey(key) : key}\` follows yet: a member's folder on it that is a git clone tells it once it is online.`)
116
+ const { rec } = found
117
+ const repo = parseRepo(`https://${rec.repo}`)
118
+ if (!repo || !repo.github) throw new CommitRefused(`\`${found.key}\` follows ${rec.repo}, which isn't on GitHub: commit from a folder with git, or ask a person (quilt_request_commit).`)
119
+ return { key: found.key, rec, repo, full: `${repo.owner}/${repo.name}` }
120
+ }
121
+
122
+ /** git's ignore rules for a branch: every .gitignore in the session's copy, scoped to its folder, plus Quilt's own. */
123
+ function gitIgnoreOf (e) {
124
+ const texts = []
125
+ e.files.forEach((t, rel) => {
126
+ if (rel !== '.gitignore' && !rel.endsWith('/.gitignore')) return
127
+ const dir = rel.includes('/') ? rel.slice(0, rel.lastIndexOf('/')) : ''
128
+ texts.push(scopeIgnore(t.toString(), dir))
129
+ })
130
+ return makeIgnore(texts)
131
+ }
132
+
133
+ /** The session's copy of a file on a branch: its bytes, null when it isn't there, or throws for one the relay can't read. */
134
+ function sessionBytes (e, rel) {
135
+ const t = e.files.get(rel)
136
+ if (t) return Buffer.from(t.toString(), 'utf8')
137
+ const b = e.blobs.get(rel)
138
+ if (b && b.stored) throw new CommitRefused(`${rel} is a large file kept encrypted in storage, which the relay can't read: commit it from a folder with git.`)
139
+ if (b) return Buffer.from(b.data || '', 'base64')
140
+ return null
141
+ }
142
+
143
+ /**
144
+ * The committer's boxes on files now in git open (docs/superpowers/specs/2026-10-16-git-boxes-design.md):
145
+ * committed, they're no longer unfinished work, so they reach everyone's folder. Returns how many opened.
146
+ */
147
+ export function openBoxesCommitted (room, who, key, rels) {
148
+ if (!room.claimFor || !room.dropClaims) return 0
149
+ const boxes = [...new Set(rels || [])].map((rel) => room.claimFor(rel, key)).filter((c) => c && c.box && c.pattern && c.by === who.name && !/[*?[]/.test(c.pattern))
150
+ if (!boxes.length) return 0
151
+ const n = room.dropClaims((c) => boxes.includes(c), (c) => `${c.by} committed it.`)
152
+ if (room.saveMeta) room.saveMeta()
153
+ if (room.broadcastClaims) room.broadcastClaims()
154
+ return n
155
+ }
156
+
157
+ /**
158
+ * Commits `files` (or, without them, the files `who` changed on record, or a task's) from
159
+ * branch `key` of the session to GitHub. `who`: { name, kind ('agent' | 'human'), access }.
160
+ * Resolves { sha, branch, created, changed, same, url, pr, nothing } or throws (CommitRefused, or
161
+ * an Error saying what GitHub refused).
162
+ */
163
+ export async function commitForMember (room, { who, key, files = null, message = '', branch: target = '', pullRequest = false, withOthers = false, task = null }, { fetch = globalThis.fetch, log = () => {} } = {}) {
164
+ const a = who.access || {}
165
+ if (a.state && a.state !== 'approved') throw new CommitRefused('You are not let into this session yet.')
166
+ if (a.role === 'viewer') throw new CommitRefused('You can only view this session, so you can\'t commit.')
167
+ key = room.resolveKey ? room.resolveKey(key) : key
168
+ // The files come from `key` (the member's branch document); where they land on GitHub is that
169
+ // branch's record, or the default branch's for a branch the relay has no record of yet (an agent's own).
170
+ const found = repoRecordFor(room, key)
171
+ const rec = found ? found.rec : null
172
+ if (!rec) throw new CommitRefused(`Quilt doesn't know which GitHub repository \`${key}\` follows yet: a member's folder on it that is a git clone tells it once it is online.`)
173
+ if (!rec.sha) throw new CommitRefused(`Quilt doesn't know which commit the session's files on \`${key}\` are at yet: a member's folder on it, in step with the session, tells it.`)
174
+ const repo = parseRepo(`https://${rec.repo}`)
175
+ if (!repo || !repo.github) throw new CommitRefused(`\`${key}\` follows ${rec.repo}, which isn't on GitHub: commit from a folder with git, or ask a person (quilt_request_commit).`)
176
+ // The commit is for `who`: when the org requires GitHub, their (or their agent's person's) GitHub permissions count (plan §2).
177
+ const actor = Object.entries((room.meta && room.meta.members) || {}).find(([k, m]) => m && m.name === who.name && /^(person|agent):/.test(k))
178
+ const token = await credentialsFor(room, repo, { actingFor: actor ? actor[0] : '' })
179
+ const t = task ? room.doc.getMap('tasks').get(String(task)) : null
180
+ if (task && !t) throw new CommitRefused(`No task ${task} on the board.`)
181
+ message = String(message || (t && t.title) || '').trim().slice(0, 2000)
182
+ if (!message) throw new CommitRefused('Say what the commit does (message).')
183
+ const where = commitTarget({ policy: agentCommitsOf(room), kind: who.kind, name: who.name, sessionBranch: rec.ref, target, message })
184
+
185
+ const e = room.branchDoc(key)
186
+ const history = e.doc.getArray('history').toArray()
187
+ let list = cleanList(files)
188
+ if (Array.isArray(files) && files.length && !list.length) throw new CommitRefused('None of those are files in the project (give paths relative to its top folder).')
189
+ if (!list.length) {
190
+ // What was changed for the task, or by this member, on record (pulls from git are nobody's).
191
+ for (const h of history) {
192
+ if (!h || h.pulled || h.seed || typeof h.path !== 'string') continue
193
+ if (t ? h.task && h.task.id === t.id : h.by === who.name) { if (!list.includes(h.path)) list.push(h.path) }
194
+ }
195
+ if (t) for (const f of Array.isArray(t.files) ? t.files : []) if (!list.includes(f)) list.push(f)
196
+ list = cleanList(list)
197
+ }
198
+ if (!list.length) throw new CommitRefused(t ? `Task ${t.id} has no changed files on record: give them in files.` : 'Quilt has no changes of yours on record on this branch: give the files in files.')
199
+ if (list.length > MAX_COMMIT_FILES) throw new CommitRefused(`That is ${list.length} files; commit at most ${MAX_COMMIT_FILES} at a time.`)
200
+ // What git ignores never goes in: the repository's .gitignore files (as the session has them, each
201
+ // scoped to its folder) and Quilt's own list (.env and the like), whatever a member wrote.
202
+ const ig = gitIgnoreOf(e)
203
+ const ignored = list.filter((rel) => isIgnored(ig, rel))
204
+ if (ignored.length && ignored.length === list.length) throw new CommitRefused(`${ignored.slice(0, 5).join(', ')}${ignored.length > 5 ? ', …' : ''} ${ignored.length === 1 ? 'is' : 'are'} ignored by the repository's .gitignore (or is a secrets file such as .env), so ${ignored.length === 1 ? 'it isn\'t' : 'they aren\'t'} committed.`)
205
+ list = list.filter((rel) => !ignored.includes(rel))
206
+ const outside = list.filter((rel) => !mayChange(a, rel))
207
+ if (outside.length) throw new CommitRefused(`You may not change ${outside.slice(0, 5).join(', ')}${outside.length > 5 ? ', …' : ''} in this session, so you can't commit ${outside.length === 1 ? 'it' : 'them'}.`)
208
+ const content = new Map()
209
+ let bytes = 0
210
+ for (const rel of list) {
211
+ const b = sessionBytes(e, rel)
212
+ bytes += b ? b.length : 0
213
+ content.set(rel, b)
214
+ }
215
+ if (bytes > MAX_COMMIT_BYTES) throw new CommitRefused(`Those files come to ${Math.round(bytes / 1048576)} MB; commit at most ${MAX_COMMIT_BYTES / 1048576} MB at a time.`)
216
+
217
+ const gh = githubWriter({ fetch, token, owner: repo.owner, name: repo.name })
218
+ const full = `${repo.owner}/${repo.name}`
219
+ try {
220
+ // Other people's unfinished work in these files would go in with them: said, not swept in.
221
+ const base = await gh.commit(rec.sha)
222
+ const baseBlobs = await gh.blobs(base.tree)
223
+ const differ = baseBlobs ? list.filter((rel) => (content.get(rel) ? gitBlobSha(content.get(rel)) : null) !== (baseBlobs.get(rel) || null)) : list
224
+ // Each changed line is put down to whoever added or removed it last (the session's history);
225
+ // a file the relay can't read as text falls back to who edited it since the session's commit.
226
+ const others = new Map()
227
+ for (const rel of differ) {
228
+ const now = content.get(rel)
229
+ const text = !e.blobs.get(rel) && (now === null || e.files.get(rel))
230
+ let names
231
+ if (text) {
232
+ const was = baseBlobs && baseBlobs.get(rel) ? await gh.content(rec.sha, rel) : null
233
+ names = othersInChange({ before: was ? was.toString('utf8') : '', after: now ? now.toString('utf8') : '', entries: history.filter((h) => h && h.path === rel), me: who.name })
234
+ } else names = othersEditing([rel], history, { me: who.name, since: base.ts }).get(rel) || []
235
+ names = names.filter((n) => !isRelay(n))
236
+ if (names.length) others.set(rel, names)
237
+ }
238
+ if (others.size && !withOthers) {
239
+ const lines = [...others].map(([rel, names]) => `${rel} (${names.join(', ')})`)
240
+ throw new CommitRefused(`These files also hold changes by others that are not in git yet: ${lines.join('; ')}. ` +
241
+ 'Leave them out (files), ask those people to commit first, or commit them together with with_others: true (they are named as co-authors).')
242
+ }
243
+ const coAuthors = [...new Set([...others.values()].flat())]
244
+ const r = await commitOnGitHub(gh, {
245
+ files: content,
246
+ branch: where.branch,
247
+ from: rec.sha,
248
+ base: where.own ? null : rec.sha,
249
+ message: messageFor({ message, by: who.name, task: t ? { id: t.id, title: t.title } : null, coAuthors: withOthers ? coAuthors : [] }),
250
+ author: who.kind === 'agent' ? { name: who.name, email: noreply(who.name) } : null
251
+ })
252
+ const out = { ...r, repo: full, url: `https://github.com/${full}/commit/${r.sha}`, ...(ignored.length ? { ignored } : {}) }
253
+ const pr = () => gh.pull({ title: message.split('\n')[0].slice(0, 200), head: where.branch, base: rec.ref, body: `Made in Quilt by ${who.name}${t ? ` for task ${t.id}: ${t.title}` : ''}.\n\n${message}` })
254
+ // Already committed: a pull request can still be asked for, for a branch that is there.
255
+ // What git has now as the session does (committed, or already so) is no longer the committer's unfinished work.
256
+ openBoxesCommitted(room, who, key, [...(r.changed || []), ...(r.same || [])])
257
+ if (r.nothing) {
258
+ if (pullRequest && where.own && r.exists) { out.pr = await pr(); if (t) linkTaskPull(room, t.id, { number: out.pr.number, url: out.pr.url, branch: where.branch, base: rec.ref }, { log }) }
259
+ return out
260
+ }
261
+ if (pullRequest && where.own) {
262
+ out.pr = await pr()
263
+ if (t) linkTaskPull(room, t.id, { number: out.pr.number, url: out.pr.url, branch: where.branch, base: rec.ref }, { log })
264
+ }
265
+ if (where.own && r.sha) for (const x of readTasks(room.doc.getMap('tasks'))) if (x.pr && x.pr.branch === where.branch) noteTaskRevision(room, x.pr.number, r.sha)
266
+ // What went to GitHub, by content: when that branch is merged (or squashed, or rebased) into the
267
+ // session's branch, pulling it down keeps the session's copy, which is that or newer (upstream.js).
268
+ notePublished(room.doc, new Map(r.changed.map((rel) => [rel, content.get(rel)])), RELAY_PUBLISH)
269
+ log(`[${room.name}] ${key}: ${who.name} committed ${r.sha.slice(0, 7)} to ${full} ${where.branch} (${r.changed.length} files)`)
270
+ // Everyone sees what went into git, and by whom; it wakes nobody.
271
+ if (room.postChat) {
272
+ room.postChat({ by: RELAY_BY, text: `${who.name} committed ${r.sha.slice(0, 7)} to ${where.branch}${r.created ? ' (a new branch)' : ''}: "${message.split('\n')[0].slice(0, 200)}" (${r.changed.length} file${r.changed.length === 1 ? '' : 's'})${out.pr ? `, pull request ${out.pr.url}` : ''}.`, kind: 'commit' })
273
+ }
274
+ // The session's own branch moved. Made on the session's commit, the new one is where the session
275
+ // now stands (its files are the session's own): measured from it, so the next commit isn't taken
276
+ // for a change on GitHub. On a later commit, folders and the relay bring the rest in as usual.
277
+ if (!where.own) {
278
+ if (r.parent === rec.sha) { rec.sha = r.sha; rec.head = r.sha; room.saveMeta(); if (room.broadcastBranches) room.broadcastBranches() }
279
+ if (room.upstreamSoon) room.upstreamSoon(key)
280
+ }
281
+ closeRequests(room, who.name, new Set([...r.changed, ...r.same]), r.sha)
282
+ return out
283
+ } catch (err) {
284
+ if (err instanceof CommitRefused) throw err
285
+ throw new Error(writeProblem(err, full))
286
+ }
287
+ }
288
+
289
+ /** Open commit requests by `name` whose files are all in this commit: done, with its hash. */
290
+ function closeRequests (room, name, files, sha) {
291
+ const map = room.doc.getMap('commitRequests')
292
+ room.doc.transact(() => {
293
+ for (const r of map.values()) {
294
+ if (!r || r.state !== 'open' || r.by !== name || !Array.isArray(r.files) || !r.files.length) continue
295
+ if (r.files.every((f) => files.has(f))) map.set(r.id, { ...r, state: 'done', doneBy: name, hash: sha, doneAt: Date.now(), pushed: true })
296
+ }
297
+ })
298
+ }
299
+
300
+ /** quilt_commit's answer. */
301
+ export function describeCommit (r, { policy } = {}) {
302
+ if (r.nothing) return `Nothing to commit: ${r.same.length ? r.same.join(', ') : 'those files'} ${r.same.length === 1 ? 'is' : 'are'} already on ${r.branch} as they are in the session.${r.pr ? ` Pull request${r.pr.existed ? ' (already open)' : ''}: ${r.pr.url}` : ''}`
303
+ const lines = [`Committed ${r.sha.slice(0, 7)} to ${r.repo} ${r.branch}${r.created ? ' (new branch)' : ''}: ${r.changed.length} file${r.changed.length === 1 ? '' : 's'} (${r.changed.slice(0, 10).join(', ')}${r.changed.length > 10 ? ', …' : ''}). ${r.url}`]
304
+ if (r.same.length) lines.push(`Already as in the session, so not in the commit: ${r.same.slice(0, 10).join(', ')}${r.same.length > 10 ? ', …' : ''}.`)
305
+ if (r.ignored && r.ignored.length) lines.push(`Left out, as git ignores ${r.ignored.length === 1 ? 'it' : 'them'}: ${r.ignored.slice(0, 10).join(', ')}.`)
306
+ if (r.pr) lines.push(`Pull request${r.pr.existed ? ' (already open)' : ''}: ${r.pr.url}`)
307
+ else if (r.created && policy === 'branches') lines.push('Open a pull request for it with quilt_commit again and pull_request: true, or ask a person to merge the branch.')
308
+ return lines.join('\n')
309
+ }
310
+
311
+ /**
312
+ * The open pull requests of the session branch's repository, with how each one's checks stand:
313
+ * [{ number, title, head, base, url, by, draft, checks }]. Throws CommitRefused when the relay can't read it.
314
+ */
315
+ export async function pullRequestsFor (room, { key }, { fetch = globalThis.fetch } = {}) {
316
+ const { repo, full } = githubRepoOf(room, key)
317
+ const token = await credentialsFor(room, repo)
318
+ const gh = githubWriter({ fetch, token, owner: repo.owner, name: repo.name })
319
+ try {
320
+ const list = await gh.pulls()
321
+ const out = []
322
+ for (const p of list.slice(0, 20)) out.push({ ...p, checks: p.sha ? await gh.checks(p.sha).catch(() => ({ state: 'none', failing: [] })) : { state: 'none', failing: [] } })
323
+ return { repo: full, pulls: out }
324
+ } catch (err) { throw new Error(writeProblem(err, full)) }
325
+ }
326
+
327
+ /**
328
+ * Merges a pull request (by number, or the branch it comes from) into its base, for a member:
329
+ * people always (as far as GitHub lets the session's connection), agents only when the owner lets
330
+ * them commit to any branch. Refused while checks fail or are still running, unless `force`
331
+ * (people only). The head branch is deleted afterwards when it is a Quilt agent's (quilt/…) or
332
+ * `deleteBranch`. Resolves { sha, number, title, head, base, deleted }.
333
+ */
334
+ export async function mergeForMember (room, { who, key, number = null, branch = '', method = 'squash', deleteBranch = null, force = false }, { fetch = globalThis.fetch, log = () => {} } = {}) {
335
+ const a = who.access || {}
336
+ if (a.state && a.state !== 'approved') throw new CommitRefused('You are not let into this session yet.')
337
+ if (a.role === 'viewer') throw new CommitRefused('You can only view this session, so you can\'t merge.')
338
+ if (who.kind === 'agent' && agentCommitsOf(room) !== 'any') throw new CommitRefused('The session owner lets agents commit only to branches of their own (or not at all), so merging is for people: ask one in chat, with the pull request\'s link.')
339
+ if (!['merge', 'squash', 'rebase'].includes(method)) throw new CommitRefused('method is merge, squash or rebase.')
340
+ const { repo, full, rec } = githubRepoOf(room, key)
341
+ const token = await credentialsFor(room, repo)
342
+ const gh = githubWriter({ fetch, token, owner: repo.owner, name: repo.name })
343
+ try {
344
+ let n = Number(number) || 0
345
+ if (!n) {
346
+ const head = String(branch || '').trim().replace(/^refs\/heads\//, '')
347
+ if (!head) throw new CommitRefused('Say which pull request (number) or branch to merge.')
348
+ const open = (await gh.pulls()).find((p) => p.head === head)
349
+ if (!open) throw new CommitRefused(`No open pull request from ${head}. Open one first (push the branch from a folder, or quilt_git_commit on it without one: Quilt opens it).`)
350
+ n = open.number
351
+ }
352
+ const pr = await gh.pullRequest(n)
353
+ if (pr.merged) return { sha: '', number: n, title: pr.title, head: pr.head, base: pr.base, deleted: false, already: true }
354
+ if (pr.state !== 'open') throw new CommitRefused(`Pull request #${n} is closed.`)
355
+ const checks = await gh.checks(pr.sha)
356
+ if (checks.state === 'failing' && !(force && who.kind !== 'agent')) throw new CommitRefused(`Pull request #${n} has failing checks (${checks.failing.join(', ')}): fix them first${who.kind !== 'agent' ? ', or merge with force: true' : ''}.`)
357
+ if (checks.state === 'pending' && !(force && who.kind !== 'agent')) throw new CommitRefused(`Pull request #${n}'s checks are still running: try again when they have finished${who.kind !== 'agent' ? ', or merge with force: true' : ''}.`)
358
+ if (pr.mergeable === false) throw new CommitRefused(`Pull request #${n} can't be merged as it is (${pr.mergeableState || 'conflicts'}): bring ${pr.base} into ${pr.head} and resolve, then try again.`)
359
+ const r = await gh.merge(n, { method })
360
+ const del = deleteBranch === null ? /^quilt\//.test(pr.head) : !!deleteBranch
361
+ if (del && pr.head !== rec.ref) {
362
+ await gh.deleteBranch(pr.head)
363
+ const was = upstreamsOf(room)[room.resolveKey ? room.resolveKey(pr.head) : pr.head]
364
+ if (was) { was.gone = Date.now(); delete was.problem } // nothing more to bring into that branch; it stays in the session as it is
365
+ }
366
+ log(`[${room.name}] ${key}: ${who.name} merged #${n} (${pr.head} → ${pr.base}) as ${String(r.sha).slice(0, 7)}`)
367
+ if (room.postChat) room.postChat({ by: RELAY_BY, text: `${who.name} merged pull request #${n} (${pr.head} → ${pr.base}) as ${String(r.sha).slice(0, 7)}: "${pr.title}"${del ? `, and deleted ${pr.head}` : ''}.`, kind: 'commit' })
368
+ // The base moved: look now (GitHub's webhook says so too, when the app is on the repository).
369
+ if (pr.base === rec.ref && room.upstreamSoon) room.upstreamSoon(key)
370
+ return { sha: r.sha, number: n, title: pr.title, head: pr.head, base: pr.base, deleted: del }
371
+ } catch (err) {
372
+ if (err instanceof CommitRefused) throw err
373
+ throw new Error(writeProblem(err, full))
374
+ }
375
+ }
376
+
377
+ /**
378
+ * quilt_git_commit, for an agent without a folder: commits to the branch the agent is on (`key`,
379
+ * its branch document). A branch of its own gets its pull request opened and tied to its task; the
380
+ * session's branch itself is open to agents only under policy "any". Resolves commitForMember's
381
+ * result plus { pr } when one was opened or found.
382
+ */
383
+ export async function gitCommitFor (room, { who, key, message, files = null, task = null, withOthers = false }, { fetch = globalThis.fetch, log = () => {} } = {}) {
384
+ const { rec } = githubRepoOf(room, key)
385
+ const here = room.resolveKey ? room.resolveKey(key) : key
386
+ const own = who.kind === 'agent' && here.startsWith(agentPrefix(who.name))
387
+ const policy = agentCommitsOf(room)
388
+ if (who.kind === 'agent' && policy === 'off') throw new CommitRefused(`${gitForEveryoneWords({ policy: 'off', by: room.meta.agentCommitsSet && room.meta.agentCommitsSet.mode === 'off' ? room.meta.agentCommitsSet : null })} Ask a person for the commit with quilt_request_commit.`)
389
+ if (who.kind === 'agent' && !own && policy !== 'any') {
390
+ throw new CommitRefused(`You are on ${here}, and the owner lets agents commit only to branches of their own (${agentPrefix(who.name)}<topic>). Take the task (quilt_move_task to doing puts you on a branch for it) or quilt_git_branch with create: true, then commit there; the pull request opens by itself.`)
391
+ }
392
+ const target = here === rec.ref ? '' : here
393
+ const r = await commitForMember(room, { who, key: here, message, files, branch: target, pullRequest: false, withOthers, task }, { fetch, log })
394
+ if (own && (r.changed.length || r.exists)) {
395
+ try {
396
+ const pr = await openPullOnPush(room, { who, key: here, branch: here, files: r.changed, subject: String(message || '').split('\n')[0], taskId: task || null }, { fetch, log })
397
+ if (pr) r.pr = { number: pr.number, url: pr.url, existed: !r.changed.length || undefined }
398
+ } catch (err) { log(`[${room.name}] ${here}: committed, but could not open the pull request: ${err.message}`) }
399
+ }
400
+ return r
401
+ }
402
+
403
+ /** quilt_git_commit's answer. */
404
+ export function describeGitCommit (r, { policy } = {}) {
405
+ if (r.nothing) return `Nothing to commit: ${r.same.length ? r.same.join(', ') : 'those files'} ${r.same.length === 1 ? 'is' : 'are'} already on ${r.branch} as they are in the session.${r.pr ? ` Pull request #${r.pr.number}: ${r.pr.url}` : ''}`
406
+ const lines = [`Committed ${r.sha.slice(0, 7)} to ${r.repo} ${r.branch}${r.created ? ' (new branch)' : ''}: ${r.changed.length} file${r.changed.length === 1 ? '' : 's'} (${r.changed.slice(0, 10).join(', ')}${r.changed.length > 10 ? ', …' : ''}). ${r.url}`]
407
+ if (r.same.length) lines.push(`Already as in the session, so not in the commit: ${r.same.slice(0, 10).join(', ')}${r.same.length > 10 ? ', …' : ''}.`)
408
+ if (r.ignored && r.ignored.length) lines.push(`Left out, as git ignores ${r.ignored.length === 1 ? 'it' : 'them'}: ${r.ignored.slice(0, 10).join(', ')}.`)
409
+ if (r.pr) lines.push(`Pull request #${r.pr.number} (${r.pr.existed ? 'already open' : 'opened'}, tied to your task): ${r.pr.url}. Move the task to QA when the work is done and tested; Done merges it${policy === 'any' ? '' : ' (a person moves it)'}.`)
410
+ return lines.join('\n')
411
+ }
412
+
413
+ /** quilt_git_status: where an agent without a folder stands in git. */
414
+ export async function gitStatusFor (room, { who, key }, { fetch = globalThis.fetch } = {}) {
415
+ const here = room.resolveKey ? room.resolveKey(key) : key
416
+ const policy = agentCommitsOf(room)
417
+ const able = agentsCanCommit(room, here)
418
+ let repo = null; let base = null
419
+ try { const g = githubRepoOf(room, here); repo = g.full; base = g.rec.ref } catch {}
420
+ const own = who.kind === 'agent' && here.startsWith(agentPrefix(who.name))
421
+ const u = able ? await unpublishedFor(room, { key: here, who: who.name }, { fetch }) : { unpublished: [], awaitingMerge: [] }
422
+ const tasks = readTasks(room.doc.getMap('tasks')).filter((t) => !t.archived && t.assignee === who.name && t.column !== 'done')
423
+ const task = tasks.find((t) => t.pr && t.pr.branch === here) || tasks.find((t) => t.column === 'doing') || null
424
+ let onGitHub = null
425
+ if (repo && able) {
426
+ try {
427
+ const { repo: r } = githubRepoOf(room, here)
428
+ const gh = githubWriter({ fetch, token: await credentialsFor(room, r), owner: r.owner, name: r.name })
429
+ onGitHub = await gh.ref(here)
430
+ } catch {}
431
+ }
432
+ return { branch: here, base, repo, policy, able, own, onGitHub, unpublished: u.unpublished || [], awaitingMerge: u.awaitingMerge || [], task: task ? { id: task.id, title: task.title, column: task.column, pr: task.pr || null } : null }
433
+ }
434
+
435
+ export function describeGitStatus (s) {
436
+ const lines = [`On ${s.branch}${s.repo ? ` of ${s.repo}` : ''}${s.base && s.base !== s.branch ? ` (merges into ${s.base})` : ''}${s.onGitHub ? `, on GitHub at ${s.onGitHub.slice(0, 7)}` : s.onGitHub === null && s.repo && s.able ? ', not on GitHub yet' : ''}.`]
437
+ if (!s.repo) lines.push('The session doesn\'t follow a GitHub repository yet (a member\'s folder that is a clone tells it).')
438
+ else if (!s.able) lines.push(s.policy === 'off' ? 'Agents may not commit here (the owner\'s setting): ask a person with quilt_request_commit.' : 'Nobody has connected GitHub for this session yet, so the relay can\'t commit for you; ask the owner to connect it.')
439
+ else lines.push(`Agents may push ${s.policy === 'any' ? 'to any branch' : 'to branches of their own only'}${s.own ? '; this one is yours' : s.policy === 'any' ? '' : '; this one is not (take a task, or quilt_git_branch with create: true)'}.`)
440
+ if (s.unpublished.length) lines.push(`Your changes not in git yet: ${s.unpublished.slice(0, 15).join(', ')}${s.unpublished.length > 15 ? `, and ${s.unpublished.length - 15} more` : ''}. quilt_git_commit puts them on this branch.`)
441
+ else if (s.able) lines.push('Nothing of yours is uncommitted on this branch.')
442
+ if (s.awaitingMerge.length) lines.push(`On this branch but not merged into ${s.base} yet: ${s.awaitingMerge.slice(0, 15).join(', ')}.`)
443
+ if (s.task) lines.push(`Your task: "${s.task.title}" [${s.task.id}] in ${s.task.column}${s.task.pr ? `, pull request #${s.task.pr.number} ${s.task.pr.url}` : ', no pull request yet'}.`)
444
+ return lines.join('\n')
445
+ }
446
+
447
+ /** quilt_git_log: the branch's newest commits on GitHub (the branch it merges into when the branch itself isn't there yet). */
448
+ export async function gitLogFor (room, { key, n = 10 }, { fetch = globalThis.fetch } = {}) {
449
+ const here = room.resolveKey ? room.resolveKey(key) : key
450
+ const { repo, full, rec } = githubRepoOf(room, here)
451
+ const gh = githubWriter({ fetch, token: await credentialsFor(room, repo), owner: repo.owner, name: repo.name })
452
+ try {
453
+ const exists = here === rec.ref || !!(await gh.ref(here))
454
+ const branch = exists ? here : rec.ref
455
+ return { repo: full, branch, asked: here, commits: await gh.log(branch, n) }
456
+ } catch (err) { throw new Error(writeProblem(err, full)) }
457
+ }
458
+
459
+ export function describeGitLog (r) {
460
+ const head = r.branch === r.asked ? `Newest commits on ${r.repo} ${r.branch}:` : `${r.asked} isn't on GitHub yet; newest commits on ${r.branch}, which it starts from:`
461
+ if (!r.commits.length) return `${head} none.`
462
+ return `${head}\n${r.commits.map((c) => `- ${c.sha.slice(0, 7)} ${c.message.split('\n')[0].slice(0, 100)} (${c.author}${c.date ? `, ${c.date.slice(0, 10)}` : ''})`).join('\n')}`
463
+ }
464
+
465
+ /**
466
+ * quilt_git_diff: how the session's copy of files differs from GitHub (the branch's head, or the
467
+ * branch it merges into when it isn't on GitHub yet). `files`: those, or the agent's own changes.
468
+ */
469
+ export async function gitDiffFor (room, { who, key, files = null }, { fetch = globalThis.fetch, lineDiff } = {}) {
470
+ const here = room.resolveKey ? room.resolveKey(key) : key
471
+ const { repo, full, rec } = githubRepoOf(room, here)
472
+ const e = room.branchDoc(here)
473
+ let list = cleanList(files)
474
+ if (!list.length) list = (await unpublishedFor(room, { key: here, who: who.name }, { fetch })).unpublished
475
+ if (!list.length) return { repo: full, branch: here, against: null, files: [] }
476
+ const gh = githubWriter({ fetch, token: await credentialsFor(room, repo), owner: repo.owner, name: repo.name })
477
+ try {
478
+ const head = (here === rec.ref ? null : await gh.ref(here)) || await gh.ref(rec.ref)
479
+ const against = head === (await gh.ref(rec.ref)) && here !== rec.ref ? rec.ref : here
480
+ const out = []
481
+ for (const rel of list.slice(0, 20)) {
482
+ let now = null
483
+ try { now = sessionBytes(e, rel) } catch { out.push({ path: rel, note: 'a large stored file: not compared' }); continue }
484
+ const was = head ? await gh.content(head, rel) : null
485
+ const text = (b) => b === null || b === undefined ? '' : b.toString('utf8')
486
+ const binary = (b) => !!b && b.includes(0)
487
+ if (binary(now) || binary(was)) { out.push({ path: rel, note: now && was && Buffer.compare(now, was) === 0 ? 'same' : 'binary, differs' }); continue }
488
+ if (!now && !was) { out.push({ path: rel, note: 'not in the session, not on GitHub' }); continue }
489
+ if (now && was && Buffer.compare(now, was) === 0) { out.push({ path: rel, note: 'same' }); continue }
490
+ out.push({ path: rel, diff: lineDiff ? lineDiff(text(was), text(now)) : `${was ? '-' : ''}${now ? '+' : ''}`, created: !was, deleted: !now })
491
+ }
492
+ return { repo: full, branch: here, against, head, files: out }
493
+ } catch (err) { throw new Error(writeProblem(err, full)) }
494
+ }
495
+
496
+ export function describeGitDiff (r) {
497
+ if (!r.files.length) return `Nothing of yours differs from GitHub on ${r.branch}.`
498
+ const head = `The session's copy on ${r.branch} against GitHub's ${r.against}${r.head ? ` (${r.head.slice(0, 7)})` : ''}:`
499
+ return `${head}\n${r.files.map((f) => f.note ? `--- ${f.path}: ${f.note}` : `--- ${f.path}${f.created ? ' (new)' : f.deleted ? ' (deleted)' : ''}\n${f.diff}`).join('\n')}`
500
+ }
501
+
502
+ export const GIT_STATUS_DESCRIPTION = 'Where you stand in git: the branch you are on and what it merges into, the repository, what agents may push here, your changes not in git yet, what is on your branch but not merged, and your task\'s pull request. Read it before committing or when unsure what shipped.'
503
+ export const GIT_LOG_DESCRIPTION = 'The newest commits on GitHub for the branch you are on (or the branch it starts from while yours isn\'t pushed yet): who committed what, when.'
504
+ export const GIT_DIFF_DESCRIPTION = 'How the session\'s copy of files differs from GitHub: your uncommitted changes by default, or the files you name (unified diff per text file). Read it before committing, or to review what someone changed.'
505
+ export const GIT_REVERT_DESCRIPTION = 'Put GitHub\'s version of files back into the session (the branch\'s head, or the branch it starts from): undoes uncommitted changes to those files in the session, for everyone. Say which files; nothing else is touched.'
506
+
507
+ /** quilt_pull_requests' answer. */
508
+ export function describePulls ({ repo, pulls }) {
509
+ if (!pulls.length) return `No open pull requests in ${repo}.`
510
+ const checks = (c) => c.state === 'passing' ? 'checks passing' : c.state === 'failing' ? `checks failing: ${c.failing.join(', ')}` : c.state === 'pending' ? 'checks running' : 'no checks'
511
+ return `Open pull requests in ${repo}:\n${pulls.map((p) => `- #${p.number} ${p.title} (${p.head} → ${p.base}, by ${p.by || '?'}${p.draft ? ', draft' : ''}; ${checks(p.checks)}) ${p.url}`).join('\n')}`
512
+ }
513
+
514
+ /** quilt_merge's answer. */
515
+ export function describeMerge (r) {
516
+ if (r.already) return `Pull request #${r.number} (${r.head} → ${r.base}) was already merged.`
517
+ return `Merged pull request #${r.number} (${r.head} → ${r.base}) as ${String(r.sha).slice(0, 7)}: ${r.title}.${r.deleted ? ` ${r.head} is deleted.` : ''} The session on ${r.base} brings it in now.`
518
+ }
519
+
520
+ export const PULLS_DESCRIPTION = 'The open pull requests of this branch\'s repository on GitHub, with how each one\'s checks stand (passing, failing and which, still running, or none). Read it before merging, and to find the pull request for a branch.'
521
+ export const MERGE_DESCRIPTION = 'Merge a pull request on GitHub (by number, or by the branch it comes from) into its base. ' +
522
+ 'Refused while its checks fail or are still running. A Quilt agent\'s branch (quilt/…) is deleted after the merge. ' +
523
+ 'People may always merge (as far as the session\'s GitHub connection may); agents only when the session owner lets agents commit to any branch, otherwise ask a person in chat with the pull request\'s link. ' +
524
+ 'The session on the base branch brings the merge in within seconds.'
525
+ /** quilt_merge's arguments, the same for the local and the hosted tool (`z`: zod). */
526
+ export const mergeSchema = (z) => ({
527
+ number: z.number().int().min(1).optional().describe('The pull request number'),
528
+ branch: z.string().max(200).optional().describe('Or the branch the pull request comes from'),
529
+ method: z.enum(['squash', 'merge', 'rebase']).optional().describe('How to merge (default squash)'),
530
+ delete_branch: z.boolean().optional().describe('Delete the merged branch (default: yes for quilt/… branches, no otherwise)'),
531
+ force: z.boolean().optional().describe('People only: merge even while checks fail or are still running')
532
+ })
533
+
534
+ /**
535
+ * Whether agents can commit on branch `key` themselves: the owner lets them, and a member's GitHub
536
+ * connection (or the owner's token) covers the repository. Cheap: from the relay's record.
537
+ */
538
+ export function agentsCanCommit (room, key) {
539
+ if (agentCommitsOf(room) === 'off') return false
540
+ const found = repoRecordFor(room, key)
541
+ if (!found) return false
542
+ const rec = found.rec
543
+ return !!(validToken(room.meta.githubToken) || (rec.github && rec.github.state === 'ok'))
544
+ }
545
+
546
+ /**
547
+ * A folder's git pushed (its pre-push hook told its session, which tells the relay): the pushed
548
+ * content goes on the room's published record, so the branch comes back down clean once merged
549
+ * (upstream.js), and the chat hears it. `files`: [{ path, id }] (id: sha1 of the bytes); only
550
+ * paths the member may change are recorded. Written by the relay, so a member whose own writes
551
+ * are held to a folder (session-access.js) is on record all the same.
552
+ */
553
+ export function notePushedFor (room, { who, key, branch = '', sha = '', count = 1, files = [], subject = '' }, { fetch = globalThis.fetch, log = () => {} } = {}) {
554
+ const a = who.access || {}
555
+ if (a.state && a.state !== 'approved') throw new CommitRefused('You are not let into this session yet.')
556
+ const list = (Array.isArray(files) ? files : []).filter((f) => f && typeof f.path === 'string' && isSafeRelPath(f.path) && mayChange(a, f.path)).slice(0, 2000)
557
+ notePublishedIds(room.doc, list.map((f) => [f.path, f.id]), RELAY_PUBLISH)
558
+ let full = null
559
+ try { full = githubRepoOf(room, key).full } catch {}
560
+ const where = full ? `${full} ${branch}` : branch
561
+ const n = Math.max(1, Number(count) || 1)
562
+ const names = list.map((f) => f.path)
563
+ log(`[${room.name}] ${key}: ${who.name} pushed ${n} commit${n === 1 ? '' : 's'} to ${where} (${names.length} files)`)
564
+ if (room.postChat) room.postChat({ by: RELAY_BY, text: `${who.name} pushed ${n} commit${n === 1 ? '' : 's'} to ${where}${names.length ? `: ${names.slice(0, 8).join(', ')}${names.length > 8 ? ` and ${names.length - 8} more` : ''}` : ''}${full && /^[0-9a-f]{40}$/.test(sha) ? ` https://github.com/${full}/commit/${sha}` : ''}`, kind: 'commit' })
565
+ // An agent's branch of its own: the pull request opens by itself (after this answer), so no push is left hanging.
566
+ if (who.kind === 'agent' && full && branch.startsWith(agentPrefix(who.name)) && agentCommitsOf(room) !== 'off') {
567
+ openPullOnPush(room, { who, key, branch, files: names, subject: String(subject || '') }, { fetch, log }).catch((err) => log(`[${room.name}] ${key}: could not open a pull request for ${who.name}'s ${branch}: ${err.message}`))
568
+ }
569
+ return { ok: true, recorded: list.length, branch, repo: full }
570
+ }
571
+
572
+ /** Sets the pull request a task's work is in ({ number, url, branch, base }) and notes it on the task. */
573
+ /**
574
+ * The commit a task's pull request is meant to carry (addendum 16.5): every push to the pull request's
575
+ * branch for the task moves it. reviewPulls moves the task to Done only when the merge has this commit.
576
+ */
577
+ export function noteTaskRevision (room, number, sha) {
578
+ const map = room.doc.getMap('tasks')
579
+ for (const t of readTasks(map)) {
580
+ if (!t.pr || t.pr.number !== number || t.archived || t.column === 'done' || t.pr.revision === sha) continue
581
+ updateTask(room.doc, map, { id: t.id, pr: { ...t.pr, revision: sha } }, RELAY_PUBLISH)
582
+ }
583
+ }
584
+
585
+ export function linkTaskPull (room, taskId, pr, { log = () => {} } = {}) {
586
+ const map = room.doc.getMap('tasks')
587
+ const cur = readTasks(map).find((t) => t.id === taskId)
588
+ if (!cur) return null
589
+ if (cur.pr && cur.pr.number === pr.number) return cur
590
+ const task = updateTask(room.doc, map, { id: taskId, pr }, RELAY_PUBLISH)
591
+ try { addComment(room.doc, room.doc.getMap('taskComments'), readTasks(map), { taskId, by: RELAY_BY, text: `Pull request #${pr.number} (${pr.branch} → ${pr.base}): ${pr.url}. Moving this task to Done merges it, once its checks pass.` }, RELAY_PUBLISH) } catch (err) { log(`[${room.name}] could not note the pull request on task ${taskId}: ${err.message}`) }
592
+ return task
593
+ }
594
+
595
+ /**
596
+ * The task an agent's push is for: the agent's task In progress whose title slug the branch ends
597
+ * with, else its only task In progress, else a task made for the push (so every push has a task,
598
+ * and the board's gates cover it). `by` names the agent.
599
+ */
600
+ function taskForPush (room, { who, branch, files, subject, taskId = null }) {
601
+ const map = room.doc.getMap('tasks')
602
+ const tasks = readTasks(map).filter((t) => !t.archived && t.column !== 'done')
603
+ const named = taskId ? tasks.find((t) => t.id === taskId) : null
604
+ if (named) return named
605
+ const mine = tasks.filter((t) => t.assignee === who.name)
606
+ const tail = branch.slice(agentPrefix(who.name).length)
607
+ const linked = mine.find((t) => t.pr && t.pr.branch === branch)
608
+ if (linked) return linked
609
+ const bySlug = mine.find((t) => t.column === 'doing' && slug(t.title) === tail) || mine.find((t) => slug(t.title) === tail)
610
+ if (bySlug) return bySlug
611
+ const doing = mine.filter((t) => t.column === 'doing' && !t.pr)
612
+ if (doing.length === 1) return doing[0]
613
+ const title = (subject || '').split('\n')[0].trim().slice(0, 120) || tail.replace(/-/g, ' ')
614
+ return addTask(room.doc, map, { title, by: RELAY_BY, assignee: who.name, column: 'doing', files: (files || []).slice(0, 20), conv: '' }, RELAY_PUBLISH)
615
+ }
616
+
617
+ /**
618
+ * Opens the pull request for an agent's pushed branch into the branch its work merges into (or
619
+ * finds the one already open), ties it to the agent's task (made if none), and tells the chat.
620
+ */
621
+ export async function openPullOnPush (room, { who, key, branch, files = [], subject = '', taskId = null, revision = null }, { fetch = globalThis.fetch, log = () => {} } = {}) {
622
+ const { repo, full, rec } = githubRepoOf(room, key)
623
+ if (branch === rec.ref) return null
624
+ const token = await credentialsFor(room, repo)
625
+ const gh = githubWriter({ fetch, token, owner: repo.owner, name: repo.name })
626
+ const task = taskForPush(room, { who, branch, files, subject, taskId })
627
+ if (task.pr && task.pr.branch === branch) { if (revision) noteTaskRevision(room, task.pr.number, revision); return task.pr }
628
+ const title = task.title
629
+ const pr = await gh.pull({ title, head: branch, base: rec.ref, body: `${who.name}'s work on task ${task.id}: ${task.title}.\n\nOpened by Quilt when the branch was pushed. Moving the task to Done in Quilt merges this once its checks pass.` })
630
+ const linked = { number: pr.number, url: pr.url, branch, base: rec.ref, ...(revision ? { revision } : {}) }
631
+ linkTaskPull(room, task.id, linked, { log })
632
+ if (revision) noteTaskRevision(room, pr.number, revision)
633
+ log(`[${room.name}] ${key}: ${pr.existed ? 'found' : 'opened'} pull request #${pr.number} for ${who.name}'s ${branch} (task ${task.id})`)
634
+ if (!pr.existed && room.postChat) room.postChat({ by: RELAY_BY, text: `Opened pull request #${pr.number} for ${who.name}'s ${branch} → ${rec.ref}: "${title}" ${pr.url}. Moving task ${task.id} to Done merges it once its checks pass.`, kind: 'commit' })
635
+ return linked
636
+ }
637
+
638
+ /**
639
+ * Moving a task to Done: when its work is on a branch (a pull request) and not yet on the branch
640
+ * it merges into, Done merges that pull request, once its checks pass. People may always; agents
641
+ * only when the owner lets agents push to any branch (otherwise the task waits in QA for a person).
642
+ * Resolves { merged: { sha, number, head, base } } or { merged: null } (nothing to merge); throws
643
+ * CommitRefused with what to do when it can't.
644
+ */
645
+ export async function finishTaskFor (room, { who, key, task: taskId }, { fetch = globalThis.fetch, log = () => {} } = {}) {
646
+ const map = room.doc.getMap('tasks')
647
+ const task = readTasks(map).find((t) => t.id === taskId)
648
+ if (!task) return { merged: null }
649
+ // A clash task (clash.js) is the merge itself.
650
+ if (Object.values((room.meta && room.meta.upstreams) || {}).some((rec) => rec && rec.clash && rec.clash.task === taskId)) return { merged: null }
651
+ if (!agentsCanCommit(room, key) && !task.pr) return { merged: null }
652
+ const u = await unpublishedFor(room, { key, who: task.assignee || who.name, task: taskId, column: 'done' }, { fetch })
653
+ if (u.unpublished.length) throw new CommitRefused(unpublishedRefusal(task, u.unpublished, 'done', []))
654
+ if (!u.awaitingMerge.length && !task.pr) return { merged: null }
655
+ if (!task.pr) throw new CommitRefused(`Not moved to Done: "${task.title}"'s work is on a branch with no pull request yet. Push the branch (from a folder: git push, and Quilt opens it; without a folder: quilt_git_commit on your branch with task: "${task.id}"), then move the task again.`)
656
+ if (who.kind === 'agent' && agentCommitsOf(room) !== 'any') {
657
+ throw new CommitRefused(`Not moved to Done: Done merges pull request #${task.pr.number} (${task.pr.branch} → ${task.pr.base}), and here that is a person's call (the owner lets agents push only to branches of their own). Leave the task in QA: a person moves it to Done when it is good, and Quilt merges. ${task.pr.url}`)
658
+ }
659
+ const r = await mergeForMember(room, { who, key, number: task.pr.number, method: 'squash' }, { fetch, log })
660
+ if (r.already) return { merged: null, already: true }
661
+ try { addComment(room.doc, room.doc.getMap('taskComments'), readTasks(map), { taskId, by: RELAY_BY, text: `Merged pull request #${r.number} (${r.head} → ${r.base}) as ${String(r.sha).slice(0, 7)}, as ${who.name} moved the task to Done.` }, RELAY_PUBLISH) } catch {}
662
+ return { merged: { sha: r.sha, number: r.number, head: r.head, base: r.base } }
663
+ }
664
+
665
+ // Pull requests tied to tasks are looked at with the relay's looks (about every ten minutes) and
666
+ // when GitHub says one changed: merged elsewhere moves the task to Done; closed is told; green and
667
+ // waiting in QA nudges whoever may merge; failing checks nudge the agent. Each event (a pull
668
+ // request at a commit, closed, failing at a commit) is told once: the room's saved record
669
+ // (meta.pullTold) says which were, across restarts. One look's news goes as one message per person.
670
+ export const PULL_TOLD_KEEP_MS = 30 * 24 * 60 * 60 * 1000
671
+
672
+ /** Whether event `k` was told already; records it as told now when not. Saved with the room. */
673
+ function firstTelling (room, k, now) {
674
+ const told = room.meta.pullTold && typeof room.meta.pullTold === 'object' ? room.meta.pullTold : (room.meta.pullTold = {})
675
+ if (told[k]) return false
676
+ told[k] = now
677
+ for (const [key, at] of Object.entries(told)) if (now - at > PULL_TOLD_KEEP_MS) delete told[key]
678
+ return true
679
+ }
680
+
681
+ const prLine = (pr, t) => `PR #${pr.number} "${t.title}" ${pr.url}`
682
+
683
+ export async function reviewPulls (room, { fetch = globalThis.fetch, log = () => {}, now = Date.now(), only = null } = {}) {
684
+ const map = room.doc.getMap('tasks')
685
+ const tasks = readTasks(map).filter((t) => t.pr && !t.archived && t.column !== 'done' && (only == null || t.pr.number === only))
686
+ if (!tasks.length) return []
687
+ let gh
688
+ try {
689
+ const { repo } = githubRepoOf(room, DEFAULT_KEY)
690
+ const token = await credentialsFor(room, repo)
691
+ gh = githubWriter({ fetch, token, owner: repo.owner, name: repo.name })
692
+ } catch { return [] }
693
+ const once = (k) => firstTelling(room, k, now)
694
+ // What this look has to say, per person: { ready: [lines], failing: [lines], closed: [lines] }.
695
+ const news = new Map()
696
+ const tell = (to, kind, line) => {
697
+ if (!to) return
698
+ const n = news.get(to) || { ready: [], failing: [], closed: [] }
699
+ if (!n[kind].includes(line)) n[kind].push(line)
700
+ news.set(to, n)
701
+ }
702
+ const out = []
703
+ for (const t of tasks) {
704
+ let pr
705
+ try { pr = await gh.pullRequest(t.pr.number) } catch (err) { log(`[${room.name}] could not read pull request #${t.pr.number}: ${err.message}`); continue }
706
+ if (pr.merged) {
707
+ // Addendum 16.5: Done only when the merge has the task's last change. A push that reached the branch after
708
+ // the merge (or a merge of an older head) leaves the task where it is, and its assignee is told.
709
+ const rev = t.pr.revision
710
+ if (rev && pr.sha && rev !== pr.sha) {
711
+ let has = null
712
+ try { has = ['identical', 'ahead'].includes(await gh.compare(rev, pr.sha)) } catch (err) { log(`[${room.name}] could not compare ${rev.slice(0, 7)} with pull request #${pr.number}: ${err.message}`) }
713
+ if (has !== true) {
714
+ if (has === false && once(`missed\0${t.pr.number}\0${rev}`)) {
715
+ const text = `Pull request #${pr.number} was merged on GitHub without the task's last change (${rev.slice(0, 7)}, pushed to ${pr.head} after the merged ${String(pr.sha).slice(0, 7)}): "${t.title}" stays in ${t.column} until that change is merged too.`
716
+ try { addComment(room.doc, room.doc.getMap('taskComments'), readTasks(map), { taskId: t.id, by: RELAY_BY, text }, RELAY_PUBLISH) } catch {}
717
+ if (room.postChat) room.postChat({ by: RELAY_BY, ...(t.assignee ? { to: t.assignee } : {}), kind: 'nudge', text })
718
+ }
719
+ out.push({ task: t.id, merged: true, missing: rev })
720
+ continue
721
+ }
722
+ }
723
+ if (!once(`merged\0${pr.number}`)) continue
724
+ updateTask(room.doc, map, { id: t.id, column: 'done', verified: `Pull request #${pr.number} (${pr.head} → ${pr.base}) merged on GitHub as ${String(pr.sha || '').slice(0, 7) || 'a commit'}.` }, RELAY_PUBLISH)
725
+ try { addComment(room.doc, room.doc.getMap('taskComments'), readTasks(map), { taskId: t.id, by: RELAY_BY, text: `Pull request #${pr.number} was merged on GitHub: the task is Done.` }, RELAY_PUBLISH) } catch {}
726
+ if (room.postChat) room.postChat({ by: RELAY_BY, text: `PR #${pr.number} "${t.title}" was merged on GitHub; its task is Done.`, kind: 'commit' })
727
+ if (room.upstreamSoon) room.upstreamSoon(DEFAULT_KEY)
728
+ out.push({ task: t.id, merged: true })
729
+ continue
730
+ }
731
+ if (pr.state !== 'open') {
732
+ if (t.assignee && once(`closed\0${t.pr.number}`)) tell(t.assignee, 'closed', prLine(pr, t))
733
+ out.push({ task: t.id, closed: true })
734
+ continue
735
+ }
736
+ const checks = pr.sha ? await gh.checks(pr.sha).catch(() => ({ state: 'none', failing: [] })) : { state: 'none', failing: [] }
737
+ if (checks.state === 'failing') {
738
+ if (t.assignee && once(`failing\0${t.pr.number}\0${pr.sha}`)) {
739
+ tell(t.assignee, 'failing', `${prLine(pr, t)} (${checks.failing.join(', ')})`)
740
+ out.push({ task: t.id, failing: true })
741
+ }
742
+ continue
743
+ }
744
+ if (t.column === 'qa' && (checks.state === 'passing' || checks.state === 'none') && once(`ready\0${t.pr.number}\0${pr.sha}`)) {
745
+ const owner = room.meta && room.meta.ownerName
746
+ tell(owner, 'ready', prLine(pr, t))
747
+ if (agentCommitsOf(room) === 'any' && t.assignee !== owner) tell(t.assignee, 'ready', prLine(pr, t))
748
+ out.push({ task: t.id, ready: true })
749
+ }
750
+ }
751
+ if (news.size && room.saveMeta) room.saveMeta()
752
+ if (room.postChat) {
753
+ for (const [to, n] of news) {
754
+ const parts = []
755
+ if (n.ready.length) parts.push(n.ready.length === 1 ? `Ready to merge: ${n.ready[0]}. Move its task to Done to merge it.` : `Ready to merge (move each task to Done):\n${n.ready.map((l) => `- ${l}`).join('\n')}`)
756
+ if (n.failing.length) parts.push(n.failing.length === 1 ? `Checks failing: ${n.failing[0]}. Fix them on its branch and push again.` : `Checks failing (fix each on its branch and push again):\n${n.failing.map((l) => `- ${l}`).join('\n')}`)
757
+ if (n.closed.length) parts.push(`Closed on GitHub without merging: ${n.closed.join('; ')}. If the work still stands, push its branch again; if not, move the task back to To do or archive it.`)
758
+ room.postChat({ by: RELAY_BY, to, kind: 'nudge', text: parts.join('\n') })
759
+ }
760
+ }
761
+ return out
762
+ }
763
+
764
+ /**
765
+ * Which of `files` (or of every file `who` changed on record on the branch) are not in git as the
766
+ * session has them: { able, unpublished: [paths], awaitingMerge: [paths] }. `unpublished` is nowhere
767
+ * on GitHub; `awaitingMerge` is on a branch (pushed from a folder, or quilt_commit: the room's
768
+ * published record) but not on the session branch's head yet. For `column` 'qa' only `unpublished`
769
+ * counts (a pull request under review is in git); for 'done' and by default both. `able` is
770
+ * whether agents can commit here themselves (agentsCanCommit); without it nothing is checked.
771
+ * GitHub's file list for the head is kept until the head moves. Never throws: an error reads as nothing unpublished.
772
+ */
773
+ export async function unpublishedFor (room, { key, who, files = null, task = null, column = '' }, { fetch = globalThis.fetch } = {}) {
774
+ const able = agentsCanCommit(room, key)
775
+ if (!able) return { able, unpublished: [], awaitingMerge: [] }
776
+ try {
777
+ const { repo, rec } = githubRepoOf(room, key)
778
+ const e = room.branchDoc(room.resolveKey ? room.resolveKey(key) : key)
779
+ const history = e.doc.getArray('history').toArray()
780
+ let list = cleanList(files)
781
+ if (!list.length) {
782
+ const t = task ? room.doc.getMap('tasks').get(String(task)) : null
783
+ for (const h of history) {
784
+ if (!h || h.pulled || h.seed || typeof h.path !== 'string') continue
785
+ if (t ? h.task && h.task.id === t.id : h.by === who) { if (!list.includes(h.path)) list.push(h.path) }
786
+ }
787
+ if (t) for (const f of Array.isArray(t.files) ? t.files : []) if (!list.includes(f)) list.push(f)
788
+ list = cleanList(list)
789
+ }
790
+ const ig = gitIgnoreOf(e)
791
+ list = list.filter((rel) => !isIgnored(ig, rel))
792
+ if (!list.length) return { able, unpublished: [], awaitingMerge: [] }
793
+ const token = await credentialsFor(room, repo)
794
+ const gh = githubWriter({ fetch, token, owner: repo.owner, name: repo.name })
795
+ const head = await gh.ref(rec.ref)
796
+ if (!head) return { able, unpublished: [], awaitingMerge: [] }
797
+ if (!room.headBlobs) room.headBlobs = new Map()
798
+ let cached = room.headBlobs.get(rec.ref)
799
+ if (!cached || cached.head !== head) {
800
+ const blobs = await gh.blobs((await gh.commit(head)).tree)
801
+ if (!blobs) return { able, unpublished: [], awaitingMerge: [] }
802
+ cached = { head, blobs }
803
+ room.headBlobs.set(rec.ref, cached)
804
+ }
805
+ const published = publishedOf(room.doc)
806
+ const unpublished = []
807
+ const awaitingMerge = []
808
+ for (const rel of list) {
809
+ let now = null
810
+ try { now = sessionBytes(e, rel) } catch { continue } // a large stored file: committed from a folder
811
+ const id = now ? gitBlobSha(now) : null
812
+ if (id === (cached.blobs.get(rel) || null)) continue
813
+ if (now && (published.get(rel) || []).includes(crypto.createHash('sha1').update(now).digest('hex'))) awaitingMerge.push(rel) // the published record keeps content ids: sha1 of the bytes (upstream.js)
814
+ else unpublished.push(rel)
815
+ }
816
+ return { able, unpublished, awaitingMerge: column === 'qa' ? [] : awaitingMerge, head }
817
+ } catch { return { able, unpublished: [], awaitingMerge: [] } }
818
+ }
819
+
820
+ /**
821
+ * Why a task may not move on: its files are not in git yet (and the agent can get them there itself),
822
+ * or, for Done, they are on a branch whose pull request isn't merged into the session's branch.
823
+ */
824
+ export function unpublishedRefusal (task, unpublished, column, awaitingMerge = []) {
825
+ const title = task && task.title ? `"${task.title}"` : 'this task'
826
+ const some = (l) => `${l.slice(0, 8).join(', ')}${l.length > 8 ? `, and ${l.length - 8} more` : ''}`
827
+ if (!unpublished.length && awaitingMerge.length) {
828
+ return `Not moved to Done: ${title}'s work is on a branch but not merged into the session's branch yet (${some(awaitingMerge)}). ` +
829
+ 'Open its pull request if you haven\'t and ask a person to merge it (an agent may merge when the owner lets agents push to any branch); move the task to QA meanwhile.'
830
+ }
831
+ return `Not moved to ${column === 'qa' ? 'QA' : 'Done'}: ${title} has work that isn't in git yet (${some(unpublished)}). ` +
832
+ `Get it into git first: commit those files and push from your folder (a branch of your own, then a pull request, unless the owner lets agents push to the session's branch); without a folder, quilt_git_commit on your branch (files: those, and a message${task && task.id ? `, or task: "${task.id}"` : ''}). Then move the task again. Work only in the session has not shipped.`
833
+ }
834
+
835
+ // Agents that leave work uncommitted are reminded (a message that needs no reply, which wakes
836
+ // them): once every REMIND_MS per agent and branch, for files they last changed REMIND_QUIET_MS ago or more.
837
+ export const REMIND_MS = 30 * 60 * 1000
838
+ export const REMIND_QUIET_MS = 5 * 60 * 1000
839
+ /**
840
+ * Every agent with unpublished work on branch `key` is reminded to commit it (relay-commit.js).
841
+ * Run by the relay with its looks, about every ten minutes; cheap when nothing changed.
842
+ */
843
+ export async function remindUnpublished (room, key, { fetch = globalThis.fetch, now = Date.now() } = {}) {
844
+ if (!agentsCanCommit(room, key) || !room.postChat) return []
845
+ const agents = new Set(Object.values((room.meta && room.meta.members) || {}).filter((m) => m && m.kind === 'agent' && m.name).map((m) => m.name))
846
+ // Only a branch that is open (loaded): loading every branch of a room at once, each tick, ran the relay out of
847
+ // memory. Its agents are reminded when it is next open.
848
+ const e = room.store && room.store.get ? room.store.get(room.resolveKey ? room.resolveKey(key) : key) : room.branchDoc(room.resolveKey ? room.resolveKey(key) : key)
849
+ if (!e) { handBackRequests(room, key, agents); return [] }
850
+ const history = e.doc.getArray('history').toArray()
851
+ const last = new Map() // agent -> ts of their newest edit
852
+ for (const h of history) if (h && !h.pulled && !h.seed && h.by && agents.has(h.by) && now - (h.ts || 0) < 24 * 60 * 60 * 1000) last.set(h.by, Math.max(last.get(h.by) || 0, h.ts || 0))
853
+ if (!room.reminded) room.reminded = new Map()
854
+ const told = []
855
+ for (const [name, ts] of last) {
856
+ if (now - ts < REMIND_QUIET_MS) continue // still at it
857
+ const k = `${name}\0${key}`
858
+ if (now - (room.reminded.get(k) || 0) < REMIND_MS) continue
859
+ const r = await unpublishedFor(room, { key, who: name }, { fetch })
860
+ if (!r.unpublished.length) continue
861
+ room.reminded.set(k, now)
862
+ room.postChat({ by: RELAY_BY, to: name, kind: 'nudge', text: `You changed ${r.unpublished.slice(0, 8).join(', ')}${r.unpublished.length > 8 ? ` and ${r.unpublished.length - 8} more` : ''} on ${key}, and ${r.unpublished.length === 1 ? 'it isn\'t' : 'they aren\'t'} in git. When that work is finished and tested, commit those files and push from your folder (a branch of your own with a pull request, unless the owner lets agents push to ${key}); without a folder, quilt_git_commit with the files and a message. If it is still in progress, carry on.` })
863
+ told.push(name)
864
+ }
865
+ handBackRequests(room, key, agents)
866
+ return told
867
+ }
868
+
869
+ /**
870
+ * Open commit requests by agents that can get their work into git themselves (asked before they
871
+ * could, or before GitHub was connected) are handed back: closed for people, and the agent is told
872
+ * to push it. So nothing of an agent's sits on a person's plate once the agent can do it.
873
+ */
874
+ export function handBackRequests (room, key, agents) {
875
+ if (!room.postChat || !agentsCanCommit(room, key)) return []
876
+ const map = room.doc.getMap('commitRequests')
877
+ const back = [...map.values()].filter((r) => r && r.state === 'open' && agents.has(r.by) && (!r.branch || r.branch === key))
878
+ if (!back.length) return []
879
+ room.doc.transact(() => {
880
+ for (const r of back) map.set(r.id, { ...r, state: 'done', doneBy: RELAY_BY, doneAt: Date.now(), hash: '', committed: [], note: `handed back to ${r.by}: agents get their own work into git here` })
881
+ }, RELAY_PUBLISH)
882
+ for (const r of back) {
883
+ const files = Array.isArray(r.files) && r.files.length ? ` (${r.files.slice(0, 8).join(', ')}${r.files.length > 8 ? `, and ${r.files.length - 8} more` : ''})` : ''
884
+ room.postChat({ by: RELAY_BY, to: r.by, kind: 'nudge', text: `Your commit request "${r.message}"${files} was handed back to you: agents get their own work into git here now. Commit those files and push from your folder (a branch of your own with a pull request, unless the owner lets agents push to ${key}); without a folder, quilt_git_commit.` })
885
+ }
886
+ return back.map((r) => r.id)
887
+ }