@markjaquith/agency 2.4.0 → 2.6.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 +56 -20
- package/cli.ts +43 -2
- package/package.json +1 -1
- package/skills/agency/SKILL.md +41 -5
- package/src/cli.test.ts +31 -3
- package/src/commands/archive.test.ts +69 -0
- package/src/commands/archive.ts +70 -0
- package/src/commands/init.test.ts +3 -0
- package/src/commands/phase.ts +23 -1
- package/src/commands/task-phase.test.ts +46 -1
- package/src/commands/task.test.ts +94 -0
- package/src/commands/task.ts +160 -17
- package/src/commands/validate.test.ts +65 -0
- package/src/commands/validate.ts +19 -5
- package/src/commands/work.test.ts +100 -34
- package/src/commands/work.ts +30 -10
- package/src/commands/workbase.test.ts +62 -0
- package/src/commands/workbase.ts +58 -0
- package/src/services/ArchiveService.test.ts +334 -0
- package/src/services/ArchiveService.ts +246 -0
- package/src/services/PhaseService.ts +28 -0
- package/src/services/TaskPhaseService.test.ts +79 -0
- package/src/services/TaskService.ts +31 -1
- package/src/services/WorkbaseService.test.ts +99 -1
- package/src/services/WorkbaseService.ts +101 -2
- package/src/services/WorktreeService.test.ts +150 -1
- package/src/services/WorktreeService.ts +123 -1
- package/src/test-utils.ts +2 -0
- package/src/utils/progress.test.ts +37 -0
- package/src/utils/progress.ts +36 -0
- package/src/workbase/AGENTS.md +3 -0
- package/src/workbase/opencode-file.ts +37 -0
- package/src/workbase/schemas.test.ts +114 -0
- package/src/workbase/schemas.ts +18 -2
- package/src/workbase/work-target.test.ts +16 -9
- package/src/workbase/work-target.ts +37 -6
- package/src/workbase/workbase-choice.ts +71 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
interface ProgressOptions {
|
|
2
|
+
readonly silent?: boolean
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
interface ProgressOutput {
|
|
6
|
+
readonly isTTY: boolean
|
|
7
|
+
readonly write: (text: string) => void
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface Progress {
|
|
11
|
+
readonly start: (message: string) => void
|
|
12
|
+
readonly succeed: (message: string) => void
|
|
13
|
+
readonly fail: (message: string) => void
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const terminalOutput: ProgressOutput = {
|
|
17
|
+
isTTY: Boolean(process.stderr.isTTY),
|
|
18
|
+
write: (text) => process.stderr.write(text),
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const createProgress = (
|
|
22
|
+
options: ProgressOptions,
|
|
23
|
+
output: ProgressOutput = terminalOutput,
|
|
24
|
+
): Progress => {
|
|
25
|
+
const enabled = !options.silent && output.isTTY
|
|
26
|
+
const write = (symbol: string, message: string, complete: boolean) => {
|
|
27
|
+
if (!enabled) return
|
|
28
|
+
output.write(`\r\x1b[2K${symbol} ${message}${complete ? "\n" : ""}`)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
start: (message) => write("\x1b[2m○\x1b[0m", message, false),
|
|
33
|
+
succeed: (message) => write("\x1b[32m✓\x1b[0m", message, true),
|
|
34
|
+
fail: (message) => write("\x1b[31m✗\x1b[0m", message, true),
|
|
35
|
+
}
|
|
36
|
+
}
|
package/src/workbase/AGENTS.md
CHANGED
|
@@ -24,7 +24,10 @@ field. Repositories listed in plural `repos` are read-only references.
|
|
|
24
24
|
|
|
25
25
|
- Keep task-level decisions in `TASK.md` and phase-specific delivery context in
|
|
26
26
|
`PHASE.md`.
|
|
27
|
+
- Keep execution-unit `status` current with `agency task status` or
|
|
28
|
+
`agency phase status`; `agency work` marks launched work as `working`.
|
|
27
29
|
- Do not manually create, move, or remove worktrees under `code/`.
|
|
30
|
+
- Use `agency archive`, rather than moving work item folders manually.
|
|
28
31
|
- Do not edit bare repositories or repository symlinks under `repos/`.
|
|
29
32
|
- Do not run `agency work` from an active agent session unless the user
|
|
30
33
|
explicitly asks to launch another agent.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { createHash } from "node:crypto"
|
|
2
|
+
|
|
3
|
+
const managedHeaderPattern =
|
|
4
|
+
/^\/\/ agency-managed: sha256=([a-f0-9]{64})\r?\n\r?\n/
|
|
5
|
+
|
|
6
|
+
const checksum = (content: string) =>
|
|
7
|
+
createHash("sha256").update(content).digest("hex")
|
|
8
|
+
|
|
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",
|
|
16
|
+
},
|
|
17
|
+
epics: {
|
|
18
|
+
path: "../epics",
|
|
19
|
+
description: "Agency epic definitions and orchestration context",
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
null,
|
|
24
|
+
2,
|
|
25
|
+
)}\n`
|
|
26
|
+
|
|
27
|
+
const renderManagedWorkbaseOpencode = (content: string = body) =>
|
|
28
|
+
`// agency-managed: sha256=${checksum(content)}\n\n${content}`
|
|
29
|
+
|
|
30
|
+
export const managedWorkbaseOpencode = renderManagedWorkbaseOpencode()
|
|
31
|
+
|
|
32
|
+
export const canUpdateManagedWorkbaseOpencode = (content: string) => {
|
|
33
|
+
const match = content.match(managedHeaderPattern)
|
|
34
|
+
if (!match?.[1]) return false
|
|
35
|
+
|
|
36
|
+
return checksum(content.slice(match[0].length)) === match[1]
|
|
37
|
+
}
|
|
@@ -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", () => {
|
|
@@ -53,6 +55,15 @@ describe("body-of-work descriptions", () => {
|
|
|
53
55
|
expect(epic.description).toBeUndefined()
|
|
54
56
|
})
|
|
55
57
|
|
|
58
|
+
test("allows tasks without an external ticket", () => {
|
|
59
|
+
const task = Schema.decodeUnknownSync(TaskFrontmatter)({
|
|
60
|
+
ticketUrl: null,
|
|
61
|
+
phases: [],
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
expect(task.ticketUrl).toBeNull()
|
|
65
|
+
})
|
|
66
|
+
|
|
56
67
|
test("rejects an empty description when present", () => {
|
|
57
68
|
expect(() =>
|
|
58
69
|
Schema.decodeUnknownSync(PhaseFrontmatter)({
|
|
@@ -66,6 +77,109 @@ describe("body-of-work descriptions", () => {
|
|
|
66
77
|
})
|
|
67
78
|
})
|
|
68
79
|
|
|
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
|
+
|
|
89
|
+
test("defaults execution units to open", () => {
|
|
90
|
+
const task = Schema.decodeUnknownSync(TaskFrontmatter)({
|
|
91
|
+
ticketUrl: "https://example.com/task",
|
|
92
|
+
repo: "agency",
|
|
93
|
+
branch: "task/default-status",
|
|
94
|
+
base: "main",
|
|
95
|
+
pr: null,
|
|
96
|
+
})
|
|
97
|
+
const phase = Schema.decodeUnknownSync(PhaseFrontmatter)({
|
|
98
|
+
repo: "agency",
|
|
99
|
+
branch: "task/default-phase-status",
|
|
100
|
+
base: "main",
|
|
101
|
+
pr: null,
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
expect("status" in task && task.status).toBe("open")
|
|
105
|
+
expect(phase.status).toBe("open")
|
|
106
|
+
})
|
|
107
|
+
|
|
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()
|
|
135
|
+
expect(() =>
|
|
136
|
+
Schema.decodeUnknownSync(TaskFrontmatter)({
|
|
137
|
+
ticketUrl: null,
|
|
138
|
+
repo: "agency",
|
|
139
|
+
branch: "task/invalid",
|
|
140
|
+
base: "main",
|
|
141
|
+
pr: null,
|
|
142
|
+
status: "blocked",
|
|
143
|
+
}),
|
|
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()
|
|
180
|
+
})
|
|
181
|
+
})
|
|
182
|
+
|
|
69
183
|
describe("schema boundaries", () => {
|
|
70
184
|
const rejects = <S extends Schema.Schema.AnyNoContext>(
|
|
71
185
|
schema: S,
|
package/src/workbase/schemas.ts
CHANGED
|
@@ -15,6 +15,14 @@ export const RepositoryReference = Schema.Struct({
|
|
|
15
15
|
ref: NonEmptyString,
|
|
16
16
|
})
|
|
17
17
|
|
|
18
|
+
export const WorkStatus = Schema.Literal(
|
|
19
|
+
"open",
|
|
20
|
+
"working",
|
|
21
|
+
"delegated",
|
|
22
|
+
"done",
|
|
23
|
+
"dropped",
|
|
24
|
+
)
|
|
25
|
+
|
|
18
26
|
const Url = NonEmptyString.pipe(Schema.pattern(/^[a-zA-Z][a-zA-Z0-9+.-]*:/))
|
|
19
27
|
|
|
20
28
|
const GitHubPullRequestUrl = NonEmptyString.pipe(
|
|
@@ -26,6 +34,11 @@ export const WorkbaseConfig = Schema.Struct({
|
|
|
26
34
|
worktreeCreateCommand: Schema.optional(Schema.NonEmptyArray(NonEmptyString)),
|
|
27
35
|
})
|
|
28
36
|
|
|
37
|
+
export const WorkbaseRegistry = Schema.Struct({
|
|
38
|
+
version: Schema.Literal(1),
|
|
39
|
+
workbases: Schema.Array(NonEmptyString),
|
|
40
|
+
})
|
|
41
|
+
|
|
29
42
|
export const Dependency = Schema.Struct({
|
|
30
43
|
id: EntityId,
|
|
31
44
|
dependsOn: Schema.optional(Schema.Array(EntityId)),
|
|
@@ -37,6 +50,7 @@ const ExecutionUnit = {
|
|
|
37
50
|
branch: NonEmptyString,
|
|
38
51
|
base: NonEmptyString,
|
|
39
52
|
pr: Schema.NullOr(GitHubPullRequestUrl),
|
|
53
|
+
status: Schema.optionalWith(WorkStatus, { default: () => "open" as const }),
|
|
40
54
|
}
|
|
41
55
|
|
|
42
56
|
export const EpicFrontmatter = Schema.Struct({
|
|
@@ -47,14 +61,14 @@ export const EpicFrontmatter = Schema.Struct({
|
|
|
47
61
|
})
|
|
48
62
|
|
|
49
63
|
const SinglePhaseTaskFrontmatter = Schema.Struct({
|
|
50
|
-
ticketUrl: Url,
|
|
64
|
+
ticketUrl: Schema.NullOr(Url),
|
|
51
65
|
description: Description,
|
|
52
66
|
epic: Schema.optional(EntityId),
|
|
53
67
|
...ExecutionUnit,
|
|
54
68
|
})
|
|
55
69
|
|
|
56
70
|
const MultiPhaseTaskFrontmatter = Schema.Struct({
|
|
57
|
-
ticketUrl: Url,
|
|
71
|
+
ticketUrl: Schema.NullOr(Url),
|
|
58
72
|
description: Description,
|
|
59
73
|
epic: Schema.optional(EntityId),
|
|
60
74
|
phases: Schema.Array(Dependency),
|
|
@@ -71,8 +85,10 @@ export const PhaseFrontmatter = Schema.Struct({
|
|
|
71
85
|
})
|
|
72
86
|
|
|
73
87
|
export type WorkbaseConfig = Schema.Schema.Type<typeof WorkbaseConfig>
|
|
88
|
+
export type WorkbaseRegistry = Schema.Schema.Type<typeof WorkbaseRegistry>
|
|
74
89
|
export type Dependency = Schema.Schema.Type<typeof Dependency>
|
|
75
90
|
export type RepositoryReference = Schema.Schema.Type<typeof RepositoryReference>
|
|
91
|
+
export type WorkStatus = Schema.Schema.Type<typeof WorkStatus>
|
|
76
92
|
export type EpicFrontmatter = Schema.Schema.Type<typeof EpicFrontmatter>
|
|
77
93
|
export type TaskFrontmatter = Schema.Schema.Type<typeof TaskFrontmatter>
|
|
78
94
|
export type PhaseFrontmatter = Schema.Schema.Type<typeof PhaseFrontmatter>
|
|
@@ -25,32 +25,37 @@ describe("work target choices", () => {
|
|
|
25
25
|
{
|
|
26
26
|
id: "single",
|
|
27
27
|
path: "/workbase/tasks/single/TASK.md",
|
|
28
|
-
data: {},
|
|
28
|
+
data: { status: "done" },
|
|
29
29
|
},
|
|
30
30
|
{
|
|
31
31
|
id: "standalone",
|
|
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
|
{
|
|
38
43
|
taskId: "multi",
|
|
39
44
|
id: "verify",
|
|
40
45
|
path: "/workbase/tasks/multi/phases/verify/PHASE.md",
|
|
41
|
-
data: {},
|
|
46
|
+
data: { status: "done" },
|
|
42
47
|
},
|
|
43
48
|
{
|
|
44
49
|
taskId: "multi",
|
|
45
50
|
id: "build",
|
|
46
51
|
path: "/workbase/tasks/multi/phases/build/PHASE.md",
|
|
47
|
-
data: {},
|
|
52
|
+
data: { status: "working" },
|
|
48
53
|
},
|
|
49
54
|
{
|
|
50
55
|
taskId: "multi",
|
|
51
56
|
id: "unlisted",
|
|
52
57
|
path: "/workbase/tasks/multi/phases/unlisted/PHASE.md",
|
|
53
|
-
data: {},
|
|
58
|
+
data: { status: "dropped" },
|
|
54
59
|
},
|
|
55
60
|
],
|
|
56
61
|
)
|
|
@@ -58,11 +63,12 @@ describe("work target choices", () => {
|
|
|
58
63
|
expect(choices.map((choice) => choice.label)).toEqual([
|
|
59
64
|
"\x1b[35m\x1b[0m delivery\x1b[2m - Ship the release\x1b[0m",
|
|
60
65
|
" \x1b[36m\x1b[0m multi",
|
|
61
|
-
" \x1b[33m\x1b[0m build",
|
|
62
|
-
" \x1b[33m\x1b[0m verify",
|
|
63
|
-
" \x1b[33m\x1b[0m unlisted",
|
|
64
|
-
" \x1b[36m\x1b[0m single",
|
|
65
|
-
"\x1b[36m\x1b[0m standalone\x1b[2m - Independent work\x1b[0m",
|
|
66
|
+
" \x1b[34m◐\x1b[0m \x1b[33m\x1b[0m build",
|
|
67
|
+
" \x1b[32m✓\x1b[0m \x1b[33m\x1b[0m verify",
|
|
68
|
+
" \x1b[31m⊘\x1b[0m \x1b[33m\x1b[0m unlisted",
|
|
69
|
+
" \x1b[32m✓\x1b[0m \x1b[36m\x1b[0m single",
|
|
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
|
})
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Effect } from "effect"
|
|
2
|
+
import type { WorkStatus } from "./schemas"
|
|
2
3
|
|
|
3
4
|
export type WorkTarget =
|
|
4
5
|
| {
|
|
@@ -36,14 +37,17 @@ interface TaskRecord {
|
|
|
36
37
|
readonly description?: string
|
|
37
38
|
readonly phases: readonly { readonly id: string }[]
|
|
38
39
|
}
|
|
39
|
-
| { readonly description?: string }
|
|
40
|
+
| { readonly description?: string; readonly status?: WorkStatus }
|
|
40
41
|
}
|
|
41
42
|
|
|
42
43
|
interface PhaseRecord {
|
|
43
44
|
readonly taskId: string
|
|
44
45
|
readonly id: string
|
|
45
46
|
readonly path: string
|
|
46
|
-
readonly data: {
|
|
47
|
+
readonly data: {
|
|
48
|
+
readonly description?: string
|
|
49
|
+
readonly status?: WorkStatus
|
|
50
|
+
}
|
|
47
51
|
}
|
|
48
52
|
|
|
49
53
|
export interface WorkTargetChoice {
|
|
@@ -51,13 +55,22 @@ export interface WorkTargetChoice {
|
|
|
51
55
|
readonly target: WorkTarget
|
|
52
56
|
}
|
|
53
57
|
|
|
58
|
+
const statusIcons: Record<WorkStatus, string> = {
|
|
59
|
+
open: "\x1b[2m○\x1b[0m",
|
|
60
|
+
working: "\x1b[34m◐\x1b[0m",
|
|
61
|
+
delegated: "\x1b[35m↗\x1b[0m",
|
|
62
|
+
done: "\x1b[32m✓\x1b[0m",
|
|
63
|
+
dropped: "\x1b[31m⊘\x1b[0m",
|
|
64
|
+
}
|
|
65
|
+
|
|
54
66
|
const label = (
|
|
55
67
|
indent: string,
|
|
56
68
|
kind: WorkTarget["kind"],
|
|
57
69
|
id: string,
|
|
58
70
|
description?: string,
|
|
71
|
+
status?: WorkStatus,
|
|
59
72
|
) =>
|
|
60
|
-
`${indent}${
|
|
73
|
+
`${indent}${status === undefined ? "" : `${statusIcons[status]} `}${
|
|
61
74
|
{
|
|
62
75
|
epic: "\x1b[35m\x1b[0m",
|
|
63
76
|
task: "\x1b[36m\x1b[0m",
|
|
@@ -73,7 +86,13 @@ const taskChoices = (
|
|
|
73
86
|
const multiPhase = "phases" in task.data
|
|
74
87
|
const choices: WorkTargetChoice[] = [
|
|
75
88
|
{
|
|
76
|
-
label: label(
|
|
89
|
+
label: label(
|
|
90
|
+
indent,
|
|
91
|
+
"task",
|
|
92
|
+
task.id,
|
|
93
|
+
task.data.description,
|
|
94
|
+
multiPhase ? undefined : (task.data.status ?? "open"),
|
|
95
|
+
),
|
|
77
96
|
target: {
|
|
78
97
|
kind: "task",
|
|
79
98
|
taskId: task.id,
|
|
@@ -91,7 +110,13 @@ const taskChoices = (
|
|
|
91
110
|
if (!record) continue
|
|
92
111
|
renderedPhases.add(record.id)
|
|
93
112
|
choices.push({
|
|
94
|
-
label: label(
|
|
113
|
+
label: label(
|
|
114
|
+
`${indent} `,
|
|
115
|
+
"phase",
|
|
116
|
+
record.id,
|
|
117
|
+
record.data.description,
|
|
118
|
+
record.data.status ?? "open",
|
|
119
|
+
),
|
|
95
120
|
target: {
|
|
96
121
|
kind: "phase",
|
|
97
122
|
taskId: task.id,
|
|
@@ -103,7 +128,13 @@ const taskChoices = (
|
|
|
103
128
|
for (const record of phaseRecords) {
|
|
104
129
|
if (renderedPhases.has(record.id)) continue
|
|
105
130
|
choices.push({
|
|
106
|
-
label: label(
|
|
131
|
+
label: label(
|
|
132
|
+
`${indent} `,
|
|
133
|
+
"phase",
|
|
134
|
+
record.id,
|
|
135
|
+
record.data.description,
|
|
136
|
+
record.data.status ?? "open",
|
|
137
|
+
),
|
|
107
138
|
target: {
|
|
108
139
|
kind: "phase",
|
|
109
140
|
taskId: task.id,
|
|
@@ -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
|
+
})
|