@markjaquith/agency 2.11.0 → 2.12.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 CHANGED
@@ -262,9 +262,10 @@ Nodes use stable IDs (`epic:<id>`, `task:<id>`, `phase:<task>/<phase>`,
262
262
  `repository:<alias>`, and `execution-unit:<kind>/<id>`). Typed edges are `owns`,
263
263
  `depends_on`, `writes`, and `references`.
264
264
 
265
- Every work node includes status, readiness, blockers, reverse dependents, and
266
- aggregate progress. Only `done` satisfies a dependency. The graph summary counts
267
- the statuses of all execution units, independent of filters.
265
+ Every work node includes status, readiness, `blockedBy`, detailed blockers,
266
+ terminal state, reverse dependents, and aggregate progress. Only `done` satisfies
267
+ a dependency; `dropped` is terminal but does not satisfy dependents. The graph
268
+ summary counts the statuses of all execution units, independent of filters.
268
269
 
269
270
  ```text
270
271
  agency graph [--json | --jsonl] [--ready | --blocked]
@@ -390,7 +391,9 @@ Single-phase tasks and phases store status in YAML. New execution units start
390
391
  `open`, and `agency work` marks the selected execution unit `working` immediately
391
392
  before launch. Use the status subcommands to mark work `delegated`, `done`,
392
393
  `dropped`, or open it again. The interactive work selector displays status
393
- markers before execution units.
394
+ markers before execution units. Open, working, and delegated work may transition
395
+ to any status. Done and dropped work are terminal and may only remain unchanged
396
+ or transition to open; reopen terminal work before changing its outcome.
394
397
 
395
398
  ### Archive
396
399
 
package/index.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export * from "./src/workbase/schemas"
2
2
  export * from "./src/protocol"
3
3
  export * from "./src/graph-schema"
4
+ export * from "./src/readiness"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "2.11.0",
3
+ "version": "2.12.0",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -125,10 +125,15 @@
125
125
  "readiness": {
126
126
  "type": "object",
127
127
  "additionalProperties": false,
128
- "required": ["ready", "blocked", "blockers"],
128
+ "required": ["ready", "blocked", "blockedBy", "terminal", "blockers"],
129
129
  "properties": {
130
130
  "ready": { "type": "boolean" },
131
131
  "blocked": { "type": "boolean" },
132
+ "blockedBy": {
133
+ "type": "array",
134
+ "items": { "type": "string" }
135
+ },
136
+ "terminal": { "type": "boolean" },
132
137
  "blockers": {
133
138
  "type": "array",
134
139
  "items": { "$ref": "#/$defs/blocker" }
@@ -14,6 +14,13 @@ describe("graph contract", () => {
14
14
  required: ["ready", "blocked", "statuses", "repositories", "kinds"],
15
15
  })
16
16
  expect(jsonSchema.$defs.node.allOf).toHaveLength(5)
17
+ expect(jsonSchema.$defs.readiness.required).toEqual([
18
+ "ready",
19
+ "blocked",
20
+ "blockedBy",
21
+ "terminal",
22
+ "blockers",
23
+ ])
17
24
  expect(jsonSchema.$defs.node.allOf[3]?.then?.properties).toMatchObject({
18
25
  status: { type: "null" },
19
26
  readiness: { type: "null" },
@@ -46,6 +46,8 @@ export const GraphProgress = Schema.Struct({
46
46
  export const GraphReadiness = Schema.Struct({
47
47
  ready: Schema.Boolean,
48
48
  blocked: Schema.Boolean,
49
+ blockedBy: Schema.Array(Schema.String),
50
+ terminal: Schema.Boolean,
49
51
  blockers: Schema.Array(GraphBlocker),
50
52
  })
51
53
 
@@ -0,0 +1,47 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import {
3
+ aggregateProgress,
4
+ canTransitionStatus,
5
+ isDependencySatisfied,
6
+ readinessState,
7
+ } from "./readiness"
8
+
9
+ describe("readiness model", () => {
10
+ test("only done satisfies dependencies and terminal states remain distinct", () => {
11
+ expect(isDependencySatisfied("done")).toBe(true)
12
+ expect(isDependencySatisfied("dropped")).toBe(false)
13
+ expect(readinessState("done", [{ id: "task:one" }])).toEqual({
14
+ ready: false,
15
+ blocked: true,
16
+ blockedBy: ["task:one"],
17
+ terminal: true,
18
+ })
19
+ expect(readinessState("working", [{ id: "claim:self" }])).toEqual({
20
+ ready: false,
21
+ blocked: true,
22
+ blockedBy: ["claim:self"],
23
+ terminal: false,
24
+ })
25
+ })
26
+
27
+ test("rolls child statuses into deterministic aggregate progress", () => {
28
+ expect(aggregateProgress(["done", "dropped"])).toEqual({
29
+ status: "dropped",
30
+ total: 2,
31
+ open: 0,
32
+ working: 0,
33
+ delegated: 0,
34
+ done: 1,
35
+ dropped: 1,
36
+ terminal: 2,
37
+ })
38
+ })
39
+
40
+ test("requires terminal work to reopen before changing its outcome", () => {
41
+ expect(canTransitionStatus("open", "done")).toBe(true)
42
+ expect(canTransitionStatus("working", "delegated")).toBe(true)
43
+ expect(canTransitionStatus("done", "dropped")).toBe(false)
44
+ expect(canTransitionStatus("dropped", "done")).toBe(false)
45
+ expect(canTransitionStatus("done", "open")).toBe(true)
46
+ })
47
+ })
@@ -0,0 +1,58 @@
1
+ import type { WorkStatus } from "./workbase/schemas"
2
+
3
+ export interface ReadinessBlocker {
4
+ readonly id: string
5
+ }
6
+
7
+ export const WORK_STATUS_TRANSITIONS = {
8
+ open: ["open", "working", "delegated", "done", "dropped"],
9
+ working: ["open", "working", "delegated", "done", "dropped"],
10
+ delegated: ["open", "working", "delegated", "done", "dropped"],
11
+ done: ["open", "done"],
12
+ dropped: ["open", "dropped"],
13
+ } as const satisfies Record<WorkStatus, readonly WorkStatus[]>
14
+
15
+ export const isTerminalStatus = (status: WorkStatus) =>
16
+ status === "done" || status === "dropped"
17
+
18
+ export const isDependencySatisfied = (status: WorkStatus | undefined) =>
19
+ status === "done"
20
+
21
+ export const canTransitionStatus = (from: WorkStatus, to: WorkStatus) =>
22
+ (WORK_STATUS_TRANSITIONS[from] as readonly WorkStatus[]).includes(to)
23
+
24
+ export const aggregateProgress = (statuses: readonly WorkStatus[]) => {
25
+ const counts = {
26
+ total: statuses.length,
27
+ open: statuses.filter((status) => status === "open").length,
28
+ working: statuses.filter((status) => status === "working").length,
29
+ delegated: statuses.filter((status) => status === "delegated").length,
30
+ done: statuses.filter((status) => status === "done").length,
31
+ dropped: statuses.filter((status) => status === "dropped").length,
32
+ terminal: statuses.filter(isTerminalStatus).length,
33
+ }
34
+ const status: WorkStatus =
35
+ statuses.length === 0
36
+ ? "open"
37
+ : statuses.every((value) => value === "done")
38
+ ? "done"
39
+ : statuses.every(isTerminalStatus)
40
+ ? "dropped"
41
+ : statuses.includes("working")
42
+ ? "working"
43
+ : statuses.includes("delegated")
44
+ ? "delegated"
45
+ : "open"
46
+ return { status, ...counts }
47
+ }
48
+
49
+ export const readinessState = (
50
+ status: WorkStatus,
51
+ blockers: readonly ReadinessBlocker[],
52
+ ready = status === "open" && blockers.length === 0,
53
+ ) => ({
54
+ ready,
55
+ blocked: !ready && blockers.length > 0,
56
+ blockedBy: [...new Set(blockers.map((blocker) => blocker.id))].sort(),
57
+ terminal: isTerminalStatus(status),
58
+ })
@@ -4,6 +4,7 @@ import { join, relative, resolve, sep } from "node:path"
4
4
  import { FileSystemService } from "./FileSystemService"
5
5
  import { WorkbaseService } from "./WorkbaseService"
6
6
  import { RepositoryService } from "./RepositoryService"
7
+ import { aggregateProgress, readinessState } from "../readiness"
7
8
  import { parseFrontmatter } from "../workbase/frontmatter"
8
9
  import {
9
10
  EpicFrontmatter,
@@ -63,29 +64,6 @@ interface ReferenceCheckout extends CheckoutInspection {
63
64
  readonly resolvedCommit: string | null
64
65
  }
65
66
 
66
- const statusCounts = (statuses: readonly WorkStatus[]) => ({
67
- total: statuses.length,
68
- open: statuses.filter((status) => status === "open").length,
69
- working: statuses.filter((status) => status === "working").length,
70
- delegated: statuses.filter((status) => status === "delegated").length,
71
- done: statuses.filter((status) => status === "done").length,
72
- dropped: statuses.filter((status) => status === "dropped").length,
73
- terminal: statuses.filter(
74
- (status) => status === "done" || status === "dropped",
75
- ).length,
76
- })
77
-
78
- const aggregateStatus = (statuses: readonly WorkStatus[]): WorkStatus => {
79
- if (statuses.length === 0) return "open"
80
- if (statuses.every((status) => status === "done")) return "done"
81
- if (statuses.every((status) => status === "done" || status === "dropped")) {
82
- return "dropped"
83
- }
84
- if (statuses.includes("working")) return "working"
85
- if (statuses.includes("delegated")) return "delegated"
86
- return "open"
87
- }
88
-
89
67
  const decode = <S extends Schema.Schema.AnyNoContext>(
90
68
  schema: S,
91
69
  input: unknown,
@@ -337,13 +315,13 @@ export class ContextService extends Effect.Service<ContextService>()(
337
315
  return (record.data as PhaseData).status
338
316
  const data = record.data as TaskData
339
317
  if (!("phases" in data)) return data.status
340
- return aggregateStatus(
318
+ return aggregateProgress(
341
319
  data.phases.map(
342
320
  (item) =>
343
321
  phaseDocuments.get(`${record.taskId}/${item.id}`)?.data
344
322
  .status ?? "open",
345
323
  ),
346
- )
324
+ ).status
347
325
  }
348
326
 
349
327
  const taskDependencyEntries = (
@@ -551,7 +529,7 @@ export class ContextService extends Effect.Service<ContextService>()(
551
529
  )
552
530
  : [child.data.status]
553
531
  })
554
- targetStatus = aggregateStatus(aggregateStatuses)
532
+ targetStatus = aggregateProgress(aggregateStatuses).status
555
533
  descendantsReady = epic.data.tasks.some((item: Dependency) =>
556
534
  taskReady(item.id),
557
535
  )
@@ -832,14 +810,10 @@ export class ContextService extends Effect.Service<ContextService>()(
832
810
  dependencies,
833
811
  dependents,
834
812
  readiness: {
835
- ready,
836
- blocked: !ready && blockers.length > 0,
813
+ ...readinessState(targetStatus, blockers, ready),
837
814
  blockers,
838
815
  },
839
- aggregate: {
840
- status: aggregateStatus(aggregateStatuses),
841
- ...statusCounts(aggregateStatuses),
842
- },
816
+ aggregate: aggregateProgress(aggregateStatuses),
843
817
  },
844
818
  authority: {
845
819
  mode: executionData ? "execution" : "orchestration",
@@ -154,12 +154,33 @@ describe("GraphService", () => {
154
154
  )!
155
155
  const verify = graph.nodes.find((node) => node.id === "phase:ship/verify")!
156
156
  expect(prepare.dependents).toEqual(["task:ship"])
157
- expect(ship.readiness).toMatchObject({ ready: true, blocked: false })
157
+ expect(prepare.readiness).toMatchObject({
158
+ ready: false,
159
+ terminal: true,
160
+ })
161
+ expect(ship.readiness).toMatchObject({
162
+ ready: true,
163
+ blocked: false,
164
+ terminal: false,
165
+ })
166
+ expect(ship.aggregate).toMatchObject({ total: 2, open: 2, terminal: 0 })
158
167
  expect(implement.dependents).toEqual(["phase:ship/verify"])
159
168
  expect(implement.readiness).toMatchObject({ ready: true, blocked: false })
169
+ expect(
170
+ graph.nodes.find(
171
+ (node) => node.id === "execution-unit:phase/ship/implement",
172
+ )?.readiness,
173
+ ).toMatchObject({
174
+ ready: true,
175
+ blocked: false,
176
+ blockedBy: [],
177
+ terminal: false,
178
+ })
160
179
  expect(verify.readiness).toMatchObject({
161
180
  ready: false,
162
181
  blocked: true,
182
+ blockedBy: ["phase:ship/implement"],
183
+ terminal: false,
163
184
  blockers: [
164
185
  {
165
186
  kind: "dependency",
@@ -168,6 +189,8 @@ describe("GraphService", () => {
168
189
  },
169
190
  ],
170
191
  })
192
+ const epic = graph.nodes.find((node) => node.id === "epic:delivery")!
193
+ expect(epic.aggregate).toMatchObject({ total: 3, done: 1, open: 2 })
171
194
  expect(graph.summary).toEqual({
172
195
  status: "open",
173
196
  total: 3,
@@ -181,6 +204,36 @@ describe("GraphService", () => {
181
204
  expect(await getGraph(root)).toEqual(graph)
182
205
  })
183
206
 
207
+ test("never reports claimed or terminal execution units as ready", async () => {
208
+ const root = await createWorkbase()
209
+ roots.push(root)
210
+ const path = "tasks/ship/phases/implement/PHASE.md"
211
+
212
+ for (const status of ["working", "delegated", "done", "dropped"]) {
213
+ await write(
214
+ root,
215
+ path,
216
+ `---
217
+ repo: agency
218
+ branch: feat/implement
219
+ base: main
220
+ pr: null
221
+ status: ${status}
222
+ ---
223
+
224
+ # Implement
225
+ `,
226
+ )
227
+ const graph = await getGraph(root, {
228
+ kinds: ["execution-unit"],
229
+ ready: true,
230
+ })
231
+ expect(graph.nodes.map((node) => node.key)).not.toContain(
232
+ "phase/ship/implement",
233
+ )
234
+ }
235
+ })
236
+
184
237
  test("applies filters after computing graph state", async () => {
185
238
  const root = await createWorkbase()
186
239
  roots.push(root)
@@ -235,6 +288,7 @@ status: open
235
288
  reason: "Unlisted phase 'orphan'",
236
289
  },
237
290
  ],
291
+ blockedBy: ["tasks/ship/TASK.md"],
238
292
  })
239
293
  })
240
294
 
@@ -12,9 +12,13 @@ import {
12
12
  type GraphNode,
13
13
  type GraphNodeKind,
14
14
  type GraphPr,
15
- type GraphProgress,
16
15
  type GraphRepositoryGit,
17
16
  } from "../graph-schema"
17
+ import {
18
+ aggregateProgress,
19
+ isDependencySatisfied,
20
+ readinessState,
21
+ } from "../readiness"
18
22
  import { parseFrontmatter } from "../workbase/frontmatter"
19
23
  import {
20
24
  EpicFrontmatter,
@@ -69,33 +73,6 @@ const taskExecutionNodeId = (taskId: string) => `execution-unit:task/${taskId}`
69
73
  const phaseExecutionNodeId = (taskId: string, phaseId: string) =>
70
74
  `execution-unit:phase/${taskId}/${phaseId}`
71
75
 
72
- const progress = (statuses: readonly WorkStatus[]): GraphProgress => {
73
- const counts = {
74
- total: statuses.length,
75
- open: statuses.filter((status) => status === "open").length,
76
- working: statuses.filter((status) => status === "working").length,
77
- delegated: statuses.filter((status) => status === "delegated").length,
78
- done: statuses.filter((status) => status === "done").length,
79
- dropped: statuses.filter((status) => status === "dropped").length,
80
- terminal: statuses.filter(
81
- (status) => status === "done" || status === "dropped",
82
- ).length,
83
- }
84
- const status: WorkStatus =
85
- statuses.length === 0
86
- ? "open"
87
- : statuses.every((value) => value === "done")
88
- ? "done"
89
- : statuses.every((value) => value === "done" || value === "dropped")
90
- ? "dropped"
91
- : statuses.includes("working")
92
- ? "working"
93
- : statuses.includes("delegated")
94
- ? "delegated"
95
- : "open"
96
- return { status, ...counts }
97
- }
98
-
99
76
  const hash = (content: string) =>
100
77
  new Bun.CryptoHasher("sha256").update(content).digest("hex")
101
78
 
@@ -256,7 +233,7 @@ export class GraphService extends Effect.Service<GraphService>()(
256
233
  : [task.data.status]
257
234
  }
258
235
  const taskStatus = (taskId: string) =>
259
- progress(taskLeafStatuses(taskId)).status
236
+ aggregateProgress(taskLeafStatuses(taskId)).status
260
237
  const dependencyBlockers = (
261
238
  dependencies: readonly string[],
262
239
  toId: (id: string) => string,
@@ -265,7 +242,7 @@ export class GraphService extends Effect.Service<GraphService>()(
265
242
  ): GraphBlocker[] =>
266
243
  dependencies.flatMap((dependency) => {
267
244
  const value = status(dependency)
268
- return value === "done"
245
+ return isDependencySatisfied(value)
269
246
  ? []
270
247
  : [
271
248
  {
@@ -336,10 +313,9 @@ export class GraphService extends Effect.Service<GraphService>()(
336
313
  }
337
314
  return {
338
315
  status,
339
- aggregate: progress([status]),
316
+ aggregate: aggregateProgress([status]),
340
317
  readiness: {
341
- ready: status === "open" && blockers.length === 0,
342
- blocked: status === "open" && blockers.length > 0,
318
+ ...readinessState(status, blockers),
343
319
  blockers: uniqueBlockers(blockers),
344
320
  },
345
321
  }
@@ -348,7 +324,7 @@ export class GraphService extends Effect.Service<GraphService>()(
348
324
  const taskState = (taskId: string) => {
349
325
  const task = tasks.get(taskId)
350
326
  const statuses = taskLeafStatuses(taskId)
351
- const aggregate = progress(statuses)
327
+ const aggregate = aggregateProgress(statuses)
352
328
  const descendantPaths = task
353
329
  ? [
354
330
  relative(root, task.path),
@@ -410,8 +386,7 @@ export class GraphService extends Effect.Service<GraphService>()(
410
386
  status: aggregate.status,
411
387
  aggregate,
412
388
  readiness: {
413
- ready,
414
- blocked: !ready && taskBlockers.length > 0,
389
+ ...readinessState(aggregate.status, taskBlockers, ready),
415
390
  blockers: taskBlockers,
416
391
  },
417
392
  }
@@ -422,7 +397,7 @@ export class GraphService extends Effect.Service<GraphService>()(
422
397
  const statuses =
423
398
  epic?.data.tasks.flatMap((item) => taskLeafStatuses(item.id)) ??
424
399
  []
425
- const aggregate = progress(statuses)
400
+ const aggregate = aggregateProgress(statuses)
426
401
  const paths = epic
427
402
  ? [
428
403
  relative(root, epic.path),
@@ -467,8 +442,7 @@ export class GraphService extends Effect.Service<GraphService>()(
467
442
  status: aggregate.status,
468
443
  aggregate,
469
444
  readiness: {
470
- ready,
471
- blocked: !ready && epicBlockers.length > 0,
445
+ ...readinessState(aggregate.status, epicBlockers, ready),
472
446
  blockers: epicBlockers,
473
447
  },
474
448
  }
@@ -874,7 +848,7 @@ export class GraphService extends Effect.Service<GraphService>()(
874
848
  includes,
875
849
  nodes: filteredNodes,
876
850
  edges: filteredEdges,
877
- summary: progress(leafStatuses),
851
+ summary: aggregateProgress(leafStatuses),
878
852
  validation: {
879
853
  valid: validation.valid,
880
854
  issues: validation.issues,
@@ -15,6 +15,7 @@ import {
15
15
  formatMarkdownDocument,
16
16
  parseFrontmatter,
17
17
  } from "../workbase/frontmatter"
18
+ import { canTransitionStatus } from "../readiness"
18
19
 
19
20
  class PhaseError extends Data.TaggedError("PhaseError")<{
20
21
  readonly message: string
@@ -340,6 +341,11 @@ export class PhaseService extends Effect.Service<PhaseService>()(
340
341
  const service = yield* PhaseService
341
342
  const validStatus = yield* decodeStatus(status)
342
343
  const record = yield* service.show(taskId, id, startPath)
344
+ if (!canTransitionStatus(record.data.status, validStatus)) {
345
+ return yield* new PhaseError({
346
+ message: `Cannot transition phase '${id}' from ${record.data.status} to ${validStatus}; reopen it first`,
347
+ })
348
+ }
343
349
  const parsed = yield* parseFrontmatter(record.content, record.path)
344
350
  const data = { ...record.data, status: validStatus }
345
351
  const content = formatMarkdownDocument(data, parsed.body)
@@ -244,6 +244,22 @@ describe("task and phase services", () => {
244
244
  expect(task.data.status).toBe("delegated")
245
245
  expect(task.content).toContain("status: delegated")
246
246
  expect(task.content).toContain("Describe the task outcome.")
247
+ await runTestEffect(
248
+ TaskService.pipe(
249
+ Effect.flatMap((service) =>
250
+ service.setStatus("single-status", "done", root),
251
+ ),
252
+ ),
253
+ )
254
+ await expect(
255
+ runTestEffect(
256
+ TaskService.pipe(
257
+ Effect.flatMap((service) =>
258
+ service.setStatus("single-status", "dropped", root),
259
+ ),
260
+ ),
261
+ ),
262
+ ).rejects.toThrow("reopen it first")
247
263
 
248
264
  await runTestEffect(
249
265
  TaskService.pipe(
@@ -283,6 +299,33 @@ describe("task and phase services", () => {
283
299
  ),
284
300
  )
285
301
  expect(phase.data.status).toBe("dropped")
302
+ await expect(
303
+ runTestEffect(
304
+ PhaseService.pipe(
305
+ Effect.flatMap((service) =>
306
+ service.setStatus("multi-status", "implementation", "done", root),
307
+ ),
308
+ ),
309
+ ),
310
+ ).rejects.toThrow("reopen it first")
311
+ await runTestEffect(
312
+ PhaseService.pipe(
313
+ Effect.flatMap((service) =>
314
+ service.setStatus("multi-status", "implementation", "open", root),
315
+ ),
316
+ ),
317
+ )
318
+ expect(
319
+ (
320
+ await runTestEffect(
321
+ PhaseService.pipe(
322
+ Effect.flatMap((service) =>
323
+ service.setStatus("multi-status", "implementation", "done", root),
324
+ ),
325
+ ),
326
+ )
327
+ ).data.status,
328
+ ).toBe("done")
286
329
  await expect(
287
330
  runTestEffect(
288
331
  TaskService.pipe(
@@ -15,6 +15,7 @@ import {
15
15
  formatMarkdownDocument,
16
16
  parseFrontmatter,
17
17
  } from "../workbase/frontmatter"
18
+ import { canTransitionStatus } from "../readiness"
18
19
 
19
20
  class TaskError extends Data.TaggedError("TaskError")<{
20
21
  readonly message: string
@@ -212,6 +213,11 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
212
213
  message: `Task '${id}' has multiple phases; set status on a phase instead`,
213
214
  })
214
215
  }
216
+ if (!canTransitionStatus(record.data.status, validStatus)) {
217
+ return yield* new TaskError({
218
+ message: `Cannot transition task '${id}' from ${record.data.status} to ${validStatus}; reopen it first`,
219
+ })
220
+ }
215
221
  const parsed = yield* parseFrontmatter(record.content, record.path)
216
222
  const data = { ...record.data, status: validStatus }
217
223
  const content = formatMarkdownDocument(data, parsed.body)