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/tasks.js ADDED
@@ -0,0 +1,670 @@
1
+ // Shared task board for a session: four columns, stored as plain objects in
2
+ // the Y.Doc so everyone (and every AI) sees the same list.
3
+ import crypto from 'node:crypto'
4
+ import { isSafeRelPath } from './pathrules.js'
5
+ import { parseSchedule, canonicalCron, cronToText } from './ui/schedule.js'
6
+
7
+ export const COLUMNS = [
8
+ { id: 'todo', name: 'To do' },
9
+ { id: 'doing', name: 'In progress' },
10
+ { id: 'qa', name: 'QA' },
11
+ { id: 'done', name: 'Done' }
12
+ ]
13
+ export const COLUMN_IDS = new Set(COLUMNS.map((c) => c.id))
14
+ export const MAX_TASKS = 200
15
+ export const MAX_TITLE = 200
16
+ export const MAX_FILES = 20
17
+ export const MAX_FILE_PATH = 240
18
+ const MAX_ASSIGNEE = 80
19
+ const MAX_TOOL = 40
20
+ const MAX_CONV = 200
21
+ const MAX_VERIFIED = 1000
22
+ const MAX_QA_NOTES = MAX_VERIFIED
23
+ // An edit from an AI chat only becomes a task when it just happened. Older
24
+ // lines are the reader's backfill of an earlier conversation.
25
+ export const AUTO_TASK_MS = 2 * 60 * 1000
26
+ const CHANGE = /^(Edited|Created|Deleted) (.+)$/
27
+
28
+ const HEX_ID = /^[0-9a-f]{16}$/i
29
+ // Control chars and bidi overrides: a task title is shown to everyone.
30
+ const INVISIBLE = /[\u0000-\u001f\u007f\u200b-\u200f\u202a-\u202e\u2066-\u2069\ufeff]/g
31
+
32
+ export function columnName (id) {
33
+ return COLUMNS.find((c) => c.id === id)?.name || ''
34
+ }
35
+
36
+ export function cleanTitle (title) {
37
+ return String(title ?? '').replace(INVISIBLE, ' ').replace(/\s+/g, ' ').trim().slice(0, MAX_TITLE)
38
+ }
39
+
40
+ function cleanName (name) {
41
+ const n = String(name ?? '').replace(INVISIBLE, ' ').replace(/\s+/g, ' ').trim().slice(0, MAX_ASSIGNEE)
42
+ return n || 'someone'
43
+ }
44
+
45
+ /** A person to assign to. Empty means unassigned. "me" is resolved by the caller first. */
46
+ export function cleanAssignee (name) {
47
+ return String(name ?? '').replace(INVISIBLE, ' ').replace(/\s+/g, ' ').trim().slice(0, MAX_ASSIGNEE)
48
+ }
49
+
50
+ function cleanTool (tool) {
51
+ return String(tool ?? '').replace(INVISIBLE, ' ').replace(/\s+/g, ' ').trim().slice(0, MAX_TOOL)
52
+ }
53
+
54
+ function oneLine (s) {
55
+ return String(s ?? '').replace(/\s+/g, ' ').trim()
56
+ }
57
+
58
+ /** Project paths a task is about. Throws on anything that is not a safe relative path. */
59
+ export function cleanFiles (files) {
60
+ if (files == null) return []
61
+ if (!Array.isArray(files)) throw new Error('files must be a list of paths')
62
+ const out = []
63
+ for (const raw of files) {
64
+ const p = String(raw ?? '').trim().replace(/^\.\//, '').replace(/\\/g, '/')
65
+ if (!p) continue
66
+ if (p.length > MAX_FILE_PATH || !isSafeRelPath(p)) throw new Error(`not a project file: ${p.slice(0, 80)}`)
67
+ if (!out.includes(p)) out.push(p)
68
+ if (out.length > MAX_FILES) throw new Error(`a task can list at most ${MAX_FILES} files`)
69
+ }
70
+ return out
71
+ }
72
+
73
+ /**
74
+ * Who a task is for, from a tool or API call.
75
+ * `assignee` is a person's name, "me", or "" to clear. `to_ai` / `forAi` means that
76
+ * person's AI rather than the person. `files` is omitted from the result when
77
+ * the caller did not send it, so an update can leave the list alone.
78
+ */
79
+ export function assignmentFields ({ assignee, forAi, to_ai, tool, files, me, peers } = {}) {
80
+ const sent = assignee !== undefined || forAi !== undefined || to_ai !== undefined
81
+ const raw = assignee == null ? '' : String(assignee).trim()
82
+ const name = raw === 'me' ? cleanAssignee(me) : cleanAssignee(raw)
83
+ if (raw && !name) throw new Error('say who it is for')
84
+ const ai = !!(forAi || to_ai) && !!name
85
+ let toolName = ''
86
+ if (ai) {
87
+ const mine = !!(me && name === cleanAssignee(me))
88
+ const peer = mine ? null : (peers || []).find((p) => p && p.name === name)
89
+ const live = peer?.tool && peer.tool !== 'unknown' ? peer.tool : ''
90
+ // `tool` is the hint for yourself, or an explicit label the caller already resolved.
91
+ toolName = cleanTool(live || tool || '')
92
+ }
93
+ const out = {}
94
+ if (sent) {
95
+ out.assignee = name
96
+ out.forAi = ai
97
+ out.tool = toolName
98
+ }
99
+ if (files !== undefined) out.files = cleanFiles(files)
100
+ return out
101
+ }
102
+
103
+ /** True when `task` is assigned to this reader. `asAi` means the reader is that person's AI. */
104
+ export function assignedToReader (task, { name, asAi } = {}) {
105
+ if (!task || !task.assignee || !name || task.assignee !== name) return false
106
+ return asAi ? !!task.forAi : !task.forAi
107
+ }
108
+
109
+ /** "you", "Brandon", "your Cursor", "Brandon's Cursor". Empty when unassigned. */
110
+ export function assigneeLabel (task, me) {
111
+ if (!task?.assignee) return ''
112
+ const mine = !!me && task.assignee === me
113
+ if (!task.forAi) return mine ? 'you' : task.assignee
114
+ const tool = task.tool && task.tool !== 'unknown' ? task.tool : 'AI'
115
+ return mine ? `your ${tool}` : `${task.assignee}'s ${tool}`
116
+ }
117
+
118
+ /** A task we'll show. Anything a modified client pushed that isn't this shape is ignored. */
119
+ export function publicTask (value) {
120
+ if (!value || typeof value !== 'object') return null
121
+ const title = typeof value.title === 'string' ? value.title : ''
122
+ if (typeof value.id !== 'string' || !HEX_ID.test(value.id)) return null
123
+ if (!title || title.length > MAX_TITLE || title !== cleanTitle(title)) return null
124
+ if (!COLUMN_IDS.has(value.column)) return null
125
+ if (typeof value.by !== 'string' || value.by.length > 80) return null
126
+ if (typeof value.order !== 'number' || !Number.isFinite(value.order)) return null
127
+ if (typeof value.ts !== 'number' || !Number.isFinite(value.ts)) return null
128
+ const who = storedAssignee(value)
129
+ if (!who) return null
130
+ const files = storedFiles(value)
131
+ if (!files) return null
132
+ const conv = storedConv(value.conv)
133
+ if (conv == null) return null
134
+ const verified = storedVerified(value.verified)
135
+ if (verified == null) return null
136
+ const qaNotes = storedQaNotes(value.qaNotes)
137
+ if (qaNotes == null) return null
138
+ const recurring = storedFlag(value.recurring)
139
+ if (recurring == null) return null
140
+ const cron = canonicalCron(value.cron)
141
+ if (cron == null) return null
142
+ const archived = storedFlag(value.archived)
143
+ if (archived == null) return null
144
+ // When it was archived. Tasks archived before this was kept have none; a bad value is dropped, not the task.
145
+ const archivedAt = archived && Number.isFinite(value.archivedAt) && value.archivedAt > 0 ? value.archivedAt : 0
146
+ const pr = storedPull(value.pr)
147
+ return { id: value.id, title, column: value.column, by: value.by, order: value.order, ts: value.ts, ...who, files, conv, verified, qaNotes, recurring, cron, archived, ...(archivedAt ? { archivedAt } : {}), ...(pr ? { pr } : {}) }
148
+ }
149
+
150
+ /** Tasks still on the board: archived ones are kept, but off the board and out of every listing. */
151
+ export function activeTasks (tasks) {
152
+ return (tasks || []).filter((t) => t && !t.archived)
153
+ }
154
+
155
+ /** What an agent said it ran and saw before moving the task to Done. Newlines kept, control chars dropped. */
156
+ export function cleanVerified (text) {
157
+ return String(text ?? '').replace(/\r\n?/g, '\n').split('\n')
158
+ .map((line) => line.replace(INVISIBLE, ' ').replace(/[ \t]+/g, ' ').trim())
159
+ .join('\n').replace(/\n{3,}/g, '\n\n').trim().slice(0, MAX_VERIFIED).trimEnd()
160
+ }
161
+
162
+ // Missing means no evidence was given. A value that is present must already be cleaned.
163
+ function storedVerified (v) {
164
+ if (v == null || v === '') return ''
165
+ if (typeof v !== 'string' || v.length > MAX_VERIFIED) return null
166
+ if (v !== cleanVerified(v)) return null
167
+ return v
168
+ }
169
+
170
+ /** What an agent wrote about its changes and self-validation when moving the task to QA. */
171
+ export function cleanQaNotes (text) {
172
+ // Trim after cutting: a cut that lands on a space would otherwise store a value that
173
+ // doesn't survive cleaning again, and storedQaNotes would then hide the whole task.
174
+ return cleanVerified(text).slice(0, MAX_QA_NOTES).trimEnd()
175
+ }
176
+
177
+ function storedQaNotes (v) {
178
+ if (v == null || v === '') return ''
179
+ if (typeof v !== 'string' || v.length > MAX_QA_NOTES) return null
180
+ if (v !== cleanQaNotes(v)) return null
181
+ return v
182
+ }
183
+
184
+ // Missing means off (tasks from before the flag existed).
185
+ function storedFlag (v) {
186
+ if (v == null) return false
187
+ if (typeof v !== 'boolean') return null
188
+ return v
189
+ }
190
+
191
+ /** The pull request a task's work is in ({ number, url, branch, base }, set by the relay when the branch is pushed), or null; a bad value reads as none. */
192
+ function storedPull (v) {
193
+ if (!v || typeof v !== 'object' || !Number.isInteger(v.number) || v.number < 1 || typeof v.url !== 'string' || v.url.length > 400) return null
194
+ return { number: v.number, url: v.url, branch: typeof v.branch === 'string' ? v.branch.slice(0, 200) : '', base: typeof v.base === 'string' ? v.base.slice(0, 200) : '', ...(typeof v.revision === 'string' && /^[0-9a-f]{40}$/.test(v.revision) ? { revision: v.revision } : {}) }
195
+ }
196
+
197
+ // Older tasks have no assignee. A value that sets one must already be cleaned,
198
+ // the same rule as the title, so a peer cannot push a name we would display differently.
199
+ function storedAssignee (value) {
200
+ const has = value.assignee != null && value.assignee !== ''
201
+ if (!has) {
202
+ if (value.forAi === true || (value.tool != null && value.tool !== '')) return null
203
+ return { assignee: '', forAi: false, tool: '' }
204
+ }
205
+ if (typeof value.assignee !== 'string' || value.assignee.length > MAX_ASSIGNEE) return null
206
+ if (value.assignee !== cleanAssignee(value.assignee)) return null
207
+ if (value.forAi != null && typeof value.forAi !== 'boolean') return null
208
+ const forAi = value.forAi === true
209
+ if (!forAi) {
210
+ if (value.tool != null && value.tool !== '') return null
211
+ return { assignee: value.assignee, forAi: false, tool: '' }
212
+ }
213
+ if (value.tool == null || value.tool === '') return { assignee: value.assignee, forAi: true, tool: '' }
214
+ if (typeof value.tool !== 'string' || value.tool.length > MAX_TOOL || value.tool !== cleanTool(value.tool)) return null
215
+ return { assignee: value.assignee, forAi: true, tool: value.tool }
216
+ }
217
+
218
+ function cleanConv (conv) {
219
+ const c = String(conv ?? '').replace(INVISIBLE, '').trim()
220
+ if (!c || c.length > MAX_CONV) return ''
221
+ return c
222
+ }
223
+
224
+ // Missing means this task is not tied to an AI chat. A value that is present
225
+ // must already be cleaned, the same rule as the title.
226
+ function storedConv (conv) {
227
+ if (conv == null || conv === '') return ''
228
+ if (typeof conv !== 'string' || conv.length > MAX_CONV) return null
229
+ if (conv !== cleanConv(conv)) return null
230
+ return conv
231
+ }
232
+
233
+ function storedFiles (value) {
234
+ if (value.files == null) return []
235
+ if (!Array.isArray(value.files) || value.files.length > MAX_FILES) return null
236
+ const files = []
237
+ for (const f of value.files) {
238
+ if (typeof f !== 'string' || f.length > MAX_FILE_PATH || !isSafeRelPath(f)) return null
239
+ if (!files.includes(f)) files.push(f)
240
+ }
241
+ return files
242
+ }
243
+
244
+ function byOrder (a, b) {
245
+ return a.order - b.order || a.ts - b.ts || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)
246
+ }
247
+
248
+ function split (map) {
249
+ const valid = []
250
+ const junk = []
251
+ map.forEach((value, key) => {
252
+ const task = publicTask(value)
253
+ if (task && task.id === key) valid.push(task)
254
+ else junk.push(key)
255
+ })
256
+ valid.sort(byOrder)
257
+ return { valid, junk }
258
+ }
259
+
260
+ export function readTasks (map) {
261
+ return split(map).valid
262
+ }
263
+
264
+ function everyTask (tasks) {
265
+ if (tasks && typeof tasks.forEach === 'function' && !Array.isArray(tasks)) return readTasks(tasks)
266
+ return (Array.isArray(tasks) ? tasks : []).map(publicTask).filter(Boolean).sort(byOrder)
267
+ }
268
+
269
+ // The board as people see it: archived tasks left out.
270
+ function listed (tasks) {
271
+ return activeTasks(everyTask(tasks))
272
+ }
273
+
274
+ function nextOrder (tasks, column) {
275
+ const col = tasks.filter((t) => t.column === column)
276
+ if (!col.length) return 1
277
+ return Math.max(...col.map((t) => t.order)) + 1
278
+ }
279
+
280
+ /** Where a card lands in `column`. `beforeId` inserts ahead of that card; otherwise it goes last. */
281
+ function orderBefore (tasks, column, beforeId) {
282
+ const col = tasks.filter((t) => t.column === column).sort(byOrder)
283
+ const last = col.length ? col[col.length - 1].order + 1 : 1
284
+ if (!beforeId) return last
285
+ const i = col.findIndex((t) => t.id === beforeId)
286
+ if (i < 0) return last
287
+ if (i === 0) return col[0].order - 1
288
+ return (col[i - 1].order + col[i].order) / 2
289
+ }
290
+
291
+ /**
292
+ * Adds a task to To do. When the board is full, the oldest archived task, then the
293
+ * oldest Done task, is dropped to make room. Throws if there is nothing finished to drop.
294
+ */
295
+ export function addTask (doc, map, { id = null, title, by, assignee = '', forAi = false, tool = '', files = [], column = 'todo', conv = '' }, origin) {
296
+ if (id != null && (typeof id !== 'string' || !HEX_ID.test(id))) throw new Error('a task id is 16 hex characters')
297
+ const clean = cleanTitle(title)
298
+ if (!clean) throw new Error('say what the task is')
299
+ if (!COLUMN_IDS.has(column)) throw new Error('pick To do, In progress, QA, or Done')
300
+ const who = assignmentFields({ assignee, forAi, tool, files })
301
+ const { valid, junk } = split(map)
302
+ const dropping = []
303
+ if (valid.length >= MAX_TASKS) {
304
+ const oldest = (a, b) => a.ts - b.ts || byOrder(a, b)
305
+ const shelvedFirst = (a, b) => (a.archivedAt || a.ts) - (b.archivedAt || b.ts) || byOrder(a, b) // archived longest ago
306
+ const done = [
307
+ ...valid.filter((t) => t.archived).sort(shelvedFirst),
308
+ ...valid.filter((t) => !t.archived && t.column === 'done').sort(oldest)
309
+ ]
310
+ const need = valid.length - MAX_TASKS + 1
311
+ if (done.length < need) throw new Error('the board is full. Remove a task first.')
312
+ dropping.push(...done.slice(0, need).map((t) => t.id))
313
+ }
314
+ const task = {
315
+ id: id || crypto.randomBytes(8).toString('hex'),
316
+ title: clean,
317
+ column,
318
+ by: cleanName(by),
319
+ assignee: who.assignee || '',
320
+ forAi: !!who.forAi,
321
+ tool: who.tool || '',
322
+ files: who.files || [],
323
+ conv: cleanConv(conv),
324
+ verified: '',
325
+ qaNotes: '',
326
+ recurring: false,
327
+ cron: '',
328
+ archived: false,
329
+ order: nextOrder(valid, column),
330
+ ts: Date.now()
331
+ }
332
+ doc.transact(() => {
333
+ for (const key of junk) map.delete(key)
334
+ for (const id of dropping) map.delete(id)
335
+ map.set(task.id, task)
336
+ }, origin)
337
+ return task
338
+ }
339
+
340
+ /**
341
+ * Changes a task's title, column, place, assignee, files, verified evidence, QA notes, repeat schedule, or archived flag.
342
+ * `before` is a task id to insert ahead of. Leaving Done clears `verified`; leaving QA clears `qaNotes`.
343
+ * Moving an archived task to another column brings it back onto the board.
344
+ * `cron` is 5-field cron or a phrase such as "daily at 9". A schedule turns `recurring` on.
345
+ */
346
+ export function updateTask (doc, map, { id, title, column, before, assignee, forAi, tool, files, verified, qaNotes, recurring, cron, archived, pr } = {}, origin) {
347
+ const { valid } = split(map)
348
+ const cur = valid.find((t) => t.id === id)
349
+ if (!cur) throw new Error('no such task')
350
+ const next = { ...cur }
351
+ let changed = false
352
+ if (pr !== undefined) {
353
+ const clean = pr === null ? null : storedPull(pr)
354
+ if (JSON.stringify(clean) !== JSON.stringify(cur.pr || null)) { if (clean) next.pr = clean; else delete next.pr; changed = true }
355
+ }
356
+ if (title !== undefined) {
357
+ const clean = cleanTitle(title)
358
+ if (!clean) throw new Error('say what the task is')
359
+ if (clean !== cur.title) { next.title = clean; changed = true }
360
+ }
361
+ if (column !== undefined && !COLUMN_IDS.has(column)) throw new Error('pick To do, In progress, QA, or Done')
362
+ const moving = column !== undefined && column !== cur.column
363
+ const beforeId = typeof before === 'string' && before && before !== id ? before : null
364
+ if (moving || beforeId) {
365
+ const dest = column || cur.column
366
+ next.column = dest
367
+ next.order = orderBefore(valid.filter((t) => t.id !== id), dest, beforeId)
368
+ changed = true
369
+ }
370
+ if (assignee !== undefined || forAi !== undefined) {
371
+ const who = assignmentFields({
372
+ assignee: assignee !== undefined ? assignee : cur.assignee,
373
+ forAi: forAi !== undefined ? forAi : cur.forAi,
374
+ tool: tool !== undefined ? tool : cur.tool
375
+ })
376
+ if (who.assignee !== cur.assignee || who.forAi !== cur.forAi || who.tool !== cur.tool) {
377
+ next.assignee = who.assignee
378
+ next.forAi = who.forAi
379
+ next.tool = who.tool
380
+ changed = true
381
+ }
382
+ }
383
+ if (files !== undefined) {
384
+ const list = cleanFiles(files)
385
+ if (list.length !== cur.files.length || list.some((f, i) => f !== cur.files[i])) {
386
+ next.files = list
387
+ changed = true
388
+ }
389
+ }
390
+ if (verified !== undefined) {
391
+ const v = cleanVerified(verified)
392
+ if (v !== cur.verified) { next.verified = v; changed = true }
393
+ } else if (moving && cur.column === 'done' && cur.verified) {
394
+ next.verified = ''
395
+ changed = true
396
+ }
397
+ if (qaNotes !== undefined) {
398
+ const q = cleanQaNotes(qaNotes)
399
+ if (q !== (cur.qaNotes || '')) { next.qaNotes = q; changed = true }
400
+ } else if (moving && cur.column === 'qa' && cur.qaNotes) {
401
+ next.qaNotes = ''
402
+ changed = true
403
+ }
404
+ if (recurring !== undefined) {
405
+ if (typeof recurring !== 'boolean') throw new Error('recurring must be true or false')
406
+ if (recurring !== !!cur.recurring) { next.recurring = recurring; changed = true }
407
+ }
408
+ if (cron !== undefined) {
409
+ const c = parseSchedule(cron)
410
+ if (c !== (cur.cron || '')) { next.cron = c; changed = true }
411
+ if (c && !next.recurring) { next.recurring = true; changed = true }
412
+ }
413
+ if (archived !== undefined) {
414
+ if (typeof archived !== 'boolean') throw new Error('archived must be true or false')
415
+ if (archived !== cur.archived) { next.archived = archived; changed = true }
416
+ } else if (moving && cur.archived) {
417
+ next.archived = false
418
+ changed = true
419
+ }
420
+ // When it went onto the shelf, for the archived list's "archived 3d ago" and its order.
421
+ if (next.archived && !cur.archived) next.archivedAt = Date.now()
422
+ if (!next.archived) delete next.archivedAt
423
+ if (!changed) return cur
424
+ doc.transact(() => map.set(id, next), origin)
425
+ return next
426
+ }
427
+
428
+ export function deleteTask (doc, map, id, origin) {
429
+ const { valid } = split(map)
430
+ if (!valid.some((t) => t.id === id)) throw new Error('no such task')
431
+ doc.transact(() => map.delete(id), origin)
432
+ }
433
+
434
+ function taskLine (t, me) {
435
+ const who = assigneeLabel(t, me)
436
+ const files = (t.files || []).map((f) => `\`${f}\``).join(', ')
437
+ const tail = [who ? ` → ${who}` : '', files ? ` · ${files}` : ''].join('')
438
+ const repeat = t.recurring ? `\n - repeats: ${t.cron ? cronToText(t.cron) : 'again, no schedule yet'}` : ''
439
+ const verified = t.column === 'done' && t.verified ? `\n - verified: ${shortVerified(t.verified)}` : ''
440
+ const qa = t.column === 'qa' && t.qaNotes ? `\n - qa: ${shortVerified(t.qaNotes)}` : ''
441
+ return `- ${oneLine(t.title)} _(${t.by === me ? 'you' : oneLine(t.by) || 'someone'})_${tail}${repeat}${verified}${qa}`
442
+ }
443
+
444
+ function shortVerified (v) {
445
+ const one = oneLine(v)
446
+ return one.length > 160 ? `${one.slice(0, 157)}…` : one
447
+ }
448
+
449
+ function openTasks (list) {
450
+ return list.filter((t) => t.column === 'todo' || t.column === 'doing')
451
+ }
452
+
453
+ // ------------------------------------------------------------ AI pickup --
454
+ // Each person chooses whether their AI takes work from the board by itself (Settings): "off",
455
+ // "mine" (To do tasks assigned to it) or "any" (those, then unassigned ones). When it finishes,
456
+ // it is handed the next one, in whatever tool it runs in.
457
+
458
+ export const PICKUP_MODES = ['off', 'mine', 'any']
459
+ export const pickupMode = (v) => PICKUP_MODES.includes(v) ? v : 'off'
460
+
461
+ /**
462
+ * The To do task this reader's AI should start next, or null: none while it still has one In
463
+ * progress. `reader` is { name, asAi }.
464
+ */
465
+ export function nextTask (tasks, reader, mode) {
466
+ mode = pickupMode(mode)
467
+ if (mode === 'off' || !reader?.name) return null
468
+ const list = listed(tasks)
469
+ if (list.some((t) => t.column === 'doing' && assignedToReader(t, reader))) return null
470
+ const todo = list.filter((t) => t.column === 'todo')
471
+ return todo.find((t) => assignedToReader(t, reader)) || (mode === 'any' ? todo.find((t) => !t.assignee) : null) || null
472
+ }
473
+
474
+ /** What an AI is told when it finishes and the board has its next task; '' for none. */
475
+ export function renderNextTask (task) {
476
+ if (!task) return ''
477
+ const take = task.assignee
478
+ ? `Start it now: quilt_move_task (id ${task.id}, column "doing") gives you the briefing.`
479
+ : `It is unassigned: take it with quilt_assign_task (id ${task.id}, assignee "me", to_ai true), then quilt_move_task (column "doing") for the briefing, and start.`
480
+ return `Your next task, from the board (this person lets their AI pick up work by itself): ${task.id} "${oneLine(task.title)}"${task.files?.length ? ` [${task.files.join(', ')}]` : ''}. ${take} ` +
481
+ 'If you cannot take it on now, say why in chat (quilt_message) instead.'
482
+ }
483
+
484
+ /**
485
+ * Markdown for people (STATUS.md, quilt_status). `me` is shown as "you".
486
+ * `asAi` means the reader is that person's AI, so tasks assigned to the AI are theirs.
487
+ * `mentionYours` adds "none are yours" when the reader has no open tasks.
488
+ */
489
+ export function taskMarkdown (tasks, me, { tool = '', asAi = false, mentionYours = false } = {}) {
490
+ const list = listed(tasks)
491
+ if (!list.length) return mentionYours ? '_No tasks yet._\n\n_No open tasks are assigned to you._' : '_No tasks yet._'
492
+ const reader = { name: me, asAi }
493
+ const open = openTasks(list)
494
+ const mine = open.filter((t) => assignedToReader(t, reader))
495
+ const other = open.filter((t) => t.assignee === me && !assignedToReader(t, reader))
496
+ const head = []
497
+ if (mine.length) {
498
+ const via = asAi ? ` — your ${assigneeTool(tool)}` : ''
499
+ head.push(`**Yours**${via}`)
500
+ head.push(mine.map((t) => `- ${oneLine(t.title)} (${columnName(t.column)})${fileTail(t)}`).join('\n'))
501
+ } else if (mentionYours) head.push('_No open tasks are assigned to you._')
502
+ if (other.length) {
503
+ const label = asAi ? me : `your ${assigneeTool(other[0].tool || tool)}`
504
+ head.push('', `**Open, assigned to ${label}**`)
505
+ head.push(other.map((t) => `- ${oneLine(t.title)} (${columnName(t.column)})${fileTail(t)}`).join('\n'))
506
+ }
507
+ const cols = COLUMNS.map((col) => {
508
+ const items = list.filter((t) => t.column === col.id)
509
+ const body = items.length ? items.map((t) => taskLine(t, me)).join('\n') : '_Nothing._'
510
+ return `**${col.name}**\n${body}`
511
+ }).join('\n\n')
512
+ return head.length ? `${head.join('\n')}\n\n${cols}` : cols
513
+ }
514
+
515
+ /** A project path from an AI action line ("Edited src/app.js"). Empty when it is not a file change. */
516
+ export function actionPath (text) {
517
+ const m = CHANGE.exec(String(text || '').trim())
518
+ if (!m) return ''
519
+ const p = m[2].trim().replace(/\\/g, '/').replace(/^\.\//, '')
520
+ if (!p || p === 'a file') return ''
521
+ return p
522
+ }
523
+
524
+ function rememberPrompt (prompts, conv, text) {
525
+ if (!prompts || typeof prompts.delete !== 'function') return
526
+ const line = String(text).split(/\r?\n/).map((s) => s.trim()).find(Boolean) || ''
527
+ if (!line) return
528
+ prompts.delete(conv)
529
+ prompts.set(conv, line.slice(0, MAX_TITLE))
530
+ while (prompts.size > 40) prompts.delete(prompts.keys().next().value)
531
+ }
532
+
533
+ // A stand-in until the AI's short title arrives (or for good, without one): the
534
+ // prompt's first sentence, cut at a word.
535
+ const AUTO_TITLE_MAX = 60
536
+
537
+ function autoTitle (prompt, files) {
538
+ const line = cleanTitle(String(prompt || '').split(/\r?\n/)[0])
539
+ if (line) {
540
+ const sentence = line.match(/^.{12,}?[.!?](?=\s|$)/)?.[0] || line
541
+ if (sentence.length <= AUTO_TITLE_MAX) return sentence
542
+ const cut = sentence.slice(0, AUTO_TITLE_MAX - 1)
543
+ const space = cut.lastIndexOf(' ')
544
+ return `${(space > AUTO_TITLE_MAX / 2 ? cut.slice(0, space) : cut).replace(/[\s,;:]+$/, '')}…`
545
+ }
546
+ if (!files.length) return ''
547
+ const more = files.length - 1
548
+ return cleanTitle(more ? `Edit ${files[0]} and ${more} more` : `Edit ${files[0]}`)
549
+ }
550
+
551
+ /**
552
+ * What to do with one batch of AI chat entries. A prompt is remembered. A file
553
+ * edit from the last couple of minutes opens an In progress task for that
554
+ * chat, or adds the file to the task that chat already has. Questions, reads,
555
+ * and older backfill do not create anything. `prompts` is updated in place.
556
+ * `me` is the person whose chat this is, so a task is only extended when they
557
+ * created it.
558
+ */
559
+ export function planAutoTask ({ entries, tasks, prompts, now = Date.now(), me = '', sharing = true, windowMs = AUTO_TASK_MS } = {}) {
560
+ const ops = []
561
+ if (!entries?.length) return ops
562
+ const fresh = new Map()
563
+ for (const e of entries) {
564
+ if (!e) continue
565
+ const conv = cleanConv(e.conv)
566
+ if (!conv) continue
567
+ if (e.kind === 'prompt' && e.text) rememberPrompt(prompts, conv, e.text)
568
+ if (!sharing || e.kind !== 'action') continue
569
+ const ts = Number(e.ts)
570
+ if (!Number.isFinite(ts) || now - ts > windowMs) continue
571
+ let bucket = fresh.get(conv)
572
+ if (!bucket) { bucket = { tool: '', files: [] }; fresh.set(conv, bucket) }
573
+ if (e.tool) bucket.tool = cleanTool(e.tool)
574
+ const p = actionPath(e.text)
575
+ if (p && !bucket.files.includes(p)) bucket.files.push(p)
576
+ }
577
+ const open = activeTasks(Array.isArray(tasks) ? tasks : []).filter((t) => t.column === 'todo' || t.column === 'doing')
578
+ for (const [conv, bucket] of fresh) {
579
+ const files = []
580
+ for (const f of bucket.files) {
581
+ try {
582
+ const one = cleanFiles([f])
583
+ if (one[0] && !files.includes(one[0])) files.push(one[0])
584
+ } catch { /* not a project path */ }
585
+ if (files.length >= MAX_FILES) break
586
+ }
587
+ const existing = open.find((t) => t.conv === conv && t.by === me)
588
+ if (existing) {
589
+ const merged = [...(existing.files || [])]
590
+ let grew = false
591
+ for (const f of files) {
592
+ if (merged.includes(f) || merged.length >= MAX_FILES) continue
593
+ merged.push(f)
594
+ grew = true
595
+ }
596
+ if (grew) ops.push({ update: { id: existing.id, files: merged } })
597
+ continue
598
+ }
599
+ if (!files.length) continue
600
+ if (files.every((f) => open.some((t) => (t.files || []).includes(f)))) continue
601
+ const title = autoTitle(prompts?.get(conv), files)
602
+ if (!title || open.some((t) => t.title === title)) continue
603
+ ops.push({ create: { title, files, conv, tool: bucket.tool, column: 'doing', request: prompts?.get(conv) || '' } })
604
+ open.push({ title, files, conv, column: 'doing', by: me })
605
+ }
606
+ return ops
607
+ }
608
+
609
+ function assigneeTool (tool) {
610
+ const t = oneLine(tool)
611
+ return t && t !== 'unknown' ? t : 'AI'
612
+ }
613
+
614
+ function fileTail (t) {
615
+ if (!t.files?.length) return ''
616
+ return ` · ${t.files.map((f) => `\`${f}\``).join(', ')}`
617
+ }
618
+
619
+ function agentLine (t) {
620
+ const who = assigneeLabel(t, '')
621
+ const files = (t.files || []).join(', ')
622
+ const tail = [who ? ` → ${who}` : '', files ? ` [${files}]` : ''].join('')
623
+ const repeat = t.recurring ? `\n repeats: ${t.cron ? cronToText(t.cron) : 'again, no schedule yet'}` : ''
624
+ const verified = t.column === 'done' && t.verified ? `\n verified: ${shortVerified(t.verified)}` : ''
625
+ const qa = t.column === 'qa' && t.qaNotes ? `\n qa: ${shortVerified(t.qaNotes)}` : ''
626
+ return `- ${t.id} ${oneLine(t.title)} (${oneLine(t.by) || 'someone'})${tail}${repeat}${verified}${qa}`
627
+ }
628
+
629
+ /**
630
+ * Plain text for an agent, with ids so it can move a task.
631
+ * `reader` is { name, tool, asAi }. Open tasks assigned to that reader are listed first.
632
+ * `archived` lists the archived tasks instead of the board.
633
+ */
634
+ export function formatTasks (tasks, reader, { archived = false } = {}) {
635
+ const all = everyTask(tasks)
636
+ const shelved = all.filter((t) => t.archived)
637
+ if (archived) {
638
+ if (!shelved.length) return 'No archived tasks.'
639
+ return `Archived (off the board; quilt_archive_task with archived false brings one back)\n${shelved.map(agentLine).join('\n')}`
640
+ }
641
+ const list = activeTasks(all)
642
+ const hidden = shelved.length
643
+ ? `\n\n${shelved.length} archived task${shelved.length === 1 ? ' is' : 's are'} not shown: quilt_tasks with archived true lists them.`
644
+ : ''
645
+ const board = () => {
646
+ if (!list.length) return `No tasks yet. The board has four columns: To do, In progress, QA, and Done.${hidden}`
647
+ return COLUMNS.map((col) => {
648
+ const items = list.filter((t) => t.column === col.id)
649
+ const lines = items.length ? items.map(agentLine) : ['- Nothing.']
650
+ return `${col.name}\n${lines.join('\n')}`
651
+ }).join('\n\n') + hidden
652
+ }
653
+ if (!reader?.name) return board()
654
+ const open = openTasks(list)
655
+ const mine = open.filter((t) => assignedToReader(t, reader))
656
+ const other = open.filter((t) => t.assignee === reader.name && !assignedToReader(t, reader))
657
+ const lines = []
658
+ if (!mine.length) lines.push('No open tasks are assigned to you.')
659
+ else {
660
+ const via = reader.asAi ? ` (your ${assigneeTool(reader.tool)})` : ''
661
+ lines.push(`Yours — open tasks assigned to you${via}:`)
662
+ for (const t of mine) lines.push(`- ${t.id} ${oneLine(t.title)} (${columnName(t.column)})${t.files?.length ? ` [${t.files.join(', ')}]` : ''}`)
663
+ }
664
+ if (other.length) {
665
+ const label = reader.asAi ? reader.name : `your ${assigneeTool(other[0].tool || reader.tool)}`
666
+ lines.push('', `Open, but assigned to ${label}, not to you:`)
667
+ for (const t of other) lines.push(`- ${t.id} ${oneLine(t.title)} (${columnName(t.column)})`)
668
+ }
669
+ return `${lines.join('\n')}\n\n${board()}`
670
+ }
@@ -0,0 +1,20 @@
1
+ // Turning a whole-file rewrite into small CRDT edits, so concurrent changes
2
+ // to different parts of a file both survive. Shared by the CLI and web app.
3
+ import diff from 'fast-diff'
4
+
5
+ /** Applies the minimal set of inserts/deletes to turn ytext into `next`. Returns "+added -removed" lines. */
6
+ export function applyTextDiff (ytext, next) {
7
+ const prev = ytext.toString()
8
+ if (prev === next) return ''
9
+ let pos = 0; let added = 0; let removed = 0
10
+ for (const [op, str] of diff(prev, next)) {
11
+ if (op === diff.EQUAL) pos += str.length
12
+ else if (op === diff.DELETE) { ytext.delete(pos, str.length); removed += countLines(str) } else { ytext.insert(pos, str); pos += str.length; added += countLines(str) }
13
+ }
14
+ return `+${added} -${removed}`
15
+ }
16
+
17
+ function countLines (s) {
18
+ const n = s.split('\n').length - 1
19
+ return n || 1
20
+ }