@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.
Files changed (37) hide show
  1. package/README.md +56 -20
  2. package/cli.ts +43 -2
  3. package/package.json +1 -1
  4. package/skills/agency/SKILL.md +41 -5
  5. package/src/cli.test.ts +31 -3
  6. package/src/commands/archive.test.ts +69 -0
  7. package/src/commands/archive.ts +70 -0
  8. package/src/commands/init.test.ts +3 -0
  9. package/src/commands/phase.ts +23 -1
  10. package/src/commands/task-phase.test.ts +46 -1
  11. package/src/commands/task.test.ts +94 -0
  12. package/src/commands/task.ts +160 -17
  13. package/src/commands/validate.test.ts +65 -0
  14. package/src/commands/validate.ts +19 -5
  15. package/src/commands/work.test.ts +100 -34
  16. package/src/commands/work.ts +30 -10
  17. package/src/commands/workbase.test.ts +62 -0
  18. package/src/commands/workbase.ts +58 -0
  19. package/src/services/ArchiveService.test.ts +334 -0
  20. package/src/services/ArchiveService.ts +246 -0
  21. package/src/services/PhaseService.ts +28 -0
  22. package/src/services/TaskPhaseService.test.ts +79 -0
  23. package/src/services/TaskService.ts +31 -1
  24. package/src/services/WorkbaseService.test.ts +99 -1
  25. package/src/services/WorkbaseService.ts +101 -2
  26. package/src/services/WorktreeService.test.ts +150 -1
  27. package/src/services/WorktreeService.ts +123 -1
  28. package/src/test-utils.ts +2 -0
  29. package/src/utils/progress.test.ts +37 -0
  30. package/src/utils/progress.ts +36 -0
  31. package/src/workbase/AGENTS.md +3 -0
  32. package/src/workbase/opencode-file.ts +37 -0
  33. package/src/workbase/schemas.test.ts +114 -0
  34. package/src/workbase/schemas.ts +18 -2
  35. package/src/workbase/work-target.test.ts +16 -9
  36. package/src/workbase/work-target.ts +37 -6
  37. package/src/workbase/workbase-choice.ts +71 -0
@@ -95,6 +95,7 @@ describe("task and phase command JSON output", () => {
95
95
  branch: "task/first",
96
96
  base: "main",
97
97
  pr: null,
98
+ status: "open",
98
99
  },
99
100
  },
100
101
  ])
@@ -118,6 +119,7 @@ describe("task and phase command JSON output", () => {
118
119
  branch: "task/first",
119
120
  base: "main",
120
121
  pr: null,
122
+ status: "open",
121
123
  },
122
124
  })
123
125
  })
@@ -156,10 +158,53 @@ describe("task and phase command JSON output", () => {
156
158
  expect(JSON.parse(phaseLogs[0]!)).toMatchObject({
157
159
  taskId: "another",
158
160
  id: "first",
159
- data: { repo: "agency", branch: "task/another-first", base: "main" },
161
+ data: {
162
+ repo: "agency",
163
+ branch: "task/another-first",
164
+ base: "main",
165
+ status: "open",
166
+ },
160
167
  })
161
168
  })
162
169
 
