@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
@@ -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
+ }
@@ -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,
@@ -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: { readonly description?: string }
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(indent, "task", task.id, task.data.description),
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(`${indent} `, "phase", record.id, record.data.description),
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(`${indent} `, "phase", record.id, record.data.description),
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
+ })