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.
- package/LICENSE +85 -0
- package/README.md +630 -2
- package/RELEASES.md +733 -0
- package/assets/logo.svg +1 -0
- package/bin/quilt.js +816 -0
- package/desktop/main.js +315 -0
- package/package.json +163 -7
- package/src/account.js +563 -0
- package/src/admit-policy.js +32 -0
- package/src/agent-join.js +370 -0
- package/src/agent-task-workflow.js +176 -0
- package/src/agents/actions.js +85 -0
- package/src/agents/claude-code.js +180 -0
- package/src/agents/common.js +21 -0
- package/src/agents/cursor-transcripts.js +170 -0
- package/src/agents/cursor.js +404 -0
- package/src/agents/index.js +69 -0
- package/src/ai-vm-signin.js +98 -0
- package/src/ai-vm.js +380 -0
- package/src/api/access.js +75 -0
- package/src/api/activity.js +181 -0
- package/src/api/agent-auth.js +130 -0
- package/src/api/agent-placement.js +157 -0
- package/src/api/audit.js +83 -0
- package/src/api/auth.js +17 -0
- package/src/api/domains.js +30 -0
- package/src/api/file-paths.js +40 -0
- package/src/api/file-store.js +99 -0
- package/src/api/fly-machines.js +76 -0
- package/src/api/github-app.js +172 -0
- package/src/api/http.js +34 -0
- package/src/api/invite-email.js +53 -0
- package/src/api/issues.js +110 -0
- package/src/api/join-text.js +78 -0
- package/src/api/mailer.js +47 -0
- package/src/api/memory-store.js +1083 -0
- package/src/api/org-access.js +54 -0
- package/src/api/permissions.js +71 -0
- package/src/api/relay-cluster.js +251 -0
- package/src/api/relay-machines.js +60 -0
- package/src/api/routes/access-types.js +54 -0
- package/src/api/routes/agent-invites.js +115 -0
- package/src/api/routes/agents.js +157 -0
- package/src/api/routes/ai-vm.js +369 -0
- package/src/api/routes/github.js +380 -0
- package/src/api/routes/grants.js +70 -0
- package/src/api/routes/invites.js +188 -0
- package/src/api/routes/issues.js +47 -0
- package/src/api/routes/join.js +167 -0
- package/src/api/routes/members.js +110 -0
- package/src/api/routes/orgs.js +177 -0
- package/src/api/routes/relay-cluster.js +90 -0
- package/src/api/routes/relay.js +76 -0
- package/src/api/routes/session-invites.js +119 -0
- package/src/api/routes/sessions.js +92 -0
- package/src/api/routes/teams.js +132 -0
- package/src/api/routes/workspace-agents.js +332 -0
- package/src/api/routes/workspace-files.js +277 -0
- package/src/api/routes/workspace-invites.js +196 -0
- package/src/api/routes/workspaces.js +225 -0
- package/src/api/server.js +623 -0
- package/src/api/slugs.js +20 -0
- package/src/api/supabase-store.js +765 -0
- package/src/api/team-access.js +30 -0
- package/src/api/tokens.js +31 -0
- package/src/api/workspace-access.js +62 -0
- package/src/api/workspace-reach.js +60 -0
- package/src/autowork-tools.js +166 -0
- package/src/autowork.js +387 -0
- package/src/blobstore.js +99 -0
- package/src/boxes.js +37 -0
- package/src/branchdocs.js +235 -0
- package/src/branches.js +194 -0
- package/src/catchup.js +111 -0
- package/src/chat-archive.js +85 -0
- package/src/chat-links.js +923 -0
- package/src/clash.js +140 -0
- package/src/cloud-ai.js +50 -0
- package/src/commit-request.js +59 -0
- package/src/commit.js +213 -0
- package/src/connection.js +569 -0
- package/src/control.js +245 -0
- package/src/conversation.js +125 -0
- package/src/doctor.js +156 -0
- package/src/duties.js +303 -0
- package/src/editors.js +228 -0
- package/src/fsutil.js +87 -0
- package/src/git-hook.js +48 -0
- package/src/git.js +147 -0
- package/src/github-commit.js +320 -0
- package/src/gitignore.js +49 -0
- package/src/gitstate.js +900 -0
- package/src/history.js +307 -0
- package/src/hooks.js +371 -0
- package/src/hosted-agents.js +24 -0
- package/src/hosted-client.js +112 -0
- package/src/identity.js +99 -0
- package/src/inbox.js +237 -0
- package/src/integrations.js +221 -0
- package/src/key-cli.js +115 -0
- package/src/largefiles.js +91 -0
- package/src/legacy.js +25 -0
- package/src/mcp-proxy.js +40 -0
- package/src/mcp-room.js +32 -0
- package/src/mcp.js +1261 -0
- package/src/merge3.js +158 -0
- package/src/merges.js +148 -0
- package/src/pass-source.js +176 -0
- package/src/passes.js +64 -0
- package/src/pathrules.js +127 -0
- package/src/persona.js +101 -0
- package/src/presence.js +360 -0
- package/src/private-file.js +41 -0
- package/src/procs.js +70 -0
- package/src/project-rules.js +178 -0
- package/src/protocol.js +92 -0
- package/src/quiet-warnings.js +9 -0
- package/src/relay-ai-vm.js +82 -0
- package/src/relay-ask.js +141 -0
- package/src/relay-ci.js +111 -0
- package/src/relay-clash.js +597 -0
- package/src/relay-cluster.js +454 -0
- package/src/relay-commit.js +887 -0
- package/src/relay-filekey.js +46 -0
- package/src/relay-git.js +274 -0
- package/src/relay-githttp.js +137 -0
- package/src/relay-github.js +109 -0
- package/src/relay-lease.js +137 -0
- package/src/relay-lfs.js +37 -0
- package/src/relay-mcp.js +1679 -0
- package/src/relay-metrics.js +75 -0
- package/src/relay-outbound.js +1144 -0
- package/src/relay-sync.js +719 -0
- package/src/relay-turn-sync.js +307 -0
- package/src/relay-upstream.js +934 -0
- package/src/relay-webhooks.js +111 -0
- package/src/releases.js +131 -0
- package/src/report.js +129 -0
- package/src/runner.js +306 -0
- package/src/same-edit.js +18 -0
- package/src/server.js +4187 -0
- package/src/session-access.js +170 -0
- package/src/session-name.js +15 -0
- package/src/session.js +7361 -0
- package/src/settings.js +46 -0
- package/src/setup.js +184 -0
- package/src/shell-path.js +46 -0
- package/src/status.js +199 -0
- package/src/summarize.js +109 -0
- package/src/sync-words.js +42 -0
- package/src/task-comments.js +92 -0
- package/src/tasks.js +670 -0
- package/src/textdiff.js +20 -0
- package/src/turn-sync-plan.js +142 -0
- package/src/turn-sync.js +191 -0
- package/src/ui/access-form.js +37 -0
- package/src/ui/agent-env-key.js +76 -0
- package/src/ui/agent-guide.js +127 -0
- package/src/ui/agent-kinds.js +175 -0
- package/src/ui/agent-place.js +265 -0
- package/src/ui/app.css +2309 -0
- package/src/ui/app.js +542 -0
- package/src/ui/board.js +465 -0
- package/src/ui/boxes.js +76 -0
- package/src/ui/branches.js +111 -0
- package/src/ui/catchup.js +77 -0
- package/src/ui/changes.js +159 -0
- package/src/ui/chat.js +266 -0
- package/src/ui/cloud-ai-model.js +107 -0
- package/src/ui/cloud-ai.js +221 -0
- package/src/ui/common.js +431 -0
- package/src/ui/csv.js +21 -0
- package/src/ui/diffview.js +74 -0
- package/src/ui/feed-convs.js +39 -0
- package/src/ui/feed.js +174 -0
- package/src/ui/files.js +579 -0
- package/src/ui/fileview.js +123 -0
- package/src/ui/git-rule.js +60 -0
- package/src/ui/home.js +1323 -0
- package/src/ui/index.html +16 -0
- package/src/ui/invite.js +97 -0
- package/src/ui/invites.js +79 -0
- package/src/ui/loom-model.js +516 -0
- package/src/ui/loom.css +349 -0
- package/src/ui/loom.js +753 -0
- package/src/ui/mark.js +104 -0
- package/src/ui/merges.js +158 -0
- package/src/ui/rail-model.js +78 -0
- package/src/ui/rail.js +173 -0
- package/src/ui/releases.js +182 -0
- package/src/ui/schedule.js +156 -0
- package/src/ui/session.js +3394 -0
- package/src/ui/signin.js +75 -0
- package/src/ui/sounds.js +110 -0
- package/src/ui/tool-logo.js +86 -0
- package/src/ui/tree.js +276 -0
- package/src/ui/workspaces.js +542 -0
- package/src/ui-server.js +1272 -0
- package/src/update-check.js +67 -0
- package/src/upstream.js +155 -0
- package/src/webhooks.js +235 -0
- package/src/workspace-cli.js +62 -0
- package/src/workspace-tools.js +340 -0
- package/index.js +0 -2
package/src/settings.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Per-user settings in ~/.quilt/settings.json: your colour, AI tool and session
|
|
2
|
+
// defaults. And the one relay Quilt uses.
|
|
3
|
+
import fs from 'node:fs'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { quiltHome } from './legacy.js'
|
|
6
|
+
|
|
7
|
+
/** The relay every session uses. QUILT_SERVER overrides it, for development and tests only. */
|
|
8
|
+
export const HOSTED_RELAY = 'wss://relay.heyquilt.com'
|
|
9
|
+
// The hosted relay's older address points at the same relay, so its sessions reopen as they are.
|
|
10
|
+
const HOSTED_ALIASES = [HOSTED_RELAY, 'wss://cowove-relay.fly.dev']
|
|
11
|
+
// Settings from when you could pick a relay. Ignored, and dropped the next time settings are saved.
|
|
12
|
+
const RETIRED = ['relay', 'relayKey', 'relayMode', 'publicUrl']
|
|
13
|
+
|
|
14
|
+
const file = () => path.join(quiltHome(), 'settings.json')
|
|
15
|
+
|
|
16
|
+
export function getSettings () {
|
|
17
|
+
let s = {}
|
|
18
|
+
try { s = JSON.parse(fs.readFileSync(file(), 'utf8')) || {} } catch {}
|
|
19
|
+
for (const k of RETIRED) delete s[k]
|
|
20
|
+
return s
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function saveSettings (patch) {
|
|
24
|
+
const next = { ...getSettings(), ...patch }
|
|
25
|
+
for (const k of Object.keys(next)) if (next[k] === undefined || next[k] === null || next[k] === '') delete next[k]
|
|
26
|
+
fs.mkdirSync(path.dirname(file()), { recursive: true })
|
|
27
|
+
fs.writeFileSync(file(), JSON.stringify(next, null, 2), { mode: 0o600 })
|
|
28
|
+
return next
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function relayUrl () {
|
|
32
|
+
return process.env.QUILT_SERVER || HOSTED_RELAY
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function isHostedRelay (server) {
|
|
36
|
+
return HOSTED_ALIASES.includes(String(server || '').replace(/\/+$/, ''))
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A saved session on any relay other than Quilt's own (or the QUILT_SERVER relay in use): one that
|
|
41
|
+
* ran on someone's own computer, or through a tunnel to it. Quilt no longer runs those, and must
|
|
42
|
+
* never send this computer's pass to them.
|
|
43
|
+
*/
|
|
44
|
+
export function unsupportedRelay (server) {
|
|
45
|
+
return !!server && !isHostedRelay(server) && server !== relayUrl()
|
|
46
|
+
}
|
package/src/setup.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// Wires quilt into AI coding tools in the current project: registers the MCP
|
|
2
|
+
// server for Claude Code / Cursor and adds pairing guidance to the agent
|
|
3
|
+
// instruction files. These files live in the project, so they sync to
|
|
4
|
+
// everyone in the session.
|
|
5
|
+
import fs from 'node:fs'
|
|
6
|
+
import path from 'node:path'
|
|
7
|
+
import { hookSettings, isQuiltHook } from './hooks.js'
|
|
8
|
+
import { registerEverywhere } from './integrations.js'
|
|
9
|
+
import { TASK_WORKFLOW_MD, CHECKLIST_SCAFFOLD, extractChecklist } from './agent-task-workflow.js'
|
|
10
|
+
|
|
11
|
+
const START = '<!-- quilt:start -->'
|
|
12
|
+
const END = '<!-- quilt:end -->'
|
|
13
|
+
|
|
14
|
+
export const AGENT_GUIDE = `${START}
|
|
15
|
+
## Live pair session (quilt)
|
|
16
|
+
|
|
17
|
+
This project is being edited **live by more than one person at once**, each
|
|
18
|
+
with their own AI coding tool. Files can change underneath you at any time.
|
|
19
|
+
|
|
20
|
+
- Before starting a task, check what your collaborators are doing: call the
|
|
21
|
+
\`quilt_status\` MCP tool, or run \`quilt status\` in a shell, or read
|
|
22
|
+
\`.quilt/STATUS.md\`.
|
|
23
|
+
- The session has a shared task board (To do, In progress, QA, Done). Read it with
|
|
24
|
+
\`quilt_tasks\`. Add work with \`quilt_add_task\`. Move a task to In progress
|
|
25
|
+
when you start it and to QA with \`qaNotes\` when you finish (\`quilt_move_task\`).
|
|
26
|
+
${TASK_WORKFLOW_MD}
|
|
27
|
+
- Read the project's rules and design system with \`quilt_project_rules\` before your
|
|
28
|
+
first change: the same rules whatever tool each person uses.
|
|
29
|
+
- See what a partner's AI is doing with \`quilt_partner_feed\`, and where people
|
|
30
|
+
are working with \`quilt_list_files\` (recent edits and claims).
|
|
31
|
+
- Announce what you're working on (\`quilt_set_focus\` / \`quilt focus "..."\`).
|
|
32
|
+
- Share your work with \`quilt_share\`: when you start a request (what was asked,
|
|
33
|
+
your plan) and when you finish (what you did, the files you changed). Partners
|
|
34
|
+
see it in their feed, it goes on the task board, and the host won't commit under you.
|
|
35
|
+
- Before you change files, call \`quilt_before_edit\` with their paths. It tells you
|
|
36
|
+
whether each one is yours to edit (claiming free ones for you) and shows what
|
|
37
|
+
people said about those files in chat. Don't edit a file it refuses.
|
|
38
|
+
- You're a member of your own in the session, apart from your person and their other
|
|
39
|
+
AI sessions, named after your work: "<their first name> · <label>", from your git
|
|
40
|
+
branch or the first thing you say you're doing. Rename yourself with
|
|
41
|
+
\`quilt_name_session\`. Your messages, inbox, claims and duties are your own. People
|
|
42
|
+
see all of a person's AI sessions as one, "<person>'s AI": write to another person's AI
|
|
43
|
+
by that name ("@Daniel's AI"), and what is written to your person's AI reaches whichever
|
|
44
|
+
of their sessions was active last (with none of them live, their cloud AI, if they
|
|
45
|
+
connected one: their own AI tool on their own cloud VM).
|
|
46
|
+
- Claims follow edits, whatever tool you are: the moment you change a file nobody
|
|
47
|
+
holds, Quilt claims it for you, and lets go when you finish (your AI goes idle, or
|
|
48
|
+
the file has been quiet for a few minutes). Claim ahead only for a larger change
|
|
49
|
+
across several files (\`quilt_claim\` / \`quilt claim <path>\`) and release it when
|
|
50
|
+
done (\`quilt_release\` / \`quilt release <path>\`).
|
|
51
|
+
- Your unfinished work is boxed: in a folder that is a git repository, a file you
|
|
52
|
+
change stays in a box until you commit it. Everyone sees the change, but it is only
|
|
53
|
+
in your folder, so nobody else's git can commit it. Committing it (or putting it
|
|
54
|
+
back) opens the box by itself; finishing or releasing does not. \`quilt_unpack\`
|
|
55
|
+
shares a box early when someone needs it: decide yourself, and ask your person first
|
|
56
|
+
for a large, risky or critical change or when the project's rules say so. A file
|
|
57
|
+
boxed by someone else isn't in your folder yet: don't build on it; ask for it with
|
|
58
|
+
\`quilt_request_file\`.
|
|
59
|
+
- If a file is claimed by someone else, your edit is refused or undone, and the next
|
|
60
|
+
quilt tool you call tells you so. Don't retry or work around it: ask for it in its
|
|
61
|
+
file queue (\`quilt_request_file\`: a title like "Working on X for task Y" and up to
|
|
62
|
+
300 characters on your plan), then carry on with other work. You're woken when it's
|
|
63
|
+
handed to you, with the holder's context.
|
|
64
|
+
- When someone waits in the queue for a file you hold, every quilt answer says so.
|
|
65
|
+
Finish your change, then hand it off with \`quilt_handoff\` and your context (what
|
|
66
|
+
you changed, what's left, gotchas). You can't finish or release it before. A claim
|
|
67
|
+
whose holder does nothing for 20 minutes goes to the next one waiting.
|
|
68
|
+
- Answer collaborators' messages (\`quilt_read_messages\`): help with their change,
|
|
69
|
+
or say when you'll be done. New ones are shown at the top of
|
|
70
|
+
every quilt answer. When you finish, call \`quilt_set_work\` with "done": it is
|
|
71
|
+
refused until everyone who wrote to you has an answer.
|
|
72
|
+
- \`quilt_inbox\` lists what is waiting for you: mentions of you (@yourname), direct
|
|
73
|
+
messages and tasks handed to you. Read it when you start and act on each one.
|
|
74
|
+
- Before moving a ticket to QA, run the checks under "Verifying a change" (in
|
|
75
|
+
AGENTS.md; add them there if the section is missing) and pass a change description
|
|
76
|
+
plus self-validation as \`qaNotes\` to \`quilt_move_task\`. QA without notes is refused.
|
|
77
|
+
Moving to Done still needs \`verified\`.
|
|
78
|
+
- Always re-read a file right before editing it; never rely on an old copy.
|
|
79
|
+
- Prefer small, focused edits over rewriting whole files.
|
|
80
|
+
- Git works as usual here: pull and push yourself, and commit only the files you
|
|
81
|
+
changed (never \`git add -A\` or \`git commit -a\`: the folder holds other people's
|
|
82
|
+
unfinished work). An agent's folder is a checkout Quilt made, and its git signs in
|
|
83
|
+
to GitHub through Quilt within the session owner's policy. Taking a task puts your
|
|
84
|
+
folder on a branch of your own for it (quilt/<you>/<task>); push it and Quilt opens
|
|
85
|
+
the pull request; Done merges it once checks pass (a person moves it to Done unless
|
|
86
|
+
the owner lets agents push anywhere). Pushes and merges on GitHub reach the session
|
|
87
|
+
within seconds, so never pull by hand. Quilt recognises git
|
|
88
|
+
commands, so a stash or reset on this computer never erases the session's
|
|
89
|
+
work (it comes back a moment later), a git conflict pauses this folder until
|
|
90
|
+
you resolve it and \`git add\` it, and checking out another branch moves this
|
|
91
|
+
folder to that branch's work in the session (the old branch keeps its own).
|
|
92
|
+
Deleting a file deletes it for everyone.
|
|
93
|
+
- If git refuses to pull because untracked files would be overwritten, those
|
|
94
|
+
files came from the session: \`quilt_status\` lists them under "Pulling" and
|
|
95
|
+
says whether they match the commits. Make way and pull with
|
|
96
|
+
\`rm <files> && git pull --autostash\`: Quilt keeps them for everyone and the
|
|
97
|
+
pull lands them (where they differ, Quilt merges the two afterwards).
|
|
98
|
+
${END}`
|
|
99
|
+
|
|
100
|
+
function upsertBlock (file, block) {
|
|
101
|
+
let text = ''
|
|
102
|
+
try { text = fs.readFileSync(file, 'utf8') } catch {}
|
|
103
|
+
const re = new RegExp(`${START}[\\s\\S]*?${END}`)
|
|
104
|
+
const next = re.test(text) ? text.replace(re, block) : (text ? text.replace(/\s*$/, '\n\n') : '') + block + '\n'
|
|
105
|
+
if (next !== text) fs.writeFileSync(file, next)
|
|
106
|
+
return next !== text
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Points a tool's own rules file (CLAUDE.md, GEMINI.md) at AGENTS.md, the one set of rules for
|
|
111
|
+
* every tool (project-rules.js): an "@AGENTS.md" line on top, which Claude Code and Gemini CLI
|
|
112
|
+
* read in. Quilt's guide block comes in with AGENTS.md, so it leaves this file; the rest of the
|
|
113
|
+
* file is the owner's and stays. `create`: write the file when it isn't there. True when written.
|
|
114
|
+
*/
|
|
115
|
+
export function pointAtAgents (file, { create = false } = {}) {
|
|
116
|
+
let text = null
|
|
117
|
+
try { text = fs.readFileSync(file, 'utf8') } catch {}
|
|
118
|
+
if (text === null && !create) return false
|
|
119
|
+
const own = (text || '').replace(new RegExp(`\\n*${START}[\\s\\S]*?${END}\\n*`), '\n').replace(/^\s+/, '')
|
|
120
|
+
const next = /^\s*@AGENTS\.md\s*$/m.test(own) ? own.replace(/\s*$/, '\n') : `@AGENTS.md\n${own.trim() ? `\n${own.replace(/\s*$/, '\n')}` : ''}`
|
|
121
|
+
if (next === text) return false
|
|
122
|
+
fs.writeFileSync(file, next)
|
|
123
|
+
return true
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Where Quilt's Claude Code hooks live: this person's own settings, which never sync or get committed. */
|
|
127
|
+
export const HOOKS_FILE = '.claude/settings.local.json'
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Puts Quilt's hooks into the project's .claude/settings.local.json, replacing earlier
|
|
131
|
+
* Quilt entries and leaving other hooks and settings alone. Returns true when the file changed.
|
|
132
|
+
*/
|
|
133
|
+
export function installHooks (root) {
|
|
134
|
+
const file = path.join(root, HOOKS_FILE)
|
|
135
|
+
let prev = ''
|
|
136
|
+
try { prev = fs.readFileSync(file, 'utf8') } catch {}
|
|
137
|
+
let json = {}
|
|
138
|
+
try { json = JSON.parse(prev) || {} } catch {}
|
|
139
|
+
if (typeof json !== 'object' || Array.isArray(json)) json = {}
|
|
140
|
+
const hooks = (json.hooks && typeof json.hooks === 'object' && !Array.isArray(json.hooks)) ? json.hooks : {}
|
|
141
|
+
const ours = isQuiltHook
|
|
142
|
+
for (const [event, entries] of Object.entries(hookSettings())) {
|
|
143
|
+
const kept = (Array.isArray(hooks[event]) ? hooks[event] : [])
|
|
144
|
+
.map((e) => (e && Array.isArray(e.hooks) ? { ...e, hooks: e.hooks.filter((h) => !ours(h)) } : e))
|
|
145
|
+
.filter((e) => e && (!Array.isArray(e.hooks) || e.hooks.length))
|
|
146
|
+
hooks[event] = [...kept, ...entries]
|
|
147
|
+
}
|
|
148
|
+
json.hooks = hooks
|
|
149
|
+
const text = JSON.stringify(json, null, 2) + '\n'
|
|
150
|
+
if (prev === text) return false
|
|
151
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
152
|
+
fs.writeFileSync(file, text)
|
|
153
|
+
return true
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Adds the "Verifying a change" template to AGENTS.md when neither guide has that
|
|
158
|
+
* section yet, outside Quilt's block so owners can edit it. Returns true when written.
|
|
159
|
+
*/
|
|
160
|
+
export function scaffoldChecklist (root) {
|
|
161
|
+
const read = (f) => { try { return fs.readFileSync(path.join(root, f), 'utf8') } catch { return '' } }
|
|
162
|
+
if (extractChecklist(read('AGENTS.md')) || extractChecklist(read('CLAUDE.md'))) return false
|
|
163
|
+
const file = path.join(root, 'AGENTS.md')
|
|
164
|
+
const text = read('AGENTS.md')
|
|
165
|
+
fs.writeFileSync(file, (text ? text.replace(/\s*$/, '\n\n') : '') + CHECKLIST_SCAFFOLD)
|
|
166
|
+
return true
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function setup (root, { home } = {}) {
|
|
170
|
+
const changed = []
|
|
171
|
+
// MCP servers go in each AI tool's own settings on this computer (with this install's path),
|
|
172
|
+
// not in the project, whose files sync to everyone. See integrations.js.
|
|
173
|
+
for (const r of registerEverywhere(home ? { home } : {})) {
|
|
174
|
+
if (r.result === 'added' || r.result === 'updated') changed.push(`${r.file} (${r.name} MCP server)`)
|
|
175
|
+
else if (r.result === 'skipped' || r.result === 'failed') changed.push(`${r.file}: left alone, not plain JSON (${r.name})`)
|
|
176
|
+
}
|
|
177
|
+
if (installHooks(root)) changed.push(`${HOOKS_FILE} (Claude Code hooks: files are claimed as you edit them)`)
|
|
178
|
+
if (upsertBlock(path.join(root, 'AGENTS.md'), AGENT_GUIDE)) changed.push('AGENTS.md (Cursor, Codex, and other agents)')
|
|
179
|
+
// Claude Code and Gemini CLI read AGENTS.md through a pointer, so every tool has the same rules.
|
|
180
|
+
if (pointAtAgents(path.join(root, 'CLAUDE.md'), { create: true })) changed.push('CLAUDE.md (Claude Code: reads AGENTS.md)')
|
|
181
|
+
if (pointAtAgents(path.join(root, 'GEMINI.md'))) changed.push('GEMINI.md (Gemini CLI: reads AGENTS.md)')
|
|
182
|
+
if (scaffoldChecklist(root)) changed.push('AGENTS.md ("Verifying a change": fill in what proves a change works here)')
|
|
183
|
+
return changed
|
|
184
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// An app opened from Finder or the Dock gets a minimal PATH (/usr/bin:/bin:/usr/sbin:/sbin),
|
|
2
|
+
// so git from Homebrew or Xcode's tools isn't found. The PATH your login shell sets up is
|
|
3
|
+
// read once at startup and put in front of it.
|
|
4
|
+
import { execFile } from 'node:child_process'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
|
|
7
|
+
const MARK = '__QUILT_PATH__'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The shell's PATH entries first, then the current ones, each once, in order;
|
|
11
|
+
* on macOS and Linux (`sep` ":"), /usr/bin is always there (the system git lives in it).
|
|
12
|
+
*/
|
|
13
|
+
export function mergePath (shellPath, currentPath, sep = path.delimiter) {
|
|
14
|
+
const seen = new Set()
|
|
15
|
+
const out = []
|
|
16
|
+
for (const dir of [...String(shellPath || '').split(sep), ...String(currentPath || '').split(sep), ...(sep === ':' ? ['/usr/bin'] : [])]) {
|
|
17
|
+
if (!dir || seen.has(dir)) continue
|
|
18
|
+
seen.add(dir)
|
|
19
|
+
out.push(dir)
|
|
20
|
+
}
|
|
21
|
+
return out.join(sep)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The PATH printed between two markers (a shell's startup files may print their own lines), or null. */
|
|
25
|
+
export function pathFromOutput (out) {
|
|
26
|
+
const m = new RegExp(`${MARK}(.*?)${MARK}`, 's').exec(String(out || ''))
|
|
27
|
+
return m && m[1].trim() ? m[1].trim() : null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Your login shell's PATH, or null when it can't be read within `timeoutMs`. Never rejects. */
|
|
31
|
+
export function loginShellPath ({ shell = process.env.SHELL || '/bin/zsh', timeoutMs = 3000 } = {}) {
|
|
32
|
+
if (process.platform === 'win32') return Promise.resolve(null)
|
|
33
|
+
return new Promise((resolve) => {
|
|
34
|
+
execFile(shell, ['-ilc', `echo "${MARK}\${PATH}${MARK}"`], { timeout: timeoutMs, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }, (err, stdout) => {
|
|
35
|
+
resolve(err ? null : pathFromOutput(stdout))
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Sets process.env.PATH to your login shell's PATH merged with the current one. Not on Windows: its PATH is left as it is. */
|
|
41
|
+
export async function adoptLoginShellPath (opts) {
|
|
42
|
+
if (process.platform === 'win32') return process.env.PATH
|
|
43
|
+
const shellPath = await loginShellPath(opts)
|
|
44
|
+
process.env.PATH = mergePath(shellPath, process.env.PATH)
|
|
45
|
+
return process.env.PATH
|
|
46
|
+
}
|
package/src/status.js
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// Renders a session status snapshot as Markdown. Used for .quilt/STATUS.md,
|
|
2
|
+
// `quilt status`, and the MCP `quilt_status` tool, so every tool sees the same view.
|
|
3
|
+
|
|
4
|
+
import { branchesMarkdown } from './branches.js'
|
|
5
|
+
import { taskMarkdown } from './tasks.js'
|
|
6
|
+
import { mergeAction } from './merges.js'
|
|
7
|
+
import { catchUpMarkdown } from './catchup.js'
|
|
8
|
+
import { DEFAULT_KEY } from './branchdocs.js'
|
|
9
|
+
|
|
10
|
+
/** A shell word for a path (quoted only when it needs to be). */
|
|
11
|
+
const shellWord = (p) => /^[\w./@+-]+$/.test(p) ? p : `'${p.replace(/'/g, "'\\''")}'`
|
|
12
|
+
|
|
13
|
+
/** The one line that makes way for a pull over files the session put here, and pulls. */
|
|
14
|
+
export function makeWayCommand (paths) {
|
|
15
|
+
return `rm ${paths.map(shellWord).join(' ')} && git pull --autostash`
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* What to tell a person's AI when the upstream adds files the session already
|
|
20
|
+
* put in this folder (git refuses to pull over untracked files). `adds`: [{ path, same, waiting }].
|
|
21
|
+
*/
|
|
22
|
+
export function pullAdvice ({ upstream, behind, adds }) {
|
|
23
|
+
const inWay = adds.filter((a) => !a.waiting)
|
|
24
|
+
const differ = adds.filter((a) => !a.same)
|
|
25
|
+
const list = (xs) => xs.map((a) => a.path).join(', ')
|
|
26
|
+
const lines = [`${upstream} has ${behind} commit${behind === 1 ? '' : 's'} adding ${list(adds)}, which the session already put in this folder${differ.length ? '' : ' with the same content'}.`]
|
|
27
|
+
if (inWay.length) lines.push(`git won't pull over untracked files: make way and pull with \`${makeWayCommand(inWay.map((a) => a.path))}\`. Quilt keeps them for everyone and doesn't share the removal (a plain delete at any other time deletes for everyone).`)
|
|
28
|
+
else lines.push('They are out of the way: pull now (`git pull --autostash`). Quilt keeps them for everyone meanwhile.')
|
|
29
|
+
for (const a of differ) lines.push(`${a.path} differs from the session's: after the pull Quilt merges the two (a merge record if they clash).`)
|
|
30
|
+
return lines.join(' ')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const ago = (ts) => {
|
|
34
|
+
const s = Math.max(0, Math.round((Date.now() - ts) / 1000))
|
|
35
|
+
if (s < 60) return `${s}s ago`
|
|
36
|
+
if (s < 3600) return `${Math.round(s / 60)}m ago`
|
|
37
|
+
return `${Math.round(s / 3600)}h ago`
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function renderStatus (st, { asAi = false, mentionYours = false } = {}) {
|
|
41
|
+
const out = []
|
|
42
|
+
out.push(`# Quilt pair session: room \`${st.room}\``)
|
|
43
|
+
out.push('')
|
|
44
|
+
out.push(`Relay: ${st.connected ? 'connected' : '**disconnected** (edits are kept and will sync on reconnect)'} · ${st.fileCount} shared files`)
|
|
45
|
+
if (!st.connected && st.problem) out.push(`⚠️ ${st.problem}`)
|
|
46
|
+
out.push(`You: **${st.me.name}** (${st.me.tool})${st.me.persona ? `, one of ${st.me.of}'s AI sessions` : ''}${st.me.focus ? ` · focus: ${st.me.focus}` : ''}`)
|
|
47
|
+
if (st.workspaceName || st.workspace) out.push(`Workspace: ${st.workspaceName || st.workspace}`)
|
|
48
|
+
out.push('')
|
|
49
|
+
|
|
50
|
+
const away = catchUpMarkdown(st.catchUp, { ago })
|
|
51
|
+
if (away.length) out.push(...away, '')
|
|
52
|
+
|
|
53
|
+
out.push('## Partners online')
|
|
54
|
+
if (!st.peers.length) out.push('_Nobody else is connected right now._')
|
|
55
|
+
for (const p of st.peers.filter((x) => x.name !== st.me.name)) {
|
|
56
|
+
const tools = [p.tool, ...(p.agents || [])].filter((t) => t && t !== 'unknown')
|
|
57
|
+
const kind = p.persona ? ` [AI session of ${p.mine && st.me.persona ? 'your person' : p.of}]` : p.kind === 'agent' ? ' [AI agent]' : ''
|
|
58
|
+
out.push(`- **${p.name}**${kind}${tools.length ? ` (${[...new Set(tools)].join(', ')})` : ''}${p.focus ? `: working on: ${p.focus}` : ''}`)
|
|
59
|
+
const ai = aiLine(p.agent)
|
|
60
|
+
if (ai) out.push(` - AI: ${ai}`)
|
|
61
|
+
const editing = p.editing.slice(0, 8)
|
|
62
|
+
if (editing.length) out.push(` - recently edited: ${editing.map((e) => `\`${e.path}\` (${e.secondsAgo}s ago)`).join(', ')}`)
|
|
63
|
+
}
|
|
64
|
+
out.push('')
|
|
65
|
+
if (st.away && st.away.length) {
|
|
66
|
+
out.push('## Away')
|
|
67
|
+
out.push('_Agents that work in turns, between turns: write to them as usual, they read it at the start of their next turn._')
|
|
68
|
+
for (const a of st.away) out.push(`- **${a.name}** [AI agent]: last here ${ago(a.lastSeen)}`)
|
|
69
|
+
out.push('')
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
out.push('## Tasks')
|
|
73
|
+
out.push(taskMarkdown(st.tasks, st.me.name, { tool: st.me.tool, asAi, mentionYours }))
|
|
74
|
+
out.push('')
|
|
75
|
+
|
|
76
|
+
if (st.branches && st.branches.length) {
|
|
77
|
+
out.push('## Branches')
|
|
78
|
+
out.push(branchesMarkdown(st.branches, { limit: 6 }))
|
|
79
|
+
out.push('')
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const openCommits = (st.commits || []).filter((r) => r.state === 'open')
|
|
83
|
+
if (openCommits.length) {
|
|
84
|
+
out.push('## Commit requests')
|
|
85
|
+
for (const r of openCommits) {
|
|
86
|
+
const who = r.by === st.me.name ? 'you' : r.by
|
|
87
|
+
const branch = r.branch && r.branch !== DEFAULT_KEY ? ` on \`${r.branch}\`` : ''
|
|
88
|
+
out.push(`- [${r.id}] ${ago(r.ts)}: ${who} asked for a commit${branch}: ${r.message}${r.error ? `. Last try: ${r.error}` : ''}`)
|
|
89
|
+
if (Array.isArray(r.files) && r.files.length) out.push(` Files: ${r.files.slice(0, 20).join(', ')}${r.files.length > 20 ? `, and ${r.files.length - 20} more` : ''}`)
|
|
90
|
+
if (r.description) out.push(` ${String(r.description).replace(/\s+/g, ' ')}`)
|
|
91
|
+
}
|
|
92
|
+
if (st.canCommit) out.push('This folder has git: commit one in the app (Commit and push), or by hand and close it with quilt_commit_request_done.')
|
|
93
|
+
out.push('')
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const pull = st.git && st.git.pull
|
|
97
|
+
if (pull && pull.adds && pull.adds.length) {
|
|
98
|
+
out.push('## Pulling')
|
|
99
|
+
out.push(pullAdvice(pull))
|
|
100
|
+
for (const a of pull.adds) out.push(`- \`${a.path}\`: ${a.waiting ? 'out of the way, waiting for your pull' : a.same ? 'same content as the session\'s' : 'differs from the session\'s'}`)
|
|
101
|
+
out.push('')
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Boxes (docs/superpowers/specs/2026-10-16-git-boxes-design.md): unfinished work only in its owner's folder.
|
|
105
|
+
const boxed = st.claims.filter((c) => c.box)
|
|
106
|
+
if (boxed.length) {
|
|
107
|
+
out.push('## Boxed work')
|
|
108
|
+
for (const c of boxed) out.push(c.by === st.me.name ? `- \`${c.pattern}\`: your unfinished work: in your folder only, until you commit it (or quilt_unpack)` : `- \`${c.pattern}\`: ${c.by}'s unfinished work: not in your folder until they commit or unpack it`)
|
|
109
|
+
out.push('')
|
|
110
|
+
}
|
|
111
|
+
const plain = st.claims.filter((c) => !c.box)
|
|
112
|
+
out.push('## Claimed files')
|
|
113
|
+
if (!plain.length) out.push('_No claims._')
|
|
114
|
+
for (const c of plain) {
|
|
115
|
+
const who = c.by === st.me.name ? 'you' : c.by
|
|
116
|
+
out.push(`- \`${c.pattern}\`: ${who}${c.note ? `: ${c.note}` : ''} (${ago(c.ts)})`)
|
|
117
|
+
}
|
|
118
|
+
out.push('')
|
|
119
|
+
|
|
120
|
+
const merges = (st.merges || []).filter((m) => m.state !== 'done')
|
|
121
|
+
if (merges.length) {
|
|
122
|
+
out.push('## Merges to settle')
|
|
123
|
+
for (const m of merges) {
|
|
124
|
+
const who = m.by === st.me.name ? 'you' : m.by
|
|
125
|
+
const otherName = m.others[0] ? (m.others[0] === st.me.name ? 'you' : m.others[0]) : null
|
|
126
|
+
const action = mergeAction(m)
|
|
127
|
+
const theirsPart = m.theirsHash === null ? ', it was deleted in the session' : otherName ? `, ${otherName} changed it in the session` : ''
|
|
128
|
+
out.push(`- \`${m.path}\` (id ${m.id}): ${who} ${action}${theirsPart}${m.kind === 'ai' ? '; merged by AI, needs a look' : ''}. See quilt_merges.`)
|
|
129
|
+
}
|
|
130
|
+
out.push('')
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
out.push('## Recent activity')
|
|
134
|
+
const act = st.activity.slice(-15).reverse()
|
|
135
|
+
if (!act.length) out.push('_Nothing yet._')
|
|
136
|
+
for (const a of act) {
|
|
137
|
+
const who = a.by === st.me.name ? 'you' : a.by
|
|
138
|
+
if (a.kind === 'pulled') out.push(`- ${ago(a.ts)}: ${who} pulled ${a.detail || 'commits'}`)
|
|
139
|
+
else if (a.kind === 'brought') out.push(`- ${ago(a.ts)}: ${who} brought in ${a.detail || 'commits'}${a.branch ? ` on \`${a.branch}\`` : ''}`)
|
|
140
|
+
else if (a.kind === 'switched') out.push(`- ${ago(a.ts)}: ${who} switched to \`${a.branch}\``)
|
|
141
|
+
else out.push(`- ${ago(a.ts)}: ${who} ${a.kind} \`${a.path}\`${a.detail ? ` (${a.detail})` : ''}`)
|
|
142
|
+
}
|
|
143
|
+
out.push('')
|
|
144
|
+
|
|
145
|
+
out.push('## Changes')
|
|
146
|
+
const changes = st.changes || []
|
|
147
|
+
if (!changes.length) out.push('_No changes yet._')
|
|
148
|
+
for (const p of changes) {
|
|
149
|
+
const who = p.name === st.me.name ? 'you' : `**${p.name}**`
|
|
150
|
+
const line = (g, label) => {
|
|
151
|
+
const n = g.fileCount ?? g.files.length
|
|
152
|
+
const files = g.files.map((f) => `\`${f.path}\` (${fileChange(f)})`)
|
|
153
|
+
if (n > g.files.length) files.push(`and ${n - g.files.length} more`)
|
|
154
|
+
out.push(`- ${who}${label}: ${n} file${n === 1 ? '' : 's'}, +${g.added} -${g.removed} (${ago(g.ts)}): ${files.join(', ')}`)
|
|
155
|
+
}
|
|
156
|
+
// Their own edits, then what their git pulls brought (other people's commits), never mixed.
|
|
157
|
+
if ((p.fileCount ?? p.files.length) || !p.pulled) line(p, '')
|
|
158
|
+
if (p.pulled) line(p.pulled, ' pulled from git')
|
|
159
|
+
}
|
|
160
|
+
out.push('')
|
|
161
|
+
|
|
162
|
+
out.push(`## Messages${st.unread ? ` (${st.unread} unread)` : ''}`)
|
|
163
|
+
if (!st.chat.length) out.push('_No messages._')
|
|
164
|
+
for (const m of st.chat.slice(-10)) out.push(`- ${renderMessage(m, st.me.name)}`)
|
|
165
|
+
out.push('')
|
|
166
|
+
return out.join('\n')
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function aiLine (a) {
|
|
170
|
+
if (!a) return ''
|
|
171
|
+
if (a.sharing === false) return 'sharing paused'
|
|
172
|
+
if (a.status === 'unavailable') return `${a.tool || 'feed'} unavailable`
|
|
173
|
+
if (!a.tool) return ''
|
|
174
|
+
return `${a.tool} ${a.status === 'working' ? 'is working' : 'idle'} (see quilt_partner_feed)`
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function renderMessage (m, me) {
|
|
178
|
+
const from = m.by === me ? 'you' : m.by
|
|
179
|
+
const to = m.to ? ` → ${m.to === me ? 'you' : m.to} (direct, id ${m.id}${m.re ? `, answers ${m.re}` : ''})` : ''
|
|
180
|
+
let line = `${ago(m.ts)} ${m.unread ? '🆕 ' : ''}**${from}${to}:** ${m.text}`
|
|
181
|
+
if (m.file) {
|
|
182
|
+
line += ` 📎 \`${m.file.name}\` (${formatBytes(m.file.size)})`
|
|
183
|
+
line += m.file.localPath ? `, saved at \`${m.file.localPath}\`` : `, id \`${m.id}\``
|
|
184
|
+
}
|
|
185
|
+
return line
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function formatBytes (n) {
|
|
189
|
+
if (n < 1024) return `${n} B`
|
|
190
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
|
|
191
|
+
return `${(n / 1024 / 1024).toFixed(1)} MB`
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** One file's change in a few words: "deleted", "new, +5" or "+12 -1". */
|
|
195
|
+
function fileChange (f) {
|
|
196
|
+
if (f.kind === 'deleted') return 'deleted'
|
|
197
|
+
if (f.kind === 'created') return `new, +${f.added}${f.removed ? ` -${f.removed}` : ''}`
|
|
198
|
+
return `+${f.added} -${f.removed}`
|
|
199
|
+
}
|
package/src/summarize.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Summaries of your AI chat, made on your computer before anything is
|
|
2
|
+
// shared: your prompts and your AI's replies become a sentence or two. Uses
|
|
3
|
+
// the Claude Code CLI (`claude -p`) when it's installed and signed in, and
|
|
4
|
+
// falls back to shortening the text when it isn't.
|
|
5
|
+
import { spawn } from 'node:child_process'
|
|
6
|
+
import os from 'node:os'
|
|
7
|
+
|
|
8
|
+
const INSTRUCTIONS = {
|
|
9
|
+
prompt: 'Summarize this request a developer gave their AI coding assistant in one plain sentence of at most 20 words. Output only the sentence.',
|
|
10
|
+
reply: 'Summarize this reply from an AI coding assistant in one or two plain sentences, at most 35 words in total. Mention files or decisions if there are any. Output only the summary.',
|
|
11
|
+
task: 'Name the coding task in this request as a short to-do title: 2 to 6 words, starting with a verb, like "Add dark mode" or "Fix login redirect". Output only the title.'
|
|
12
|
+
}
|
|
13
|
+
const MAX_TASK_TITLE = 80
|
|
14
|
+
const SHORT_ENOUGH = 140 // characters: already short, share as is
|
|
15
|
+
const TIMEOUT_MS = 45000
|
|
16
|
+
const RETRY_AFTER_MS = 10 * 60 * 1000 // after the CLI fails, shorten instead for a while
|
|
17
|
+
|
|
18
|
+
/** The first sentence (or first `max` characters) of text. */
|
|
19
|
+
export function shorten (text, max = 160) {
|
|
20
|
+
const flat = String(text || '').replace(/```[\s\S]*?```/g, ' [code] ').replace(/\s+/g, ' ').trim()
|
|
21
|
+
const sentence = flat.match(/^.*?[.!?](\s|$)/)
|
|
22
|
+
const first = sentence && sentence[0].length >= 20 ? sentence[0].trim() : flat
|
|
23
|
+
if (first.length <= max) return first
|
|
24
|
+
const cut = first.slice(0, max - 1)
|
|
25
|
+
return `${cut.slice(0, cut.lastIndexOf(' ') > max / 2 ? cut.lastIndexOf(' ') : cut.length).replace(/[\s,;:]+$/, '')}…`
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Runs a command with text on stdin (in a temp folder, so it never shows up as a chat in the project). */
|
|
29
|
+
function runCli (cmd, args, input) {
|
|
30
|
+
return new Promise((resolve, reject) => {
|
|
31
|
+
const child = spawn(cmd, args, { cwd: os.tmpdir(), env: process.env, stdio: ['pipe', 'pipe', 'pipe'] })
|
|
32
|
+
let out = ''
|
|
33
|
+
let err = ''
|
|
34
|
+
const timer = setTimeout(() => { child.kill('SIGKILL'); reject(new Error('timed out')) }, TIMEOUT_MS)
|
|
35
|
+
child.stdout.on('data', (d) => { out += d })
|
|
36
|
+
child.stderr.on('data', (d) => { err += d })
|
|
37
|
+
child.on('error', (e) => { clearTimeout(timer); reject(e.code === 'ENOENT' ? new Error(`${cmd} is not installed`) : e) })
|
|
38
|
+
child.on('close', (code) => {
|
|
39
|
+
clearTimeout(timer)
|
|
40
|
+
if (code === 0 && out.trim()) resolve(out.trim())
|
|
41
|
+
else reject(new Error((err || out).trim().split('\n').pop() || `${cmd} exited with ${code}`))
|
|
42
|
+
})
|
|
43
|
+
child.stdin.end(input)
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The summarizing command: QUILT_SUMMARY_CMD (for tests or other tools) or Claude Code's CLI. */
|
|
48
|
+
function command (kind) {
|
|
49
|
+
const custom = process.env.QUILT_SUMMARY_CMD
|
|
50
|
+
if (custom) {
|
|
51
|
+
const [cmd, ...args] = custom.split(' ').filter(Boolean)
|
|
52
|
+
return [cmd, [...args, INSTRUCTIONS[kind]]]
|
|
53
|
+
}
|
|
54
|
+
return ['claude', ['-p', '--model', 'haiku', '--no-session-persistence', INSTRUCTIONS[kind]]]
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Returns summarize(kind, text) -> Promise<{ text, how: 'ai'|'shortened'|'as-is' }>.
|
|
59
|
+
* `onWarn` hears (once per failure spell) why summaries fell back to shortening.
|
|
60
|
+
*/
|
|
61
|
+
export function createSummarizer ({ onWarn = () => {}, run = runCli } = {}) {
|
|
62
|
+
let brokenUntil = 0
|
|
63
|
+
return async function summarize (kind, text) {
|
|
64
|
+
text = String(text || '')
|
|
65
|
+
if (!INSTRUCTIONS[kind]) return { text, how: 'as-is' }
|
|
66
|
+
if (text.length <= SHORT_ENOUGH) return { text, how: 'as-is' }
|
|
67
|
+
if (Date.now() >= brokenUntil) {
|
|
68
|
+
try {
|
|
69
|
+
const [cmd, args] = command(kind)
|
|
70
|
+
const out = await run(cmd, args, text.slice(0, 20000))
|
|
71
|
+
return { text: out.replace(/\s+/g, ' ').trim().slice(0, 400), how: 'ai' }
|
|
72
|
+
} catch (err) {
|
|
73
|
+
brokenUntil = Date.now() + RETRY_AFTER_MS
|
|
74
|
+
onWarn(/auth|log ?in|sign ?in|OAuth/i.test(err.message)
|
|
75
|
+
? 'Summaries are shortened for now: your claude CLI isn\'t signed in (run `claude` once in a terminal to sign in).'
|
|
76
|
+
: `Summaries are shortened for now: ${err.message}`)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return { text: shorten(text), how: 'shortened' }
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** One clean line from the model: no quotes, "Title:" label or trailing period. Empty when nothing is left. */
|
|
84
|
+
export function cleanTaskTitle (out) {
|
|
85
|
+
const line = String(out || '').split(/\r?\n/).map((s) => s.trim()).find(Boolean) || ''
|
|
86
|
+
return line.replace(/^(task|title)\s*:\s*/i, '').replace(/^["'`*]+|["'`*]+$/g, '').replace(/[.!]+$/, '').trim().slice(0, MAX_TASK_TITLE)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Returns taskTitle(request, files) -> Promise<string|null>: a short title for an
|
|
91
|
+
* auto-created task ("Add dark mode"), or null when the CLI isn't available.
|
|
92
|
+
* Unlike summaries, short requests are titled too: "okay, go ahead" is short but no title.
|
|
93
|
+
*/
|
|
94
|
+
export function createTaskTitler ({ onWarn = () => {}, run = runCli } = {}) {
|
|
95
|
+
let brokenUntil = 0
|
|
96
|
+
return async function taskTitle (request, files = []) {
|
|
97
|
+
const text = String(request || '').trim()
|
|
98
|
+
if (!text || Date.now() < brokenUntil) return null
|
|
99
|
+
const input = files.length ? `${text.slice(0, 4000)}\n\nFiles it changed: ${files.slice(0, 10).join(', ')}` : text.slice(0, 4000)
|
|
100
|
+
try {
|
|
101
|
+
const [cmd, args] = command('task')
|
|
102
|
+
return cleanTaskTitle(await run(cmd, args, input)) || null
|
|
103
|
+
} catch (err) {
|
|
104
|
+
brokenUntil = Date.now() + RETRY_AFTER_MS
|
|
105
|
+
onWarn(`Task titles use the start of your prompt for now: ${err.message}`)
|
|
106
|
+
return null
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Where each branch stands with GitHub (relay-sync.js syncStatus), in words for agents: the
|
|
2
|
+
// quilt_sync_status tool, hosted (relay-mcp.js) and in a folder (mcp.js), from the branch list.
|
|
3
|
+
|
|
4
|
+
const short = (sha) => (typeof sha === 'string' && sha ? sha.slice(0, 7) : '')
|
|
5
|
+
const plural = (n, w) => `${n} ${w}${n === 1 ? '' : 's'}`
|
|
6
|
+
|
|
7
|
+
/** One line for branch entry `b` ({ key, relay: { repo, sync, ... } }), or null when it doesn't follow GitHub. */
|
|
8
|
+
export function syncLine (b) {
|
|
9
|
+
const r = b && b.relay
|
|
10
|
+
const s = r && r.sync
|
|
11
|
+
if (!s) return null
|
|
12
|
+
const repo = String(r.repo || '').replace(/^github\.com\//, '')
|
|
13
|
+
const at = s.revision ? ` at ${short(s.revision)}` : ''
|
|
14
|
+
const head = `${b.key}${repo ? ` (${repo})` : ''}`
|
|
15
|
+
switch (s.state) {
|
|
16
|
+
case 'synced': return `${head}: in step with GitHub${at}; nothing uncommitted.`
|
|
17
|
+
case 'github-pending': return `${head}: has GitHub's latest${at}; ${plural(s.pending || 0, 'file')} changed in the session and not committed (quilt_git_commit commits yours).`
|
|
18
|
+
case 'conflict-held': return `${head}: ${s.why || `${plural(s.held || 0, 'file')} held for a merge`}${at}; the merger proposes with quilt_clash (quilt_clash status lists them).`
|
|
19
|
+
case 'pr-pending': return `${head}: ${r.leased ? 'another session holds this branch' : 'protected on GitHub'}, so commits go through ${s.pr && s.pr.number ? `pull request #${s.pr.number}${s.pr.url ? ` (${s.pr.url})` : ''}` : 'a pull request (opened with the next commit)'}${at}.`
|
|
20
|
+
case 'offline': return `${head}: GitHub unreachable${s.since ? ` since ${new Date(s.since).toISOString().slice(11, 16)} UTC` : ''}; the session keeps working, and commits asked for go when it answers.`
|
|
21
|
+
case 'permission-held': return `${head}: ${s.why || 'GitHub refused the relay'}.`
|
|
22
|
+
default: return `${head}: not syncing with GitHub (${s.why || 'paused'}).`
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The files of a branch that are GitHub's version, not edited in the session since: one line, or ''. */
|
|
27
|
+
export function fromGithubLine (s) {
|
|
28
|
+
const files = s && Array.isArray(s.fromGithubFiles) ? s.fromGithubFiles : []
|
|
29
|
+
if (!files.length) return ''
|
|
30
|
+
const more = (s.fromGithub || files.length) - files.length
|
|
31
|
+
return ` From GitHub, not edited here since: ${files.map((f) => `${f.path} (${short(f.sha)}${f.by ? ` by ${f.by}` : ''})`).join(', ')}${more > 0 ? `, and ${more} more` : ''}.`
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Every branch that follows GitHub, the caller's first: one line each. */
|
|
35
|
+
export function syncStatusText (list, { mine = '' } = {}) {
|
|
36
|
+
const rows = (Array.isArray(list) ? list : []).filter((b) => b && b.relay && b.relay.sync)
|
|
37
|
+
if (!rows.length) return 'No branch of this session syncs with GitHub (none follows a GitHub repository, or the relay has no git sync).'
|
|
38
|
+
rows.sort((a, b) => (a.key === mine ? -1 : b.key === mine ? 1 : 0))
|
|
39
|
+
return rows.map((b) => [syncLine(b), fromGithubLine(b.relay.sync)].filter(Boolean).join('\n')).filter(Boolean).join('\n')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const SYNC_STATUS_DESCRIPTION = 'Where each branch of the session stands with GitHub: in step (with the commit it is at), files changed and not committed, files held for a merge, commits going through a pull request (a protected branch, or another session holds it), GitHub unreachable, or sync paused and why. Pushes and merges on GitHub come in by themselves, and the files that are GitHub\'s version, not edited here since, are listed with the commit that brought them; nothing goes to GitHub until someone commits.'
|