heyquilt 0.0.1 → 0.3.106
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +85 -0
- package/README.md +633 -2
- package/RELEASES.md +862 -0
- package/assets/logo.svg +1 -0
- package/bin/quilt.js +849 -0
- package/desktop/main.js +315 -0
- package/package.json +163 -7
- package/src/account.js +591 -0
- package/src/admit-policy.js +32 -0
- package/src/agent-join.js +370 -0
- package/src/agent-task-workflow.js +176 -0
- package/src/agents/actions.js +85 -0
- package/src/agents/claude-code.js +187 -0
- package/src/agents/common.js +21 -0
- package/src/agents/cursor-transcripts.js +177 -0
- package/src/agents/cursor.js +404 -0
- package/src/agents/index.js +69 -0
- package/src/ai-vm-signin.js +110 -0
- package/src/ai-vm.js +522 -0
- package/src/api/access.js +75 -0
- package/src/api/activity.js +181 -0
- package/src/api/agent-auth.js +136 -0
- package/src/api/agent-placement.js +157 -0
- package/src/api/audit.js +83 -0
- package/src/api/auth.js +17 -0
- package/src/api/domains.js +30 -0
- package/src/api/file-paths.js +40 -0
- package/src/api/file-store.js +99 -0
- package/src/api/fly-machines.js +133 -0
- package/src/api/github-app.js +172 -0
- package/src/api/http.js +34 -0
- package/src/api/invite-email.js +53 -0
- package/src/api/issues.js +110 -0
- package/src/api/join-text.js +78 -0
- package/src/api/mailer.js +47 -0
- package/src/api/memory-store.js +1219 -0
- package/src/api/org-access.js +54 -0
- package/src/api/permissions.js +71 -0
- package/src/api/relay-cluster.js +545 -0
- package/src/api/relay-machines.js +92 -0
- package/src/api/releases.js +87 -0
- package/src/api/routes/access-types.js +54 -0
- package/src/api/routes/agent-invites.js +115 -0
- package/src/api/routes/agents.js +252 -0
- package/src/api/routes/ai-vm.js +495 -0
- package/src/api/routes/github.js +380 -0
- package/src/api/routes/grants.js +70 -0
- package/src/api/routes/invites.js +188 -0
- package/src/api/routes/issues.js +47 -0
- package/src/api/routes/join.js +167 -0
- package/src/api/routes/members.js +110 -0
- package/src/api/routes/orgs.js +179 -0
- package/src/api/routes/relay-cluster.js +90 -0
- package/src/api/routes/relay.js +76 -0
- package/src/api/routes/secrets.js +448 -0
- package/src/api/routes/session-invites.js +119 -0
- package/src/api/routes/sessions.js +92 -0
- package/src/api/routes/teams.js +132 -0
- package/src/api/routes/workspace-agents.js +332 -0
- package/src/api/routes/workspace-files.js +277 -0
- package/src/api/routes/workspace-invites.js +196 -0
- package/src/api/routes/workspaces.js +232 -0
- package/src/api/sandbox-keys.js +57 -0
- package/src/api/secrets-access.js +93 -0
- package/src/api/server.js +654 -0
- package/src/api/slugs.js +20 -0
- package/src/api/supabase-store.js +875 -0
- package/src/api/team-access.js +30 -0
- package/src/api/tokens.js +31 -0
- package/src/api/workspace-access.js +62 -0
- package/src/api/workspace-reach.js +60 -0
- package/src/autowork-tools.js +166 -0
- package/src/autowork.js +375 -0
- package/src/blobstore.js +99 -0
- package/src/boxes.js +37 -0
- package/src/branchdocs.js +235 -0
- package/src/branches.js +194 -0
- package/src/catchup.js +111 -0
- package/src/chat-archive.js +85 -0
- package/src/chat-links.js +923 -0
- package/src/clash.js +128 -0
- package/src/cloud-ai.js +50 -0
- package/src/commit-request.js +59 -0
- package/src/commit.js +213 -0
- package/src/connection.js +569 -0
- package/src/control.js +248 -0
- package/src/conversation.js +125 -0
- package/src/doctor.js +156 -0
- package/src/duties.js +303 -0
- package/src/editors.js +228 -0
- package/src/fsutil.js +97 -0
- package/src/git-hook.js +48 -0
- package/src/git.js +147 -0
- package/src/github-commit.js +320 -0
- package/src/gitignore.js +49 -0
- package/src/gitstate.js +924 -0
- package/src/history.js +307 -0
- package/src/hooks.js +371 -0
- package/src/hosted-agents.js +24 -0
- package/src/hosted-client.js +112 -0
- package/src/identity.js +99 -0
- package/src/inbox.js +237 -0
- package/src/integrations.js +221 -0
- package/src/key-cli.js +115 -0
- package/src/largefiles.js +91 -0
- package/src/legacy.js +25 -0
- package/src/mcp-proxy.js +45 -0
- package/src/mcp-room.js +32 -0
- package/src/mcp.js +1258 -0
- package/src/merge3.js +240 -0
- package/src/merges.js +148 -0
- package/src/pass-source.js +183 -0
- package/src/passes.js +64 -0
- package/src/pathrules.js +134 -0
- package/src/persona.js +101 -0
- package/src/presence.js +360 -0
- package/src/private-file.js +41 -0
- package/src/procs.js +70 -0
- package/src/project-rules.js +178 -0
- package/src/protocol.js +92 -0
- package/src/quiet-warnings.js +9 -0
- package/src/relay-ai-vm.js +82 -0
- package/src/relay-ask.js +141 -0
- package/src/relay-ci.js +111 -0
- package/src/relay-clash.js +214 -0
- package/src/relay-cluster.js +454 -0
- package/src/relay-commit.js +887 -0
- package/src/relay-filekey.js +46 -0
- package/src/relay-git.js +274 -0
- package/src/relay-githttp.js +166 -0
- package/src/relay-github.js +109 -0
- package/src/relay-lease.js +137 -0
- package/src/relay-lfs.js +37 -0
- package/src/relay-mcp.js +1691 -0
- package/src/relay-metrics.js +140 -0
- package/src/relay-outbound.js +1273 -0
- package/src/relay-sync.js +716 -0
- package/src/relay-turn-sync.js +506 -0
- package/src/relay-upstream.js +804 -0
- package/src/relay-webhooks.js +111 -0
- package/src/releases.js +138 -0
- package/src/report.js +129 -0
- package/src/runner.js +307 -0
- package/src/same-edit.js +18 -0
- package/src/secret-import.js +55 -0
- package/src/secret-names.js +26 -0
- package/src/secret-run.js +80 -0
- package/src/secrets-client.js +56 -0
- package/src/server.js +4214 -0
- package/src/session-access.js +170 -0
- package/src/session-name.js +15 -0
- package/src/session.js +7446 -0
- package/src/settings.js +46 -0
- package/src/setup.js +184 -0
- package/src/shell-path.js +46 -0
- package/src/status.js +197 -0
- package/src/summarize.js +109 -0
- package/src/sync-words.js +42 -0
- package/src/task-comments.js +92 -0
- package/src/tasks.js +670 -0
- package/src/textdiff.js +20 -0
- package/src/turn-sync-plan.js +145 -0
- package/src/turn-sync.js +212 -0
- package/src/ui/access-form.js +37 -0
- package/src/ui/agent-env-key.js +76 -0
- package/src/ui/agent-guide.js +127 -0
- package/src/ui/agent-kinds.js +175 -0
- package/src/ui/agent-place.js +265 -0
- package/src/ui/app.css +2494 -0
- package/src/ui/app.js +546 -0
- package/src/ui/board.js +465 -0
- package/src/ui/boxes.js +76 -0
- package/src/ui/branches.js +111 -0
- package/src/ui/catchup.js +77 -0
- package/src/ui/changes.js +159 -0
- package/src/ui/chat.js +266 -0
- package/src/ui/cloud-ai-model.js +108 -0
- package/src/ui/cloud-ai.js +221 -0
- package/src/ui/common.js +433 -0
- package/src/ui/csv.js +21 -0
- package/src/ui/diffview.js +74 -0
- package/src/ui/feed-convs.js +39 -0
- package/src/ui/feed.js +174 -0
- package/src/ui/files.js +579 -0
- package/src/ui/fileview.js +150 -0
- package/src/ui/git-rule.js +60 -0
- package/src/ui/home.js +1322 -0
- package/src/ui/index.html +16 -0
- package/src/ui/invite.js +97 -0
- package/src/ui/invites.js +79 -0
- package/src/ui/loom-model.js +516 -0
- package/src/ui/loom.css +365 -0
- package/src/ui/loom.js +786 -0
- package/src/ui/mark.js +104 -0
- package/src/ui/rail-model.js +75 -0
- package/src/ui/rail.js +173 -0
- package/src/ui/releases.js +182 -0
- package/src/ui/sandbox-keys-model.js +53 -0
- package/src/ui/sandbox-keys.js +132 -0
- package/src/ui/schedule.js +156 -0
- package/src/ui/secret-rules.js +255 -0
- package/src/ui/secrets-view.js +162 -0
- package/src/ui/secrets.js +283 -0
- package/src/ui/session.js +3292 -0
- package/src/ui/signin.js +75 -0
- package/src/ui/sounds.js +110 -0
- package/src/ui/tool-logo.js +86 -0
- package/src/ui/tree.js +281 -0
- package/src/ui/workspaces.js +547 -0
- package/src/ui-server.js +1381 -0
- package/src/update-check.js +67 -0
- package/src/upstream.js +192 -0
- package/src/vault/crypto.js +96 -0
- package/src/vault/kms.js +61 -0
- package/src/vault/main.js +33 -0
- package/src/vault/server.js +279 -0
- package/src/vault/sigv4.js +38 -0
- package/src/webhooks.js +235 -0
- package/src/workspace-cli.js +62 -0
- package/src/workspace-tools.js +340 -0
- 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
|
+
}
|
package/src/textdiff.js
ADDED
|
@@ -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
|
+
}
|