@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.
- package/README.md +21 -14
- package/cli.ts +28 -6
- package/package.json +1 -1
- package/skills/agency/SKILL.md +18 -9
- package/src/cli.test.ts +30 -3
- package/src/commands/init.test.ts +3 -0
- package/src/commands/phase.ts +1 -1
- package/src/commands/task-phase.test.ts +4 -4
- package/src/commands/task.ts +1 -1
- package/src/commands/validate.test.ts +65 -0
- package/src/commands/validate.ts +19 -5
- package/src/commands/work.test.ts +65 -7
- package/src/commands/work.ts +39 -10
- package/src/commands/workbase.test.ts +62 -0
- package/src/commands/workbase.ts +58 -0
- package/src/services/TaskPhaseService.test.ts +3 -3
- package/src/services/WorkbaseService.test.ts +35 -3
- package/src/services/WorkbaseService.ts +73 -6
- package/src/services/WorktreeService.test.ts +45 -0
- package/src/services/WorktreeService.ts +10 -6
- package/src/workbase/opencode-file.ts +25 -16
- package/src/workbase/schemas.test.ts +74 -10
- package/src/workbase/schemas.ts +13 -1
- package/src/workbase/work-target.test.ts +7 -0
- package/src/workbase/work-target.ts +1 -0
- package/src/workbase/workbase-choice.ts +71 -0
|
@@ -136,11 +136,13 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
136
136
|
})
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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 =
|
|
10
|
-
{
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
24
|
+
permission: {
|
|
25
|
+
external_directory: {
|
|
26
|
+
[`${join(root, "tasks")}/*`]: "allow",
|
|
27
|
+
[`${join(root, "epics")}/*`]: "allow",
|
|
28
|
+
},
|
|
20
29
|
},
|
|
21
30
|
},
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
)}\n`
|
|
31
|
+
null,
|
|
32
|
+
2,
|
|
33
|
+
)}\n`
|
|
26
34
|
|
|
27
|
-
const renderManagedWorkbaseOpencode = (content: string
|
|
35
|
+
const renderManagedWorkbaseOpencode = (content: string) =>
|
|
28
36
|
`// agency-managed: sha256=${checksum(content)}\n\n${content}`
|
|
29
37
|
|
|
30
|
-
export const managedWorkbaseOpencode =
|
|
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
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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(
|
|
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
|
|
package/src/workbase/schemas.ts
CHANGED
|
@@ -15,7 +15,13 @@ export const RepositoryReference = Schema.Struct({
|
|
|
15
15
|
ref: NonEmptyString,
|
|
16
16
|
})
|
|
17
17
|
|
|
18
|
-
export const WorkStatus = Schema.Literal(
|
|
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
|
})
|
|
@@ -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
|
+
})
|