170
+ test("sets task and phase status", async () => {
171
+ const phaseLogs = await captureLogs(() =>
172
+ runTestEffect(
173
+ phase({
174
+ subcommand: "status",
175
+ args: ["multi", "first", "delegated"],
176
+ cwd: root,
177
+ json: true,
178
+ }),
179
+ ),
180
+ )
181
+ expect(JSON.parse(phaseLogs[0]!).data.status).toBe("delegated")
182
+
183
+ await runTestEffect(
184
+ task({
185
+ subcommand: "create",
186
+ args: ["single-status"],
187
+ ticketUrl: "https://example.com/task",
188
+ repo: "agency",
189
+ branch: "task/single-status",
190
+ base: "main",
191
+ cwd: root,
192
+ silent: true,
193
+ }),
194
+ )
195
+ const taskLogs = await captureLogs(() =>
196
+ runTestEffect(
197
+ task({
198
+ subcommand: "status",
199
+ args: ["single-status", "delegated"],
200
+ cwd: root,
201
+ json: true,
202
+ }),
203
+ ),
204
+ )
205
+ expect(JSON.parse(taskLogs[0]!).data.status).toBe("delegated")
206
+ })
207
+
163
208
  test("converts a single-phase task with an explicit first phase ID", async () => {
164
209
  await runTestEffect(
165
210
  task({
@@ -0,0 +1,94 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test"
2
+ import { Effect } from "effect"
3
+ import { mkdir } from "node:fs/promises"
4
+ import { join } from "node:path"
5
+ import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
6
+ import { task, type TaskInteraction } from "./task"
7
+
8
+ describe("task creation input", () => {
9
+ let root: string
10
+
11
+ beforeEach(async () => {
12
+ root = await createTempDir()
13
+ await Bun.write(join(root, "agency.json"), '{"version":2}\n')
14
+ await mkdir(join(root, "repos/agency"), { recursive: true })
15
+ })
16
+
17
+ afterEach(async () => cleanupTempDir(root))
18
+
19
+ test("guides task creation and defaults branch metadata", async () => {
20
+ await mkdir(join(root, "epics/delivery"), { recursive: true })
21
+ await Bun.write(
22
+ join(root, "epics/delivery/EPIC.md"),
23
+ "---\nticketUrl: https://example.com/delivery\nrepos:\n - repo: agency\n ref: main\ntasks: []\n---\n\n# Delivery\n",
24
+ )
25
+ const textAnswers = ["guided-task", "", ""]
26
+ const prompts: string[] = []
27
+ const interaction: TaskInteraction = {
28
+ text: (prompt) => {
29
+ prompts.push(prompt)
30
+ return Effect.succeed(textAnswers.shift() ?? "")
31
+ },
32
+ select: (prompt, choices) => {
33
+ prompts.push(`${prompt}: ${choices.join(",")}`)
34
+ return Effect.succeed(
35
+ prompt === "Parent epic"
36
+ ? "(none)"
37
+ : prompt === "Task type"
38
+ ? "single-phase"
39
+ : "agency",
40
+ )
41
+ },
42
+ }
43
+
44
+ await runTestEffect(
45
+ task(
46
+ { subcommand: "new", args: [], cwd: root, silent: true },
47
+ interaction,
48
+ ),
49
+ )
50
+
51
+ const content = await Bun.file(
52
+ join(root, "tasks/guided-task/TASK.md"),
53
+ ).text()
54
+ expect(content).toContain("ticketUrl: null")
55
+ expect(content).toContain("repo: agency")
56
+ expect(content).toContain("branch: task/guided-task")
57
+ expect(content).toContain("base: main")
58
+ expect(prompts).toEqual([
59
+ "Task ID: ",
60
+ "Ticket URL (optional): ",
61
+ "Description (optional): ",
62
+ "Parent epic: (none),delivery",
63
+ "Task type: single-phase,multi-phase",
64
+ "Writable repository: agency",
65
+ ])
66
+ })
67
+
68
+ test("keeps scripted creation non-interactive and permits no ticket URL", async () => {
69
+ const interaction: TaskInteraction = {
70
+ text: () => Effect.fail(new Error("unexpected text prompt")),
71
+ select: () => Effect.fail(new Error("unexpected selection prompt")),
72
+ }
73
+
74
+ await runTestEffect(
75
+ task(
76
+ {
77
+ subcommand: "create",
78
+ args: ["scripted-task"],
79
+ repo: "agency",
80
+ cwd: root,
81
+ silent: true,
82
+ },
83
+ interaction,
84
+ ),
85
+ )
86
+
87
+ const content = await Bun.file(
88
+ join(root, "tasks/scripted-task/TASK.md"),
89
+ ).text()
90
+ expect(content).toContain("ticketUrl: null")
91
+ expect(content).toContain("branch: task/scripted-task")
92
+ expect(content).toContain("base: main")
93
+ })
94
+ })
@@ -1,6 +1,9 @@
1
1
  import { Effect } from "effect"
2
+ import { createInterface } from "node:readline/promises"
2
3
  import type { BaseCommandOptions } from "../utils/command"
3
4
  import { TaskService } from "../services/TaskService"
5
+ import { EpicService } from "../services/EpicService"
6
+ import { RepositoryService } from "../services/RepositoryService"
4
7
  import { createLoggers } from "../utils/effect"
5
8
  import { parseRepositoryReferences } from "../workbase/repository-reference"
6
9
 
@@ -18,31 +21,151 @@ interface TaskOptions extends BaseCommandOptions {
18
21
  readonly json?: boolean
19
22
  }
20
23
 
21
- export const task = (options: TaskOptions) =>
24
+ export interface TaskInteraction {
25
+ readonly text: (prompt: string) => Effect.Effect<string, Error>
26
+ readonly select: (
27
+ prompt: string,
28
+ choices: readonly string[],
29
+ ) => Effect.Effect<string | null, Error>
30
+ }
31
+
32
+ const defaultInteraction: TaskInteraction = {
33
+ text: (prompt) =>
34
+ Effect.tryPromise({
35
+ try: async () => {
36
+ const input = createInterface({
37
+ input: process.stdin,
38
+ output: process.stderr,
39
+ })
40
+ try {
41
+ return await input.question(prompt)
42
+ } finally {
43
+ input.close()
44
+ }
45
+ },
46
+ catch: (cause) => new Error("Failed to read task input", { cause }),
47
+ }),
48
+ select: (prompt, choices) =>
49
+ Effect.tryPromise({
50
+ try: async () => {
51
+ const process = Bun.spawn(
52
+ ["fzf", `--prompt=${prompt}> `, "--height=~40%", "--reverse"],
53
+ {
54
+ stdin: new Blob([choices.join("\n")]),
55
+ stdout: "pipe",
56
+ stderr: "inherit",
57
+ },
58
+ )
59
+ const [exitCode, output] = await Promise.all([
60
+ process.exited,
61
+ new Response(process.stdout).text(),
62
+ ])
63
+ if (exitCode === 1 || exitCode === 130) return null
64
+ if (exitCode !== 0) throw new Error(`fzf exited with code ${exitCode}`)
65
+ return output.trim() || null
66
+ },
67
+ catch: (cause) =>
68
+ new Error("Failed to select task input with fzf", { cause }),
69
+ }),
70
+ }
71
+
72
+ export const task = (
73
+ options: TaskOptions,
74
+ interaction: TaskInteraction = defaultInteraction,
75
+ ) =>
22
76
  Effect.gen(function* () {
23
77
  const tasks = yield* TaskService
78
+ const epics = yield* EpicService
79
+ const repositories = yield* RepositoryService
24
80
  const { log } = createLoggers(options)
25
81
  const cwd = options.cwd ?? process.cwd()
26
82
 
27
83
  switch (options.subcommand) {
84
+ case "new":
28
85
  case "create": {
29
- const id = options.args[0]
30
- if (!id || !options.ticketUrl) {
31
- return yield* Effect.fail(
32
- new Error("Usage: agency task create <id> --ticket-url <url>"),
86
+ const interactive = options.subcommand === "new" || !options.args[0]
87
+ const id =
88
+ options.args[0] ??
89
+ (interactive
90
+ ? (yield* interaction.text("Task ID: ")).trim()
91
+ : undefined)
92
+ if (!id) {
93
+ return yield* Effect.fail(new Error("Task ID is required"))
94
+ }
95
+
96
+ let ticketUrl = options.ticketUrl?.trim() || null
97
+ let description = options.description?.trim() || undefined
98
+ let epic = options.epic
99
+ let multiPhase = options.multiPhase ?? false
100
+ let repo = options.repo
101
+
102
+ if (interactive) {
103
+ if (options.ticketUrl === undefined) {
104
+ ticketUrl =
105
+ (yield* interaction.text("Ticket URL (optional): ")).trim() ||
106
+ null
107
+ }
108
+ if (options.description === undefined) {
109
+ description =
110
+ (yield* interaction.text("Description (optional): ")).trim() ||
111
+ undefined
112
+ }
113
+ if (options.epic === undefined) {
114
+ const epicRecords = yield* epics.list(cwd)
115
+ if (epicRecords.length > 0) {
116
+ const none = "(none)"
117
+ const selected = yield* interaction.select("Parent epic", [
118
+ none,
119
+ ...epicRecords.map((record) => record.id),
120
+ ])
121
+ if (selected === null) {
122
+ return yield* Effect.fail(new Error("Task creation cancelled"))
123
+ }
124
+ epic = selected === none ? undefined : selected
125
+ }
126
+ }
127
+ if (options.multiPhase === undefined) {
128
+ const selected = yield* interaction.select("Task type", [
129
+ "single-phase",
130
+ "multi-phase",
131
+ ])
132
+ if (selected === null) {
133
+ return yield* Effect.fail(new Error("Task creation cancelled"))
134
+ }
135
+ multiPhase = selected === "multi-phase"
136
+ }
137
+ }
138
+
139
+ if (!multiPhase && !repo) {
140
+ const records = yield* repositories.list(cwd)
141
+ if (records.length === 0) {
142
+ return yield* Effect.fail(
143
+ new Error(
144
+ "No repositories found; add or link a repository first",
145
+ ),
146
+ )
147
+ }
148
+ const selected = yield* interaction.select(
149
+ "Writable repository",
150
+ records.map((record) => record.alias),
33
151
  )
152
+ if (!selected) {
153
+ return yield* Effect.fail(new Error("Task creation cancelled"))
154
+ }
155
+ repo = selected
34
156
  }
157
+
35
158
  const record = yield* tasks.create(
36
159
  {
37
160
  id,
38
- ticketUrl: options.ticketUrl,
39
- description: options.description,
40
- epic: options.epic,
41
- multiPhase: options.multiPhase,
42
- repo: options.repo,
161
+ ticketUrl,
162
+ description,
163
+ epic,
164
+ multiPhase,
165
+ repo,
43
166
  repos: parseRepositoryReferences(options.references),
44
- branch: options.branch,
45
- base: options.base,
167
+ branch: multiPhase ? undefined : (options.branch ?? `task/${id}`),
168
+ base: multiPhase ? undefined : (options.base ?? "main"),
46
169
  },
47
170
  cwd,
48
171
  )
@@ -81,9 +204,27 @@ export const task = (options: TaskOptions) =>
81
204
  )
82
205
  return
83
206
  }
207
+ case "status": {
208
+ const [id, status] = options.args
209
+ if (!id || !status) {
210
+ return yield* Effect.fail(
211
+ new Error("Usage: agency task status <id> <status>"),
212
+ )
213
+ }
214
+ const record = yield* tasks.setStatus(id, status, cwd)
215
+ const { content: _, ...output } = record
216
+ log(
217
+ options.json
218
+ ? JSON.stringify(output, null, 2)
219
+ : `Marked task '${id}' as ${record.data.status}`,
220
+ )
221
+ return
222
+ }
84
223
  default:
85
224
  return yield* Effect.fail(
86
- new Error("Subcommand is required. Available: create, list, show"),
225
+ new Error(
226
+ "Subcommand is required. Available: new, create, list, show, status",
227
+ ),
87
228
  )
88
229
  }
89
230
  })
@@ -92,19 +233,21 @@ export const help = `
92
233
  Usage: agency task <subcommand>
93
234
 
94
235
  Subcommands:
95
- create <id> Create a task
236
+ new [id] Create a task with guided input
237
+ create <id> Create a task; omitted metadata uses defaults
96
238
  list List tasks
97
239
  show <id> Show a task
240
+ status <id> <status> Set open, working, delegated, done, or dropped
98
241
 
99
242
  Create options:
100
- --ticket-url <url> External ticket URL
243
+ --ticket-url <url> External ticket URL (optional)
101
244
  --description <text> Short description of the task
102
245
  --epic <id> Parent epic
103
246
  --repo <alias> Writable repository
104
247
  --reference <alias>:<ref>
105
248
  Read-only repository reference; repeatable
106
- --branch <name> Working branch
107
- --base <name> Base branch
249
+ --branch <name> Working branch (default: task/<id>)
250
+ --base <name> Base branch (default: main)
108
251
  --multi-phase Create a task container for phases
109
252
 
110
253
  Options:
@@ -1,4 +1,5 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test"
2
+ import { Effect } from "effect"
2
3
  import { mkdir } from "node:fs/promises"
3
4
  import { join } from "node:path"
4
5
  import {
@@ -7,6 +8,9 @@ import {
7
8
  createTempDir,
8
9
  runTestEffect,
9
10
  } from "../test-utils"
11
+ import { FileSystemService } from "../services/FileSystemService"
12
+ import { WorkbaseService } from "../services/WorkbaseService"
13
+ import type { PickWorkbase } from "../workbase/workbase-choice"
10
14
  import { validate } from "./validate"
11
15
 
12
16
  describe("validate command", () => {
@@ -43,6 +47,67 @@ pr: null
43
47
  ).resolves.toBeUndefined()
44
48
  })
45
49
 
50
+ test("validates an explicit workbase path", async () => {
51
+ await Bun.write(
52
+ join(root, "tasks/example/TASK.md"),
53
+ `---
54
+ ticketUrl: null
55
+ repo: agency
56
+ branch: task/example
57
+ base: main
58
+ pr: null
59
+ ---
60
+ `,
61
+ )
62
+
63
+ await expect(
64
+ runTestEffect(
65
+ validate({ path: root, cwd: join(root, "outside"), silent: true }),
66
+ ),
67
+ ).resolves.toBeUndefined()
68
+ })
69
+
70
+ test("selects a registered workbase when local discovery fails", async () => {
71
+ const discovered: string[] = []
72
+ const workbase = {
73
+ discover: (path: string) => {
74
+ discovered.push(path)
75
+ return path === "/outside"
76
+ ? Effect.fail({
77
+ _tag: "WorkbaseNotFoundError" as const,
78
+ message: "No Agency workbase found from /outside",
79
+ })
80
+ : Effect.succeed(path)
81
+ },
82
+ listRegistered: () => Effect.succeed(["/first", "/selected"]),
83
+ validate: (path: string) =>
84
+ Effect.succeed({
85
+ root: path,
86
+ issues: [],
87
+ epicCount: 0,
88
+ taskCount: 0,
89
+ phaseCount: 0,
90
+ valid: true,
91
+ }),
92
+ }
93
+ const fs = {
94
+ runCommand: () => Effect.succeed({ exitCode: 0, stdout: "", stderr: "" }),
95
+ }
96
+ const pick: PickWorkbase = (workbases) => {
97
+ expect(workbases).toEqual(["/first", "/selected"])
98
+ return Effect.succeed("/selected")
99
+ }
100
+
101
+ await Effect.runPromise(
102
+ validate({ cwd: "/outside", silent: true }, pick).pipe(
103
+ Effect.provideService(WorkbaseService, workbase as never),
104
+ Effect.provideService(FileSystemService, fs as never),
105
+ ) as Effect.Effect<void, unknown, never>,
106
+ )
107
+
108
+ expect(discovered).toEqual(["/outside", "/selected"])
109
+ })
110
+
46
111
  test("outputs the validation report as JSON", async () => {
47
112
  await Bun.write(
48
113
  join(root, "tasks/example/TASK.md"),
@@ -2,8 +2,14 @@ import { Data, Effect } from "effect"
2
2
  import type { BaseCommandOptions } from "../utils/command"
3
3
  import { WorkbaseService } from "../services/WorkbaseService"
4
4
  import { createLoggers } from "../utils/effect"
5
+ import {
6
+ pickWorkbase,
7
+ resolveWorkbase,
8
+ type PickWorkbase,
9
+ } from "../workbase/workbase-choice"
5
10
 
6
11
  interface ValidateOptions extends BaseCommandOptions {
12
+ readonly path?: string
7
13
  readonly json?: boolean
8
14
  }
9
15
 
@@ -11,11 +17,19 @@ class ValidationFailedError extends Data.TaggedError("ValidationFailedError")<{
11
17
  readonly message: string
12
18
  }> {}
13
19
 
14
- export const validate = (options: ValidateOptions = {}) =>
20
+ export const validate = (
21
+ options: ValidateOptions = {},
22
+ pick: PickWorkbase = pickWorkbase,
23
+ ) =>
15
24
  Effect.gen(function* () {
16
25
  const workbase = yield* WorkbaseService
17
26
  const { log } = createLoggers(options)
18
- const report = yield* workbase.validate(options.cwd ?? process.cwd())
27
+ const startPath = options.path ?? options.cwd ?? process.cwd()
28
+ const root = options.path
29
+ ? yield* workbase.discover(startPath)
30
+ : yield* resolveWorkbase(startPath, log, pick)
31
+ if (!root) return
32
+ const report = yield* workbase.validate(root)
19
33
 
20
34
  if (options.json) {
21
35
  log(JSON.stringify(report, null, 2))
@@ -40,10 +54,10 @@ export const validate = (options: ValidateOptions = {}) =>
40
54
  })
41
55
 
42
56
  export const help = `
43
- Usage: agency validate [options]
57
+ Usage: agency validate [path] [options]
44
58
 
45
- Validate the current workbase configuration, frontmatter, references, and
46
- dependency graphs.
59
+ Validate a workbase's configuration, frontmatter, references, and dependency
60
+ graphs. The current or selected registered workbase is used when path is omitted.
47
61
 
48
62
  Options:
49
63
  --json Output the validation report as JSON