@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 +42 -0
- package/package.json +1 -1
- package/src/act-schema.ts +13 -0
- package/src/cli-parser.ts +2 -2
- package/src/commands/act.ts +18 -0
- package/src/commands/phase.ts +20 -10
- package/src/commands/task.ts +48 -11
- package/src/protocol.ts +6 -0
- package/src/services/WorkbaseService.ts +14 -0
- package/src/workbase/AGENTS.md +5 -2
- package/src/workbase/branch-name-command.ts +109 -0
- package/src/workbase/branch-name-template.ts +48 -0
- package/src/workbase/schemas.ts +1 -0
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
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> --
|
|
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> --
|
|
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: [
|
package/src/commands/act.ts
CHANGED
|
@@ -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)
|
package/src/commands/phase.ts
CHANGED
|
@@ -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> --
|
|
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
|
|
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
|
package/src/commands/task.ts
CHANGED
|
@@ -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 :
|
|
201
|
-
base: multiPhase ? undefined :
|
|
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
|
|
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
|
|
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
|
|
351
|
-
base
|
|
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
|
)) {
|
package/src/workbase/AGENTS.md
CHANGED
|
@@ -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> --
|
|
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> --
|
|
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
|
+
}
|
package/src/workbase/schemas.ts
CHANGED
|
@@ -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({
|