heyquilt 0.3.121 → 0.3.138

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 (129) hide show
  1. package/RELEASES.md +292 -17
  2. package/bin/quilt.js +72 -5
  3. package/desktop/main.js +16 -2
  4. package/package.json +3 -1
  5. package/src/agent-task-workflow.js +2 -2
  6. package/src/agents/common.js +13 -0
  7. package/src/ai-vm.js +9 -1
  8. package/src/api/activity.js +5 -0
  9. package/src/api/memory-store.js +21 -10
  10. package/src/api/notify.js +134 -0
  11. package/src/api/routes/browsers.js +29 -0
  12. package/src/api/routes/github.js +0 -13
  13. package/src/api/routes/relay.js +26 -3
  14. package/src/api/routes/seam-connect.js +103 -0
  15. package/src/api/routes/session-migrate.js +39 -0
  16. package/src/api/routes/sessions.js +73 -1
  17. package/src/api/server.js +46 -12
  18. package/src/api/supabase-store.js +53 -4
  19. package/src/autowork.js +182 -16
  20. package/src/connection.js +39 -9
  21. package/src/control.js +139 -13
  22. package/src/force-date.js +27 -0
  23. package/src/fsutil.js +13 -3
  24. package/src/gitstate.js +38 -2
  25. package/src/history.js +25 -1
  26. package/src/mcp.js +93 -7
  27. package/src/merge3.js +11 -3
  28. package/src/now-summary.js +113 -0
  29. package/src/pathrules.js +12 -0
  30. package/src/protocol.js +10 -2
  31. package/src/quilt-default.js +84 -0
  32. package/src/quilt-history.js +106 -0
  33. package/src/quilt-remote.js +78 -0
  34. package/src/relay-commit.js +5 -0
  35. package/src/relay-lease.js +6 -2
  36. package/src/relay-mcp.js +172 -8
  37. package/src/relay-outbound.js +1 -1
  38. package/src/relay-prune.js +4 -1
  39. package/src/release-words.js +2 -2
  40. package/src/runner.js +20 -7
  41. package/src/seam-access.js +97 -0
  42. package/src/seam-claims.js +53 -0
  43. package/src/seam-url.js +15 -0
  44. package/src/server.js +1767 -33
  45. package/src/session-follow.js +515 -0
  46. package/src/session-layout3.js +466 -0
  47. package/src/session-migrate.js +123 -0
  48. package/src/session-quilt.js +145 -0
  49. package/src/session-seam.js +284 -0
  50. package/src/session.js +899 -46
  51. package/src/source-live/bring-in.js +283 -0
  52. package/src/source-live/live-block.js +507 -35
  53. package/src/source-live/live-square.js +17 -6
  54. package/src/status.js +5 -0
  55. package/src/ui/agent-guide.js +1 -1
  56. package/src/ui/app.css +85 -20
  57. package/src/ui/app.js +4 -1
  58. package/src/ui/chat.js +9 -3
  59. package/src/ui/cloud-ai-model.js +13 -0
  60. package/src/ui/cloud-ai.js +10 -2
  61. package/src/ui/common.js +6 -2
  62. package/src/ui/fileview.js +20 -7
  63. package/src/ui/force-words.js +19 -0
  64. package/src/ui/git-rule.js +9 -0
  65. package/src/ui/home.js +0 -147
  66. package/src/ui/loom-model.js +15 -1
  67. package/src/ui/quiltview.js +213 -0
  68. package/src/ui/seam-model.js +251 -0
  69. package/src/ui/session.js +413 -31
  70. package/src/ui/voice-call.js +83 -14
  71. package/src/ui/voice-model.js +39 -0
  72. package/src/ui/voice.js +167 -17
  73. package/src/ui-server.js +80 -28
  74. package/src/update-check.js +25 -1
  75. package/src/voice.js +13 -6
  76. package/src/git.js +0 -147
  77. package/src/quilts/AGENTS.md +0 -1
  78. package/src/quilts/CLAUDE.md +0 -1
  79. package/src/quilts/archiver.js +0 -175
  80. package/src/quilts/autorevert.js +0 -49
  81. package/src/quilts/bisect-run.js +0 -31
  82. package/src/quilts/blame.js +0 -103
  83. package/src/quilts/block.js +0 -347
  84. package/src/quilts/bucket.js +0 -25
  85. package/src/quilts/cbor.js +0 -92
  86. package/src/quilts/claims.js +0 -144
  87. package/src/quilts/cli.js +0 -449
  88. package/src/quilts/client.js +0 -128
  89. package/src/quilts/conflict-format.js +0 -190
  90. package/src/quilts/describe.js +0 -266
  91. package/src/quilts/dogfood.js +0 -82
  92. package/src/quilts/errors.js +0 -8
  93. package/src/quilts/events.js +0 -160
  94. package/src/quilts/export-git.js +0 -130
  95. package/src/quilts/export.js +0 -81
  96. package/src/quilts/fastmerge.js +0 -41
  97. package/src/quilts/hash.js +0 -44
  98. package/src/quilts/history.js +0 -266
  99. package/src/quilts/ids.js +0 -12
  100. package/src/quilts/import-git.js +0 -291
  101. package/src/quilts/import.js +0 -134
  102. package/src/quilts/index.js +0 -72
  103. package/src/quilts/limits.js +0 -67
  104. package/src/quilts/lines.js +0 -49
  105. package/src/quilts/live.js +0 -50
  106. package/src/quilts/merge-smart.js +0 -114
  107. package/src/quilts/merge.js +0 -486
  108. package/src/quilts/objects.js +0 -144
  109. package/src/quilts/pack.js +0 -118
  110. package/src/quilts/records.js +0 -211
  111. package/src/quilts/resolutions.js +0 -69
  112. package/src/quilts/reviews.js +0 -195
  113. package/src/quilts/routes.js +0 -126
  114. package/src/quilts/service.js +0 -923
  115. package/src/quilts/setup.js +0 -34
  116. package/src/quilts/signing.js +0 -149
  117. package/src/quilts/squares.js +0 -239
  118. package/src/quilts/store-memory.js +0 -683
  119. package/src/quilts/store-rpc.js +0 -79
  120. package/src/quilts/store-supabase.js +0 -13
  121. package/src/quilts/tags.js +0 -64
  122. package/src/quilts/textdiff.js +0 -96
  123. package/src/quilts/tokens.js +0 -66
  124. package/src/quilts/tools.js +0 -375
  125. package/src/quilts/tree.js +0 -351
  126. package/src/quilts/webhook.js +0 -116
  127. package/src/quilts/window.js +0 -119
  128. package/src/quilts/xdiff.js +0 -435
  129. package/src/ui/branches.js +0 -111
