@markjaquith/agency 2.65.1 → 2.66.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
@@ -347,8 +347,10 @@ external effects the failed invocation may have completed.
347
347
 
348
348
  ### Agent Runners
349
349
 
350
- OpenCode and Claude Code are built-in runner presets. Select either preset or a
351
- configured runner with `agency work --runner <name>`. A launch is fresh unless
350
+ OpenCode (`opencode2` and `opencode`) and Claude Code are built-in runner presets.
351
+ Without an explicit runner, Agency uses the first available executable in this
352
+ order: `opencode2`, `opencode`, then `claude`. Select a preset or configured
353
+ runner with `agency work --runner <name>`. A launch is fresh unless
352
354
  `AGENCY_SESSION_ID` is already set; resumed launches use the runner's
353
355
  `resumeCommand` when configured. The built-in presets use `--continue` only for
354
356
  resumed launches. By default Agency opens the runner without a prompt. `--auto`
@@ -399,8 +401,9 @@ to the receiving OpenCode session, injects an explicit active-worker system
399
401
  instruction, and restores Agency identity for that session's shell environment.
400
402
  This session bridge is necessary because an OpenCode client can attach to a
401
403
  long-lived server process that did not inherit the client's launch environment.
402
- The `opencode` runner remains rooted in its task or epic working directory so
403
- the workbase `AGENTS.md` and managed OpenCode config are discovered normally.
404
+ The `opencode2` and `opencode` runners remain rooted in their task or epic
405
+ working directory so the workbase `AGENTS.md` and managed OpenCode config are
406
+ discovered normally.
404
407
  Agency's managed OpenCode plugin grants the active workbase external-directory
405
408
  access and adds existing checkout-local `.claude/skills`, `.agents/skills`, and
406
409
  `.opencode/{skill,skills}` directories to `skills.paths`.
@@ -650,8 +653,8 @@ detailed dependency, validation, or status blockers for orchestrators.
650
653
 
651
654
  `agency work` consults this shared readiness model before materializing. Blocked,
652
655
  done, and dropped targets are rejected unless `--force` is supplied explicitly.
653
- `agency pr` leaves command semantics to `gh`; run `agency context . --json` first
654
- when readiness or validation state needs inspection.
656
+ Task-aware `agency pr create` applies Agency readiness and validation checks;
657
+ untargeted `agency pr` invocations leave command semantics to `gh`.
655
658
 
656
659
  ### Reconciliation
657
660
 
@@ -663,6 +666,10 @@ structured `changes`, `warnings`, `unresolved`, and per-execution evidence. The
663
666
  default mode applies safe reconciliation transitions; `--dry-run` is explicitly
664
667
  observational.
665
668
 
669
+ Pass `<task-id>` to scope reconciliation to one task and its repositories. A
670
+ multi-phase task scope includes all of its phases; add `[phase-id]` to select one
671
+ phase. Scoped sync does not query, materialize, or reconcile unrelated work.
672
+
666
673
  `agency sync` performs only these safe transitions:
667
674
 
668
675
  - materialize declared but missing repositories from their canonical remotes;
