@markjaquith/agency 2.11.0 → 2.13.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
 
@@ -409,6 +412,7 @@ before moving files, refuses dirty worktrees, and preserves branches.
409
412
 
410
413
  ```text
411
414
  agency work [<directory> | --epic <epic-id>] [--opencode | --claude]
415
+ agency work prepare [target] [--dry-run] [--json]
412
416
  agency pr create <task-id> [phase-id] [--draft] [--json]
413
417
  ```
414
418
 
@@ -418,6 +422,12 @@ workbase, Agency first presents the registered workbases, then the selected
418
422
  workbase's hierarchy. If `fzf` is not installed, Agency prints the available
419
423
  choices and asks for an explicit directory.
420
424
 
425
+ `agency work prepare` resolves an execution unit and creates or reuses its
426
+ writable and reference worktrees without launching an agent or changing status.
427
+ Its JSON result includes document and checkout paths, resolved commits, actions,
428
+ and Git operations. Use `--dry-run` to report planned fetch, branch, and worktree
429
+ changes without applying them.
430
+
421
431
  Epic and multi-phase task targets launch orchestration agents beside their
422
432
  documents. Single-phase tasks and phases fetch repositories, create or reuse
423
433
  worktrees under `code/`, and launch an execution agent in the writable checkout
package/cli.ts CHANGED
@@ -5,7 +5,7 @@ import { parseCli } from "./src/cli-parser"
5
5
  import { init, help as initHelp } from "./src/commands/init"
6
6
  import { task, help as taskHelp } from "./src/commands/task"
7
7
  import { pr, help as prHelp } from "./src/commands/pr"
8
- import { work, help as workHelp } from "./src/commands/work"
8
+ import { work, workPrepare, help as workHelp } from "./src/commands/work"
9
9
  import { status, help as statusHelp } from "./src/commands/status"
10
10
  import { validate, help as validateHelp } from "./src/commands/validate"
11
11
  import { context, help as contextHelp } from "./src/commands/context"
@@ -274,10 +274,13 @@ const commands: Record<string, Command> = {
274
274
  console.log(workHelp)
275
275
  return
276
276
  }