@@ -0,0 +1,113 @@
1
+ // The website dashboard's summary of now (plans/dashboard ticket 9): for one person, what is going on in
2
+ // a session this relay holds. Who is in it and what each AI is doing, the board in brief, the latest
3
+ // file changes, held files and who waits for them, and what needs this person: messages to them not
4
+ // answered, tasks for them, files someone waits for from them, and, for the owner, commit requests and
5
+ // people asking to join. The accounts API asks for it (GET /v1/me/now, every 15 to 30 seconds from the
6
+ // page) and merges what each relay machine says; nothing here is streamed or stored.
7
+ import { waitingOn } from './duties.js'
8
+ import { readTasks, activeTasks } from './tasks.js'
9
+
10
+ export const NOW_LIMITS = { people: 24, recent: 8, detailRecent: 80, held: 24, needs: 24, text: 160 }
11
+ const EDIT_FRESH_MS = 4 * 60 * 1000 // a peer's edit counts as current this long (as relay-mcp.js)
12
+
13
+ const clip = (s, n = NOW_LIMITS.text) => {
14
+ const t = String(s || '').replace(/\s+/g, ' ').trim()
15
+ return t.length > n ? `${t.slice(0, n - 1)}…` : t
16
+ }
17
+
18
+ /** The name `account` ('person:<id>') has in `room`, or '' when it isn't the owner or a member. */
19
+ export function nameIn (room, account) {
20
+ const meta = room && room.meta
21
+ if (!meta || typeof account !== 'string') return ''
22
+ if (meta.ownerSub && meta.ownerSub === account) return meta.ownerName || 'owner'
23
+ const m = meta.members && Object.prototype.hasOwnProperty.call(meta.members, account) ? meta.members[account] : null
24
+ return m && !m.chat && typeof m.name === 'string' ? m.name : ''
25
+ }
26
+
27
+ /** The files a peer edited in the last few minutes, newest first. */
28
+ function freshEdits (editing, now) {
29
+ return Object.entries(editing || {})
30
+ .filter(([, ts]) => Number.isFinite(ts) && now - ts < EDIT_FRESH_MS)
31
+ .sort((a, b) => b[1] - a[1])
32
+ .map(([p]) => p)
33
+ }
34
+
35
+ /** Who is in the room now, each with what their AI is doing. */
36
+ function peopleIn (room, now) {
37
+ const out = []
38
+ const seen = new Set()
39
+ const states = room.awareness && room.awareness.getStates ? [...room.awareness.getStates().values()] : []
40
+ for (const s of states) {
41
+ if (!s || typeof s.name !== 'string' || !s.name || seen.has(s.name)) continue
42
+ seen.add(s.name)
43
+ const ag = s.agent || {}
44
+ const working = ag.sharing !== false && (ag.status === 'working' || (s.work && s.work.state === 'working'))
45
+ out.push({
46
+ name: clip(s.name, 64),
47
+ kind: s.kind === 'agent' ? 'agent' : 'person',
48
+ tool: clip(ag.tool || s.tool || '', 40),
49
+ ai: ag.sharing === false ? 'paused' : working ? 'working' : (ag.tool ? 'idle' : ''),
50
+ focus: clip(s.focus || (s.work && s.work.state === 'working' ? s.work.note : '') || '', 120),
51
+ editing: freshEdits(s.editing, now).slice(0, 3)
52
+ })
53
+ }
54
+ for (const h of room.hostedOnline ? room.hostedOnline() : []) {
55
+ if (!h || !h.name || seen.has(h.name)) continue
56
+ seen.add(h.name)
57
+ out.push({ name: clip(h.name, 64), kind: h.kind === 'agent' ? 'agent' : 'person', tool: 'hosted', ai: '', focus: '', editing: [] })
58
+ }
59
+ return out.slice(0, NOW_LIMITS.people)
60
+ }
61
+
62
+ /**
63
+ * The summary of `room` for `account`, or null when the account isn't in it. `now` is the time;
64
+ * `detail` (one session's page) keeps more of the latest file changes, for its Activity tab.
65
+ * The room is read through what Room already offers (server.js): meta, awareness, doc, claimList, pendingList.
66
+ */
67
+ export function nowSummary (room, account, { now = Date.now(), detail = false } = {}) {
68
+ const me = nameIn(room, account)
69
+ if (!me) return null
70
+ const owner = !!(room.meta.ownerSub && room.meta.ownerSub === account)
71
+ const doc = room.doc
72
+ const chat = doc ? doc.getArray('chat').toArray() : []
73
+ const tasks = doc ? activeTasks(readTasks(doc.getMap('tasks'))) : []
74
+ const claims = room.claimList ? room.claimList() : []
75
+ const people = peopleIn(room, now)
76
+
77
+ const needs = []
78
+ for (const m of waitingOn(chat.filter((x) => x && (!x.to || x.to === me || x.by === me)), me, { now })) {
79
+ needs.push({ kind: m.kind, by: clip(m.by, 64), text: clip(m.text), at: m.ts })
80
+ }
81
+ for (const t of tasks) {
82
+ if (t.column === 'todo' && t.assignee === me && !t.forAi) needs.push({ kind: 'task', by: clip(t.by, 64), text: clip(t.title), at: t.ts, task: t.id })
83
+ }
84
+ for (const c of claims) {
85
+ if (c.by === me && c.queue && c.queue.length) needs.push({ kind: 'file', by: clip(c.queue[0].by, 64), text: clip(c.pattern, 200), at: c.queue[0].ts || c.ts, waiting: c.queue.length })
86
+ }
87
+ if (owner && doc) {
88
+ for (const r of doc.getMap('commitRequests').values()) {
89
+ if (r && r.id && r.state === 'open') needs.push({ kind: 'commit', by: clip(r.by, 64), text: clip(r.message), at: r.ts, files: Array.isArray(r.files) ? r.files.length : 0 })
90
+ }
91
+ for (const p of room.pendingList ? room.pendingList() : []) needs.push({ kind: 'join', by: clip(p.name, 64), text: p.kind === 'agent' ? 'An agent asks to join' : 'Asks to join', at: p.since, agent: p.kind === 'agent' })
92
+ }
93
+ needs.sort((a, b) => (b.at || 0) - (a.at || 0))
94
+
95
+ const byColumn = { todo: 0, doing: 0, qa: 0 }
96
+ for (const t of tasks) if (t.column in byColumn) byColumn[t.column]++
97
+ const recent = (doc ? doc.getArray('activity').toArray() : []).filter((x) => x && x.by).slice(-(detail ? NOW_LIMITS.detailRecent : NOW_LIMITS.recent)).reverse()
98
+ .map((x) => ({ by: clip(x.by, 64), kind: clip(x.kind, 20), path: clip(x.path || x.detail || '', 200), at: x.ts }))
99
+
100
+ return {
101
+ room: room.name,
102
+ name: clip(room.meta.name || '', 80),
103
+ me,
104
+ owner,
105
+ people,
106
+ working: people.filter((p) => p.ai === 'working').length,
107
+ tasks: { open: byColumn.todo + byColumn.doing + byColumn.qa, ...byColumn, mine: tasks.filter((t) => t.column !== 'done' && t.assignee === me).length },
108
+ recent,
109
+ held: claims.slice(0, NOW_LIMITS.held).map((c) => ({ path: clip(c.pattern, 200), by: clip(c.by, 64), box: !!c.box, waiting: (c.queue || []).map((q) => clip(q.by, 64)) })),
110
+ needs: needs.slice(0, NOW_LIMITS.needs),
111
+ at: now
112
+ }
113
+ }
package/src/pathrules.js CHANGED
@@ -20,6 +20,8 @@ export const CHAT_FILES = 'Chat files'
20
20
  // copies of the project.
