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.
Files changed (221) hide show
  1. package/LICENSE +85 -0
  2. package/README.md +633 -2
  3. package/RELEASES.md +862 -0
  4. package/assets/logo.svg +1 -0
  5. package/bin/quilt.js +849 -0
  6. package/desktop/main.js +315 -0
  7. package/package.json +163 -7
  8. package/src/account.js +591 -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 +187 -0
  14. package/src/agents/common.js +21 -0
  15. package/src/agents/cursor-transcripts.js +177 -0
  16. package/src/agents/cursor.js +404 -0
  17. package/src/agents/index.js +69 -0
  18. package/src/ai-vm-signin.js +110 -0
  19. package/src/ai-vm.js +522 -0
  20. package/src/api/access.js +75 -0
  21. package/src/api/activity.js +181 -0
  22. package/src/api/agent-auth.js +136 -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 +133 -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 +1219 -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 +545 -0
  40. package/src/api/relay-machines.js +92 -0
  41. package/src/api/releases.js +87 -0
  42. package/src/api/routes/access-types.js +54 -0
  43. package/src/api/routes/agent-invites.js +115 -0
  44. package/src/api/routes/agents.js +252 -0
  45. package/src/api/routes/ai-vm.js +495 -0
  46. package/src/api/routes/github.js +380 -0
  47. package/src/api/routes/grants.js +70 -0
  48. package/src/api/routes/invites.js +188 -0
  49. package/src/api/routes/issues.js +47 -0
  50. package/src/api/routes/join.js +167 -0
  51. package/src/api/routes/members.js +110 -0
  52. package/src/api/routes/orgs.js +179 -0
  53. package/src/api/routes/relay-cluster.js +90 -0
  54. package/src/api/routes/relay.js +76 -0
  55. package/src/api/routes/secrets.js +448 -0
  56. package/src/api/routes/session-invites.js +119 -0
  57. package/src/api/routes/sessions.js +92 -0
  58. package/src/api/routes/teams.js +132 -0
  59. package/src/api/routes/workspace-agents.js +332 -0
  60. package/src/api/routes/workspace-files.js +277 -0
  61. package/src/api/routes/workspace-invites.js +196 -0
  62. package/src/api/routes/workspaces.js +232 -0
  63. package/src/api/sandbox-keys.js +57 -0
  64. package/src/api/secrets-access.js +93 -0
  65. package/src/api/server.js +654 -0
  66. package/src/api/slugs.js +20 -0
  67. package/src/api/supabase-store.js +875 -0
  68. package/src/api/team-access.js +30 -0
  69. package/src/api/tokens.js +31 -0
  70. package/src/api/workspace-access.js +62 -0
  71. package/src/api/workspace-reach.js +60 -0
  72. package/src/autowork-tools.js +166 -0
  73. package/src/autowork.js +375 -0
  74. package/src/blobstore.js +99 -0
  75. package/src/boxes.js +37 -0
  76. package/src/branchdocs.js +235 -0
  77. package/src/branches.js +194 -0
  78. package/src/catchup.js +111 -0
  79. package/src/chat-archive.js +85 -0
  80. package/src/chat-links.js +923 -0
  81. package/src/clash.js +128 -0
  82. package/src/cloud-ai.js +50 -0
  83. package/src/commit-request.js +59 -0
  84. package/src/commit.js +213 -0
  85. package/src/connection.js +569 -0
  86. package/src/control.js +248 -0
  87. package/src/conversation.js +125 -0
  88. package/src/doctor.js +156 -0
  89. package/src/duties.js +303 -0
  90. package/src/editors.js +228 -0
  91. package/src/fsutil.js +97 -0
  92. package/src/git-hook.js +48 -0
  93. package/src/git.js +147 -0
  94. package/src/github-commit.js +320 -0
  95. package/src/gitignore.js +49 -0
  96. package/src/gitstate.js +924 -0
  97. package/src/history.js +307 -0
  98. package/src/hooks.js +371 -0
  99. package/src/hosted-agents.js +24 -0
  100. package/src/hosted-client.js +112 -0
  101. package/src/identity.js +99 -0
  102. package/src/inbox.js +237 -0
  103. package/src/integrations.js +221 -0
  104. package/src/key-cli.js +115 -0
  105. package/src/largefiles.js +91 -0
  106. package/src/legacy.js +25 -0
  107. package/src/mcp-proxy.js +45 -0
  108. package/src/mcp-room.js +32 -0
  109. package/src/mcp.js +1258 -0
  110. package/src/merge3.js +240 -0
  111. package/src/merges.js +148 -0
  112. package/src/pass-source.js +183 -0
  113. package/src/passes.js +64 -0
  114. package/src/pathrules.js +134 -0
  115. package/src/persona.js +101 -0
  116. package/src/presence.js +360 -0
  117. package/src/private-file.js +41 -0
  118. package/src/procs.js +70 -0
  119. package/src/project-rules.js +178 -0
  120. package/src/protocol.js +92 -0
  121. package/src/quiet-warnings.js +9 -0
  122. package/src/relay-ai-vm.js +82 -0
  123. package/src/relay-ask.js +141 -0
  124. package/src/relay-ci.js +111 -0
  125. package/src/relay-clash.js +214 -0
  126. package/src/relay-cluster.js +454 -0
  127. package/src/relay-commit.js +887 -0
  128. package/src/relay-filekey.js +46 -0
  129. package/src/relay-git.js +274 -0
  130. package/src/relay-githttp.js +166 -0
  131. package/src/relay-github.js +109 -0
  132. package/src/relay-lease.js +137 -0
  133. package/src/relay-lfs.js +37 -0
  134. package/src/relay-mcp.js +1691 -0
  135. package/src/relay-metrics.js +140 -0
  136. package/src/relay-outbound.js +1273 -0
  137. package/src/relay-sync.js +716 -0
  138. package/src/relay-turn-sync.js +506 -0
  139. package/src/relay-upstream.js +804 -0
  140. package/src/relay-webhooks.js +111 -0
  141. package/src/releases.js +138 -0
  142. package/src/report.js +129 -0
  143. package/src/runner.js +307 -0
  144. package/src/same-edit.js +18 -0
  145. package/src/secret-import.js +55 -0
  146. package/src/secret-names.js +26 -0
  147. package/src/secret-run.js +80 -0
  148. package/src/secrets-client.js +56 -0
  149. package/src/server.js +4214 -0
  150. package/src/session-access.js +170 -0
  151. package/src/session-name.js +15 -0
  152. package/src/session.js +7446 -0
  153. package/src/settings.js +46 -0
  154. package/src/setup.js +184 -0
  155. package/src/shell-path.js +46 -0
  156. package/src/status.js +197 -0
  157. package/src/summarize.js +109 -0
  158. package/src/sync-words.js +42 -0
  159. package/src/task-comments.js +92 -0
  160. package/src/tasks.js +670 -0
  161. package/src/textdiff.js +20 -0
  162. package/src/turn-sync-plan.js +145 -0
  163. package/src/turn-sync.js +212 -0
  164. package/src/ui/access-form.js +37 -0
  165. package/src/ui/agent-env-key.js +76 -0
  166. package/src/ui/agent-guide.js +127 -0
  167. package/src/ui/agent-kinds.js +175 -0
  168. package/src/ui/agent-place.js +265 -0
  169. package/src/ui/app.css +2494 -0
  170. package/src/ui/app.js +546 -0
  171. package/src/ui/board.js +465 -0
  172. package/src/ui/boxes.js +76 -0
  173. package/src/ui/branches.js +111 -0
  174. package/src/ui/catchup.js +77 -0
  175. package/src/ui/changes.js +159 -0
  176. package/src/ui/chat.js +266 -0
  177. package/src/ui/cloud-ai-model.js +108 -0
  178. package/src/ui/cloud-ai.js +221 -0
  179. package/src/ui/common.js +433 -0
  180. package/src/ui/csv.js +21 -0
  181. package/src/ui/diffview.js +74 -0
  182. package/src/ui/feed-convs.js +39 -0
  183. package/src/ui/feed.js +174 -0
  184. package/src/ui/files.js +579 -0
  185. package/src/ui/fileview.js +150 -0
  186. package/src/ui/git-rule.js +60 -0
  187. package/src/ui/home.js +1322 -0
  188. package/src/ui/index.html +16 -0
  189. package/src/ui/invite.js +97 -0
  190. package/src/ui/invites.js +79 -0
  191. package/src/ui/loom-model.js +516 -0
  192. package/src/ui/loom.css +365 -0
  193. package/src/ui/loom.js +786 -0
  194. package/src/ui/mark.js +104 -0
  195. package/src/ui/rail-model.js +75 -0
  196. package/src/ui/rail.js +173 -0
  197. package/src/ui/releases.js +182 -0
  198. package/src/ui/sandbox-keys-model.js +53 -0
  199. package/src/ui/sandbox-keys.js +132 -0
  200. package/src/ui/schedule.js +156 -0
  201. package/src/ui/secret-rules.js +255 -0
  202. package/src/ui/secrets-view.js +162 -0
  203. package/src/ui/secrets.js +283 -0
  204. package/src/ui/session.js +3292 -0
  205. package/src/ui/signin.js +75 -0
  206. package/src/ui/sounds.js +110 -0
  207. package/src/ui/tool-logo.js +86 -0
  208. package/src/ui/tree.js +281 -0
  209. package/src/ui/workspaces.js +547 -0
  210. package/src/ui-server.js +1381 -0
  211. package/src/update-check.js +67 -0
  212. package/src/upstream.js +192 -0
  213. package/src/vault/crypto.js +96 -0
  214. package/src/vault/kms.js +61 -0
  215. package/src/vault/main.js +33 -0
  216. package/src/vault/server.js +279 -0
  217. package/src/vault/sigv4.js +38 -0
  218. package/src/webhooks.js +235 -0
  219. package/src/workspace-cli.js +62 -0
  220. package/src/workspace-tools.js +340 -0
  221. package/index.js +0 -2
