@markjaquith/agency 2.30.1 → 2.32.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.
Files changed (38) hide show
  1. package/README.md +56 -38
  2. package/cli.ts +2 -1
  3. package/package.json +5 -2
  4. package/skills/agency/SKILL.md +7 -6
  5. package/skills/agency/references/commands.md +17 -14
  6. package/skills/agency/references/contracts.md +4 -5
  7. package/skills/agency/references/recipes.md +13 -10
  8. package/src/cli-parser.test.ts +12 -2
  9. package/src/cli-parser.ts +5 -2
  10. package/src/cli.test.ts +34 -10
  11. package/src/commands/doctor.test.ts +11 -1
  12. package/src/commands/phase.ts +1 -1
  13. package/src/commands/task.test.ts +34 -1
  14. package/src/commands/task.ts +16 -20
  15. package/src/commands/work.test.ts +65 -65
  16. package/src/commands/work.ts +43 -35
  17. package/src/opentui.d.ts +1 -0
  18. package/src/services/DoctorService.ts +22 -0
  19. package/src/services/EpicService.test.ts +3 -0
  20. package/src/services/EpicService.ts +2 -1
  21. package/src/services/IntegrationService.test.ts +2 -1
  22. package/src/services/PhaseService.ts +5 -4
  23. package/src/services/PullRequestService.test.ts +4 -1
  24. package/src/services/ReadinessService.test.ts +54 -0
  25. package/src/services/ReadinessService.ts +39 -2
  26. package/src/services/TaskPhaseService.test.ts +38 -11
  27. package/src/services/TaskService.ts +4 -3
  28. package/src/utils/chooser.test.ts +49 -12
  29. package/src/utils/chooser.ts +20 -37
  30. package/src/utils/interactive-loader.ts +6 -0
  31. package/src/utils/interactive.test.tsx +258 -0
  32. package/src/utils/interactive.tsx +290 -0
  33. package/src/workbase/AGENTS.md +5 -5
  34. package/src/workbase/frontmatter.ts +17 -0
  35. package/src/workbase/runner-command.test.ts +30 -4
  36. package/src/workbase/runner-command.ts +21 -6
  37. package/src/workbase/schemas.test.ts +4 -2
  38. package/src/workbase/schemas.ts +4 -0
@@ -61,10 +61,43 @@ describe("task creation input", () => {
61
61
  "Description (optional): ",
62
62
  "Parent epic: (none),delivery",
63
63
  "Task type: single-phase,multi-phase",
64
- "Writable repository: agency",
65
64
  ])
66
65
  })
67
66
 