21
21
  export const ALWAYS_IGNORED = [
22
22
  '.git', '.quilt', '.cowove', 'node_modules', '.DS_Store', 'Thumbs.db', '.claude/worktrees',
23
+ // A Seam block's own record (a folder checked out with seam checkout): Seam's, never the session's.
24
+ '.seam',
23
25
  `/${CHAT_FILES}`,
24
26
  // Each person's own Claude Code settings (permissions, Quilt's hooks) stay on their machine.
25
27
  '.claude/settings.local.json',
@@ -66,6 +68,16 @@ export function makeIgnore (texts = []) {
66
68
  }
67
69
  }
68
70
 
71
+ /** The folders a .gitmodules text names as submodules (`path = …`), as safe relative paths. */
72
+ export function submodulePathsIn (text) {
73
+ const out = []
74
+ for (const m of String(text || '').matchAll(/^\s*path\s*=\s*(.+?)\s*$/gm)) {
75
+ const p = m[1].replace(/^"(.*)"$/, '$1').replace(/\/+$/, '')
76
+ if (isSafeRelPath(p)) out.push(p)
77
+ }
78
+ return out
79
+ }
80
+
69
81
  export function isIgnored (ig, rel) {
70
82
  if (!rel || rel === '.') return false
71
83
  // Check every ancestor directory so "dist/" also excludes "dist/a/b.js".
package/src/protocol.js CHANGED
@@ -17,7 +17,7 @@ export const MSG_ADMIN = 14 // client (owner) -> relay: JSON { id, op: 'approve'
17
17
  export const MSG_MEMBERS = 15 // relay -> client: JSON { members, sessionName, pending?, reply?: { id, ok, error } }
18
18
  export const MSG_PASS = 16 // client -> relay: JSON { pass }: a fresh session pass, sent at least every 5 minutes
19
19
  export const MSG_BRANCH = 17 // client -> relay: JSON { id, op: 'join'|'confirm'|'remove', branch, base?, adopt?, sv? }
20
- export const MSG_BRANCHES = 18 // relay -> client: JSON { branches, reply?: { id, op, ok, error?, branch?, created?, base? } }
20
+ export const MSG_BRANCHES = 18 // relay -> client: JSON { branches, quilt?, shadow?, reply?: { id, op, ok, error?, branch?, created?, base? } }
21
21
 
22
22
  // Every sync message names its document: the room's (chat, tasks, the feed, commit requests,
23
23
  // activity) is '', a branch's (its files) is the branch key. Git never allows an empty branch name.
@@ -25,7 +25,15 @@ export const ROOM_DOC = ''
25
25
 
26
26
  // What this app can do, sent when it connects. The relay turns away apps without what a session needs.
27
27
  // branch-diffs: this app reads a branch document that holds only what differs from git (branch-view.js).
28
- export const FEATURES = 'large-files,branches,branch-diffs'
28
+ // quilts: this app works in a session whose files are in its squares' live documents (layout 3,
29
+ // Seam's plans/quilt-source-control/16 flag 4: sent by every app before anyone opens a block).
30
+ // quilt-folders: this app's folder follows its branch's square in place at layout 3 (session-follow.js);
31
+ // an app with `quilts` but not this (0.3.125) kept syncing to the read-only branch documents.
32
+ // quilt-moves: this app's folder follows a square whose base moves (main brought in): it re-projects onto
33
+ // the new head, says which head each edit was made on, and takes in only its own edits. The relay brings
34
+ // main into a square only when every folder on it can follow that (server.js outdatedOn); 0.3.133 and
35
+ // 0.3.134, which can but don't say so, are known by their asking to bring main in.
36
+ export const FEATURES = 'large-files,branches,branch-diffs,quilts,quilt-folders,quilt-moves'
29
37
 
30
38
  // WebSocket close codes the relay uses to refuse a client for good.
31
39
  export const CLOSE_AUTH_FAILED = 4401
@@ -0,0 +1,84 @@
1
+ // New sessions are quilts from the start (Seam's plans/quilt-source-control/16, flags order 8,
2
+ // QUILT_SOURCE_DEFAULT=1 on the accounts API): the app that starts a session (the desktop app,
3
+ // `quilt start`, or an AI's quilt_start_session; every tool goes through the same Session) makes it
4
+ // a quilt without history as soon as the folder's files have reached the relay: patch 1 holds them
5
+ // (binaries and modes too), and the room moves to layout 3 with a square for the folder.
6
+ //
7
+ // Nothing is lost when it can't: a folder with a git remote is not made a quilt here (16: a room
8
+ // with a git remote becomes a quilt only with its history, which flag 9's "Make this a quilt"
9
+ // starts), and any failure leaves the session a working git-mode session, with the reason said to
10
+ // the owner and their AIs and `quilt session migrate` to try again by hand.
11
+ import { execFileSync } from 'node:child_process'
12
+ import { apiUrl } from './account.js'
13
+
14
+ const FEATURES_TIMEOUT_MS = 5000
15
+ const SYNC_TRIES = 20
16
+ const SYNC_WAIT_MS = 500
17
+
18
+ /** Whether the accounts API at `api` has new sessions start as quilts (GET /v1/features `quilt_source_default`). False when it can't say. */
19
+ export async function quiltDefaultOn ({ api = apiUrl(), fetch: fetchImpl = globalThis.fetch } = {}) {
20
+ try {
21
+ const res = await fetchImpl(`${String(api).replace(/\/+$/, '')}/v1/features`, { signal: AbortSignal.timeout(FEATURES_TIMEOUT_MS) })
22
+ if (!res.ok) return false
23
+ const f = await res.json()
24
+ return !!(f && f.quilt_source_default === true)
25
+ } catch { return false }
26
+ }
27
+
28
+ /** Whether the folder at `root` is a git repository with a remote (its history belongs in the quilt: 16 "With history"). */
29
+ export function folderHasGitRemote (root) {
30
+ try {
31
+ return execFileSync('git', ['--no-optional-locks', 'remote'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 10_000 }).trim().length > 0
32
+ } catch { return false }
33
+ }
34
+
35
+ const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
36
+
37
+ /**
38
+ * Makes the new session `session` a quilt (migrate, then the layout-3 switch) as its owner, once.
39
+ * Resolves to { done: true, quilt } or { done: false, why }; never throws.
40
+ */
41
+ export async function startAsQuilt (session, { flagOn = quiltDefaultOn, credential, hasRemote = folderHasGitRemote, syncTries = SYNC_TRIES, syncWaitMs = SYNC_WAIT_MS } = {}) {
42
+ const skip = (why) => ({ done: false, why })
43
+ try {
44
+ if (!(await flagOn())) return skip('off')
45
+ if (session.stopped) return skip('stopped')
46
+ if (session.quilt) return skip('already a quilt')
47
+ if (session.root && hasRemote(session.root)) {
48
+ session.log('🧵 this folder has a git remote, so the new session stays on git: it becomes a quilt with its history later (Make this a quilt).')
49
+ return skip('git remote')
50
+ }
51
+ if (session.branchForm === 'diff') return skip('diff branch')
52
+ // The folder's files are its patch 1: they must all be on the relay first.
53
+ let synced = false
54
+ for (let i = 0; i < syncTries && !synced && !session.stopped; i++) {
55
+ if (session.ready && !session.seeding) {
56
+ try { session.flushPending?.() } catch {}
57
+ try { await session.conn.confirmBranch(); synced = true; break } catch {}
58
+ }
59
+ await wait(syncWaitMs)
60
+ }
61
+ if (session.stopped) return skip('stopped')
62
+ if (!synced) throw new Error("the folder's files had not all reached the relay")
63
+ if (session.quilt) return skip('already a quilt')
64
+ const token = await credential()
65
+ if (!token) throw new Error('no sign-in to reach Seam with')
66
+ const m = await session.quiltMigrate(token)
67
+ await session.quiltSwitch(token)
68
+ // The folder equals patch 1, so the switch opened no square for it: one is opened now, as the
69
+ // owner, and the folder follows it (16: a square for the session's first folder). Should that
70
+ // fail, the session is a quilt still, and the folder's first edit asks for its square (readOnly).
71
+ for (let i = 0; i < syncTries * 4 && !session.stopped && !(session.quilt && session.quilt.layout === 3); i++) await wait(syncWaitMs / 4)
72
+ const branch = typeof session.branchKey === 'function' ? session.branchKey() : session.branch
73
+ if (session.quilt && session.quilt.layout === 3 && !(session.quilt.live || {})[branch]) {
74
+ try { await session.openBranchSquare(branch, { quiet: false, token }) } catch (err) { session.log(`🧵 couldn't open a square for this folder yet (${err.message}); its first edit asks again.`) }
75
+ }
76
+ session.log(`🧵 new session: its code is in Seam from the start (quilt ${(m.quilt && m.quilt.slug) || ''}, patch 1).`)
77
+ return { done: true, quilt: m.quilt }
78
+ } catch (err) {
79
+ const why = String((err && err.message) || err)
80
+ session.log(`🧵 this new session's code could not go into Seam (${why}); the session works as before, on git. The owner can try again with: quilt session migrate`)
81
+ if (session.notice) { try { session.notice(`This new session's code could not go into Seam (${why}), so it stays on git for now. The session owner can try again with \`quilt session migrate\`.`) } catch {} }
82
+ return skip(why)
83
+ }
84
+ }
@@ -0,0 +1,106 @@
1
+ // quilt_history in a session linked to a quilt (Seam's plans/quilt-source-control/16, step 1
2
+ // "Shadow", and after the session became a quilt): the chronology is Seam's log (the commits and
3
+ // patches, imported from git or made in Seam) plus the session's own live record of changes not
4
+ // committed yet (the branch document's `history`, history.js), oldest first, newest kept.
5
+ //
6
+ // Quilt reads Seam over its HTTP API (a client with the @heyquilt/seam quiltClient shape: `log`,
7
+ // `show`), as the member (Session.seamMember) or, on the relay, with the room's read token. When
8
+ // Seam can't be read, the session's own record is shown with a note, never an error: the tool
9
+ // worked before the link and keeps working without Seam. The same for every AI tool: this runs
10
+ // behind quilt_history in both MCP servers (mcp.js through the folder's daemon, relay-mcp.js).
11
+ import { MAX_DIFF_CHARS, queryHistory } from './history.js'
12
+
13
+ /**
14
+ * The quilt a room's chronology reads from: `{ id, slug, at?, shadow? }`, or null. A quilt the
15
+ * session became (meta.quilt_id), or the quilt it shadows while git is still the authority
16
+ * (meta.quiltShadow, 16 step 1: an import following the session's git remote). Only with Seam
17
+ * on (QUILT_SOURCE_URL), so every other session reads its own record exactly as before.
18
+ */
19
+ export function historyQuilt (meta, env = process.env) {
20
+ if (!env || !env.QUILT_SOURCE_URL || !meta) return null
21
+ if (typeof meta.quilt_id === 'string' && meta.quilt_id) {
22
+ const q = meta.quilt || {}
23
+ return { id: meta.quilt_id, slug: q.slug || '', ...(q.at ? { at: q.at } : {}) }
24
+ }
25
+ const s = meta.quiltShadow
26
+ if (s && typeof s.id === 'string' && s.id) return { id: s.id, slug: s.slug || '', shadow: true }
27
+ return null
28
+ }
29
+
30
+ /** The part of a path or glob Seam's log can filter by: a file, or the folder before the first wildcard. */
31
+ export function seamPathOf (p) {
32
+ const s = String(p || '').replace(/\\/g, '/').replace(/^\.\//, '')
33
+ if (!s) return ''
34
+ const wild = s.search(/[*?[{]/)
35
+ if (wild < 0) return s.replace(/\/+$/, '')
36
+ const dir = s.slice(0, wild)
37
+ return dir.includes('/') ? dir.slice(0, dir.lastIndexOf('/')) : ''
38
+ }
39
+
40
+ const MAX_SHOWN = 10 // commits whose diffs are read when asked for them
41
+
42
+ /**
43
+ * The quilt's log as chronology entries, oldest first: one per commit, `{ seam: true, commit,
44
+ * short, patch, by, ts, message, files, task, diff }`. `since` is a timestamp. Throws what Seam
45
+ * said when it can't be read.
46
+ */
47
+ export async function seamHistory (seam, quilt, { path = '', by, since, task, limit = 30, withDiff = false, rev = 'main' } = {}) {
48
+ const p = seamPathOf(path)
49
+ const r = await seam.log(quilt, { rev, limit: Math.min(Number(limit) || 30, 200), ...(p ? { paths: [p] } : {}), ...(by ? { author: by } : {}), ...(typeof since === 'number' ? { since: String(Math.floor(since)) } : {}), ...(task ? { task } : {}) })
50
+ const out = []
51
+ for (const c of (r && r.commits) || []) {
52
+ if (!c || !c.id) continue
53
+ const a = c.author || {}
54
+ out.push({
55
+ id: `seam:${c.id}`,
56
+ seam: true,
57
+ commit: c.id,
58
+ short: c.short || c.id.slice(0, 12),
59
+ ...(c.patch && c.patch.patch_no != null ? { patch: { line: c.patch.line || rev, no: c.patch.patch_no } } : {}),
60
+ by: String(a.name || a.member_id || ''),
61
+ ts: Number(c.at ?? c.authored_at) || 0,
62
+ message: String(c.message || '').split('\n')[0].slice(0, 200),
63
+ files: Number(c.files) || (Array.isArray(c.changes) ? c.changes.length : 0),
64
+ task: c.task_id ? { id: String(c.task_id), title: '' } : null,
65
+ path: p,
66
+ diff: ''
67
+ })
68
+ }
69
+ if (withDiff) {
70
+ for (const e of out.slice(0, MAX_SHOWN)) {
71
+ try {
72
+ const s = await seam.show(quilt, e.commit, { format: 'unified', ...(p ? { paths: [p] } : {}) })
73
+ const text = ((s && s.diff && s.diff.files) || []).map((f) => f.patch || (f.binary ? `${f.path}: binary` : '')).filter(Boolean).join('\n')
74
+ e.diff = text.length > MAX_DIFF_CHARS ? `${text.slice(0, MAX_DIFF_CHARS)}\n… (diff truncated)` : text
75
+ } catch { e.diff = '' }
76
+ }
77
+ }
78
+ return out.sort((x, y) => x.ts - y.ts)
79
+ }
80
+
81
+ /**
82
+ * The chronology of a linked session: the quilt's log (`seam`, a client) and the session's own
83
+ * record (`entries`, history.js), filtered by `query` (path, by, since timestamp, task, limit,
84
+ * withDiff), oldest first, the newest `limit` kept. A change git pulled into the session is the
85
+ * log's (an imported patch) and is left out of the session's record when the log answered, so it
86
+ * is not counted twice (16: "entries with `pulled` or `seed` are dropped", here only `pulled`:
87
+ * a seed is the session's first copy, which the log has no entry for). Returns { list, note }:
88
+ * `note` says why the log is missing, when it is.
89
+ */
90
+ export async function linkedHistory ({ seam, quilt, entries = [], query = {} }) {
91
+ const { path = '', by, since, task, limit = 30, withDiff = false } = query
92
+ let fromSeam = []
93
+ let note = ''
94
+ if (!seam) note = `Seam's log for ${quilt.slug || quilt.id} was not read: the session has no read token for it (its owner links it again); this is the session's own record.`
95
+ else {
96
+ try { fromSeam = await seamHistory(seam, quilt.id, { path, by, since, task, limit, withDiff }) } catch (err) { note = `Seam's log for ${quilt.slug || quilt.id} could not be read (${String(err.message || err).slice(0, 200)}); this is the session's own record.` }
97
+ }
98
+ return { list: mergeLinked(entries, fromSeam, { ...query, logRead: !note }), note }
99
+ }
100
+
101
+ /** The session's own entries (filtered by `query`; git's pulls left out when `logRead`) with the log's, oldest first, the newest `limit`. */
102
+ export function mergeLinked (entries, fromSeam, { path = '', by, since, task, limit = 30, logRead = true } = {}) {
103
+ const own = queryHistory(logRead ? entries.filter((e) => !(e && e.pulled)) : entries, { path, by, since, task, limit: 0 })
104
+ const list = [...own, ...(fromSeam || [])].sort((a, b) => a.ts - b.ts)
105
+ return limit && list.length > limit ? list.slice(-limit) : list
106
+ }
@@ -0,0 +1,78 @@
1
+ // "Make this a quilt" for a session with a git remote (Seam's plans/quilt-source-control/16, flag 9, and
2
+ // "An opt-in team's migration"): the steps the owner's one button runs, in order, each time it is
3
+ // clicked, from where the session stands. Pure: no I/O (test/unit/quilt-remote.test.js).
4
+ //
5
+ // 1. import Seam imports the GitHub repository and follows it (the owner's Seam GitHub App
6
+ // installation is the read credential; 16 step 1). The session stays git-mode.
7
+ // 2. link Once the import follows, the session reads the quilt's log (the shadow link).
8
+ // 3. cut-over In the owner's sitting: main must take pushes from Seam's export only (a ruleset only
9
+ // the repository's GitHub owner can add: plain steps), then the import is cut over
10
+ // with export to the same remote on, then the session moves in with its history.
11
+
12
+ export const SEAM_APP = { id: 5475977, slug: 'seam-by-heyquilt' }
13
+
14
+ /** owner/name of the session's GitHub remote, from its upstreams (the default branch's first), or null. */
15
+ export function githubRepoOf (upstreams, defaultKey) {
16
+ const all = Object.entries(upstreams || {})
17
+ const pick = all.find(([k]) => k === defaultKey) || all[0]
18
+ const m = pick && /^github\.com\/([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+?)(\.git)?$/.exec(String((pick[1] && pick[1].repo) || ''))
19
+ return m ? m[1] : null
20
+ }
21
+
22
+ /** Which of the owner's Seam GitHub installations reaches `repo`, or null. */
23
+ export function installationFor (gh, repo) {
24
+ const want = String(repo).toLowerCase()
25
+ return ((gh && gh.installations) || []).find((i) => (i.repos || []).some((r) => String(r.full_name || '').toLowerCase() === want)) || null
26
+ }
27
+
28
+ /** The step the session is at: 'import' (nothing yet), 'link' (importing), 'cutover' (linked to the quilt that follows). */
29
+ export function remoteStage ({ quiltImport = null, quiltShadow = null }) {
30
+ if (quiltShadow) return 'cutover'
31
+ if (quiltImport) return 'link'
32
+ return 'import'
33
+ }
34
+
35
+ /** Whether GitHub's rules for main (GET /repos/:o/:r/rules/branches/main) keep everyone but an app from pushing to it. */
36
+ export function mainGuarded (rules) {
37
+ return Array.isArray(rules) && rules.some((r) => r && r.type === 'update')
38
+ }
39
+
40
+ /** What the repository's GitHub owner does so only Seam pushes to main (16's cut-over checklist, step 5). */
41
+ export function rulesetSteps (repo) {
42
+ return [
43
+ `On GitHub, open https://github.com/${repo}/settings/rules and choose New ruleset, then New branch ruleset.`,
44
+ 'Name it "Seam is the authority on main" and set Enforcement status to Active.',
45
+ `Under Bypass list, add the app ${SEAM_APP.slug} (Seam), set to Always allow.`,
46
+ 'Under Target branches, add the default branch and the pattern release/**.',
47
+ 'Turn on Restrict updates, Restrict deletions and Block force pushes, then Create.',
48
+ 'Ask everyone to stop pushing to main and wait for CI\'s last run there. Then click Move into Seam now.'
49
+ ]
50
+ }
51
+
52
+ /** The ruleset as GitHub's API takes it (for an owner who prefers `gh api -X POST repos/<repo>/rulesets --input`). */
53
+ export function rulesetBody () {
54
+ return {
55
+ name: 'Seam is the authority on main',
56
+ target: 'branch',
57
+ enforcement: 'active',
58
+ bypass_actors: [{ actor_id: SEAM_APP.id, actor_type: 'Integration', bypass_mode: 'always' }],
59
+ conditions: { ref_name: { include: ['~DEFAULT_BRANCH', 'refs/heads/release/**'], exclude: [] } },
60
+ rules: [{ type: 'update' }, { type: 'deletion' }, { type: 'non_fast_forward' }]
61
+ }
62
+ }
63
+
64
+ /** What the owner does when no Seam GitHub installation of theirs reaches the repository. */
65
+ export function installSteps (repo, installUrl = `https://github.com/apps/${SEAM_APP.slug}/installations/new`) {
66
+ return [
67
+ 'Sign in to Seam at https://seam.heyquilt.com with the same account you use for Quilt.',
68
+ `Connect GitHub there and install the Seam app on ${repo} (${installUrl}). Seam reads the repository with it, and later pushes main to it.`,
69
+ 'Then click Make this a quilt again.'
70
+ ]
71
+ }
72
+
73
+ /** One error whose message is the plain steps, with the steps alone in `steps` (the app lists them). */
74
+ export function stepsError (lead, steps) {
75
+ const e = new Error(`${lead}\n${steps.map((s, i) => `${i + 1}. ${s}`).join('\n')}`)
76
+ e.steps = steps
77
+ return e
78
+ }
@@ -17,6 +17,7 @@ import {
17
17
  } from './github-commit.js'
18
18
  import { readTasks, addTask, updateTask } from './tasks.js'
19
19
  import { addComment } from './task-comments.js'
20
+ import { quiltOf } from './session-seam.js'
20
21
 
21
22
  export { CommitRefused }
22
23
 
@@ -566,6 +567,8 @@ export const mergeSchema = (z) => ({
566
567
  * connection (or the owner's token) covers the repository. Cheap: from the relay's record.
567
568
  */
568
569
  export function agentsCanCommit (room, key) {
570
+ // A session that is a quilt has no GitHub path for anyone's work: it lands with Seam (session-seam.js).
571
+ if (quiltOf(room.meta)) return false
569
572
  if (agentCommitsOf(room) === 'off') return false
570
573
  const found = repoRecordFor(room, key)
571
574
  if (!found) return false
@@ -676,6 +679,8 @@ export async function finishTaskFor (room, { who, key, task: taskId }, { fetch =
676
679
  const map = room.doc.getMap('tasks')
677
680
  const task = readTasks(map).find((t) => t.id === taskId)
678
681
  if (!task) return { merged: null }
682
+ // In a quilt a task's work lands with seam_merge: a pull request from before the switch is git's, and stays as it is.
683
+ if (quiltOf(room.meta)) return { merged: null }
679
684
  // A clash task (clash.js) is the merge itself.
680
685
  if (Object.values((room.meta && room.meta.upstreams) || {}).some((rec) => rec && rec.clash && rec.clash.task === taskId)) return { merged: null }
681
686
  if (!agentsCanCommit(room, key) && !task.pr) return { merged: null }
@@ -1,5 +1,5 @@
1
1
  // One live session per repository and branch (plan §8, addendum 16.6). The session that pushes
2
- // repo@branch holds its lease in the accounts API (git_sync_leases), which works across relay
2
+ // repo@branch holds its lease in the accounts API (relay_leases), which works across relay
3
3
  // machines; the relay renews every lease it holds every LEASE_HEARTBEAT_MS, for every session active
4
4
  // in the last 24 hours (loaded or not), and keeps the list on its disk so a restart picks them up.
5
5
  // A lease lapses LEASE_TTL_MS after its last heartbeat (two missed: a crashed relay's).
@@ -52,9 +52,13 @@ function localStore (now) {
52
52
  }
53
53
  }
54
54
 
55
+ /** A lease's key in the accounts API (relay_leases): "<repo> <branch>". */
56
+ export const leaseKeyOf = (repo, branch) => `${repo} ${branch}`
57
+
55
58
  function apiStore ({ apiUrl, secret, fetch }) {
56
59
  const call = async (what, body) => {
57
- const res = await fetch(`${String(apiUrl).replace(/\/+$/, '')}/v1/relay/leases/${what}`, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${secret}` }, body: JSON.stringify(body) })
60
+ // `key` (relay_leases, 16 R2) beside repo and branch, which an API from before R2 reads instead: the same lease either way.
61
+ const res = await fetch(`${String(apiUrl).replace(/\/+$/, '')}/v1/relay/leases/${what}`, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${secret}` }, body: JSON.stringify({ key: leaseKeyOf(body.repo, body.branch), ...body }) })
58
62
  if (!res.ok) throw new Error(`the accounts API answered ${res.status}`)
59
63
  return res.json()
60
64
  }