@markjaquith/agency 2.15.0 → 2.17.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
@@ -304,6 +304,40 @@ inspection are opt-in include layers.
304
304
  `end` record with counts. Combining the metadata with the streamed node and edge
305
305
  records reconstructs the same result as `--json`.
306
306
 
307
+ ### Next Ready Work
308
+
309
+ `agency next` lists ready execution units in descending unlock priority, with
310
+ their epic and task context. `agency next --select` returns only the highest-
311
+ priority ready unit in human output.
312
+
313
+ `agency next --json` returns the same ranked `ready` set plus every `excluded`
314
+ execution unit. Excluded entries retain status, terminal state, `blockedBy`, and
315
+ detailed dependency, validation, or status blockers for orchestrators.
316
+
317
+ `agency work` and `agency pr create` consult this shared readiness model before
318
+ materializing or pushing. Blocked, done, and dropped targets are rejected unless
319
+ `--force` is supplied explicitly. PR creation permits active `working` and
320
+ `delegated` targets when they have no dependency or validation blocker.
321
+
322
+ ### Reconciliation
323
+
324
+ `agency sync` compares every execution declaration with local branch and worktree
325
+ registration, writable and reference checkout dirtiness, resolved reference
326
+ commits, claim expiry, and GitHub pull request and merge state. It reports
327
+ structured `changes`, `warnings`, `unresolved`, and per-execution evidence. The
328
+ default and `--dry-run` modes are observational.
329
+
330
+ `agency sync --apply` performs only these safe transitions:
331
+
332
+ - materialize missing checkouts when no registration, branch, or path conflicts;
333
+ - release an active claim only after its declared expiry has passed;
334
+ - record a single PR whose head and base match the declaration; and
335
+ - mark work done after its authoritative PR is merged and no active claim remains.
336
+
337
+ Apply never modifies dirty checkouts, moves worktrees, switches branches, resets
338
+ reference commits, chooses among multiple PRs, or bypasses active claims. Those
339
+ conditions remain visible in `warnings` or `unresolved` with a suggested action.
340
+
307
341
  ### Workbase and Repositories
308
342
 
