@markjaquith/agency 2.17.0 → 2.19.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.
@@ -0,0 +1,151 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test"
2
+ import { Effect } from "effect"
3
+ import { mkdir } from "node:fs/promises"
4
+ import { dirname, join } from "node:path"
5
+ import { cleanupTempDir, createTempDir, runTestEffect } from "./test-utils"
6
+ import { getWorkViews } from "./work-view"
7
+
8
+ const write = async (root: string, path: string, content: string) => {
9
+ const fullPath = join(root, path)
10
+ await mkdir(dirname(fullPath), { recursive: true })
11
+ await Bun.write(fullPath, content)
12
+ }
13
+
14
+ describe("work views", () => {
15
+ let root: string
16
+
17
+ beforeEach(async () => {
18
+ root = await createTempDir()
19
+ await write(root, "agency.json", '{"version":2}\n')
20
+ await mkdir(join(root, "repos/agency"), { recursive: true })
21
+ await write(
22
+ root,
23
+ "epics/delivery/EPIC.md",
24
+ `---
25
+ ticketUrl: https://example.com/delivery
26
+ repos:
27
+ - repo: agency
28
+ ref: main
29
+ tasks:
30
+ - id: zeta
31
+ - id: alpha
32
+ ---
33
+ `,
34
+ )
35
+ await write(
36
+ root,
37
+ "tasks/zeta/TASK.md",
38
+ `---
39
+ ticketUrl: null
40
+ epic: delivery
41
+ phases:
42
+ - id: verify
43
+ dependsOn: [implement]
44
+ - id: implement
45
+ ---
46
+ `,
47
+ )
48
+ await write(
49
+ root,
50
+ "tasks/zeta/phases/verify/PHASE.md",
51
+ `---
52
+ repo: agency
53
+ branch: feat/verify
54
+ base: main
55
+ pr: null
56
+ status: open
57
+ ---
58
+ `,
59
+ )
60
+ await write(
61
+ root,
62
+ "tasks/zeta/phases/implement/PHASE.md",
63
+ `---
64
+ repo: agency
65
+ branch: feat/implement
66
+ base: main
67
+ pr: https://github.com/example/agency/pull/1
68
+ status: dropped
69
+ ---
70
+ `,
71
+ )
72
+ await mkdir(join(root, "tasks/zeta/phases/implement/code/agency"), {
73
+ recursive: true,
74
+ })
75
+ await write(
76
+ root,
77
+ "tasks/alpha/TASK.md",
78
+ `---
79
+ ticketUrl: null
80
+ epic: delivery
81
+ repo: agency
82
+ branch: feat/alpha
83
+ base: main
84
+ pr: null
85
+ status: open
86
+ ---
87
+ `,
88
+ )
89
+ })
90
+
91
+ afterEach(async () => cleanupTempDir(root))
92
+
93
+ const views = (options: Record<string, unknown> = {}) =>
94
+ runTestEffect(Effect.suspend(() => getWorkViews({ cwd: root, ...options })))
95
+
96
+ test("uses declared graph order and exposes operational state", async () => {
97
+ const result = await views()
98
+
99
+ expect(result.taskRows.map((row) => row.id)).toEqual(["zeta", "alpha"])
100
+ expect(result.phaseRows.map((row) => row.id)).toEqual([
101
+ "verify",
102
+ "implement",
103
+ ])
104
+ expect(result.executionRows.map((row) => row.key)).toEqual([
105
+ "zeta/verify",
106
+ "zeta/implement",
107
+ "alpha",
108
+ ])
109
+ expect(result.phaseRows[0]).toMatchObject({
110
+ parent: "zeta",
111
+ status: "open",
112
+ readiness: "blocked",
113
+ repositories: "agency",
114
+ branch: "feat/verify",
115
+ pr: "absent",
116
+ worktree: "absent",
117
+ })
118
+ expect(result.phaseRows[1]).toMatchObject({
119
+ readiness: "terminal",
120
+ pr: "present",
121
+ worktree: "materialized",
122
+ })
123
+ expect(result.taskRows[0]).toMatchObject({
124
+ parent: "delivery",
125
+ branch: "multiple",
126
+ pr: "1/2 present",
127
+ worktree: "1/2 materialized",
128
+ })
129
+ })
130
+
131
+ test("composes lifecycle, repository, readiness, and PR filters", async () => {
132
+ expect(
133
+ (await views({ statuses: ["dropped"] })).executionRows.map(
134
+ (row) => row.key,
135
+ ),
136
+ ).toEqual(["zeta/implement"])
137
+ expect(
138
+ (await views({ repositories: ["agency"], blocked: true })).phaseRows.map(
139
+ (row) => row.id,
140
+ ),
141
+ ).toEqual(["verify"])
142
+ expect(
143
+ (await views({ ready: true, pr: false })).executionRows.map(
144
+ (row) => row.key,
145
+ ),
146
+ ).toEqual(["alpha"])
147
+ expect((await views({ pr: true })).phaseRows.map((row) => row.id)).toEqual([
148
+ "implement",
149
+ ])
150
+ })
151
+ })
@@ -0,0 +1,253 @@
1
+ import { Effect } from "effect"
2
+ import type { GraphNode } from "./graph-schema"
3
+ import { GraphService } from "./services/GraphService"
4
+ import type { WorkStatus } from "./workbase/schemas"
5
+
6
+ type WorkNode = Extract<
7
+ GraphNode,
8
+ { readonly kind: "epic" | "task" | "phase" | "execution-unit" }
9
+ >
10
+ type EntityNode = Exclude<WorkNode, { readonly kind: "execution-unit" }>
11
+ type ExecutionNode = Extract<WorkNode, { readonly kind: "execution-unit" }>
12
+
13
+ export interface WorkViewOptions {
14
+ readonly cwd?: string
15
+ readonly statuses?: readonly string[]
16
+ readonly repositories?: readonly string[]
17
+ readonly ready?: boolean
18
+ readonly blocked?: boolean
19
+ readonly pr?: boolean
20
+ }
21
+
22
+ export interface WorkViewRow {
23
+ readonly kind: "epic" | "task" | "phase"
24
+ readonly id: string
25
+ readonly key: string
26
+ readonly parent: string
27
+ readonly status: WorkStatus
28
+ readonly readiness: "ready" | "blocked" | "waiting" | "terminal"
29
+ readonly repositories: string
30
+ readonly branch: string
31
+ readonly pr: string
32
+ readonly worktree: string
33
+ readonly hasPr: boolean
34
+ }
35
+
36
+ const allowedStatuses = new Set<WorkStatus>([
37
+ "open",
38
+ "working",
39
+ "delegated",
40
+ "done",
41
+ "dropped",
42
+ ])
43
+
44
+ const validatedStatuses = (values: readonly string[] | undefined) =>
45
+ (values ?? []).map((value) => {
46
+ if (!allowedStatuses.has(value as WorkStatus)) {
47
+ throw new Error(
48
+ `Invalid --status value '${value}'. Expected one of: ${[...allowedStatuses].join(", ")}`,
49
+ )
50
+ }
51
+ return value as WorkStatus
52
+ })
53
+
54
+ const readinessLabel = (node: EntityNode): WorkViewRow["readiness"] =>
55
+ node.readiness.ready
56
+ ? "ready"
57
+ : node.readiness.terminal
58
+ ? "terminal"
59
+ : node.readiness.blocked
60
+ ? "blocked"
61
+ : "waiting"
62
+
63
+ const aggregateLabel = (
64
+ executions: readonly ExecutionNode[],
65
+ predicate: (node: ExecutionNode) => boolean,
66
+ singular: readonly [present: string, absent: string],
67
+ ) => {
68
+ if (executions.length === 0) return "-"
69
+ const count = executions.filter(predicate).length
70
+ if (executions.length === 1) return count === 1 ? singular[0] : singular[1]
71
+ return `${count}/${executions.length} ${singular[0]}`
72
+ }
73
+
74
+ const rowFor = (
75
+ node: EntityNode,
76
+ executions: readonly ExecutionNode[],
77
+ ): WorkViewRow => {
78
+ const branches = [
79
+ ...new Set(executions.map((execution) => execution.data.branch)),
80
+ ]
81
+ const parent =
82
+ node.kind === "task"
83
+ ? (node.data.epic ?? "-")
84
+ : node.kind === "phase"
85
+ ? node.key.slice(0, node.key.indexOf("/"))
86
+ : "-"
87
+ const id =
88
+ node.kind === "phase" ? node.key.slice(node.key.indexOf("/") + 1) : node.key
89
+
90
+ return {
91
+ kind: node.kind,
92
+ id,
93
+ key: node.key,
94
+ parent,
95
+ status: node.status,
96
+ readiness: readinessLabel(node),
97
+ repositories: node.repositories.join(",") || "-",
98
+ branch:
99
+ branches.length === 0
100
+ ? "-"
101
+ : branches.length === 1
102
+ ? branches[0]!
103
+ : "multiple",
104
+ pr: aggregateLabel(executions, (execution) => Boolean(execution.data.pr), [
105
+ "present",
106
+ "absent",
107
+ ]),
108
+ worktree: aggregateLabel(
109
+ executions,
110
+ (execution) => execution.workspace?.materialized === true,
111
+ ["materialized", "absent"],
112
+ ),
113
+ hasPr: executions.some((execution) => Boolean(execution.data.pr)),
114
+ }
115
+ }
116
+
117
+ const orderedTasks = (
118
+ epics: readonly Extract<EntityNode, { readonly kind: "epic" }>[],
119
+ tasks: ReadonlyMap<string, Extract<EntityNode, { readonly kind: "task" }>>,
120
+ ) => {
121
+ const ordered: Extract<EntityNode, { readonly kind: "task" }>[] = []
122
+ const seen = new Set<string>()
123
+ for (const epic of epics) {
124
+ for (const item of epic.data.tasks) {
125
+ const task = tasks.get(item.id)
126
+ if (task && !seen.has(task.key)) {
127
+ ordered.push(task)
128
+ seen.add(task.key)
129
+ }
130
+ }
131
+ }
132
+ for (const task of [...tasks.values()].sort((a, b) =>
133
+ a.key.localeCompare(b.key),
134
+ )) {
135
+ if (!seen.has(task.key)) ordered.push(task)
136
+ }
137
+ return ordered
138
+ }
139
+
140
+ const orderedPhases = (
141
+ task: Extract<EntityNode, { readonly kind: "task" }>,
142
+ phases: ReadonlyMap<string, Extract<EntityNode, { readonly kind: "phase" }>>,
143
+ ) => {
144
+ const ordered: Extract<EntityNode, { readonly kind: "phase" }>[] = []
145
+ const seen = new Set<string>()
146
+ if ("phases" in task.data) {
147
+ for (const item of task.data.phases) {
148
+ const phase = phases.get(`${task.key}/${item.id}`)
149
+ if (phase) {
150
+ ordered.push(phase)
151
+ seen.add(phase.key)
152
+ }
153
+ }
154
+ }
155
+ for (const phase of [...phases.values()]
156
+ .filter((value) => value.key.startsWith(`${task.key}/`))
157
+ .sort((a, b) => a.key.localeCompare(b.key))) {
158
+ if (!seen.has(phase.key)) ordered.push(phase)
159
+ }
160
+ return ordered
161
+ }
162
+
163
+ export const getWorkViews = (options: WorkViewOptions = {}) =>
164
+ Effect.gen(function* () {
165
+ const graphService = yield* GraphService
166
+ const statuses = yield* Effect.sync(() =>
167
+ validatedStatuses(options.statuses),
168
+ )
169
+ const graph = yield* graphService.get({
170
+ cwd: options.cwd,
171
+ include: ["workspace"],
172
+ })
173
+ const workNodes = graph.nodes.filter(
174
+ (node): node is WorkNode => node.kind !== "repository",
175
+ )
176
+ const epics = workNodes
177
+ .filter(
178
+ (node): node is Extract<EntityNode, { readonly kind: "epic" }> =>
179
+ node.kind === "epic",
180
+ )
181
+ .sort((a, b) => a.key.localeCompare(b.key))
182
+ const tasks = new Map(
183
+ workNodes
184
+ .filter(
185
+ (node): node is Extract<EntityNode, { readonly kind: "task" }> =>
186
+ node.kind === "task",
187
+ )
188
+ .map((node) => [node.key, node]),
189
+ )
190
+ const phases = new Map(
191
+ workNodes
192
+ .filter(
193
+ (node): node is Extract<EntityNode, { readonly kind: "phase" }> =>
194
+ node.kind === "phase",
195
+ )
196
+ .map((node) => [node.key, node]),
197
+ )
198
+ const executions = workNodes.filter(
199
+ (node): node is ExecutionNode => node.kind === "execution-unit",
200
+ )
201
+ const taskOrder = orderedTasks(epics, tasks)
202
+ const phaseOrder = taskOrder.flatMap((task) => orderedPhases(task, phases))
203
+ const executionsFor = (node: EntityNode) => {
204
+ if (node.kind === "phase") {
205
+ const separator = node.key.indexOf("/")
206
+ const taskId = node.key.slice(0, separator)
207
+ const phaseId = node.key.slice(separator + 1)
208
+ return executions.filter(
209
+ (execution) =>
210
+ execution.data.taskId === taskId &&
211
+ execution.data.phaseId === phaseId,
212
+ )
213
+ }
214
+ if (node.kind === "task") {
215
+ return executions.filter(
216
+ (execution) => execution.data.taskId === node.key,
217
+ )
218
+ }
219
+ const taskIds = new Set(node.data.tasks.map((item) => item.id))
220
+ return executions.filter((execution) =>
221
+ taskIds.has(execution.data.taskId),
222
+ )
223
+ }
224
+ const makeRows = (nodes: readonly EntityNode[]) =>
225
+ nodes.map((node) => rowFor(node, executionsFor(node)))
226
+ const filterRows = (rows: readonly WorkViewRow[]) =>
227
+ rows
228
+ .filter((row) => statuses.length === 0 || statuses.includes(row.status))
229
+ .filter(
230
+ (row) =>
231
+ !options.repositories?.length ||
232
+ row.repositories
233
+ .split(",")
234
+ .some((repo) => options.repositories!.includes(repo)),
235
+ )
236
+ .filter((row) => options.ready !== true || row.readiness === "ready")
237
+ .filter(
238
+ (row) => options.blocked !== true || row.readiness === "blocked",
239
+ )
240
+ .filter((row) => options.pr === undefined || row.hasPr === options.pr)
241
+
242
+ const epicRows = filterRows(makeRows(epics))
243
+ const taskRows = filterRows(makeRows(taskOrder))
244
+ const phaseRows = filterRows(makeRows(phaseOrder))
245
+ const executionEntities: EntityNode[] = []
246
+ for (const task of taskOrder) {
247
+ const taskPhases = orderedPhases(task, phases)
248
+ executionEntities.push(...(taskPhases.length > 0 ? taskPhases : [task]))
249
+ }
250
+ const executionRows = filterRows(makeRows(executionEntities))
251
+
252
+ return { epicRows, taskRows, phaseRows, executionRows }
253
+ })
@@ -185,23 +185,34 @@ describe("workbase registry", () => {
185
185
  test("accepts registered paths", () => {
186
186
  expect(
187
187
  Schema.decodeUnknownSync(WorkbaseRegistry)({
188
- version: 1,
189
- workbases: ["/work/one", "/work/two"],
188
+ version: 2,
189
+ workbases: [
190
+ { id: "wb-one", name: "one", path: "/work/one" },
191
+ { id: "wb-two", path: "/work/two" },
192
+ ],
193
+ defaultId: "wb-one",
190
194
  }),
191
- ).toEqual({ version: 1, workbases: ["/work/one", "/work/two"] })
195
+ ).toEqual({
196
+ version: 2,
197
+ workbases: [
198
+ { id: "wb-one", name: "one", path: "/work/one" },
199
+ { id: "wb-two", path: "/work/two" },
200
+ ],
201
+ defaultId: "wb-one",
202
+ })
192
203
  })
193
204
 
194
205
  test("rejects invalid versions and empty paths", () => {
195
206
  expect(() =>
196
207
  Schema.decodeUnknownSync(WorkbaseRegistry)({
197
- version: 2,
208
+ version: 3,
198
209
  workbases: [],
199
210
  }),
200
211
  ).toThrow()
201
212
  expect(() =>
202
213
  Schema.decodeUnknownSync(WorkbaseRegistry)({
203
- version: 1,
204
- workbases: [""],
214
+ version: 2,
215
+ workbases: [{ id: "wb-one", path: "" }],
205
216
  }),
206
217
  ).toThrow()
207
218
  })
@@ -54,11 +54,23 @@ export const WorkbaseConfig = Schema.Struct({
54
54
  worktreeCreateCommand: Schema.optional(Schema.NonEmptyArray(NonEmptyString)),
55
55
  })
56
56
 
57
- export const WorkbaseRegistry = Schema.Struct({
57
+ export const LegacyWorkbaseRegistry = Schema.Struct({
58
58
  version: Schema.Literal(1),
59
59
  workbases: Schema.Array(NonEmptyString),
60
60
  })
61
61
 
62
+ export const WorkbaseRegistration = Schema.Struct({
63
+ id: EntityId,
64
+ name: Schema.optional(EntityId),
65
+ path: NonEmptyString,
66
+ })
67
+
68
+ export const WorkbaseRegistry = Schema.Struct({
69
+ version: Schema.Literal(2),
70
+ workbases: Schema.Array(WorkbaseRegistration),
71
+ defaultId: Schema.optional(EntityId),
72
+ })
73
+
62
74
  export const Dependency = Schema.Struct({
63
75
  id: EntityId,
64
76
  dependsOn: Schema.optional(Schema.Array(EntityId)),
@@ -107,6 +119,9 @@ export const PhaseFrontmatter = Schema.Struct({
107
119
 
108
120
  export type WorkbaseConfig = Schema.Schema.Type<typeof WorkbaseConfig>
109
121
  export type WorkbaseRegistry = Schema.Schema.Type<typeof WorkbaseRegistry>
122
+ export type WorkbaseRegistration = Schema.Schema.Type<
123
+ typeof WorkbaseRegistration
124
+ >
110
125
  export type Dependency = Schema.Schema.Type<typeof Dependency>
111
126
  export type RepositoryReference = Schema.Schema.Type<typeof RepositoryReference>
112
127
  export type WorkStatus = Schema.Schema.Type<typeof WorkStatus>
@@ -30,6 +30,8 @@ export const resolveWorkbase = (
30
30
  return yield* workbase.discover(startPath).pipe(
31
31
  Effect.catchTag("WorkbaseNotFoundError", () =>
32
32
  Effect.gen(function* () {
33
+ const defaultWorkbase = yield* workbase.getDefault()
34
+ if (defaultWorkbase) return defaultWorkbase.path
33
35
  const registered = yield* workbase.listRegistered()
34
36
  if (registered.length === 0) {
35
37
  return yield* Effect.fail(