@markjaquith/agency 3.11.0 → 3.13.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
@@ -184,8 +184,50 @@ existing Git repositories.
184
184
  Existing version 2 workbases without `repositories` remain valid. Run
185
185
  `agency repo setup` to preview deterministic adoption of legacy local aliases;
186
186
  `agency repo setup --apply` writes declarations only when a portable origin is
187
- unambiguous. Workbase configuration may also provide a custom writable-worktree
188
- creation command.
187
+ unambiguous. Workbase configuration may also provide custom writable-worktree
188
+ creation and removal commands.
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.
189
231
 
190
232
  ### Custom Worktree Command
191
233
 
@@ -257,6 +299,43 @@ The configured command applies only to the writable checkout.
257
299
  Supplemental read-only repositories remain detached Git worktrees at their
258
300
  declared refs so they do not acquire writable branches.
259
301
 
302
+ ### Custom Worktree Remove Command
303
+
304
+ Git workbases remove worktrees with `git worktree remove`. Set
305
+ `worktreeRemoveCommand` to an argv template when another tool should remove
306
+ writable worktrees:
307
+
308
+ ```json
309
+ {
310
+ "version": 2,
311
+ "worktreeRemoveCommand": [
312
+ "wt",
313
+ "-C",
314
+ "{repo}",
315
+ "-y",
316
+ "remove",
317
+ "{worktree}",
318
+ "--no-delete-branch",
319
+ "--foreground",
320
+ "--format",
321
+ "json"
322
+ ]
323
+ }
324
+ ```
325
+
326
+ It accepts the same placeholders and environment variables as
327
+ `worktreeCreateCommand`, and `{repo}` and `{worktree}` are required. Agency
328
+ invokes it directly without a shell whenever it removes an existing writable
329
+ checkout, including `agency worktree remove`, rebuilds, archives, and rollback
330
+ of a failed materialization. Agency still refuses dirty or mismatched checkouts
331
+ before running the command.
332
+
333
+ The command must remove the checkout synchronously, unregister it from Git, and
334
+ preserve the execution branch; Agency verifies all three afterward. Rollback of
335
+ a newly created checkout may delete the branch because Agency discards it
336
+ anyway. Reference checkouts and stale registrations are still removed or pruned
337
+ with Git.
338
+
260
339
  ### Post-checkout Commands
261
340
 
262
341
  Each repository declaration may provide a `postCheckoutCommand` argv
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "3.11.0",
3
+ "version": "3.13.0",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
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
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",
@@ -172,6 +172,15 @@ export class DoctorService extends Effect.Service<DoctorService>()(
172
172
  ] as const,
173
173
  ]
174
174
  : []),
175
+ ...(config.worktreeRemoveCommand
176
+ ? [
177
+ [
178
+ "integration.worktree-remove",
179
+ config.worktreeRemoveCommand,
180
+ "Worktree remover",
181
+ ] as const,
182
+ ]
183
+ : []),
175
184
  ...Object.entries(config.repositories ?? {}).flatMap(
176
185
  ([alias, repository]) =>
177
186
  repository.postCheckoutCommand
@@ -20,8 +20,9 @@ import {
20
20
  type WorkbaseRegistry as WorkbaseRegistryData,
21
21
  type WorkbaseRegistration,
22
22
  } from "../workbase/schemas"
23
- import { validateWorktreeCreateCommand } from "../workbase/worktree-command"
23
+ import { validateWorktreeCommand } 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"
@@ -314,18 +315,34 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
314
315
  message: `Invalid workbase configuration in ${configPath}:\n${decoded.error}`,
315
316
  })
316
317
  }
317
- if (decoded.value.worktreeCreateCommand) {
318
+ for (const setting of [
319
+ "worktreeCreateCommand",
320
+ "worktreeRemoveCommand",
321
+ ] as const) {
322
+ const command = decoded.value[setting]
323
+ if (!command) continue
318
324
  try {
319
- validateWorktreeCreateCommand(
320
- decoded.value.worktreeCreateCommand,
321
- )
325
+ validateWorktreeCommand(setting, command)
322
326
  } catch (cause) {
323
327
  return yield* new WorkbaseConfigError({
324
328
  path: configPath,
325
329
  message:
326
330
  cause instanceof Error
327
331
  ? cause.message
328
- : "Invalid worktreeCreateCommand",
332
+ : `Invalid ${setting}`,
333
+ })
334
+ }
335
+ }
336
+ if (decoded.value.branchNameCommand) {
337
+ try {
338
+ validateBranchNameCommand(decoded.value.branchNameCommand)
339
+ } catch (cause) {
340
+ return yield* new WorkbaseConfigError({
341
+ path: configPath,
342
+ message:
343
+ cause instanceof Error
344
+ ? cause.message
345
+ : "Invalid branchNameCommand",
329
346
  })
330
347
  }
331
348
  }
@@ -5,7 +5,7 @@ import { WorkbaseService } from "./WorkbaseService"
5
5
  import { TaskService } from "./TaskService"
6
6
  import { PhaseService } from "./PhaseService"
7
7
  import {
8
- expandWorktreeCreateCommand,
8
+ expandWorktreeCommand,
9
9
  worktreeCommandEnvironment,
10
10
  } from "../workbase/worktree-command"
11
11
  import {
@@ -258,6 +258,105 @@ const runPostCheckoutHook = (options: {
258
258
  })
259
259
  })