@@ -1050,7 +1057,7 @@ agency work [<directory> | --epic <epic-id>] [--runner <name>] [--auto] [--print
1050
1057
  agency work prepare [target] [--evidence <json-or-path>] [--dry-run] [--json]
1051
1058
  agency worktree <list|inspect|prepare|remove|rebuild|repair>
1052
1059
  agency push [--json]
1053
- agency pr create <task-id> [phase-id] [--draft] [--force] [--json]
1060
+ agency pr create <task-id> [phase-id] [--draft] [--title <title>] [--head <branch>] [--base <branch>] [--label <label>] [--force] [--json]
1054
1061
  agency pr [args...]
1055
1062
  ```
1056
1063
 
@@ -1059,10 +1066,10 @@ configured external chooser. Pass a directory, including `.` for the current
1059
1066
  directory, to infer its epic, task, or phase. Outside a workbase, Agency first
1060
1067
  presents the registered workbases, then the selected workbase's hierarchy.
1061
1068
 
1062
- OpenCode is the default runner, with automatic Claude fallback when neither is
1063
- explicitly selected. `--opencode` and `--claude` remain aliases for requiring
1064
- their built-in presets. Launches are interactive and promptless by default; use
1065
- `--auto` to send Agency's generated context prompt.
1069
+ Agency automatically uses the first available runner in this order: `opencode2`,
1070
+ `opencode`, then `claude`. `--opencode` and `--claude` remain aliases for
1071
+ requiring their corresponding built-in presets. Launches are interactive and
1072
+ promptless by default; use `--auto` to send Agency's generated context prompt.
1066
1073
 
1067
1074
  `agency work prepare` resolves an execution unit and creates or reuses its
1068
1075
  writable and reference worktrees, or its single pinned review checkout, without
@@ -1137,9 +1144,16 @@ post-commit working copy, in which case it publishes `@-`; described empty
1137
1144
  changes remain intentional publication tips. Missing descriptions or authors
1138
1145
  stop with exact change IDs and remediation commands. Agency creates or safely
1139
1146
  advances only the declared bookmark and never invents a `push-*` bookmark.
1147
+ If the fetched remote base advanced outside the local stack, Agency prints the
1148
+ exact `jj rebase` command needed to move the stack onto `<base>@<remote>`. Push
1149
+ reports deterministic fetch, inspection, validation, and publication progress
1150
+ on stderr, including while `--json` reserves stdout for one machine result.
1140
1151
 
1141
1152
  Task-aware `agency pr create <task-id> [phase-id]` uses Agency's delivery flow,
1142
- including readiness checks and durable PR recording. Other `agency pr`
1153
+ including readiness checks and durable PR recording. It accepts draft, title,
1154
+ declared head/base confirmation, and repeatable label options; a contradicting
1155
+ head or base is rejected rather than recording inconsistent delivery metadata.
1156
+ Other `agency pr`
1143
1157
  invocations forward every argument to `gh pr`. From an execution task or phase
1144
1158
  directory, including descendants, passthrough runs in that execution unit's
1145
1159
  authoritative writable checkout. Otherwise it runs in the caller's current
package/cli-main.ts CHANGED
@@ -322,6 +322,10 @@ const commands: Record<string, Command> = {
322
322
  phaseId: args[2],
323
323
  draft: options.draft,
324
324
  force: options.force,
325
+ title: options.title,
326
+ head: options.head,
327
+ base: options.base,
328
+ labels: options.label,
325
329
  json: options.json,
326
330
  silent: options.silent,
327
331
  verbose: options.verbose,
@@ -750,13 +754,15 @@ const commands: Record<string, Command> = {
750
754
  },
751
755
  },
752
756
  sync: {
753
- run: async (_args: string[], options: Record<string, any>) => {
757
+ run: async (args: string[], options: Record<string, any>) => {
754
758
  if (options.help) {
755
759
  console.log(syncHelp)
756
760
  return
757
761
  }
758
762
  await runCommand(
759
763
  sync({
764
+ taskId: args[0],
765
+ phaseId: args[1],
760
766
  dryRun: options["dry-run"],
761
767
  json: options.json,
762
768
  silent: options.silent,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "2.65.1",
3
+ "version": "2.66.0",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -334,7 +334,7 @@ describe("strict CLI parsing", () => {
334
334
  [["context", "one", "two"], "agency context"],
335
335
  [["graph", "extra"], "agency graph"],
336
336
  [["next", "extra"], "agency next"],
337
- [["sync", "extra"], "agency sync"],
337
+ [["sync", "one", "two", "three"], "agency sync"],
338
338
  [
339
339
  [
340
340
  "claim",
@@ -430,6 +430,31 @@ describe("strict CLI parsing", () => {
430
430
  args: ["create", "ship"],
431
431
  })
432
432
  expect(selected.passthrough).toBeUndefined()
433
+ expect(
434
+ parseCli([
435
+ "pr",
436
+ "create",
437
+ "ship",
438
+ "--title",
439
+ "Ship it",
440
+ "--head",
441
+ "task/ship",
442
+ "--base",
443
+ "main",
444
+ "--label",
445
+ "ai-assisted",
446
+ "--label",
447
+ "product-area:platform",
448
+ ]),
449
+ ).toMatchObject({
450
+ args: ["create", "ship"],
451
+ values: {
452
+ title: "Ship it",
453
+ head: "task/ship",
454
+ base: "main",
455
+ label: ["ai-assisted", "product-area:platform"],
456
+ },
457
+ })
433
458
  })
434
459
 
435
460
  test("parses explicit dry-run reconciliation", () => {
@@ -441,6 +466,11 @@ describe("strict CLI parsing", () => {
441
466
  commandName: "sync",
442
467
  values: { "dry-run": true, json: true },
443
468
  })
469
+ expect(parseCli(["sync", "ship", "release", "--json"])).toMatchObject({
470
+ commandName: "sync",
471
+ args: ["ship", "release"],
472
+ values: { json: true },
473
+ })
444
474
  expect(() => parseCli(["sync", "--apply"])).toThrow("Unknown option")
445
475
  })
446
476
 
package/src/cli-parser.ts CHANGED
@@ -732,16 +732,17 @@ const commands = {
732
732
  },
733
733
  },
734
734
  sync: {
735
- usage: "agency sync [--dry-run] [--json]",
735
+ usage: "agency sync [<task-id> [phase-id]] [--dry-run] [--json]",
736
736
  options: {
737
737
  ...outputOptions,
738
+ ...entitySelectorOptions,
738
739
  "dry-run": { type: "boolean" },
739
740
  },
740
741
  command: {
741
- usage: "agency sync [--dry-run] [--json]",
742
+ usage: "agency sync [<task-id> [phase-id]] [--dry-run] [--json]",
742
743
  minArgs: 0,
743
- maxArgs: 0,
744
- options: ["dry-run", "json"],
744
+ maxArgs: 2,
745
+ options: ["dry-run", "json", "task", "phase"],
745
746
  },
746
747
  },
747
748
  archive: {
@@ -964,14 +965,28 @@ const commands = {
964
965
  force: { type: "boolean" },
965
966
  task: { type: "string" },
966
967
  phase: { type: "string" },
968
+ title: { type: "string" },
969
+ head: { type: "string" },
970
+ base: { type: "string" },
971
+ label: { type: "string", multiple: true },
967
972
  },
968
973
  subcommands: {
969
974
  create: {
970
- usage:
971
- "agency pr create <task-id> [phase-id] [--draft] [--force] [--json]",
975
+ usage: "agency pr create <task-id> [phase-id] [options]",
972
976
  minArgs: 1,
973
977
  maxArgs: 2,
974
- options: ["draft", "force", "json", "task", "phase"],
978
+ options: [
979
+ "draft",
980
+ "force",
981
+ "json",
982
+ "task",
983
+ "phase",
984
+ "title",
985
+ "head",
986
+ "base",
987
+ "label",
988
+ ],
989
+ repeatable: ["label"],
975
990
  },
976
991
  },
977
992
  },
@@ -219,6 +219,73 @@ Phase prose.
219
219
  )
220
220
  })
221
221
 
222
+ test("reports the current jj change without inventing a missing bookmark", async () => {
223
+ if (!Bun.which("jj")) return
224
+ await cleanupTempDir(root)
225
+ root = await createTempDir()
226
+ const remote = join(root, "remote.git")
227
+ const seed = join(root, "seed")
228
+ const repository = join(root, "repos/agency")
229
+ const checkout = join(root, "tasks/example/code/agency")
230
+ await mkdir(join(root, "repos"), { recursive: true })
231
+ await write(root, "agency.json", '{"version":2,"vcs":"jj"}\n')
232
+ await run(root, ["git", "init", "--bare", "--initial-branch=main", remote])
233
+ await run(root, ["git", "init", "--initial-branch=main", seed])
234
+ await run(seed, ["git", "config", "user.email", "test@example.com"])
235
+ await run(seed, ["git", "config", "user.name", "Test"])
236
+ await Bun.write(join(seed, "README.md"), "example\n")
237
+ await run(seed, ["git", "add", "README.md"])
238
+ await run(seed, ["git", "commit", "-m", "initial"])
239
+ await run(seed, ["git", "remote", "add", "origin", remote])
240
+ await run(seed, ["git", "push", "origin", "main"])
241
+ await run(root, ["jj", "git", "clone", "--no-colocate", remote, repository])
242
+ await write(
243
+ root,
244
+ "tasks/example/TASK.md",
245
+ `---
246
+ ticketUrl: null
247
+ repo: agency
248
+ branch: task/example
249
+ base: main
250
+ pr: null
251
+ status: working
252
+ ---
253
+
254
+ # Example
255
+ `,
256
+ )
257
+ await mkdir(dirname(checkout), { recursive: true })
258
+ await run(repository, [
259
+ "jj",
260
+ "workspace",
261
+ "add",
262
+ "--name",
263
+ "task-example",
264
+ "-r",
265
+ "main",
266
+ checkout,
267
+ ])
268
+ await Bun.write(join(checkout, "feature.txt"), "current change\n")
269
+ const current = Bun.spawn(
270
+ ["jj", "log", "--no-graph", "-r", "@", "-T", "commit_id"],
271
+ { cwd: checkout, stdout: "pipe", stderr: "pipe" },
272
+ )
273
+ const currentCommit = (await new Response(current.stdout).text()).trim()
274
+ expect(await current.exited).toBe(0)
275
+
276
+ const result = await readContext(root, "tasks/example")
277
+ expect(result.workspace.writable).toMatchObject({
278
+ branchCommit: null,
279
+ checkoutCommit: currentCommit,
280
+ checkoutBranch: null,
281
+ detached: true,
282
+ dirty: false,
283
+ })
284
+ expect(result.workspace.warnings).toContain(
285
+ `Unable to resolve branch 'task/example' in ${repository}`,
286
+ )
287
+ })
288
+
222
289
  test("makes compact projection explicit without omitting essential identity", async () => {
223
290
  const result = await readContext(
224
291
  root,
@@ -158,6 +158,10 @@ status: open
158
158
  phaseId: "implementation",
159
159
  draft: true,
160
160
  force: true,
161
+ title: "Ship it",
162
+ head: "task/example",
163
+ base: "main",
164
+ labels: ["ai-assisted"],
161
165
  cwd: "/workbase",
162
166
  json: true,
163
167
  }).pipe(
@@ -177,7 +181,15 @@ status: open
177
181
  "implementation",
178
182
  true,
179
183
  "/workbase",
180
- expect.objectContaining({ force: true, draft: true, json: true }),
184
+ expect.objectContaining({
185
+ force: true,
186
+ draft: true,
187
+ title: "Ship it",
188
+ head: "task/example",
189
+ base: "main",
190
+ labels: ["ai-assisted"],
191
+ json: true,
192
+ }),
181
193
  ])
182
194
  })
183
195
 
@@ -14,6 +14,10 @@ interface PrCreateOptions extends BaseCommandOptions {
14
14
  readonly phaseId?: string
15
15
  readonly draft?: boolean
16
16
  readonly force?: boolean
17
+ readonly title?: string
18
+ readonly head?: string
19
+ readonly base?: string
20
+ readonly labels?: readonly string[]
17
21
  }
18
22
 
19
23
  const branchTargetCommands = new Set([
@@ -126,11 +130,21 @@ export const pr = (args: readonly string[], cwd: string = process.cwd()) =>
126
130
  })
127
131
 
128
132
  export const help = `
129
- Usage: agency pr create <task-id> [phase-id] [--draft] [--force] [--json]
133
+ Usage: agency pr create <task-id> [phase-id] [options]
130
134
  agency pr [args...]
131
135
 
132
- Create records a pull request for an Agency execution unit. Other invocations
133
- run gh pr unchanged, focusing the writable repository checkout when invoked from
136
+ Create records a pull request for an Agency execution unit and accepts the
137
+ options listed below. Invocations without an Agency task target run gh pr
138
+ unchanged, focusing the writable repository checkout when invoked from
134
139
  an Agency execution task or phase. In jj workbases, Agency supplies the declared
135
140
  branch and repository to gh subcommands that would otherwise infer Git context.
141
+
142
+ Options:
143
+ --draft Create the pull request as a draft
144
+ --title <title> Override the generated pull request title
145
+ --head <branch> Confirm the declared head branch
146
+ --base <branch> Confirm the declared base branch
147
+ --label <label> Add a label (repeatable)
148
+ --force Override readiness checks
149
+ --json Output one versioned machine result
136
150
  `
@@ -0,0 +1,55 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { Effect } from "effect"
3
+ import { PushService } from "../services/PushService"
4
+ import { captureLogs } from "../test-utils"
5
+ import type { Progress } from "../utils/progress"
6
+ import { push } from "./push"
7
+
8
+ describe("push command", () => {
9
+ test("reports deterministic progress while preserving one JSON result", async () => {
10
+ const updates: string[] = []
11
+ const progress: Progress = {
12
+ start: (message) => updates.push(`start:${message}`),
13
+ succeed: (message) => updates.push(`succeed:${message}`),
14
+ fail: (message) => updates.push(`fail:${message}`),
15
+ }
16
+ const result = {
17
+ vcs: "jj" as const,
18
+ taskId: "example",
19
+ branch: "task/example",
20
+ base: "main",
21
+ remote: "origin",
22
+ tip: "abc123",
23
+ changeId: "change",
24
+ }
25
+ const logs = await captureLogs(() =>
26
+ Effect.runPromise(
27
+ push({ cwd: "/workbase", json: true }, progress).pipe(
28
+ Effect.provideService(PushService, {
29
+ publish: (_cwd: string, options: any) => {
30
+ for (const stage of [
31
+ "context",
32
+ "fetch",
33
+ "inspect",
34
+ "validate",
35
+ "publish",
36
+ ] as const)
37
+ options.onProgress(stage)
38
+ return Effect.succeed(result)
39
+ },
40
+ } as never),
41
+ ) as Effect.Effect<void, unknown, never>,
42
+ ),
43
+ )
44
+
45
+ expect(logs).toEqual([JSON.stringify(result, null, 2)])
46
+ expect(updates).toEqual([
47
+ "start:Inspecting Agency execution context",
48
+ "start:Fetching remote state",
49
+ "start:Selecting the publication tip",
50
+ "start:Validating outgoing changes",
51
+ "start:Publishing the declared branch",
52
+ "succeed:Published task/example to origin",
53
+ ])
54
+ })
55
+ })
@@ -2,12 +2,39 @@ import { Effect } from "effect"
2
2
  import { PushService } from "../services/PushService"
3
3
  import type { BaseCommandOptions } from "../utils/command"
4
4
  import { createLoggers } from "../utils/effect"
5
+ import { createProgress, type Progress } from "../utils/progress"
5
6
 
6
- export const push = (options: BaseCommandOptions = {}) =>
7
+ const stageMessage = {
8
+ context: "Inspecting Agency execution context",
9
+ fetch: "Fetching remote state",
10
+ inspect: "Selecting the publication tip",
11
+ validate: "Validating outgoing changes",
12
+ publish: "Publishing the declared branch",
13
+ } as const
14
+
15
+ export const push = (
16
+ options: BaseCommandOptions = {},
17
+ progress: Progress = createProgress({ silent: options.silent }),
18
+ ) =>
7
19
  Effect.gen(function* () {
8
20
  const publications = yield* PushService
9
21
  const { log } = createLoggers(options)
10
- const result = yield* publications.publish(options.cwd ?? process.cwd())
22
+ const showProgress = !options.silent
23
+ const result = yield* publications
24
+ .publish(options.cwd ?? process.cwd(), {
25
+ onProgress: showProgress
26
+ ? (stage) => progress.start(stageMessage[stage])
27
+ : undefined,
28
+ })
29
+ .pipe(
30
+ Effect.tapError(() =>
31
+ Effect.sync(() => {
32
+ if (showProgress) progress.fail("Publication failed")
33
+ }),
34
+ ),
35
+ )
36
+ if (showProgress)
37
+ progress.succeed(`Published ${result.branch} to ${result.remote}`)
11
38
  log(
12
39
  options.json
13
40
  ? JSON.stringify(result, null, 2)
@@ -82,6 +82,41 @@ describe("sync command", () => {
82
82
  ).toBe(false)
83
83
  })
84
84
 
85
+ test("scopes reconciliation to one task", async () => {
86
+ await runTestEffect(
87
+ TaskService.pipe(
88
+ Effect.flatMap((service) =>
89
+ service.create(
90
+ {
91
+ id: "second",
92
+ ticketUrl: null,
93
+ repo: "agency",
94
+ branch: "task/second",
95
+ base: "main",
96
+ },
97
+ root,
98
+ ),
99
+ ),
100
+ ),
101
+ )
102
+
103
+ const logs = await captureLogs(() =>
104
+ runTestEffect(sync({ cwd: root, taskId: "example", json: true })),
105
+ )
106
+ const result = JSON.parse(logs[0]!)
107
+ expect(result.executions.map((execution: any) => execution.target)).toEqual(
108
+ ["task:example"],
109
+ )
110
+ expect(
111
+ await Bun.file(
112
+ join(root, "tasks/example/code/agency/README.md"),
113
+ ).exists(),
114
+ ).toBe(true)
115
+ expect(
116
+ await Bun.file(join(root, "tasks/second/code/agency/README.md")).exists(),
117
+ ).toBe(false)
118
+ })
119
+
85
120
  test("preserves structured output behind --json", async () => {
86
121
  const logs = await captureLogs(() =>
87
122
  runTestEffect(sync({ cwd: root, dryRun: true, json: true })),
@@ -6,6 +6,8 @@ import { createProgress, type Progress } from "../utils/progress"
6
6
 
7
7
  interface SyncCommandOptions extends BaseCommandOptions {
8
8
  readonly dryRun?: boolean
9
+ readonly taskId?: string
10
+ readonly phaseId?: string
9
11
  }
10
12
 
11
13
  interface Notice {
@@ -44,6 +46,8 @@ export const sync = (
44
46
  .reconcile({
45
47
  cwd: options.cwd,
46
48
  apply: options.dryRun !== true,
49
+ taskId: options.taskId,
50
+ phaseId: options.phaseId,
47
51
  onProgress: showProgress
48
52
  ? ({ stage, current, total, target }) => {
49
53
  if (stage === "repositories") {
@@ -118,11 +122,13 @@ export const sync = (
118
122
  })
119
123
 
120
124
  export const help = `
121
- Usage: agency sync [--dry-run] [--json]
125
+ Usage: agency sync [<task-id> [phase-id]] [--dry-run] [--json]
122
126
 
123
127
  Compare portable repository declarations and execution state with local Git
124
128
  repositories, worktrees, branches, references, claims, and pull requests.
125
129
  Safe reconciliation transitions are applied by default.
130
+ When a task or phase is provided, only that target and its repositories are
131
+ queried or reconciled. Task scope includes all phases of a multi-phase task.
126
132
 
127
133
  Options:
128
134
  --dry-run Report planned safe transitions without changing state
@@ -1050,18 +1050,45 @@ describe("work command", () => {
1050
1050
  ).toBeUndefined()
1051
1051
  })