package/src/clash.js ADDED
@@ -0,0 +1,128 @@
1
+ // Commits from outside that a folder can't bring in (git couldn't read a file
2
+ // they change: too large, or behind a filter such as Git LFS) are handed to ONE
3
+ // member as a task on the board, instead of every folder behind on that branch
4
+ // telling its AI. Files both sides changed never come here: Quilt combines them
5
+ // itself (upstream.js combine). Each folder that reports the clash works out the same owner from
6
+ // presence; only the owner's Quilt writes the task, under an id derived from
7
+ // the branch and the upstream commit, so two that race write the same task.
8
+ // Pure: Session (reviewClash) reads presence and the board and applies these.
9
+ import crypto from 'node:crypto'
10
+ import { MAX_FILES, MAX_TITLE } from './tasks.js'
11
+
12
+ const HEX_ID = /^[0-9a-f]{16}$/
13
+ const SHA = /^[0-9a-f]{7,64}$/
14
+
15
+ /** The task id for a clash on `branch` with upstream commit `sha`: the same in every member's Quilt. */
16
+ export function clashTaskId (branch, sha) {
17
+ return crypto.createHash('sha256').update(`quilt-clash\n${branch}\n${sha}`).digest('hex').slice(0, 16)
18
+ }
19
+
20
+ /** A clash record from the room's "clashes" map (branch -> record), or null for anything else. */
21
+ export function readClash (value, branch) {
22
+ if (!value || typeof value !== 'object') return null
23
+ if (typeof value.task !== 'string' || !HEX_ID.test(value.task)) return null
24
+ if (typeof value.sha !== 'string' || !SHA.test(value.sha)) return null
25
+ if (typeof value.upstream !== 'string' || !value.upstream || value.upstream.length > 200) return null
26
+ const ts = Number.isFinite(value.ts) ? value.ts : 0
27
+ return { branch, task: value.task, sha: value.sha, upstream: value.upstream, ts }
28
+ }
29
+
30
+ /** Whether a member's presence shows a live AI session working through its app (persona.js). */
31
+ export function hasAi (st) {
32
+ return !!st && Array.isArray(st.personas) && st.personas.some((p) => p && typeof p.name === 'string' && p.name)
33
+ }
34
+
35
+ /**
36
+ * The members who may take this clash: their folder is on `branch` with `upstream` as its
37
+ * upstream and reports clashing files (not a diverged branch: that history is the folder's own),
38
+ * their Quilt hands clashes out (git.clash 1), and they may change the clashing files.
39
+ * `states`: [[clientID, presence state]], this member's own included.
40
+ * Each: { id, name, kind, ai, rank } (rank 0 an agent member, 1 a person with an AI session, 2 a person).
41
+ */
42
+ export function clashCandidates (states, { branch, upstream }) {
43
+ const out = []
44
+ for (const [id, st] of states) {
45
+ if (!st || typeof st.name !== 'string' || !st.name || !st.git || typeof st.git !== 'object') continue
46
+ const g = st.git
47
+ const u = g.upstream
48
+ if (g.clash !== 1 || g.branch !== branch || !u || u.name !== upstream) continue
49
+ // A folder whose fetch fails can't pull, so its AI couldn't merge.
50
+ if (!(u.conflicts > 0) || u.diverged || u.mayWrite !== true || u.fetchOk === false) continue
51
+ const agent = st.kind === 'agent'
52
+ const ai = !agent && hasAi(st)
53
+ out.push({ id, name: st.name, kind: agent ? 'agent' : 'human', ai, rank: agent ? 0 : ai ? 1 : 2 })
54
+ }
55
+ return out
56
+ }
57
+
58
+ /** The candidates in the order they take the clash: an agent member first, then a person with an AI, then anyone; the lowest client id among equals. */
59
+ export function clashOrder (candidates) {
60
+ return [...candidates].sort((a, b) => a.rank - b.rank || a.id - b.id)
61
+ }
62
+
63
+ /** Who takes the clash (the first in clashOrder not in `except`), or null. */
64
+ export function pickClashOwner (candidates, { except = [] } = {}) {
65
+ return clashOrder(candidates.filter((c) => !except.includes(c.name)))[0] || null
66
+ }
67
+
68
+ /**
69
+ * The presence state of whoever `assignee` is (a member, or one of a member's AI sessions),
70
+ * counted only when that member's folder is on `branch`; null when they are not here.
71
+ */
72
+ export function holderOf (states, assignee, branch) {
73
+ for (const [id, st] of states) {
74
+ if (!st || !st.git || st.git.branch !== branch) continue
75
+ if (st.name === assignee || (Array.isArray(st.personas) && st.personas.some((p) => p && p.name === assignee))) return { id, st, rank: st.kind === 'agent' ? 0 : hasAi(st) ? 1 : 2 }
76
+ }
77
+ return null
78
+ }
79
+
80
+ /** How a task's assignee is named in a notice and on the branch list: an agent by name, a person as "<name>'s AI". */
81
+ export function ownerLabel ({ assignee, forAi }) {
82
+ return forAi ? `${assignee}'s AI` : assignee
83
+ }
84
+
85
+ const n = (k, w) => `${k} ${w}${k === 1 ? '' : 's'}`
86
+
87
+ /** The task's title: "Bring 4 commits from origin/main into the session: src/a.js, src/b.js clash". */
88
+ export function clashTitle ({ upstream, behind, conflicts = [] }) {
89
+ const head = `Bring ${n(behind, 'commit')} from ${upstream} into the session: `
90
+ const paths = conflicts.map((c) => c.path)
91
+ const tail = paths.length === 1 ? ' clashes' : ' clash'
92
+ const text = (shown) => {
93
+ const rest = paths.length - shown.length
94
+ return head + shown.join(', ') + (rest ? ` and ${rest} more` : '') + tail
95
+ }
96
+ let shown = []
97
+ for (const p of paths) {
98
+ const next = [...shown, p]
99
+ if (shown.length && text(next).length > MAX_TITLE) break
100
+ shown = next
101
+ }
102
+ return text(shown).slice(0, MAX_TITLE)
103
+ }
104
+
105
+ /** The files the task lists (the board keeps at most MAX_FILES). */
106
+ export function clashFiles (conflicts = []) {
107
+ return [...new Set(conflicts.map((c) => c.path))].slice(0, MAX_FILES)
108
+ }
109
+
110
+ /** The task's description, as its first comment: each file and why. Nobody is asked to pull or merge. */
111
+ export function clashBrief ({ branch, upstream, sha, behind, conflicts = [] }) {
112
+ const lines = [`${n(behind, 'commit')} on ${upstream} (up to ${sha.slice(0, 7)}) change files Quilt can't read here, so it brought nothing in yet:`]
113
+ for (const c of conflicts.slice(0, 30)) lines.push(`- ${c.path}: ${c.why}`)
114
+ if (conflicts.length > 30) lines.push(`- and ${conflicts.length - 30} more`)
115
+ lines.push('', `Files both sides changed are combined by Quilt on its own; nothing here needs merging by hand. Once a folder on \`${branch}\` that can read these files is at ${sha.slice(0, 7)} or later, every other folder follows it and Quilt closes this task by itself: no need to move it.`)
116
+ return lines.join('\n')
117
+ }
118
+
119
+ /** The notice for a member whose folder is behind but who is not merging: leave it to the owner. */
120
+ export function leaveItNotice ({ label, upstream, task, facts }) {
121
+ return `${label} is bringing in the commits from ${upstream} (task ${task}); leave those files to them.${facts ? ` ${facts}` : ''}`
122
+ }
123
+
124
+ /** What a folder that reports the clash knows about it, without the advice to pull. */
125
+ export function clashFacts ({ branch, upstream, behind, conflicts = [] }) {
126
+ const list = conflicts.slice(0, 4).map((c) => `${c.path} (${c.why})`).join('; ') + (conflicts.length > 4 ? `; and ${conflicts.length - 4} more` : '')
127
+ return `${branch} is ${n(behind, 'commit')} behind ${upstream}, and Quilt can't read ${list} to bring them in.`
128
+ }
@@ -0,0 +1,50 @@
1
+ // A person's AI on its own cloud machine (docs/superpowers/specs/2026-10-17-ai-vm.md) is one of
2
+ // that person's AI sessions: its presence says whose (`aiOf`), and it answers for
3
+ // "<person>'s AI" only while none of the person's AI sessions on a computer is live. Pure, so
4
+ // the relay (which wakes a suspended one) and every app (which shows it and reads its inbox)
5
+ // decide the same way.
6
+ import { aiName } from './ui/chat.js'
7
+
8
+ /** The label a cloud AI's name carries: "Daniel · cloud". */
9
+ export const CLOUD_LABEL = 'cloud'
10
+
11
+ /**
12
+ * Whether one of `person`'s AI sessions on a computer is live: a presence of theirs (not a
13
+ * cloud AI's) publishing an AI session that isn't a "work on its own" run.
14
+ */
15
+ export function localAiLive (states, person) {
16
+ for (const s of states || []) {
17
+ if (!s || s.name !== person || s.aiOf || !Array.isArray(s.personas)) continue
18
+ if (s.personas.some((p) => p && typeof p.name === 'string' && !p.auto)) return true
19
+ }
20
+ return false
21
+ }
22
+
23
+ /** The names of `person`'s AI sessions on computers: what they say never wakes the cloud AI. */
24
+ export function localAiNames (states, person) {
25
+ const out = []
26
+ for (const s of states || []) {
27
+ if (!s || s.name !== person || s.aiOf || !Array.isArray(s.personas)) continue
28
+ for (const p of s.personas) if (p && typeof p.name === 'string') out.push(p.name)
29
+ }
30
+ return out
31
+ }
32
+
33
+ /**
34
+ * How a cloud AI named `name`, of `person`, reads the inbox (inbox.js): its own name always;
35
+ * "<person>'s AI" and tasks for it only while no AI session of theirs on a computer is live.
36
+ */
37
+ export function cloudReader ({ name, person, states = [], aliases = [] }) {
38
+ const local = localAiLive(states, person)
39
+ return {
40
+ name,
41
+ aliases: local ? [...aliases] : [...aliases, aiName(person)],
42
+ agent: true,
43
+ asAi: false,
44
+ ...(local ? {} : { of: person }),
45
+ own: localAiNames(states, person)
46
+ }
47
+ }
48
+
49
+ /** Whether a presence is a cloud AI's (its relay checked `aiOf` against its pass). */
50
+ export const isCloudAi = (state) => !!(state && typeof state.aiOf === 'string' && state.aiOf)
@@ -0,0 +1,59 @@
1
+ // Commit requests (quilt_request_commit): an AI or a person asks for a commit, says what it
2
+ // is for and which files changed, and the session owner (whose machine usually commits) is
3
+ // told with a direct message. Shared by the folder's Session and the relay's hosted MCP so
4
+ // both build the same request and the same message.
5
+ import { isSafeRelPath } from './pathrules.js'
6
+
7
+ export const MAX_COMMIT_MESSAGE = 500
8
+ export const MAX_COMMIT_DESCRIPTION = 2000
9
+ export const MAX_COMMIT_FILES = 100
10
+
11
+ /** The changed files an agent listed: clean relative paths, no duplicates, at most MAX_COMMIT_FILES. */
12
+ export function cleanCommitFiles (files) {
13
+ if (files == null) return []
14
+ if (!Array.isArray(files)) throw new Error('files must be a list of paths')
15
+ const out = []
16
+ for (const f of files) {
17
+ const rel = String(f == null ? '' : f).trim().replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '')
18
+ if (!rel) continue
19
+ if (!isSafeRelPath(rel)) throw new Error(`not a path in the project: ${String(f).slice(0, 200)}`)
20
+ if (!out.includes(rel)) out.push(rel)
21
+ }
22
+ if (out.length > MAX_COMMIT_FILES) throw new Error(`list at most ${MAX_COMMIT_FILES} files`)
23
+ return out
24
+ }
25
+
26
+ /** The owner's name from a member list ({ name, role }), or ''. */
27
+ export function ownerName (members) {
28
+ const o = (members || []).find((m) => m && m.role === 'owner' && m.name)
29
+ return o ? String(o.name) : ''
30
+ }
31
+
32
+ /** A new open request. Throws when there is nothing to say. */
33
+ export function makeCommitRequest ({ id, by, message, description = '', files = [], branch = '', ts = Date.now() }) {
34
+ const msg = String(message || '').trim().slice(0, MAX_COMMIT_MESSAGE)
35
+ if (!msg) throw new Error('say what the commit is for')
36
+ const desc = String(description || '').trim().slice(0, MAX_COMMIT_DESCRIPTION)
37
+ const list = cleanCommitFiles(files)
38
+ return { id, by, message: msg, ...(desc ? { description: desc } : {}), ...(list.length ? { files: list } : {}), ...(branch ? { branch } : {}), ts, state: 'open' }
39
+ }
40
+
41
+ /** What the owner is told: who wants a commit, what for, which files, and how to settle it. */
42
+ export function commitRequestText (r, { defaultBranch = 'main' } = {}) {
43
+ const branch = r.branch && r.branch !== defaultBranch ? ` on \`${r.branch}\`` : ''
44
+ const files = r.files && r.files.length
45
+ ? ` Files (${r.files.length}): ${r.files.slice(0, 20).join(', ')}${r.files.length > 20 ? `, and ${r.files.length - 20} more` : ''}.`
46
+ : ''
47
+ const desc = r.description ? ` ${r.description.replace(/\s+/g, ' ')}` : ''
48
+ return `📌 Commit requested${branch} (${r.id}): ${r.message.replace(/[.!?]+$/, '')}.${desc}${files} ` +
49
+ 'Commit with git when it is a good moment (quilt_commit_status), then mark it done with quilt_commit_request_done.'
50
+ }
51
+
52
+ /** The chat message that tells the owner, or null when there is no owner to tell or the owner asked. */
53
+ export function commitRequestMessage (r, { owner, id, defaultBranch, of = null }) {
54
+ if (!owner || owner === r.by) return null
55
+ return { id, by: r.by, to: owner, text: commitRequestText(r, { defaultBranch }), ts: r.ts, kind: 'commit', commit: r.id, ...(of ? { of } : {}) }
56
+ }
57
+
58
+ /** A message that tells the owner about a commit request: settled by committing, not by a reply. */
59
+ export const commitRequestNote = (m) => !!m && m.kind === 'commit'
package/src/commit.js ADDED
@@ -0,0 +1,213 @@
1
+ // Commit requests, committed in a person's folder with git: exactly the files a request names
2
+ // (those that differ from HEAD), with its message, then pushed to the branch's upstream. This is
3
+ // the way in when the relay can't commit for the asker (relay-commit.js: no GitHub token, a
4
+ // repository not on GitHub, or the owner keeps agents from committing): a person commits it from
5
+ // the app's commit panel (session.js: commitRequest).
6
+ //
7
+ // Committing touches no file: `git commit --only -- <paths>` moves the branch and the index
8
+ // entries of those paths, and leaves everything else (other people's uncommitted work, files
9
+ // someone staged by hand) as it was. Quilt never merges history: a push git refuses (the
10
+ // remote moved on) is reported, not forced.
11
+ //
12
+ // The pure parts (who changed which uncommitted file, what a person should know before
13
+ // committing a request, the commit message) are here too, so the app, the daemon and tests agree.
14
+ import { execFile } from 'node:child_process'
15
+ import { isSafeRelPath, globMatcher } from './pathrules.js'
16
+
17
+ export const COMMIT_TIMEOUT_MS = 60 * 1000 // a pre-commit hook may run tests
18
+ export const PUSH_TIMEOUT_MS = 90 * 1000
19
+ export const MAX_REQUEST_FILES = 500
20
+ export const MAX_MESSAGE = 500
21
+ // How long a request is left alone after it is made: the asker's AI may still be saving its files.
22
+ export const REQUEST_SETTLE_MS = 60 * 1000
23
+
24
+ const NO_PROMPT = { GIT_TERMINAL_PROMPT: '0', GCM_INTERACTIVE: 'never', GIT_OPTIONAL_LOCKS: '0' }
25
+
26
+ /** Runs git: { ok, out, err }. Never rejects. */
27
+ function git (root, args, { timeout = 15000, env = {} } = {}) {
28
+ return new Promise((resolve) => {
29
+ let child
30
+ try {
31
+ child = execFile(process.env.QUILT_GIT || 'git', args, { cwd: root, timeout, maxBuffer: 64 * 1024 * 1024, env: { ...process.env, ...NO_PROMPT, ...env } },
32
+ (err, stdout, stderr) => resolve({ ok: !err, out: String(stdout || ''), err: String(stderr || (err && err.message) || '') }))
33
+ } catch (err) { return resolve({ ok: false, out: '', err: err.message }) }
34
+ child.stdin.end()
35
+ })
36
+ }
37
+
38
+ /** The paths a request may name: relative, inside the folder, no duplicates. */
39
+ export function cleanFiles (files) {
40
+ const out = []
41
+ for (const f of Array.isArray(files) ? files : []) {
42
+ const p = String(f || '').trim().replace(/\\/g, '/').replace(/^\.\//, '')
43
+ if (!isSafeRelPath(p) || out.includes(p)) continue
44
+ out.push(p)
45
+ if (out.length >= MAX_REQUEST_FILES) break
46
+ }
47
+ return out
48
+ }
49
+
50
+ /**
51
+ * Which of `files` differ from HEAD (changed, new or deleted, not ignored): Map path -> 'M' | 'A' | 'D'.
52
+ * Null when git can't say.
53
+ */
54
+ export async function uncommitted (root, files = null) {
55
+ const args = ['-c', 'core.quotePath=false', 'status', '--porcelain=v1', '-z', '--no-renames', '--untracked-files=all']
56
+ if (files) {
57
+ if (!files.length) return new Map()
58
+ args.push('--', ...files.map((f) => `:(literal)${f}`))
59
+ }
60
+ const r = await git(root, args)
61
+ if (!r.ok) return null
62
+ const out = new Map()
63
+ for (const rec of r.out.split('\0')) {
64
+ if (rec.length < 4) continue
65
+ const xy = rec.slice(0, 2)
66
+ const rel = rec.slice(3)
67
+ if (xy === '!!') continue
68
+ out.set(rel, xy === '??' || xy.includes('A') ? 'A' : xy.includes('D') ? 'D' : 'M')
69
+ }
70
+ return out
71
+ }
72
+
73
+ /** When each path was last committed (ms), from the newest `depth` commits: Map path -> ts. */
74
+ export async function lastCommitted (root, { depth = 2000 } = {}) {
75
+ const r = await git(root, ['-c', 'core.quotePath=false', 'log', `-n${depth}`, '--format=%x00%ct', '--name-only', 'HEAD'])
76
+ const out = new Map()
77
+ if (!r.ok) return out
78
+ let ts = 0
79
+ for (const line of r.out.split('\n')) {
80
+ if (line.startsWith('\0')) { ts = Number(line.slice(1)) * 1000 || 0; continue }
81
+ if (line && !out.has(line)) out.set(line, ts)
82
+ }
83
+ return out
84
+ }
85
+
86
+ /**
87
+ * Commits the request's files that differ from HEAD, and pushes. Resolves
88
+ * { hash, files, pushed, pushError, nothing } or throws with a plain reason (git refused, a hook failed).
89
+ * `push` false only commits. `upstream` is { remote, name } (gitstate.upstreamOf); without one nothing is pushed.
90
+ */
91
+ export async function commitFiles (root, { files, message, push = true, upstream = null, branch = '' } = {}) {
92
+ const wanted = cleanFiles(files)
93
+ const changed = await uncommitted(root, wanted)
94
+ if (!changed) throw new Error('git could not read this folder')
95
+ const paths = wanted.filter((f) => changed.has(f))
96
+ if (!paths.length) return { hash: null, files: [], pushed: false, pushError: null, nothing: true }
97
+ const spec = paths.map((f) => `:(literal)${f}`)
98
+ // New files must be known to git before `commit --only` takes them; deletions are taken as they are.
99
+ const added = paths.filter((f) => changed.get(f) === 'A')
100
+ if (added.length) {
101
+ const a = await git(root, ['add', '--', ...added.map((f) => `:(literal)${f}`)])
102
+ if (!a.ok) throw new Error(`git add failed: ${firstLine(a.err)}`)
103
+ }
104
+ const c = await git(root, ['commit', '--only', '--no-edit', '-m', message, '--', ...spec], { timeout: COMMIT_TIMEOUT_MS })
105
+ if (!c.ok) {
106
+ // Leave nothing half-done: what was added for this commit goes back to untracked.
107
+ if (added.length) await git(root, ['reset', '-q', '--', ...added.map((f) => `:(literal)${f}`)])
108
+ throw new Error(`git commit failed: ${firstLine(c.err || c.out)}`)
109
+ }
110
+ const hash = (await git(root, ['rev-parse', 'HEAD'])).out.trim()
111
+ if (!push || !upstream || !upstream.remote || upstream.remote === '.') return { hash, files: paths, pushed: false, pushError: upstream ? null : 'this branch has no upstream to push to', nothing: false }
112
+ const target = String(upstream.name || '').startsWith(`${upstream.remote}/`) ? upstream.name.slice(upstream.remote.length + 1) : branch
113
+ const p = await git(root, ['-c', 'core.askPass=', 'push', '--quiet', upstream.remote, `HEAD:refs/heads/${target}`], {
114
+ timeout: PUSH_TIMEOUT_MS,
115
+ env: { GIT_ASKPASS: '', SSH_ASKPASS: '', GIT_SSH_COMMAND: process.env.GIT_SSH_COMMAND || 'ssh -o BatchMode=yes' }
116
+ })
117
+ return { hash, files: paths, pushed: p.ok, pushError: p.ok ? null : pushProblem(p.err, upstream.remote, target), nothing: false }
118
+ }
119
+
120
+ const firstLine = (s) => String(s || '').split('\n').map((l) => l.replace(/^(fatal|error):\s*/i, '').trim()).find(Boolean)?.slice(0, 200) || 'unknown error'
121
+
122
+ /** Why a push failed, in plain words (never a URL: it may carry credentials). */
123
+ export function pushProblem (stderr, remote = 'origin', branch = '') {
124
+ const e = String(stderr || '').toLowerCase()
125
+ if (/non-fast-forward|fetch first|rejected/.test(e)) return `${remote}/${branch} has commits this folder doesn't have yet: the commit is made here and will be pushed after Quilt catches the branch up`
126
+ if (/could not read (username|password)|terminal prompts disabled|authentication failed|permission denied|repository not found|could not read from remote|access denied|returned error: 40[13]/.test(e)) return `git can't sign in to ${remote} from this folder to push`
127
+ if (/could not resolve host|failed to connect|unable to access|network is unreachable|timed out/.test(e)) return `can't reach ${remote} (no network?)`
128
+ return `git push failed: ${firstLine(stderr).replace(/[a-z]+:\/\/[^\s'"]*@/gi, '')}`
129
+ }
130
+
131
+ /** The commit message: what the request says, and who asked (and for which task). */
132
+ export function commitMessage (r) {
133
+ const first = String(r.message || '').trim().split('\n')[0].slice(0, 120) || 'Changes from the Quilt session'
134
+ const rest = String(r.message || '').trim().split('\n').slice(1).join('\n').trim()
135
+ const by = r.by ? `Asked for by ${r.by} in Quilt${r.task ? ` (task ${r.task.id}: ${r.task.title})` : ''}.` : ''
136
+ return [first, rest, by].filter(Boolean).join('\n\n')
137
+ }
138
+
139
+ /**
140
+ * Who made each uncommitted change, from the session's history: for each path in `dirty`, the
141
+ * people whose own edits (not pulls from git) to it came after it was last committed.
142
+ * Returns Map path -> [names], newest editor last. `history`: entries, oldest first.
143
+ */
144
+ export function editorsSince (dirty, history, committedAt = new Map()) {
145
+ const out = new Map()
146
+ for (const rel of dirty) out.set(rel, [])
147
+ for (const e of history || []) {
148
+ if (!e || !out.has(e.path) || e.pulled || !e.by) continue
149
+ if ((e.ts || 0) <= (committedAt.get(e.path) || 0)) continue
150
+ const list = out.get(e.path)
151
+ const i = list.indexOf(e.by)
152
+ if (i >= 0) list.splice(i, 1)
153
+ list.push(e.by)
154
+ }
155
+ return out
156
+ }
157
+
158
+ /**
159
+ * Uncommitted work grouped by who made it: [{ by, files }], most files first. A file several
160
+ * people changed is listed under each. Files no history entry explains go under ''.
161
+ */
162
+ export function uncommittedByPerson (editors) {
163
+ const groups = new Map()
164
+ for (const [rel, names] of editors) {
165
+ for (const n of names.length ? names : ['']) {
166
+ if (!groups.has(n)) groups.set(n, [])
167
+ groups.get(n).push(rel)
168
+ }
169
+ }
170
+ return [...groups].map(([by, files]) => ({ by, files: files.sort() })).sort((a, b) => b.files.length - a.files.length || a.by.localeCompare(b.by))
171
+ }
172
+
173
+ /**
174
+ * What the person committing a request should know first, or '': a file with conflict markers,
175
+ * one that also holds others' uncommitted changes (they would go in too), one someone else holds,
176
+ * or an asker still at work.
177
+ */
178
+ export function requestWarning (r, { editors, claims = [], markers = new Set(), taskEditors = new Set(), busy = [], now = Date.now() } = {}) {
179
+ if (now - (r.ts || 0) < REQUEST_SETTLE_MS) return 'just asked'
180
+ if (busy.includes(r.by)) return `${r.by} is still working`
181
+ for (const [rel, names] of editors) {
182
+ if (markers.has(rel)) return `${rel} has conflict markers`
183
+ const others = names.filter((n) => n !== r.by && !taskEditors.has(n))
184
+ if (others.length) return `${rel} also has uncommitted changes by ${others.join(', ')}`
185
+ const held = claims.find((c) => c && c.by && c.by !== r.by && globMatcher(String(c.pattern || ''))(rel))
186
+ if (held) return `${held.by} holds ${rel}`
187
+ }
188
+ return ''
189
+ }
190
+
191
+ // ------------------------------------------------------------- words --
192
+ // The same for the local and the hosted tools, and the HTTP routes' docs.
193
+
194
+ export const REQUEST_COMMIT_DESCRIPTION = 'Ask a person to commit your finished work, when you can\'t get it into git yourself (the owner keeps agents from committing, nobody has connected GitHub, or the repository is not on GitHub). ' +
195
+ 'A person with git on this branch commits exactly these files (those that differ from git) with your message, from their app, and you are told the commit. ' +
196
+ 'Give the files you changed; without them, the task\'s changes (task) or every change of yours on record are asked for.'
197
+
198
+ /** Open requests (with ids, files and what holds them up) and recent commits, as lines for an agent. */
199
+ export function describeCommitRequests (c, { canCommit = false } = {}) {
200
+ const lines = []
201
+ const open = c.open || []
202
+ if (open.length) {
203
+ lines.push('Open commit requests:')
204
+ for (const r of open) {
205
+ const files = Array.isArray(r.files) && r.files.length ? ` (${r.files.length} file${r.files.length === 1 ? '' : 's'}: ${r.files.slice(0, 8).join(', ')}${r.files.length > 8 ? ', …' : ''})` : ''
206
+ lines.push(`- [${r.id}] ${r.by}${r.branch ? ` on \`${r.branch}\`` : ''}: ${r.message}${r.task ? ` (task ${r.task.id})` : ''}${files}${r.error ? `. Last try: ${r.error}` : ''}`)
207
+ }
208
+ lines.push(canCommit ? 'This folder has git: its app commits a request with its Commit button.' : 'A person with git on that branch commits them from their app; the asker is told the commit.')
209
+ } else lines.push('No open commit requests.')
210
+ const recent = (c.recent || []).filter((r) => r.hash)
211
+ if (recent.length) lines.push('Committed lately:', ...recent.map((r) => `- ${r.hash.slice(0, 7)} ${r.message} (${r.by}${r.pushed === false ? `; not pushed${r.pushError ? `: ${r.pushError}` : ''}` : ''})`))
212
+ return lines.join('\n')
213
+ }