260
260
 
261
+ const runWorktreeRemoveCommand = (options: {
262
+ readonly command: readonly string[]
263
+ readonly alias: string
264
+ readonly repositoryPath: string
265
+ readonly checkoutPath: string
266
+ readonly branch: string
267
+ readonly base: string
268
+ readonly preserveBranch: boolean
269
+ readonly forwardOutput: boolean
270
+ readonly verboseLog: (...args: unknown[]) => void
271
+ }) =>
272
+ Effect.gen(function* () {
273
+ const fs = yield* FileSystemService
274
+ const variables = {
275
+ repo: options.repositoryPath,
276
+ worktree: options.checkoutPath,
277
+ branch: options.branch,
278
+ base: options.base,
279
+ }
280
+ let args: string[]
281
+ try {
282
+ args = expandWorktreeCommand(
283
+ "worktreeRemoveCommand",
284
+ options.command,
285
+ variables,
286
+ )
287
+ } catch (cause) {
288
+ return yield* new WorktreeError({
289
+ message:
290
+ cause instanceof Error
291
+ ? cause.message
292
+ : "Invalid worktreeRemoveCommand",
293
+ })
294
+ }
295
+ const canonicalCheckoutPath = yield* fs.realPath(options.checkoutPath)
296
+ options.verboseLog(
297
+ `Running worktree remove command: ${formatCommand(args)}`,
298
+ )
299
+ const result = yield* fs.runCommand(args, {
300
+ cwd: options.repositoryPath,
301
+ captureOutput: true,
302
+ forwardOutput: options.forwardOutput,
303
+ env: worktreeCommandEnvironment(variables),
304
+ })
305
+ if (result.exitCode !== 0) {
306
+ return yield* new WorktreeError({
307
+ message: `Failed to remove worktree for '${options.alias}': ${result.stderr}`,
308
+ })
309
+ }
310
+ if (yield* fs.exists(options.checkoutPath)) {
311
+ return yield* new WorktreeError({
312
+ message: `Worktree remove command did not remove ${options.checkoutPath}`,
313
+ })
314
+ }
315
+ const listed = yield* fs.runCommand(
316
+ [
317
+ "git",
318
+ "-C",
319
+ options.repositoryPath,
320
+ "worktree",
321
+ "list",
322
+ "--porcelain",
323
+ "-z",
324
+ ],
325
+ { captureOutput: true },
326
+ )
327
+ if (
328
+ listed.exitCode !== 0 ||
329
+ parseWorktreeList(listed.stdout).some(
330
+ (worktree) =>
331
+ resolve(worktree.path) === canonicalCheckoutPath ||
332
+ resolve(worktree.path) === resolve(options.checkoutPath),
333
+ )
334
+ ) {
335
+ return yield* new WorktreeError({
336
+ message: `Worktree remove command left ${options.checkoutPath} registered as a Git worktree`,
337
+ })
338
+ }
339
+ if (options.preserveBranch) {
340
+ const branchExists = yield* fs.runCommand(
341
+ [
342
+ "git",
343
+ "-C",
344
+ options.repositoryPath,
345
+ "show-ref",
346
+ "--verify",
347
+ "--quiet",
348
+ `refs/heads/${options.branch}`,
349
+ ],
350
+ { captureOutput: true },
351
+ )
352
+ if (branchExists.exitCode !== 0) {
353
+ return yield* new WorktreeError({
354
+ message: `Worktree remove command deleted branch '${options.branch}' in repository '${options.alias}'; it must preserve the branch`,
355
+ })
356
+ }
357
+ }
358
+ })
359
+
261
360
  const isCommitId = (ref: string) => /^[0-9a-f]{40,64}$/i.test(ref)
262
361
 
263
362
  const originRef = (ref: string) =>
@@ -1083,12 +1182,16 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
1083
1182
  }
