@markjaquith/agency 2.5.0 → 2.7.0

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.
@@ -136,11 +136,13 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
136
136
  })
137
137
  }
138
138
 
139
- const remote = yield* fs.runCommand(
140
- ["git", "-C", repositoryPath, "remote", "get-url", "origin"],
141
- { captureOutput: true },
142
- )
143
- if (remote.exitCode === 0) {
139
+ const fetchOrigin = Effect.gen(function* () {
140
+ const remote = yield* fs.runCommand(
141
+ ["git", "-C", repositoryPath, "remote", "get-url", "origin"],
142
+ { captureOutput: true },
143
+ )
144
+ if (remote.exitCode !== 0) return
145
+
144
146
  const fetch = yield* fs.runCommand(
145
147
  ["git", "-C", repositoryPath, "fetch", "origin"],
146
148
  { captureOutput: true },
@@ -150,7 +152,7 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
150
152
  message: `Failed to fetch '${alias}': ${fetch.stderr}`,
151
153
  })
152
154
  }
153
- }
155
+ })
154
156
 
155
157
  const listed = yield* fs.runCommand(
156
158
  [
@@ -208,6 +210,7 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
208
210
  message: `Worktree registry contains a missing checkout at ${checkoutPath}`,
209
211
  })
210
212
  }
213
+ yield* fetchOrigin
211
214
 
212
215
  let args: string[]
213
216
  let env: Record<string, string> | undefined
@@ -341,6 +344,7 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
341
344
  message: `Worktree registry contains a missing checkout at ${checkoutPath}`,
342
345
  })
343
346
  }
