@markjaquith/agency 3.11.0 → 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
@@ -187,6 +187,48 @@ Existing version 2 workbases without `repositories` remain valid. Run
187
187
  unambiguous. Workbase configuration may also provide a custom writable-worktree
188
188
  creation command.
189
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
+
190
232
  ### Custom Worktree Command
191
233
 
192
234
  Git workbases create worktrees with Git. Set `worktreeCreateCommand` to an
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "3.11.0",
3
+ "version": "3.12.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",
@@ -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
@@ -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,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({