@markjaquith/agency 2.65.1 → 2.66.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 +26 -12
- package/cli-main.ts +7 -1
- package/package.json +1 -1
- package/src/cli-parser.test.ts +31 -1
- package/src/cli-parser.ts +22 -7
- package/src/commands/context.test.ts +67 -0
- package/src/commands/pr.test.ts +13 -1
- package/src/commands/pr.ts +17 -3
- package/src/commands/push.test.ts +55 -0
- package/src/commands/push.ts +29 -2
- package/src/commands/sync.test.ts +35 -0
- package/src/commands/sync.ts +7 -1
- package/src/commands/work.test.ts +34 -7
- package/src/commands/work.ts +13 -6
- package/src/services/ContextService.ts +12 -8
- package/src/services/PullRequestService.test.ts +47 -1
- package/src/services/PullRequestService.ts +26 -0
- package/src/services/PushService.test.ts +38 -2
- package/src/services/PushService.ts +21 -2
- package/src/services/RepositoryService.ts +17 -3
- package/src/services/SyncService.ts +59 -27
- package/src/workbase/delivery-command.test.ts +49 -0
- package/src/workbase/delivery-command.ts +13 -3
- package/src/workbase/runner-command.test.ts +12 -0
- package/src/workbase/runner-command.ts +6 -0
package/README.md
CHANGED
|
@@ -347,8 +347,10 @@ external effects the failed invocation may have completed.
|
|
|
347
347
|
|
|
348
348
|
### Agent Runners
|
|
349
349
|
|
|
350
|
-
OpenCode and Claude Code are built-in runner presets.
|
|
351
|
-
|
|
350
|
+
OpenCode (`opencode2` and `opencode`) and Claude Code are built-in runner presets.
|
|
351
|
+
Without an explicit runner, Agency uses the first available executable in this
|
|
352
|
+
order: `opencode2`, `opencode`, then `claude`. Select a preset or configured
|
|
353
|
+
runner with `agency work --runner <name>`. A launch is fresh unless
|
|
352
354
|
`AGENCY_SESSION_ID` is already set; resumed launches use the runner's
|
|
353
355
|
`resumeCommand` when configured. The built-in presets use `--continue` only for
|
|
354
356
|
resumed launches. By default Agency opens the runner without a prompt. `--auto`
|
|
@@ -399,8 +401,9 @@ to the receiving OpenCode session, injects an explicit active-worker system
|
|
|
399
401
|
instruction, and restores Agency identity for that session's shell environment.
|
|
400
402
|
This session bridge is necessary because an OpenCode client can attach to a
|
|
401
403
|
long-lived server process that did not inherit the client's launch environment.
|
|
402
|
-
The `opencode`
|
|
403
|
-
the workbase `AGENTS.md` and managed OpenCode config are
|
|
404
|
+
The `opencode2` and `opencode` runners remain rooted in their task or epic
|
|
405
|
+
working directory so the workbase `AGENTS.md` and managed OpenCode config are
|
|
406
|
+
discovered normally.
|
|
404
407
|
Agency's managed OpenCode plugin grants the active workbase external-directory
|
|
405
408
|
access and adds existing checkout-local `.claude/skills`, `.agents/skills`, and
|
|
406
409
|
`.opencode/{skill,skills}` directories to `skills.paths`.
|
|
@@ -650,8 +653,8 @@ detailed dependency, validation, or status blockers for orchestrators.
|
|
|
650
653
|
|
|
651
654
|
`agency work` consults this shared readiness model before materializing. Blocked,
|
|
652
655
|
done, and dropped targets are rejected unless `--force` is supplied explicitly.
|
|
653
|
-
`agency pr
|
|
654
|
-
|
|
656
|
+
Task-aware `agency pr create` applies Agency readiness and validation checks;
|
|
657
|
+
untargeted `agency pr` invocations leave command semantics to `gh`.
|
|
655
658
|
|
|
656
659
|
### Reconciliation
|
|
657
660
|
|
|
@@ -663,6 +666,10 @@ structured `changes`, `warnings`, `unresolved`, and per-execution evidence. The
|
|
|
663
666
|
default mode applies safe reconciliation transitions; `--dry-run` is explicitly
|
|
664
667
|
observational.
|
|
665
668
|
|
|
669
|
+
Pass `<task-id>` to scope reconciliation to one task and its repositories. A
|
|
670
|
+
multi-phase task scope includes all of its phases; add `[phase-id]` to select one
|
|
671
|
+
phase. Scoped sync does not query, materialize, or reconcile unrelated work.
|
|
672
|
+
|
|
666
673
|
`agency sync` performs only these safe transitions:
|
|
667
674
|
|
|
668
675
|
- materialize declared but missing repositories from their canonical remotes;
|
|
@@ -1050,7 +1057,7 @@ agency work [<directory> | --epic <epic-id>] [--runner <name>] [--auto] [--print
|
|
|
1050
1057
|
agency work prepare [target] [--evidence <json-or-path>] [--dry-run] [--json]
|
|
1051
1058
|
agency worktree <list|inspect|prepare|remove|rebuild|repair>
|
|
1052
1059
|
agency push [--json]
|
|
1053
|
-
agency pr create <task-id> [phase-id] [--draft] [--force] [--json]
|
|
1060
|
+
agency pr create <task-id> [phase-id] [--draft] [--title <title>] [--head <branch>] [--base <branch>] [--label <label>] [--force] [--json]
|
|
1054
1061
|
agency pr [args...]
|
|
1055
1062
|
```
|
|
1056
1063
|
|
|
@@ -1059,10 +1066,10 @@ configured external chooser. Pass a directory, including `.` for the current
|
|
|
1059
1066
|
directory, to infer its epic, task, or phase. Outside a workbase, Agency first
|
|
1060
1067
|
presents the registered workbases, then the selected workbase's hierarchy.
|
|
1061
1068
|
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
their built-in presets. Launches are interactive and
|
|
1065
|
-
`--auto` to send Agency's generated context prompt.
|
|
1069
|
+
Agency automatically uses the first available runner in this order: `opencode2`,
|
|
1070
|
+
`opencode`, then `claude`. `--opencode` and `--claude` remain aliases for
|
|
1071
|
+
requiring their corresponding built-in presets. Launches are interactive and
|
|
1072
|
+
promptless by default; use `--auto` to send Agency's generated context prompt.
|
|
1066
1073
|
|
|
1067
1074
|
`agency work prepare` resolves an execution unit and creates or reuses its
|
|
1068
1075
|
writable and reference worktrees, or its single pinned review checkout, without
|
|
@@ -1137,9 +1144,16 @@ post-commit working copy, in which case it publishes `@-`; described empty
|
|
|
1137
1144
|
changes remain intentional publication tips. Missing descriptions or authors
|
|
1138
1145
|
stop with exact change IDs and remediation commands. Agency creates or safely
|
|
1139
1146
|
advances only the declared bookmark and never invents a `push-*` bookmark.
|
|
1147
|
+
If the fetched remote base advanced outside the local stack, Agency prints the
|
|
1148
|
+
exact `jj rebase` command needed to move the stack onto `<base>@<remote>`. Push
|
|
1149
|
+
reports deterministic fetch, inspection, validation, and publication progress
|
|
1150
|
+
on stderr, including while `--json` reserves stdout for one machine result.
|
|
1140
1151
|
|
|
1141
1152
|
Task-aware `agency pr create <task-id> [phase-id]` uses Agency's delivery flow,
|
|
1142
|
-
including readiness checks and durable PR recording.
|
|
1153
|
+
including readiness checks and durable PR recording. It accepts draft, title,
|
|
1154
|
+
declared head/base confirmation, and repeatable label options; a contradicting
|
|
1155
|
+
head or base is rejected rather than recording inconsistent delivery metadata.
|
|
1156
|
+
Other `agency pr`
|
|
1143
1157
|
invocations forward every argument to `gh pr`. From an execution task or phase
|
|
1144
1158
|
directory, including descendants, passthrough runs in that execution unit's
|
|
1145
1159
|
authoritative writable checkout. Otherwise it runs in the caller's current
|
package/cli-main.ts
CHANGED
|
@@ -322,6 +322,10 @@ const commands: Record<string, Command> = {
|
|
|
322
322
|
phaseId: args[2],
|
|
323
323
|
draft: options.draft,
|
|
324
324
|
force: options.force,
|
|
325
|
+
title: options.title,
|
|
326
|
+
head: options.head,
|
|
327
|
+
base: options.base,
|
|
328
|
+
labels: options.label,
|
|
325
329
|
json: options.json,
|
|
326
330
|
silent: options.silent,
|
|
327
331
|
verbose: options.verbose,
|
|
@@ -750,13 +754,15 @@ const commands: Record<string, Command> = {
|
|
|
750
754
|
},
|
|
751
755
|
},
|
|
752
756
|
sync: {
|
|
753
|
-
run: async (
|
|
757
|
+
run: async (args: string[], options: Record<string, any>) => {
|
|
754
758
|
if (options.help) {
|
|
755
759
|
console.log(syncHelp)
|
|
756
760
|
return
|
|
757
761
|
}
|
|
758
762
|
await runCommand(
|
|
759
763
|
sync({
|
|
764
|
+
taskId: args[0],
|
|
765
|
+
phaseId: args[1],
|
|
760
766
|
dryRun: options["dry-run"],
|
|
761
767
|
json: options.json,
|
|
762
768
|
silent: options.silent,
|
package/package.json
CHANGED
package/src/cli-parser.test.ts
CHANGED
|
@@ -334,7 +334,7 @@ describe("strict CLI parsing", () => {
|
|
|
334
334
|
[["context", "one", "two"], "agency context"],
|
|
335
335
|
[["graph", "extra"], "agency graph"],
|
|
336
336
|
[["next", "extra"], "agency next"],
|
|
337
|
-
[["sync", "
|
|
337
|
+
[["sync", "one", "two", "three"], "agency sync"],
|
|
338
338
|
[
|
|
339
339
|
[
|
|
340
340
|
"claim",
|
|
@@ -430,6 +430,31 @@ describe("strict CLI parsing", () => {
|
|
|
430
430
|
args: ["create", "ship"],
|
|
431
431
|
})
|
|
432
432
|
expect(selected.passthrough).toBeUndefined()
|
|
433
|
+
expect(
|
|
434
|
+
parseCli([
|
|
435
|
+
"pr",
|
|
436
|
+
"create",
|
|
437
|
+
"ship",
|
|
438
|
+
"--title",
|
|
439
|
+
"Ship it",
|
|
440
|
+
"--head",
|
|
441
|
+
"task/ship",
|
|
442
|
+
"--base",
|
|
443
|
+
"main",
|
|
444
|
+
"--label",
|
|
445
|
+
"ai-assisted",
|
|
446
|
+
"--label",
|
|
447
|
+
"product-area:platform",
|
|
448
|
+
]),
|
|
449
|
+
).toMatchObject({
|
|
450
|
+
args: ["create", "ship"],
|
|
451
|
+
values: {
|
|
452
|
+
title: "Ship it",
|
|
453
|
+
head: "task/ship",
|
|
454
|
+
base: "main",
|
|
455
|
+
label: ["ai-assisted", "product-area:platform"],
|
|
456
|
+
},
|
|
457
|
+
})
|
|
433
458
|
})
|
|
434
459
|
|
|
435
460
|
test("parses explicit dry-run reconciliation", () => {
|
|
@@ -441,6 +466,11 @@ describe("strict CLI parsing", () => {
|
|
|
441
466
|
commandName: "sync",
|
|
442
467
|
values: { "dry-run": true, json: true },
|
|
443
468
|
})
|
|
469
|
+
expect(parseCli(["sync", "ship", "release", "--json"])).toMatchObject({
|
|
470
|
+
commandName: "sync",
|
|
471
|
+
args: ["ship", "release"],
|
|
472
|
+
values: { json: true },
|
|
473
|
+
})
|
|
444
474
|
expect(() => parseCli(["sync", "--apply"])).toThrow("Unknown option")
|
|
445
475
|
})
|
|
446
476
|
|
package/src/cli-parser.ts
CHANGED
|
@@ -732,16 +732,17 @@ const commands = {
|
|
|
732
732
|
},
|
|
733
733
|
},
|
|
734
734
|
sync: {
|
|
735
|
-
usage: "agency sync [--dry-run] [--json]",
|
|
735
|
+
usage: "agency sync [<task-id> [phase-id]] [--dry-run] [--json]",
|
|
736
736
|
options: {
|
|
737
737
|
...outputOptions,
|
|
738
|
+
...entitySelectorOptions,
|
|
738
739
|
"dry-run": { type: "boolean" },
|
|
739
740
|
},
|
|
740
741
|
command: {
|
|
741
|
-
usage: "agency sync [--dry-run] [--json]",
|
|
742
|
+
usage: "agency sync [<task-id> [phase-id]] [--dry-run] [--json]",
|
|
742
743
|
minArgs: 0,
|
|
743
|
-
maxArgs:
|
|
744
|
-
options: ["dry-run", "json"],
|
|
744
|
+
maxArgs: 2,
|
|
745
|
+
options: ["dry-run", "json", "task", "phase"],
|
|
745
746
|
},
|
|
746
747
|
},
|
|
747
748
|
archive: {
|
|
@@ -964,14 +965,28 @@ const commands = {
|
|
|
964
965
|
force: { type: "boolean" },
|
|
965
966
|
task: { type: "string" },
|
|
966
967
|
phase: { type: "string" },
|
|
968
|
+
title: { type: "string" },
|
|
969
|
+
head: { type: "string" },
|
|
970
|
+
base: { type: "string" },
|
|
971
|
+
label: { type: "string", multiple: true },
|
|
967
972
|
},
|
|
968
973
|
subcommands: {
|
|
969
974
|
create: {
|
|
970
|
-
usage:
|
|
971
|
-
"agency pr create <task-id> [phase-id] [--draft] [--force] [--json]",
|
|
975
|
+
usage: "agency pr create <task-id> [phase-id] [options]",
|
|
972
976
|
minArgs: 1,
|
|
973
977
|
maxArgs: 2,
|
|
974
|
-
options: [
|
|
978
|
+
options: [
|
|
979
|
+
"draft",
|
|
980
|
+
"force",
|
|
981
|
+
"json",
|
|
982
|
+
"task",
|
|
983
|
+
"phase",
|
|
984
|
+
"title",
|
|
985
|
+
"head",
|
|
986
|
+
"base",
|
|
987
|
+
"label",
|
|
988
|
+
],
|
|
989
|
+
repeatable: ["label"],
|
|
975
990
|
},
|
|
976
991
|
},
|
|
977
992
|
},
|
|
@@ -219,6 +219,73 @@ Phase prose.
|
|
|
219
219
|
)
|
|
220
220
|
})
|
|
221
221
|
|
|
222
|
+
test("reports the current jj change without inventing a missing bookmark", async () => {
|
|
223
|
+
if (!Bun.which("jj")) return
|
|
224
|
+
await cleanupTempDir(root)
|
|
225
|
+
root = await createTempDir()
|
|
226
|
+
const remote = join(root, "remote.git")
|
|
227
|
+
const seed = join(root, "seed")
|
|
228
|
+
const repository = join(root, "repos/agency")
|
|
229
|
+
const checkout = join(root, "tasks/example/code/agency")
|
|
230
|
+
await mkdir(join(root, "repos"), { recursive: true })
|
|
231
|
+
await write(root, "agency.json", '{"version":2,"vcs":"jj"}\n')
|
|
232
|
+
await run(root, ["git", "init", "--bare", "--initial-branch=main", remote])
|
|
233
|
+
await run(root, ["git", "init", "--initial-branch=main", seed])
|
|
234
|
+
await run(seed, ["git", "config", "user.email", "test@example.com"])
|
|
235
|
+
await run(seed, ["git", "config", "user.name", "Test"])
|
|
236
|
+
await Bun.write(join(seed, "README.md"), "example\n")
|
|
237
|
+
await run(seed, ["git", "add", "README.md"])
|
|
238
|
+
await run(seed, ["git", "commit", "-m", "initial"])
|
|
239
|
+
await run(seed, ["git", "remote", "add", "origin", remote])
|
|
240
|
+
await run(seed, ["git", "push", "origin", "main"])
|
|
241
|
+
await run(root, ["jj", "git", "clone", "--no-colocate", remote, repository])
|
|
242
|
+
await write(
|
|
243
|
+
root,
|
|
244
|
+
"tasks/example/TASK.md",
|
|
245
|
+
`---
|
|
246
|
+
ticketUrl: null
|
|
247
|
+
repo: agency
|
|
248
|
+
branch: task/example
|
|
249
|
+
base: main
|
|
250
|
+
pr: null
|
|
251
|
+
status: working
|
|
252
|
+
---
|
|
253
|
+
|
|
254
|
+
# Example
|
|
255
|
+
`,
|
|
256
|
+
)
|
|
257
|
+
await mkdir(dirname(checkout), { recursive: true })
|
|
258
|
+
await run(repository, [
|
|
259
|
+
"jj",
|
|
260
|
+
"workspace",
|
|
261
|
+
"add",
|
|
262
|
+
"--name",
|
|
263
|
+
"task-example",
|
|
264
|
+
"-r",
|
|
265
|
+
"main",
|
|
266
|
+
checkout,
|
|
267
|
+
])
|
|
268
|
+
await Bun.write(join(checkout, "feature.txt"), "current change\n")
|
|
269
|
+
const current = Bun.spawn(
|
|
270
|
+
["jj", "log", "--no-graph", "-r", "@", "-T", "commit_id"],
|
|
271
|
+
{ cwd: checkout, stdout: "pipe", stderr: "pipe" },
|
|
272
|
+
)
|
|
273
|
+
const currentCommit = (await new Response(current.stdout).text()).trim()
|
|
274
|
+
expect(await current.exited).toBe(0)
|
|
275
|
+
|
|
276
|
+
const result = await readContext(root, "tasks/example")
|
|
277
|
+
expect(result.workspace.writable).toMatchObject({
|
|
278
|
+
branchCommit: null,
|
|
279
|
+
checkoutCommit: currentCommit,
|
|
280
|
+
checkoutBranch: null,
|
|
281
|
+
detached: true,
|
|
282
|
+
dirty: false,
|
|
283
|
+
})
|
|
284
|
+
expect(result.workspace.warnings).toContain(
|
|
285
|
+
`Unable to resolve branch 'task/example' in ${repository}`,
|
|
286
|
+
)
|
|
287
|
+
})
|
|
288
|
+
|
|
222
289
|
test("makes compact projection explicit without omitting essential identity", async () => {
|
|
223
290
|
const result = await readContext(
|
|
224
291
|
root,
|
package/src/commands/pr.test.ts
CHANGED
|
@@ -158,6 +158,10 @@ status: open
|
|
|
158
158
|
phaseId: "implementation",
|
|
159
159
|
draft: true,
|
|
160
160
|
force: true,
|
|
161
|
+
title: "Ship it",
|
|
162
|
+
head: "task/example",
|
|
163
|
+
base: "main",
|
|
164
|
+
labels: ["ai-assisted"],
|
|
161
165
|
cwd: "/workbase",
|
|
162
166
|
json: true,
|
|
163
167
|
}).pipe(
|
|
@@ -177,7 +181,15 @@ status: open
|
|
|
177
181
|
"implementation",
|
|
178
182
|
true,
|
|
179
183
|
"/workbase",
|
|
180
|
-
expect.objectContaining({
|
|
184
|
+
expect.objectContaining({
|
|
185
|
+
force: true,
|
|
186
|
+
draft: true,
|
|
187
|
+
title: "Ship it",
|
|
188
|
+
head: "task/example",
|
|
189
|
+
base: "main",
|
|
190
|
+
labels: ["ai-assisted"],
|
|
191
|
+
json: true,
|
|
192
|
+
}),
|
|
181
193
|
])
|
|
182
194
|
})
|
|
183
195
|
|
package/src/commands/pr.ts
CHANGED
|
@@ -14,6 +14,10 @@ interface PrCreateOptions extends BaseCommandOptions {
|
|
|
14
14
|
readonly phaseId?: string
|
|
15
15
|
readonly draft?: boolean
|
|
16
16
|
readonly force?: boolean
|
|
17
|
+
readonly title?: string
|
|
18
|
+
readonly head?: string
|
|
19
|
+
readonly base?: string
|
|
20
|
+
readonly labels?: readonly string[]
|
|
17
21
|
}
|
|
18
22
|
|
|
19
23
|
const branchTargetCommands = new Set([
|
|
@@ -126,11 +130,21 @@ export const pr = (args: readonly string[], cwd: string = process.cwd()) =>
|
|
|
126
130
|
})
|
|
127
131
|
|
|
128
132
|
export const help = `
|
|
129
|
-
Usage: agency pr create <task-id> [phase-id] [
|
|
133
|
+
Usage: agency pr create <task-id> [phase-id] [options]
|
|
130
134
|
agency pr [args...]
|
|
131
135
|
|
|
132
|
-
Create records a pull request for an Agency execution unit
|
|
133
|
-
|
|
136
|
+
Create records a pull request for an Agency execution unit and accepts the
|
|
137
|
+
options listed below. Invocations without an Agency task target run gh pr
|
|
138
|
+
unchanged, focusing the writable repository checkout when invoked from
|
|
134
139
|
an Agency execution task or phase. In jj workbases, Agency supplies the declared
|
|
135
140
|
branch and repository to gh subcommands that would otherwise infer Git context.
|
|
141
|
+
|
|
142
|
+
Options:
|
|
143
|
+
--draft Create the pull request as a draft
|
|
144
|
+
--title <title> Override the generated pull request title
|
|
145
|
+
--head <branch> Confirm the declared head branch
|
|
146
|
+
--base <branch> Confirm the declared base branch
|
|
147
|
+
--label <label> Add a label (repeatable)
|
|
148
|
+
--force Override readiness checks
|
|
149
|
+
--json Output one versioned machine result
|
|
136
150
|
`
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { Effect } from "effect"
|
|
3
|
+
import { PushService } from "../services/PushService"
|
|
4
|
+
import { captureLogs } from "../test-utils"
|
|
5
|
+
import type { Progress } from "../utils/progress"
|
|
6
|
+
import { push } from "./push"
|
|
7
|
+
|
|
8
|
+
describe("push command", () => {
|
|
9
|
+
test("reports deterministic progress while preserving one JSON result", async () => {
|
|
10
|
+
const updates: string[] = []
|
|
11
|
+
const progress: Progress = {
|
|
12
|
+
start: (message) => updates.push(`start:${message}`),
|
|
13
|
+
succeed: (message) => updates.push(`succeed:${message}`),
|
|
14
|
+
fail: (message) => updates.push(`fail:${message}`),
|
|
15
|
+
}
|
|
16
|
+
const result = {
|
|
17
|
+
vcs: "jj" as const,
|
|
18
|
+
taskId: "example",
|
|
19
|
+
branch: "task/example",
|
|
20
|
+
base: "main",
|
|
21
|
+
remote: "origin",
|
|
22
|
+
tip: "abc123",
|
|
23
|
+
changeId: "change",
|
|
24
|
+
}
|
|
25
|
+
const logs = await captureLogs(() =>
|
|
26
|
+
Effect.runPromise(
|
|
27
|
+
push({ cwd: "/workbase", json: true }, progress).pipe(
|
|
28
|
+
Effect.provideService(PushService, {
|
|
29
|
+
publish: (_cwd: string, options: any) => {
|
|
30
|
+
for (const stage of [
|
|
31
|
+
"context",
|
|
32
|
+
"fetch",
|
|
33
|
+
"inspect",
|
|
34
|
+
"validate",
|
|
35
|
+
"publish",
|
|
36
|
+
] as const)
|
|
37
|
+
options.onProgress(stage)
|
|
38
|
+
return Effect.succeed(result)
|
|
39
|
+
},
|
|
40
|
+
} as never),
|
|
41
|
+
) as Effect.Effect<void, unknown, never>,
|
|
42
|
+
),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
expect(logs).toEqual([JSON.stringify(result, null, 2)])
|
|
46
|
+
expect(updates).toEqual([
|
|
47
|
+
"start:Inspecting Agency execution context",
|
|
48
|
+
"start:Fetching remote state",
|
|
49
|
+
"start:Selecting the publication tip",
|
|
50
|
+
"start:Validating outgoing changes",
|
|
51
|
+
"start:Publishing the declared branch",
|
|
52
|
+
"succeed:Published task/example to origin",
|
|
53
|
+
])
|
|
54
|
+
})
|
|
55
|
+
})
|
package/src/commands/push.ts
CHANGED
|
@@ -2,12 +2,39 @@ import { Effect } from "effect"
|
|
|
2
2
|
import { PushService } from "../services/PushService"
|
|
3
3
|
import type { BaseCommandOptions } from "../utils/command"
|
|
4
4
|
import { createLoggers } from "../utils/effect"
|
|
5
|
+
import { createProgress, type Progress } from "../utils/progress"
|
|
5
6
|
|
|
6
|
-
|
|
7
|
+
const stageMessage = {
|
|
8
|
+
context: "Inspecting Agency execution context",
|
|
9
|
+
fetch: "Fetching remote state",
|
|
10
|
+
inspect: "Selecting the publication tip",
|
|
11
|
+
validate: "Validating outgoing changes",
|
|
12
|
+
publish: "Publishing the declared branch",
|
|
13
|
+
} as const
|
|
14
|
+
|
|
15
|
+
export const push = (
|
|
16
|
+
options: BaseCommandOptions = {},
|
|
17
|
+
progress: Progress = createProgress({ silent: options.silent }),
|
|
18
|
+
) =>
|
|
7
19
|
Effect.gen(function* () {
|
|
8
20
|
const publications = yield* PushService
|
|
9
21
|
const { log } = createLoggers(options)
|
|
10
|
-
const
|
|
22
|
+
const showProgress = !options.silent
|
|
23
|
+
const result = yield* publications
|
|
24
|
+
.publish(options.cwd ?? process.cwd(), {
|
|
25
|
+
onProgress: showProgress
|
|
26
|
+
? (stage) => progress.start(stageMessage[stage])
|
|
27
|
+
: undefined,
|
|
28
|
+
})
|
|
29
|
+
.pipe(
|
|
30
|
+
Effect.tapError(() =>
|
|
31
|
+
Effect.sync(() => {
|
|
32
|
+
if (showProgress) progress.fail("Publication failed")
|
|
33
|
+
}),
|
|
34
|
+
),
|
|
35
|
+
)
|
|
36
|
+
if (showProgress)
|
|
37
|
+
progress.succeed(`Published ${result.branch} to ${result.remote}`)
|
|
11
38
|
log(
|
|
12
39
|
options.json
|
|
13
40
|
? JSON.stringify(result, null, 2)
|
|
@@ -82,6 +82,41 @@ describe("sync command", () => {
|
|
|
82
82
|
).toBe(false)
|
|
83
83
|
})
|
|
84
84
|
|
|
85
|
+
test("scopes reconciliation to one task", async () => {
|
|
86
|
+
await runTestEffect(
|
|
87
|
+
TaskService.pipe(
|
|
88
|
+
Effect.flatMap((service) =>
|
|
89
|
+
service.create(
|
|
90
|
+
{
|
|
91
|
+
id: "second",
|
|
92
|
+
ticketUrl: null,
|
|
93
|
+
repo: "agency",
|
|
94
|
+
branch: "task/second",
|
|
95
|
+
base: "main",
|
|
96
|
+
},
|
|
97
|
+
root,
|
|
98
|
+
),
|
|
99
|
+
),
|
|
100
|
+
),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
const logs = await captureLogs(() =>
|
|
104
|
+
runTestEffect(sync({ cwd: root, taskId: "example", json: true })),
|
|
105
|
+
)
|
|
106
|
+
const result = JSON.parse(logs[0]!)
|
|
107
|
+
expect(result.executions.map((execution: any) => execution.target)).toEqual(
|
|
108
|
+
["task:example"],
|
|
109
|
+
)
|
|
110
|
+
expect(
|
|
111
|
+
await Bun.file(
|
|
112
|
+
join(root, "tasks/example/code/agency/README.md"),
|
|
113
|
+
).exists(),
|
|
114
|
+
).toBe(true)
|
|
115
|
+
expect(
|
|
116
|
+
await Bun.file(join(root, "tasks/second/code/agency/README.md")).exists(),
|
|
117
|
+
).toBe(false)
|
|
118
|
+
})
|
|
119
|
+
|
|
85
120
|
test("preserves structured output behind --json", async () => {
|
|
86
121
|
const logs = await captureLogs(() =>
|
|
87
122
|
runTestEffect(sync({ cwd: root, dryRun: true, json: true })),
|
package/src/commands/sync.ts
CHANGED
|
@@ -6,6 +6,8 @@ import { createProgress, type Progress } from "../utils/progress"
|
|
|
6
6
|
|
|
7
7
|
interface SyncCommandOptions extends BaseCommandOptions {
|
|
8
8
|
readonly dryRun?: boolean
|
|
9
|
+
readonly taskId?: string
|
|
10
|
+
readonly phaseId?: string
|
|
9
11
|
}
|
|
10
12
|
|
|
11
13
|
interface Notice {
|
|
@@ -44,6 +46,8 @@ export const sync = (
|
|
|
44
46
|
.reconcile({
|
|
45
47
|
cwd: options.cwd,
|
|
46
48
|
apply: options.dryRun !== true,
|
|
49
|
+
taskId: options.taskId,
|
|
50
|
+
phaseId: options.phaseId,
|
|
47
51
|
onProgress: showProgress
|
|
48
52
|
? ({ stage, current, total, target }) => {
|
|
49
53
|
if (stage === "repositories") {
|
|
@@ -118,11 +122,13 @@ export const sync = (
|
|
|
118
122
|
})
|
|
119
123
|
|
|
120
124
|
export const help = `
|
|
121
|
-
Usage: agency sync [--dry-run] [--json]
|
|
125
|
+
Usage: agency sync [<task-id> [phase-id]] [--dry-run] [--json]
|
|
122
126
|
|
|
123
127
|
Compare portable repository declarations and execution state with local Git
|
|
124
128
|
repositories, worktrees, branches, references, claims, and pull requests.
|
|
125
129
|
Safe reconciliation transitions are applied by default.
|
|
130
|
+
When a task or phase is provided, only that target and its repositories are
|
|
131
|
+
queried or reconciled. Task scope includes all phases of a multi-phase task.
|
|
126
132
|
|
|
127
133
|
Options:
|
|
128
134
|
--dry-run Report planned safe transitions without changing state
|
|
@@ -1050,18 +1050,45 @@ describe("work command", () => {
|
|
|
1050
1050
|
).toBeUndefined()
|
|
1051
1051
|
})
|
|
1052
1052
|
|
|
1053
|
-
test("automatically
|
|
1054
|
-
const harness = createHarness(
|
|
1053
|
+
test("automatically prefers opencode2", async () => {
|
|
1054
|
+
const harness = createHarness()
|
|
1055
|
+
|
|
1056
|
+
await harness.run({ taskId: "example" })
|
|
1057
|
+
|
|
1058
|
+
expect(harness.probes).toEqual(["opencode2"])
|
|
1059
|
+
expect(harness.launches[0]).toEqual({
|
|
1060
|
+
cli: "opencode2",
|
|
1061
|
+
args: ["opencode2"],
|
|
1062
|
+
cwd: taskDirectory,
|
|
1063
|
+
})
|
|
1064
|
+
})
|
|
1065
|
+
|
|
1066
|
+
test("automatically falls back from opencode2 to opencode", async () => {
|
|
1067
|
+
const harness = createHarness({ available: { opencode2: false } })
|
|
1055
1068
|
|
|
1056
1069
|
await harness.run({ taskId: "example" })
|
|
1057
1070
|
|
|
1058
|
-
expect(harness.probes).toEqual(["
|
|
1071
|
+
expect(harness.probes).toEqual(["opencode2", "opencode"])
|
|
1072
|
+
expect(harness.launches[0]).toEqual({
|
|
1073
|
+
cli: "opencode",
|
|
1074
|
+
args: ["opencode"],
|
|
1075
|
+
cwd: taskDirectory,
|
|
1076
|
+
})
|
|
1077
|
+
})
|
|
1078
|
+
|
|
1079
|
+
test("automatically falls back from OpenCode to Claude", async () => {
|
|
1080
|
+
const harness = createHarness({
|
|
1081
|
+
available: { opencode2: false, opencode: false },
|
|
1082
|
+
})
|
|
1083
|
+
|
|
1084
|
+
await harness.run({ taskId: "example" })
|
|
1085
|
+
|
|
1086
|
+
expect(harness.probes).toEqual(["opencode2", "opencode", "claude"])
|
|
1059
1087
|
expect(harness.launches[0]).toEqual({
|
|
1060
1088
|
cli: "claude",
|
|
1061
1089
|
args: ["claude"],
|
|
1062
1090
|
cwd: taskDirectory,
|
|
1063
1091
|
})
|
|
1064
|
-
expect(harness.launchEnvironments[0]?.OPENCODE_CONFIG).toBeUndefined()
|
|
1065
1092
|
})
|
|
1066
1093
|
|
|
1067
1094
|
test("does not fall back when OpenCode is explicitly required", async () => {
|
|
@@ -1090,13 +1117,13 @@ describe("work command", () => {
|
|
|
1090
1117
|
|
|
1091
1118
|
test("fails when neither agent tool is available", async () => {
|
|
1092
1119
|
const harness = createHarness({
|
|
1093
|
-
available: { opencode: false, claude: false },
|
|
1120
|
+
available: { opencode2: false, opencode: false, claude: false },
|
|
1094
1121
|
})
|
|
1095
1122
|
|
|
1096
1123
|
await expect(harness.run({ taskId: "example" })).rejects.toThrow(
|
|
1097
1124
|
"claude CLI tool not found",
|
|
1098
1125
|
)
|
|
1099
|
-
expect(harness.probes).toEqual(["opencode", "claude"])
|
|
1126
|
+
expect(harness.probes).toEqual(["opencode2", "opencode", "claude"])
|
|
1100
1127
|
expect(harness.launches).toEqual([])
|
|
1101
1128
|
})
|
|
1102
1129
|
|
|
@@ -1150,7 +1177,7 @@ describe("work command", () => {
|
|
|
1150
1177
|
verboseHarness.run({ taskId: "example", verbose: true }),
|
|
1151
1178
|
)
|
|
1152
1179
|
expect(verboseLogs).toEqual([
|
|
1153
|
-
"Launching command:
|
|
1180
|
+
"Launching command: opencode2 (cwd: /workbase/tasks/example)",
|
|
1154
1181
|
])
|
|
1155
1182
|
expect(verboseHarness.materializeOptions[0]?.verbose).toBe(true)
|
|
1156
1183
|
|
package/src/commands/work.ts
CHANGED
|
@@ -280,10 +280,16 @@ export const work = (
|
|
|
280
280
|
options.claude ||
|
|
281
281
|
process.env.AGENCY_RUNNER,
|
|
282
282
|
)
|
|
283
|
-
|
|
283
|
+
const defaultRunners = ["opencode2", "opencode", "claude"] as const
|
|
284
|
+
let defaultRunnerIndex = 0
|
|
285
|
+
let runner: string =
|
|
284
286
|
options.runner ??
|
|
285
287
|
process.env.AGENCY_RUNNER ??
|
|
286
|
-
(options.claude
|
|
288
|
+
(options.claude
|
|
289
|
+
? "claude"
|
|
290
|
+
: options.opencode
|
|
291
|
+
? "opencode"
|
|
292
|
+
: defaultRunners[defaultRunnerIndex]!)
|
|
287
293
|
const claimant = process.env.AGENCY_CLAIMANT ?? process.env.USER ?? "agency"
|
|
288
294
|
const sessionId =
|
|
289
295
|
process.env.AGENCY_SESSION_ID ?? `${process.pid}-${Date.now()}`
|
|
@@ -311,12 +317,12 @@ export const work = (
|
|
|
311
317
|
let available = yield* fs.runCommand(["which", cli], {
|
|
312
318
|
captureOutput: true,
|
|
313
319
|
})
|
|
314
|
-
|
|
320
|
+
while (
|
|
315
321
|
available.exitCode !== 0 &&
|
|
316
322
|
!explicitlyRequested &&
|
|
317
|
-
|
|
323
|
+
defaultRunnerIndex < defaultRunners.length - 1
|
|
318
324
|
) {
|
|
319
|
-
runner =
|
|
325
|
+
runner = defaultRunners[++defaultRunnerIndex]!
|
|
320
326
|
resolved = resolveRunnerCommand(
|
|
321
327
|
runner,
|
|
322
328
|
config.runners,
|
|
@@ -580,7 +586,8 @@ Launch an agent for an epic, task, or phase. With no directory, select one
|
|
|
580
586
|
interactively. A positional argument resolves as a directory first, then as a task
|
|
581
587
|
ID. Use '.' for the current directory. Outside a workbase, select a registered
|
|
582
588
|
workbase first. Managed OpenCode launches receive whole-workbase access through
|
|
583
|
-
Agency's project plugin; Agency context remains authoritative for writes.
|
|
589
|
+
Agency's project plugin; Agency context remains authoritative for writes. Automatic
|
|
590
|
+
runner discovery checks opencode2, opencode, then claude.
|
|
584
591
|
|
|
585
592
|
The prepare subcommand resolves and materializes an execution workspace without
|
|
586
593
|
launching an agent or changing lifecycle status. --dry-run reports planned Git
|
|
@@ -898,6 +898,7 @@ export class ContextService extends Effect.Service<ContextService>()(
|
|
|
898
898
|
repositoryPath: string,
|
|
899
899
|
checkoutPath: string,
|
|
900
900
|
expectedBranch: string | null = null,
|
|
901
|
+
branchCommit: string | null = null,
|
|
901
902
|
) =>
|
|
902
903
|
Effect.gen(function* (): Generator<any, CheckoutInspection, any> {
|
|
903
904
|
const materialized = yield* fs.isDirectory(checkoutPath)
|
|
@@ -914,9 +915,10 @@ export class ContextService extends Effect.Service<ContextService>()(
|
|
|
914
915
|
const canonicalCheckoutPath = materialized
|
|
915
916
|
? yield* fs.realPath(checkoutPath)
|
|
916
917
|
: resolve(checkoutPath)
|
|
917
|
-
const
|
|
918
|
+
const workspace = workspaces.find(
|
|
918
919
|
(workspace) => workspace.path === canonicalCheckoutPath,
|
|
919
920
|
)
|
|
921
|
+
const registered = workspace !== undefined
|
|
920
922
|
if (!materialized) {
|
|
921
923
|
return {
|
|
922
924
|
materialized: false,
|
|
@@ -930,10 +932,14 @@ export class ContextService extends Effect.Service<ContextService>()(
|
|
|
930
932
|
return {
|
|
931
933
|
materialized: true,
|
|
932
934
|
registered,
|
|
933
|
-
checkoutCommit:
|
|
934
|
-
checkoutBranch:
|
|
935
|
-
|
|
936
|
-
|
|
935
|
+
checkoutCommit: workspace?.commit ?? null,
|
|
936
|
+
checkoutBranch:
|
|
937
|
+
expectedBranch && branchCommit === workspace?.commit
|
|
938
|
+
? expectedBranch
|
|
939
|
+
: null,
|
|
940
|
+
detached:
|
|
941
|
+
!expectedBranch || branchCommit !== workspace?.commit,
|
|
942
|
+
dirty: workspace ? false : null,
|
|
937
943
|
}
|
|
938
944
|
}
|
|
939
945
|
const listed = yield* runGit(fs, repositoryPath, [
|
|
@@ -1014,10 +1020,8 @@ export class ContextService extends Effect.Service<ContextService>()(
|
|
|
1014
1020
|
repositoryPath,
|
|
1015
1021
|
checkoutPath,
|
|
1016
1022
|
executionData.branch,
|
|
1023
|
+
branchCommit,
|
|
1017
1024
|
)
|
|
1018
|
-
if (backend.kind === "jj" && branchCommit === null) {
|
|
1019
|
-
branchCommit = checkout.checkoutCommit
|
|
1020
|
-
}
|
|
1021
1025
|
if (branchCommit === null) {
|
|
1022
1026
|
inspectionWarnings.push(
|
|
1023
1027
|
`Unable to resolve branch '${executionData.branch}' in ${repositoryPath}`,
|
|
@@ -68,11 +68,12 @@ describe("PullRequestService", () => {
|
|
|
68
68
|
phaseId?: string,
|
|
69
69
|
draft = false,
|
|
70
70
|
force = false,
|
|
71
|
+
options: Record<string, unknown> = {},
|
|
71
72
|
) =>
|
|
72
73
|
runTestEffect(
|
|
73
74
|
PullRequestService.pipe(
|
|
74
75
|
Effect.flatMap((service) =>
|
|
75
|
-
service.create(taskId, phaseId, draft, root, { force }),
|
|
76
|
+
service.create(taskId, phaseId, draft, root, { force, ...options }),
|
|
76
77
|
),
|
|
77
78
|
),
|
|
78
79
|
)
|
|
@@ -303,6 +304,51 @@ process.exit(${exitCode})
|
|
|
303
304
|
])
|
|
304
305
|
})
|
|
305
306
|
|
|
307
|
+
test("passes task-aware GitHub title and labels with declared refs", async () => {
|
|
308
|
+
await createTask()
|
|
309
|
+
await writeFakeGh({
|
|
310
|
+
stdout: "https://github.com/example/agency/pull/45\n",
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
await createPullRequest("example", undefined, true, false, {
|
|
314
|
+
title: "Ship the workflow",
|
|
315
|
+
head: "task/example",
|
|
316
|
+
base: "main",
|
|
317
|
+
labels: ["ai-assisted", "platform"],
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
expect((await readGhCall()).args).toEqual([
|
|
321
|
+
"pr",
|
|
322
|
+
"create",
|
|
323
|
+
"--fill",
|
|
324
|
+
"--title",
|
|
325
|
+
"Ship the workflow",
|
|
326
|
+
"--base",
|
|
327
|
+
"main",
|
|
328
|
+
"--head",
|
|
329
|
+
"task/example",
|
|
330
|
+
"--draft",
|
|
331
|
+
"--label",
|
|
332
|
+
"ai-assisted",
|
|
333
|
+
"--label",
|
|
334
|
+
"platform",
|
|
335
|
+
])
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
test("rejects task-aware head and base values that contradict declarations", async () => {
|
|
339
|
+
await createTask()
|
|
340
|
+
await expect(
|
|
341
|
+
createPullRequest("example", undefined, false, false, {
|
|
342
|
+
head: "other",
|
|
343
|
+
}),
|
|
344
|
+
).rejects.toThrow("does not match declared branch")
|
|
345
|
+
await expect(
|
|
346
|
+
createPullRequest("example", undefined, false, false, {
|
|
347
|
+
base: "release",
|
|
348
|
+
}),
|
|
349
|
+
).rejects.toThrow("does not match declared base")
|
|
350
|
+
})
|
|
351
|
+
|
|
306
352
|
test("guards terminal targets before materializing unless forced", async () => {
|
|
307
353
|
await createTask()
|
|
308
354
|
await runTestEffect(
|
|
@@ -27,6 +27,10 @@ class PullRequestError extends Data.TaggedError("PullRequestError")<{
|
|
|
27
27
|
|
|
28
28
|
interface PullRequestOptions extends BaseCommandOptions {
|
|
29
29
|
readonly force?: boolean
|
|
30
|
+
readonly title?: string
|
|
31
|
+
readonly head?: string
|
|
32
|
+
readonly base?: string
|
|
33
|
+
readonly labels?: readonly string[]
|
|
30
34
|
}
|
|
31
35
|
|
|
32
36
|
export class PullRequestService extends Effect.Service<PullRequestService>()(
|
|
@@ -158,6 +162,25 @@ export class PullRequestService extends Effect.Service<PullRequestService>()(
|
|
|
158
162
|
})
|
|
159
163
|
}
|
|
160
164
|
const remote = config.delivery?.remote ?? "origin"
|
|
165
|
+
if (options.head && options.head !== execution.branch) {
|
|
166
|
+
return yield* new PullRequestError({
|
|
167
|
+
message: `Requested head '${options.head}' does not match declared branch '${execution.branch}'`,
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
if (options.base && options.base !== execution.base) {
|
|
171
|
+
return yield* new PullRequestError({
|
|
172
|
+
message: `Requested base '${options.base}' does not match declared base '${execution.base}'`,
|
|
173
|
+
})
|
|
174
|
+
}
|
|
175
|
+
if (
|
|
176
|
+
config.delivery &&
|
|
177
|
+
(options.title || (options.labels?.length ?? 0) > 0)
|
|
178
|
+
) {
|
|
179
|
+
return yield* new PullRequestError({
|
|
180
|
+
message:
|
|
181
|
+
"Task-aware --title and --label options require the default GitHub delivery provider",
|
|
182
|
+
})
|
|
183
|
+
}
|
|
161
184
|
|
|
162
185
|
const dirty = yield* backend.workspaceDirty(workspace.writablePath)
|
|
163
186
|
if (dirty === null) {
|
|
@@ -211,6 +234,9 @@ export class PullRequestService extends Effect.Service<PullRequestService>()(
|
|
|
211
234
|
repository,
|
|
212
235
|
draft,
|
|
213
236
|
vcs: config.vcs ?? "git",
|
|
237
|
+
title: options.title,
|
|
238
|
+
head: options.head,
|
|
239
|
+
labels: options.labels,
|
|
214
240
|
...(defaults ? { defaults } : {}),
|
|
215
241
|
})
|
|
216
242
|
const gitEnvironment = config.delivery
|
|
@@ -376,6 +376,42 @@ describe("PushService", () => {
|
|
|
376
376
|
)
|
|
377
377
|
).stdout,
|
|
378
378
|
).toBe("true")
|
|
379
|
+
|
|
380
|
+
await requireCommand(["jj", "new", "@"], fixture.checkout)
|
|
381
|
+
await Bun.write(join(fixture.checkout, "follow-up.txt"), "follow up\n")
|
|
382
|
+
await requireCommand(
|
|
383
|
+
["jj", "describe", "-m", "Add follow-up"],
|
|
384
|
+
fixture.checkout,
|
|
385
|
+
)
|
|
386
|
+
const advanced = await publish(fixture.taskPath)
|
|
387
|
+
expect(advanced.tip).not.toBe(result.tip)
|
|
388
|
+
expect(await remoteBranch(fixture.remote)).toBe(advanced.tip)
|
|
389
|
+
}, 15_000)
|
|
390
|
+
|
|
391
|
+
test("diagnoses a jj stack whose remote base advanced", async () => {
|
|
392
|
+
if (!Bun.which("jj")) return
|
|
393
|
+
const fixture = await setup("jj")
|
|
394
|
+
await Bun.write(join(fixture.checkout, "feature.txt"), "local\n")
|
|
395
|
+
await requireCommand(
|
|
396
|
+
["jj", "describe", "-m", "Local feature"],
|
|
397
|
+
fixture.checkout,
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
const other = join(fixture.root, "other-main")
|
|
401
|
+
await requireCommand(["git", "clone", fixture.remote, other])
|
|
402
|
+
await requireCommand(["git", "config", "user.name", "Other"], other)
|
|
403
|
+
await requireCommand(
|
|
404
|
+
["git", "config", "user.email", "other@example.com"],
|
|
405
|
+
other,
|
|
406
|
+
)
|
|
407
|
+
await Bun.write(join(other, "remote.txt"), "remote\n")
|
|
408
|
+
await requireCommand(["git", "add", "remote.txt"], other)
|
|
409
|
+
await requireCommand(["git", "commit", "-m", "Advance main"], other)
|
|
410
|
+
await requireCommand(["git", "push", "origin", "main"], other)
|
|
411
|
+
|
|
412
|
+
await expect(publish(fixture.taskPath)).rejects.toThrow(
|
|
413
|
+
"jj rebase -s 'roots(main@origin..@)' -d main@origin",
|
|
414
|
+
)
|
|
379
415
|
})
|
|
380
416
|
|
|
381
417
|
test("selects the described parent of a canonical jj post-commit working copy", async () => {
|
|
@@ -424,7 +460,7 @@ describe("PushService", () => {
|
|
|
424
460
|
await expect(publish(undescribed.taskPath)).rejects.toThrow(
|
|
425
461
|
`Change ${changeId} has no description. Run: jj describe -r ${changeId}`,
|
|
426
462
|
)
|
|
427
|
-
})
|
|
463
|
+
}, 15_000)
|
|
428
464
|
|
|
429
465
|
test("rejects an empty jj task and remote bookmark divergence", async () => {
|
|
430
466
|
if (!Bun.which("jj")) return
|
|
@@ -463,7 +499,7 @@ describe("PushService", () => {
|
|
|
463
499
|
await expect(publish(fixture.taskPath)).rejects.toThrow(
|
|
464
500
|
"refusing to move it",
|
|
465
501
|
)
|
|
466
|
-
})
|
|
502
|
+
}, 15_000)
|
|
467
503
|
|
|
468
504
|
test("rejects conflicted jj changes with an actionable change ID", async () => {
|
|
469
505
|
if (!Bun.which("jj")) return
|
|
@@ -37,6 +37,12 @@ interface PushResult {
|
|
|
37
37
|
readonly changeId?: string
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
type PushStage = "context" | "fetch" | "inspect" | "validate" | "publish"
|
|
41
|
+
|
|
42
|
+
interface PushOptions {
|
|
43
|
+
readonly onProgress?: (stage: PushStage) => void
|
|
44
|
+
}
|
|
45
|
+
|
|
40
46
|
const validEmail = (email: string) => /^[^@\s]+@[^@\s]+$/.test(email)
|
|
41
47
|
|
|
42
48
|
const requireCommand = (
|
|
@@ -153,8 +159,10 @@ const publishGit = (
|
|
|
153
159
|
remote: string,
|
|
154
160
|
branch: string,
|
|
155
161
|
base: string,
|
|
162
|
+
onProgress?: (stage: PushStage) => void,
|
|
156
163
|
) =>
|
|
157
164
|
Effect.gen(function* () {
|
|
165
|
+
onProgress?.("inspect")
|
|
158
166
|
const currentBranch = yield* git(
|
|
159
167
|
fs,
|
|
160
168
|
checkout,
|
|
@@ -179,6 +187,7 @@ const publishGit = (
|
|
|
179
187
|
})
|
|
180
188
|
}
|
|
181
189
|
|
|
190
|
+
onProgress?.("fetch")
|
|
182
191
|
yield* git(
|
|
183
192
|
fs,
|
|
184
193
|
checkout,
|
|
@@ -202,6 +211,7 @@ const publishGit = (
|
|
|
202
211
|
})
|
|
203
212
|
}
|
|
204
213
|
|
|
214
|
+
onProgress?.("validate")
|
|
205
215
|
const log = yield* git(
|
|
206
216
|
fs,
|
|
207
217
|
checkout,
|
|
@@ -228,6 +238,7 @@ const publishGit = (
|
|
|
228
238
|
})
|
|
229
239
|
}
|
|
230
240
|
|
|
241
|
+
onProgress?.("publish")
|
|
231
242
|
yield* git(
|
|
232
243
|
fs,
|
|
233
244
|
checkout,
|
|
@@ -361,14 +372,17 @@ const publishJj = (
|
|
|
361
372
|
remote: string,
|
|
362
373
|
branch: string,
|
|
363
374
|
base: string,
|
|
375
|
+
onProgress?: (stage: PushStage) => void,
|
|
364
376
|
) =>
|
|
365
377
|
Effect.gen(function* () {
|
|
378
|
+
onProgress?.("fetch")
|
|
366
379
|
yield* jj(
|
|
367
380
|
fs,
|
|
368
381
|
checkout,
|
|
369
382
|
["git", "fetch", "--remote", remote],
|
|
370
383
|
`Failed to fetch remote '${remote}'`,
|
|
371
384
|
)
|
|
385
|
+
onProgress?.("inspect")
|
|
372
386
|
const workingCopy = yield* jjRevision(fs, checkout, "@")
|
|
373
387
|
if (!workingCopy) {
|
|
374
388
|
return yield* new PushError({
|
|
@@ -403,10 +417,11 @@ const publishJj = (
|
|
|
403
417
|
!(yield* jjAncestor(fs, checkout, baseRevision.commitId, tip.commitId))
|
|
404
418
|
) {
|
|
405
419
|
return yield* new PushError({
|
|
406
|
-
message: `Declared base '${base}' (${baseRevision.commitId}) is not an ancestor of jj tip ${tip.changeId} (${tip.commitId})`,
|
|
420
|
+
message: `Declared base '${base}' (${baseRevision.commitId}) is not an ancestor of jj tip ${tip.changeId} (${tip.commitId}). Rebase the stack with: jj rebase -s 'roots(${baseBookmark}..@)' -d ${baseBookmark}`,
|
|
407
421
|
})
|
|
408
422
|
}
|
|
409
423
|
|
|
424
|
+
onProgress?.("validate")
|
|
410
425
|
const outgoing = yield* jjCommits(
|
|
411
426
|
fs,
|
|
412
427
|
checkout,
|
|
@@ -444,6 +459,7 @@ const publishJj = (
|
|
|
444
459
|
})
|
|
445
460
|
}
|
|
446
461
|
|
|
462
|
+
onProgress?.("publish")
|
|
447
463
|
yield* jj(
|
|
448
464
|
fs,
|
|
449
465
|
checkout,
|
|
@@ -467,8 +483,9 @@ const publishJj = (
|
|
|
467
483
|
|
|
468
484
|
export class PushService extends Effect.Service<PushService>()("PushService", {
|
|
469
485
|
sync: () => ({
|
|
470
|
-
publish: (startPath: string = process.cwd()) =>
|
|
486
|
+
publish: (startPath: string = process.cwd(), options: PushOptions = {}) =>
|
|
471
487
|
Effect.gen(function* () {
|
|
488
|
+
options.onProgress?.("context")
|
|
472
489
|
const contexts = yield* ContextService
|
|
473
490
|
const fs = yield* FileSystemService
|
|
474
491
|
const tasks = yield* TaskService
|
|
@@ -554,6 +571,7 @@ export class PushService extends Effect.Service<PushService>()("PushService", {
|
|
|
554
571
|
remote,
|
|
555
572
|
execution.branch,
|
|
556
573
|
execution.base,
|
|
574
|
+
options.onProgress,
|
|
557
575
|
)
|
|
558
576
|
: yield* publishGit(
|
|
559
577
|
fs,
|
|
@@ -561,6 +579,7 @@ export class PushService extends Effect.Service<PushService>()("PushService", {
|
|
|
561
579
|
remote,
|
|
562
580
|
execution.branch,
|
|
563
581
|
execution.base,
|
|
582
|
+
options.onProgress,
|
|
564
583
|
)
|
|
565
584
|
return {
|
|
566
585
|
vcs: context.workbase.vcs,
|
|
@@ -811,7 +811,11 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
|
|
|
811
811
|
}),
|
|
812
812
|
|
|
813
813
|
setup: (
|
|
814
|
-
options: {
|
|
814
|
+
options: {
|
|
815
|
+
readonly cwd?: string
|
|
816
|
+
readonly apply?: boolean
|
|
817
|
+
readonly aliases?: readonly string[]
|
|
818
|
+
} = {},
|
|
815
819
|
) =>
|
|
816
820
|
Effect.gen(function* () {
|
|
817
821
|
const service = yield* RepositoryService
|
|
@@ -819,7 +823,13 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
|
|
|
819
823
|
const versionControl = yield* VersionControlService
|
|
820
824
|
const state = yield* configState(options.cwd ?? process.cwd())
|
|
821
825
|
const backend = yield* versionControl.forWorkbase(state.root)
|
|
822
|
-
const
|
|
826
|
+
const requestedAliases = options.aliases
|
|
827
|
+
? new Set(options.aliases)
|
|
828
|
+
: null
|
|
829
|
+
const repositories = (yield* service.list(state.root)).filter(
|
|
830
|
+
(repository) =>
|
|
831
|
+
!requestedAliases || requestedAliases.has(repository.alias),
|
|
832
|
+
)
|
|
823
833
|
const planned: Omit<RepositorySetupAction, "status">[] = []
|
|
824
834
|
const unresolved: RepositorySetupIssue[] = []
|
|
825
835
|
|
|
@@ -933,7 +943,11 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
|
|
|
933
943
|
unresolved,
|
|
934
944
|
repositories:
|
|
935
945
|
options.apply === true
|
|
936
|
-
? yield* service.list(state.root)
|
|
946
|
+
? (yield* service.list(state.root)).filter(
|
|
947
|
+
(repository) =>
|
|
948
|
+
!requestedAliases ||
|
|
949
|
+
requestedAliases.has(repository.alias),
|
|
950
|
+
)
|
|
937
951
|
: repositories,
|
|
938
952
|
} satisfies RepositorySetupResult
|
|
939
953
|
}),
|
|
@@ -170,6 +170,8 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
|
|
|
170
170
|
readonly apply?: boolean
|
|
171
171
|
readonly now?: Date
|
|
172
172
|
readonly onProgress?: (progress: SyncProgress) => void
|
|
173
|
+
readonly taskId?: string
|
|
174
|
+
readonly phaseId?: string
|
|
173
175
|
} = {},
|
|
174
176
|
) =>
|
|
175
177
|
Effect.gen(function* () {
|
|
@@ -191,9 +193,66 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
|
|
|
191
193
|
.join("\n"),
|
|
192
194
|
})
|
|
193
195
|
}
|
|
196
|
+
if (options.phaseId && !options.taskId) {
|
|
197
|
+
return yield* new SyncError({
|
|
198
|
+
message: "A phase sync scope requires a task ID",
|
|
199
|
+
})
|
|
200
|
+
}
|
|
201
|
+
const allTaskRecords = yield* tasks.list(root)
|
|
202
|
+
const taskRecords = options.taskId
|
|
203
|
+
? allTaskRecords.filter((task) => task.id === options.taskId)
|
|
204
|
+
: allTaskRecords
|
|
205
|
+
if (options.taskId && taskRecords.length === 0) {
|
|
206
|
+
return yield* new SyncError({
|
|
207
|
+
message: `Task '${options.taskId}' does not exist`,
|
|
208
|
+
})
|
|
209
|
+
}
|
|
210
|
+
const records: ExecutionRecord[] = []
|
|
211
|
+
for (const task of taskRecords) {
|
|
212
|
+
if ("phases" in task.data) {
|
|
213
|
+
for (const phase of yield* phases.list(task.id, root)) {
|
|
214
|
+
if (options.phaseId && phase.id !== options.phaseId) continue
|
|
215
|
+
records.push({
|
|
216
|
+
key: `phase:${task.id}/${phase.id}`,
|
|
217
|
+
taskId: task.id,
|
|
218
|
+
phaseId: phase.id,
|
|
219
|
+
path: phase.path,
|
|
220
|
+
revision: documentRevision(phase.content),
|
|
221
|
+
data: phase.data,
|
|
222
|
+
})
|
|
223
|
+
}
|
|
224
|
+
} else if (!options.phaseId && !("review" in task.data)) {
|
|
225
|
+
records.push({
|
|
226
|
+
key: `task:${task.id}`,
|
|
227
|
+
taskId: task.id,
|
|
228
|
+
path: task.path,
|
|
229
|
+
revision: documentRevision(task.content),
|
|
230
|
+
data: task.data,
|
|
231
|
+
})
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
const reviewRecords = options.phaseId
|
|
235
|
+
? []
|
|
236
|
+
: taskRecords.filter((task) => "review" in task.data)
|
|
237
|
+
if (options.phaseId && records.length === 0) {
|
|
238
|
+
return yield* new SyncError({
|
|
239
|
+
message: `Phase '${options.taskId}/${options.phaseId}' does not exist`,
|
|
240
|
+
})
|
|
241
|
+
}
|
|
242
|
+
const repositoryAliases = new Set<string>()
|
|
243
|
+
for (const record of records) {
|
|
244
|
+
repositoryAliases.add(record.data.repo)
|
|
245
|
+
for (const reference of record.data.repos ?? [])
|
|
246
|
+
repositoryAliases.add(reference.repo)
|
|
247
|
+
}
|
|
248
|
+
for (const task of reviewRecords) {
|
|
249
|
+
if ("review" in task.data)
|
|
250
|
+
repositoryAliases.add(task.data.review.repo)
|
|
251
|
+
}
|
|
194
252
|
const repositorySetup = yield* repositories.setup({
|
|
195
253
|
cwd: root,
|
|
196
254
|
apply: options.apply === true,
|
|
255
|
+
...(options.taskId ? { aliases: [...repositoryAliases] } : {}),
|
|
197
256
|
})
|
|
198
257
|
options.onProgress?.({
|
|
199
258
|
stage: "repositories",
|
|
@@ -241,30 +300,6 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
|
|
|
241
300
|
}),
|
|
242
301
|
),
|
|
243
302
|
)
|
|
244
|
-
const taskRecords = yield* tasks.list(root)
|
|
245
|
-
const records: ExecutionRecord[] = []
|
|
246
|
-
for (const task of taskRecords) {
|
|
247
|
-
if ("phases" in task.data) {
|
|
248
|
-
for (const phase of yield* phases.list(task.id, root)) {
|
|
249
|
-
records.push({
|
|
250
|
-
key: `phase:${task.id}/${phase.id}`,
|
|
251
|
-
taskId: task.id,
|
|
252
|
-
phaseId: phase.id,
|
|
253
|
-
path: phase.path,
|
|
254
|
-
revision: documentRevision(phase.content),
|
|
255
|
-
data: phase.data,
|
|
256
|
-
})
|
|
257
|
-
}
|
|
258
|
-
} else if (!("review" in task.data)) {
|
|
259
|
-
records.push({
|
|
260
|
-
key: `task:${task.id}`,
|
|
261
|
-
taskId: task.id,
|
|
262
|
-
path: task.path,
|
|
263
|
-
revision: documentRevision(task.content),
|
|
264
|
-
data: task.data,
|
|
265
|
-
})
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
303
|
const listRegistered = (repositoryPath: string) =>
|
|
269
304
|
Effect.gen(function* () {
|
|
270
305
|
if (registeredByRepository.has(repositoryPath))
|
|
@@ -409,9 +444,6 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
|
|
|
409
444
|
{ concurrency: 8 },
|
|
410
445
|
),
|
|
411
446
|
)
|
|
412
|
-
const reviewRecords = taskRecords.filter(
|
|
413
|
-
(task) => "review" in task.data,
|
|
414
|
-
)
|
|
415
447
|
const executionTotal = records.length + reviewRecords.length
|
|
416
448
|
let reconciledExecutions = 0
|
|
417
449
|
const reportExecution = (target: string) => {
|
|
@@ -67,6 +67,55 @@ describe("delivery commands", () => {
|
|
|
67
67
|
argv: ["gh", "pr", "create", "--fill", "--base", "main"],
|
|
68
68
|
environment: {},
|
|
69
69
|
})
|
|
70
|
+
expect(
|
|
71
|
+
resolveGitHubCreateCommand({
|
|
72
|
+
...input,
|
|
73
|
+
vcs: "git",
|
|
74
|
+
head: "feat/example",
|
|
75
|
+
}),
|
|
76
|
+
).toEqual({
|
|
77
|
+
argv: [
|
|
78
|
+
"gh",
|
|
79
|
+
"pr",
|
|
80
|
+
"create",
|
|
81
|
+
"--fill",
|
|
82
|
+
"--base",
|
|
83
|
+
"main",
|
|
84
|
+
"--head",
|
|
85
|
+
"feat/example",
|
|
86
|
+
],
|
|
87
|
+
environment: {},
|
|
88
|
+
})
|
|
89
|
+
expect(
|
|
90
|
+
resolveGitHubCreateCommand({
|
|
91
|
+
...input,
|
|
92
|
+
vcs: "jj",
|
|
93
|
+
defaults: { title: "Generated", body: "Details" },
|
|
94
|
+
title: "Requested",
|
|
95
|
+
labels: ["ai-assisted", "platform"],
|
|
96
|
+
}),
|
|
97
|
+
).toEqual({
|
|
98
|
+
argv: [
|
|
99
|
+
"gh",
|
|
100
|
+
"pr",
|
|
101
|
+
"create",
|
|
102
|
+
"--title",
|
|
103
|
+
"Requested",
|
|
104
|
+
"--body",
|
|
105
|
+
"Details",
|
|
106
|
+
"--base",
|
|
107
|
+
"main",
|
|
108
|
+
"--head",
|
|
109
|
+
"feat/example",
|
|
110
|
+
"--repo",
|
|
111
|
+
"example/agency",
|
|
112
|
+
"--label",
|
|
113
|
+
"ai-assisted",
|
|
114
|
+
"--label",
|
|
115
|
+
"platform",
|
|
116
|
+
],
|
|
117
|
+
environment: {},
|
|
118
|
+
})
|
|
70
119
|
})
|
|
71
120
|
|
|
72
121
|
test("rejects unknown placeholders", () => {
|
|
@@ -25,6 +25,9 @@ export const resolveGitHubCreateCommand = ({
|
|
|
25
25
|
draft,
|
|
26
26
|
vcs,
|
|
27
27
|
defaults,
|
|
28
|
+
title,
|
|
29
|
+
head,
|
|
30
|
+
labels = [],
|
|
28
31
|
}: {
|
|
29
32
|
readonly base: string
|
|
30
33
|
readonly branch: string
|
|
@@ -32,18 +35,25 @@ export const resolveGitHubCreateCommand = ({
|
|
|
32
35
|
readonly draft: boolean
|
|
33
36
|
readonly vcs: "git" | "jj"
|
|
34
37
|
readonly defaults?: { readonly title: string; readonly body: string }
|
|
38
|
+
readonly title?: string
|
|
39
|
+
readonly head?: string
|
|
40
|
+
readonly labels?: readonly string[]
|
|
35
41
|
}) => ({
|
|
36
42
|
argv: [
|
|
37
43
|
"gh",
|
|
38
44
|
"pr",
|
|
39
45
|
"create",
|
|
40
46
|
...(defaults
|
|
41
|
-
? ["--title", defaults.title, "--body", defaults.body]
|
|
42
|
-
:
|
|
47
|
+
? ["--title", title ?? defaults.title, "--body", defaults.body]
|
|
48
|
+
: title
|
|
49
|
+
? ["--fill", "--title", title]
|
|
50
|
+
: ["--fill"]),
|
|
43
51
|
"--base",
|
|
44
52
|
base,
|
|
45
|
-
...(vcs === "jj" ? ["--head",
|
|
53
|
+
...(vcs === "jj" || head ? ["--head", head ?? branch] : []),
|
|
54
|
+
...(vcs === "jj" ? ["--repo", repository] : []),
|
|
46
55
|
...(draft ? ["--draft"] : []),
|
|
56
|
+
...labels.flatMap((label) => ["--label", label]),
|
|
47
57
|
],
|
|
48
58
|
environment: {},
|
|
49
59
|
})
|
|
@@ -19,6 +19,12 @@ const variables = {
|
|
|
19
19
|
|
|
20
20
|
describe("runner commands", () => {
|
|
21
21
|
test("uses promptless interactive commands for built-in presets", () => {
|
|
22
|
+
expect(
|
|
23
|
+
resolveRunnerCommand("opencode2", undefined, variables, false).argv,
|
|
24
|
+
).toEqual(["opencode2"])
|
|
25
|
+
expect(
|
|
26
|
+
resolveRunnerCommand("opencode2", undefined, variables, true).argv,
|
|
27
|
+
).toEqual(["opencode2", "--continue"])
|
|
22
28
|
expect(
|
|
23
29
|
resolveRunnerCommand("opencode", undefined, variables, false).argv,
|
|
24
30
|
).toEqual(["opencode"])
|
|
@@ -31,6 +37,12 @@ describe("runner commands", () => {
|
|
|
31
37
|
})
|
|
32
38
|
|
|
33
39
|
test("uses autonomous commands when a prompt is requested", () => {
|
|
40
|
+
expect(
|
|
41
|
+
resolveRunnerCommand("opencode2", undefined, variables, false, true).argv,
|
|
42
|
+
).toEqual(["opencode2", "--prompt", "Read the task."])
|
|
43
|
+
expect(
|
|
44
|
+
resolveRunnerCommand("opencode2", undefined, variables, true, true).argv,
|
|
45
|
+
).toEqual(["opencode2", "--continue", "--prompt", "Read the task."])
|
|
34
46
|
expect(
|
|
35
47
|
resolveRunnerCommand("opencode", undefined, variables, false, true).argv,
|
|
36
48
|
).toEqual(["opencode", "--prompt", "Read the task."])
|
|
@@ -31,6 +31,12 @@ const PLACEHOLDERS = new Set<keyof RunnerCommandVariables>([
|
|
|
31
31
|
])
|
|
32
32
|
|
|
33
33
|
const BUILTIN_RUNNERS: Readonly<Record<string, RunnerDefinition>> = {
|
|
34
|
+
opencode2: {
|
|
35
|
+
command: ["opencode2"],
|
|
36
|
+
autoCommand: ["opencode2", "--prompt", "{prompt}"],
|
|
37
|
+
resumeCommand: ["opencode2", "--continue"],
|
|
38
|
+
autoResumeCommand: ["opencode2", "--continue", "--prompt", "{prompt}"],
|
|
39
|
+
},
|
|
34
40
|
opencode: {
|
|
35
41
|
command: ["opencode"],
|
|
36
42
|
autoCommand: ["opencode", "--prompt", "{prompt}"],
|