277
+ const preparing = args[0] === "prepare"
277
278
  await runCommand(
278
- work({
279
- directory: args[0],
279
+ (preparing ? workPrepare : work)({
280
+ directory: args[preparing ? 1 : 0],
280
281
  epicId: options.epic,
282
+ json: options.json,
283
+ dryRun: options["dry-run"],
281
284
  silent: options.silent,
282
285
  verbose: options.verbose,
283
286
  opencode: options.opencode,
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.13.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" }
@@ -155,6 +155,22 @@ describe("strict CLI parsing", () => {
155
155
  expectUsageError(["status", "--compact"], "agency status")
156
156
  })
157
157
 
158
+ test("accepts work preparation options only for the prepare subcommand", () => {
159
+ expect(
160
+ parseCli(["work", "prepare", "tasks/example", "--dry-run", "--json"]),
161
+ ).toMatchObject({
162
+ commandName: "work",
163
+ args: ["prepare", "tasks/example"],
164
+ values: { "dry-run": true, json: true },
165
+ })
166
+ expect(() => parseCli(["work", "example", "--dry-run"])).toThrow(
167
+ "only valid with 'agency work prepare'",
168
+ )
169
+ expect(() => parseCli(["work", "prepare", "--opencode"])).toThrow(
170
+ "cannot be combined",
171
+ )
172
+ })
173
+
158
174
  test("accepts repeatable graph filters and rejects output conflicts", () => {
159
175
  expect(
160
176
  parseCli([
package/src/cli-parser.ts CHANGED
@@ -283,18 +283,22 @@ const commands = {
283
283
  },
284
284
  },
285
285
  work: {
286
- usage: "agency work [<directory-or-task-id> | --epic <epic-id>]",
286
+ usage:
287
+ "agency work [<directory-or-task-id> | --epic <epic-id>] | agency work prepare [target] [--dry-run] [--json]",
287
288
  options: {
288
289
  ...commonOptions,
290
+ json: { type: "boolean" },
291
+ "dry-run": { type: "boolean" },
289
292
  epic: { type: "string" },
290
293
  opencode: { type: "boolean" },
291
294
  claude: { type: "boolean" },
292
295
  },
293
296
  command: {
294
- usage: "agency work [<directory-or-task-id> | --epic <epic-id>]",
297
+ usage:
298
+ "agency work [<directory-or-task-id> | --epic <epic-id>] | agency work prepare [target] [--dry-run] [--json]",
295
299
  minArgs: 0,
296
- maxArgs: 1,
297
- options: ["epic", "opencode", "claude"],
300
+ maxArgs: 2,
301
+ options: ["json", "dry-run", "epic", "opencode", "claude"],
298
302
  conflicts: [
299
303
  ["opencode", "claude"],
300
304
  ["epic", "$positional"],
@@ -649,6 +653,27 @@ export function parseCli(args: readonly string[]): ParsedCli {
649
653
  if (commandName === "graph") {
650
654
  validateGraphOptions(parsed.values, spec)
651
655
  }
656
+ if (commandName === "work") {
657
+ const preparing = commandPositionals[0] === "prepare"
658
+ if (
659
+ (!preparing && commandPositionals.length > 1) ||
660
+ (preparing &&
661
+ (parsed.values.epic || parsed.values.opencode || parsed.values.claude))
662
+ ) {
663
+ throw usageError(
664
+ preparing
665
+ ? "Work preparation cannot be combined with agent or epic options."
666
+ : "The work command accepts at most one target.",
667
+ spec.usage,
668
+ )
669
+ }
670
+ if (!preparing && (parsed.values.json || parsed.values["dry-run"])) {
671
+ throw usageError(
672
+ "Options '--json' and '--dry-run' are only valid with 'agency work prepare'.",
673
+ spec.usage,
674
+ )
675
+ }
676
+ }
652
677
 
653
678
  return {
654
679
  commandName: commandName as keyof typeof commands,
package/src/cli.test.ts CHANGED
@@ -301,6 +301,76 @@ status: open
301
301
  })
302
302
  })
303
303
 
304
+ test("prepares a workspace and reports a non-mutating dry-run", async () => {
305
+ const parent = await createTempDir()
306
+ tempDirs.push(parent)
307
+ const root = join(parent, "workbase")
308
+ const source = join(parent, "source")
309
+ expect(
310
+ Bun.spawnSync(["git", "init", "--initial-branch=main", source]).exitCode,
311
+ ).toBe(0)
312
+ await Bun.write(join(source, "README.md"), "example\n")
313
+ for (const args of [
314
+ ["config", "user.email", "test@example.com"],
315
+ ["config", "user.name", "Test"],
316
+ ["add", "README.md"],
317
+ ["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
318
+ ]) {
319
+ expect(Bun.spawnSync(["git", "-C", source, ...args]).exitCode).toBe(0)
320
+ }
321
+
322
+ parseJson(await runCli(["init", root, "--json"], parent))
323
+ parseJson(await runCli(["repo", "link", "agency", source, "--json"], root))
324
+ parseJson(
325
+ await runCli(
326
+ [
327
+ "task",
328
+ "create",
329
+ "example",
330
+ "--repo",
331
+ "agency",
332
+ "--branch",
333
+ "feat/example",
334
+ "--base",
335
+ "main",
336
+ "--json",
337
+ ],
338
+ root,
339
+ ),
340
+ )
341
+
342
+ const planned = parseJson(
343
+ await runCli(["work", "prepare", "example", "--dry-run", "--json"], root),
344
+ )
345
+ const workbaseRoot = await realpath(root)
346
+ expect(planned).toMatchObject({
347
+ dryRun: true,
348
+ taskPath: join(workbaseRoot, "tasks/example/TASK.md"),
349
+ phasePath: null,
350
+ checkouts: [
351
+ {
352
+ repo: "agency",
353
+ kind: "writable",
354
+ action: "created",
355
+ resolvedCommit: expect.stringMatching(/^[0-9a-f]{40}$/),
356
+ },
357
+ ],
358
+ })
359
+ await expect(access(join(root, "tasks/example/code"))).rejects.toThrow()
360
+
361
+ const prepared = parseJson(
362
+ await runCli(["work", "prepare", "example", "--json"], root),
363
+ )
364
+ expect(prepared).toMatchObject({
365
+ dryRun: false,
366
+ checkouts: [{ action: "created", kind: "writable" }],
367
+ })
368
+ const task = parseJson(
369
+ await runCli(["task", "show", "example", "--json"], root),
370
+ )
371
+ expect(task.data.status).toBe("open")
372
+ })
373
+
304
374
  test("envelopes help and version output in machine mode", async () => {
305
375
  const help = await runCli(["status", "--help", "--json"])
306
376
  expect(parseJson(help)).toContain("Usage: agency status")
@@ -7,7 +7,7 @@ import { TaskService } from "../services/TaskService"
7
7
  import { PhaseService } from "../services/PhaseService"
8
8
  import { WorktreeService } from "../services/WorktreeService"
9
9
  import { captureErrors, captureLogs } from "../test-utils"
10
- import { work } from "./work"
10
+ import { work, workPrepare } from "./work"
11
11
  import type { PickWorkTarget } from "../workbase/work-target"
12
12
  import type { PickWorkbase } from "../workbase/workbase-choice"
13
13
  import type { Progress } from "../utils/progress"
@@ -24,6 +24,9 @@ const singlePhaseWorkspace: ExecutionWorkspace = {
24
24
  writablePath: "/workbase/tasks/example/code/agency",
25
25
  repo: "agency",
26
26
  repos: [],
27
+ dryRun: false,
28
+ checkouts: [],
29
+ operations: [],
27
30
  }
28
31
 
29
32
  const multiPhaseWorkspace: ExecutionWorkspace = {
@@ -34,6 +37,9 @@ const multiPhaseWorkspace: ExecutionWorkspace = {
34
37
  writablePath: "/workbase/tasks/example/phases/implementation/code/agency",
35
38
  repo: "agency",
36
39
  repos: [],
40
+ dryRun: false,
41
+ checkouts: [],
42
+ operations: [],
37
43
  }
38
44
 
39
45
  interface HarnessOptions {
@@ -167,6 +173,16 @@ const createHarness = (options: HarnessOptions = {}) => {
167
173
  Effect.provideService(PhaseService, phases as never),
168
174
  ) as Effect.Effect<void, unknown, never>,
169
175
  )
176
+ const runPrepare = (commandOptions: Parameters<typeof workPrepare>[0]) =>
177
+ Effect.runPromise(
178
+ workPrepare(commandOptions).pipe(
179
+ Effect.provideService(WorktreeService, worktrees as never),
180
+ Effect.provideService(FileSystemService, fs as never),
181
+ Effect.provideService(WorkbaseService, workbase as never),
182
+ Effect.provideService(TaskService, tasks as never),
183
+ Effect.provideService(PhaseService, phases as never),
184
+ ) as Effect.Effect<void, unknown, never>,
185
+ )
170
186
 
171
187
  return {
172
188
  events,
@@ -177,10 +193,32 @@ const createHarness = (options: HarnessOptions = {}) => {
177
193
  shownTasks,
178
194
  progressUpdates,
179
195
  run,
196
+ runPrepare,
180
197
  }
181
198
  }
182
199
 
183
200
  describe("work command", () => {
201
+ test("prepares without launching or changing lifecycle status", async () => {
202
+ const harness = createHarness({ existingDirectories: [] })
203
+
204
+ await captureLogs(() =>
205
+ harness.runPrepare({
206
+ cwd: "/workbase",
207
+ directory: "example",
208
+ json: true,
209
+ dryRun: true,
210
+ }),
211
+ )
212
+
213
+ expect(harness.events).toEqual(["materialize"])
214
+ expect(harness.launches).toEqual([])
215
+ expect(harness.statusUpdates).toEqual([])
216
+ expect(harness.materializeOptions[0]).toMatchObject({
217
+ json: true,
218
+ dryRun: true,
219
+ })
220
+ })
221
+
184
222
  test("launches an epic agent from an epic directory", async () => {
185
223
  const harness = createHarness()
186
224
 
@@ -243,14 +243,74 @@ export const work = (
243
243
  launch(cli, [cli, ...args], launchPath)
244
244
  })
245
245
 
246
+ export const workPrepare = (options: WorkOptions = {}) =>
247
+ Effect.gen(function* () {
248
+ const fs = yield* FileSystemService
249
+ const workbase = yield* WorkbaseService
250
+ const tasks = yield* TaskService
251
+ const phases = yield* PhaseService
252
+ const worktrees = yield* WorktreeService
253
+ const { log } = createLoggers(options)
254
+ const cwd = options.cwd ?? process.cwd()
255
+ const targetPath = options.directory ? resolve(cwd, options.directory) : cwd
256
+ const isDirectory = yield* fs.isDirectory(targetPath)
257
+ const root = yield* workbase.discover(isDirectory ? targetPath : cwd)
258
+
259
+ let taskId: string | undefined
260
+ let phaseId: string | undefined
261
+ if (options.directory && !isDirectory) {
262
+ const task = yield* tasks.show(options.directory, root)
263
+ taskId = task.id
264
+ } else {
265
+ const path = relative(root, targetPath)
266
+ const parts =
267
+ !path || isAbsolute(path) || path.startsWith(`..${sep}`)
268
+ ? []
269
+ : path.split(sep)
270
+ if (parts[0] === "tasks" && parts[1]) {
271
+ const task = yield* tasks.show(parts[1], root)
272
+ taskId = task.id
273
+ if (parts[2] === "phases" && parts[3]) {
274
+ const phase = yield* phases.show(task.id, parts[3], root)
275
+ phaseId = phase.id
276
+ }
277
+ }
278
+ }
279
+
280
+ if (!taskId) {
281
+ return yield* Effect.fail(
282
+ new Error(
283
+ "Work preparation requires a task ID or a path inside an execution unit",
284
+ ),
285
+ )
286
+ }
287
+
288
+ const workspace = yield* worktrees.materialize(taskId, phaseId, root, {
289
+ ...options,
290
+ dryRun: options.dryRun,
291
+ })
292
+ if (options.json) {
293
+ log(JSON.stringify(workspace, null, 2))
294
+ } else {
295
+ log(
296
+ `${workspace.dryRun ? "Workspace plan" : "Workspace ready"}: ${workspace.writablePath}`,
297
+ )
298
+ }
299
+ })
300
+
246
301
  export const help = `
247
302
  Usage: agency work [<directory-or-task-id> | --epic <epic-id>]
303
+ agency work prepare [target] [--dry-run] [--json]
248
304
 
249
305
  Launch an agent for an epic, task, or phase. With no directory, select one
250
306
  with fzf. A positional argument resolves as a directory first, then as a task
251
307
  ID. Use '.' for the current directory. Outside a workbase, select a registered
252
308
  workbase first.
253
309
 
310
+ The prepare subcommand resolves and materializes an execution workspace without
311
+ launching an agent or changing lifecycle status. --dry-run reports planned Git
312
+ changes without fetching, creating branches, or creating worktrees.
313
+
254
314
  Options:
255
315
  --epic <id> Work on an epic
256
316
  --opencode Require OpenCode
@@ -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",