309
343
  ```text
@@ -324,7 +358,7 @@ repository. Alias names are then used by all documents and commands.
324
358
 
325
359
  Commands that print Agency-owned results accept `--json`, including initialization,
326
360
  integration inspection/sync, repository mutations, entity creation/list/show,
327
- status, validation, graph export, and PR creation.
361
+ status, validation, graph export, reconciliation, and PR creation.
328
362
 
329
363
  ### Epics
330
364
 
package/cli.ts CHANGED
@@ -10,6 +10,8 @@ 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"
12
12
  import { graph, help as graphHelp } from "./src/commands/graph"
13
+ import { next, help as nextHelp } from "./src/commands/next"
14
+ import { sync, help as syncHelp } from "./src/commands/sync"
13
15
  import { repo, help as repoHelp } from "./src/commands/repo"
14
16
  import { epic, help as epicHelp } from "./src/commands/epic"
15
17
  import { phase, help as phaseHelp } from "./src/commands/phase"
@@ -33,6 +35,8 @@ import { IntegrationService } from "./src/services/IntegrationService"
33
35
  import { ContextService } from "./src/services/ContextService"
34
36
  import { GraphService } from "./src/services/GraphService"
35
37
  import { ClaimService } from "./src/services/ClaimService"
38
+ import { SyncService } from "./src/services/SyncService"
39
+ import { ReadinessService } from "./src/services/ReadinessService"
36
40
  import {
37
41
  claimCommand,
38
42
  claimHelp,
@@ -61,6 +65,8 @@ const CliLayer = Layer.mergeAll(
61
65
  ContextService.Default,
62
66
  GraphService.Default,
63
67
  ClaimService.Default,
68
+ SyncService.Default,
69
+ ReadinessService.Default,
64
70
  )
65
71
 
66
72
  /**
@@ -210,6 +216,7 @@ const commands: Record<string, Command> = {
210
216
  taskId: args[1],
211
217
  phaseId: args[2],
212
218
  draft: options.draft,
219
+ force: options.force,
213
220
  json: options.json,
214
221
  silent: options.silent,
215
222
  verbose: options.verbose,
@@ -348,11 +355,25 @@ const commands: Record<string, Command> = {
348
355
  verbose: options.verbose,
349
356
  opencode: options.opencode,
350
357
  claude: options.claude,
358
+ force: options.force,
351
359
  inputAllowed: options.inputAllowed,
352
360
  }),
353
361
  )
354
362
  },
355
363
  },
364
+ next: {
365
+ run: async (_args: string[], options: Record<string, any>) => {
366
+ if (options.help) return console.log(nextHelp)
367
+ await runCommand(
368
+ next({
369
+ select: options.select,
370
+ json: options.json,
371
+ silent: options.silent,
372
+ verbose: options.verbose,
373
+ }),
374
+ )
375
+ },
376
+ },
356
377
  status: {
357
378
  run: async (_args: string[], options: Record<string, any>) => {
358
379
  if (options.help) {
@@ -424,6 +445,23 @@ const commands: Record<string, Command> = {
424
445
  )
425
446
  },
426
447
  },
448
+ sync: {
449
+ run: async (_args: string[], options: Record<string, any>) => {
450
+ if (options.help) {
451
+ console.log(syncHelp)
452
+ return
453
+ }
454
+ await runCommand(
455
+ sync({
456
+ apply: options.apply,
457
+ dryRun: options["dry-run"],
458
+ json: options.json,
459
+ silent: options.silent,
460
+ verbose: options.verbose,
461
+ }),
462
+ )
463
+ },
464
+ },
427
465
  }
428
466
 
429
467
  function showMainHelp() {
@@ -444,12 +482,14 @@ Commands:
444
482
  archive <type> Archive a work item
445
483
  task <subcommand> Manage tasks
446
484
  work [directory|task] Work on an epic, task, or phase
485
+ next List or select ready execution units
447
486
  pr create Create a pull request for an execution unit
448
487
  repo <subcommand> Manage workbase repositories
449
488
  status Show status for the current workbase
450
489
  validate [path] Validate a workbase
451
490
  context [target] Return complete target context
452
491
  graph Export the complete workbase graph
492
+ sync Reconcile declarations with external state
453
493
 
454
494
  Global Options:
455
495
  -h, --help Show help for a command
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "2.15.0",
3
+ "version": "2.17.0",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -141,6 +141,8 @@ describe("strict CLI parsing", () => {
141
141
  [["validate", "one", "two"], "agency validate"],
142
142
  [["context", "one", "two"], "agency context"],
143
143
  [["graph", "extra"], "agency graph"],
144
+ [["next", "extra"], "agency next"],
145
+ [["sync", "extra"], "agency sync"],
144
146
  [
145
147
  [
146
148
  "claim",
@@ -191,6 +193,38 @@ describe("strict CLI parsing", () => {
191
193
  }
192
194
  })
193
195
 
196
+ test("parses readiness selection and explicit guard overrides", () => {
197
+ expect(parseCli(["next", "--select", "--json"])).toMatchObject({
198
+ commandName: "next",
199
+ values: { select: true, json: true },
200
+ })
201
+ expect(parseCli(["work", "example", "--force"])).toMatchObject({
202
+ commandName: "work",
203
+ values: { force: true },
204
+ })
205
+ expect(parseCli(["pr", "create", "example", "--force"])).toMatchObject({
206
+ commandName: "pr",
207
+ values: { force: true },
208
+ })
209
+ expect(() => parseCli(["work", "prepare", "example", "--force"])).toThrow(
210
+ "cannot be combined",
211
+ )
212
+ })
213
+
214
+ test("parses reconciliation modes and rejects conflicting modes", () => {
215
+ expect(parseCli(["sync", "--dry-run", "--json"])).toMatchObject({
216
+ commandName: "sync",
217
+ values: { "dry-run": true, json: true },
218
+ })
219
+ expect(parseCli(["sync", "--apply"])).toMatchObject({
220
+ commandName: "sync",
221
+ values: { apply: true },
222
+ })
223
+ expect(() => parseCli(["sync", "--dry-run", "--apply"])).toThrow(
224
+ "cannot be combined",
225
+ )
226
+ })
227
+
194
228
  test("validates revision-guarded claim operations", () => {
195
229
  const revision = "0".repeat(64)
196
230
  expect(
package/src/cli-parser.ts CHANGED
@@ -319,6 +319,21 @@ const commands = {
319
319
  required: ["session-id", "revision", "outcome"],
320
320
  },
321
321
  },
322
+ sync: {
323
+ usage: "agency sync [--dry-run | --apply] [--json]",
324
+ options: {
325
+ ...outputOptions,
326
+ "dry-run": { type: "boolean" },
327
+ apply: { type: "boolean" },
328
+ },
329
+ command: {
330
+ usage: "agency sync [--dry-run | --apply] [--json]",
331
+ minArgs: 0,
332
+ maxArgs: 0,
333
+ options: ["dry-run", "apply", "json"],
334
+ conflicts: [["dry-run", "apply"]],
335
+ },
336
+ },
322
337
  archive: {
323
338
  usage: "agency archive <epic|task|phase>",
324
339
  options: outputOptions,
@@ -353,13 +368,14 @@ const commands = {
353
368
  epic: { type: "string" },
354
369
  opencode: { type: "boolean" },
355
370
  claude: { type: "boolean" },
371
+ force: { type: "boolean" },
356
372
  },
357
373
  command: {
358
374
  usage:
359
375
  "agency work [<directory-or-task-id> | --epic <epic-id>] | agency work prepare [target] [--dry-run] [--json]",
360
376
  minArgs: 0,
361
377
  maxArgs: 2,
362
- options: ["json", "dry-run", "epic", "opencode", "claude"],
378
+ options: ["json", "dry-run", "epic", "opencode", "claude", "force"],
363
379
  conflicts: [
364
380
  ["opencode", "claude"],
365
381
  ["epic", "$positional"],
@@ -371,16 +387,31 @@ const commands = {
371
387
  options: {
372
388
  ...outputOptions,
373
389
  draft: { type: "boolean" },
390
+ force: { type: "boolean" },
374
391
  },
375
392
  subcommands: {
376
393
  create: {
377
- usage: "agency pr create <task-id> [phase-id] [--draft] [--json]",
394
+ usage:
395
+ "agency pr create <task-id> [phase-id] [--draft] [--force] [--json]",
378
396
  minArgs: 1,
379
397
  maxArgs: 2,
380
- options: ["draft", "json"],
398
+ options: ["draft", "force", "json"],
381
399
  },
382
400
  },
383
401
  },
402
+ next: {
403
+ usage: "agency next [--select] [--json]",
404
+ options: {
405
+ ...outputOptions,
406
+ select: { type: "boolean" },
407
+ },
408
+ command: {
409
+ usage: "agency next [--select] [--json]",
410
+ minArgs: 0,
411
+ maxArgs: 0,
412
+ options: ["select", "json"],
413
+ },
414
+ },
384
415
  status: {
385
416
  usage: "agency status [--json]",
386
417
  options: outputOptions,
@@ -729,7 +760,10 @@ export function parseCli(args: readonly string[]): ParsedCli {
729
760
  if (
730
761
  (!preparing && commandPositionals.length > 1) ||
731
762
  (preparing &&
732
- (parsed.values.epic || parsed.values.opencode || parsed.values.claude))
763
+ (parsed.values.epic ||
764
+ parsed.values.opencode ||
765
+ parsed.values.claude ||
766
+ parsed.values.force))
733
767
  ) {
734
768
  throw usageError(
735
769
  preparing
package/src/cli.test.ts CHANGED
@@ -245,6 +245,7 @@ describe("CLI", () => {
245
245
  ["validate", "Usage: agency validate"],
246
246
  ["context", "Usage: agency context"],
247
247
  ["graph", "Usage: agency graph"],
248
+ ["next", "Usage: agency next"],
248
249
  ] as const) {
249
250
  const result = await runCli([command, "--help"])
250
251
  expect(result.exitCode).toBe(0)
@@ -264,6 +265,55 @@ describe("CLI", () => {
264
265
 
265
266
  const after = await runCli(["status", "--silent"], root)
266
267
  expect(after).toEqual({ exitCode: 0, stdout: "", stderr: "" })
268
+ }, 10_000)
269
+
270
+ test("lists ready work and exposes excluded blockers through one result", async () => {
271
+ const root = await createTempDir()
272
+ tempDirs.push(root)
273
+ await Bun.write(join(root, "agency.json"), '{"version":2}\n')
274
+ await mkdir(join(root, "repos/agency"), { recursive: true })
275
+ for (const id of ["ready", "finished"]) {
276
+ parseJson(
277
+ await runCli(
278
+ ["task", "create", id, "--repo", "agency", "--json"],
279
+ root,
280
+ ),
281
+ )
282
+ }
283
+ parseJson(
284
+ await runCli(["task", "status", "finished", "done", "--json"], root),
285
+ )
286
+
287
+ const human = await runCli(["next"], root)
288
+ expect(human).toMatchObject({ exitCode: 0, stderr: "" })
289
+ expect(human.stdout).toContain("1. task/ready")
290
+ expect(human.stdout).not.toContain("task/finished")
291
+
292
+ const result = parseJson(await runCli(["next", "--select", "--json"], root))
293
+ expect(result.selected).toMatchObject({ key: "task/ready", rank: 1 })
294
+ expect(result.ready.map((item: any) => item.key)).toEqual(["task/ready"])
295
+ expect(result.excluded).toMatchObject([
296
+ {
297
+ key: "task/finished",
298
+ status: "done",
299
+ terminal: true,
300
+ blockers: [{ kind: "status", reason: "Task status is done" }],
301
+ },
302
+ ])
303
+
304
+ const blockedPr = await runCli(["pr", "create", "finished", "--json"], root)
305
+ expect(blockedPr.exitCode).toBe(1)
306
+ expect(blockedPr.stderr).toBe("")
307
+ expect(JSON.parse(blockedPr.stdout)).toMatchObject({
308
+ ok: false,
309
+ error: {
310
+ code: "EXECUTION_BLOCKED",
311
+ fields: {
312
+ status: "done",
313
+ blockers: [{ kind: "status", reason: "Task status is done" }],
314
+ },
315
+ },
316
+ })
267
317
  })
268
318
 
269
319
  test("reports and synchronizes managed integration files", async () => {
@@ -0,0 +1,64 @@
1
+ import { Effect } from "effect"
2
+ import { ReadinessService } from "../services/ReadinessService"
3
+ import type { BaseCommandOptions } from "../utils/command"
4
+ import { createLoggers } from "../utils/effect"
5
+
6
+ interface NextOptions extends BaseCommandOptions {
7
+ readonly select?: boolean
8
+ }
9
+
10
+ const context = (item: {
11
+ readonly parent: { readonly taskId?: string; readonly epicId?: string }
12
+ }) =>
13
+ [
14
+ item.parent.epicId ? `epic ${item.parent.epicId}` : undefined,
15
+ item.parent.taskId ? `task ${item.parent.taskId}` : undefined,
16
+ ]
17
+ .filter(Boolean)
18
+ .join(" / ")
19
+
20
+ export const next = (options: NextOptions = {}) =>
21
+ Effect.gen(function* () {
22
+ const readiness = yield* ReadinessService
23
+ const { log } = createLoggers(options)
24
+ const result = yield* readiness.getNext(
25
+ options.cwd ?? process.cwd(),
26
+ options.select,
27
+ )
28
+ if (options.json) {
29
+ log(JSON.stringify(result, null, 2))
30
+ return
31
+ }
32
+ if (options.select) {
33
+ if (!result.selected) {
34
+ log("No execution units are ready.")
35
+ return
36
+ }
37
+ const parent = context(result.selected)
38
+ log(
39
+ `${result.selected.key}${parent ? ` (${parent})` : ""} - priority ${result.selected.priority.dependentCount}`,
40
+ )
41
+ return
42
+ }
43
+ if (result.ready.length === 0) {
44
+ log("No execution units are ready.")
45
+ return
46
+ }
47
+ for (const item of result.ready) {
48
+ const parent = context(item)
49
+ log(
50
+ `${item.rank}. ${item.key}${parent ? ` (${parent})` : ""} - priority ${item.priority.dependentCount}`,
51
+ )
52
+ }
53
+ })
54
+
55
+ export const help = `
56
+ Usage: agency next [--select] [--json]
57
+
58
+ List ready execution units in priority order or select the highest-priority unit.
59
+ Structured output also includes excluded units and their blockers.
60
+
61
+ Options:
62
+ --select Return only the highest-priority ready unit in human output
63
+ --json Output ready and excluded execution units as JSON
64
+ `
@@ -8,6 +8,7 @@ interface PrOptions extends BaseCommandOptions {
8
8
  readonly taskId?: string
9
9
  readonly phaseId?: string
10
10
  readonly draft?: boolean
11
+ readonly force?: boolean
11
12
  }
12
13
 
13
14
  export const pr = (options: PrOptions) =>
@@ -37,5 +38,6 @@ task or phase document.
37
38
 
38
39
  Options:
39
40
  --draft Create a draft pull request
41
+ --force Override readiness and terminal-state guards
40
42
  --json Output the pull request URL as JSON
41
43
  `
