@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 +81 -2
- 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/DoctorService.ts +9 -0
- package/src/services/WorkbaseService.ts +23 -6
- package/src/services/WorktreeService.ts +196 -21
- 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 +2 -0
- package/src/workbase/worktree-command.ts +11 -7
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
|
|
188
|
-
creation
|
|
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
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",
|
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
:
|
|
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
|
-
|
|
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
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
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 =
|
|
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
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
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
|
|
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",
|
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,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
|
|
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
|
-
|
|
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
|
|
35
|
+
export const expandWorktreeCommand = (
|
|
36
|
+
setting: WorktreeCommandSetting,
|
|
33
37
|
command: readonly string[],
|
|
34
38
|
variables: WorktreeCommandVariables,
|
|
35
39
|
): string[] => {
|
|
36
|
-
|
|
40
|
+
validateWorktreeCommand(setting, command)
|
|
37
41
|
|
|
38
42
|
return command.map((argument) =>
|
|
39
43
|
argument.replaceAll(/\{([^{}]+)\}/g, (match, placeholder: string) => {
|