347
+ yield* fetchOrigin
344
348
  const result = yield* fs.runCommand(
345
349
  [
346
350
  "git",
@@ -1,4 +1,5 @@
1
1
  import { createHash } from "node:crypto"
2
+ import { join } from "node:path"
2
3
 
3
4
  const managedHeaderPattern =
4
5
  /^\/\/ agency-managed: sha256=([a-f0-9]{64})\r?\n\r?\n/
@@ -6,28 +7,36 @@ const managedHeaderPattern =
6
7
  const checksum = (content: string) =>
7
8
  createHash("sha256").update(content).digest("hex")
8
9
 
9
- const body = `${JSON.stringify(
10
- {
11
- $schema: "https://opencode.ai/config.json",
12
- references: {
13
- tasks: {
14
- path: "../tasks",
15
- description: "Agency task definitions and execution context",
10
+ const body = (root: string) =>
11
+ `${JSON.stringify(
12
+ {
13
+ $schema: "https://opencode.ai/config.json",
14
+ references: {
15
+ tasks: {
16
+ path: "../tasks",
17
+ description: "Agency task definitions and execution context",
18
+ },
19
+ epics: {
20
+ path: "../epics",
21
+ description: "Agency epic definitions and orchestration context",
22
+ },
16
23
  },
17
- epics: {
18
- path: "../epics",
19
- description: "Agency epic definitions and orchestration context",
24
+ permission: {
25
+ external_directory: {
26
+ [`${join(root, "tasks")}/*`]: "allow",
27
+ [`${join(root, "epics")}/*`]: "allow",
28
+ },
20
29
  },
21
30
  },
22
- },
23
- null,
24
- 2,
25
- )}\n`
31
+ null,
32
+ 2,
33
+ )}\n`
26
34
 
27
- const renderManagedWorkbaseOpencode = (content: string = body) =>
35
+ const renderManagedWorkbaseOpencode = (content: string) =>
28
36
  `// agency-managed: sha256=${checksum(content)}\n\n${content}`
29
37
 
30
- export const managedWorkbaseOpencode = renderManagedWorkbaseOpencode()
38
+ export const managedWorkbaseOpencode = (root: string) =>
39
+ renderManagedWorkbaseOpencode(body(root))
31
40
 
32
41
  export const canUpdateManagedWorkbaseOpencode = (content: string) => {
33
42
  const match = content.match(managedHeaderPattern)
@@ -5,7 +5,9 @@ import {
5
5
  EpicFrontmatter,
6
6
  PhaseFrontmatter,
7
7
  TaskFrontmatter,
8
+ WorkStatus,
8
9
  WorkbaseConfig,
10
+ WorkbaseRegistry,
9
11
  } from "./schemas"
10
12
 
11
13
  describe("body-of-work descriptions", () => {
@@ -76,6 +78,14 @@ describe("body-of-work descriptions", () => {
76
78
  })
77
79
 
78
80
  describe("work status", () => {
81
+ const supportedStatuses: Record<WorkStatus, true> = {
82
+ open: true,
83
+ working: true,
84
+ delegated: true,
85
+ done: true,
86
+ dropped: true,
87
+ }
88
+
79
89
  test("defaults execution units to open", () => {
80
90
  const task = Schema.decodeUnknownSync(TaskFrontmatter)({
81
91
  ticketUrl: "https://example.com/task",
@@ -95,17 +105,36 @@ describe("work status", () => {
95
105
  expect(phase.status).toBe("open")
96
106
  })
97
107
 
98
- test("accepts supported statuses and rejects other values", () => {
99
- const phase = Schema.decodeUnknownSync(PhaseFrontmatter)({
100
- repo: "agency",
101
- branch: "task/done",
102
- base: "main",
103
- pr: null,
104
- status: "done",
105
- })
106
- expect(phase.status).toBe("done")
108
+ test("accepts every supported status on tasks and phases", () => {
109
+ for (const status of Object.keys(supportedStatuses) as WorkStatus[]) {
110
+ expect(Schema.decodeUnknownSync(WorkStatus)(status)).toBe(status)
111
+
112
+ const task = Schema.decodeUnknownSync(TaskFrontmatter)({
113
+ ticketUrl: null,
114
+ repo: "agency",
115
+ branch: `task/${status}`,
116
+ base: "main",
117
+ pr: null,
118
+ status,
119
+ })
120
+ const phase = Schema.decodeUnknownSync(PhaseFrontmatter)({
121
+ repo: "agency",
122
+ branch: `phase/${status}`,
123
+ base: "main",
124
+ pr: null,
125
+ status,
126
+ })
127
+
128
+ expect("status" in task && task.status).toBe(status)
129
+ expect(phase.status).toBe(status)
130
+ }
131
+ })
132
+
133
+ test("rejects unsupported statuses on tasks and phases", () => {
134
+ expect(() => Schema.decodeUnknownSync(WorkStatus)("blocked")).toThrow()
107
135
  expect(() =>
108
- Schema.decodeUnknownSync(PhaseFrontmatter)({
136
+ Schema.decodeUnknownSync(TaskFrontmatter)({
137
+ ticketUrl: null,
109
138
  repo: "agency",
110
139
  branch: "task/invalid",
111
140
  base: "main",
@@ -113,6 +142,41 @@ describe("work status", () => {
113
142
  status: "blocked",
114
143
  }),
115
144
  ).toThrow()
145
+ expect(() =>
146
+ Schema.decodeUnknownSync(PhaseFrontmatter)({
147
+ repo: "agency",
148
+ branch: "phase/invalid",
149
+ base: "main",
150
+ pr: null,
151
+ status: "blocked",
152
+ }),
153
+ ).toThrow()
154
+ })
155
+ })
156
+
157
+ describe("workbase registry", () => {
158
+ test("accepts registered paths", () => {
159
+ expect(
160
+ Schema.decodeUnknownSync(WorkbaseRegistry)({
161
+ version: 1,
162
+ workbases: ["/work/one", "/work/two"],
163
+ }),
164
+ ).toEqual({ version: 1, workbases: ["/work/one", "/work/two"] })
165
+ })
166
+
167
+ test("rejects invalid versions and empty paths", () => {
168
+ expect(() =>
169
+ Schema.decodeUnknownSync(WorkbaseRegistry)({
170
+ version: 2,
171
+ workbases: [],
172
+ }),
173
+ ).toThrow()
174
+ expect(() =>
175
+ Schema.decodeUnknownSync(WorkbaseRegistry)({
176
+ version: 1,
177
+ workbases: [""],
178
+ }),
179
+ ).toThrow()
116
180
  })
117
181
  })
118
182
 
@@ -15,7 +15,13 @@ export const RepositoryReference = Schema.Struct({
15
15
  ref: NonEmptyString,
16
16
  })
17
17
 
18
- export const WorkStatus = Schema.Literal("open", "working", "done", "dropped")
18
+ export const WorkStatus = Schema.Literal(
19
+ "open",
20
+ "working",
21
+ "delegated",
22
+ "done",
23
+ "dropped",
24
+ )
19
25
 
20
26
  const Url = NonEmptyString.pipe(Schema.pattern(/^[a-zA-Z][a-zA-Z0-9+.-]*:/))
21
27
 
@@ -28,6 +34,11 @@ export const WorkbaseConfig = Schema.Struct({
28
34
  worktreeCreateCommand: Schema.optional(Schema.NonEmptyArray(NonEmptyString)),
29
35
  })
30
36
 
37
+ export const WorkbaseRegistry = Schema.Struct({
38
+ version: Schema.Literal(1),
39
+ workbases: Schema.Array(NonEmptyString),
40
+ })
41
+
31
42
  export const Dependency = Schema.Struct({
32
43
  id: EntityId,
33
44
  dependsOn: Schema.optional(Schema.Array(EntityId)),
@@ -74,6 +85,7 @@ export const PhaseFrontmatter = Schema.Struct({
74
85
  })
75
86
 
76
87
  export type WorkbaseConfig = Schema.Schema.Type<typeof WorkbaseConfig>
88
+ export type WorkbaseRegistry = Schema.Schema.Type<typeof WorkbaseRegistry>
77
89
  export type Dependency = Schema.Schema.Type<typeof Dependency>
78
90
  export type RepositoryReference = Schema.Schema.Type<typeof RepositoryReference>
79
91
  export type WorkStatus = Schema.Schema.Type<typeof WorkStatus>
@@ -32,6 +32,11 @@ describe("work target choices", () => {
32
32
  path: "/workbase/tasks/standalone/TASK.md",
33
33
  data: { description: "Independent work" },
34
34
  },
35
+ {
36
+ id: "delegated",
37
+ path: "/workbase/tasks/delegated/TASK.md",
38
+ data: { status: "delegated" },
39
+ },
35
40
  ],
36
41
  [
37
42
  {
@@ -63,6 +68,7 @@ describe("work target choices", () => {
63
68
  " \x1b[31m⊘\x1b[0m \x1b[33m󰔚\x1b[0m unlisted",
64
69
  " \x1b[32m✓\x1b[0m \x1b[36m󰗡\x1b[0m single",
65
70
  "\x1b[2m○\x1b[0m \x1b[36m󰗡\x1b[0m standalone\x1b[2m - Independent work\x1b[0m",
71
+ "\x1b[35m↗\x1b[0m \x1b[36m󰗡\x1b[0m delegated",
66
72
  ])
67
73
  expect(choices.map((choice) => choice.target.kind)).toEqual([
68
74
  "epic",
@@ -72,6 +78,7 @@ describe("work target choices", () => {
72
78
  "phase",
73
79
  "task",
74
80
  "task",
81
+ "task",
75
82
  ])
76
83
  })
77
84
  })
@@ -58,6 +58,7 @@ export interface WorkTargetChoice {
58
58
  const statusIcons: Record<WorkStatus, string> = {
59
59
  open: "\x1b[2m○\x1b[0m",
60
60
  working: "\x1b[34m◐\x1b[0m",
61
+ delegated: "\x1b[35m↗\x1b[0m",
61
62
  done: "\x1b[32m✓\x1b[0m",
62
63
  dropped: "\x1b[31m⊘\x1b[0m",
63
64
  }
@@ -0,0 +1,71 @@
1
+ import { Effect } from "effect"
2
+ import { resolve } from "node:path"
3
+ import { FileSystemService } from "../services/FileSystemService"
4
+ import { WorkbaseService } from "../services/WorkbaseService"
5
+
6
+ export type PickWorkbase = (
7
+ workbases: readonly string[],
8
+ ) => Effect.Effect<string | null, Error>
9
+
10
+ export const pickWorkbase: PickWorkbase = (workbases) =>
11
+ Effect.tryPromise({
12
+ try: async () => {
13
+ const input = workbases
14
+ .map((workbase, index) => `${index}\t${workbase}`)
15
+ .join("\n")
16
+ const process = Bun.spawn(
17
+ ["fzf", "--delimiter=\t", "--with-nth=2..", "--prompt=Workbase> "],
18
+ { stdin: new Blob([input]), stdout: "pipe", stderr: "inherit" },
19
+ )
20
+ const [exitCode, output] = await Promise.all([
21
+ process.exited,
22
+ new Response(process.stdout).text(),
23
+ ])
24
+ if (exitCode === 1 || exitCode === 130) return null
25
+ if (exitCode !== 0) throw new Error(`fzf exited with code ${exitCode}`)
26
+ const index = Number.parseInt(output.split("\t", 1)[0] ?? "", 10)
27
+ return workbases[index] ?? null
28
+ },
29
+ catch: (cause) =>
30
+ new Error("Failed to select a workbase with fzf", { cause }),
31
+ })
32
+
33
+ export const resolveWorkbase = (
34
+ startPath: string,
35
+ log: (message: string) => void,
36
+ pick: PickWorkbase = pickWorkbase,
37
+ ) =>
38
+ Effect.gen(function* () {
39
+ const fs = yield* FileSystemService
40
+ const workbase = yield* WorkbaseService
41
+
42
+ return yield* workbase.discover(startPath).pipe(
43
+ Effect.catchTag("WorkbaseNotFoundError", () =>
44
+ Effect.gen(function* () {
45
+ const registered = yield* workbase.listRegistered()
46
+ if (registered.length === 0) {
47
+ return yield* Effect.fail(
48
+ new Error(
49
+ `No Agency workbase found from ${resolve(startPath)}. Register one with 'agency workbase add <path>'.`,
50
+ ),
51
+ )
52
+ }
53
+
54
+ const fzf = yield* fs.runCommand(["which", "fzf"], {
55
+ captureOutput: true,
56
+ })
57
+ if (fzf.exitCode !== 0) {
58
+ for (const path of registered) log(path)
59
+ return yield* Effect.fail(
60
+ new Error(
61
+ "fzf is required to select a workbase; install fzf or run Agency from a registered workbase",
62
+ ),
63
+ )
64
+ }
65
+
66
+ const selected = yield* pick(registered)
67
+ return selected ? yield* workbase.discover(selected) : null
68
+ }),
69
+ ),
70
+ )
71
+ })