@markjaquith/agency 3.10.2 → 3.12.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
@@ -140,7 +140,8 @@ whether the server plugin registered writable-checkout skills. It uses a native
140
140
  toast and does not submit a prompt to an LLM. When no writable checkout skill
141
141
  directory is available, server initialization is reported as indeterminate
142
142
  rather than inferred from plugin discovery.
143
- OpenCode discovers the config and plugin from task and epic launch directories.
143
+ OpenCode discovers the config and plugin from Agency launch directories and
144
+ their ancestors.
144
145
  The plugin uses OpenCode's plural discovery directory and exports both the V1
145
146
  server function and the `opencode2` module wrapper. Integration sync migrates a
146
147
  checksum-valid legacy singular-path plugin and preserves customized files.
@@ -186,6 +187,48 @@ Existing version 2 workbases without `repositories` remain valid. Run
186
187
  unambiguous. Workbase configuration may also provide a custom writable-worktree
187
188
  creation command.
188
189
 
190
+ ### Custom Branch Names
191
+
192
+ Set `branchNameCommand` to an argv template to choose the branch recorded for a
193
+ new execution unit when `--branch` is omitted:
194
+
195
+ ```json
196
+ {
197
+ "version": 2,
198
+ "branchNameCommand": ["wt-resolve-new-branch-name", "{ticket}"]
199
+ }
200
+ ```
201
+
202
+ Agency invokes the command directly, without a shell, from the workbase root and
203
+ uses its trimmed stdout as the branch name. The command has 120 seconds to
204
+ finish. A non-zero exit, empty output, timeout, or output rejected by
205
+ `git check-ref-format --branch` fails creation without falling back. Explicit
206
+ `--branch` always wins. Multi-phase task containers and review tasks do not have
207
+ writable branches and therefore do not invoke this command.
208
+
209
+ Available placeholders are:
210
+
211
+ | Placeholder | Value |
212
+ | ---------------- | -------------------------------------------------- |
213
+ | `{id}` | ID of the task or phase being created |
214
+ | `{ticket}` | Task ticket URL, or `{id}` when there is no ticket |
215
+ | `{ticketUrl}` | Task ticket URL, or an empty string when absent |
216
+ | `{repo}` | Writable repository alias |
217
+ | `{base}` | Base branch |
218
+ | `{workbaseRoot}` | Absolute workbase root |
219
+ | `{taskId}` | Task ID |
220
+ | `{phaseId}` | Phase ID, or an empty string when creating a task |
221
+
222
+ Resolver failures are reported with the `BRANCH_NAME_COMMAND_FAILED` error code
223
+ in JSON output.
224
+
225
+ Matching `AGENCY_ID`, `AGENCY_TICKET`, `AGENCY_TICKET_URL`, `AGENCY_REPO`,
226
+ `AGENCY_BASE`, `AGENCY_WORKBASE_ROOT`, `AGENCY_TASK_ID`, and `AGENCY_PHASE_ID`
227
+ environment variables are also set. Without `branchNameCommand`, task branches
228
+ remain `task/<id>` and phase branches default to `task/<task-id>-<phase-id>`.
229
+ Callers should omit `--branch` when they want the workbase policy; do not pass a
230
+ hard-coded value merely to reproduce Agency's built-in default.
231
+
189
232
  ### Custom Worktree Command
190
233
 
191
234
  Git workbases create worktrees with Git. Set `worktreeCreateCommand` to an
@@ -394,9 +437,13 @@ identify the invoking session, so Agency deliberately avoids leaking one
394
437
  session's worker identity into another; the validated marker and injected system
395
438
  instruction remain the V2 fallback when the long-lived server did not inherit
396
439
  the client's launch environment.
397
- The `opencode2` and `opencode` agents remain rooted in their task or epic
398
- working directory so the workbase `AGENTS.md` and managed OpenCode config are
399
- discovered normally.
440
+ For execution units, the built-in `pi` agent and the `opencode2` and `opencode`
441
+ agents running OpenCode V2 launch from the authoritative writable checkout so
442
+ their project and Git interfaces reflect the implementation repository.
443
+ OpenCode V1 stops configuration discovery at the checkout's Git root, so V1
444
+ launches remain rooted in the task or phase directory. Epic and multi-phase task orchestration remains
445
+ rooted in its Agency document directory. Ancestor discovery still supplies the
446
+ workbase `AGENTS.md` and managed OpenCode config.
400
447
  Agency's managed OpenCode plugin grants the active workbase external-directory
401
448
  access and exposes existing checkout-local `.claude/skills`, `.agents/skills`,
402
449
  and `.opencode/{skill,skills}` definitions. V1 adds those source directories to
@@ -406,8 +453,9 @@ hook. The global Pi
406
453
  extension provides equivalent whole-workbase context and additionally discovers
407
454
  checkout-local `.pi/skills` through Pi's `resources_discover` lifecycle.
408
455
  `agency work` supplies the checkout directly; plain OpenCode and Pi launches
409
- resolve a materialized execution-unit
410
- checkout through `agency context`. A multi-phase
456
+ from a task or phase directory resolve a materialized execution-unit checkout
457
+ through `agency context` and instruct the agent to change to it before
458
+ implementation or Git operations. A multi-phase
411
459
  task root has no single checkout, so launch from its phase directory when using
412
460
  plain OpenCode or Pi. Other checkout-local configuration is not composed.
