@markjaquith/agency 2.19.0 → 2.21.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
@@ -10,7 +10,7 @@ or write.
10
10
  - [Bun](https://bun.sh) 1.0 or newer
11
11
  - Git
12
12
  - [GitHub CLI](https://cli.github.com/) for `agency pr create`
13
- - OpenCode or Claude Code for `agency work`
13
+ - OpenCode, Claude Code, or a configured runner for `agency work`
14
14
 
15
15
  ## Installation
16
16
 
@@ -145,6 +145,41 @@ The configured command applies only to the writable checkout. Supplemental
145
145
  read-only repositories remain detached Git worktrees at their declared refs so
146
146
  they do not acquire writable branches.
147
147
 
148
+ ### Agent Runners
149
+
150
+ OpenCode and Claude Code are built-in runner presets. Select either preset or a
151
+ configured runner with `agency work --runner <name>`. A launch is fresh unless
152
+ `AGENCY_SESSION_ID` is already set; resumed launches use the runner's
153
+ `resumeCommand` when configured. The built-in presets use `--continue` only for
154
+ resumed launches.
155
+
156
+ Custom runners are direct argv commands, never shell snippets:
157
+
158
+ ```json
159
+ {
160
+ "version": 2,
161
+ "runners": {
162
+ "custom": {
163
+ "command": ["my-agent", "--prompt", "{prompt}"],
164
+ "resumeCommand": ["my-agent", "resume", "{sessionId}", "{prompt}"],
165
+ "environment": { "MY_AGENT_TARGET": "{target}" }
166
+ }
167
+ }
168
+ }
169
+ ```
170
+
171
+ Available placeholders are `{prompt}`, `{workbase}`, `{target}`, `{task}`,
172
+ `{phase}`, `{claimant}`, `{sessionId}`, and `{claimRevision}`. Task and phase
173
+ placeholders are empty when they do not apply. If `resumeCommand` is omitted,
174
+ the fresh command is also used for resumed sessions.
175
+
176
+ Every runner receives the same `AGENCY_RUNNER`, `AGENCY_CLAIMANT`,
177
+ `AGENCY_SESSION_ID`, `AGENCY_CLAIM_REVISION`, `AGENCY_WORKBASE`, `AGENCY_TARGET`,
178
+ `AGENCY_TASK_ID`, `AGENCY_PHASE_ID`, and `AGENCY_PROMPT` environment. Configured
179
+ environment is added without overriding these normalized values.
180
+ `--print-command` prints the exact cwd and argv plus non-secret environment keys
181
+ without launching the runner.
182
+
148
183
  ### Custom Chooser Command
149
184
 
150
185
  Interactive selection uses a native numbered chooser by default. To use an
@@ -373,6 +408,9 @@ agency epic create <id> --ticket-url <url> [--description <text>] [--json]
373
408
  --repo <alias>:<ref> [--repo <alias>:<ref>...]
374
409
  agency epic list [filters] [--json]
375
410
  agency epic show <id> [--json]
411
+ agency epic update <id> [--ticket-url <url>] [--description <text>]
412
+ [--clear-description] [--repo <alias>:<ref>...] [--json]
413
+ agency epic rename <id> <new-id> [--json]
376
414
  ```
377
415
 
378
416
  Creating a task with `--epic <id>` adds the task to the epic and writes the task
@@ -428,8 +466,18 @@ Inspect tasks:
428
466
  agency task list [filters] [--json]
429
467
  agency task show <id> [--json]
430
468
  agency task status <id> <open|done|dropped> [--json]
469
+ agency task update <id> [metadata options] [--json]
470
+ agency task rename <id> <new-id> [--json]
471
+ agency task move <id> (--epic <epic-id> | --no-epic) [--json]
472
+ agency task dependency <add|remove> <task-id> <dependency-id> [--json]
431
473
  ```
432
474
 
475
+ Task updates can replace or clear descriptions, tickets, repository references,
476
+ and pull request URLs, or replace writable repository, branch, and base metadata.
477
+ Execution metadata changes refuse to run while code is materialized. Moving a
478
+ task with scoped incoming or outgoing dependencies also refuses until those
479
+ dependencies are removed.
480
+
433
481
  To add a phase to an existing single-phase task, name the phase that will own
434
482
  the task's current execution fields with `--first-phase`:
435
483
 
@@ -455,8 +503,18 @@ agency phase create <task-id> <phase-id>
455
503
  agency phase list <task-id> [filters] [--json]
456
504
  agency phase show <task-id> <phase-id> [--json]
457
505
  agency phase status <task-id> <phase-id> <open|done|dropped> [--json]
506
+ agency phase update <task-id> <phase-id> [metadata options] [--json]
507
+ agency phase rename <task-id> <phase-id> <new-id> [--json]
508
+ agency phase dependency <add|remove> <task-id> <phase-id> <dependency-id>
509
+ [--json]
458
510
  ```
459
511
 
512
+ Dependency additions append without reordering existing declarations and reject
513
+ unknown IDs, self-dependencies, and cycles. Rename operations update structured
514
+ references as one rollback-capable mutation and refuse when a materialized
515
+ worktree would make the directory move unsafe. Mutation JSON includes changed
516
+ paths and the focused validation scope.
517
+
460
518
  Single-phase tasks and phases store status in YAML. New execution units start
461
519
  `open`, and `agency work` marks the selected execution unit `working` immediately
462
520
  before launch. Use claims for coordinated ownership and the status subcommands
@@ -500,9 +558,9 @@ replaced with a revision-guarded claim.
500
558
 
501
559
  `agency work` claims an execution unit before launching its agent. Set
502
560
  `AGENCY_CLAIMANT`, `AGENCY_RUNNER`, or `AGENCY_SESSION_ID` to supply orchestrator
503
- identities; otherwise Agency derives them from the user, selected agent, and
504
- process. The launched agent receives `AGENCY_SESSION_ID` and
505
- `AGENCY_CLAIM_REVISION` for a later release or finish operation.
561
+ identities; otherwise Agency derives them from the user and process. The selected
562
+ runner name is recorded on the claim. The launched agent receives the normalized
563
+ runner environment documented above for a later release or finish operation.
506
564
 
507
565
  ### Archive
508
566
 
@@ -520,7 +578,7 @@ before moving files, refuses dirty worktrees, and preserves branches.
520
578
  ### Work and Pull Requests
521
579
 
522
580
  ```text
523
- agency work [<directory> | --epic <epic-id>] [--opencode | --claude]
581
+ agency work [<directory> | --epic <epic-id>] [--runner <name>] [--print-command]
524
582
  agency work prepare [target] [--dry-run] [--json]
525
583
  agency pr create <task-id> [phase-id] [--draft] [--json]
526
584
  ```
@@ -531,6 +589,10 @@ workbase, Agency first presents the registered workbases, then the selected
531
589
  workbase's hierarchy. If `fzf` is not installed, Agency prints the available
532
590
  choices and asks for an explicit directory.
533
591
 
592
+ OpenCode is the default runner, with automatic Claude fallback when neither is
593
+ explicitly selected. `--opencode` and `--claude` remain aliases for requiring
594
+ their built-in presets.
595
+
534
596
  `agency work prepare` resolves an execution unit and creates or reuses its
535
597
  writable and reference worktrees without launching an agent or changing status.
536
598
  Its JSON result includes document and checkout paths, resolved commits, actions,
package/cli.ts CHANGED
@@ -38,6 +38,7 @@ import { GraphService } from "./src/services/GraphService"
38
38
  import { ClaimService } from "./src/services/ClaimService"
39
39
  import { SyncService } from "./src/services/SyncService"
40
40
  import { ReadinessService } from "./src/services/ReadinessService"
41
+ import { GraphMutationService } from "./src/services/GraphMutationService"
41
42
  import {
42
43
  claimCommand,
43
44
  claimHelp,
@@ -68,6 +69,7 @@ const CliLayer = Layer.mergeAll(
68
69
  ClaimService.Default,
69
70
  SyncService.Default,
70
71
  ReadinessService.Default,
72
+ GraphMutationService.Default,
71
73
  )
72
74
 
73
75
  /**
@@ -235,6 +237,7 @@ const commands: Record<string, Command> = {
235
237
  args: args.slice(1),
236
238
  ticketUrl: options["ticket-url"],
237
239
  description: options.description,
240
+ clearDescription: options["clear-description"],
238
241
  repos: options.repo,
239
242
  json: options.json,
240
243
  statuses: options.status,
@@ -278,10 +281,14 @@ const commands: Record<string, Command> = {
278
281
  subcommand: args[0],
279
282
  args: args.slice(1),
280
283
  description: options.description,
284
+ clearDescription: options["clear-description"],
281
285
  repo: options.repo?.[0],
282
286
  references: options.reference,
283
287
  branch: options.branch,
284
288
  base: options.base,
289
+ clearReferences: options["clear-references"],
290
+ prUrl: options["pr-url"],
291
+ clearPr: options["clear-pr"],
285
292
  dependsOn: options["depends-on"],
286
293
  firstPhase: options["first-phase"],
287
294
  json: options.json,
@@ -381,12 +388,18 @@ const commands: Record<string, Command> = {
381
388
  subcommand: args[0],
382
389
  args: args.slice(1),
383
390
  ticketUrl: options["ticket-url"],
391
+ clearTicket: options["clear-ticket"],
384
392
  description: options.description,
393
+ clearDescription: options["clear-description"],
385
394
  epic: options.epic,
386
395
  repo: options.repo?.[0],
387
396
  references: options.reference,
388
397
  branch: options.branch,
389
398
  base: options.base,
399
+ clearReferences: options["clear-references"],
400
+ prUrl: options["pr-url"],
401
+ clearPr: options["clear-pr"],
402
+ noEpic: options["no-epic"],
390
403
  multiPhase: options["multi-phase"],
391
404
  json: options.json,
392
405
  statuses: options.status,
@@ -419,6 +432,8 @@ const commands: Record<string, Command> = {
419
432
  verbose: options.verbose,
420
433
  opencode: options.opencode,
421
434
  claude: options.claude,
435
+ runner: options.runner,
436
+ printCommand: options["print-command"],
422
437
  force: options.force,
423
438
  inputAllowed: options.inputAllowed,
424
439
  cwd: options.cwd,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "2.19.0",
3
+ "version": "2.21.0",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -279,7 +279,10 @@ agency work --epic <epic-id>
279
279
  agency work --task <task-id> [--phase <phase-id>] --workbase <selector>
280
280
  ```
281
281
 
282
- Use `--opencode` or `--claude` to require a specific agent. This command fetches
282
+ Use `--runner <name>` to select a configured runner or the built-in `opencode`
283
+ and `claude` presets. `--opencode` and `--claude` remain shorthand. Use
284
+ `--print-command` to inspect the resolved cwd, argv, and non-secret environment
285
+ without launching. This command fetches
283
286
  repositories for execution targets, creates or reuses their worktrees, and
284
287
  replaces the current process with the selected agent. With no directory it opens
285
288
  an `fzf` picker containing the workbase hierarchy. Pass `.`, or another
@@ -321,6 +321,22 @@ describe("strict CLI parsing", () => {
321
321
  )
322
322
  })
323
323
 
324
+ test("accepts runner selection and command inspection for work", () => {
325
+ expect(
326
+ parseCli(["work", "example", "--runner", "custom", "--print-command"]),
327
+ ).toMatchObject({
328
+ commandName: "work",
329
+ args: ["example"],
330
+ values: { runner: "custom", "print-command": true },
331
+ })
332
+ expect(() =>
333
+ parseCli(["work", "example", "--runner", "custom", "--claude"]),
334
+ ).toThrow("cannot be combined")
335
+ expect(() => parseCli(["work", "prepare", "--print-command"])).toThrow(
336
+ "cannot be combined",
337
+ )
338
+ })
339
+
324
340
  test("accepts repeatable graph filters and rejects output conflicts", () => {
325
341
  expect(
326
342
  parseCli([
@@ -371,7 +387,51 @@ describe("strict CLI parsing", () => {
371
387
  )
372
388
  expectUsageError(
373
389
  ["task", "crate"],
374
- "agency task <new|create|list|show|status>",
390
+ "agency task <new|create|list|show|status|update|rename|move|dependency>",
391
+ )
392
+ })
393
+
394
+ test("parses graph mutation commands and rejects unsafe ambiguity", () => {
395
+ expect(
396
+ parseCli([
397
+ "task",
398
+ "update",
399
+ "example",
400
+ "--description",
401
+ "Revised",
402
+ "--reference",
403
+ "docs:main",
404
+ "--reference",
405
+ "api:main",
406
+ "--clear-pr",
407
+ ]),
408
+ ).toMatchObject({
409
+ args: ["update", "example"],
410
+ values: {
411
+ description: "Revised",
412
+ reference: ["docs:main", "api:main"],
413
+ "clear-pr": true,
414
+ },
415
+ })
416
+ expect(
417
+ parseCli(["task", "move", "example", "--no-epic"]).values,
418
+ ).toMatchObject({
419
+ "no-epic": true,
420
+ })
421
+ expect(parseCli(["phase", "rename", "task", "old", "new"]).args).toEqual([
422
+ "rename",
423
+ "task",
424
+ "old",
425
+ "new",
426
+ ])
427
+ expect(() => parseCli(["task", "update", "example"])).toThrow(
428
+ "At least one update option",
429
+ )
430
+ expect(() =>
431
+ parseCli(["task", "move", "example", "--epic", "one", "--no-epic"]),
432
+ ).toThrow("cannot be combined")
433
+ expect(() => parseCli(["task", "dependency", "replace", "a", "b"])).toThrow(
434
+ "must be 'add' or 'remove'",
375
435
  )
376
436
  })
377
437
 
package/src/cli-parser.ts CHANGED
@@ -90,6 +90,15 @@ const phaseCreateOptions = {
90
90
  "first-phase": { type: "string" },
91
91
  } satisfies OptionConfig
92
92
 
93
+ const mutationOptions = {
94
+ "clear-description": { type: "boolean" },
95
+ "clear-ticket": { type: "boolean" },
96
+ "clear-references": { type: "boolean" },
97
+ "pr-url": { type: "string" },
98
+ "clear-pr": { type: "boolean" },
99
+ "no-epic": { type: "boolean" },
100
+ } satisfies OptionConfig
101
+
93
102
  const claimOptions = {
94
103
  ...outputOptions,
95
104
  claimant: { type: "string" },
@@ -200,8 +209,13 @@ const commands = {
200
209
  },
201
210
  },
202
211
  epic: {
203
- usage: "agency epic <create|list|show>",
204
- options: { ...createOptions, ...viewOptions, epic: { type: "string" } },
212
+ usage: "agency epic <create|list|show|update|rename>",
213
+ options: {
214
+ ...createOptions,
215
+ ...viewOptions,
216
+ ...mutationOptions,
217
+ epic: { type: "string" },
218
+ },
205
219
  subcommands: {
206
220
  create: {
207
221
  usage:
@@ -226,11 +240,38 @@ const commands = {
226
240
  maxArgs: 1,
227
241
  options: ["json", "epic"],
228
242
  },
243
+ update: {
244
+ usage: "agency epic update <id> [options] [--json]",
245
+ minArgs: 1,
246
+ maxArgs: 1,
247
+ options: [
248
+ "description",
249
+ "clear-description",
250
+ "ticket-url",
251
+ "repo",
252
+ "json",
253
+ ],
254
+ repeatable: ["repo"],
255
+ conflicts: [["description", "clear-description"]],
256
+ },
257
+ rename: {
258
+ usage: "agency epic rename <id> <new-id> [--json]",
259
+ minArgs: 2,
260
+ maxArgs: 2,
261
+ options: ["json"],
262
+ },
229
263
  },
230
264
  },
231
265
  task: {
232
- usage: "agency task <new|create|list|show|status>",
233
- options: { ...taskCreateOptions, ...viewOptions, task: { type: "string" } },
266
+ usage:
267
+ "agency task <new|create|list|show|status|update|rename|move|dependency>",
268
+ options: {
269
+ ...taskCreateOptions,
270
+ ...viewOptions,
271
+ ...mutationOptions,
272
+ "pr-url": { type: "string" },
273
+ task: { type: "string" },
274
+ },
234
275
  subcommands: {
235
276
  new: {
236
277
  usage: "agency task new [id] [options]",
@@ -287,13 +328,61 @@ const commands = {
287
328
  maxArgs: 2,
288
329
  options: ["json", "task"],
289
330
  },
331
+ update: {
332
+ usage: "agency task update <id> [options] [--json]",
333
+ minArgs: 1,
334
+ maxArgs: 1,
335
+ options: [
336
+ "ticket-url",
337
+ "clear-ticket",
338
+ "description",
339
+ "clear-description",
340
+ "repo",
341
+ "reference",
342
+ "clear-references",
343
+ "branch",
344
+ "base",
345
+ "pr-url",
346
+ "clear-pr",
347
+ "json",
348
+ ],
349
+ repeatable: ["reference"],
350
+ conflicts: [
351
+ ["ticket-url", "clear-ticket"],
352
+ ["description", "clear-description"],
353
+ ["reference", "clear-references"],
354
+ ["pr-url", "clear-pr"],
355
+ ],
356
+ },
357
+ rename: {
358
+ usage: "agency task rename <id> <new-id> [--json]",
359
+ minArgs: 2,
360
+ maxArgs: 2,
361
+ options: ["json"],
362
+ },
363
+ move: {
364
+ usage: "agency task move <id> (--epic <id> | --no-epic) [--json]",
365
+ minArgs: 1,
366
+ maxArgs: 1,
367
+ options: ["epic", "no-epic", "json"],
368
+ conflicts: [["epic", "no-epic"]],
369
+ },
370
+ dependency: {
371
+ usage:
372
+ "agency task dependency <add|remove> <task-id> <dependency-id> [--json]",
373
+ minArgs: 3,
374
+ maxArgs: 3,
375
+ options: ["json"],
376
+ },
290
377
  },
291
378
  },
292
379
  phase: {
293
- usage: "agency phase <create|list|show|status>",
380
+ usage: "agency phase <create|list|show|status|update|rename|dependency>",
294
381
  options: {
295
382
  ...phaseCreateOptions,
296
383
  ...viewOptions,
384
+ ...mutationOptions,
385
+ "pr-url": { type: "string" },
297
386
  task: { type: "string" },
298
387
  phase: { type: "string" },
299
388
  },
@@ -338,6 +427,42 @@ const commands = {
338
427
  maxArgs: 3,
339
428
  options: ["json", "task", "phase"],
340
429
  },
430
+ update: {
431
+ usage: "agency phase update <task-id> <phase-id> [options] [--json]",
432
+ minArgs: 2,
433
+ maxArgs: 2,
434
+ options: [
435
+ "description",
436
+ "clear-description",
437
+ "repo",
438
+ "reference",
439
+ "clear-references",
440
+ "branch",
441
+ "base",
442
+ "pr-url",
443
+ "clear-pr",
444
+ "json",
445
+ ],
446
+ repeatable: ["reference"],
447
+ conflicts: [
448
+ ["description", "clear-description"],
449
+ ["reference", "clear-references"],
450
+ ["pr-url", "clear-pr"],
451
+ ],
452
+ },
453
+ rename: {
454
+ usage: "agency phase rename <task-id> <phase-id> <new-id> [--json]",
455
+ minArgs: 3,
456
+ maxArgs: 3,
457
+ options: ["json"],
458
+ },
459
+ dependency: {
460
+ usage:
461
+ "agency phase dependency <add|remove> <task-id> <phase-id> <dependency-id> [--json]",
462
+ minArgs: 4,
463
+ maxArgs: 4,
464
+ options: ["json"],
465
+ },
341
466
  },
342
467
  },
343
468
  claim: {
@@ -439,19 +564,21 @@ const commands = {
439
564
  },
440
565
  work: {
441
566
  usage:
442
- "agency work [<directory-or-task-id> | --epic <epic-id>] | agency work prepare [target] [--dry-run] [--json]",
567
+ "agency work [<directory-or-task-id> | --epic <epic-id>] [--runner <name>] | agency work prepare [target] [--dry-run] [--json]",
443
568
  options: {
444
569
  ...commonOptions,
445
570
  ...entitySelectorOptions,
446
571
  json: { type: "boolean" },
447
572
  "dry-run": { type: "boolean" },
573
+ runner: { type: "string" },
574
+ "print-command": { type: "boolean" },
448
575
  opencode: { type: "boolean" },
449
576
  claude: { type: "boolean" },
450
577
  force: { type: "boolean" },
451
578
  },
452
579
  command: {
453
580
  usage:
454
- "agency work [<directory-or-task-id> | --epic <epic-id>] | agency work prepare [target] [--dry-run] [--json]",
581
+ "agency work [<directory-or-task-id> | --epic <epic-id>] [--runner <name>] | agency work prepare [target] [--dry-run] [--json]",
455
582
  minArgs: 0,
456
583
  maxArgs: 2,
457
584
  options: [
@@ -460,12 +587,16 @@ const commands = {
460
587
  "epic",
461
588
  "task",
462
589
  "phase",
590
+ "runner",
591
+ "print-command",
463
592
  "opencode",
464
593
  "claude",
465
594
  "force",
466
595
  ],
467
596
  conflicts: [
468
597
  ["opencode", "claude"],
598
+ ["runner", "opencode"],
599
+ ["runner", "claude"],
469
600
  ["epic", "$positional"],
470
601
  ],
471
602
  },
@@ -971,6 +1102,34 @@ export function parseCli(args: readonly string[]): ParsedCli {
971
1102
  ) {
972
1103
  validateTaskCreate(parsed.values, spec, subcommand === "create")
973
1104
  }
1105
+ if (["task", "phase"].includes(commandName) && subcommand === "dependency") {
1106
+ if (!["add", "remove"].includes(commandPositionals[0] ?? "")) {
1107
+ throw usageError(
1108
+ "Dependency operation must be 'add' or 'remove'.",
1109
+ spec.usage,
1110
+ )
1111
+ }
1112
+ }
1113
+ if (commandName === "task" && subcommand === "move") {
1114
+ if (
1115
+ parsed.values.epic === undefined &&
1116
+ parsed.values["no-epic"] === undefined
1117
+ ) {
1118
+ throw usageError(
1119
+ "One of '--epic' or '--no-epic' is required.",
1120
+ spec.usage,
1121
+ )
1122
+ }
1123
+ }
1124
+ if (
1125
+ ["epic", "task", "phase"].includes(commandName) &&
1126
+ subcommand === "update"
1127
+ ) {
1128
+ const mutationNames = (spec.options ?? []).filter((name) => name !== "json")
1129
+ if (!mutationNames.some((name) => parsed.values[name] !== undefined)) {
1130
+ throw usageError("At least one update option is required.", spec.usage)
1131
+ }
1132
+ }
974
1133
  if (commandName === "graph") {
975
1134
  validateGraphOptions(parsed.values, spec)
976
1135
  }
@@ -998,6 +1157,8 @@ export function parseCli(args: readonly string[]): ParsedCli {
998
1157
  (parsed.values.epic ||
999
1158
  parsed.values.opencode ||
1000
1159
  parsed.values.claude ||
1160
+ parsed.values.runner ||
1161
+ parsed.values["print-command"] ||
1001
1162
  parsed.values.force))
1002
1163
  ) {
1003
1164
  throw usageError(
package/src/cli.test.ts CHANGED
@@ -166,6 +166,68 @@ describe("CLI", () => {
166
166
  expect(finished.claim).toMatchObject({ state: "finished", outcome: "done" })
167
167
  })
168
168
 
169
+ test("routes graph mutations through structured output", async () => {
170
+ const root = await createTempDir()
171
+ tempDirs.push(root)
172
+ await Bun.write(join(root, "agency.json"), '{"version":2}\n')
173
+ await mkdir(join(root, "repos", "agency"), { recursive: true })
174
+ parseJson(
175
+ await runCli(
176
+ [
177
+ "epic",
178
+ "create",
179
+ "delivery",
180
+ "--ticket-url",
181
+ "https://example.com/delivery",
182
+ "--repo",
183
+ "agency:main",
184
+ "--json",
185
+ ],
186
+ root,
187
+ ),
188
+ )
189
+ for (const id of ["first", "second"]) {
190
+ parseJson(
191
+ await runCli(
192
+ [
193
+ "task",
194
+ "create",
195
+ id,
196
+ "--repo",
197
+ "agency",
198
+ "--epic",
199
+ "delivery",
200
+ "--json",
201
+ ],
202
+ root,
203
+ ),
204
+ )
205
+ }
206
+
207
+ const updated = parseJson(
208
+ await runCli(
209
+ ["task", "update", "second", "--description", "Revised", "--json"],
210
+ root,
211
+ ),
212
+ )
213
+ expect(updated).toMatchObject({
214
+ operation: "task.update",
215
+ entity: { kind: "task", id: "second" },
216
+ validation: { valid: true, scope: ["tasks/second/TASK.md"] },
217
+ })
218
+
219
+ const dependency = parseJson(
220
+ await runCli(
221
+ ["task", "dependency", "add", "second", "first", "--json"],
222
+ root,
223
+ ),
224
+ )
225
+ expect(dependency).toMatchObject({
226
+ operation: "task.dependency.add",
227
+ changedPaths: ["epics/delivery/EPIC.md"],
228
+ })
229
+ })
230
+
169
231
  test("emits one versioned error envelope for usage and command failures", async () => {
170
232
  const usage = await runCli(["unknown", "--json"])
171
233
  expect(usage.exitCode).toBe(1)