67
+ test("asks for a repository only when more than one is available", async () => {
68
+ await mkdir(join(root, "repos/web"), { recursive: true })
69
+ const prompts: string[] = []
70
+ const interaction: TaskInteraction = {
71
+ text: () => Effect.fail(new Error("unexpected text prompt")),
72
+ select: (prompt, choices) => {
73
+ prompts.push(`${prompt}: ${choices.join(",")}`)
74
+ return Effect.succeed(prompt === "Task type" ? "single-phase" : "web")
75
+ },
76
+ }
77
+
78
+ await runTestEffect(
79
+ task(
80
+ {
81
+ subcommand: "new",
82
+ args: ["multi-repo"],
83
+ ticketUrl: "",
84
+ description: "",
85
+ cwd: root,
86
+ silent: true,
87
+ },
88
+ interaction,
89
+ ),
90
+ )
91
+
92
+ expect(prompts).toEqual([
93
+ "Task type: single-phase,multi-phase",
94
+ "Writable repository: agency,web",
95
+ ])
96
+ expect(
97
+ await Bun.file(join(root, "tasks/multi-repo/TASK.md")).text(),
98
+ ).toContain("repo: web")
99
+ })
100
+
68
101
  test("keeps scripted creation non-interactive and permits no ticket URL", async () => {
69
102
  const interaction: TaskInteraction = {
70
103
  text: () => Effect.fail(new Error("unexpected text prompt")),
@@ -1,5 +1,4 @@
1
1
  import { Effect } from "effect"
2
- import { createInterface } from "node:readline/promises"
3
2
  import type { BaseCommandOptions } from "../utils/command"
4
3
  import { TaskService } from "../services/TaskService"
5
4
  import { EpicService } from "../services/EpicService"
@@ -51,17 +50,10 @@ const defaultInteraction = (
51
50
  ): TaskInteraction => ({
52
51
  text: (prompt) =>
53
52
  Effect.tryPromise({
54
- try: async () => {
55
- const input = createInterface({
56
- input: process.stdin,
57
- output: process.stderr,
58
- })
59
- try {
60
- return await input.question(prompt)
61
- } finally {
62
- input.close()
63
- }
64
- },
53
+ try: async () =>
54
+ (
55
+ await (await import("../utils/interactive-loader")).loadInteractive()
56
+ ).promptText(prompt),
65
57
  catch: (cause) => new Error("Failed to read task input", { cause }),
66
58
  }),
67
59
  select: (prompt, choices) =>
@@ -157,14 +149,18 @@ export const task = (options: TaskOptions, interaction?: TaskInteraction) =>
157
149
  ),
158
150
  )
159
151
  }
160
- const selected = yield* activeInteraction.select(
161
- "Writable repository",
162
- records.map((record) => record.alias),
163
- )
164
- if (!selected) {
165
- return yield* Effect.fail(new Error("Task creation cancelled"))
152
+ if (records.length === 1) {
153
+ repo = records[0]!.alias
154
+ } else {
155
+ const selected = yield* activeInteraction.select(
156
+ "Writable repository",
157
+ records.map((record) => record.alias),
158
+ )
159
+ if (!selected) {
160
+ return yield* Effect.fail(new Error("Task creation cancelled"))
161
+ }
162
+ repo = selected
166
163
  }
167
- repo = selected
168
164
  }
169
165
 
170
166
  const record = yield* tasks.create(
@@ -410,7 +406,7 @@ Subcommands:
410
406
  create <id> Create a task without prompting
411
407
  list List tasks
412
408
  show <id> Show a task
413
- status <id> <status> Set open, done, or dropped
409
+ status <id> <status> Set open, working, done, or dropped
414
410
  update <id> Update task metadata
415
411
  rename <id> <new-id> Rename a task and update graph references
416
412
  move <id> Move a task with --epic or --no-epic
@@ -6,7 +6,6 @@ import { EpicService } from "../services/EpicService"
6
6
  import { TaskService } from "../services/TaskService"
7
7
  import { PhaseService } from "../services/PhaseService"
8
8
  import { WorktreeService } from "../services/WorktreeService"
9
- import { ClaimService } from "../services/ClaimService"
10
9
  import { ReadinessService } from "../services/ReadinessService"
11
10
  import { IntegrationService } from "../services/IntegrationService"
12
11
  import { captureErrors, captureLogs } from "../test-utils"
@@ -45,6 +44,8 @@ const multiPhaseWorkspace: ExecutionWorkspace = {
45
44
  operations: [],
46
45
  }
47
46
 
47
+ const taskDirectory = "/workbase/tasks/example"
48
+
48
49
  interface HarnessOptions {
49
50
  readonly workspace?: ExecutionWorkspace
50
51
  readonly materializeError?: Error
@@ -54,7 +55,9 @@ interface HarnessOptions {
54
55
  string,
55
56
  {
56
57
  command: readonly [string, ...string[]]
58
+ autoCommand?: readonly [string, ...string[]]
57
59
  resumeCommand?: readonly [string, ...string[]]
60
+ autoResumeCommand?: readonly [string, ...string[]]
58
61
  environment?: Record<string, string>
59
62
  }
60
63
  >
@@ -66,7 +69,7 @@ interface HarnessOptions {
66
69
  readonly registeredWorkbases?: readonly string[]
67
70
  readonly existingDirectories?: readonly string[]
68
71
  readonly guardError?: Error
69
- readonly readyTargetIds?: readonly string[]
72
+ readonly workTargetIds?: readonly string[]
70
73
  readonly opencodeIntegrationState?: "managed" | "customized"
71
74
  }
72
75
 
@@ -161,35 +164,11 @@ const createHarness = (options: HarnessOptions = {}) => {
161
164
  return Effect.void
162
165
  },
163
166
  }
164
- const claims = {
165
- inspect: (taskId: string, phaseId?: string) =>
166
- Effect.succeed({
167
- target: {
168
- kind: phaseId ? "phase" : "task",
169
- taskId,
170
- phaseId,
171
- path: phaseId
172
- ? `/workbase/tasks/${taskId}/phases/${phaseId}/PHASE.md`
173
- : `/workbase/tasks/${taskId}/TASK.md`,
174
- label: phaseId ? `phase '${taskId}/${phaseId}'` : `task '${taskId}'`,
175
- },
176
- revision: "0".repeat(64),
177
- data: {},
178
- }),
179
- claim: (input: { taskId: string; phaseId?: string }) => {
180
- statusUpdates.push(
181
- input.phaseId
182
- ? `phase:${input.taskId}:${input.phaseId}:working`
183
- : `task:${input.taskId}:working`,
184
- )
185
- return Effect.succeed({ revision: "1".repeat(64) })
186
- },
187
- }
188
167
  const readiness = {
189
- getReadyWorkTargetIds: () =>
168
+ getWorkTargetIds: () =>
190
169
  Effect.succeed(
191
170
  new Set(
192
- options.readyTargetIds ?? [
171
+ options.workTargetIds ?? [
193
172
  ...(options.epicRecords ?? []).map(
194
173
  (record: any) => `epic:${record.id}`,
195
174
  ),
@@ -271,7 +250,6 @@ const createHarness = (options: HarnessOptions = {}) => {
271
250
  Effect.provideService(EpicService, epics as never),
272
251
  Effect.provideService(TaskService, tasks as never),
273
252
  Effect.provideService(PhaseService, phases as never),
274
- Effect.provideService(ClaimService, claims as never),
275
253
  Effect.provideService(ReadinessService, readiness as never),
276
254
  Effect.provideService(IntegrationService, integrations as never),
277
255
  ) as Effect.Effect<void, unknown, never>,
@@ -338,7 +316,7 @@ describe("work command", () => {
338
316
  })
339
317
  })
340
318
 
341
- test("offers only graph-ready targets to the interactive chooser", async () => {
319
+ test("offers only launchable targets to the interactive chooser", async () => {
342
320
  const harness = createHarness({
343
321
  taskRecords: [
344
322
  {
@@ -352,7 +330,7 @@ describe("work command", () => {
352
330
  data: { status: "open" },
353
331
  },
354
332
  ],
355
- readyTargetIds: ["execution-unit:task/ready"],
333
+ workTargetIds: ["execution-unit:task/ready"],
356
334
  })
357
335
  let labels: readonly string[] = []
358
336
  const pick: PickWorkTarget = (choices) => {
@@ -392,6 +370,7 @@ describe("work command", () => {
392
370
  cwd: "/workbase/epics/delivery",
393
371
  directory: ".",
394
372
  opencode: true,
373
+ auto: true,
395
374
  })
396
375
 
397
376
  expect(harness.events).toEqual(["probe:opencode", "launch:opencode"])
@@ -435,7 +414,7 @@ describe("work command", () => {
435
414
  })
436
415
 
437
416
  expect(harness.shownTasks).toEqual(["delivery"])
438
- expect(harness.launches[0]?.cwd).toBe(singlePhaseWorkspace.writablePath)
417
+ expect(harness.launches[0]?.cwd).toBe(taskDirectory)
439
418
  })
440
419
 
441
420
  test("treats a positional value as a task ID when it is not a directory", async () => {
@@ -448,7 +427,7 @@ describe("work command", () => {
448
427
  })
449
428
 
450
429
  expect(harness.shownTasks).toEqual(["delivery"])
451
- expect(harness.launches[0]?.cwd).toBe(singlePhaseWorkspace.writablePath)
430
+ expect(harness.launches[0]?.cwd).toBe(taskDirectory)
452
431
  })
453
432
 
454
433
  test("launches a multi-phase task agent without materializing", async () => {
@@ -458,6 +437,7 @@ describe("work command", () => {
458
437
  cwd: "/workbase/tasks/delivery",
459
438
  directory: ".",
460
439
  opencode: true,
440
+ auto: true,
461
441
  })
462
442
 
463
443
  expect(harness.events).toEqual(["probe:opencode", "launch:opencode"])
@@ -482,6 +462,7 @@ describe("work command", () => {
482
462
  cwd: "/workbase/tasks/example/phases/implementation/code/agency/src",
483
463
  directory: ".",
484
464
  opencode: true,
465
+ auto: true,
485
466
  })
486
467
 
487
468
  expect(harness.events).toEqual([
@@ -496,7 +477,7 @@ describe("work command", () => {
496
477
  "--prompt",
497
478
  "Start the task. Read /workbase/tasks/example/TASK.md and /workbase/tasks/example/phases/implementation/PHASE.md.",
498
479
  ],
499
- cwd: multiPhaseWorkspace.writablePath,
480
+ cwd: taskDirectory,
500
481
  })
501
482
  expect(harness.statusUpdates).toEqual([
502
483
  "phase:example:implementation:working",
@@ -516,7 +497,7 @@ describe("work command", () => {
516
497
  })
517
498
 
518
499
  expect(harness.events[0]).toBe("materialize")
519
- expect(harness.launches[0]?.cwd).toBe(singlePhaseWorkspace.writablePath)
500
+ expect(harness.launches[0]?.cwd).toBe(taskDirectory)
520
501
  expect(harness.launchEnvironments[0]?.OPENCODE_CONFIG).toBe(
521
502
  "/workbase/.opencode/opencode.jsonc",
522
503
  )
@@ -552,7 +533,7 @@ describe("work command", () => {
552
533
  "probe:opencode",
553
534
  "launch:opencode",
554
535
  ])
555
- expect(harness.launches[0]?.cwd).toBe(multiPhaseWorkspace.writablePath)
536
+ expect(harness.launches[0]?.cwd).toBe(taskDirectory)
556
537
  })
557
538
 
558
539
  test("requires an explicit target when input is disabled", async () => {
@@ -678,7 +659,7 @@ describe("work command", () => {
678
659
  expect(harness.events).toEqual([])
679
660
  })
680
661
 
681
- test("launches OpenCode in the writable checkout with explicit context", async () => {
662
+ test("launches OpenCode in the task directory with explicit context", async () => {
682
663
  const harness = createHarness()
683
664
 
684
665
  await harness.run({ taskId: "example", opencode: true })
@@ -691,14 +672,11 @@ describe("work command", () => {
691
672
  expect(harness.launches).toEqual([
692
673
  {
693
674
  cli: "opencode",
694
- args: [
695
- "opencode",
696
- "--prompt",
697
- "Start the task. Read /workbase/tasks/example/TASK.md.",
698
- ],
699
- cwd: singlePhaseWorkspace.writablePath,
675
+ args: ["opencode"],
676
+ cwd: taskDirectory,
700
677
  },
701
678
  ])
679
+ expect(harness.launchEnvironments[0]?.AGENCY_PROMPT).toBe("")
702
680
  expect(harness.statusUpdates).toEqual(["task:example:working"])
703
681
  expect(harness.launchEnvironments[0]?.OPENCODE_CONFIG).toBe(
704
682
  "/workbase/.opencode/opencode.jsonc",
@@ -709,6 +687,21 @@ describe("work command", () => {
709
687
  ])
710
688
  })
711
689
 
690
+ test("sends the generated prompt only with --auto", async () => {
691
+ const harness = createHarness()
692
+
693
+ await harness.run({ taskId: "example", opencode: true, auto: true })
694
+
695
+ expect(harness.launches[0]?.args).toEqual([
696
+ "opencode",
697
+ "--prompt",
698
+ "Start the task. Read /workbase/tasks/example/TASK.md.",
699
+ ])
700
+ expect(harness.launchEnvironments[0]?.AGENCY_PROMPT).toBe(
701
+ "Start the task. Read /workbase/tasks/example/TASK.md.",
702
+ )
703
+ })
704
+
712
705
  test("resumes OpenCode deterministically when a session identity exists", async () => {
713
706
  const harness = createHarness({ workspace: multiPhaseWorkspace })
714
707
  process.env.AGENCY_SESSION_ID = "existing-session"
@@ -717,6 +710,7 @@ describe("work command", () => {
717
710
  taskId: "example",
718
711
  phaseId: "implementation",
719
712
  opencode: true,
713
+ auto: true,
720
714
  })
721
715
  } finally {
722
716
  delete process.env.AGENCY_SESSION_ID
@@ -730,16 +724,17 @@ describe("work command", () => {
730
724
  "--prompt",
731
725
  "Start the task. Read /workbase/tasks/example/TASK.md and /workbase/tasks/example/phases/implementation/PHASE.md.",
732
726
  ],
733
- cwd: multiPhaseWorkspace.writablePath,
727
+ cwd: taskDirectory,
734
728
  })
735
729
  })
736
730
 
737
- test("expands a named runner with shared context and claim identity", async () => {
731
+ test("expands a named runner with shared context", async () => {
738
732
  const harness = createHarness({
739
733
  available: { codex: true },
740
734
  runners: {
741
735
  custom: {
742
- command: ["codex", "--task", "{task}", "{prompt}"],
736
+ command: ["codex"],
737
+ autoCommand: ["codex", "--task", "{task}", "{prompt}"],
743
738
  environment: {
744
739
  CUSTOM_TARGET: "{target}",
745
740
  AGENCY_TARGET: "cannot-override",
@@ -748,7 +743,7 @@ describe("work command", () => {
748
743
  },
749
744
  })
750
745
 
751
- await harness.run({ taskId: "example", runner: "custom" })
746
+ await harness.run({ taskId: "example", runner: "custom", auto: true })
752
747
 
753
748
  expect(harness.probes).toEqual(["codex"])
754
749
  expect(harness.launches[0]).toEqual({
@@ -759,7 +754,7 @@ describe("work command", () => {
759
754
  "example",
760
755
  "Start the task. Read /workbase/tasks/example/TASK.md.",
761
756
  ],
762
- cwd: singlePhaseWorkspace.writablePath,
757
+ cwd: taskDirectory,
763
758
  })
764
759
  expect(harness.launchEnvironments[0]).toMatchObject({
765
760
  AGENCY_RUNNER: "custom",
@@ -768,7 +763,7 @@ describe("work command", () => {
768
763
  AGENCY_TARGET: "execution-unit:task/example",
769
764
  AGENCY_TASK_ID: "example",
770
765
  AGENCY_PHASE_ID: "",
771
- AGENCY_CLAIM_REVISION: "1".repeat(64),
766
+ AGENCY_CLAIM_REVISION: "",
772
767
  CUSTOM_TARGET: "execution-unit:task/example",
773
768
  })
774
769
  })
@@ -778,7 +773,8 @@ describe("work command", () => {
778
773
  available: { agent: true },
779
774
  runners: {
780
775
  custom: {
781
- command: ["agent", "{prompt}"],
776
+ command: ["agent"],
777
+ autoCommand: ["agent", "{prompt}"],
782
778
  environment: {
783
779
  VISIBLE: "{task}",
784
780
  API_TOKEN: "do-not-print",
@@ -797,11 +793,10 @@ describe("work command", () => {
797
793
  const printed = JSON.parse(output.join("\n"))
798
794
 
799
795
  expect(harness.launches).toEqual([])
800
- expect(printed.cwd).toBe(singlePhaseWorkspace.writablePath)
801
- expect(printed.argv).toEqual([
802
- "agent",
803
- "Start the task. Read /workbase/tasks/example/TASK.md.",
804
- ])
796
+ expect(harness.statusUpdates).toEqual([])
797
+ expect(printed.cwd).toBe(taskDirectory)
798
+ expect(printed.argv).toEqual(["agent"])
799
+ expect(printed.environment.AGENCY_PROMPT).toBe("")
805
800
  expect(printed.environment.VISIBLE).toBe("example")
806
801
  expect(printed.environment.API_TOKEN).toBeUndefined()
807
802
  })
@@ -816,16 +811,21 @@ describe("work command", () => {
816
811
  expect(printed.environment.OPENCODE_CONFIG).toBe(
817
812
  "/workbase/.opencode/opencode.jsonc",
818
813
  )
814
+ const edit = {
815
+ "*": "deny",
816
+ "tasks/example/code/agency/**": "allow",
817
+ "code/agency/**": "allow",
818
+ }
819
819
  expect(JSON.parse(printed.environment.OPENCODE_CONFIG_CONTENT)).toEqual({
820
820
  permission: {
821
821
  external_directory: { "/workbase/**": "allow" },
822
- edit: { "../**": "deny" },
822
+ edit,
823
823
  },
824
824
  agent: {
825
- build: { permission: { edit: { "../**": "deny" } } },
826
- plan: { permission: { edit: { "../**": "deny" } } },
827
- general: { permission: { edit: { "../**": "deny" } } },
828
- explore: { permission: { edit: { "../**": "deny" } } },
825
+ build: { permission: { edit } },
826
+ plan: { permission: { edit } },
827
+ general: { permission: { edit } },
828
+ explore: { permission: { edit } },
829
829
  },
830
830
  })
831
831
  })
@@ -848,8 +848,8 @@ describe("work command", () => {
848
848
  expect(harness.probes).toEqual(["opencode", "claude"])
849
849
  expect(harness.launches[0]).toEqual({
850
850
  cli: "claude",
851
- args: ["claude", "Start the task. Read /workbase/tasks/example/TASK.md."],
852
- cwd: singlePhaseWorkspace.writablePath,
851
+ args: ["claude"],
852
+ cwd: taskDirectory,
853
853
  })
854
854
  expect(harness.launchEnvironments[0]?.OPENCODE_CONFIG).toBeUndefined()
855
855
  })
@@ -873,8 +873,8 @@ describe("work command", () => {
873
873
  expect(harness.probes).toEqual(["claude"])
874
874
  expect(harness.launches[0]).toEqual({
875
875
  cli: "claude",
876
- args: ["claude", "Start the task. Read /workbase/tasks/example/TASK.md."],
877
- cwd: singlePhaseWorkspace.writablePath,
876
+ args: ["claude"],
877
+ cwd: taskDirectory,
878
878
  })
879
879
  })
880
880
 
@@ -911,7 +911,7 @@ describe("work command", () => {
911
911
  verboseHarness.run({ taskId: "example", verbose: true }),
912
912
  )
913
913
  expect(verboseLogs).toEqual([
914
- "Launching command: opencode --prompt 'Start the task. Read /workbase/tasks/example/TASK.md.' (cwd: /workbase/tasks/example/code/agency)",
914
+ "Launching command: opencode (cwd: /workbase/tasks/example)",
915
915
  ])
916
916
  expect(verboseHarness.materializeOptions[0]?.verbose).toBe(true)
917
917
 
@@ -7,7 +7,6 @@ import { WorkbaseService } from "../services/WorkbaseService"
7
7
  import { EpicService } from "../services/EpicService"
8
8
  import { TaskService } from "../services/TaskService"
9
9
  import { PhaseService } from "../services/PhaseService"
10
- import { ClaimService } from "../services/ClaimService"
11
10
  import { ReadinessService } from "../services/ReadinessService"
12
11
  import { IntegrationService } from "../services/IntegrationService"
13
12
  import { createLoggers } from "../utils/effect"
@@ -39,6 +38,7 @@ interface WorkOptions extends BaseCommandOptions {
39
38
  readonly claude?: boolean
40
39
  readonly runner?: string
41
40
  readonly printCommand?: boolean
41
+ readonly auto?: boolean
42
42
  readonly force?: boolean
43
43
  }
44
44
 
@@ -105,7 +105,6 @@ export const work = (
105
105
  const epics = yield* EpicService
106
106
  const tasks = yield* TaskService
107
107
  const phases = yield* PhaseService
108
- const claims = yield* ClaimService
109
108
  const readiness = yield* ReadinessService
110
109
  const integrations = yield* IntegrationService
111
110
  const { log, verboseLog } = createLoggers(options)
@@ -207,13 +206,13 @@ export const work = (
207
206
  taskRecords,
208
207
  phaseRecords,
209
208
  )
210
- const readyTargetIds = options.force
209
+ const workTargetIds = options.force
211
210
  ? null
212
- : yield* readiness.getReadyWorkTargetIds(root)
211
+ : yield* readiness.getWorkTargetIds(root)
213
212
  const choices = options.force
214
213
  ? allChoices
215
214
  : allChoices.filter((choice) =>
216
- readyTargetIds!.has(targetNodeId(choice.target)),
215
+ workTargetIds!.has(targetNodeId(choice.target)),
217
216
  )
218
217
  if (choices.length === 0) {
219
218
  return yield* Effect.fail(
@@ -227,6 +226,7 @@ export const work = (
227
226
 
228
227
  let prompt: string
229
228
  let launchPath: string
229
+ let writablePath: string | undefined
230
230
  if (target.kind === "epic") {
231
231
  prompt = `Work on the epic. Read ${target.path}.`
232
232
  launchPath = dirname(target.path)
@@ -250,7 +250,8 @@ export const work = (
250
250
  prompt = workspace.phasePath
251
251
  ? `Start the task. Read ${workspace.taskPath} and ${workspace.phasePath}.`
252
252
  : `Start the task. Read ${workspace.taskPath}.`
253
- launchPath = workspace.writablePath
253
+ launchPath = dirname(workspace.taskPath)
254
+ writablePath = workspace.writablePath
254
255
  }
255
256
 
256
257
  const explicitlyRequested = Boolean(
@@ -267,22 +268,22 @@ export const work = (
267
268
  const sessionId =
268
269
  process.env.AGENCY_SESSION_ID ?? `${process.pid}-${Date.now()}`
269
270
  const resume = process.env.AGENCY_SESSION_ID !== undefined
270
- let claimRevision = ""
271
- let variables = {
272
- prompt,
271
+ const variables = {
272
+ prompt: options.auto ? prompt : "",
273
273
  workbase: root,
274
274
  target: targetNodeId(target),
275
275
  task: target.kind === "epic" ? "" : target.taskId,
276
276
  phase: target.kind === "phase" ? target.phaseId : "",
277
277
  claimant,
278
278
  sessionId,
279
- claimRevision,
279
+ claimRevision: "",
280
280
  }
281
281
  let resolved = resolveRunnerCommand(
282
282
  runner,
283
283
  config.runners,
284
284
  variables,
285
285
  resume,
286
+ options.auto,
286
287
  )
287
288
  let cli = resolved.argv[0]!
288
289
  let available = yield* fs.runCommand(["which", cli], {
@@ -294,35 +295,26 @@ export const work = (
294
295
  runner === "opencode"
295
296
  ) {
296
297
  runner = "claude"
297
- resolved = resolveRunnerCommand(runner, config.runners, variables, resume)
298
+ resolved = resolveRunnerCommand(
299
+ runner,
300
+ config.runners,
301
+ variables,
302
+ resume,
303
+ options.auto,
304
+ )
298
305
  cli = resolved.argv[0]!
299
306
  available = yield* fs.runCommand(["which", cli], { captureOutput: true })
300
307
  }
301
308
  if (available.exitCode !== 0) {
302
309
  return yield* Effect.fail(new Error(`${cli} CLI tool not found`))
303
310
  }
304
- if (
305
- target.kind === "phase" ||
306
- (target.kind === "task" && !target.multiPhase)
307
- ) {
308
- const phaseId = target.kind === "phase" ? target.phaseId : undefined
309
- const current = yield* claims.inspect(target.taskId, phaseId, root)
310
- const acquired = yield* claims.claim(
311
- {
312
- taskId: target.taskId,
313
- ...(phaseId ? { phaseId } : {}),
314
- claimant,
315
- runner,
316
- sessionId,
317
- revision: current.revision,
318
- },
319
- root,
320
- )
321
- claimRevision = acquired.revision
322
- }
323
-
324
- variables = { ...variables, claimRevision }
325
- resolved = resolveRunnerCommand(runner, config.runners, variables, resume)
311
+ resolved = resolveRunnerCommand(
312
+ runner,
313
+ config.runners,
314
+ variables,
315
+ resume,
316
+ options.auto,
317
+ )
326
318
  cli = resolved.argv[0]!
327
319
  const environment = {
328
320
  ...resolved.environment,
@@ -333,7 +325,17 @@ export const work = (
333
325
  const execution =
334
326
  target.kind === "phase" ||
335
327
  (target.kind === "task" && !target.multiPhase)
336
- const edit = { [execution ? "../**" : "*"]: "deny" as const }
328
+ const edit = execution
329
+ ? {
330
+ "*": "deny" as const,
331
+ ...Object.fromEntries(
332
+ [root, launchPath].map((base) => [
333
+ join(relative(base, writablePath!), "**").split(sep).join("/"),
334
+ "allow" as const,
335
+ ]),
336
+ ),
337
+ }
338
+ : { "*": "deny" as const }
337
339
  environment.OPENCODE_CONFIG_CONTENT = JSON.stringify({
338
340
  permission: {
339
341
  external_directory: { [join(root, "**")]: "allow" },
@@ -361,6 +363,11 @@ export const work = (
361
363
  )
362
364
  return
363
365
  }
366
+ if (target.kind === "phase") {
367
+ yield* phases.setStatus(target.taskId, target.phaseId, "working", root)
368
+ } else if (target.kind === "task" && !target.multiPhase) {
369
+ yield* tasks.setStatus(target.taskId, "working", root)
370
+ }
364
371
  for (const [key, value] of Object.entries(environment)) {
365
372
  process.env[key] = value
366
373
  }
@@ -438,7 +445,7 @@ export const workPrepare = (options: WorkOptions = {}) =>
438
445
  })
439
446
 
440
447
  export const help = `
441
- Usage: agency work [<directory-or-task-id> | --epic <epic-id>] [--runner <name>]
448
+ Usage: agency work [<directory-or-task-id> | --epic <epic-id>] [--runner <name>] [--auto]
442
449
  agency work prepare [target] [--dry-run] [--json]
443
450
 
444
451
  Launch an agent for an epic, task, or phase. With no directory, select one
@@ -458,6 +465,7 @@ Options:
458
465
  --workbase <target> Select a workbase by ID, name, or path
459
466
  --cwd <path> Resolve context from a specific directory
460
467
  --runner <name> Select a configured runner or built-in preset
468
+ --auto Send the generated context prompt to the runner
461
469
  --print-command Print cwd, argv, and non-secret environment without launch
462
470
  --opencode Require the OpenCode preset
463
471
  --claude Require the Claude Code preset
@@ -0,0 +1 @@
1
+ declare module "@opentui/solid/preload"
@@ -170,6 +170,17 @@ export class DoctorService extends Effect.Service<DoctorService>()(
170
170
  `Configured runner '${name}'`,
171
171
  ] as const,
172
172
  ),
173
+ ...Object.entries(config.runners ?? {}).flatMap(([name, runner]) =>
174
+ runner.autoCommand
175
+ ? [
176
+ [
177
+ `integration.runner.${name}.auto`,
178
+ runner.autoCommand,
179
+ `Configured runner '${name}' auto`,
180
+ ] as const,
181
+ ]
182
+ : [],
183
+ ),
173
184
  ...Object.entries(config.runners ?? {}).flatMap(([name, runner]) =>
174
185
  runner.resumeCommand
175
186
  ? [
@@ -181,6 +192,17 @@ export class DoctorService extends Effect.Service<DoctorService>()(
181
192
  ]
182
193
  : [],
183
194
  ),
195
+ ...Object.entries(config.runners ?? {}).flatMap(([name, runner]) =>
196
+ runner.autoResumeCommand
197
+ ? [
198
+ [
199
+ `integration.runner.${name}.auto-resume`,
200
+ runner.autoResumeCommand,
201
+ `Configured runner '${name}' auto resume`,
202
+ ] as const,
203
+ ]
204
+ : [],
205
+ ),
184
206
  ...(config.delivery
185
207
  ? [
186
208
  [
@@ -34,6 +34,9 @@ describe("EpicService", () => {
34
34
 
35
35
  expect(created.content).toContain("ticketUrl:")
36
36
  expect(created.content).toContain("tasks: []")
37
+ expect(created.content).toContain(
38
+ "# Workspace Orchestration\n\n## Outcome\n\nDescribe the epic outcome.\n\n## Plan\n\nDescribe the current approach.\n\n## Important Decisions\n\nRecord consequential decisions and their rationale.",
39
+ )
37
40
 
38
41
  const records = await runTestEffect(
39
42
  EpicService.pipe(Effect.flatMap((service) => service.list(root))),