@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.
@@ -5,12 +5,14 @@ import { createLoggers } from "../utils/effect"
5
5
  import { formatTable } from "../utils/table"
6
6
  import { getWorkViews } from "../work-view"
7
7
  import { parseRepositoryReferences } from "../workbase/repository-reference"
8
+ import { GraphMutationService } from "../services/GraphMutationService"
8
9
 
9
10
  interface EpicOptions extends BaseCommandOptions {
10
11
  readonly subcommand?: string
11
12
  readonly args: readonly string[]
12
13
  readonly ticketUrl?: string
13
14
  readonly description?: string
15
+ readonly clearDescription?: boolean
14
16
  readonly repos?: readonly string[]
15
17
  readonly json?: boolean
16
18
  readonly statuses?: readonly string[]
@@ -23,6 +25,7 @@ interface EpicOptions extends BaseCommandOptions {
23
25
  export const epic = (options: EpicOptions) =>
24
26
  Effect.gen(function* () {
25
27
  const epics = yield* EpicService
28
+ const mutations = yield* GraphMutationService
26
29
  const { log } = createLoggers(options)
27
30
  const cwd = options.cwd ?? process.cwd()
28
31
 
@@ -105,10 +108,49 @@ export const epic = (options: EpicOptions) =>
105
108
  return
106
109
  }
107
110
 
111
+ case "update": {
112
+ const id = options.args[0]
113
+ if (!id) return yield* Effect.fail(new Error("Epic ID is required"))
114
+ const output = yield* mutations.updateEpic(
115
+ id,
116
+ {
117
+ description: options.clearDescription ? null : options.description,
118
+ ticketUrl: options.ticketUrl,
119
+ repos:
120
+ options.repos === undefined
121
+ ? undefined
122
+ : parseRepositoryReferences(options.repos),
123
+ },
124
+ cwd,
125
+ )
126
+ log(
127
+ options.json
128
+ ? JSON.stringify(output, null, 2)
129
+ : `Updated epic '${id}'`,
130
+ )
131
+ return
132
+ }
133
+
134
+ case "rename": {
135
+ const [id, newId] = options.args
136
+ if (!id || !newId) {
137
+ return yield* Effect.fail(
138
+ new Error("Epic ID and new ID are required"),
139
+ )
140
+ }
141
+ const output = yield* mutations.renameEpic(id, newId, cwd)
142
+ log(
143
+ options.json
144
+ ? JSON.stringify(output, null, 2)
145
+ : `Renamed epic '${id}' to '${newId}'`,
146
+ )
147
+ return
148
+ }
149
+
108
150
  default:
109
151
  return yield* Effect.fail(
110
152
  new Error(
111
- "Subcommand is required. Available subcommands: create, list, show",
153
+ "Subcommand is required. Available subcommands: create, list, show, update, rename",
112
154
  ),
113
155
  )
114
156
  }
@@ -121,12 +163,20 @@ Subcommands:
121
163
  create <id> Create an epic
122
164
  list List epics
123
165
  show <id> Show an epic
166
+ update <id> Update epic metadata
167
+ rename <id> <new-id> Rename an epic and update task references
124
168
 
125
169
  Create options:
126
170
  --ticket-url <url> External ticket URL
127
171
  --description <text> Short description of the epic
128
172
  --repo <alias>:<ref> Read-only repository reference; repeatable
129
173
 
174
+ Update options:
175
+ --ticket-url <url> Replace the external ticket URL
176
+ --description <text> Replace the description
177
+ --clear-description Remove the description
178
+ --repo <alias>:<ref> Replace repository references; repeatable
179
+
130
180
  Options:
131
181
  --json Output results as JSON
132
182
  --status <status> Filter list by status; repeatable
@@ -5,15 +5,20 @@ import { createLoggers } from "../utils/effect"
5
5
  import { formatTable } from "../utils/table"
6
6
  import { getWorkViews } from "../work-view"
7
7
  import { parseRepositoryReferences } from "../workbase/repository-reference"
8
+ import { GraphMutationService } from "../services/GraphMutationService"
8
9
 
9
10
  interface PhaseOptions extends BaseCommandOptions {
10
11
  readonly subcommand?: string
11
12
  readonly args: readonly string[]
12
13
  readonly description?: string
14
+ readonly clearDescription?: boolean
13
15
  readonly repo?: string
14
16
  readonly references?: readonly string[]
15
17
  readonly branch?: string
16
18
  readonly base?: string
19
+ readonly clearReferences?: boolean
20
+ readonly prUrl?: string
21
+ readonly clearPr?: boolean
17
22
  readonly dependsOn?: readonly string[]
18
23
  readonly firstPhase?: string
19
24
  readonly json?: boolean
@@ -27,6 +32,7 @@ interface PhaseOptions extends BaseCommandOptions {
27
32
  export const phase = (options: PhaseOptions) =>
28
33
  Effect.gen(function* () {
29
34
  const phases = yield* PhaseService
35
+ const mutations = yield* GraphMutationService
30
36
  const { log } = createLoggers(options)
31
37
  const cwd = options.cwd ?? process.cwd()
32
38
  const [taskId, phaseId] = options.args
@@ -153,10 +159,84 @@ export const phase = (options: PhaseOptions) =>
153
159
  )
154
160
  return
155
161
  }
162
+ case "update": {
163
+ if (!taskId || !phaseId) {
164
+ return yield* Effect.fail(
165
+ new Error("Task ID and phase ID are required"),
166
+ )
167
+ }
168
+ const output = yield* mutations.updatePhase(
169
+ taskId,
170
+ phaseId,
171
+ {
172
+ description: options.clearDescription ? null : options.description,
173
+ repo: options.repo,
174
+ repos: options.clearReferences
175
+ ? null
176
+ : options.references === undefined
177
+ ? undefined
178
+ : parseRepositoryReferences(options.references),
179
+ branch: options.branch,
180
+ base: options.base,
181
+ pr: options.clearPr ? null : options.prUrl,
182
+ },
183
+ cwd,
184
+ )
185
+ log(
186
+ options.json
187
+ ? JSON.stringify(output, null, 2)
188
+ : `Updated phase '${phaseId}'`,
189
+ )
190
+ return
191
+ }
192
+ case "rename": {
193
+ const newId = options.args[2]
194
+ if (!taskId || !phaseId || !newId) {
195
+ return yield* Effect.fail(
196
+ new Error("Task ID, phase ID, and new ID are required"),
197
+ )
198
+ }
199
+ const output = yield* mutations.renamePhase(taskId, phaseId, newId, cwd)
200
+ log(
201
+ options.json
202
+ ? JSON.stringify(output, null, 2)
203
+ : `Renamed phase '${phaseId}' to '${newId}'`,
204
+ )
205
+ return
206
+ }
207
+ case "dependency": {
208
+ const [operation, dependencyTaskId, dependencyPhaseId, dependencyId] =
209
+ options.args
210
+ if (
211
+ (operation !== "add" && operation !== "remove") ||
212
+ !dependencyTaskId ||
213
+ !dependencyPhaseId ||
214
+ !dependencyId
215
+ ) {
216
+ return yield* Effect.fail(
217
+ new Error(
218
+ "Usage: agency phase dependency <add|remove> <task-id> <phase-id> <dependency-id>",
219
+ ),
220
+ )
221
+ }
222
+ const output = yield* mutations.mutatePhaseDependency(
223
+ operation,
224
+ dependencyTaskId,
225
+ dependencyPhaseId,
226
+ dependencyId,
227
+ cwd,
228
+ )
229
+ log(
230
+ options.json
231
+ ? JSON.stringify(output, null, 2)
232
+ : `${operation === "add" ? "Added" : "Removed"} dependency '${dependencyId}' ${operation === "add" ? "to" : "from"} phase '${dependencyPhaseId}'`,
233
+ )
234
+ return
235
+ }
156
236
  default:
157
237
  return yield* Effect.fail(
158
238
  new Error(
159
- "Subcommand is required. Available: create, list, show, status",
239
+ "Subcommand is required. Available: create, list, show, status, update, rename, dependency",
160
240
  ),
161
241
  )
162
242
  }
@@ -171,6 +251,11 @@ Subcommands:
171
251
  show <task> <phase> Show a phase
172
252
  status <task> <phase> <status>
173
253
  Set open, done, or dropped
254
+ update <task> <phase> Update phase metadata
255
+ rename <task> <phase> <new-id>
256
+ Rename a phase and update dependencies
257
+ dependency <operation> <task> <phase> <dependency>
258
+ Add or remove a phase dependency
174
259
 
175
260
  Create options:
176
261
  --description <text> Short description of the phase
@@ -182,6 +267,14 @@ Create options:
182
267
  --depends-on <id> Phase dependency; repeatable
183
268
  --first-phase <id> Existing execution phase ID when converting a task
184
269
 
270
+ Update options:
271
+ --description <text> / --clear-description
272
+ --repo <alias> Replace the writable repository
273
+ --reference <alias>:<ref> / --clear-references
274
+ --branch <name> Replace the working branch
275
+ --base <name> Replace the base branch
276
+ --pr-url <url> / --clear-pr
277
+
185
278
  Options:
186
279
  --json Output results as JSON
187
280
  --status <status> Filter list by status; repeatable
@@ -10,17 +10,24 @@ import { WorkbaseService } from "../services/WorkbaseService"
10
10
  import { choose } from "../utils/chooser"
11
11
  import { formatTable } from "../utils/table"
12
12
  import { getWorkViews } from "../work-view"
13
+ import { GraphMutationService } from "../services/GraphMutationService"
13
14
 
14
15
  interface TaskOptions extends BaseCommandOptions {
15
16
  readonly subcommand?: string
16
17
  readonly args: readonly string[]
17
18
  readonly ticketUrl?: string
18
19
  readonly description?: string
20
+ readonly clearDescription?: boolean
21
+ readonly clearTicket?: boolean
19
22
  readonly epic?: string
20
23
  readonly repo?: string
21
24
  readonly references?: readonly string[]
22
25
  readonly branch?: string
23
26
  readonly base?: string
27
+ readonly clearReferences?: boolean
28
+ readonly prUrl?: string
29
+ readonly clearPr?: boolean
30
+ readonly noEpic?: boolean
24
31
  readonly multiPhase?: boolean
25
32
  readonly json?: boolean
26
33
  readonly statuses?: readonly string[]
@@ -74,6 +81,7 @@ export const task = (options: TaskOptions, interaction?: TaskInteraction) =>
74
81
  const epics = yield* EpicService
75
82
  const repositories = yield* RepositoryService
76
83
  const workbase = yield* WorkbaseService
84
+ const mutations = yield* GraphMutationService
77
85
  const { log } = createLoggers(options)
78
86
  const cwd = options.cwd ?? process.cwd()
79
87
 
@@ -291,10 +299,95 @@ export const task = (options: TaskOptions, interaction?: TaskInteraction) =>
291
299
  )
292
300
  return
293
301
  }
302
+ case "update": {
303
+ const id = options.args[0]
304
+ if (!id) return yield* Effect.fail(new Error("Task ID is required"))
305
+ const output = yield* mutations.updateTask(
306
+ id,
307
+ {
308
+ description: options.clearDescription ? null : options.description,
309
+ ticketUrl: options.clearTicket ? null : options.ticketUrl,
310
+ repo: options.repo,
311
+ repos: options.clearReferences
312
+ ? null
313
+ : options.references === undefined
314
+ ? undefined
315
+ : parseRepositoryReferences(options.references),
316
+ branch: options.branch,
317
+ base: options.base,
318
+ pr: options.clearPr ? null : options.prUrl,
319
+ },
320
+ cwd,
321
+ )
322
+ log(
323
+ options.json
324
+ ? JSON.stringify(output, null, 2)
325
+ : `Updated task '${id}'`,
326
+ )
327
+ return
328
+ }
329
+ case "rename": {
330
+ const [id, newId] = options.args
331
+ if (!id || !newId) {
332
+ return yield* Effect.fail(
333
+ new Error("Task ID and new ID are required"),
334
+ )
335
+ }
336
+ const output = yield* mutations.renameTask(id, newId, cwd)
337
+ log(
338
+ options.json
339
+ ? JSON.stringify(output, null, 2)
340
+ : `Renamed task '${id}' to '${newId}'`,
341
+ )
342
+ return
343
+ }
344
+ case "move": {
345
+ const id = options.args[0]
346
+ if (!id) return yield* Effect.fail(new Error("Task ID is required"))
347
+ const output = yield* mutations.moveTask(
348
+ id,
349
+ options.noEpic ? null : (options.epic ?? null),
350
+ cwd,
351
+ )
352
+ log(
353
+ options.json
354
+ ? JSON.stringify(output, null, 2)
355
+ : options.noEpic
356
+ ? `Removed task '${id}' from its epic`
357
+ : `Moved task '${id}' to epic '${options.epic}'`,
358
+ )
359
+ return
360
+ }
361
+ case "dependency": {
362
+ const [operation, id, dependencyId] = options.args
363
+ if (
364
+ (operation !== "add" && operation !== "remove") ||
365
+ !id ||
366
+ !dependencyId
367
+ ) {
368
+ return yield* Effect.fail(
369
+ new Error(
370
+ "Usage: agency task dependency <add|remove> <task-id> <dependency-id>",
371
+ ),
372
+ )
373
+ }
374
+ const output = yield* mutations.mutateTaskDependency(
375
+ operation,
376
+ id,
377
+ dependencyId,
378
+ cwd,
379
+ )
380
+ log(
381
+ options.json
382
+ ? JSON.stringify(output, null, 2)
383
+ : `${operation === "add" ? "Added" : "Removed"} dependency '${dependencyId}' ${operation === "add" ? "to" : "from"} task '${id}'`,
384
+ )
385
+ return
386
+ }
294
387
  default:
295
388
  return yield* Effect.fail(
296
389
  new Error(
297
- "Subcommand is required. Available: new, create, list, show, status",
390
+ "Subcommand is required. Available: new, create, list, show, status, update, rename, move, dependency",
298
391
  ),
299
392
  )
300
393
  }
@@ -309,6 +402,11 @@ Subcommands:
309
402
  list List tasks
310
403
  show <id> Show a task
311
404
  status <id> <status> Set open, done, or dropped
405
+ update <id> Update task metadata
406
+ rename <id> <new-id> Rename a task and update graph references
407
+ move <id> Move a task with --epic or --no-epic
408
+ dependency <operation> <task> <dependency>
409
+ Add or remove a task dependency
312
410
 
313
411
  Create options:
314
412
  --ticket-url <url> External ticket URL (optional)
@@ -321,6 +419,15 @@ Create options:
321
419
  --base <name> Base branch (default: main)
322
420
  --multi-phase Create a task container for phases
323
421
 
422
+ Update options:
423
+ --ticket-url <url> / --clear-ticket
424
+ --description <text> / --clear-description
425
+ --repo <alias> Replace the writable repository
426
+ --reference <alias>:<ref> / --clear-references
427
+ --branch <name> Replace the working branch
428
+ --base <name> Replace the base branch
429
+ --pr-url <url> / --clear-pr
430
+
324
431
  Task creation is noninteractive. Single-phase tasks require --repo; use
325
432
  --multi-phase instead for a task container. Guided input is available only
326
433
  through task new, which fails when --no-input is set or no TTY is available.
@@ -47,8 +47,16 @@ const multiPhaseWorkspace: ExecutionWorkspace = {
47
47
  interface HarnessOptions {
48
48
  readonly workspace?: ExecutionWorkspace
49
49
  readonly materializeError?: Error
50
- readonly available?: Partial<Record<"opencode" | "claude", boolean>>
50
+ readonly available?: Readonly<Record<string, boolean>>
51
51
  readonly chooserCommand?: readonly string[]
52
+ readonly runners?: Record<
53
+ string,
54
+ {
55
+ command: readonly [string, ...string[]]
56
+ resumeCommand?: readonly [string, ...string[]]
57
+ environment?: Record<string, string>
58
+ }
59
+ >
52
60
  readonly multiPhaseTasks?: readonly string[]
53
61
  readonly epicRecords?: readonly any[]
54
62
  readonly taskRecords?: readonly any[]
@@ -72,6 +80,7 @@ const createHarness = (options: HarnessOptions = {}) => {
72
80
  args: readonly string[]
73
81
  cwd: string
74
82
  }> = []
83
+ const launchEnvironments: Array<Readonly<Record<string, string>>> = []
75
84
  const materializeOptions: Array<
76
85
  Parameters<WorktreeService["materialize"]>[3]
77
86
  > = []
@@ -105,6 +114,7 @@ const createHarness = (options: HarnessOptions = {}) => {
105
114
  config: {
106
115
  version: 2 as const,
107
116
  chooserCommand: options.chooserCommand,
117
+ runners: options.runners,
108
118
  },
109
119
  }),
110
120
  }
@@ -204,7 +214,7 @@ const createHarness = (options: HarnessOptions = {}) => {
204
214
  isDirectory: (path: string) =>
205
215
  Effect.succeed(options.existingDirectories?.includes(path) ?? true),
206
216
  runCommand: (args: readonly string[]) => {
207
- const cli = args[1] as "opencode" | "claude"
217
+ const cli = args[1]!
208
218
  events.push(`probe:${cli}`)
209
219
  probes.push(cli)
210
220
  return Effect.succeed({
@@ -214,9 +224,15 @@ const createHarness = (options: HarnessOptions = {}) => {
214
224
  })
215
225
  },
216
226
  }
217
- const launch = (cli: string, args: readonly string[], cwd: string) => {
227
+ const launch = (
228
+ cli: string,
229
+ args: readonly string[],
230
+ cwd: string,
231
+ environment: Readonly<Record<string, string>>,
232
+ ) => {
218
233
  events.push(`launch:${cli}`)
219
234
  launches.push({ cli, args, cwd })
235
+ launchEnvironments.push(environment)
220
236
  }
221
237
  const defaultPick: PickWorkTarget = () => Effect.succeed(null)
222
238
  const defaultPickWorkbase: PickWorkbase = () => Effect.succeed(null)
@@ -257,6 +273,7 @@ const createHarness = (options: HarnessOptions = {}) => {
257
273
  events,
258
274
  probes,
259
275
  launches,
276
+ launchEnvironments,
260
277
  materializeOptions,
261
278
  statusUpdates,
262
279
  shownTasks,
@@ -353,7 +370,6 @@ describe("work command", () => {
353
370
  cli: "opencode",
354
371
  args: [
355
372
  "opencode",
356
- "--continue",
357
373
  "--prompt",
358
374
  "Work on the epic. Read /workbase/epics/delivery/EPIC.md.",
359
375
  ],
@@ -403,7 +419,6 @@ describe("work command", () => {
403
419
  cli: "opencode",
404
420
  args: [
405
421
  "opencode",
406
- "--continue",
407
422
  "--prompt",
408
423
  "Work on the task. Read /workbase/tasks/delivery/TASK.md.",
409
424
  ],
@@ -429,7 +444,6 @@ describe("work command", () => {
429
444
  cli: "opencode",
430
445
  args: [
431
446
  "opencode",
432
- "--continue",
433
447
  "--prompt",
434
448
  "Start the task. Read /workbase/tasks/example/TASK.md and /workbase/tasks/example/phases/implementation/PHASE.md.",
435
449
  ],
@@ -596,7 +610,7 @@ describe("work command", () => {
596
610
 
597
611
  await expect(
598
612
  harness.run({ taskId: "example", opencode: true, claude: true }),
599
- ).rejects.toThrow("Cannot use both --opencode and --claude")
613
+ ).rejects.toThrow("Cannot combine --runner, --opencode, and --claude")
600
614
  expect(harness.events).toEqual([])
601
615
  })
602
616
 
@@ -624,7 +638,6 @@ describe("work command", () => {
624
638
  cli: "opencode",
625
639
  args: [
626
640
  "opencode",
627
- "--continue",
628
641
  "--prompt",
629
642
  "Start the task. Read /workbase/tasks/example/TASK.md.",
630
643
  ],
@@ -638,14 +651,18 @@ describe("work command", () => {
638
651
  ])
639
652
  })
640
653
 
641
- test("continues OpenCode with explicit task and phase context", async () => {
654
+ test("resumes OpenCode deterministically when a session identity exists", async () => {
642
655
  const harness = createHarness({ workspace: multiPhaseWorkspace })
643
-
644
- await harness.run({
645
- taskId: "example",
646
- phaseId: "implementation",
647
- opencode: true,
648
- })
656
+ process.env.AGENCY_SESSION_ID = "existing-session"
657
+ try {
658
+ await harness.run({
659
+ taskId: "example",
660
+ phaseId: "implementation",
661
+ opencode: true,
662
+ })
663
+ } finally {
664
+ delete process.env.AGENCY_SESSION_ID
665
+ }
649
666
 
650
667
  expect(harness.launches[0]).toEqual({
651
668
  cli: "opencode",
@@ -659,6 +676,78 @@ describe("work command", () => {
659
676
  })
660
677
  })
661
678
 
679
+ test("expands a named runner with shared context and claim identity", async () => {
680
+ const harness = createHarness({
681
+ available: { codex: true },
682
+ runners: {
683
+ custom: {
684
+ command: ["codex", "--task", "{task}", "{prompt}"],
685
+ environment: {
686
+ CUSTOM_TARGET: "{target}",
687
+ AGENCY_TARGET: "cannot-override",
688
+ },
689
+ },
690
+ },
691
+ })
692
+
693
+ await harness.run({ taskId: "example", runner: "custom" })
694
+
695
+ expect(harness.probes).toEqual(["codex"])
696
+ expect(harness.launches[0]).toEqual({
697
+ cli: "codex",
698
+ args: [
699
+ "codex",
700
+ "--task",
701
+ "example",
702
+ "Start the task. Read /workbase/tasks/example/TASK.md.",
703
+ ],
704
+ cwd: singlePhaseWorkspace.writablePath,
705
+ })
706
+ expect(harness.launchEnvironments[0]).toMatchObject({
707
+ AGENCY_RUNNER: "custom",
708
+ AGENCY_CLAIMANT: process.env.USER ?? "agency",
709
+ AGENCY_WORKBASE: "/workbase",
710
+ AGENCY_TARGET: "execution-unit:task/example",
711
+ AGENCY_TASK_ID: "example",
712
+ AGENCY_PHASE_ID: "",
713
+ AGENCY_CLAIM_REVISION: "1".repeat(64),
714
+ CUSTOM_TARGET: "execution-unit:task/example",
715
+ })
716
+ })
717
+
718
+ test("prints the exact command contract without launching and omits secrets", async () => {
719
+ const harness = createHarness({
720
+ available: { agent: true },
721
+ runners: {
722
+ custom: {
723
+ command: ["agent", "{prompt}"],
724
+ environment: {
725
+ VISIBLE: "{task}",
726
+ API_TOKEN: "do-not-print",
727
+ },
728
+ },
729
+ },
730
+ })
731
+
732
+ const output = await captureLogs(() =>
733
+ harness.run({
734
+ taskId: "example",
735
+ runner: "custom",
736
+ printCommand: true,
737
+ }),
738
+ )
739
+ const printed = JSON.parse(output.join("\n"))
740
+
741
+ expect(harness.launches).toEqual([])
742
+ expect(printed.cwd).toBe(singlePhaseWorkspace.writablePath)
743
+ expect(printed.argv).toEqual([
744
+ "agent",
745
+ "Start the task. Read /workbase/tasks/example/TASK.md.",
746
+ ])
747
+ expect(printed.environment.VISIBLE).toBe("example")
748
+ expect(printed.environment.API_TOKEN).toBeUndefined()
749
+ })
750
+
662
751
  test("automatically falls back to Claude", async () => {
663
752
  const harness = createHarness({ available: { opencode: false } })
664
753
 
@@ -729,7 +818,7 @@ describe("work command", () => {
729
818
  verboseHarness.run({ taskId: "example", verbose: true }),
730
819
  )
731
820
  expect(verboseLogs).toEqual([
732
- "Launching command: opencode --continue --prompt 'Start the task. Read /workbase/tasks/example/TASK.md.' (cwd: /workbase/tasks/example/code/agency)",
821
+ "Launching command: opencode --prompt 'Start the task. Read /workbase/tasks/example/TASK.md.' (cwd: /workbase/tasks/example/code/agency)",
733
822
  ])
734
823
  expect(verboseHarness.materializeOptions[0]?.verbose).toBe(true)
735
824