1052
1052
 
1053
- test("automatically falls back to Claude", async () => {
1054
- const harness = createHarness({ available: { opencode: false } })
1053
+ test("automatically prefers opencode2", async () => {
1054
+ const harness = createHarness()
1055
+
1056
+ await harness.run({ taskId: "example" })
1057
+
1058
+ expect(harness.probes).toEqual(["opencode2"])
1059
+ expect(harness.launches[0]).toEqual({
1060
+ cli: "opencode2",
1061
+ args: ["opencode2"],
1062
+ cwd: taskDirectory,
1063
+ })
1064
+ })
1065
+
1066
+ test("automatically falls back from opencode2 to opencode", async () => {
1067
+ const harness = createHarness({ available: { opencode2: false } })
1055
1068
 
1056
1069
  await harness.run({ taskId: "example" })
1057
1070
 
1058
- expect(harness.probes).toEqual(["opencode", "claude"])
1071
+ expect(harness.probes).toEqual(["opencode2", "opencode"])
1072
+ expect(harness.launches[0]).toEqual({
1073
+ cli: "opencode",
1074
+ args: ["opencode"],
1075
+ cwd: taskDirectory,
1076
+ })
1077
+ })
1078
+
1079
+ test("automatically falls back from OpenCode to Claude", async () => {
1080
+ const harness = createHarness({
1081
+ available: { opencode2: false, opencode: false },
1082
+ })
1083
+
1084
+ await harness.run({ taskId: "example" })
1085
+
1086
+ expect(harness.probes).toEqual(["opencode2", "opencode", "claude"])
1059
1087
  expect(harness.launches[0]).toEqual({
1060
1088
  cli: "claude",
1061
1089
  args: ["claude"],
1062
1090
  cwd: taskDirectory,
1063
1091
  })
1064
- expect(harness.launchEnvironments[0]?.OPENCODE_CONFIG).toBeUndefined()
1065
1092
  })
1066
1093
 
1067
1094
  test("does not fall back when OpenCode is explicitly required", async () => {
@@ -1090,13 +1117,13 @@ describe("work command", () => {
1090
1117
 
1091
1118
  test("fails when neither agent tool is available", async () => {
1092
1119
  const harness = createHarness({
1093
- available: { opencode: false, claude: false },
1120
+ available: { opencode2: false, opencode: false, claude: false },
1094
1121
  })
1095
1122
 
1096
1123
  await expect(harness.run({ taskId: "example" })).rejects.toThrow(
1097
1124
  "claude CLI tool not found",
1098
1125
  )
1099
- expect(harness.probes).toEqual(["opencode", "claude"])
1126
+ expect(harness.probes).toEqual(["opencode2", "opencode", "claude"])
1100
1127
  expect(harness.launches).toEqual([])
1101
1128
  })
1102
1129
 
@@ -1150,7 +1177,7 @@ describe("work command", () => {
1150
1177
  verboseHarness.run({ taskId: "example", verbose: true }),
1151
1178
  )
1152
1179
  expect(verboseLogs).toEqual([
1153
- "Launching command: opencode (cwd: /workbase/tasks/example)",
1180
+ "Launching command: opencode2 (cwd: /workbase/tasks/example)",
1154
1181
  ])
1155
1182
  expect(verboseHarness.materializeOptions[0]?.verbose).toBe(true)
1156
1183
 
@@ -280,10 +280,16 @@ export const work = (
280
280
  options.claude ||
281
281
  process.env.AGENCY_RUNNER,
282
282
  )
283
- let runner =
283
+ const defaultRunners = ["opencode2", "opencode", "claude"] as const
284
+ let defaultRunnerIndex = 0
285
+ let runner: string =
284
286
  options.runner ??
285
287
  process.env.AGENCY_RUNNER ??
286
- (options.claude ? "claude" : "opencode")
288
+ (options.claude
289
+ ? "claude"
290
+ : options.opencode
291
+ ? "opencode"
292
+ : defaultRunners[defaultRunnerIndex]!)
287
293
  const claimant = process.env.AGENCY_CLAIMANT ?? process.env.USER ?? "agency"
288
294
  const sessionId =
289
295
  process.env.AGENCY_SESSION_ID ?? `${process.pid}-${Date.now()}`
@@ -311,12 +317,12 @@ export const work = (
311
317
  let available = yield* fs.runCommand(["which", cli], {
312
318
  captureOutput: true,
313
319
  })
314
- if (
320
+ while (
315
321
  available.exitCode !== 0 &&
316
322
  !explicitlyRequested &&
317
- runner === "opencode"
323
+ defaultRunnerIndex < defaultRunners.length - 1
318
324
  ) {
319
- runner = "claude"
325
+ runner = defaultRunners[++defaultRunnerIndex]!
320
326
  resolved = resolveRunnerCommand(
321
327
  runner,
322
328
  config.runners,
@@ -580,7 +586,8 @@ Launch an agent for an epic, task, or phase. With no directory, select one
580
586
  interactively. A positional argument resolves as a directory first, then as a task
581
587
  ID. Use '.' for the current directory. Outside a workbase, select a registered
582
588
  workbase first. Managed OpenCode launches receive whole-workbase access through
583
- Agency's project plugin; Agency context remains authoritative for writes.
589
+ Agency's project plugin; Agency context remains authoritative for writes. Automatic
590
+ runner discovery checks opencode2, opencode, then claude.
584
591
 
585
592
  The prepare subcommand resolves and materializes an execution workspace without
586
593
  launching an agent or changing lifecycle status. --dry-run reports planned Git
@@ -898,6 +898,7 @@ export class ContextService extends Effect.Service<ContextService>()(
898
898
  repositoryPath: string,
899
899
  checkoutPath: string,
900
900
  expectedBranch: string | null = null,
901
+ branchCommit: string | null = null,
901
902
  ) =>
902
903
  Effect.gen(function* (): Generator<any, CheckoutInspection, any> {
903
904
  const materialized = yield* fs.isDirectory(checkoutPath)
@@ -914,9 +915,10 @@ export class ContextService extends Effect.Service<ContextService>()(
914
915
  const canonicalCheckoutPath = materialized
915
916
  ? yield* fs.realPath(checkoutPath)
916
917
  : resolve(checkoutPath)
917
- const registered = workspaces.some(
918
+ const workspace = workspaces.find(
918
919
  (workspace) => workspace.path === canonicalCheckoutPath,
919
920
  )
921
+ const registered = workspace !== undefined
920
922
  if (!materialized) {
921
923
  return {
922
924
  materialized: false,
@@ -930,10 +932,14 @@ export class ContextService extends Effect.Service<ContextService>()(
930
932
  return {
931
933
  materialized: true,
932
934
  registered,
933
- checkoutCommit: yield* backend.workspaceHead(checkoutPath),
934
- checkoutBranch: expectedBranch,
935
- detached: expectedBranch === null,
936
- dirty: yield* backend.workspaceDirty(checkoutPath),
935
+ checkoutCommit: workspace?.commit ?? null,
936
+ checkoutBranch:
937
+ expectedBranch && branchCommit === workspace?.commit
938
+ ? expectedBranch
939
+ : null,
940
+ detached:
941
+ !expectedBranch || branchCommit !== workspace?.commit,
942
+ dirty: workspace ? false : null,
937
943
  }
938
944
  }
939
945
  const listed = yield* runGit(fs, repositoryPath, [
@@ -1014,10 +1020,8 @@ export class ContextService extends Effect.Service<ContextService>()(
1014
1020
  repositoryPath,
1015
1021
  checkoutPath,
1016
1022
  executionData.branch,
1023
+ branchCommit,
1017
1024
  )
1018
- if (backend.kind === "jj" && branchCommit === null) {
1019
- branchCommit = checkout.checkoutCommit
1020
- }
1021
1025
  if (branchCommit === null) {
1022
1026
  inspectionWarnings.push(
1023
1027
  `Unable to resolve branch '${executionData.branch}' in ${repositoryPath}`,
@@ -68,11 +68,12 @@ describe("PullRequestService", () => {
68
68
  phaseId?: string,
69
69
  draft = false,
70
70
  force = false,
71
+ options: Record<string, unknown> = {},
71
72
  ) =>
72
73
  runTestEffect(
73
74
  PullRequestService.pipe(
74
75
  Effect.flatMap((service) =>
75
- service.create(taskId, phaseId, draft, root, { force }),
76
+ service.create(taskId, phaseId, draft, root, { force, ...options }),
76
77
  ),
77
78
  ),
78
79
  )
@@ -303,6 +304,51 @@ process.exit(${exitCode})
303
304
  ])
304
305
  })
305
306
 
307
+ test("passes task-aware GitHub title and labels with declared refs", async () => {
308
+ await createTask()
309
+ await writeFakeGh({
310
+ stdout: "https://github.com/example/agency/pull/45\n",
311
+ })
312
+
313
+ await createPullRequest("example", undefined, true, false, {
314
+ title: "Ship the workflow",
315
+ head: "task/example",
316
+ base: "main",
317
+ labels: ["ai-assisted", "platform"],
318
+ })
319
+
320
+ expect((await readGhCall()).args).toEqual([
321
+ "pr",
322
+ "create",
323
+ "--fill",
324
+ "--title",
325
+ "Ship the workflow",
326
+ "--base",
327
+ "main",
328
+ "--head",
329
+ "task/example",
330
+ "--draft",
331
+ "--label",
332
+ "ai-assisted",
333
+ "--label",
334
+ "platform",
335
+ ])
336
+ })
337
+
338
+ test("rejects task-aware head and base values that contradict declarations", async () => {
339
+ await createTask()
340
+ await expect(
341
+ createPullRequest("example", undefined, false, false, {
342
+ head: "other",
343
+ }),
344
+ ).rejects.toThrow("does not match declared branch")
345
+ await expect(
346
+ createPullRequest("example", undefined, false, false, {
347
+ base: "release",
348
+ }),
349
+ ).rejects.toThrow("does not match declared base")
350
+ })
351
+
306
352
  test("guards terminal targets before materializing unless forced", async () => {
307
353
  await createTask()
308
354
  await runTestEffect(
@@ -27,6 +27,10 @@ class PullRequestError extends Data.TaggedError("PullRequestError")<{
27
27
 
28
28
  interface PullRequestOptions extends BaseCommandOptions {
29
29
  readonly force?: boolean
30
+ readonly title?: string
31
+ readonly head?: string
32
+ readonly base?: string
33
+ readonly labels?: readonly string[]
30
34
  }
31
35
 
32
36
  export class PullRequestService extends Effect.Service<PullRequestService>()(
@@ -158,6 +162,25 @@ export class PullRequestService extends Effect.Service<PullRequestService>()(
158
162
  })
159
163
  }
160
164
  const remote = config.delivery?.remote ?? "origin"
165
+ if (options.head && options.head !== execution.branch) {
166
+ return yield* new PullRequestError({
167
+ message: `Requested head '${options.head}' does not match declared branch '${execution.branch}'`,
168
+ })
169
+ }
170
+ if (options.base && options.base !== execution.base) {
171
+ return yield* new PullRequestError({
172
+ message: `Requested base '${options.base}' does not match declared base '${execution.base}'`,
173
+ })
174
+ }
175
+ if (
176
+ config.delivery &&
177
+ (options.title || (options.labels?.length ?? 0) > 0)
178
+ ) {
179
+ return yield* new PullRequestError({
180
+ message:
181
+ "Task-aware --title and --label options require the default GitHub delivery provider",
182
+ })
183
+ }
161
184
 
162
185
  const dirty = yield* backend.workspaceDirty(workspace.writablePath)
163
186
  if (dirty === null) {
@@ -211,6 +234,9 @@ export class PullRequestService extends Effect.Service<PullRequestService>()(
211
234
  repository,
212
235
  draft,
213
236
  vcs: config.vcs ?? "git",
237
+ title: options.title,
238
+ head: options.head,
239
+ labels: options.labels,
214
240
  ...(defaults ? { defaults } : {}),
215
241
  })
216
242
  const gitEnvironment = config.delivery
@@ -376,6 +376,42 @@ describe("PushService", () => {
376
376
  )
377
377
  ).stdout,
378
378
  ).toBe("true")
379
+
380
+ await requireCommand(["jj", "new", "@"], fixture.checkout)
381
+ await Bun.write(join(fixture.checkout, "follow-up.txt"), "follow up\n")
382
+ await requireCommand(
383
+ ["jj", "describe", "-m", "Add follow-up"],
384
+ fixture.checkout,
385
+ )
386
+ const advanced = await publish(fixture.taskPath)
387
+ expect(advanced.tip).not.toBe(result.tip)
388
+ expect(await remoteBranch(fixture.remote)).toBe(advanced.tip)
389
+ }, 15_000)
390
+
391
+ test("diagnoses a jj stack whose remote base advanced", async () => {
392
+ if (!Bun.which("jj")) return
393
+ const fixture = await setup("jj")
394
+ await Bun.write(join(fixture.checkout, "feature.txt"), "local\n")
395
+ await requireCommand(
396
+ ["jj", "describe", "-m", "Local feature"],
397
+ fixture.checkout,
398
+ )
399
+
400
+ const other = join(fixture.root, "other-main")
401
+ await requireCommand(["git", "clone", fixture.remote, other])
402
+ await requireCommand(["git", "config", "user.name", "Other"], other)
403
+ await requireCommand(
404
+ ["git", "config", "user.email", "other@example.com"],
405
+ other,
406
+ )
407
+ await Bun.write(join(other, "remote.txt"), "remote\n")
408
+ await requireCommand(["git", "add", "remote.txt"], other)
409
+ await requireCommand(["git", "commit", "-m", "Advance main"], other)
410
+ await requireCommand(["git", "push", "origin", "main"], other)
411
+
412
+ await expect(publish(fixture.taskPath)).rejects.toThrow(
413
+ "jj rebase -s 'roots(main@origin..@)' -d main@origin",
414
+ )
379
415
  })
380
416
 
381
417
  test("selects the described parent of a canonical jj post-commit working copy", async () => {
@@ -424,7 +460,7 @@ describe("PushService", () => {
424
460
  await expect(publish(undescribed.taskPath)).rejects.toThrow(
425
461
  `Change ${changeId} has no description. Run: jj describe -r ${changeId}`,
426
462
  )
427
- })
463
+ }, 15_000)
428
464
 
429
465
  test("rejects an empty jj task and remote bookmark divergence", async () => {
430
466
  if (!Bun.which("jj")) return
@@ -463,7 +499,7 @@ describe("PushService", () => {
463
499
  await expect(publish(fixture.taskPath)).rejects.toThrow(
464
500
  "refusing to move it",
465
501
  )
466
- })
502
+ }, 15_000)
467
503
 
468
504
  test("rejects conflicted jj changes with an actionable change ID", async () => {
469
505
  if (!Bun.which("jj")) return
@@ -37,6 +37,12 @@ interface PushResult {
37
37
  readonly changeId?: string
38
38
  }
39
39
 
40
+ type PushStage = "context" | "fetch" | "inspect" | "validate" | "publish"
41
+
42
+ interface PushOptions {
43
+ readonly onProgress?: (stage: PushStage) => void
44
+ }
45
+
40
46
  const validEmail = (email: string) => /^[^@\s]+@[^@\s]+$/.test(email)
41
47
 
42
48
  const requireCommand = (
@@ -153,8 +159,10 @@ const publishGit = (
153
159
  remote: string,
154
160
  branch: string,
155
161
  base: string,
162
+ onProgress?: (stage: PushStage) => void,
156
163
  ) =>
157
164
  Effect.gen(function* () {
165
+ onProgress?.("inspect")
158
166
  const currentBranch = yield* git(
159
167
  fs,
160
168
  checkout,
@@ -179,6 +187,7 @@ const publishGit = (
179
187
  })
180
188
  }
181
189
 
190
+ onProgress?.("fetch")
182
191
  yield* git(
183
192
  fs,
184
193
  checkout,
@@ -202,6 +211,7 @@ const publishGit = (
202
211
  })
203
212
  }
204
213
 
214
+ onProgress?.("validate")
205
215
  const log = yield* git(
206
216
  fs,
207
217
  checkout,
@@ -228,6 +238,7 @@ const publishGit = (
228
238
  })
229
239
  }
230
240
 
241
+ onProgress?.("publish")
231
242
  yield* git(
232
243
  fs,
233
244
  checkout,
@@ -361,14 +372,17 @@ const publishJj = (
361
372
  remote: string,
362
373
  branch: string,
363
374
  base: string,
375
+ onProgress?: (stage: PushStage) => void,
364
376
  ) =>
365
377
  Effect.gen(function* () {
378
+ onProgress?.("fetch")
366
379
  yield* jj(
367
380
  fs,
368
381
  checkout,
369
382
  ["git", "fetch", "--remote", remote],
370
383
  `Failed to fetch remote '${remote}'`,
371
384
  )
385
+ onProgress?.("inspect")
372
386
  const workingCopy = yield* jjRevision(fs, checkout, "@")
373
387
  if (!workingCopy) {
374
388
  return yield* new PushError({
@@ -403,10 +417,11 @@ const publishJj = (
403
417
  !(yield* jjAncestor(fs, checkout, baseRevision.commitId, tip.commitId))
404
418
  ) {
405
419
  return yield* new PushError({
406
- message: `Declared base '${base}' (${baseRevision.commitId}) is not an ancestor of jj tip ${tip.changeId} (${tip.commitId})`,
420
+ message: `Declared base '${base}' (${baseRevision.commitId}) is not an ancestor of jj tip ${tip.changeId} (${tip.commitId}). Rebase the stack with: jj rebase -s 'roots(${baseBookmark}..@)' -d ${baseBookmark}`,
407
421
  })
408
422
  }
409
423
 
424
+ onProgress?.("validate")
410
425
  const outgoing = yield* jjCommits(
411
426
  fs,
412
427
  checkout,
@@ -444,6 +459,7 @@ const publishJj = (
444
459
  })
445
460
  }
446
461
 
462
+ onProgress?.("publish")
447
463
  yield* jj(
448
464
  fs,
449
465
  checkout,
@@ -467,8 +483,9 @@ const publishJj = (
467
483
 
468
484
  export class PushService extends Effect.Service<PushService>()("PushService", {
469
485
  sync: () => ({
470
- publish: (startPath: string = process.cwd()) =>
486
+ publish: (startPath: string = process.cwd(), options: PushOptions = {}) =>
471
487
  Effect.gen(function* () {
488
+ options.onProgress?.("context")
472
489
  const contexts = yield* ContextService
473
490
  const fs = yield* FileSystemService
474
491
  const tasks = yield* TaskService
@@ -554,6 +571,7 @@ export class PushService extends Effect.Service<PushService>()("PushService", {
554
571
  remote,
555
572
  execution.branch,
556
573
  execution.base,
574
+ options.onProgress,
557
575
  )
558
576
  : yield* publishGit(
559
577
  fs,
@@ -561,6 +579,7 @@ export class PushService extends Effect.Service<PushService>()("PushService", {
561
579
  remote,
562
580
  execution.branch,
563
581
  execution.base,
582
+ options.onProgress,
564
583
  )
565
584
  return {
566
585
  vcs: context.workbase.vcs,
@@ -811,7 +811,11 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
811
811
  }),
812
812
 
813
813
  setup: (
814
- options: { readonly cwd?: string; readonly apply?: boolean } = {},
814
+ options: {
815
+ readonly cwd?: string
816
+ readonly apply?: boolean
817
+ readonly aliases?: readonly string[]
818
+ } = {},
815
819
  ) =>
816
820
  Effect.gen(function* () {
817
821
  const service = yield* RepositoryService
@@ -819,7 +823,13 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
819
823
  const versionControl = yield* VersionControlService
820
824
  const state = yield* configState(options.cwd ?? process.cwd())
821
825
  const backend = yield* versionControl.forWorkbase(state.root)
822
- const repositories = yield* service.list(state.root)
826
+ const requestedAliases = options.aliases
827
+ ? new Set(options.aliases)
828
+ : null
829
+ const repositories = (yield* service.list(state.root)).filter(
830
+ (repository) =>
831
+ !requestedAliases || requestedAliases.has(repository.alias),
832
+ )
823
833
  const planned: Omit<RepositorySetupAction, "status">[] = []
824
834
  const unresolved: RepositorySetupIssue[] = []
825
835
 
@@ -933,7 +943,11 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
933
943
  unresolved,
934
944
  repositories:
935
945
  options.apply === true
936
- ? yield* service.list(state.root)
946
+ ? (yield* service.list(state.root)).filter(
947
+ (repository) =>
948
+ !requestedAliases ||
949
+ requestedAliases.has(repository.alias),
950
+ )
937
951
  : repositories,
938
952
  } satisfies RepositorySetupResult
939
953
  }),
@@ -170,6 +170,8 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
170
170
  readonly apply?: boolean
171
171
  readonly now?: Date
172
172
  readonly onProgress?: (progress: SyncProgress) => void
173
+ readonly taskId?: string
174
+ readonly phaseId?: string
173
175
  } = {},
174
176
  ) =>
175
177
  Effect.gen(function* () {
@@ -191,9 +193,66 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
191
193
  .join("\n"),
192
194
  })
193
195
  }
196
+ if (options.phaseId && !options.taskId) {
197
+ return yield* new SyncError({
198
+ message: "A phase sync scope requires a task ID",
199
+ })
200
+ }
201
+ const allTaskRecords = yield* tasks.list(root)
202
+ const taskRecords = options.taskId
203
+ ? allTaskRecords.filter((task) => task.id === options.taskId)
204
+ : allTaskRecords
205
+ if (options.taskId && taskRecords.length === 0) {
206
+ return yield* new SyncError({
207
+ message: `Task '${options.taskId}' does not exist`,
208
+ })
209
+ }
210
+ const records: ExecutionRecord[] = []
211
+ for (const task of taskRecords) {
212
+ if ("phases" in task.data) {
213
+ for (const phase of yield* phases.list(task.id, root)) {
214
+ if (options.phaseId && phase.id !== options.phaseId) continue
215
+ records.push({
216
+ key: `phase:${task.id}/${phase.id}`,
217
+ taskId: task.id,
218
+ phaseId: phase.id,
219
+ path: phase.path,
220
+ revision: documentRevision(phase.content),
221
+ data: phase.data,
222
+ })
223
+ }
224
+ } else if (!options.phaseId && !("review" in task.data)) {
225
+ records.push({
226
+ key: `task:${task.id}`,
227
+ taskId: task.id,
228
+ path: task.path,
229
+ revision: documentRevision(task.content),
230
+ data: task.data,
231
+ })
232
+ }
233
+ }
234
+ const reviewRecords = options.phaseId
235
+ ? []
236
+ : taskRecords.filter((task) => "review" in task.data)
237
+ if (options.phaseId && records.length === 0) {
238
+ return yield* new SyncError({
239
+ message: `Phase '${options.taskId}/${options.phaseId}' does not exist`,
240
+ })
241
+ }
242
+ const repositoryAliases = new Set<string>()
243
+ for (const record of records) {
244
+ repositoryAliases.add(record.data.repo)
245
+ for (const reference of record.data.repos ?? [])
246
+ repositoryAliases.add(reference.repo)
247
+ }
248
+ for (const task of reviewRecords) {
249
+ if ("review" in task.data)
250
+ repositoryAliases.add(task.data.review.repo)
251
+ }
194
252
  const repositorySetup = yield* repositories.setup({
195
253
  cwd: root,
196
254
  apply: options.apply === true,
255
+ ...(options.taskId ? { aliases: [...repositoryAliases] } : {}),
197
256
  })
198
257
  options.onProgress?.({
199
258
  stage: "repositories",
@@ -241,30 +300,6 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
241
300
  }),
242
301
  ),
243
302
  )
244
- const taskRecords = yield* tasks.list(root)
245
- const records: ExecutionRecord[] = []
246
- for (const task of taskRecords) {
247
- if ("phases" in task.data) {
248
- for (const phase of yield* phases.list(task.id, root)) {
249
- records.push({
250
- key: `phase:${task.id}/${phase.id}`,
251
- taskId: task.id,
252
- phaseId: phase.id,
253
- path: phase.path,
254
- revision: documentRevision(phase.content),
255
- data: phase.data,
256
- })
257
- }
258
- } else if (!("review" in task.data)) {
259
- records.push({
260
- key: `task:${task.id}`,
261
- taskId: task.id,
262
- path: task.path,
263
- revision: documentRevision(task.content),
264
- data: task.data,
265
- })
266
- }
267
- }
268
303
  const listRegistered = (repositoryPath: string) =>
269
304
  Effect.gen(function* () {
270
305
  if (registeredByRepository.has(repositoryPath))
@@ -409,9 +444,6 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
409
444
  { concurrency: 8 },
410
445
  ),
411
446
  )
412
- const reviewRecords = taskRecords.filter(
413
- (task) => "review" in task.data,
414
- )
415
447
  const executionTotal = records.length + reviewRecords.length
416
448
  let reconciledExecutions = 0
417
449
  const reportExecution = (target: string) => {
@@ -67,6 +67,55 @@ describe("delivery commands", () => {
67
67
  argv: ["gh", "pr", "create", "--fill", "--base", "main"],
68
68
  environment: {},
69
69
  })
70
+ expect(
71
+ resolveGitHubCreateCommand({
72
+ ...input,
73
+ vcs: "git",
74
+ head: "feat/example",
75
+ }),
76
+ ).toEqual({
77
+ argv: [
78
+ "gh",
79
+ "pr",
80
+ "create",
81
+ "--fill",
82
+ "--base",
83
+ "main",
84
+ "--head",
85
+ "feat/example",
86
+ ],
87
+ environment: {},
88
+ })
89
+ expect(
90
+ resolveGitHubCreateCommand({
91
+ ...input,
92
+ vcs: "jj",
93
+ defaults: { title: "Generated", body: "Details" },
94
+ title: "Requested",
95
+ labels: ["ai-assisted", "platform"],
96
+ }),
97
+ ).toEqual({
98
+ argv: [
99
+ "gh",
100
+ "pr",
101
+ "create",
102
+ "--title",
103
+ "Requested",
104
+ "--body",
105
+ "Details",
106
+ "--base",
107
+ "main",
108
+ "--head",
109
+ "feat/example",
110
+ "--repo",
111
+ "example/agency",
112
+ "--label",
113
+ "ai-assisted",
114
+ "--label",
115
+ "platform",
116
+ ],
117
+ environment: {},
118
+ })
70
119
  })
71
120
 
72
121
  test("rejects unknown placeholders", () => {
@@ -25,6 +25,9 @@ export const resolveGitHubCreateCommand = ({
25
25
  draft,
26
26
  vcs,
27
27
  defaults,
28
+ title,
29
+ head,
30
+ labels = [],
28
31
  }: {
29
32
  readonly base: string
30
33
  readonly branch: string
@@ -32,18 +35,25 @@ export const resolveGitHubCreateCommand = ({
32
35
  readonly draft: boolean
33
36
  readonly vcs: "git" | "jj"
34
37
  readonly defaults?: { readonly title: string; readonly body: string }
38
+ readonly title?: string
39
+ readonly head?: string
40
+ readonly labels?: readonly string[]
35
41
  }) => ({
36
42
  argv: [
37
43
  "gh",
38
44
  "pr",
39
45
  "create",
40
46
  ...(defaults
41
- ? ["--title", defaults.title, "--body", defaults.body]
42
- : ["--fill"]),
47
+ ? ["--title", title ?? defaults.title, "--body", defaults.body]
48
+ : title
49
+ ? ["--fill", "--title", title]
50
+ : ["--fill"]),
43
51
  "--base",
44
52
  base,
45
- ...(vcs === "jj" ? ["--head", branch, "--repo", repository] : []),
53
+ ...(vcs === "jj" || head ? ["--head", head ?? branch] : []),
54
+ ...(vcs === "jj" ? ["--repo", repository] : []),
46
55
  ...(draft ? ["--draft"] : []),
56
+ ...labels.flatMap((label) => ["--label", label]),
47
57
  ],
48
58
  environment: {},
49
59
  })
@@ -19,6 +19,12 @@ const variables = {
19
19
 
20
20
  describe("runner commands", () => {
21
21
  test("uses promptless interactive commands for built-in presets", () => {
22
+ expect(
23
+ resolveRunnerCommand("opencode2", undefined, variables, false).argv,
24
+ ).toEqual(["opencode2"])
25
+ expect(
26
+ resolveRunnerCommand("opencode2", undefined, variables, true).argv,
27
+ ).toEqual(["opencode2", "--continue"])
22
28
  expect(
23
29
  resolveRunnerCommand("opencode", undefined, variables, false).argv,
24
30
  ).toEqual(["opencode"])
@@ -31,6 +37,12 @@ describe("runner commands", () => {
31
37
  })
32
38
 
33
39
  test("uses autonomous commands when a prompt is requested", () => {
40
+ expect(
41
+ resolveRunnerCommand("opencode2", undefined, variables, false, true).argv,
42
+ ).toEqual(["opencode2", "--prompt", "Read the task."])
43
+ expect(
44
+ resolveRunnerCommand("opencode2", undefined, variables, true, true).argv,
45
+ ).toEqual(["opencode2", "--continue", "--prompt", "Read the task."])
34
46
  expect(
35
47
  resolveRunnerCommand("opencode", undefined, variables, false, true).argv,
36
48
  ).toEqual(["opencode", "--prompt", "Read the task."])
@@ -31,6 +31,12 @@ const PLACEHOLDERS = new Set<keyof RunnerCommandVariables>([
31
31
  ])
32
32
 
33
33
  const BUILTIN_RUNNERS: Readonly<Record<string, RunnerDefinition>> = {
34
+ opencode2: {
35
+ command: ["opencode2"],
36
+ autoCommand: ["opencode2", "--prompt", "{prompt}"],
37
+ resumeCommand: ["opencode2", "--continue"],
38
+ autoResumeCommand: ["opencode2", "--continue", "--prompt", "{prompt}"],
39
+ },
34
40
  opencode: {
35
41
  command: ["opencode"],
36
42
  autoCommand: ["opencode", "--prompt", "{prompt}"],