@markjaquith/agency 2.52.1 → 2.52.2
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/cli-main.ts +854 -0
- package/cli.ts +15 -850
- package/package.json +2 -1
- package/src/services/TaskService.ts +1 -1
- package/src/services/VcsMigrationService.test.ts +46 -0
- package/src/services/VcsMigrationService.ts +35 -21
- package/src/services/VersionControlService.ts +9 -3
- package/src/services/WorktreeService.ts +234 -40
- package/src/vcs-status-fast.ts +315 -0
- package/src/workbase/opencode-plugin-file.ts +31 -4
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import { lstat, readdir, realpath, stat } from "node:fs/promises"
|
|
2
|
+
import { dirname, join, resolve } from "node:path"
|
|
3
|
+
|
|
4
|
+
interface Execution {
|
|
5
|
+
readonly taskId: string
|
|
6
|
+
readonly phaseId?: string
|
|
7
|
+
readonly documentPath: string
|
|
8
|
+
readonly repo: string
|
|
9
|
+
readonly branch: string
|
|
10
|
+
readonly claimActive: boolean
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface Workspace {
|
|
14
|
+
readonly path: string
|
|
15
|
+
readonly dirty: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface Blocker {
|
|
19
|
+
readonly kind: string
|
|
20
|
+
readonly target: string
|
|
21
|
+
readonly message: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const run = async (args: readonly string[]) => {
|
|
25
|
+
const process = Bun.spawn([...args], { stdout: "pipe", stderr: "pipe" })
|
|
26
|
+
const [exitCode, stdout] = await Promise.all([
|
|
27
|
+
process.exited,
|
|
28
|
+
new Response(process.stdout).text(),
|
|
29
|
+
])
|
|
30
|
+
return { exitCode, stdout: stdout.trim() }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const directoryExists = async (path: string) => {
|
|
34
|
+
try {
|
|
35
|
+
return (await stat(path)).isDirectory()
|
|
36
|
+
} catch {
|
|
37
|
+
return false
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const frontmatter = (content: string) => {
|
|
42
|
+
if (!content.startsWith("---\n")) return null
|
|
43
|
+
const end = content.indexOf("\n---\n", 4)
|
|
44
|
+
return end === -1 ? null : content.slice(4, end)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const scalar = (content: string, key: string) => {
|
|
48
|
+
const match = content.match(new RegExp(`^${key}:\\s*(.+?)\\s*$`, "m"))
|
|
49
|
+
if (!match) return null
|
|
50
|
+
const value = match[1]!
|
|
51
|
+
return value.startsWith('"') && value.endsWith('"')
|
|
52
|
+
? value.slice(1, -1)
|
|
53
|
+
: value
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const activeClaim = (content: string) => {
|
|
57
|
+
const claim = content.match(/^claim:\s*\n((?:^[ \t]+.*(?:\n|$))*)/m)?.[1]
|
|
58
|
+
return claim ? /^\s+state:\s*active\s*$/m.test(claim) : false
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const discoverRoot = async (startPath: string) => {
|
|
62
|
+
let current = startPath
|
|
63
|
+
while (true) {
|
|
64
|
+
const configPath = join(current, "agency.json")
|
|
65
|
+
if (await Bun.file(configPath).exists()) return current
|
|
66
|
+
const parent = dirname(current)
|
|
67
|
+
if (parent === current) return null
|
|
68
|
+
current = parent
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const readExecution = async (
|
|
73
|
+
documentPath: string,
|
|
74
|
+
taskId: string,
|
|
75
|
+
phaseId?: string,
|
|
76
|
+
): Promise<Execution | null> => {
|
|
77
|
+
const content = frontmatter(await Bun.file(documentPath).text())
|
|
78
|
+
if (!content || /^repos:/m.test(content) || /^review:/m.test(content))
|
|
79
|
+
return null
|
|
80
|
+
const repo = scalar(content, "repo")
|
|
81
|
+
const branch = scalar(content, "branch")
|
|
82
|
+
if (!repo || !branch) return null
|
|
83
|
+
return {
|
|
84
|
+
taskId,
|
|
85
|
+
...(phaseId ? { phaseId } : {}),
|
|
86
|
+
documentPath,
|
|
87
|
+
repo,
|
|
88
|
+
branch,
|
|
89
|
+
claimActive: activeClaim(content),
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const readExecutions = async (root: string) => {
|
|
94
|
+
const tasksPath = join(root, "tasks")
|
|
95
|
+
const entries = (await readdir(tasksPath, { withFileTypes: true }))
|
|
96
|
+
.filter((entry) => entry.isDirectory())
|
|
97
|
+
.sort((left, right) => left.name.localeCompare(right.name))
|
|
98
|
+
const executions: Execution[] = []
|
|
99
|
+
for (const entry of entries) {
|
|
100
|
+
const taskPath = join(tasksPath, entry.name, "TASK.md")
|
|
101
|
+
const taskContent = frontmatter(await Bun.file(taskPath).text())
|
|
102
|
+
if (!taskContent) return null
|
|
103
|
+
if (/^phases:/m.test(taskContent)) {
|
|
104
|
+
const phasesPath = join(tasksPath, entry.name, "phases")
|
|
105
|
+
const phases = (await readdir(phasesPath, { withFileTypes: true }))
|
|
106
|
+
.filter((phase) => phase.isDirectory())
|
|
107
|
+
.sort((left, right) => left.name.localeCompare(right.name))
|
|
108
|
+
for (const phase of phases) {
|
|
109
|
+
const execution = await readExecution(
|
|
110
|
+
join(phasesPath, phase.name, "PHASE.md"),
|
|
111
|
+
entry.name,
|
|
112
|
+
phase.name,
|
|
113
|
+
)
|
|
114
|
+
if (!execution) return null
|
|
115
|
+
executions.push(execution)
|
|
116
|
+
}
|
|
117
|
+
} else {
|
|
118
|
+
const execution = await readExecution(taskPath, entry.name)
|
|
119
|
+
if (!execution) return null
|
|
120
|
+
executions.push(execution)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return executions
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const inspectRepository = async (root: string, alias: string) => {
|
|
127
|
+
const path = join(root, "repos", alias)
|
|
128
|
+
let stats
|
|
129
|
+
try {
|
|
130
|
+
stats = await lstat(path)
|
|
131
|
+
} catch {
|
|
132
|
+
return null
|
|
133
|
+
}
|
|
134
|
+
if (!stats.isDirectory() && !stats.isSymbolicLink()) return null
|
|
135
|
+
const [git, bare] = await Promise.all([
|
|
136
|
+
run(["git", "-C", path, "rev-parse", "--git-dir"]),
|
|
137
|
+
run(["git", "-C", path, "rev-parse", "--is-bare-repository"]),
|
|
138
|
+
])
|
|
139
|
+
if (git.exitCode !== 0 || bare.exitCode !== 0) return null
|
|
140
|
+
return {
|
|
141
|
+
alias,
|
|
142
|
+
path,
|
|
143
|
+
kind: stats.isSymbolicLink()
|
|
144
|
+
? ("symlink" as const)
|
|
145
|
+
: bare.stdout === "true"
|
|
146
|
+
? ("bare" as const)
|
|
147
|
+
: ("repository" as const),
|
|
148
|
+
initialized: await directoryExists(join(path, ".jj")),
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const listWorkspaces = async (repositoryPath: string) => {
|
|
153
|
+
const result = await run([
|
|
154
|
+
"jj",
|
|
155
|
+
"-R",
|
|
156
|
+
repositoryPath,
|
|
157
|
+
"--no-pager",
|
|
158
|
+
"workspace",
|
|
159
|
+
"list",
|
|
160
|
+
"-T",
|
|
161
|
+
'name ++ "\\t" ++ root ++ "\\t" ++ target.empty() ++ "\\n"',
|
|
162
|
+
])
|
|
163
|
+
if (result.exitCode !== 0) return null
|
|
164
|
+
return result.stdout
|
|
165
|
+
.split("\n")
|
|
166
|
+
.filter(Boolean)
|
|
167
|
+
.map((line): Workspace => {
|
|
168
|
+
const [, path, empty] = line.split("\t")
|
|
169
|
+
return { path: path!, dirty: empty === "false" }
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const inspectVcsStatusFast = async (startPath: string) => {
|
|
174
|
+
const root = await discoverRoot(startPath)
|
|
175
|
+
if (!root) return null
|
|
176
|
+
const config = await Bun.file(join(root, "agency.json")).json()
|
|
177
|
+
if (config?.version !== 2 || config?.vcs !== "jj") return null
|
|
178
|
+
const executions = await readExecutions(root)
|
|
179
|
+
if (!executions) return null
|
|
180
|
+
|
|
181
|
+
const localRepositories = (
|
|
182
|
+
await readdir(join(root, "repos"), {
|
|
183
|
+
withFileTypes: true,
|
|
184
|
+
})
|
|
185
|
+
)
|
|
186
|
+
.filter((entry) => !entry.name.startsWith(".agency-"))
|
|
187
|
+
.map((entry) => entry.name)
|
|
188
|
+
const aliases = [
|
|
189
|
+
...new Set([
|
|
190
|
+
...Object.keys(config.repositories ?? {}),
|
|
191
|
+
...localRepositories,
|
|
192
|
+
]),
|
|
193
|
+
].sort()
|
|
194
|
+
const repositories = await Promise.all(
|
|
195
|
+
aliases.map((alias) => inspectRepository(root, alias)),
|
|
196
|
+
)
|
|
197
|
+
if (repositories.some((repository) => repository === null)) return null
|
|
198
|
+
const repositoryRecords = repositories.filter(
|
|
199
|
+
(repository) => repository !== null,
|
|
200
|
+
)
|
|
201
|
+
if (repositoryRecords.some((repository) => !repository.initialized))
|
|
202
|
+
return null
|
|
203
|
+
|
|
204
|
+
const workspaceLists = await Promise.all(
|
|
205
|
+
repositoryRecords.map(async (repository) => ({
|
|
206
|
+
alias: repository.alias,
|
|
207
|
+
workspaces: await listWorkspaces(repository.path),
|
|
208
|
+
})),
|
|
209
|
+
)
|
|
210
|
+
if (workspaceLists.some(({ workspaces }) => workspaces === null)) return null
|
|
211
|
+
const workspacesByRepo = new Map(
|
|
212
|
+
workspaceLists.map(({ alias, workspaces }) => [alias, workspaces!]),
|
|
213
|
+
)
|
|
214
|
+
const owners = new Map<string, Execution[]>()
|
|
215
|
+
for (const execution of executions) {
|
|
216
|
+
const key = `${execution.repo}:${execution.branch}`
|
|
217
|
+
const entries = owners.get(key) ?? []
|
|
218
|
+
entries.push(execution)
|
|
219
|
+
owners.set(key, entries)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const blockers: Blocker[] = []
|
|
223
|
+
for (const execution of executions) {
|
|
224
|
+
if (!execution.claimActive) continue
|
|
225
|
+
const target = execution.phaseId
|
|
226
|
+
? `phase:${execution.taskId}/${execution.phaseId}`
|
|
227
|
+
: `task:${execution.taskId}`
|
|
228
|
+
blockers.push({
|
|
229
|
+
kind: "active-work",
|
|
230
|
+
target,
|
|
231
|
+
message: `${target} is active; finish or release it before migration`,
|
|
232
|
+
})
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
let workspaceCount = 0
|
|
236
|
+
for (const execution of executions) {
|
|
237
|
+
const checkoutPath = join(
|
|
238
|
+
dirname(execution.documentPath),
|
|
239
|
+
"code",
|
|
240
|
+
execution.repo,
|
|
241
|
+
)
|
|
242
|
+
const exists = await directoryExists(checkoutPath)
|
|
243
|
+
const expectedPath = exists
|
|
244
|
+
? await realpath(checkoutPath)
|
|
245
|
+
: resolve(checkoutPath)
|
|
246
|
+
const registered = workspacesByRepo
|
|
247
|
+
.get(execution.repo)
|
|
248
|
+
?.find((workspace) => workspace.path === expectedPath)
|
|
249
|
+
const conflicts: string[] = []
|
|
250
|
+
if ((owners.get(`${execution.repo}:${execution.branch}`)?.length ?? 0) > 1)
|
|
251
|
+
conflicts.push(
|
|
252
|
+
`Branch '${execution.branch}' for repository '${execution.repo}' has multiple Agency owners`,
|
|
253
|
+
)
|
|
254
|
+
if (registered && !exists)
|
|
255
|
+
conflicts.push(
|
|
256
|
+
`Workspace registry contains a missing checkout at ${checkoutPath}`,
|
|
257
|
+
)
|
|
258
|
+
if (exists && !registered)
|
|
259
|
+
conflicts.push(
|
|
260
|
+
`Existing checkout ${checkoutPath} is not registered as a jj workspace`,
|
|
261
|
+
)
|
|
262
|
+
if (conflicts.length > 0) {
|
|
263
|
+
blockers.push({
|
|
264
|
+
kind: "workspace-conflict",
|
|
265
|
+
target: checkoutPath,
|
|
266
|
+
message: conflicts.join("; "),
|
|
267
|
+
})
|
|
268
|
+
continue
|
|
269
|
+
}
|
|
270
|
+
if (!exists || !registered) continue
|
|
271
|
+
if (registered.dirty) {
|
|
272
|
+
blockers.push({
|
|
273
|
+
kind: "dirty-workspace",
|
|
274
|
+
target: checkoutPath,
|
|
275
|
+
message: `Workspace ${checkoutPath} must be clean before migration`,
|
|
276
|
+
})
|
|
277
|
+
continue
|
|
278
|
+
}
|
|
279
|
+
workspaceCount++
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return {
|
|
283
|
+
root,
|
|
284
|
+
configured: "jj",
|
|
285
|
+
source: "jj",
|
|
286
|
+
target: "jj",
|
|
287
|
+
available: { git: Bun.which("git") !== null, jj: Bun.which("jj") !== null },
|
|
288
|
+
repositories: repositoryRecords,
|
|
289
|
+
workspaceCount,
|
|
290
|
+
blockers,
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export const runVcsStatusFast = async (
|
|
295
|
+
json: boolean,
|
|
296
|
+
startPath: string = process.cwd(),
|
|
297
|
+
write: (message: string) => void = console.log,
|
|
298
|
+
) => {
|
|
299
|
+
const status = await inspectVcsStatusFast(startPath)
|
|
300
|
+
if (!status) return false
|
|
301
|
+
if (json) {
|
|
302
|
+
write(JSON.stringify({ version: 1, ok: true, result: status }))
|
|
303
|
+
} else {
|
|
304
|
+
write("Version control: jj")
|
|
305
|
+
write(
|
|
306
|
+
`Tools: git=${status.available.git ? "available" : "missing"} jj=${status.available.jj ? "available" : "missing"}`,
|
|
307
|
+
)
|
|
308
|
+
write(
|
|
309
|
+
`Repositories: ${status.repositories.length}; managed workspaces: ${status.workspaceCount}; blockers: ${status.blockers.length}`,
|
|
310
|
+
)
|
|
311
|
+
for (const blocker of status.blockers)
|
|
312
|
+
write(`blocker ${blocker.kind} ${blocker.target}: ${blocker.message}`)
|
|
313
|
+
}
|
|
314
|
+
return true
|
|
315
|
+
}
|
|
@@ -6,10 +6,37 @@ const managedHeaderPattern =
|
|
|
6
6
|
const checksum = (content: string) =>
|
|
7
7
|
createHash("sha256").update(content).digest("hex")
|
|
8
8
|
|
|
9
|
-
const body = `import { existsSync } from "node:fs"
|
|
10
|
-
import { join, sep } from "node:path"
|
|
9
|
+
const body = `import { existsSync, readFileSync } from "node:fs"
|
|
10
|
+
import { dirname, join, sep } from "node:path"
|
|
11
11
|
import type { Plugin } from "@opencode-ai/plugin"
|
|
12
12
|
|
|
13
|
+
const discoverWorkbase = (directory: string) => {
|
|
14
|
+
let current = directory
|
|
15
|
+
while (true) {
|
|
16
|
+
if (existsSync(join(current, "agency.json"))) return current
|
|
17
|
+
const parent = dirname(current)
|
|
18
|
+
if (parent === current) return
|
|
19
|
+
current = parent
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const discoverCheckout = (directory: string, root: string | undefined) => {
|
|
24
|
+
if (!root) return
|
|
25
|
+
let current = directory
|
|
26
|
+
while (current.startsWith(root)) {
|
|
27
|
+
for (const name of ["PHASE.md", "TASK.md"]) {
|
|
28
|
+
const document = join(current, name)
|
|
29
|
+
if (!existsSync(document)) continue
|
|
30
|
+
const repo = readFileSync(document, "utf8").match(/^repo:\\s*([^\\s]+)\\s*$/m)?.[1]
|
|
31
|
+
if (!repo) return
|
|
32
|
+
const checkout = join(current, "code", repo.replace(/^['\"]|['\"]$/g, ""))
|
|
33
|
+
return existsSync(checkout) ? checkout : undefined
|
|
34
|
+
}
|
|
35
|
+
if (current === root) return
|
|
36
|
+
current = dirname(current)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
13
40
|
const agencyContext = async (directory: string) => {
|
|
14
41
|
const task = process.env.AGENCY_TASK_ID
|
|
15
42
|
const phase = process.env.AGENCY_PHASE_ID
|
|
@@ -30,8 +57,8 @@ const agencyContext = async (directory: string) => {
|
|
|
30
57
|
const plugin: Plugin = async ({ directory }) => ({
|
|
31
58
|
config: async (config) => {
|
|
32
59
|
const context = await agencyContext(directory).catch(() => undefined)
|
|
33
|
-
const root = process.env.AGENCY_WORKBASE ?? context?.root
|
|
34
|
-
const checkout = process.env.AGENCY_WRITABLE_CHECKOUT ?? context?.checkout
|
|
60
|
+
const root = process.env.AGENCY_WORKBASE ?? context?.root ?? discoverWorkbase(directory)
|
|
61
|
+
const checkout = process.env.AGENCY_WRITABLE_CHECKOUT ?? context?.checkout ?? discoverCheckout(directory, root)
|
|
35
62
|
|
|
36
63
|
const reference = config.references?.workbase
|
|
37
64
|
if (
|