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
package/src/history.js ADDED
@@ -0,0 +1,307 @@
1
+ // The chronology of a session: every change to a shared file, with who made it,
2
+ // when, what changed (a capped unified diff) and the task it was for. Kept in
3
+ // the shared doc (`history`) so everyone, including hosted agents, reads the
4
+ // same record; queried with quilt_history / `quilt history`.
5
+ import crypto from 'node:crypto'
6
+ import { globMatcher } from './pathrules.js'
7
+
8
+ export const HISTORY_CAP = 2000 // entries
9
+ export const MAX_HISTORY_CHARS = 1_500_000 // total diff text kept
10
+ export const MAX_DIFF_CHARS = 6000 // per entry
11
+ export const BURST_MS = 20000 // saves closer than this, same person and file, fold into one entry
12
+ const CONTEXT = 2
13
+ const MAX_DP_CELLS = 2_000_000 // beyond this the middle of a diff is shown coarsely
14
+
15
+ /**
16
+ * A unified-style line diff ('' when equal). Common leading and trailing lines
17
+ * are skipped, the middle gets an LCS alignment when it is small enough and a
18
+ * coarse "all removed, all added" block otherwise. Capped at MAX_DIFF_CHARS.
19
+ */
20
+ export function lineDiff (before, after, { context = CONTEXT, max = MAX_DIFF_CHARS } = {}) {
21
+ before = String(before ?? ''); after = String(after ?? '')
22
+ if (before === after) return ''
23
+ const a = splitLines(before)
24
+ const b = splitLines(after)
25
+ let head = 0
26
+ while (head < a.length && head < b.length && a[head] === b[head]) head++
27
+ let tail = 0
28
+ while (tail < a.length - head && tail < b.length - head && a[a.length - 1 - tail] === b[b.length - 1 - tail]) tail++
29
+ const ma = a.slice(head, a.length - tail)
30
+ const mb = b.slice(head, b.length - tail)
31
+ const ops = align(ma, mb) // [kind, text] with kind in '=', '-', '+'
32
+ const full = [...a.slice(0, head).map((l) => ['=', l]), ...ops, ...a.slice(a.length - tail).map((l) => ['=', l])]
33
+ return cap(hunks(full, context), max)
34
+ }
35
+
36
+ function splitLines (s) {
37
+ if (!s) return []
38
+ const lines = s.split('\n')
39
+ if (lines.at(-1) === '') lines.pop()
40
+ return lines
41
+ }
42
+
43
+ function align (a, b) {
44
+ if (!a.length) return b.map((l) => ['+', l])
45
+ if (!b.length) return a.map((l) => ['-', l])
46
+ if (a.length * b.length > MAX_DP_CELLS) return [...a.map((l) => ['-', l]), ...b.map((l) => ['+', l])]
47
+ const n = a.length; const m = b.length
48
+ const dp = new Uint32Array((n + 1) * (m + 1))
49
+ for (let i = n - 1; i >= 0; i--) {
50
+ for (let j = m - 1; j >= 0; j--) {
51
+ dp[i * (m + 1) + j] = a[i] === b[j] ? dp[(i + 1) * (m + 1) + j + 1] + 1 : Math.max(dp[(i + 1) * (m + 1) + j], dp[i * (m + 1) + j + 1])
52
+ }
53
+ }
54
+ const out = []
55
+ let i = 0; let j = 0
56
+ while (i < n && j < m) {
57
+ if (a[i] === b[j]) { out.push(['=', a[i]]); i++; j++ } else if (dp[(i + 1) * (m + 1) + j] >= dp[i * (m + 1) + j + 1]) out.push(['-', a[i++]])
58
+ else out.push(['+', b[j++]])
59
+ }
60
+ while (i < n) out.push(['-', a[i++]])
61
+ while (j < m) out.push(['+', b[j++]])
62
+ return out
63
+ }
64
+
65
+ /** Groups ops into hunks with `context` equal lines around each change. */
66
+ function hunks (ops, context) {
67
+ const out = []
68
+ let i = 0
69
+ let oldLine = 1; let newLine = 1
70
+ while (i < ops.length) {
71
+ if (ops[i][0] === '=') { oldLine++; newLine++; i++; continue }
72
+ // A change starts here. Walk forward until a stretch of > 2*context equal lines or the end.
73
+ let start = i; let end = i
74
+ let equalRun = 0
75
+ for (let k = i; k < ops.length; k++) {
76
+ if (ops[k][0] === '=') { equalRun++; if (equalRun > 2 * context) break } else { equalRun = 0; end = k }
77
+ }
78
+ const from = Math.max(0, start - context)
79
+ const to = Math.min(ops.length, end + 1 + context)
80
+ // Lines before `from` since the last hunk are all equal: advance the counters.
81
+ const lead = start - from
82
+ const oldStart = oldLine - lead
83
+ const newStart = newLine - lead
84
+ let oldCount = 0; let newCount = 0
85
+ const body = []
86
+ for (let k = from; k < to; k++) {
87
+ const [kind, text] = ops[k]
88
+ if (kind !== '+') oldCount++
89
+ if (kind !== '-') newCount++
90
+ body.push((kind === '=' ? ' ' : kind) + text)
91
+ }
92
+ out.push(`@@ -${oldCount ? oldStart : 0},${oldCount} +${newCount ? newStart : 0},${newCount} @@`, ...body)
93
+ for (let k = i; k < to; k++) {
94
+ if (ops[k][0] !== '+') oldLine++
95
+ if (ops[k][0] !== '-') newLine++
96
+ }
97
+ i = to
98
+ }
99
+ return out.join('\n')
100
+ }
101
+
102
+ function cap (text, max) {
103
+ if (text.length <= max) return text
104
+ const cut = text.lastIndexOf('\n', max)
105
+ return `${text.slice(0, cut > 0 ? cut : max)}\n… (diff truncated)`
106
+ }
107
+
108
+ function counts (diff) {
109
+ let added = 0; let removed = 0
110
+ for (const line of diff.split('\n')) {
111
+ if (line.startsWith('+')) added++
112
+ else if (line.startsWith('-')) removed++
113
+ }
114
+ return { added, removed }
115
+ }
116
+
117
+ /**
118
+ * Writes entries into the shared `history` array. One instance per process;
119
+ * it remembers the text a burst started from so later saves in the same burst
120
+ * replace the entry with a diff spanning the whole burst.
121
+ */
122
+ export class HistoryLog {
123
+ constructor (doc, array, { origin = 'history', burstMs = BURST_MS } = {}) {
124
+ this.doc = doc
125
+ this.array = array
126
+ this.origin = origin
127
+ this.burstMs = burstMs
128
+ this.bursts = new Map() // "by\0path" -> { id, before, ts }
129
+ }
130
+
131
+ entries () { return this.array.toArray().filter((e) => e && typeof e === 'object' && e.path) }
132
+
133
+ /**
134
+ * @param {object} c
135
+ * @param {string} c.by who changed it
136
+ * @param {string} c.path
137
+ * @param {'created'|'edited'|'deleted'} c.kind
138
+ * @param {string} [c.before] text before (text files)
139
+ * @param {string} [c.after] text after
140
+ * @param {string} [c.detail] "N bytes" for binaries; derived from the diff otherwise
141
+ * @param {{id:string,title:string}|null} [c.task] the task this change was for
142
+ * @param {boolean} [c.pulled] a git pull brought this change (it is not the person's own edit)
143
+ * @param {number} [c.ts]
144
+ */
145
+ record ({ by, path, kind, before, after, detail, task, pulled = false, seed = false, ts = Date.now() }) {
146
+ const key = `${by}\0${path}`
147
+ const burst = this.bursts.get(key)
148
+ const text = typeof before === 'string' || typeof after === 'string'
149
+ // A save soon after a file's starting copy (seed) folds into it as the editor's own change: the entry is then theirs, not a seed.
150
+ // Never over someone else's change to the file in between (any process's, in the shared log): the fold's diff would credit their lines to `by`.
151
+ const foldable = kind === 'edited' && burst && ts - burst.ts < this.burstMs && text && !pulled && !seed && !burst.pulled && this.lastIdFor(path) === burst.id
152
+ const base = foldable ? burst.before : (before ?? '')
153
+ const diff = text ? lineDiff(base, after ?? '') : ''
154
+ const c = text ? counts(diff) : { added: 0, removed: 0 }
155
+ const entry = {
156
+ id: foldable ? burst.id : crypto.randomBytes(6).toString('hex'),
157
+ by: String(by || ''),
158
+ path: String(path || ''),
159
+ kind: foldable && burst.kind === 'created' ? 'created' : kind, // a file created and then saved again is still "created"
160
+ ts,
161
+ from: foldable ? burst.from : ts,
162
+ added: c.added,
163
+ removed: c.removed,
164
+ detail: text ? `+${c.added} -${c.removed}` : String(detail || ''),
165
+ task: task && task.id ? { id: String(task.id), title: String(task.title || '') } : null,
166
+ ...(pulled ? { pulled: true } : {}),
167
+ ...(seed ? { seed: true } : {}),
168
+ diff
169
+ }
170
+ this.doc.transact(() => {
171
+ if (foldable) {
172
+ const i = this.indexOf(entry.id)
173
+ if (i >= 0) this.array.delete(i, 1)
174
+ this.array.insert(i >= 0 ? i : this.array.length, [entry])
175
+ } else {
176
+ this.array.push([entry])
177
+ }
178
+ this.trim()
179
+ }, this.origin)
180
+ if (kind === 'deleted') this.bursts.delete(key)
181
+ else this.bursts.set(key, { id: entry.id, kind: entry.kind, before: base, from: entry.from, ts, pulled, seed })
182
+ return entry
183
+ }
184
+
185
+ /** The id of the newest entry for `path` in the shared log, or null. */
186
+ lastIdFor (path) {
187
+ for (let i = this.array.length - 1; i >= 0; i--) {
188
+ const e = this.array.get(i)
189
+ if (e && e.path === path) return e.id
190
+ }
191
+ return null
192
+ }
193
+
194
+ indexOf (id) {
195
+ for (let i = this.array.length - 1; i >= 0; i--) {
196
+ const e = this.array.get(i)
197
+ if (e && e.id === id) return i
198
+ }
199
+ return -1
200
+ }
201
+
202
+ trim () {
203
+ if (this.array.length > HISTORY_CAP) this.array.delete(0, this.array.length - HISTORY_CAP)
204
+ let total = 0
205
+ const all = this.array.toArray()
206
+ for (const e of all) total += (e && e.diff ? e.diff.length : 0) + 120
207
+ let drop = 0
208
+ while (total > MAX_HISTORY_CHARS && drop < all.length - 1) {
209
+ const e = all[drop++]
210
+ total -= (e && e.diff ? e.diff.length : 0) + 120
211
+ }
212
+ if (drop) this.array.delete(0, drop)
213
+ }
214
+ }
215
+
216
+ /**
217
+ * "2h", "45m", "3d", "1w", "today", "yesterday" or a date. Returns a timestamp,
218
+ * null for empty input, undefined when it can't be read.
219
+ */
220
+ export function parseSince (s, now = Date.now()) {
221
+ const str = String(s ?? '').trim().toLowerCase()
222
+ if (!str) return null
223
+ const m = /^(\d+(?:\.\d+)?)\s*(s|sec|m|min|h|hr|hour|d|day|w|week)s?$/.exec(str)
224
+ if (m) {
225
+ const unit = { s: 1e3, sec: 1e3, m: 60e3, min: 60e3, h: 3600e3, hr: 3600e3, hour: 3600e3, d: 86400e3, day: 86400e3, w: 7 * 86400e3, week: 7 * 86400e3 }[m[2]]
226
+ return now - Math.round(+m[1] * unit)
227
+ }
228
+ const midnight = new Date(now).setHours(0, 0, 0, 0)
229
+ if (str === 'today') return midnight
230
+ if (str === 'yesterday') return midnight - 86400e3
231
+ const t = Date.parse(s)
232
+ return Number.isNaN(t) ? undefined : t
233
+ }
234
+
235
+ /**
236
+ * Filters history entries. `path` is an exact path, a folder prefix ending in
237
+ * "/", or a glob. `by` is a name (case-insensitive). `since` is a timestamp.
238
+ * Returns oldest first; `limit` keeps the newest.
239
+ */
240
+ export function queryHistory (entries, { path, by, since, task, limit = 50 } = {}) {
241
+ let list = entries.filter((e) => e && e.path)
242
+ if (path) {
243
+ const p = String(path).replace(/\\/g, '/').replace(/^\.\//, '')
244
+ const match = /[*?[{]/.test(p) ? globMatcher(p) : p.endsWith('/') ? (x) => x.startsWith(p) : (x) => x === p
245
+ list = list.filter((e) => match(e.path))
246
+ }
247
+ if (by) { const who = String(by).trim().toLowerCase(); list = list.filter((e) => String(e.by).toLowerCase() === who) }
248
+ if (task) list = list.filter((e) => e.task && e.task.id === task)
249
+ if (typeof since === 'number') list = list.filter((e) => e.ts >= since)
250
+ list.sort((a, b) => a.ts - b.ts)
251
+ if (limit && list.length > limit) list = list.slice(-limit)
252
+ return list
253
+ }
254
+
255
+ function ago (ts, now) {
256
+ const s = Math.max(0, Math.round((now - ts) / 1000))
257
+ if (s < 60) return `${s}s ago`
258
+ if (s < 3600) return `${Math.floor(s / 60)}m ago`
259
+ if (s < 172800) return `${Math.floor(s / 3600)}h ago`
260
+ return `${Math.floor(s / 86400)}d ago`
261
+ }
262
+
263
+ /** One line per change, oldest first; with `withDiff`, each diff follows its line. */
264
+ export function formatHistory (list, { withDiff = false, now = Date.now() } = {}) {
265
+ if (!list.length) return 'No changes match.'
266
+ const out = []
267
+ for (const e of list) {
268
+ const span = e.from && e.ts - e.from > 60000 ? ` over ${Math.round((e.ts - e.from) / 60000)}m` : ''
269
+ const what = e.detail ? ` (${e.detail})` : ''
270
+ const task = e.task ? ` for "${e.task.title}" [${e.task.id}]` : ''
271
+ out.push(`[${ago(e.ts, now)}] ${e.by} ${e.kind} ${e.path}${what}${e.pulled ? ' by pulling from git' : ''}${span}${task}`)
272
+ if (withDiff && e.diff) out.push(e.diff, '')
273
+ }
274
+ return out.join('\n').replace(/\n+$/, '')
275
+ }
276
+
277
+ /**
278
+ * Who made the changes between `before` (a file as git has it) and `after` (as it is now): each
279
+ * line that differs is put down to the newest history entry (`entries`: that file's, oldest
280
+ * first) that added or removed it. Pulls from git are nobody's, and lines too common to tell
281
+ * apart (blank, a lone brace) are not counted. Names in the order their lines come. Unlike "who
282
+ * edited it since", someone whose edit was later undone, or who only opened the session with the
283
+ * file, is not named.
284
+ */
285
+ export function blameChange ({ before = '', after = '', entries = [] }) {
286
+ const diff = lineDiff(before, after, { context: 0, max: 400_000 })
287
+ if (!diff) return []
288
+ const changed = diff.split('\n').filter((l) => (l[0] === '+' || l[0] === '-') && !/^(\+\+\+|---) /.test(l) && l.slice(1).replace(/[\s{}()[\];,]/g, '').length >= 2)
289
+ const newest = [...entries].reverse().filter((e) => e && !e.pulled && !e.seed && e.by && typeof e.diff === 'string').map((e) => ({ by: e.by, lines: new Set(e.diff.split('\n')) }))
290
+ const out = []
291
+ for (const line of changed) {
292
+ const e = newest.find((x) => x.lines.has(line))
293
+ if (e && !out.includes(e.by)) out.push(e.by)
294
+ }
295
+ return out
296
+ }
297
+
298
+ /**
299
+ * The In-progress task assigned to `name` (the person or their AI): the first on
300
+ * the board, with the AI's tasks first when `preferAi` says the AI is the one working.
301
+ */
302
+ export function currentTask (tasks, name, { preferAi = false } = {}) {
303
+ const doing = (tasks || []).filter((t) => t && !t.archived && t.column === 'doing' && t.assignee === name)
304
+ if (!doing.length) return null
305
+ doing.sort((a, b) => (Number(!!b.forAi === preferAi) - Number(!!a.forAi === preferAi)) || ((a.order ?? 0) - (b.order ?? 0)))
306
+ return { id: doing[0].id, title: doing[0].title }
307
+ }