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
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
// `quilt agent join|whoami`: join Quilt as an agent from a terminal, the way an
|
|
2
|
+
// AI uses an invite link: send a short profile to the link, get keys back, and
|
|
3
|
+
// keep them in ~/.quilt/agents/<name>.json (readable only by you). An agent whose home
|
|
4
|
+
// folder is wiped between turns (a hosted sandbox) keeps them in the project's .quilt/
|
|
5
|
+
// instead (--here), and is found there from anywhere in the project.
|
|
6
|
+
import fs from 'node:fs'
|
|
7
|
+
import os from 'node:os'
|
|
8
|
+
import path from 'node:path'
|
|
9
|
+
import crypto from 'node:crypto'
|
|
10
|
+
import { quiltHome } from './legacy.js'
|
|
11
|
+
import { generateIdentity, signAgentResume } from './identity.js'
|
|
12
|
+
import { writePrivateJson } from './private-file.js'
|
|
13
|
+
import { encodeAgentEnvKey, decodeAgentEnvKey } from './ui/agent-env-key.js'
|
|
14
|
+
|
|
15
|
+
export const DEFAULTS = { provider: 'Quilt CLI', type: 'command-line agent' }
|
|
16
|
+
const NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,39}$/
|
|
17
|
+
const NOT_A_LINK = "That doesn't look like an agent invite link. Copy the whole link from Quilt."
|
|
18
|
+
// Refresh a little early so the access key doesn't lapse mid-request.
|
|
19
|
+
const EARLY_MS = 60 * 1000
|
|
20
|
+
// A refresh lock is stale once the process holding it is gone; as a safety net, also once
|
|
21
|
+
// it is this old (a live holder never needs this long: its refresh times out first).
|
|
22
|
+
const LOCK_MAX_AGE_MS = 5 * 60 * 1000
|
|
23
|
+
const LOCK_RETRY_MS = 50
|
|
24
|
+
// A refresh the API has already done can't be undone: cutting it off loses the new keys,
|
|
25
|
+
// and the spent refresh key later revokes the agent. So it gets a long time to answer.
|
|
26
|
+
const REFRESH_TIMEOUT_MS = 120 * 1000
|
|
27
|
+
// How long to wait for another process's refresh before giving up: longer than a refresh can take.
|
|
28
|
+
const LOCK_WAIT_MS = 150 * 1000
|
|
29
|
+
/** For tests: how the refresh and lock timeouts nest. */
|
|
30
|
+
export const REFRESH_TIMING = Object.freeze({ refreshTimeoutMs: REFRESH_TIMEOUT_MS, lockWaitMs: LOCK_WAIT_MS, lockMaxAgeMs: LOCK_MAX_AGE_MS })
|
|
31
|
+
|
|
32
|
+
export function agentFile (name, dir) {
|
|
33
|
+
if (!NAME.test(String(name || ''))) throw new Error('--name must be 1 to 40 letters, numbers, dots, dashes or underscores')
|
|
34
|
+
return path.join(dir || agentsHome(name), 'agents', `${name}.json`)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The folders at and above `from`, up to (not including) your home folder. */
|
|
38
|
+
function upFrom (from) {
|
|
39
|
+
const home = path.resolve(os.homedir())
|
|
40
|
+
const dirs = []
|
|
41
|
+
for (let d = path.resolve(from); d !== home; d = path.dirname(d)) {
|
|
42
|
+
dirs.push(d)
|
|
43
|
+
if (path.dirname(d) === d) break
|
|
44
|
+
}
|
|
45
|
+
return dirs
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The project's .quilt holding agents saved with --here, at or above `from`; null when there is none. */
|
|
49
|
+
export function projectAgentsHome (from = process.cwd()) {
|
|
50
|
+
for (const d of upFrom(from)) if (fs.existsSync(path.join(d, '.quilt', 'agents'))) return path.join(d, '.quilt')
|
|
51
|
+
return null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Where an agent's keys are: the project's .quilt when it was saved there, else ~/.quilt. */
|
|
55
|
+
function agentsHome (name) {
|
|
56
|
+
const here = projectAgentsHome()
|
|
57
|
+
return here && fs.existsSync(path.join(here, 'agents', `${name}.json`)) ? here : quiltHome()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]'])
|
|
61
|
+
|
|
62
|
+
/** The API's address and the token in an invite link. Plain http is only
|
|
63
|
+
* allowed for a local address (testing); anything else must be https. */
|
|
64
|
+
export function parseJoinLink (link) {
|
|
65
|
+
let u
|
|
66
|
+
try { u = new URL(String(link)) } catch { throw new Error(NOT_A_LINK) }
|
|
67
|
+
const m = u.pathname.match(/^\/v1\/join\/(qj_[A-Za-z0-9_-]+)\/?$/)
|
|
68
|
+
const protoOk = u.protocol === 'https:' || (u.protocol === 'http:' && LOCAL_HOSTS.has(u.hostname))
|
|
69
|
+
if (!m || !protoOk) throw new Error(NOT_A_LINK)
|
|
70
|
+
return { api: u.origin, token: m[1] }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function send (fetchImpl, api, method, route, body, key, extra = {}) {
|
|
74
|
+
const res = await fetchImpl(String(api).replace(/\/+$/, '') + route, {
|
|
75
|
+
...extra,
|
|
76
|
+
method,
|
|
77
|
+
headers: { ...(body ? { 'content-type': 'application/json' } : {}), ...(key ? { authorization: `Bearer ${key}` } : {}) },
|
|
78
|
+
body: body ? JSON.stringify(body) : undefined
|
|
79
|
+
})
|
|
80
|
+
return { status: res.status, ok: res.ok, body: await res.json().catch(() => null) }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Makes sure the agents directory exists, is not a symlink, and is private. */
|
|
84
|
+
function ensureAgentsDir (dir) {
|
|
85
|
+
let st
|
|
86
|
+
try { st = fs.lstatSync(dir) } catch (err) {
|
|
87
|
+
if (err.code !== 'ENOENT') throw err
|
|
88
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
if (st.isSymbolicLink()) throw new Error(`Refusing to use ${dir}: it's a symlink`)
|
|
92
|
+
// Tighten up permissions someone (or some older version) left too loose.
|
|
93
|
+
if ((st.mode & 0o777) !== 0o700) fs.chmodSync(dir, 0o700)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Saves the agent's file privately (see private-file.js), in a private agents folder. */
|
|
97
|
+
function save (file, data) {
|
|
98
|
+
ensureAgentsDir(path.dirname(file))
|
|
99
|
+
writePrivateJson(file, data)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function load (file, name) {
|
|
103
|
+
let raw
|
|
104
|
+
try {
|
|
105
|
+
raw = fs.readFileSync(file, 'utf8')
|
|
106
|
+
} catch (err) {
|
|
107
|
+
if (err.code === 'ENOENT') throw new Error(`No agent called ${name} here. Run: quilt agent join <link> --name ${name}`)
|
|
108
|
+
throw new Error(`Couldn't read the saved agent file for ${name} (corrupt or unreadable): ${err.message}`)
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
return JSON.parse(raw)
|
|
112
|
+
} catch (err) {
|
|
113
|
+
throw new Error(`Couldn't read the saved agent file for ${name} (corrupt or unreadable): ${err.message}`)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The agent saved under this name, or null when there is none (or it can't be read). */
|
|
118
|
+
function savedOrNull (file) {
|
|
119
|
+
try { return JSON.parse(fs.readFileSync(file, 'utf8')) } catch { return null }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Uses an invite link once and saves the agent's keys. An agent that joined before comes
|
|
124
|
+
* back as itself: with `agentId`, or with the id saved under this name on this computer
|
|
125
|
+
* (then it keeps its own key too). Without either it joins as a new agent.
|
|
126
|
+
*/
|
|
127
|
+
export async function agentJoin ({ link, name, agentId, provider = DEFAULTS.provider, type = DEFAULTS.type, description = '', dir, here = false, fetch: fetchImpl = globalThis.fetch, log = console.log }) {
|
|
128
|
+
const file = agentFile(name, dir || (here ? path.resolve('.quilt') : undefined))
|
|
129
|
+
const { api, token } = parseJoinLink(link)
|
|
130
|
+
const before = savedOrNull(file)
|
|
131
|
+
const id = agentId || (before?.api === api ? before.agentId : undefined)
|
|
132
|
+
// The agent's own Ed25519 key, for joining sessions. A returning agent keeps the one it has.
|
|
133
|
+
const identity = id && before?.agentId === id && before.identity ? before.identity : generateIdentity()
|
|
134
|
+
const r = await send(fetchImpl, api, 'POST', `/v1/join/${token}`, { name, provider, type, description, publicKey: identity.publicKey, ...(id ? { agentId: id } : {}) })
|
|
135
|
+
if (!r.ok) throw new Error(r.body?.error || `Couldn't join Quilt (${r.status}).`)
|
|
136
|
+
const saved = { name, api, agentId: r.body.agentId, accessKey: r.body.accessKey, accessExpiresAt: r.body.accessExpiresAt, refreshKey: r.body.refreshKey, refreshExpiresAt: r.body.refreshExpiresAt, resumeKey: r.body.resumeKey, identity }
|
|
137
|
+
save(file, saved)
|
|
138
|
+
const inProject = !dir && path.dirname(path.dirname(file)) !== quiltHome()
|
|
139
|
+
// Keys in a project folder: git leaves them out, whatever the project's .gitignore says.
|
|
140
|
+
if (inProject) fs.writeFileSync(path.join(path.dirname(file), '.gitignore'), '*\n')
|
|
141
|
+
log(`${r.body.rejoined ? 'Joined Quilt again as' : 'Joined Quilt as'} ${name} (agent id ${saved.agentId}: public, keep it to come back as yourself with a new invite). Keys saved in ${file}${inProject ? ": this project's own .quilt, which Quilt never syncs and git ignores. quilt finds them from anywhere in this folder" : ''}`)
|
|
142
|
+
return saved
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Uses an invite link once and keeps nothing on this computer: for a sandbox, whose agent signs in
|
|
147
|
+
* with a QUILT_AGENT_KEY made from the keys this returns ({ api, agentId, accessKey, ... }). The
|
|
148
|
+
* agent joins without a key of its own (it works over HTTPS, like a hosted agent).
|
|
149
|
+
*/
|
|
150
|
+
export async function agentJoinUnsaved ({ link, name, agentId, provider = DEFAULTS.provider, type = DEFAULTS.type, description = '', fetch: fetchImpl = globalThis.fetch }) {
|
|
151
|
+
agentFile(name, os.tmpdir()) // the same check of the name as a saved agent's
|
|
152
|
+
const { api, token } = parseJoinLink(link)
|
|
153
|
+
const r = await send(fetchImpl, api, 'POST', `/v1/join/${token}`, { name, provider, type, description, ...(agentId ? { agentId } : {}) })
|
|
154
|
+
if (!r.ok) throw new Error(r.body?.error || `Couldn't join Quilt (${r.status}).`)
|
|
155
|
+
return { ...r.body, api } // the address the link reached (the API names its public one)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* A new QUILT_AGENT_KEY for the agent signed in with `accessKey` at `api`, working in `session`
|
|
160
|
+
* (its invite link): a new app key the agent's person sees and can revoke on heyquilt.com.
|
|
161
|
+
*/
|
|
162
|
+
export async function makeEnvKey ({ api, accessKey, session, keyName, fetch: fetchImpl = globalThis.fetch }) {
|
|
163
|
+
if (!session) throw new Error('--session <session invite link> is needed: the key says which session the sandbox works in.')
|
|
164
|
+
const r = await send(fetchImpl, api, 'POST', '/v1/agents/me/keys', { session, ...(keyName ? { name: keyName } : {}) }, accessKey)
|
|
165
|
+
if (!r.ok) throw Object.assign(new Error(r.body?.error || `Couldn't make the key (${r.status}).`), { status: r.status })
|
|
166
|
+
// The key names the API's public address; one reached at another address (self-hosted, a test) keeps that one.
|
|
167
|
+
const k = decodeAgentEnvKey(r.body.envKey)
|
|
168
|
+
return k.api === String(api).replace(/\/+$/, '') ? r.body.envKey : encodeAgentEnvKey({ ...k, api })
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** makeEnvKey for an agent saved on this computer (quilt agent join), signing it back in when its keys were revoked. */
|
|
172
|
+
export async function savedAgentEnvKey ({ name, session, keyName, fetch: fetchImpl = globalThis.fetch }) {
|
|
173
|
+
const saved = await agentAccess({ name, fetch: fetchImpl })
|
|
174
|
+
try {
|
|
175
|
+
return await makeEnvKey({ api: saved.api, accessKey: saved.accessKey, session, keyName, fetch: fetchImpl })
|
|
176
|
+
} catch (err) {
|
|
177
|
+
if (err.status !== 401) throw err
|
|
178
|
+
const back = await agentResume({ name, accessKey: saved.accessKey, fetch: fetchImpl })
|
|
179
|
+
if (!back) throw err
|
|
180
|
+
return makeEnvKey({ api: back.api, accessKey: back.accessKey, session, keyName, fetch: fetchImpl })
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function refresh (saved, file, fetchImpl) {
|
|
185
|
+
const r = await send(fetchImpl, saved.api, 'POST', '/v1/agents/token', { refreshKey: saved.refreshKey }, null, { signal: AbortSignal.timeout(REFRESH_TIMEOUT_MS) })
|
|
186
|
+
if (r.status === 401 && canResume(saved)) return resume(saved, file, fetchImpl, r)
|
|
187
|
+
if (!r.ok) throw refused(r, `Couldn't refresh the agent's keys (${r.status}).`)
|
|
188
|
+
return keep(saved, file, r.body)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* The refresh key was turned away: most often a refresh the API made whose reply never
|
|
193
|
+
* arrived (the computer slept or went offline), so the key we kept was already spent and
|
|
194
|
+
* the API revoked the agent's keys. The agent signs for the key it joined with and gets
|
|
195
|
+
* new ones. `turnedAway` is the refresh's reply, the error to give if this fails too.
|
|
196
|
+
*/
|
|
197
|
+
async function resume (saved, file, fetchImpl, turnedAway) {
|
|
198
|
+
// The resume key every agent gets when it joins; agents saved before resume keys sign
|
|
199
|
+
// with their own key instead (the API's clock, not a test's: it has to be close to it).
|
|
200
|
+
const at = Date.now()
|
|
201
|
+
const body = saved.resumeKey ? { resumeKey: saved.resumeKey } : { agentId: saved.agentId, at, signature: signAgentResume(saved.identity, saved.agentId, at) }
|
|
202
|
+
const r = await send(fetchImpl, saved.api, 'POST', '/v1/agents/resume', body, null, { signal: AbortSignal.timeout(REFRESH_TIMEOUT_MS) })
|
|
203
|
+
if (r.ok) return keep(saved, file, r.body)
|
|
204
|
+
// An API without resume, or that doesn't know the agent: the refresh's answer says what happened.
|
|
205
|
+
if (r.status === 404) throw refused(turnedAway, `Couldn't refresh the agent's keys (${turnedAway.status}).`)
|
|
206
|
+
// Revoked by a person, a signature that doesn't match, or a clock that's off: signed out.
|
|
207
|
+
if (r.status === 400 || r.status === 401) throw Object.assign(refused(r, `Couldn't sign the agent back in (${r.status}).`), { status: 401 })
|
|
208
|
+
throw refused(r, `Couldn't sign the agent back in (${r.status}).`)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const canResume = (saved) => !!(saved.resumeKey || saved.identity)
|
|
212
|
+
|
|
213
|
+
const refused = (r, fallback) => Object.assign(new Error(r.body?.error || fallback), { status: r.status })
|
|
214
|
+
|
|
215
|
+
function keep (saved, file, body) {
|
|
216
|
+
const next = { ...saved, accessKey: body.accessKey, accessExpiresAt: body.accessExpiresAt, refreshKey: body.refreshKey, refreshExpiresAt: body.refreshExpiresAt }
|
|
217
|
+
// Save straight away: the old refresh key is spent, and using it again would revoke the agent.
|
|
218
|
+
save(file, next)
|
|
219
|
+
return next
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** A saved agent's file: { name, api, agentId, accessKey, refreshKey, …, resumeKey, identity }. */
|
|
223
|
+
export function readAgent ({ name, dir }) {
|
|
224
|
+
return load(agentFile(name, dir), name)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const readLock = (lock) => { try { return fs.readFileSync(lock, 'utf8') } catch { return null } }
|
|
228
|
+
|
|
229
|
+
/** Whether a lock holding `contents` ("<pid>.<token>") and last changed at `mtimeMs` is stale. */
|
|
230
|
+
function lockIsStale (contents, mtimeMs) {
|
|
231
|
+
if (Date.now() - mtimeMs > LOCK_MAX_AGE_MS) return true
|
|
232
|
+
const pid = Number(String(contents).split('.')[0])
|
|
233
|
+
// No pid yet (its holder has only just made it): only age can make it stale.
|
|
234
|
+
if (!Number.isInteger(pid) || pid <= 0) return false
|
|
235
|
+
try {
|
|
236
|
+
process.kill(pid, 0)
|
|
237
|
+
return false
|
|
238
|
+
} catch (err) {
|
|
239
|
+
return err.code === 'ESRCH' // EPERM: running, as someone else
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Removes the lock when its holder is gone, unless it changed while we looked.
|
|
245
|
+
* Returns true when the lock is gone (so try to take it again). Two processes
|
|
246
|
+
* removing one crashed holder's lock at the very same instant can both get in;
|
|
247
|
+
* that needs a crash and a tie, so it is accepted. (`between` lets tests step in
|
|
248
|
+
* between the two reads.)
|
|
249
|
+
*/
|
|
250
|
+
export function takeOverStale (lock, between = () => {}) {
|
|
251
|
+
let seen, st
|
|
252
|
+
try {
|
|
253
|
+
seen = fs.readFileSync(lock, 'utf8')
|
|
254
|
+
st = fs.statSync(lock)
|
|
255
|
+
} catch {
|
|
256
|
+
return true // gone already
|
|
257
|
+
}
|
|
258
|
+
if (!lockIsStale(seen, st.mtimeMs)) return false
|
|
259
|
+
between()
|
|
260
|
+
if (readLock(lock) !== seen) return false
|
|
261
|
+
fs.rmSync(lock, { force: true })
|
|
262
|
+
return true
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Runs `fn` holding `<file>.lock`, so only one process at a time refreshes an agent's
|
|
267
|
+
* keys: a refresh key used twice gets the agent revoked for good.
|
|
268
|
+
*/
|
|
269
|
+
export async function withLock (file, fn) {
|
|
270
|
+
const lock = `${file}.lock`
|
|
271
|
+
const token = `${process.pid}.${crypto.randomBytes(8).toString('hex')}`
|
|
272
|
+
const until = Date.now() + LOCK_WAIT_MS
|
|
273
|
+
for (;;) {
|
|
274
|
+
let fd = null
|
|
275
|
+
try {
|
|
276
|
+
fd = fs.openSync(lock, 'wx', 0o600)
|
|
277
|
+
} catch (err) {
|
|
278
|
+
if (err.code !== 'EEXIST') throw err
|
|
279
|
+
}
|
|
280
|
+
if (fd !== null) {
|
|
281
|
+
try { fs.writeSync(fd, token) } finally { fs.closeSync(fd) }
|
|
282
|
+
try {
|
|
283
|
+
return await fn()
|
|
284
|
+
} finally {
|
|
285
|
+
// Only our own lock, never one another process has since made.
|
|
286
|
+
if (readLock(lock) === token) fs.rmSync(lock, { force: true })
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
if (takeOverStale(lock)) continue
|
|
290
|
+
if (Date.now() > until) throw new Error(`Another Quilt process is stuck refreshing the agent's keys. Remove ${lock} and try again.`)
|
|
291
|
+
await new Promise((resolve) => setTimeout(resolve, LOCK_RETRY_MS))
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** The saved agent with a working access key, refreshed first when it has (nearly) run out. */
|
|
296
|
+
export async function agentAccess ({ name, dir, fetch: fetchImpl = globalThis.fetch, now = Date.now }) {
|
|
297
|
+
const file = agentFile(name, dir)
|
|
298
|
+
const fresh = (saved) => saved.accessExpiresAt - EARLY_MS > now()
|
|
299
|
+
const saved = load(file, name)
|
|
300
|
+
if (fresh(saved)) return saved
|
|
301
|
+
return withLock(file, () => {
|
|
302
|
+
// Another process may have refreshed while we waited: use its keys.
|
|
303
|
+
const latest = load(file, name)
|
|
304
|
+
return fresh(latest) ? latest : refresh(latest, file, fetchImpl)
|
|
305
|
+
})
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* The API turned `accessKey` away before it ran out (its keys were revoked): signs back in
|
|
310
|
+
* with the agent's key. Resolves to the saved agent with new keys, the keys another process
|
|
311
|
+
* already got, or null for an agent saved with neither a resume key nor a key of its own.
|
|
312
|
+
*/
|
|
313
|
+
export async function agentResume ({ name, dir, accessKey, fetch: fetchImpl = globalThis.fetch }) {
|
|
314
|
+
const file = agentFile(name, dir)
|
|
315
|
+
return withLock(file, () => {
|
|
316
|
+
const latest = load(file, name)
|
|
317
|
+
if (latest.accessKey !== accessKey) return latest
|
|
318
|
+
if (!canResume(latest)) return null
|
|
319
|
+
return resume(latest, file, fetchImpl, { status: 401, body: { error: "This agent's keys were revoked. Invite it again." } })
|
|
320
|
+
})
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Who the agent is, refreshing its keys first when the access key has (nearly) run out. */
|
|
324
|
+
export async function agentWhoami ({ name, dir, fetch: fetchImpl = globalThis.fetch, now = Date.now }) {
|
|
325
|
+
const saved = await agentAccess({ name, dir, fetch: fetchImpl, now })
|
|
326
|
+
let r = await send(fetchImpl, saved.api, 'GET', '/v1/agents/me', null, saved.accessKey)
|
|
327
|
+
if (r.status === 401) {
|
|
328
|
+
const back = await agentResume({ name, dir, accessKey: saved.accessKey, fetch: fetchImpl })
|
|
329
|
+
if (back) r = await send(fetchImpl, back.api, 'GET', '/v1/agents/me', null, back.accessKey)
|
|
330
|
+
}
|
|
331
|
+
if (!r.ok) throw new Error(r.body?.error || `Couldn't reach Quilt (${r.status}).`)
|
|
332
|
+
return r.body
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Whether this agent's keys are in the project (saved with --here), not in ~/.quilt. */
|
|
336
|
+
export function keptInProject (name) {
|
|
337
|
+
try { return path.dirname(path.dirname(agentFile(name))) !== quiltHome() } catch { return false }
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** The names of the agents saved in `dir`, or on this computer and in this project. */
|
|
341
|
+
export function savedAgents (dir) {
|
|
342
|
+
const names = (d) => {
|
|
343
|
+
try {
|
|
344
|
+
return fs.readdirSync(path.join(d, 'agents')).filter((f) => f.endsWith('.json') && !f.startsWith('.')).map((f) => f.slice(0, -5))
|
|
345
|
+
} catch {
|
|
346
|
+
return []
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
const all = dir ? names(dir) : [...names(quiltHome()), ...(projectAgentsHome() ? names(projectAgentsHome()) : [])]
|
|
350
|
+
return [...new Set(all)].sort()
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Which saved agent a session joins as: the one named, or the only one there is. */
|
|
354
|
+
export function pickAgent ({ agent, dir } = {}) {
|
|
355
|
+
if (agent) return agent
|
|
356
|
+
const all = savedAgents(dir)
|
|
357
|
+
if (all.length === 1) return all[0]
|
|
358
|
+
if (!all.length) throw new Error('This computer has no Quilt agent yet. The person you work with can invite one on heyquilt.com, then run: quilt agent join <link> --name <name>')
|
|
359
|
+
throw new Error(`This computer has several Quilt agents (${all.join(', ')}). Say which one to join as.`)
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export function describeAgent (me) {
|
|
363
|
+
const where = me.agent.kind === 'org' ? `an agent in ${me.agent.org.name}` : 'your personal agent'
|
|
364
|
+
const lines = [`${me.agent.name} (${me.agent.provider}, ${me.agent.type}): ${where}`]
|
|
365
|
+
// Public: what the agent gives with a new invite to come back as itself.
|
|
366
|
+
if (me.agent.id) lines.push(`Agent id: ${me.agent.id}`)
|
|
367
|
+
if (me.role) lines.push(`Role: ${me.role.name}`)
|
|
368
|
+
for (const t of me.teams) lines.push(`Team ${t.name}: ${t.access}${t.scopes.length ? `, folders ${t.scopes.join(', ')}` : ''}`)
|
|
369
|
+
return lines.join('\n')
|
|
370
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// Required workflow every agent must follow when it picks up a ticket, the
|
|
2
|
+
// briefing it gets when it does, and the notes it owes before QA (then Done).
|
|
3
|
+
// Included in MCP server instructions (local + relay) and in the project
|
|
4
|
+
// agent guides written by `quilt setup` (AGENTS.md / CLAUDE.md).
|
|
5
|
+
|
|
6
|
+
/** Heading of the project's own checks in AGENTS.md / CLAUDE.md (matched case-insensitively on "## Verif…"). */
|
|
7
|
+
export const CHECKLIST_HEADING = '## Verifying a change'
|
|
8
|
+
const CHECKLIST_RE = /^##\s+verif[^\n]*\n([\s\S]*?)(?=^##\s|(?![\s\S]))/im
|
|
9
|
+
|
|
10
|
+
/** The least an agent must say about what it verified before a ticket may be Done. */
|
|
11
|
+
export const MIN_VERIFIED = 12
|
|
12
|
+
export const MAX_VERIFIED = 1000
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* One short paragraph: grok → plan → build → test → QA with qaNotes.
|
|
16
|
+
* Keep this the single source of truth so every briefing path says the same thing.
|
|
17
|
+
*/
|
|
18
|
+
export const TASK_WORKFLOW =
|
|
19
|
+
'When you pick up a ticket (move it to In progress, or start work on one assigned to you): ' +
|
|
20
|
+
'first grok the codebase and workspace (explore the relevant files, how they fit together, and quilt_history for recent changes to them) ' +
|
|
21
|
+
'and read the project\'s rules and design system with quilt_project_rules (the same whatever tool you run in), ' +
|
|
22
|
+
'then implement a plan, then build the change, then test it (confirm the tests you touch pass) ' +
|
|
23
|
+
'and run the project\'s own checks under "Verifying a change" in AGENTS.md. ' +
|
|
24
|
+
'When finished, move the ticket to QA (not straight to Done) with `qaNotes`: describe the changes you made and how you self-validated them. ' +
|
|
25
|
+
'Keep qaNotes, verified and task comments brief: a few short sentences, no logs or play-by-play. ' +
|
|
26
|
+
'Moving to Done still requires `verified` (what you ran and what you saw). Unit tests passing is not enough when the app itself was not exercised. ' +
|
|
27
|
+
'Taking a task puts your folder on a branch of your own for it (quilt/<you>/<task>). Get the task\'s files into git before you move it to QA: commit only those files and push the branch; Quilt opens the pull request and ties it to the task. Done merges that pull request once its checks pass: a person moves it to Done (you may, when the owner lets agents push to any branch). Without a folder, `quilt_git_commit` with the task (the pull request opens by itself); when agents may not commit, ask with `quilt_request_commit`. Work only in the session has not shipped.'
|
|
28
|
+
|
|
29
|
+
/** Markdown bullets for AGENTS.md / CLAUDE.md (same steps as TASK_WORKFLOW). */
|
|
30
|
+
export const TASK_WORKFLOW_MD =
|
|
31
|
+
'- When you pick up a ticket (move it to In progress, or start work on one assigned to you):\n' +
|
|
32
|
+
' 1. **Grok** the codebase and workspace - explore the relevant files, how they fit together, and `quilt_history` for recent changes to them; read the project\'s rules and design system with `quilt_project_rules`.\n' +
|
|
33
|
+
' 2. **Plan** the change.\n' +
|
|
34
|
+
' 3. **Build** it.\n' +
|
|
35
|
+
' 4. **Test** it: confirm the tests you touch pass, then run the project\'s own checks under "Verifying a change" below.\n' +
|
|
36
|
+
' 5. **QA**: move to `qa` (not straight to Done) with `qaNotes` describing the changes and how you self-validated them.\n' +
|
|
37
|
+
' Keep `qaNotes`, `verified` and task comments brief: a few short sentences, no logs or play-by-play.\n' +
|
|
38
|
+
' 6. **Done** (after QA): `quilt_move_task` to `done` needs `verified`, what you ran and what you saw. Unit tests passing is not enough when the app itself was not exercised. Done merges the task\'s pull request once its checks pass; when the owner keeps agents to branches of their own, a person moves it to Done.\n' +
|
|
39
|
+
' 7. **Commit and push** before QA: only the task\'s files, with a message, on the branch Quilt put your folder on when you took the task (quilt/<you>/<task>); Quilt opens the pull request. Without a folder, `quilt_git_commit` with the task. When agents may not commit, ask with `quilt_request_commit`. Work only in the session has not shipped.'
|
|
40
|
+
|
|
41
|
+
/** Scaffolded into AGENTS.md by `quilt setup` when the project has no checklist yet. Owners edit it in place. */
|
|
42
|
+
export const CHECKLIST_SCAFFOLD = `${CHECKLIST_HEADING}
|
|
43
|
+
|
|
44
|
+
Every agent reads this when it picks up a ticket and must run these checks before moving
|
|
45
|
+
it to QA. Replace the examples with what proves a change works in this project.
|
|
46
|
+
|
|
47
|
+
- Run the test suite and make sure it passes.
|
|
48
|
+
- Start the app and exercise the part you changed; confirm it loads and behaves.
|
|
49
|
+
- Note anything this project needs that tests do not catch (allowlists, registrations, config).
|
|
50
|
+
`
|
|
51
|
+
|
|
52
|
+
const PLACEHOLDER = /Replace the examples with what proves a change works/
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The checklist body under "## Verifying a change" in an agent guide, trimmed.
|
|
56
|
+
* '' when the text has no such section.
|
|
57
|
+
*/
|
|
58
|
+
export function extractChecklist (text) {
|
|
59
|
+
const m = CHECKLIST_RE.exec(String(text || ''))
|
|
60
|
+
if (!m) return ''
|
|
61
|
+
return m[1].replace(/\s+$/, '').replace(/^\s*\n/, '')
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The first non-empty checklist among several guide texts (AGENTS.md first, then CLAUDE.md). */
|
|
65
|
+
export function pickChecklist (...texts) {
|
|
66
|
+
for (const t of texts) { const c = extractChecklist(t); if (c) return c }
|
|
67
|
+
return ''
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function checklistBlock (checklist) {
|
|
71
|
+
if (!checklist) {
|
|
72
|
+
return 'This project has no "Verifying a change" section in AGENTS.md yet. Run its tests and exercise the app yourself, ' +
|
|
73
|
+
'and ask the owner what proves a change works here.'
|
|
74
|
+
}
|
|
75
|
+
const head = PLACEHOLDER.test(checklist)
|
|
76
|
+
? 'This project\'s checks (AGENTS.md, still the template: ask the owner what proves a change works here):'
|
|
77
|
+
: 'This project\'s checks (from AGENTS.md):'
|
|
78
|
+
return `${head}\n${checklist}`
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function agoText (ts, now) {
|
|
82
|
+
const s = Math.max(0, Math.round((now - ts) / 1000))
|
|
83
|
+
if (s < 60) return `${s}s ago`
|
|
84
|
+
if (s < 3600) return `${Math.floor(s / 60)}m ago`
|
|
85
|
+
if (s < 172800) return `${Math.floor(s / 3600)}h ago`
|
|
86
|
+
return `${Math.floor(s / 86400)}d ago`
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* What an agent gets when it moves a ticket to In progress: the ticket, its files,
|
|
91
|
+
* recent changes to them (from the chronology), who holds claims on them, the
|
|
92
|
+
* workflow and the project's checks.
|
|
93
|
+
* @param {object} o
|
|
94
|
+
* @param {{id:string,title:string,files?:string[]}} o.task
|
|
95
|
+
* @param {Array<{by:string,path:string,kind:string,detail?:string,ts:number,task?:{title:string}|null}>} [o.history] oldest first
|
|
96
|
+
* @param {Array<{by:string,pattern:string,note?:string}>} [o.claims]
|
|
97
|
+
* @param {string} [o.checklist]
|
|
98
|
+
* @param {string} [o.me]
|
|
99
|
+
* @param {number} [o.now]
|
|
100
|
+
*/
|
|
101
|
+
export function pickupBrief ({ task, history = [], claims = [], checklist = '', me = '', now = Date.now() }) {
|
|
102
|
+
const files = task?.files || []
|
|
103
|
+
const lines = [`Picked up "${task?.title || 'this ticket'}" [${task?.id || ''}].`]
|
|
104
|
+
lines.push(files.length ? `Files: ${files.join(', ')}` : 'Files: none listed. Add them with quilt_assign_task once you know which you will touch.')
|
|
105
|
+
const recent = history.slice(-8)
|
|
106
|
+
if (recent.length) {
|
|
107
|
+
lines.push('', files.length ? 'Recent changes to these files (oldest first):' : 'Recent changes in the project (oldest first):')
|
|
108
|
+
for (const e of recent) {
|
|
109
|
+
const who = e.by === me ? 'you' : e.by
|
|
110
|
+
const forTask = e.task?.title ? ` for "${e.task.title}"` : ''
|
|
111
|
+
lines.push(`- [${agoText(e.ts, now)}] ${who} ${e.kind} ${e.path}${e.detail ? ` (${e.detail})` : ''}${forTask}`)
|
|
112
|
+
}
|
|
113
|
+
lines.push('Read the diffs with quilt_history before building on them.')
|
|
114
|
+
} else {
|
|
115
|
+
lines.push('', files.length ? 'No recorded changes to these files yet.' : 'No recorded changes yet.')
|
|
116
|
+
}
|
|
117
|
+
const comments = Array.isArray(task?.comments) ? task.comments.slice(-5) : []
|
|
118
|
+
if (comments.length) {
|
|
119
|
+
lines.push('', 'Comments on the task (latest last):')
|
|
120
|
+
for (const c of comments) lines.push(`- ${c.by === me ? 'you' : c.by} [${agoText(c.ts, now)}]: ${String(c.text).replace(/\n/g, '\n ')}`)
|
|
121
|
+
}
|
|
122
|
+
const others = claims.filter((c) => c.by !== me)
|
|
123
|
+
if (others.length) {
|
|
124
|
+
lines.push('', 'Claims to respect (ask for the file with quilt_request_file instead of editing it):')
|
|
125
|
+
for (const c of others) lines.push(`- ${c.pattern} by ${c.by}${c.note ? ` (${c.note})` : ''}`)
|
|
126
|
+
}
|
|
127
|
+
lines.push('', TASK_WORKFLOW, '', checklistBlock(checklist))
|
|
128
|
+
return lines.join('\n')
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Reminder appended when an agent moves a task to In progress (short form, no context). */
|
|
132
|
+
export function pickupReminder (title) {
|
|
133
|
+
const who = title ? `"${title}"` : 'this ticket'
|
|
134
|
+
return `Picked up ${who}. ${TASK_WORKFLOW}`
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** True when `verified` is enough evidence to move a ticket to Done. */
|
|
138
|
+
export function verifiedEnough (verified) {
|
|
139
|
+
return String(verified || '').trim().length >= MIN_VERIFIED
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Why a move to Done was refused, and what to do instead. */
|
|
143
|
+
export function doneRefusal ({ task, checklist = '' } = {}) {
|
|
144
|
+
const title = task?.title ? `"${task.title}"` : 'this ticket'
|
|
145
|
+
return `Not moved: ${title} needs \`verified\` before it can be Done. ` +
|
|
146
|
+
'Say what you ran and what you saw (for example: "npm test passed, 612 tests; launched the app and the session page rendered with the new column"). ' +
|
|
147
|
+
'Vague words like "tested" are not enough.\n\n' + checklistBlock(checklist)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The line shown on the board for a finished ticket's evidence. */
|
|
151
|
+
export function verifiedLine (task) {
|
|
152
|
+
const v = String(task?.verified || '').replace(/\s+/g, ' ').trim()
|
|
153
|
+
if (!v) return ''
|
|
154
|
+
return v.length > 160 ? `${v.slice(0, 157)}…` : v
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** True when `qaNotes` is enough to move a ticket to QA. */
|
|
158
|
+
export function qaNotesEnough (qaNotes) {
|
|
159
|
+
return String(qaNotes || '').trim().length >= MIN_VERIFIED
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Why a move to QA was refused, and what to do instead. */
|
|
163
|
+
export function qaRefusal ({ task, checklist = '' } = {}) {
|
|
164
|
+
const title = task?.title ? `"${task.title}"` : 'this ticket'
|
|
165
|
+
return `Not moved: ${title} needs \`qaNotes\` before it can go to QA. ` +
|
|
166
|
+
'Describe the changes you made and how you self-validated them ' +
|
|
167
|
+
'(for example: "Added QA column and qaNotes field; npm test passed; board shows four columns"). ' +
|
|
168
|
+
'Vague words like "done" are not enough.\n\n' + checklistBlock(checklist)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** The line shown for a QA ticket's change description. */
|
|
172
|
+
export function qaNotesLine (task) {
|
|
173
|
+
const v = String(task?.qaNotes || '').replace(/\s+/g, ' ').trim()
|
|
174
|
+
if (!v) return ''
|
|
175
|
+
return v.length > 160 ? `${v.slice(0, 157)}…` : v
|
|
176
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Turns an AI tool call into one short, shareable line ("Edited src/app.ts",
|
|
2
|
+
// "Ran npm test"). Deliberately lossy: command arguments, file contents and
|
|
3
|
+
// output never leave the machine.
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
|
|
6
|
+
const EDIT = new Set(['edit', 'multiedit', 'notebookedit', 'edit_file', 'search_replace', 'str_replace', 'strreplace', 'apply_patch', 'write'])
|
|
7
|
+
const READ = new Set(['read', 'read_file'])
|
|
8
|
+
const SEARCH = new Set(['grep', 'glob', 'grep_search', 'file_search', 'glob_file_search', 'codebase_search', 'list_dir', 'ls'])
|
|
9
|
+
const RUN = new Set(['bash', 'run_terminal_cmd', 'run_terminal_command', 'run_command', 'shell'])
|
|
10
|
+
const DELETE = new Set(['delete_file'])
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {string} tool tool name as the agent reported it
|
|
14
|
+
* @param {object} input tool input
|
|
15
|
+
* @param {string} dir the synced folder, for relative paths
|
|
16
|
+
* @param {{ existed?: (abs: string) => boolean }} [opts]
|
|
17
|
+
*/
|
|
18
|
+
export function describeAction (tool, input = {}, dir = '', opts = {}) {
|
|
19
|
+
const name = String(tool || '').trim()
|
|
20
|
+
// Cursor versions its tools ("edit_file_v2"); the suffix doesn't change what they do.
|
|
21
|
+
const key = name.toLowerCase().replace(/_v\d+$/, '')
|
|
22
|
+
input = input && typeof input === 'object' ? input : {}
|
|
23
|
+
|
|
24
|
+
if (EDIT.has(key)) {
|
|
25
|
+
const file = pickPath(input)
|
|
26
|
+
const shown = file ? rel(file, dir) : 'a file'
|
|
27
|
+
if (key === 'write' && file && opts.existed && !opts.existed(path.resolve(dir || '/', file))) return `Created ${shown}`
|
|
28
|
+
return `Edited ${shown}`
|
|
29
|
+
}
|
|
30
|
+
if (DELETE.has(key)) {
|
|
31
|
+
const file = pickPath(input)
|
|
32
|
+
return file ? `Deleted ${rel(file, dir)}` : 'Deleted a file'
|
|
33
|
+
}
|
|
34
|
+
if (READ.has(key)) {
|
|
35
|
+
const file = pickPath(input)
|
|
36
|
+
return file ? `Read ${rel(file, dir)}` : 'Read a file'
|
|
37
|
+
}
|
|
38
|
+
if (SEARCH.has(key)) return 'Searched the code'
|
|
39
|
+
if (RUN.has(key)) {
|
|
40
|
+
const cmd = summarizeCommand(input.command ?? input.cmd ?? '')
|
|
41
|
+
return cmd ? `Ran ${cmd}` : 'Ran a command'
|
|
42
|
+
}
|
|
43
|
+
return `Used ${name || 'a tool'}`
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function pickPath (input) {
|
|
47
|
+
for (const k of ['file_path', 'path', 'target_file', 'notebook_path', 'filePath', 'relative_workspace_path']) {
|
|
48
|
+
if (typeof input[k] === 'string' && input[k]) return input[k]
|
|
49
|
+
}
|
|
50
|
+
return null
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function rel (file, dir) {
|
|
54
|
+
if (!dir || !path.isAbsolute(file)) return file.replace(/^\.\//, '')
|
|
55
|
+
const r = path.relative(dir, file)
|
|
56
|
+
// Paths outside the project are reduced to their file name.
|
|
57
|
+
if (r.startsWith('..') || path.isAbsolute(r)) return path.basename(file)
|
|
58
|
+
return r.split(path.sep).join('/')
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const SETUP = new Set(['cd', 'pushd', 'export', 'set', 'source', '.', 'true'])
|
|
62
|
+
|
|
63
|
+
/** "FOO=1 npm test -- --watch" -> "npm test". Only the program and one plain word survive. */
|
|
64
|
+
export function summarizeCommand (command) {
|
|
65
|
+
// "cd app && npm test" is really "npm test": skip setup steps.
|
|
66
|
+
const steps = String(command).split(/&&|;|\|\|/).map((s) => s.trim()).filter(Boolean)
|
|
67
|
+
const main = steps.find((s) => {
|
|
68
|
+
const w = s.split(/\s+/).filter((x) => !/^[A-Za-z_][A-Za-z0-9_]*=/.test(x))
|
|
69
|
+
return w.length && !SETUP.has(w[0])
|
|
70
|
+
})
|
|
71
|
+
// Only setup steps (e.g. just "cd somewhere"): show the program, not where it went.
|
|
72
|
+
return main ? summarizeStep(main) : summarizeStep(steps[0] || '').split(' ')[0]
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function summarizeStep (step) {
|
|
76
|
+
const words = String(step).trim().split(/\s+/).filter(Boolean)
|
|
77
|
+
while (words.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0])) words.shift()
|
|
78
|
+
if (!words.length) return ''
|
|
79
|
+
let program = words[0]
|
|
80
|
+
// Show a program's name, never where it lives (paths can contain user names).
|
|
81
|
+
if (program.includes('/')) program = program.split('/').filter(Boolean).pop() || ''
|
|
82
|
+
if (!/^[A-Za-z0-9][\w.+-]*$/.test(program)) return ''
|
|
83
|
+
const next = words[1]
|
|
84
|
+
return next && /^[a-z][\w:-]*$/.test(next) ? `${program} ${next}` : program
|
|
85
|
+
}
|