413
461
  `--print-command` prints the cwd, command template, and non-secret environment
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "3.10.2",
3
+ "version": "3.12.0",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -1,5 +1,5 @@
1
1
  import { existsSync, readFileSync } from "node:fs"
2
- import { dirname, join, resolve } from "node:path"
2
+ import { dirname, join, resolve, sep } from "node:path"
3
3
 
4
4
  type ExtensionAPI = {
5
5
  exec: (
@@ -26,7 +26,9 @@ type ExtensionAPI = {
26
26
  type AgencyContext = {
27
27
  root?: string
28
28
  checkout?: string
29
+ documentDirectory?: string
29
30
  target?: string
31
+ working?: boolean
30
32
  }
31
33
 
32
34
  const contextTarget = (result: Record<string, any>): string | undefined => {
@@ -78,14 +80,18 @@ const agencyContext = async (
78
80
  result.workbase?.root !== root ||
79
81
  !target ||
80
82
  (target.startsWith("execution-unit:") &&
81
- (!result.authority?.writable?.checkoutPath || status !== "working"))
83
+ !result.authority?.writable?.checkoutPath)
82
84
  )
83
85
  return
84
86
 
85
87
  return {
86
88
  root,
87
89
  checkout: result.authority?.writable?.checkoutPath,
90
+ documentDirectory: result.target?.path
91
+ ? dirname(result.target.path)
92
+ : undefined,
88
93
  target,
94
+ working: status === "working",
89
95
  }
90
96
  }
91
97
 
@@ -150,12 +156,27 @@ export default function agencyExtension(pi: ExtensionAPI) {
150
156
  : undefined
151
157
  const activeTarget = process.env.AGENCY_TARGET
152
158
  const worker =
153
- activeTarget && context.target === activeTarget
159
+ activeTarget && context.target === activeTarget && context.working
154
160
  ? `Agency verified this Pi session as the active worker for ${activeTarget}. Perform the assigned work directly. Do not invoke agency work for this target or launch a replacement worker.`
155
161
  : undefined
156
162
  const access = `The complete Agency workbase is available at ${context.root}. Use absolute paths under that root when workbase context is needed. Agency context remains the authority for writes.`
157
- const implementation = context.checkout
158
- ? `Pi remains rooted in the task or phase directory for Agency instructions and context. Treat ${context.checkout} as the default implementation directory for source reads, edits, repository status, builds, tests, formatting, and other repository-local commands. Run Agency lifecycle and context commands from the task or phase directory. Any reference checkouts reported by Agency context are read-only.`
163
+ const checkout = context.checkout ? resolve(context.checkout) : undefined
164
+ const current = resolve(ctx.cwd)
165
+ const repository =
166
+ checkout &&
167
+ (current === checkout || current.startsWith(`${checkout}${sep}`))
168
+ const implementation = checkout
169
+ ? [
170
+ repository
171
+ ? `Pi is rooted in Agency's authoritative writable checkout at ${checkout}. Use this directory for source reads, edits, Git status and other Git operations, builds, tests, and formatting.`
172
+ : `Agency's authoritative writable checkout is ${checkout}. Before doing implementation work or Git operations, change the working directory to that checkout. Set each tool's working directory to it when supported; otherwise use absolute paths.`,
173
+ context.documentDirectory
174
+ ? `Run Agency lifecycle commands from ${context.documentDirectory}; agency context may also be queried from the checkout.`
175
+ : undefined,
176
+ "Any reference checkouts reported by Agency context are read-only.",
177
+ ]
178
+ .filter(Boolean)
179
+ .join(" ")
159
180
  : undefined
160
181
 
161
182
  return {
package/src/act-schema.ts CHANGED
@@ -49,6 +49,19 @@ const EntityFields = {
49
49
  }
50
50
 
51
51
  export const ActDiscovery = Schema.Struct({
52
+ creationDefaults: Schema.Struct({
53
+ branch: Schema.Union(
54
+ Schema.Struct({
55
+ configured: Schema.Literal(true),
56
+ guidance: Schema.String,
57
+ }),
58
+ Schema.Struct({
59
+ configured: Schema.Literal(false),
60
+ task: Schema.String,
61
+ phase: Schema.String,
62
+ }),
63
+ ),
64
+ }),
52
65
  workbase: Schema.Struct({
53
66
  root: Schema.String,
54
67
  repositories: Argv,
package/src/cli-parser.ts CHANGED
@@ -573,7 +573,7 @@ const commands = {
573
573
  subcommands: {
574
574
  new: {
575
575
  usage:
576
- "agency phase new <task-id> <phase-id> --repo <alias> --branch <name> --base <name> [options] [--work [--auto]]",
576
+ "agency phase new <task-id> <phase-id> --repo <alias> --base <name> [--branch <name>] [options] [--work [--auto]]",
577
577
  minArgs: 2,
578
578
  maxArgs: 2,
579
579
  options: [
@@ -596,7 +596,7 @@ const commands = {
596
596
  },
597
597
  create: {
598
598
  usage:
599
- "agency phase create <task-id> <phase-id> --repo <alias> --branch <name> --base <name> [options]",
599
+ "agency phase create <task-id> <phase-id> --repo <alias> --base <name> [--branch <name>] [options]",
600
600
  minArgs: 2,
601
601
  maxArgs: 2,
602
602
  options: [
@@ -160,6 +160,21 @@ const shellCommand = (argv: readonly string[]) =>
160
160
  : `'${arg.replaceAll("'", `'\\''`)}'`,
161
161
  )
162
162
  .join(" ")
163
+ const creationDefaults = (config: {
164
+ readonly branchNameCommand?: readonly string[]
165
+ }) => ({
166
+ branch: config.branchNameCommand
167
+ ? {
168
+ configured: true as const,
169
+ guidance:
170
+ "Omit --branch to use the workbase branchNameCommand; pass it only for an explicit override.",
171
+ }
172
+ : {
173
+ configured: false as const,
174
+ task: "task/<id>",
175
+ phase: "task/<task-id>-<phase-id>",
176
+ },
177
+ })
163
178
  const available = (action: ActAction) => !action.blockedReason
164
179
  const checkoutState = (inspection: {
165
180
  readonly checkouts: readonly {
@@ -463,6 +478,7 @@ const actStep = (
463
478
  log(
464
479
  JSON.stringify(
465
480
  yield* Schema.decodeUnknown(ActDiscovery)({
481
+ creationDefaults: creationDefaults(config),
466
482
  workbase: {
467
483
  root,
468
484
  repositories: graph.nodes
@@ -772,6 +788,8 @@ or exits from the front screen; Ctrl-C quits. A recap is printed when you exit.
772
788
  An existing directory or file selects its containing epic, task, or phase.
773
789
  A workbase path opens Workbase actions. Otherwise the positional value is a
774
790
  task ID. Selectors skip item selection.
791
+ JSON discovery includes creation defaults identifying when callers should omit
792
+ --branch so the workbase branchNameCommand can choose it.
775
793
 
776
794
  Options:
777
795
  --action <id> Start an action or filter discovery (IDs from --json)
@@ -7,6 +7,8 @@ import { getWorkViews } from "../work-view"
7
7
  import { parseRepositoryReferences } from "../workbase/repository-reference"
8
8
  import { GraphMutationService } from "../services/GraphMutationService"
9
9
  import { work as startWork, type StartWork } from "./work"
10
+ import { TaskService } from "../services/TaskService"
11
+ import { resolveBranchName } from "../workbase/branch-name-command"
10
12
 
11
13
  interface PhaseOptions extends BaseCommandOptions {
12
14
  readonly subcommand?: string
@@ -39,6 +41,7 @@ interface PhaseOptions extends BaseCommandOptions {
39
41
  export const phase = (options: PhaseOptions, work: StartWork = startWork) =>
40
42
  Effect.gen(function* () {
41
43
  const phases = yield* PhaseService
44
+ const tasks = yield* TaskService
42
45
  const mutations = yield* GraphMutationService
43
46
  const { log } = createLoggers(options)
44
47
  const cwd = options.cwd ?? process.cwd()
@@ -47,19 +50,26 @@ export const phase = (options: PhaseOptions, work: StartWork = startWork) =>
47
50
  switch (options.subcommand) {
48
51
  case "new":
49
52
  case "create": {
50
- if (
51
- !taskId ||
52
- !phaseId ||
53
- !options.repo ||
54
- !options.branch ||
55
- !options.base
56
- ) {
53
+ if (!taskId || !phaseId || !options.repo || !options.base) {
57
54
  return yield* Effect.fail(
58
55
  new Error(
59
- "Usage: agency phase create <task-id> <phase-id> --repo <alias> --branch <name> --base <name>",
56
+ "Usage: agency phase create <task-id> <phase-id> --repo <alias> --base <name> [--branch <name>]",
60
57
  ),
61
58
  )
62
59
  }
60
+ const parent = yield* tasks.show(taskId, cwd)
61
+ const branch =
62
+ options.branch ??
63
+ (yield* resolveBranchName({
64
+ id: phaseId,
65
+ taskId,
66
+ phaseId,
67
+ ticketUrl: parent.data.ticketUrl,
68
+ repo: options.repo,
69
+ base: options.base,
70
+ defaultBranch: `task/${taskId}-${phaseId}`,
71
+ startPath: cwd,
72
+ }))
63
73
  const record = yield* phases.create(
64
74
  {
65
75
  taskId,
@@ -67,7 +77,7 @@ export const phase = (options: PhaseOptions, work: StartWork = startWork) =>
67
77
  description: options.description,
68
78
  repo: options.repo,
69
79
  repos: parseRepositoryReferences(options.references),
70
- branch: options.branch,
80
+ branch,
71
81
  base: options.base,
72
82
  dependsOn: options.dependsOn,
73
83
  firstPhase: options.firstPhase,
@@ -311,7 +321,7 @@ Create options:
311
321
  --repo <alias> Writable repository
312
322
  --reference <alias>:<ref>
313
323
  Read-only repository reference; repeatable
314
- --branch <name> Working branch
324
+ --branch <name> Working branch (default: configured resolver or task/<task>-<phase>)
315
325
  --base <name> Base branch
316
326
  --depends-on <id> Phase dependency; repeatable
317
327
  --first-phase <id> Existing execution phase ID when converting a task
@@ -16,6 +16,7 @@ import {
16
16
  buildValidationEvidence,
17
17
  normalizeRecalledContext,
18
18
  } from "../workbase/execution-contract"
19
+ import { resolveBranchName } from "../workbase/branch-name-command"
19
20
 
20
21
  interface TaskOptions extends BaseCommandOptions {
21
22
  readonly subcommand?: string
@@ -188,6 +189,19 @@ export const task = (
188
189
  }
189
190
  }
190
191
 
192
+ const base = options.base ?? "main"
193
+ const branch =
194
+ multiPhase || options.branch
195
+ ? options.branch
196
+ : yield* resolveBranchName({
197
+ id,
198
+ taskId: id,
199
+ ticketUrl,
200
+ repo: repo!,
201
+ base,
202
+ defaultBranch: `task/${id}`,
203
+ startPath: cwd,
204
+ })
191
205
  const record = yield* tasks.create(
192
206
  {
193
207
  id,
@@ -197,8 +211,8 @@ export const task = (
197
211
  multiPhase,
198
212
  repo,
199
213
  repos: parseRepositoryReferences(options.references),
200
- branch: multiPhase ? undefined : (options.branch ?? `task/${id}`),
201
- base: multiPhase ? undefined : (options.base ?? "main"),
214
+ branch: multiPhase ? undefined : branch,
215
+ base: multiPhase ? undefined : base,
202
216
  purpose: options.purpose as "investigation" | undefined,
203
217
  },
204
218
  cwd,
@@ -275,10 +289,23 @@ export const task = (
275
289
  cwd,
276
290
  )
277
291
  : undefined
292
+ const ticketUrl = options.ticketUrl?.trim() || null
293
+ const branch =
294
+ multiPhase || review || options.branch
295
+ ? options.branch
296
+ : yield* resolveBranchName({
297
+ id,
298
+ taskId: id,
299
+ ticketUrl,
300
+ repo: repo!,
301
+ base,
302
+ defaultBranch: `task/${id}`,
303
+ startPath: cwd,
304
+ })
278
305
  const record = yield* tasks.create(
279
306
  {
280
307
  id,
281
- ticketUrl: options.ticketUrl?.trim() || null,
308
+ ticketUrl,
282
309
  description: options.description?.trim() || undefined,
283
310
  epic: options.epic,
284
311
  multiPhase,
@@ -287,10 +314,7 @@ export const task = (
287
314
  repos: review
288
315
  ? undefined
289
316
  : parseRepositoryReferences(options.references),
290
- branch:
291
- multiPhase || review
292
- ? undefined
293
- : (options.branch ?? `task/${id}`),
317
+ branch: multiPhase || review ? undefined : branch,
294
318
  base: multiPhase || review ? undefined : base,
295
319
  purpose: options.purpose as "investigation" | undefined,
296
320
  },
@@ -337,18 +361,31 @@ export const task = (
337
361
  new Error("Writable repository is required for task handoff"),
338
362
  )
339
363
  }
364
+ const ticketUrl = options.ticketUrl?.trim() || null
365
+ const base = options.base ?? "main"
366
+ const branch =
367
+ options.branch ??
368
+ (yield* resolveBranchName({
369
+ id,
370
+ taskId: id,
371
+ ticketUrl,
372
+ repo: options.repo,
373
+ base,
374
+ defaultBranch: `task/${id}`,
375
+ startPath: cwd,
376
+ }))
340
377
  const output = yield* tasks.handoff(
341
378
  {
342
379
  sourceTaskId,
343
380
  sourcePhaseId: options.sourcePhase,
344
381
  id,
345
- ticketUrl: options.ticketUrl?.trim() || null,
382
+ ticketUrl,
346
383
  description: options.description?.trim() || undefined,
347
384
  epic: options.epic,
348
385
  repo: options.repo,
349
386
  repos: parseRepositoryReferences(options.references),
350
- branch: options.branch ?? `task/${id}`,
351
- base: options.base ?? "main",
387
+ branch,
388
+ base,
352
389
  },
353
390
  cwd,
354
391
  )
@@ -591,7 +628,7 @@ Create options:
591
628
  --repo <alias> Writable repository
592
629
  --reference <alias>:<ref>
593
630
  Read-only repository reference; repeatable
594
- --branch <name> Working branch (default: task/<id>)
631
+ --branch <name> Working branch (default: configured resolver or task/<id>)
595
632
  --base <name> Base branch (default: main)
596
633
  --context-repo <alias> Recalled repository; must agree with --repo
597
634
  --context-base <name> Recalled base; must agree with --base
@@ -37,7 +37,10 @@ import {
37
37
  readValidationEvidence,
38
38
  } from "../workbase/execution-contract"
39
39
  import { ValidationFailedError } from "./validate"
40
- import { prepareOpenCodeLaunch } from "../workbase/opencode-launch"
40
+ import {
41
+ openCodeDiscoversAncestorConfig,
42
+ prepareOpenCodeLaunch,
43
+ } from "../workbase/opencode-launch"
41
44
 
42
45
  export interface WorkOptions extends BaseCommandOptions {
43
46
  readonly directory?: string
@@ -378,6 +381,15 @@ export const work = (
378
381
  options.auto,
379
382
  )
380
383
  cli = resolved.argv[0]!
384
+ if (writablePath && !config.agents?.[agent]) {
385
+ if (
386
+ agent === "pi" ||
387
+ ((agent === "opencode2" || agent === "opencode") &&
388
+ (yield* openCodeDiscoversAncestorConfig(cli)))
389
+ ) {
390
+ launchPath = writablePath
391
+ }
392
+ }
381
393
  const environment = {
382
394
  ...resolved.environment,
383
395
  ...agentEnvironment(agent, variables),
package/src/protocol.ts CHANGED
@@ -113,6 +113,12 @@ const errorMetadata: Readonly<Record<string, ErrorMetadata>> = {
113
113
  retryable: false,
114
114
  remediation: "Resolve workbase validation errors before reconciling.",
115
115
  },
116
+ BranchNameCommandError: {
117
+ code: "BRANCH_NAME_COMMAND_FAILED",
118
+ retryable: false,
119
+ remediation:
120
+ "Fix the workbase branchNameCommand or pass an explicit --branch.",
121
+ },
116
122
  ProcessError: { code: "PROCESS_ERROR", retryable: true },
117
123
  ProtocolOutputError: {
118
124
  code: "PROTOCOL_OUTPUT_ERROR",
@@ -22,6 +22,7 @@ import {
22
22
  } from "../workbase/schemas"
23
23
  import { validateWorktreeCreateCommand } from "../workbase/worktree-command"
24
24
  import { validatePostCheckoutCommand } from "../workbase/checkout-command"
25
+ import { validateBranchNameCommand } from "../workbase/branch-name-template"
25
26
  import { validateAgents } from "../workbase/agent-command"
26
27
  import { findDependencyCycles } from "../workbase/dependency-graph"
27
28
  import { validateDelivery } from "../workbase/delivery-command"
@@ -329,6 +330,19 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
329
330
  })
330
331
  }
331
332
  }
333
+ if (decoded.value.branchNameCommand) {
334
+ try {
335
+ validateBranchNameCommand(decoded.value.branchNameCommand)
336
+ } catch (cause) {
337
+ return yield* new WorkbaseConfigError({
338
+ path: configPath,
339
+ message:
340
+ cause instanceof Error
341
+ ? cause.message
342
+ : "Invalid branchNameCommand",
343
+ })
344
+ }
345
+ }
332
346
  for (const [alias, repository] of Object.entries(
333
347
  decoded.value.repositories ?? {},
334
348
  )) {
@@ -73,7 +73,7 @@ retain `--if-revision` guards when shown, and do not add flags that are not show
73
73
  4. Reconcile remote pull-request state and completion:
74
74
  `agency sync <task> [phase] --json`.
75
75
  5. Convert an existing single-phase task and add a phase:
76
- `agency phase create <task> <new-phase> --first-phase <existing-phase> --repo <alias> --branch <branch> --base <base> [--depends-on <existing-phase>] --json`.
76
+ `agency phase create <task> <new-phase> --first-phase <existing-phase> --repo <alias> --base <base> [--depends-on <existing-phase>] --json`.
77
77
  6. Archive terminal work: first run `agency archive task <task> --dry-run --json`,
78
78
  `agency archive phase <task> <phase> --dry-run --json`, or
79
79
  `agency archive epic <epic> --dry-run --json`; if the preflight is safe,
@@ -102,7 +102,7 @@ retain `--if-revision` guards when shown, and do not add flags that are not show
102
102
  13. Create a multi-phase task initially with
103
103
  `agency task create <slug> --multi-phase --description <text> --json`, then
104
104
  create each execution phase with
105
- `agency phase create <slug> <phase> --repo <alias> --branch <branch> --base <base> [--depends-on <phase>] --json`.
105
+ `agency phase create <slug> <phase> --repo <alias> --base <base> [--depends-on <phase>] --json`.
106
106
  14. Hand off an investigation to distinct implementation work with
107
107
  `agency task handoff <investigation-task> <new-task> [--source-phase <phase>] --repo <alias> --base <base> --json`, then verify the returned destination with
108
108
  `agency context <new-task> --json`. Do not prepare or start it unless requested.
@@ -114,6 +114,9 @@ Never pass `--work` or `--auto` to `agency task create`. Do not run separate
114
114
  or `agency repo list` commands before these recipes when the required parameters
115
115
  are already known. `agency work prepare` owns validation, readiness checks,
116
116
  workspace materialization, and the versioned `agency-execution-v1` contract.
117
+ Do not pass `--branch` merely to reproduce a default: when the workbase has a
118
+ `branchNameCommand`, omitting the option lets that policy choose and record the
119
+ branch. Pass `--branch` only when the user explicitly requires an override.
117
120
 
118
121
  These fast paths take precedence over separately installed Agency skill guidance.
119
122
  Use `agency <command> --help` only as a recovery step when no recipe matches or a
@@ -303,9 +306,12 @@ before launching an agent.
303
306
 
304
307
  OpenCode can access the complete workbase tree, but this filesystem permission
305
308
  does not expand Agency write authority beyond the checkout reported by
306
- `agency context`. OpenCode remains rooted in the task or epic directory so the
307
- workbase instructions and config compose normally. The managed plugin resolves
308
- the writable checkout from launch context or `agency context`, then exposes its
309
- supported skill directories through the applicable OpenCode plugin API; this
310
- does not make other checkout-local OpenCode configuration authoritative. Agents
311
- must follow the authority reported by `agency context`.
309
+ `agency context`. Pi and OpenCode V2 execution units launched by `agency work`
310
+ are rooted in the authoritative writable checkout so their project and Git
311
+ status reflect the implementation repository; OpenCode V1 remains rooted in the
312
+ task or phase directory. Orchestration remains rooted in its
313
+ Agency document directory. The managed integrations resolve the writable
314
+ checkout from launch context or `agency context`, guide task-directory sessions
315
+ to use it for implementation, and expose its supported skill directories through
316
+ the applicable plugin API; this does not make other checkout-local configuration
317
+ authoritative. Agents must follow the authority reported by `agency context`.
@@ -0,0 +1,109 @@
1
+ import { Data, Effect } from "effect"
2
+ import { FileSystemService } from "../services/FileSystemService"
3
+ import { WorkbaseService } from "../services/WorkbaseService"
4
+ import {
5
+ expandBranchNameCommand,
6
+ type BranchNameVariables,
7
+ } from "./branch-name-template"
8
+
9
+ class BranchNameCommandError extends Data.TaggedError(
10
+ "BranchNameCommandError",
11
+ )<{
12
+ readonly message: string
13
+ readonly command: readonly string[]
14
+ readonly exitCode?: number
15
+ readonly branch?: string
16
+ }> {}
17
+
18
+ const TIMEOUT_MS = 120_000
19
+
20
+ const commandEnvironment = (
21
+ variables: BranchNameVariables,
22
+ ): Record<string, string> =>
23
+ Object.fromEntries(
24
+ Object.entries(variables).map(([name, value]) => [
25
+ `AGENCY_${name.replaceAll(/([a-z])([A-Z])/g, "$1_$2").toUpperCase()}`,
26
+ value,
27
+ ]),
28
+ )
29
+
30
+ interface ResolveBranchNameInput {
31
+ readonly id: string
32
+ readonly taskId: string
33
+ readonly phaseId?: string
34
+ readonly ticketUrl?: string | null
35
+ readonly repo: string
36
+ readonly base: string
37
+ readonly defaultBranch: string
38
+ readonly startPath?: string
39
+ }
40
+
41
+ export const resolveBranchName = (input: ResolveBranchNameInput) =>
42
+ Effect.gen(function* () {
43
+ const fs = yield* FileSystemService
44
+ const workbase = yield* WorkbaseService
45
+ const { root, config } = yield* workbase.loadConfig(
46
+ input.startPath ?? process.cwd(),
47
+ )
48
+ if (!config.branchNameCommand) return input.defaultBranch
49
+
50
+ const variables: BranchNameVariables = {
51
+ id: input.id,
52
+ ticket: input.ticketUrl || input.id,
53
+ ticketUrl: input.ticketUrl || "",
54
+ repo: input.repo,
55
+ base: input.base,
56
+ workbaseRoot: root,
57
+ taskId: input.taskId,
58
+ phaseId: input.phaseId ?? "",
59
+ }
60
+ const command = expandBranchNameCommand(config.branchNameCommand, variables)
61
+ const result = yield* fs
62
+ .runCommand(command, {
63
+ cwd: root,
64
+ captureOutput: true,
65
+ env: commandEnvironment(variables),
66
+ timeoutMs: TIMEOUT_MS,
67
+ })
68
+ .pipe(
69
+ Effect.mapError(
70
+ (error) =>
71
+ new BranchNameCommandError({
72
+ message: `branchNameCommand could not run: ${error.cause instanceof Error ? error.cause.message : error.message}`,
73
+ command,
74
+ }),
75
+ ),
76
+ )
77
+ if (result.exitCode !== 0) {
78
+ return yield* Effect.fail(
79
+ new BranchNameCommandError({
80
+ message: `branchNameCommand failed with exit code ${result.exitCode}${result.stderr ? `: ${result.stderr}` : ""}`,
81
+ command,
82
+ exitCode: result.exitCode,
83
+ }),
84
+ )
85
+ }
86
+ const branch = result.stdout.trim()
87
+ if (!branch) {
88
+ return yield* Effect.fail(
89
+ new BranchNameCommandError({
90
+ message: "branchNameCommand produced an empty branch name",
91
+ command,
92
+ }),
93
+ )
94
+ }
95
+ const checked = yield* fs.runCommand(
96
+ ["git", "check-ref-format", "--branch", branch],
97
+ { cwd: root, captureOutput: true },
98
+ )
99
+ if (checked.exitCode !== 0) {
100
+ return yield* Effect.fail(
101
+ new BranchNameCommandError({
102
+ message: `branchNameCommand produced invalid Git branch name '${branch}'`,
103
+ command,
104
+ branch,
105
+ }),
106
+ )
107
+ }
108
+ return branch
109
+ })
@@ -0,0 +1,48 @@
1
+ export interface BranchNameVariables {
2
+ readonly id: string
3
+ readonly ticket: string
4
+ readonly ticketUrl: string
5
+ readonly repo: string
6
+ readonly base: string
7
+ readonly workbaseRoot: string
8
+ readonly taskId: string
9
+ readonly phaseId: string
10
+ }
11
+
12
+ const PLACEHOLDERS = new Set<keyof BranchNameVariables>([
13
+ "id",
14
+ "ticket",
15
+ "ticketUrl",
16
+ "repo",
17
+ "base",
18
+ "workbaseRoot",
19
+ "taskId",
20
+ "phaseId",
21
+ ])
22
+
23
+ export const validateBranchNameCommand = (command: readonly string[]) => {
24
+ for (const argument of command) {
25
+ for (const match of argument.matchAll(/\{([^{}]+)\}/g)) {
26
+ const placeholder = match[1]!
27
+ if (!PLACEHOLDERS.has(placeholder as keyof BranchNameVariables)) {
28
+ throw new Error(
29
+ `Unknown branchNameCommand placeholder: {${placeholder}}`,
30
+ )
31
+ }
32
+ }
33
+ }
34
+ }
35
+
36
+ export const expandBranchNameCommand = (
37
+ command: readonly string[],
38
+ variables: BranchNameVariables,
39
+ ): string[] => {
40
+ validateBranchNameCommand(command)
41
+ return command.map((argument) =>
42
+ argument.replaceAll(
43
+ /\{([^{}]+)\}/g,
44
+ (match, placeholder: string) =>
45
+ variables[placeholder as keyof BranchNameVariables] ?? match,
46
+ ),
47
+ )
48
+ }
@@ -15,6 +15,21 @@ const Admitted = Schema.Struct({
15
15
  }),
16
16
  })
17
17
 
18
+ // V1 stops config discovery at the Git root, so a checkout-rooted V1 launch
19
+ // would miss the workbase's managed OpenCode config and plugin.
20
+ export const openCodeDiscoversAncestorConfig = (cli: string) =>
21
+ Effect.gen(function* () {
22
+ const fs = yield* FileSystemService
23
+ const result = yield* fs.runCommand([cli, "--version"], {
24
+ captureOutput: true,
25
+ timeoutMs: 60_000,
26
+ })
27
+ return (
28
+ result.exitCode === 0 &&
29
+ /^(?:opencode\s+v?)?2\./.test(result.stdout.trim())
30
+ )
31
+ }).pipe(Effect.orElseSucceed(() => false))
32
+
18
33
  // V2's TUI --prompt only seeds the composer. Submit through the same CLI's
19
34
  // authenticated service connection, then attach without a prompt. Reloading or
20
35
  // reconnecting the TUI cannot replay the launch input. Only built-in Agency
@@ -27,6 +27,7 @@ const workerLaunchPattern = /^Agency worker launch target: ([^.\\s]+)\\./
27
27
  type AgencyContext = {
28
28
  root?: string
29
29
  checkout?: string
30
+ documentDirectory?: string
30
31
  target?: string
31
32
  task?: string
32
33
  phase?: string
@@ -139,9 +140,27 @@ const skillInfo = (path: string) => {
139
140
  }
140
141
  }
141
142
 
143
+ const implementationInstructions = (
144
+ client: "OpenCode" | "Pi",
145
+ context: AgencyContext | undefined,
146
+ directory: string,
147
+ ) => {
148
+ if (!context?.checkout) return
149
+ const checkout = resolve(context.checkout)
150
+ const current = resolve(directory)
151
+ const repository = current === checkout || current.startsWith(\`\${checkout}\${sep}\`)
152
+ ? \`\${client} is rooted in Agency's authoritative writable checkout at \${checkout}. Use this directory for source reads, edits, Git status and other Git operations, builds, tests, and formatting.\`
153
+ : \`Agency's authoritative writable checkout is \${checkout}. Before doing implementation work or Git operations, change the working directory to that checkout. Set each tool's working directory to it when supported; otherwise use absolute paths.\`
154
+ const lifecycle = context.documentDirectory
155
+ ? \`Run Agency lifecycle commands from \${context.documentDirectory}; agency context may also be queried from the checkout. Any reference checkouts reported by Agency context are read-only.\`
156
+ : "Any reference checkouts reported by Agency context are read-only."
157
+ return \`\${repository} \${lifecycle}\`
158
+ }
159
+
142
160
  const agencyContext = async (
143
161
  directory: string,
144
162
  useEnvironmentTarget = true,
163
+ requireWorking = false,
145
164
  ): Promise<AgencyContext | undefined> => {
146
165
  const task = useEnvironmentTarget ? process.env.AGENCY_TASK_ID : undefined
147
166
  const phase = useEnvironmentTarget ? process.env.AGENCY_PHASE_ID : undefined
@@ -161,18 +180,26 @@ const agencyContext = async (
161
180
  const result = envelope.result ?? {}
162
181
  const target = contextTarget(result)
163
182
  const document = result.target?.path
183
+ const checkout = result.authority?.writable?.checkoutPath
164
184
  const status = result.documents?.phase?.data?.status ?? result.documents?.task?.data?.status
185
+ const current = resolve(directory)
186
+ const documentDirectory = document ? dirname(document) : undefined
187
+ const matchesLocation =
188
+ documentDirectory === current ||
189
+ (checkout &&
190
+ (current === resolve(checkout) || current.startsWith(\`\${resolve(checkout)}\${sep}\`)))
165
191
  if (
166
192
  result.validation?.valid !== true ||
167
193
  !target ||
168
194
  !document ||
169
- dirname(document) !== resolve(directory) ||
170
- (target.startsWith("execution-unit:") &&
195
+ !matchesLocation ||
196
+ (requireWorking && target.startsWith("execution-unit:") &&
171
197
  (!result.authority?.writable?.checkoutPath || status !== "working"))
172
198
  ) return
173
199
  return {
174
200
  root: result.workbase?.root,
175
- checkout: result.authority?.writable?.checkoutPath,
201
+ checkout,
202
+ documentDirectory,
176
203
  target,
177
204
  task: result.target?.taskId,
178
205
  phase: result.target?.phaseId,
@@ -181,10 +208,11 @@ const agencyContext = async (
181
208
 
182
209
  const plugin: V1Plugin = async ({ directory }) => {
183
210
  const workerSessions = new Map<string, AgencyContext>()
211
+ const locationContext = agencyContext(directory, false).catch(() => undefined)
184
212
 
185
213
  return {
186
214
  config: async (config: any) => {
187
- const context = await agencyContext(directory).catch(() => undefined)
215
+ const context = await locationContext
188
216
  const root = process.env.AGENCY_WORKBASE ?? context?.root ?? discoverWorkbase(directory)
189
217
  const checkout = process.env.AGENCY_WRITABLE_CHECKOUT ?? context?.checkout ?? discoverCheckout(directory, root)
190
218
 
@@ -220,7 +248,7 @@ const plugin: V1Plugin = async ({ directory }) => {
220
248
  "chat.message": async ({ sessionID }: { sessionID: string }, output: any) => {
221
249
  const launchTarget = workerLaunchTarget(output.parts)
222
250
  if (!launchTarget) return
223
- const context = await agencyContext(directory, false).catch(() => undefined)
251
+ const context = await agencyContext(directory, false, true).catch(() => undefined)
224
252
  if (context?.target !== launchTarget) return
225
253
  workerSessions.set(sessionID, context)
226
254
  },
@@ -229,16 +257,16 @@ const plugin: V1Plugin = async ({ directory }) => {
229
257
  output: any,
230
258
  ) => {
231
259
  if (!sessionID) return
232
- const context = workerSessions.get(sessionID)
233
- if (!context?.target) return
234
- output.system.push(
235
- [
236
- \`Agency verified this OpenCode session as the active worker for \${context.target}. Perform the assigned work directly. Do not invoke agency work for this target or launch a replacement worker.\`,
237
- context.checkout
238
- ? \`OpenCode remains rooted in the task or phase directory for Agency instructions and context. Treat \${context.checkout} as the default implementation directory: use it for source reads, edits, repository status, builds, tests, formatting, and other repository-local commands. Set each tool's working directory to that checkout when supported; otherwise use absolute paths. Run Agency lifecycle and context commands from the task or phase directory. Any reference checkouts reported by Agency context are read-only.\`
239
- : undefined,
240
- ].filter(Boolean).join(" "),
241
- )
260
+ const worker = workerSessions.get(sessionID)
261
+ const context = worker ?? await locationContext
262
+ const implementation = implementationInstructions("OpenCode", context, directory)
263
+ if (!worker?.target && !implementation) return
264
+ output.system.push([
265
+ worker?.target
266
+ ? \`Agency verified this OpenCode session as the active worker for \${worker.target}. Perform the assigned work directly. Do not invoke agency work for this target or launch a replacement worker.\`
267
+ : undefined,
268
+ implementation,
269
+ ].filter(Boolean).join(" "))
242
270
  },
243
271
  "shell.env": async ({ sessionID }: { sessionID?: string }, output: any) => {
244
272
  if (!sessionID) return
@@ -259,7 +287,7 @@ export const AgencyPlugin = plugin
259
287
 
260
288
  const setup = async (context: V2PluginContext) => {
261
289
  const directory = context.location.directory
262
- const agency = await agencyContext(directory).catch(() => undefined)
290
+ const agency = await agencyContext(directory, false).catch(() => undefined)
263
291
  const root = agency?.root ?? resolve(dirname(fileURLToPath(import.meta.url)), "../..")
264
292
  const instructionsPath = join(root, ".agency", "AGENTS.md")
265
293
  const instructions = existsSync(instructionsPath)
@@ -302,23 +330,21 @@ const setup = async (context: V2PluginContext) => {
302
330
  await context.session.hook("prompt", async (event) => {
303
331
  const launchTarget = event.prompt.text.match(workerLaunchPattern)?.[1]
304
332
  if (!launchTarget) return
305
- const current = await agencyContext(directory, false).catch(() => undefined)
333
+ const current = await agencyContext(directory, false, true).catch(() => undefined)
306
334
  if (!current || current.target !== launchTarget) return
307
335
  workerSessions.set(event.sessionID, current)
308
336
  })
337
+ const implementation = implementationInstructions("OpenCode", agency, directory)
309
338
  await context.session.hook("context", (event) => {
310
339
  if (instructions) event.system.push({ type: "text", text: instructions })
311
340
  const current = workerSessions.get(event.sessionID)
312
- if (!current?.target) return
313
- event.system.push({
314
- type: "text",
315
- text: [
316
- \`Agency verified this OpenCode session as the active worker for \${current.target}. Perform the assigned work directly. Do not invoke agency work for this target or launch a replacement worker.\`,
317
- current.checkout
318
- ? \`OpenCode remains rooted in the task or phase directory for Agency instructions and context. Treat \${current.checkout} as the default implementation directory: use it for source reads, edits, repository status, builds, tests, formatting, and other repository-local commands. Set each tool's working directory to that checkout when supported; otherwise use absolute paths. Run Agency lifecycle and context commands from the task or phase directory. Any reference checkouts reported by Agency context are read-only.\`
319
- : undefined,
320
- ].filter(Boolean).join(" "),
321
- })
341
+ const text = [
342
+ current?.target
343
+ ? \`Agency verified this OpenCode session as the active worker for \${current.target}. Perform the assigned work directly. Do not invoke agency work for this target or launch a replacement worker.\`
344
+ : undefined,
345
+ implementationInstructions("OpenCode", current, directory) ?? implementation,
346
+ ].filter(Boolean).join(" ")
347
+ if (text) event.system.push({ type: "text", text })
322
348
  })
323
349
  }
324
350
 
@@ -107,6 +107,7 @@ export const WorkbaseConfig = Schema.Struct({
107
107
  Schema.Record({ key: RepositoryAlias, value: RepositoryDeclaration }),
108
108
  ),
109
109
  chooserCommand: Schema.optional(Schema.NonEmptyArray(NonEmptyString)),
110
+ branchNameCommand: Schema.optional(Schema.NonEmptyArray(NonEmptyString)),
110
111
  worktreeCreateCommand: Schema.optional(Schema.NonEmptyArray(NonEmptyString)),
111
112
  agents: Schema.optional(
112
113
  Schema.Record({