@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.
@@ -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 (