1084
1183
  if (config.worktreeCreateCommand) {
1085
1184
  try {
1086
- expandWorktreeCreateCommand(config.worktreeCreateCommand, {
1087
- repo: repositoryPath,
1088
- worktree: checkoutPath,
1089
- branch: checkout.branch,
1090
- base: executionBase,
1091
- })
1185
+ expandWorktreeCommand(
1186
+ "worktreeCreateCommand",
1187
+ config.worktreeCreateCommand,
1188
+ {
1189
+ repo: repositoryPath,
1190
+ worktree: checkoutPath,
1191
+ branch: checkout.branch,
1192
+ base: executionBase,
1193
+ },
1194
+ )
1092
1195
  } catch (cause) {
1093
1196
  return yield* new WorktreeError({
1094
1197
  message:
@@ -1364,7 +1467,8 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
1364
1467
  base: executionBase,
1365
1468
  }
1366
1469
  try {
1367
- args = expandWorktreeCreateCommand(
1470
+ args = expandWorktreeCommand(
1471
+ "worktreeCreateCommand",
1368
1472
  config.worktreeCreateCommand,
1369
1473
  variables,
1370
1474
  )
@@ -1781,18 +1885,37 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
1781
1885
  !(yield* fs.isDirectory(checkoutPath))
1782
1886
  )
1783
1887
  continue
1784
- const removed = yield* fs.runCommand(
1785
- [
1786
- "git",
1787
- "-C",
1788
- join(root, "repos", checkout.repo),
1789
- "worktree",
1790
- "remove",
1791
- "--force",
1792
- checkoutPath,
1793
- ],
1794
- { captureOutput: true },
1795
- )
1888
+ const repositoryPath = join(root, "repos", checkout.repo)
1889
+ const removed =
1890
+ config.worktreeRemoveCommand && "branch" in checkout
1891
+ ? yield* runWorktreeRemoveCommand({
1892
+ command: config.worktreeRemoveCommand,
1893
+ alias: checkout.repo,
1894
+ repositoryPath,
1895
+ checkoutPath,
1896
+ branch: checkout.branch,
1897
+ base: executionBase,
1898
+ preserveBranch: false,
1899
+ forwardOutput: forwardCommandOutput,
1900
+ verboseLog,
1901
+ }).pipe(
1902
+ Effect.as({ exitCode: 0 }),
1903
+ Effect.catchAll(() =>
1904
+ Effect.succeed({ exitCode: 1 }),
1905
+ ),
1906
+ )
1907
+ : yield* fs.runCommand(
1908
+ [
1909
+ "git",
1910
+ "-C",
1911
+ repositoryPath,
1912
+ "worktree",
1913
+ "remove",
1914
+ "--force",
1915
+ checkoutPath,
1916
+ ],
1917
+ { captureOutput: true },
1918
+ )
1796
1919
  if (removed.exitCode === 0)
1797
1920
  rolledBack.push(`create-worktree ${checkout.repo}`)
1798
1921
  else manualRecovery.push(`Remove ${checkoutPath}`)
@@ -1821,6 +1944,24 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
1821
1944
  ]),
1822
1945
  ).values()
1823
1946
  for (const branch of branchCandidates) {
1947
+ if (config.worktreeRemoveCommand) {
1948
+ const branchExists = yield* fs.runCommand(
1949
+ [
1950
+ "git",
1951
+ "-C",
1952
+ join(root, "repos", branch.repo),
1953
+ "show-ref",
1954
+ "--verify",
1955
+ "--quiet",
1956
+ `refs/heads/${branch.branch}`,
1957
+ ],
1958
+ { captureOutput: true },
1959
+ )
1960
+ if (branchExists.exitCode !== 0) {
1961
+ rolledBack.push(`create-branch ${branch.repo}`)
1962
+ continue
1963
+ }
1964
+ }
1824
1965
  const deleted = yield* fs.runCommand(
1825
1966
  [
1826
1967
  "git",
@@ -1881,7 +2022,10 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
1881
2022
  const workbase = yield* WorkbaseService
1882
2023
  const tasks = yield* TaskService
1883
2024
  const phases = yield* PhaseService
1884
- const root = yield* workbase.discover(startPath)
2025
+ const { verboseLog } = createLoggers(options)
2026
+ const forwardCommandOutput =
2027
+ options.verbose === true && !options.silent && !options.json
2028
+ const { root, config } = yield* workbase.loadConfig(startPath)
1885
2029
  const removal = Effect.gen(function* () {
1886
2030
  const inspection = yield* inspectExecution(taskId, phaseId, root)
1887
2031
  const blockingConflicts = inspection.conflicts.filter(
@@ -1907,6 +2051,7 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
1907
2051
  repo: string
1908
2052
  repos?: readonly RepositoryReference[]
1909
2053
  branch: string
2054
+ base: string
1910
2055
  }
1911
2056
  | { review: { repo: string; commit: string } }
1912
2057
  let codePath: string
@@ -1959,6 +2104,7 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
1959
2104
  checkoutPath: string
1960
2105
  registeredPath: string
1961
2106
  checkoutExists: boolean
2107
+ writable: boolean
1962
2108
  head?: string
1963
2109
  branch?: string
1964
2110
  }[] = []
@@ -2115,6 +2261,7 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
2115
2261
  checkoutPath,
2116
2262
  registeredPath: registered.path,
2117
2263
  checkoutExists,
2264
+ writable: "branch" in checkout,
2118
2265
  head: registered.head,
2119
2266
  branch: registered.branch?.replace(/^refs\/heads\//, ""),
2120
2267
  })
@@ -2188,6 +2335,34 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
2188
2335
  })
2189
2336
  const removed = yield* Effect.gen(function* () {
2190
2337
  for (const plan of removalPlans) {
2338
+ if (
2339
+ config.worktreeRemoveCommand &&
2340
+ plan.writable &&
2341
+ plan.checkoutExists &&
2342
+ plan.branch &&
2343
+ !("review" in execution)
2344
+ ) {
2345
+ yield* runWorktreeRemoveCommand({
2346
+ command: config.worktreeRemoveCommand,
2347
+ alias: plan.alias,
2348
+ repositoryPath: plan.repositoryPath,
2349
+ checkoutPath: plan.checkoutPath,
2350
+ branch: plan.branch,
2351
+ base: execution.base,
2352
+ preserveBranch: true,
2353
+ forwardOutput: forwardCommandOutput,
2354
+ verboseLog,
2355
+ }).pipe(
2356
+ Effect.tapError(() =>
2357
+ Effect.gen(function* () {
2358
+ if (!(yield* fs.exists(plan.checkoutPath)))
2359
+ completed.push(plan)
2360
+ }),
2361
+ ),
2362
+ )
2363
+ completed.push(plan)
2364
+ continue
2365
+ }
2191
2366
  const command = plan.checkoutExists
2192
2367
  ? [
2193
2368
  "git",
@@ -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
@@ -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
+ }
@@ -107,7 +107,9 @@ 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)),
112
+ worktreeRemoveCommand: Schema.optional(Schema.NonEmptyArray(NonEmptyString)),
111
113
  agents: Schema.optional(
112
114
  Schema.Record({
113
115
  key: EntityId,
@@ -5,15 +5,20 @@ interface WorktreeCommandVariables {
5
5
  readonly base: string
6
6
  }
7
7
 
8
+ type WorktreeCommandSetting = "worktreeCreateCommand" | "worktreeRemoveCommand"
9
+
8
10
  const REQUIRED_PLACEHOLDERS = ["repo", "worktree"] as const
9
11
  const PLACEHOLDERS = new Set(["repo", "worktree", "branch", "base"])
10
12
 
11
- export const validateWorktreeCreateCommand = (command: readonly string[]) => {
13
+ export const validateWorktreeCommand = (
14
+ setting: WorktreeCommandSetting,
15
+ command: readonly string[],
16
+ ) => {
12
17
  const template = command.join("\u0000")
13
18
  for (const placeholder of REQUIRED_PLACEHOLDERS) {
14
19
  if (!template.includes(`{${placeholder}}`)) {
15
20
  throw new Error(
16
- `worktreeCreateCommand must include the {${placeholder}} placeholder`,
21
+ `${setting} must include the {${placeholder}} placeholder`,
17
22
  )
18
23
  }
19
24
  }
@@ -21,19 +26,18 @@ export const validateWorktreeCreateCommand = (command: readonly string[]) => {
21
26
  for (const match of argument.matchAll(/\{([^{}]+)\}/g)) {
22
27
  const placeholder = match[1]!
23
28
  if (!PLACEHOLDERS.has(placeholder)) {
24
- throw new Error(
25
- `Unknown worktreeCreateCommand placeholder: {${placeholder}}`,
26
- )
29
+ throw new Error(`Unknown ${setting} placeholder: {${placeholder}}`)
27
30
  }
28
31
  }
29
32
  }
30
33
  }
31
34
 
32
- export const expandWorktreeCreateCommand = (
35
+ export const expandWorktreeCommand = (
36
+ setting: WorktreeCommandSetting,
33
37
  command: readonly string[],
34
38
  variables: WorktreeCommandVariables,
35
39
  ): string[] => {
36
- validateWorktreeCreateCommand(command)
40
+ validateWorktreeCommand(setting, command)
37
41
 
38
42
  return command.map((argument) =>
39
43
  argument.replaceAll(/\{([^{}]+)\}/g, (match, placeholder: string) => {