heyquilt 0.0.1 → 0.3.90

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (204) hide show
  1. package/LICENSE +85 -0
  2. package/README.md +630 -2
  3. package/RELEASES.md +733 -0
  4. package/assets/logo.svg +1 -0
  5. package/bin/quilt.js +816 -0
  6. package/desktop/main.js +315 -0
  7. package/package.json +163 -7
  8. package/src/account.js +563 -0
  9. package/src/admit-policy.js +32 -0
  10. package/src/agent-join.js +370 -0
  11. package/src/agent-task-workflow.js +176 -0
  12. package/src/agents/actions.js +85 -0
  13. package/src/agents/claude-code.js +180 -0
  14. package/src/agents/common.js +21 -0
  15. package/src/agents/cursor-transcripts.js +170 -0
  16. package/src/agents/cursor.js +404 -0
  17. package/src/agents/index.js +69 -0
  18. package/src/ai-vm-signin.js +98 -0
  19. package/src/ai-vm.js +380 -0
  20. package/src/api/access.js +75 -0
  21. package/src/api/activity.js +181 -0
  22. package/src/api/agent-auth.js +130 -0
  23. package/src/api/agent-placement.js +157 -0
  24. package/src/api/audit.js +83 -0
  25. package/src/api/auth.js +17 -0
  26. package/src/api/domains.js +30 -0
  27. package/src/api/file-paths.js +40 -0
  28. package/src/api/file-store.js +99 -0
  29. package/src/api/fly-machines.js +76 -0
  30. package/src/api/github-app.js +172 -0
  31. package/src/api/http.js +34 -0
  32. package/src/api/invite-email.js +53 -0
  33. package/src/api/issues.js +110 -0
  34. package/src/api/join-text.js +78 -0
  35. package/src/api/mailer.js +47 -0
  36. package/src/api/memory-store.js +1083 -0
  37. package/src/api/org-access.js +54 -0
  38. package/src/api/permissions.js +71 -0
  39. package/src/api/relay-cluster.js +251 -0
  40. package/src/api/relay-machines.js +60 -0
  41. package/src/api/routes/access-types.js +54 -0
  42. package/src/api/routes/agent-invites.js +115 -0
  43. package/src/api/routes/agents.js +157 -0
  44. package/src/api/routes/ai-vm.js +369 -0
  45. package/src/api/routes/github.js +380 -0
  46. package/src/api/routes/grants.js +70 -0
  47. package/src/api/routes/invites.js +188 -0
  48. package/src/api/routes/issues.js +47 -0
  49. package/src/api/routes/join.js +167 -0
  50. package/src/api/routes/members.js +110 -0
  51. package/src/api/routes/orgs.js +177 -0
  52. package/src/api/routes/relay-cluster.js +90 -0
  53. package/src/api/routes/relay.js +76 -0
  54. package/src/api/routes/session-invites.js +119 -0
  55. package/src/api/routes/sessions.js +92 -0
  56. package/src/api/routes/teams.js +132 -0
  57. package/src/api/routes/workspace-agents.js +332 -0
  58. package/src/api/routes/workspace-files.js +277 -0
  59. package/src/api/routes/workspace-invites.js +196 -0
  60. package/src/api/routes/workspaces.js +225 -0
  61. package/src/api/server.js +623 -0
  62. package/src/api/slugs.js +20 -0
  63. package/src/api/supabase-store.js +765 -0
  64. package/src/api/team-access.js +30 -0
  65. package/src/api/tokens.js +31 -0
  66. package/src/api/workspace-access.js +62 -0
  67. package/src/api/workspace-reach.js +60 -0
  68. package/src/autowork-tools.js +166 -0
  69. package/src/autowork.js +387 -0
  70. package/src/blobstore.js +99 -0
  71. package/src/boxes.js +37 -0
  72. package/src/branchdocs.js +235 -0
  73. package/src/branches.js +194 -0
  74. package/src/catchup.js +111 -0
  75. package/src/chat-archive.js +85 -0
  76. package/src/chat-links.js +923 -0
  77. package/src/clash.js +140 -0
  78. package/src/cloud-ai.js +50 -0
  79. package/src/commit-request.js +59 -0
  80. package/src/commit.js +213 -0
  81. package/src/connection.js +569 -0
  82. package/src/control.js +245 -0
  83. package/src/conversation.js +125 -0
  84. package/src/doctor.js +156 -0
  85. package/src/duties.js +303 -0
  86. package/src/editors.js +228 -0
  87. package/src/fsutil.js +87 -0
  88. package/src/git-hook.js +48 -0
  89. package/src/git.js +147 -0
  90. package/src/github-commit.js +320 -0
  91. package/src/gitignore.js +49 -0
  92. package/src/gitstate.js +900 -0
  93. package/src/history.js +307 -0
  94. package/src/hooks.js +371 -0
  95. package/src/hosted-agents.js +24 -0
  96. package/src/hosted-client.js +112 -0
  97. package/src/identity.js +99 -0
  98. package/src/inbox.js +237 -0
  99. package/src/integrations.js +221 -0
  100. package/src/key-cli.js +115 -0
  101. package/src/largefiles.js +91 -0
  102. package/src/legacy.js +25 -0
  103. package/src/mcp-proxy.js +40 -0
  104. package/src/mcp-room.js +32 -0
  105. package/src/mcp.js +1261 -0
  106. package/src/merge3.js +158 -0
  107. package/src/merges.js +148 -0
  108. package/src/pass-source.js +176 -0
  109. package/src/passes.js +64 -0
  110. package/src/pathrules.js +127 -0
  111. package/src/persona.js +101 -0
  112. package/src/presence.js +360 -0
  113. package/src/private-file.js +41 -0
  114. package/src/procs.js +70 -0
  115. package/src/project-rules.js +178 -0
  116. package/src/protocol.js +92 -0
  117. package/src/quiet-warnings.js +9 -0
  118. package/src/relay-ai-vm.js +82 -0
  119. package/src/relay-ask.js +141 -0
  120. package/src/relay-ci.js +111 -0
  121. package/src/relay-clash.js +597 -0
  122. package/src/relay-cluster.js +454 -0
  123. package/src/relay-commit.js +887 -0
  124. package/src/relay-filekey.js +46 -0
  125. package/src/relay-git.js +274 -0
  126. package/src/relay-githttp.js +137 -0
  127. package/src/relay-github.js +109 -0
  128. package/src/relay-lease.js +137 -0
  129. package/src/relay-lfs.js +37 -0
  130. package/src/relay-mcp.js +1679 -0
  131. package/src/relay-metrics.js +75 -0
  132. package/src/relay-outbound.js +1144 -0
  133. package/src/relay-sync.js +719 -0
  134. package/src/relay-turn-sync.js +307 -0
  135. package/src/relay-upstream.js +934 -0
  136. package/src/relay-webhooks.js +111 -0
  137. package/src/releases.js +131 -0
  138. package/src/report.js +129 -0
  139. package/src/runner.js +306 -0
  140. package/src/same-edit.js +18 -0
  141. package/src/server.js +4187 -0
  142. package/src/session-access.js +170 -0
  143. package/src/session-name.js +15 -0
  144. package/src/session.js +7361 -0
  145. package/src/settings.js +46 -0
  146. package/src/setup.js +184 -0
  147. package/src/shell-path.js +46 -0
  148. package/src/status.js +199 -0
  149. package/src/summarize.js +109 -0
  150. package/src/sync-words.js +42 -0
  151. package/src/task-comments.js +92 -0
  152. package/src/tasks.js +670 -0
  153. package/src/textdiff.js +20 -0
  154. package/src/turn-sync-plan.js +142 -0
  155. package/src/turn-sync.js +191 -0
  156. package/src/ui/access-form.js +37 -0
  157. package/src/ui/agent-env-key.js +76 -0
  158. package/src/ui/agent-guide.js +127 -0
  159. package/src/ui/agent-kinds.js +175 -0
  160. package/src/ui/agent-place.js +265 -0
  161. package/src/ui/app.css +2309 -0
  162. package/src/ui/app.js +542 -0
  163. package/src/ui/board.js +465 -0
  164. package/src/ui/boxes.js +76 -0
  165. package/src/ui/branches.js +111 -0
  166. package/src/ui/catchup.js +77 -0
  167. package/src/ui/changes.js +159 -0
  168. package/src/ui/chat.js +266 -0
  169. package/src/ui/cloud-ai-model.js +107 -0
  170. package/src/ui/cloud-ai.js +221 -0
  171. package/src/ui/common.js +431 -0
  172. package/src/ui/csv.js +21 -0
  173. package/src/ui/diffview.js +74 -0
  174. package/src/ui/feed-convs.js +39 -0
  175. package/src/ui/feed.js +174 -0
  176. package/src/ui/files.js +579 -0
  177. package/src/ui/fileview.js +123 -0
  178. package/src/ui/git-rule.js +60 -0
  179. package/src/ui/home.js +1323 -0
  180. package/src/ui/index.html +16 -0
  181. package/src/ui/invite.js +97 -0
  182. package/src/ui/invites.js +79 -0
  183. package/src/ui/loom-model.js +516 -0
  184. package/src/ui/loom.css +349 -0
  185. package/src/ui/loom.js +753 -0
  186. package/src/ui/mark.js +104 -0
  187. package/src/ui/merges.js +158 -0
  188. package/src/ui/rail-model.js +78 -0
  189. package/src/ui/rail.js +173 -0
  190. package/src/ui/releases.js +182 -0
  191. package/src/ui/schedule.js +156 -0
  192. package/src/ui/session.js +3394 -0
  193. package/src/ui/signin.js +75 -0
  194. package/src/ui/sounds.js +110 -0
  195. package/src/ui/tool-logo.js +86 -0
  196. package/src/ui/tree.js +276 -0
  197. package/src/ui/workspaces.js +542 -0
  198. package/src/ui-server.js +1272 -0
  199. package/src/update-check.js +67 -0
  200. package/src/upstream.js +155 -0
  201. package/src/webhooks.js +235 -0
  202. package/src/workspace-cli.js +62 -0
  203. package/src/workspace-tools.js +340 -0
  204. package/index.js +0 -2
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env node
2
+ // The hooks of the relay's git views (relay-githttp.js): git runs this for a push into a session's
3
+ // view, with the ref updates on stdin. The relay decides (QUILT_HOOK_URL, a secret only this relay
4
+ // process knows): before the refs move ("pre-receive") it checks the push and puts it on GitHub;
5
+ // after ("post-receive") it sets the view's branches back to what GitHub has. What the relay says is
6
+ // printed for the person or agent pushing ("remote: …").
7
+ import http from 'node:http'
8
+ import path from 'node:path'
9
+
10
+ // git gives hooks some paths relative to the repository (GIT_DIR=.): the relay needs them whole.
11
+ const whole = (p) => (p ? path.resolve(process.cwd(), p) : '')
12
+ const wholeList = (l) => String(l || '').split(path.delimiter).filter(Boolean).map(whole).join(path.delimiter)
13
+
14
+ const phase = process.argv[2] === 'post-receive' ? 'post' : 'pre'
15
+ let input = ''
16
+ process.stdin.setEncoding('utf8')
17
+ process.stdin.on('data', (d) => { input += d })
18
+ process.stdin.on('end', () => {
19
+ const url = process.env.QUILT_HOOK_URL
20
+ if (!url) { process.stderr.write('Quilt: this push did not come through the relay.\n'); process.exit(phase === 'pre' ? 1 : 0) }
21
+ const body = JSON.stringify({
22
+ phase,
23
+ room: process.env.QUILT_ROOM || '',
24
+ who: process.env.QUILT_WHO || '',
25
+ name: process.env.QUILT_WHO_NAME || '',
26
+ updates: input,
27
+ env: {
28
+ GIT_DIR: whole(process.env.GIT_DIR || '.'),
29
+ GIT_OBJECT_DIRECTORY: whole(process.env.GIT_OBJECT_DIRECTORY),
30
+ GIT_ALTERNATE_OBJECT_DIRECTORIES: wholeList(process.env.GIT_ALTERNATE_OBJECT_DIRECTORIES),
31
+ GIT_QUARANTINE_PATH: whole(process.env.GIT_QUARANTINE_PATH)
32
+ }
33
+ })
34
+ const req = http.request(url, { method: 'POST', headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body), authorization: `Bearer ${process.env.QUILT_HOOK_TOKEN || ''}` }, timeout: 10 * 60 * 1000 }, (res) => {
35
+ let out = ''
36
+ res.setEncoding('utf8')
37
+ res.on('data', (d) => { out += d })
38
+ res.on('end', () => {
39
+ let r = null
40
+ try { r = JSON.parse(out) } catch {}
41
+ if (r && r.message) process.stderr.write(`${r.message}\n`)
42
+ process.exit(r && r.ok ? 0 : (phase === 'pre' ? 1 : 0))
43
+ })
44
+ })
45
+ req.on('error', (err) => { process.stderr.write(`Quilt: the relay did not answer (${err.message}).\n`); process.exit(phase === 'pre' ? 1 : 0) })
46
+ req.on('timeout', () => req.destroy(new Error('it took too long')))
47
+ req.end(body)
48
+ })
package/src/git.js ADDED
@@ -0,0 +1,147 @@
1
+ // Git for starting a session from GitHub: checking names, listing repos and
2
+ // branches, cloning. Quilt never commits, pulls or pushes. Wraps the `git`
3
+ // and `gh` CLIs; arguments are always passed as a list, never through a shell.
4
+ import fs from 'node:fs'
5
+ import path from 'node:path'
6
+ import { execFile } from 'node:child_process'
7
+
8
+ // QUILT_GH lets tests stand in a fake `gh`.
9
+ const GH = () => process.env.QUILT_GH || 'gh'
10
+ const REPO_RE = /^[\w.-]+\/[\w.-]+$/
11
+ // Never stop to ask for a password or open an editor.
12
+ const ENV = { GIT_TERMINAL_PROMPT: '0', GH_PROMPT_DISABLED: '1', GIT_EDITOR: 'true', GH_NO_UPDATE_NOTIFIER: '1' }
13
+
14
+ /** Runs a command; resolves to stdout, or rejects with a short message from stderr. */
15
+ function run (cmd, args, { cwd, allowFail = false } = {}) {
16
+ return new Promise((resolve, reject) => {
17
+ execFile(cmd, args, { cwd, env: { ...process.env, ...ENV }, maxBuffer: 32 * 1024 * 1024, timeout: 5 * 60 * 1000 }, (err, stdout, stderr) => {
18
+ if (!err) return resolve(stdout)
19
+ if (allowFail) return resolve(null)
20
+ if (err.code === 'ENOENT') return reject(new Error(cmd === GH() ? 'The GitHub CLI (gh) is not installed.' : 'git is not installed.'))
21
+ const e = new Error(shortError(stderr || stdout || err.message))
22
+ e.stderr = String(stderr || '')
23
+ e.exitCode = err.code
24
+ reject(e)
25
+ })
26
+ })
27
+ }
28
+ const git = (dir, args, opts) => run('git', args, { cwd: dir, ...opts })
29
+ const gh = (args, opts) => run(GH(), args, opts)
30
+
31
+ /** The first useful line of a CLI error, without "fatal:"/"error:" noise. */
32
+ function shortError (text) {
33
+ const lines = String(text).split('\n').map((l) => l.trim()).filter(Boolean)
34
+ const line = lines.find((l) => /^(fatal|error|remote|GraphQL|HTTP)/i.test(l)) || lines[0] || 'Something went wrong.'
35
+ return line.replace(/^(fatal|error):\s*/i, '').replace(/^remote:\s*/i, '').slice(0, 300)
36
+ }
37
+
38
+ /** Throws unless `name` is a valid branch name. */
39
+ export async function checkBranchName (name) {
40
+ name = String(name || '').trim()
41
+ if (!name) throw new Error('Enter a branch name.')
42
+ // Not `--branch`: it expands @{-N} using the current folder's repo, so the
43
+ // answer would depend on where quilt happens to run.
44
+ if (name.startsWith('-') || name === 'HEAD' || (await run('git', ['check-ref-format', `refs/heads/${name}`], { allowFail: true })) === null) {
45
+ throw new Error(`"${name}" isn't a valid branch name.`)
46
+ }
47
+ return name
48
+ }
49
+
50
+ function checkRepo (repo) {
51
+ repo = String(repo || '').trim()
52
+ if (!REPO_RE.test(repo) || repo.split('/').some((p) => /^\.+$/.test(p))) throw new Error('Pick a repository (owner/name).')
53
+ return repo
54
+ }
55
+
56
+ // --------------------------------------------------------------- GitHub --
57
+
58
+ /** Is gh installed and signed in? { installed, authenticated, user, message } */
59
+ export async function ghStatus () {
60
+ const probe = (args) => new Promise((resolve) => {
61
+ execFile(GH(), args, { env: { ...process.env, ...ENV }, timeout: 20000 }, (err, stdout, stderr) => resolve({ err, text: `${stdout}\n${stderr}` }))
62
+ })
63
+ const version = await probe(['--version'])
64
+ if (version.err && version.err.code === 'ENOENT') return { installed: false, authenticated: false, user: null, message: 'Quilt can\'t list your GitHub repositories on this computer: start from a folder (a clone) instead.' }
65
+ const out = await probe(['auth', 'status', '--hostname', 'github.com'])
66
+ if (out.err) return { installed: true, authenticated: false, user: null, message: 'Quilt can\'t list your GitHub repositories on this computer: start from a folder (a clone) instead.' }
67
+ const user = (out.text.match(/account (\S+)/) || out.text.match(/as (\S+)/) || [])[1] || null
68
+ return { installed: true, authenticated: true, user, message: null }
69
+ }
70
+
71
+ /** Your repos, plus your organizations' repos, most recently updated first. */
72
+ export async function listRepos ({ limit = 100 } = {}) {
73
+ limit = Math.max(1, Math.min(1000, Number(limit) || 100))
74
+ const fields = ['--json', 'nameWithOwner,description,updatedAt,defaultBranchRef']
75
+ const parse = (out) => { try { return JSON.parse(out || '[]') } catch { return [] } }
76
+ const mine = parse(await gh(['repo', 'list', ...fields, '--limit', String(limit)]))
77
+ const orgs = String(await gh(['api', 'user/orgs', '--jq', '.[].login'], { allowFail: true }) || '').split('\n').filter(Boolean).slice(0, 10)
78
+ const theirs = await Promise.all(orgs.map((o) => gh(['repo', 'list', o, ...fields, '--limit', String(Math.min(limit, 100))], { allowFail: true }).then(parse)))
79
+ const seen = new Set()
80
+ return [...mine, ...theirs.flat()]
81
+ .filter((r) => r && r.nameWithOwner && !seen.has(r.nameWithOwner) && seen.add(r.nameWithOwner))
82
+ .sort((a, b) => String(b.updatedAt || '').localeCompare(String(a.updatedAt || '')))
83
+ .map((r) => ({ name: r.nameWithOwner, description: r.description || '', updatedAt: r.updatedAt || null, defaultBranch: r.defaultBranchRef?.name || null }))
84
+ }
85
+
86
+ /** Branch names of a GitHub repo, and which one is the default. */
87
+ export async function listBranches (repo) {
88
+ repo = checkRepo(repo)
89
+ const [names, def] = await Promise.all([
90
+ gh(['api', `repos/${repo}/branches`, '--paginate', '--jq', '.[].name']),
91
+ gh(['api', `repos/${repo}`, '--jq', '.default_branch'])
92
+ ])
93
+ const defaultBranch = def.trim() || null
94
+ const branches = names.split('\n').map((s) => s.trim()).filter(Boolean)
95
+ // Default branch first, then alphabetical.
96
+ branches.sort((a, b) => (a === defaultBranch ? -1 : b === defaultBranch ? 1 : a.localeCompare(b)))
97
+ return { branches, defaultBranch }
98
+ }
99
+
100
+ async function defaultBranchOf (dir) {
101
+ const head = await git(dir, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], { allowFail: true })
102
+ if (head && head.trim()) return head.trim().replace(/^origin\//, '')
103
+ for (const b of ['main', 'master']) {
104
+ if ((await git(dir, ['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${b}`], { allowFail: true })) !== null) return b
105
+ }
106
+ return null
107
+ }
108
+
109
+ /**
110
+ * Clones `repo` into `dir` (which must be new or empty), then checks out
111
+ * `branch`, or creates `newBranch` from origin/`base` (default: the default branch).
112
+ */
113
+ export async function cloneRepo ({ repo, dir, branch, newBranch, base }) {
114
+ repo = checkRepo(repo)
115
+ if (!dir) throw new Error('Choose a folder to clone into.')
116
+ dir = path.resolve(dir)
117
+ if (branch) branch = await checkBranchName(branch)
118
+ if (newBranch) newBranch = await checkBranchName(newBranch)
119
+ if (base) base = await checkBranchName(base)
120
+ const existed = fs.existsSync(dir)
121
+ if (existed && fs.readdirSync(dir).some((f) => f !== '.DS_Store')) {
122
+ throw new Error(`${path.basename(dir)} already has files in it. Pick an empty or new folder.`)
123
+ }
124
+ fs.mkdirSync(path.dirname(dir), { recursive: true })
125
+ try {
126
+ await gh(['repo', 'clone', repo, dir])
127
+ const has = async (b) => (await git(dir, ['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${b}`], { allowFail: true })) !== null
128
+ if (newBranch) {
129
+ base = base || await defaultBranchOf(dir)
130
+ if (!base || !(await has(base))) throw new Error(`There's no branch named ${base} on GitHub to start from.`)
131
+ if (await has(newBranch)) throw new Error(`A branch named ${newBranch} already exists. Use it instead, or pick another name.`)
132
+ await git(dir, ['checkout', '-q', '--no-track', '-b', newBranch, `origin/${base}`])
133
+ } else if (branch) {
134
+ const current = (await git(dir, ['branch', '--show-current'])).trim()
135
+ if (branch !== current) {
136
+ if (!(await has(branch))) throw new Error(`There's no branch named ${branch} on GitHub.`)
137
+ await git(dir, ['checkout', '-q', '-b', branch, '--track', `origin/${branch}`])
138
+ }
139
+ }
140
+ return { dir, branch: (await git(dir, ['branch', '--show-current'])).trim() }
141
+ } catch (err) {
142
+ // Only clean up what we made.
143
+ if (!existed) fs.rmSync(dir, { recursive: true, force: true })
144
+ else for (const f of fs.readdirSync(dir)) if (f !== '.DS_Store') fs.rmSync(path.join(dir, f), { recursive: true, force: true })
145
+ throw err
146
+ }
147
+ }
@@ -0,0 +1,320 @@
1
+ // Commits made by the relay, through GitHub's API, for any member of a session: an agent on any
2
+ // tool (Codex, Cursor, Claude Code, a hosted agent over HTTP), with no git and no credentials on
3
+ // its machine, and with nobody else online. The session's copy of the files is the content; the
4
+ // branch's commit on GitHub is the parent; only the files named change. No folder, index or
5
+ // working tree is involved, so nobody's uncommitted work is swept in.
6
+ //
7
+ // It never rewrites history: a ref is only moved forward (GitHub refuses anything else without
8
+ // force, which is never sent), and a file that changed on GitHub since the session's copy was
9
+ // taken is refused rather than overwritten. Public and private repositories work alike: writing
10
+ // needs the session's GitHub token with write access to the repository's contents (and to pull
11
+ // requests, to open one).
12
+ //
13
+ // Git for this session (AGENT_COMMITS; docs/superpowers/specs/2026-10-17-git-for-everyone.md): off for
14
+ // everyone, or on with what agents may push: their own branches only (quilt/<agent>/…, the default),
15
+ // or any branch, the session's own included. People may push any branch while it is on.
16
+ import crypto from 'node:crypto'
17
+ import { blameChange } from './history.js'
18
+
19
+ const API = 'https://api.github.com'
20
+ export const AGENT_COMMITS = ['off', 'branches', 'any']
21
+ export const DEFAULT_AGENT_COMMITS = 'branches'
22
+ export const MAX_COMMIT_FILES = 300
23
+ export const MAX_COMMIT_BYTES = 20 * 1024 * 1024
24
+ const BRANCH = /^(?!\/)(?!.*\/\/)(?!.*\.\.)(?!.*\.lock(\/|$))(?!.*[\s~^:?*[\\\x00-\x1f\x7f])(?!.*@\{)[^]+(?<![/.])$/
25
+
26
+ export class CommitRefused extends Error {}
27
+
28
+ /** Who set the rule over a session, in words: "the workspace Platform", "the team Night shift", "the org Acme". */
29
+ export const ruleSetter = (set) => `the ${set.by === 'org' ? 'org' : set.by === 'team' ? 'team' : 'workspace'}${set.name ? ` ${set.name}` : ''}`
30
+
31
+ /**
32
+ * Git for everyone, in one line, for whoever is in the session: `policy` ('off', 'branches', 'any'),
33
+ * `by` (the rule over the session, { by, name }, when one set it) and `login` (the GitHub account
34
+ * whose connection covers the repository, when one does).
35
+ */
36
+ export function gitForEveryoneWords ({ policy = DEFAULT_AGENT_COMMITS, by = null, login = '' } = {}) {
37
+ if (policy === 'off') return `Git is off for everyone in this session: ${by ? ruleSetter(by) : 'the session owner'} turned it off.`
38
+ const agents = policy === 'any' ? 'people and agents push to any branch' : 'people push to any branch, agents to branches of their own'
39
+ if (login) return `Git is on for everyone here, through @${login}'s connection: ${agents}, with nothing to set up.`
40
+ return `Git is on for everyone here once someone connects GitHub (one click in the commit panel, by anyone whose GitHub account can write to the repository): ${agents}.`
41
+ }
42
+
43
+ /** git's id for a file's bytes (what GitHub's trees list), so content is compared without fetching it. */
44
+ export function gitBlobSha (buf) {
45
+ return crypto.createHash('sha1').update(`blob ${buf.length}\0`).update(buf).digest('hex')
46
+ }
47
+
48
+ /** "duncan", "daniel-carmichael": a name as a branch path segment. */
49
+ export function slug (s, max = 40) {
50
+ return String(s || '').toLowerCase().normalize('NFKD').replace(/[^\w\s-]/g, '').trim().replace(/[\s_]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '').slice(0, max).replace(/-$/, '') || 'work'
51
+ }
52
+
53
+ /** The branches an agent owns: quilt/<its name>/… */
54
+ export const agentPrefix = (name) => `quilt/${slug(name, 30)}/`
55
+
56
+ /** Whether `name` is a branch name git and GitHub accept. */
57
+ export const validBranchName = (name) => typeof name === 'string' && name.length > 0 && name.length <= 200 && BRANCH.test(name)
58
+
59
+ /**
60
+ * Where a member's commit goes, or why it may not: { branch, own } or throws CommitRefused.
61
+ * `sessionBranch`: the GitHub branch the session's branch follows (main). `target`: what they
62
+ * asked for ('' = the default). `kind`: 'agent' for agents, anything else for people.
63
+ */
64
+ export function commitTarget ({ policy = DEFAULT_AGENT_COMMITS, kind = 'human', name, sessionBranch, target = '', message = '' }) {
65
+ const agent = kind === 'agent'
66
+ const mode = AGENT_COMMITS.includes(policy) ? policy : DEFAULT_AGENT_COMMITS
67
+ if (agent && mode === 'off') throw new CommitRefused('Git is off for everyone in this session. Ask a person for the commit with quilt_request_commit (your files and a message).')
68
+ const prefix = agentPrefix(name)
69
+ let branch = String(target || '').trim().replace(/^refs\/heads\//, '')
70
+ if (!branch) branch = agent && mode === 'branches' ? `${prefix}${slug(message, 40)}` : sessionBranch
71
+ if (!validBranchName(branch)) throw new CommitRefused(`"${branch}" is not a branch name git accepts.`)
72
+ if (agent && mode === 'branches' && !branch.startsWith(prefix)) {
73
+ throw new CommitRefused(`The session owner lets agents commit only to branches of their own: ${prefix}<topic>${branch === sessionBranch ? ` (not ${sessionBranch} itself)` : ''}. Leave branch out to get one, and open a pull request with pull_request: true.`)
74
+ }
75
+ return { branch, own: branch !== sessionBranch }
76
+ }
77
+
78
+ /**
79
+ * What a commit of `files` would change: { changes: [{ path, sha?, deleted, bytes }], same: [paths], moved: [paths] }.
80
+ * `files`: Map path -> Buffer (the session's content) or null (deleted in the session).
81
+ * `parent`: Map path -> blob sha at the commit the new one goes on; `base`: at the commit the
82
+ * session's copy is measured from. A file whose blob differs between the two changed on GitHub
83
+ * since the session took its copy: it is `moved`, and committing the session's copy would undo
84
+ * that change. Without `base` (a branch only its agent writes to) nothing counts as moved.
85
+ */
86
+ export function planCommit (files, { parent, base = null }) {
87
+ const changes = []
88
+ const same = []
89
+ const moved = []
90
+ for (const [rel, buf] of files) {
91
+ const now = buf ? gitBlobSha(buf) : null
92
+ const at = parent.get(rel) || null
93
+ if (now === at) { same.push(rel); continue }
94
+ if (base && (base.get(rel) || null) !== at) { moved.push(rel); continue }
95
+ changes.push({ path: rel, sha: now, deleted: !buf, bytes: buf || null })
96
+ }
97
+ return { changes, same, moved }
98
+ }
99
+
100
+ /**
101
+ * Who changed each file in the session since `since` (ms) besides `me`, from the branch's
102
+ * history (entries oldest first): Map path -> [names]. Pulls from git are nobody's.
103
+ */
104
+ export function othersEditing (paths, history, { me, since = 0 } = {}) {
105
+ const want = new Set(paths)
106
+ const out = new Map()
107
+ for (const e of history || []) {
108
+ if (!e || !want.has(e.path) || e.pulled || e.seed || !e.by || e.by === me || (e.ts || 0) <= since) continue
109
+ const list = out.get(e.path) || []
110
+ if (!list.includes(e.by)) list.push(e.by)
111
+ out.set(e.path, list)
112
+ }
113
+ return out
114
+ }
115
+
116
+ /** Who besides `me` made the changes a commit of one text file would carry (history.js blameChange). */
117
+ export function othersInChange ({ before = '', after = '', entries = [], me }) {
118
+ return blameChange({ before, after, entries }).filter((n) => n !== me)
119
+ }
120
+
121
+ /** The commit message, and who made it: an agent is named as the author, the token's account commits it. */
122
+ export function messageFor ({ message, by, task = null, coAuthors = [] }) {
123
+ const lines = [String(message || '').trim() || 'Changes from the Quilt session']
124
+ const trailer = [`Made in Quilt by ${by}${task ? ` for task ${task.id}: ${task.title}` : ''}.`]
125
+ for (const n of coAuthors) trailer.push(`Co-authored-by: ${n} <${noreply(n)}>`)
126
+ return `${lines.join('\n')}\n\n${trailer.join('\n')}`
127
+ }
128
+
129
+ export const noreply = (name) => `${slug(name, 40)}@agents.noreply.heyquilt.com`
130
+
131
+ class GitHubWriteError extends Error {
132
+ constructor (status, what, body) {
133
+ super(`GitHub answered ${status} to ${what}${body && body.message ? `: ${body.message}` : ''}`)
134
+ this.status = status
135
+ this.body = body
136
+ }
137
+ }
138
+
139
+ /** The GitHub calls a commit needs. The token goes in a header, never a URL. */
140
+ export function githubWriter ({ fetch = globalThis.fetch, token, owner, name }) {
141
+ const headers = { 'user-agent': 'quilt-relay', 'x-github-api-version': '2022-11-28', accept: 'application/vnd.github+json', authorization: `Bearer ${token}` }
142
+ const repo = `${API}/repos/${owner}/${name}`
143
+ const req = async (method, path, body, what) => {
144
+ const res = await fetch(`${repo}${path}`, { method, headers: { ...headers, ...(body ? { 'content-type': 'application/json' } : {}) }, ...(body ? { body: JSON.stringify(body) } : {}), redirect: 'follow' })
145
+ let json = null
146
+ try { json = await res.json() } catch {}
147
+ if (!res.ok) throw new GitHubWriteError(res.status, what, json)
148
+ return json
149
+ }
150
+ const enc = (ref) => ref.split('/').map(encodeURIComponent).join('/')
151
+ return {
152
+ /** The branch's head commit, or null when there is no such branch. */
153
+ async ref (branch) {
154
+ try { return (await req('GET', `/git/ref/heads/${enc(branch)}`, null, `branch ${branch}`)).object.sha } catch (err) { if (err.status === 404) return null; throw err }
155
+ },
156
+ /** A commit's tree sha and date. */
157
+ async commit (sha) {
158
+ const c = await req('GET', `/git/commits/${sha}`, null, `commit ${sha.slice(0, 7)}`)
159
+ return { tree: c.tree.sha, ts: Date.parse(c.committer && c.committer.date) || 0 }
160
+ },
161
+ /** Every file's blob sha in a tree: Map path -> sha (null when GitHub cut the listing short). */
162
+ async blobs (treeSha) {
163
+ const t = await req('GET', `/git/trees/${treeSha}?recursive=1`, null, 'the file list')
164
+ if (t.truncated) return null
165
+ return new Map((t.tree || []).filter((x) => x.type === 'blob').map((x) => [x.path, x.sha]))
166
+ },
167
+ async modes (treeSha) {
168
+ const t = await req('GET', `/git/trees/${treeSha}?recursive=1`, null, 'the file list')
169
+ return new Map((t.tree || []).filter((x) => x.type === 'blob').map((x) => [x.path, x.mode]))
170
+ },
171
+ /** A file's bytes at a commit, or null when it isn't there. */
172
+ async content (sha, rel) {
173
+ const res = await fetch(`${repo}/contents/${enc(rel)}?ref=${sha}`, { headers: { ...headers, accept: 'application/vnd.github.raw' }, redirect: 'follow' })
174
+ if (res.status === 404) return null
175
+ if (!res.ok) throw new GitHubWriteError(res.status, rel, null)
176
+ return Buffer.from(await res.arrayBuffer())
177
+ },
178
+ async blob (buf) { return (await req('POST', '/git/blobs', { content: buf.toString('base64'), encoding: 'base64' }, 'a file')).sha },
179
+ async tree (baseTree, entries) { return (await req('POST', '/git/trees', { base_tree: baseTree, tree: entries }, 'the new file list')).sha },
180
+ async newCommit ({ message, tree, parent, author }) {
181
+ return (await req('POST', '/git/commits', { message, tree, parents: [parent], ...(author ? { author } : {}) }, 'the commit')).sha
182
+ },
183
+ /** Moves the branch forward to `sha` (never forced: GitHub refuses a move that would lose commits). */
184
+ async move (branch, sha) { await req('PATCH', `/git/refs/heads/${enc(branch)}`, { sha, force: false }, `branch ${branch}`) },
185
+ async create (branch, sha) { await req('POST', '/git/refs', { ref: `refs/heads/${branch}`, sha }, `new branch ${branch}`) },
186
+ async pull ({ title, head, base, body }) {
187
+ try {
188
+ const p = await req('POST', '/pulls', { title, head, base, body }, 'the pull request')
189
+ return { number: p.number, url: p.html_url }
190
+ } catch (err) {
191
+ // One is already open for this branch: say which.
192
+ if (err.status === 422) {
193
+ const open = await req('GET', `/pulls?head=${encodeURIComponent(`${owner}:${head}`)}&state=open`, null, 'pull requests')
194
+ if (Array.isArray(open) && open[0]) return { number: open[0].number, url: open[0].html_url, existed: true }
195
+ }
196
+ throw err
197
+ }
198
+ },
199
+ /** The branch's newest commits: [{ sha, message, author, date, url }]. */
200
+ async log (branch, n = 10) {
201
+ const list = await req('GET', `/commits?sha=${encodeURIComponent(branch)}&per_page=${Math.max(1, Math.min(50, n | 0))}`, null, `commits on ${branch}`)
202
+ return (Array.isArray(list) ? list : []).map((c) => ({ sha: c.sha, message: (c.commit && c.commit.message) || '', author: (c.commit && c.commit.author && c.commit.author.name) || (c.author && c.author.login) || '', date: (c.commit && c.commit.author && c.commit.author.date) || '', url: c.html_url }))
203
+ },
204
+ /** Open pull requests, newest first: [{ number, title, head, base, url, draft, by, sha }]. */
205
+ async pulls () {
206
+ const list = await req('GET', '/pulls?state=open&per_page=50&sort=updated&direction=desc', null, 'pull requests')
207
+ return (Array.isArray(list) ? list : []).map((p) => ({ number: p.number, title: p.title, head: p.head && p.head.ref, base: p.base && p.base.ref, url: p.html_url, draft: !!p.draft, by: p.user && p.user.login, sha: p.head && p.head.sha }))
208
+ },
209
+ /** One pull request: { number, title, head, base, url, sha, state, merged, mergeable, mergeableState }. */
210
+ async pullRequest (number) {
211
+ const p = await req('GET', `/pulls/${Number(number)}`, null, `pull request #${number}`)
212
+ return { number: p.number, title: p.title, head: p.head && p.head.ref, base: p.base && p.base.ref, url: p.html_url, sha: p.head && p.head.sha, state: p.state, merged: !!p.merged, mergeable: p.mergeable, mergeableState: p.mergeable_state || '' }
213
+ },
214
+ /** How `head` stands to `base`: 'identical', 'ahead' (base is in head), 'behind' or 'diverged' (it isn't). */
215
+ async compare (base, head) {
216
+ const c = await req('GET', `/compare/${encodeURIComponent(base)}...${encodeURIComponent(head)}`, null, 'a comparison')
217
+ return (c && c.status) || 'diverged'
218
+ },
219
+ /** How a commit's checks stand: 'passing', 'failing', 'pending' or 'none', with the failing ones named. */
220
+ async checks (sha) {
221
+ const runs = await req('GET', `/commits/${sha}/check-runs?per_page=100`, null, 'checks')
222
+ const st = await req('GET', `/commits/${sha}/status`, null, 'statuses')
223
+ const items = (runs && Array.isArray(runs.check_runs) ? runs.check_runs : []).map((c) => ({ name: c.name, done: c.status === 'completed', ok: ['success', 'neutral', 'skipped'].includes(c.conclusion) }))
224
+ for (const s of (st && Array.isArray(st.statuses) ? st.statuses : [])) items.push({ name: s.context, done: s.state !== 'pending', ok: s.state === 'success' })
225
+ if (!items.length) return { state: 'none', failing: [] }
226
+ const failing = items.filter((c) => c.done && !c.ok).map((c) => c.name)
227
+ if (failing.length) return { state: 'failing', failing }
228
+ return { state: items.every((c) => c.done) ? 'passing' : 'pending', failing: [] }
229
+ },
230
+ /** Merges a pull request: { sha }. GitHub refuses when it can't be merged as it is (conflicts, rules). */
231
+ async merge (number, { method = 'squash', title = '' } = {}) {
232
+ const r = await req('PUT', `/pulls/${Number(number)}/merge`, { merge_method: method, ...(title ? { commit_title: title } : {}) }, `merging #${number}`)
233
+ return { sha: r.sha, merged: !!r.merged }
234
+ },
235
+ async deleteBranch (branch) {
236
+ try { await req('DELETE', `/git/refs/heads/${enc(branch)}`, null, `deleting ${branch}`) } catch (err) { if (err.status !== 404 && err.status !== 422) throw err }
237
+ }
238
+ }
239
+ }
240
+
241
+ /** Why GitHub refused, in words for whoever asked (never the token). */
242
+ export function writeProblem (err, repo) {
243
+ const s = err && err.status
244
+ if (s === 401) return `the session's GitHub token was refused by GitHub (expired or revoked): ask the session owner for a new one (quilt_github_token)`
245
+ if (s === 403 || s === 404) return `the session's GitHub token can't write to ${repo}: the owner needs a token with "Contents: Read and write" on it (and "Pull requests: Read and write" to open pull requests), set with quilt_github_token`
246
+ if (s === 409) return `${repo} is empty or being changed: try again`
247
+ if (s === 422) return `GitHub refused: ${err.body && err.body.message ? err.body.message : 'the branch moved meanwhile'}`
248
+ return err && err.message ? err.message : String(err)
249
+ }
250
+
251
+ /**
252
+ * Makes the commit on GitHub: `files` (Map path -> Buffer|null) onto `branch`, which starts at
253
+ * `from` when it doesn't exist yet (the session's commit), checked against `base` (the commit
254
+ * the session's copy is measured from; null for a branch of the asker's own, which only they write to). Resolves { sha, branch, created, changed, same } or
255
+ * throws CommitRefused (nothing to commit, files changed on GitHub) or a GitHub error.
256
+ */
257
+ export async function commitOnGitHub (gh, { files, branch, from, base, message, author, tries = 2 }) {
258
+ for (let attempt = 1; ; attempt++) {
259
+ const head = await gh.ref(branch)
260
+ const parent = head || from
261
+ const pc = await gh.commit(parent)
262
+ const parentBlobs = await gh.blobs(pc.tree)
263
+ if (!parentBlobs) throw new CommitRefused('The repository is too large for GitHub to list in one go; commit from a folder with git.')
264
+ const baseBlobs = !base ? null : base === parent ? parentBlobs : await gh.blobs((await gh.commit(base)).tree)
265
+ if (base && !baseBlobs) throw new CommitRefused('The repository is too large for GitHub to list in one go; commit from a folder with git.')
266
+ const plan = planCommit(files, { parent: parentBlobs, base: baseBlobs })
267
+ if (plan.moved.length) {
268
+ throw new CommitRefused(`${plan.moved.join(', ')} changed on ${head ? branch : 'GitHub'} since the session's copy was taken, so committing the session's copy would undo that. ` +
269
+ 'Bring those commits into the session first (quilt_sync_branch; the session then has both), then commit again.')
270
+ }
271
+ if (!plan.changes.length) return { sha: parent, branch, created: false, changed: [], same: plan.same, nothing: true, exists: !!head }
272
+ const modes = await gh.modes(pc.tree)
273
+ const entries = []
274
+ for (const c of plan.changes) {
275
+ if (c.deleted) entries.push({ path: c.path, mode: modes.get(c.path) || '100644', type: 'blob', sha: null })
276
+ else entries.push({ path: c.path, mode: modes.get(c.path) || '100644', type: 'blob', sha: await gh.blob(c.bytes) })
277
+ }
278
+ const tree = await gh.tree(pc.tree, entries)
279
+ const sha = await gh.newCommit({ message, tree, parent, author })
280
+ try {
281
+ if (head) await gh.move(branch, sha)
282
+ else await gh.create(branch, sha)
283
+ return { sha, parent, branch, created: !head, changed: plan.changes.map((c) => c.path), same: plan.same }
284
+ } catch (err) {
285
+ // Someone pushed to the branch meanwhile: start again from its new head.
286
+ if ((err.status === 422 || err.status === 409) && attempt < tries) continue
287
+ throw err
288
+ }
289
+ }
290
+ }
291
+
292
+ /** quilt_commit, the same for every agent (local MCP, hosted MCP, CLI). */
293
+ export const GIT_COMMIT_DESCRIPTION = 'Commit your work to the branch you are on, on GitHub: the session\'s copy of exactly these files (by default every change of yours on record on this branch), with your message. ' +
294
+ 'On a branch of your own (quilt/<you>/…, where taking a task puts you) the pull request to the session\'s branch opens by itself and is tied to your task. ' +
295
+ 'The session\'s branch itself is open to agents only when the owner lets agents push to any branch; otherwise take a task or quilt_git_branch create first. ' +
296
+ 'Refused for a file that changed on GitHub since the session\'s copy (quilt_sync_branch first) and for one holding others\' uncommitted changes (unless with_others). ' +
297
+ 'Commit as soon as work is finished and tested: work that is only in the session has not shipped. When the owner keeps agents from committing, ask a person with quilt_request_commit.'
298
+ export const gitCommitSchema = (z) => ({
299
+ message: z.string().min(1).max(2000).describe('What the change does (the first line is the subject)'),
300
+ files: z.array(z.string().min(1).max(500)).max(200).optional().describe('The files to commit, deleted ones too; default: every change of yours on record on this branch'),
301
+ task: z.string().max(40).optional().describe('The task this is for: its title names the pull request, and the task follows it'),
302
+ with_others: z.boolean().optional().describe('Also commit files that hold other people\'s uncommitted changes (they are named as co-authors)')
303
+ })
304
+
305
+ export const COMMIT_DESCRIPTION = 'For agents with no folder (hosted, over HTTP): commit your finished work to the repository on GitHub through the relay, since you have no git of your own; nobody else needs to be online. Agents with a folder push with git instead. ' +
306
+ 'Quilt commits the session\'s copy of exactly these files (by default every change of yours on record on this branch) with your message, and everyone sees it in chat. ' +
307
+ 'Where it goes is the session owner\'s call: by default a branch of your own (quilt/<you>/<topic>; pull_request: true opens a pull request to the session\'s branch); ' +
308
+ 'when the owner lets agents commit to any branch, leaving branch out commits to the session\'s branch itself. ' +
309
+ 'Refused for a file that changed on GitHub since the session\'s copy (bring it in with quilt_sync_branch first) and for one holding others\' uncommitted changes (unless with_others). ' +
310
+ 'Commit as soon as work is finished and tested: work that is only in the session has not shipped. When the owner keeps agents from committing, ask a person with quilt_request_commit.'
311
+
312
+ /** quilt_commit's arguments, the same for the local and the hosted tool (`z`: zod). */
313
+ export const commitSchema = (z) => ({
314
+ message: z.string().max(2000).optional().describe('The commit message: what the change does (a task\'s title by default)'),
315
+ files: z.array(z.string().max(1024)).max(300).optional().describe('The files to commit, relative to the project\'s top folder (deleted ones too); default: every change of yours on record on this branch'),
316
+ branch: z.string().max(200).optional().describe('The GitHub branch to commit to; default: a branch of your own (quilt/<you>/<topic>), or the session\'s branch when the owner lets agents commit there'),
317
+ pull_request: z.boolean().optional().describe('Open a pull request from your branch to the session\'s branch'),
318
+ with_others: z.boolean().optional().describe('Also commit files that hold others\' uncommitted changes (they are named as co-authors)'),
319
+ task: z.string().max(40).optional().describe('The task this work was for: its title is the message and its changes the files, unless you give them')
320
+ })
@@ -0,0 +1,49 @@
1
+ // Quilt keeps a session's local state in .quilt/. In a git folder that is an
2
+ // untracked folder, so `git stash -u` or `git clean -fd` would take it away. The
3
+ // project's own .gitignore is where git is told to leave it alone: a line is
4
+ // added there (and shared, like any edit of .gitignore). Nothing under .git is
5
+ // ever touched.
6
+ import fs from 'node:fs'
7
+ import path from 'node:path'
8
+ import { gitDir } from './gitstate.js'
9
+
10
+ const IGNORES_QUILT = new Set(['.quilt', '.quilt/', '/.quilt', '/.quilt/'])
11
+ export const QUILT_IGNORE_COMMENT = "# Quilt keeps this session's local state here"
12
+
13
+ /**
14
+ * Whether .gitignore's text already has a line ignoring .quilt. Trailing
15
+ * spaces don't count (git drops them); leading ones are part of the pattern.
16
+ */
17
+ export function ignoresQuilt (text) {
18
+ return String(text).split(/\r?\n/).some((line) => IGNORES_QUILT.has(line.replace(/\s+$/, '')))
19
+ }
20
+
21
+ /**
22
+ * In a git folder, makes sure .gitignore ignores .quilt/: appends a comment
23
+ * and `.quilt/` when no line does (creating the file if need be, in its own
24
+ * line endings). Returns { added: true }, { added: false } (nothing to do:
25
+ * not a git folder, or already ignored), or { added: false, error } when
26
+ * .gitignore couldn't be read or written.
27
+ */
28
+ export function ensureQuiltIgnored (root) {
29
+ if (!gitDir(root)) return { added: false }
30
+ const file = path.join(root, '.gitignore')
31
+ let text = ''
32
+ try {
33
+ const st = fs.lstatSync(file)
34
+ if (!st.isFile()) return { added: false, error: new Error('.gitignore is not a plain file') }
35
+ text = fs.readFileSync(file, 'utf8')
36
+ } catch (err) {
37
+ if (err.code !== 'ENOENT') return { added: false, error: err }
38
+ }
39
+ if (ignoresQuilt(text)) return { added: false }
40
+ const eol = text.includes('\r\n') ? '\r\n' : '\n'
41
+ const lead = text && !text.endsWith('\n') ? eol : ''
42
+ try {
43
+ // Appended, so the file's own bytes stay as they are; O_NOFOLLOW: never through a link put there meanwhile.
44
+ fs.writeFileSync(file, `${lead}${QUILT_IGNORE_COMMENT}${eol}.quilt/${eol}`, { flag: fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_APPEND | (fs.constants.O_NOFOLLOW || 0) })
45
+ } catch (err) {
46
+ return { added: false, error: err }
47
+ }
48
+ return { added: true }
49
+ }