@@ -13,6 +13,7 @@ import { task } from "./task"
13
13
  import { validate } from "./validate"
14
14
  import { context } from "./context"
15
15
  import { graph } from "./graph"
16
+ import { next } from "./next"
16
17
 
17
18
  const write = async (root: string, path: string, content: string) => {
18
19
  const fullPath = join(root, path)
@@ -145,6 +146,7 @@ status: open
145
146
  }),
146
147
  )
147
148
  await runTestEffect(graph({ cwd: root, silent: true }))
149
+ await runTestEffect(next({ cwd: root, silent: true }))
148
150
 
149
151
  expect(await Bun.file(join(root, "AGENTS.md")).exists()).toBe(false)
150
152
  expect(
@@ -0,0 +1,36 @@
1
+ import { Effect } from "effect"
2
+ import { SyncService } from "../services/SyncService"
3
+ import type { BaseCommandOptions } from "../utils/command"
4
+ import { createLoggers } from "../utils/effect"
5
+
6
+ interface SyncCommandOptions extends BaseCommandOptions {
7
+ readonly apply?: boolean
8
+ readonly dryRun?: boolean
9
+ }
10
+
11
+ export const sync = (options: SyncCommandOptions = {}) =>
12
+ Effect.gen(function* () {
13
+ const service = yield* SyncService
14
+ const { log } = createLoggers(options)
15
+ const result = yield* service.reconcile({
16
+ cwd: options.cwd,
17
+ apply: options.apply === true,
18
+ })
19
+ log(JSON.stringify(result, null, 2))
20
+ })
21
+
22
+ export const help = `
23
+ Usage: agency sync [--dry-run | --apply] [--json]
24
+
25
+ Compare declared execution state with Git worktrees, branches, references, claims,
26
+ and GitHub pull requests. Dry-run is the default.
27
+
28
+ Options:
29
+ --dry-run Report planned safe transitions without changing state
30
+ --apply Apply safe reconciliation transitions
31
+ --json Output one versioned machine result
32
+
33
+ Apply may materialize unambiguous missing checkouts, release expired claims,
34
+ record a uniquely matched PR, and mark merged work done. Dirty, stale, or
35
+ conflicting checkouts are always left unresolved.
36
+ `
@@ -7,6 +7,7 @@ import { TaskService } from "../services/TaskService"
7
7
  import { PhaseService } from "../services/PhaseService"
8
8
  import { WorktreeService } from "../services/WorktreeService"
9
9
  import { ClaimService } from "../services/ClaimService"
10
+ import { ReadinessService } from "../services/ReadinessService"
10
11
  import { captureErrors, captureLogs } from "../test-utils"
11
12
  import { work, workPrepare } from "./work"
12
13
  import type { PickWorkTarget } from "../workbase/work-target"
@@ -55,6 +56,8 @@ interface HarnessOptions {
55
56
  readonly outsideWorkbase?: boolean
56
57
  readonly registeredWorkbases?: readonly string[]
57
58
  readonly existingDirectories?: readonly string[]
59
+ readonly guardError?: Error
60
+ readonly readyTargetIds?: readonly string[]
58
61
  }
59
62
 
60
63
  const createHarness = (options: HarnessOptions = {}) => {
@@ -63,6 +66,7 @@ const createHarness = (options: HarnessOptions = {}) => {
63
66
  const statusUpdates: string[] = []
64
67
  const shownTasks: string[] = []
65
68
  const progressUpdates: string[] = []
69
+ const guards: Array<{ target: string; override?: boolean }> = []
66
70
  const launches: Array<{
67
71
  cli: string
68
72
  args: readonly string[]
@@ -167,6 +171,34 @@ const createHarness = (options: HarnessOptions = {}) => {
167
171
  return Effect.succeed({ revision: "1".repeat(64) })
168
172
  },
169
173
  }
174
+ const readiness = {
175
+ getReadyWorkTargetIds: () =>
176
+ Effect.succeed(
177
+ new Set(
178
+ options.readyTargetIds ?? [
179
+ ...(options.epicRecords ?? []).map(
180
+ (record: any) => `epic:${record.id}`,
181
+ ),
182
+ ...(options.taskRecords ?? []).map((record: any) =>
183
+ "phases" in record.data
184
+ ? `task:${record.id}`
185
+ : `execution-unit:task/${record.id}`,
186
+ ),
187
+ ...(options.phaseRecords ?? []).map(
188
+ (record: any) =>
189
+ `execution-unit:phase/${record.taskId}/${record.id}`,
190
+ ),
191
+ ],
192
+ ),
193
+ ),
194
+ guardWorkTarget: (target: string, _root: string, override?: boolean) => {
195
+ if (options.guardError || override) events.push("guard")
196
+ guards.push({ target, override })
197
+ return options.guardError && !override
198
+ ? Effect.fail(options.guardError)
199
+ : Effect.void
200
+ },
201
+ }
170
202
  const fs = {
171
203
  isDirectory: (path: string) =>
172
204
  Effect.succeed(options.existingDirectories?.includes(path) ?? true),
@@ -206,6 +238,7 @@ const createHarness = (options: HarnessOptions = {}) => {
206
238
  Effect.provideService(TaskService, tasks as never),
207
239
  Effect.provideService(PhaseService, phases as never),
208
240
  Effect.provideService(ClaimService, claims as never),
241
+ Effect.provideService(ReadinessService, readiness as never),
209
242
  ) as Effect.Effect<void, unknown, never>,
210
243
  )
211
244
  const runPrepare = (commandOptions: Parameters<typeof workPrepare>[0]) =>
@@ -227,12 +260,63 @@ const createHarness = (options: HarnessOptions = {}) => {
227
260
  statusUpdates,
228
261
  shownTasks,
229
262
  progressUpdates,
263
+ guards,
230
264
  run,
231
265
  runPrepare,
232
266
  }
233
267
  }
234
268
 
235
269
  describe("work command", () => {
270
+ test("guards execution targets before materialization and honors --force", async () => {
271
+ const blocked = createHarness({ guardError: new Error("blocked") })
272
+ await expect(
273
+ blocked.run({ taskId: "example", opencode: true }),
274
+ ).rejects.toThrow("blocked")
275
+ expect(blocked.events).toEqual(["guard"])
276
+ expect(blocked.guards).toEqual([
277
+ { target: "execution-unit:task/example", override: undefined },
278
+ ])
279
+
280
+ const forced = createHarness({ guardError: new Error("blocked") })
281
+ await forced.run({ taskId: "example", opencode: true, force: true })
282
+ expect(forced.events).toEqual([
283
+ "guard",
284
+ "materialize",
285
+ "probe:opencode",
286
+ "launch:opencode",
287
+ ])
288
+ expect(forced.guards[0]).toEqual({
289
+ target: "execution-unit:task/example",
290
+ override: true,
291
+ })
292
+ })
293
+
294
+ test("offers only graph-ready targets to the interactive chooser", async () => {
295
+ const harness = createHarness({
296
+ taskRecords: [
297
+ {
298
+ id: "ready",
299
+ path: "/workbase/tasks/ready/TASK.md",
300
+ data: { status: "open" },
301
+ },
302
+ {
303
+ id: "blocked",
304
+ path: "/workbase/tasks/blocked/TASK.md",
305
+ data: { status: "open" },
306
+ },
307
+ ],
308
+ readyTargetIds: ["execution-unit:task/ready"],
309
+ })
310
+ let labels: readonly string[] = []
311
+ const pick: PickWorkTarget = (choices) => {
312
+ labels = choices.map((choice) => choice.plainLabel)
313
+ return Effect.succeed(null)
314
+ }
315
+
316
+ await harness.run({ cwd: "/workbase" }, pick)
317
+ expect(labels).toEqual(["[open] task ready"])
318
+ })
319
+
236
320
  test("prepares without launching or changing lifecycle status", async () => {
237
321
  const harness = createHarness({ existingDirectories: [] })
238
322