@markjaquith/agency 2.53.0 → 2.54.1
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 +11 -6
- package/cli-main.ts +37 -10
- package/fixtures/protocol/orchestration-recipes.json +1 -1
- package/package.json +1 -1
- package/src/cli-parser.test.ts +30 -8
- package/src/cli-parser.ts +32 -13
- package/src/cli.test.ts +14 -0
- package/src/commands/claim.ts +1 -1
- package/src/commands/pr.test.ts +42 -2
- package/src/commands/pr.ts +29 -3
- package/src/commands/sync.test.ts +100 -0
- package/src/commands/sync.ts +44 -7
- package/src/services/IntegrationService.test.ts +1 -1
- package/src/services/PhaseService.ts +1 -1
- package/src/services/TaskService.ts +1 -1
- package/src/workbase/AGENTS.md +3 -3
package/README.md
CHANGED
|
@@ -489,9 +489,10 @@ materializations, then compares every execution declaration with local branch
|
|
|
489
489
|
and worktree registration, checkout dirtiness, resolved reference commits, claim
|
|
490
490
|
expiry, and pull request state, merge state, and mergeability. It reports
|
|
491
491
|
structured `changes`, `warnings`, `unresolved`, and per-execution evidence. The
|
|
492
|
-
default
|
|
492
|
+
default mode applies safe reconciliation transitions; `--dry-run` is explicitly
|
|
493
|
+
observational.
|
|
493
494
|
|
|
494
|
-
`agency sync
|
|
495
|
+
`agency sync` performs only these safe transitions:
|
|
495
496
|
|
|
496
497
|
- materialize declared but missing repositories from their canonical remotes;
|
|
497
498
|
- adopt legacy materializations only when they have an unambiguous portable origin;
|
|
@@ -722,7 +723,7 @@ Single-phase tasks and phases store status in YAML. New execution units start
|
|
|
722
723
|
`open`, and `agency work` marks the selected execution unit `working` immediately
|
|
723
724
|
before launch. Running `agency work` again can relaunch unclaimed `working` work.
|
|
724
725
|
By default, `done` requires an authoritative merged pull request and is applied
|
|
725
|
-
by `agency sync
|
|
726
|
+
by `agency sync`. Work whose intended outcome genuinely requires no pull
|
|
726
727
|
request may instead use an explicit `--no-pull-request --summary <text>` status
|
|
727
728
|
transition. Agency records the summary, completion time, and optional evidence
|
|
728
729
|
URL durably; reopening removes that evidence. This exceptional path refuses work
|
|
@@ -804,6 +805,7 @@ for restoration. Archived IDs are reserved until restored.
|
|
|
804
805
|
agency work [<directory> | --epic <epic-id>] [--runner <name>] [--auto] [--print-command]
|
|
805
806
|
agency work prepare [target] [--dry-run] [--json]
|
|
806
807
|
agency worktree <list|inspect|prepare|remove|rebuild|repair>
|
|
808
|
+
agency pr create <task-id> [phase-id] [--draft] [--force] [--json]
|
|
807
809
|
agency pr [args...]
|
|
808
810
|
```
|
|
809
811
|
|
|
@@ -850,9 +852,12 @@ Agency resolves the ref to a commit and creates a detached worktree. Existing
|
|
|
850
852
|
reference worktrees are reused only while their commit still matches the declared
|
|
851
853
|
ref; use a commit SHA as `ref` when reproducibility matters.
|
|
852
854
|
|
|
853
|
-
`agency pr
|
|
854
|
-
|
|
855
|
-
|
|
855
|
+
Task-aware `agency pr create <task-id> [phase-id]` uses Agency's delivery flow,
|
|
856
|
+
including readiness checks and durable PR recording. Other `agency pr`
|
|
857
|
+
invocations forward every argument to `gh pr`. From an execution task or phase
|
|
858
|
+
directory, including descendants, passthrough runs in that execution unit's
|
|
859
|
+
authoritative writable checkout. Otherwise it runs in the caller's current
|
|
860
|
+
directory.
|
|
856
861
|
|
|
857
862
|
### Status and Validation
|
|
858
863
|
|
package/cli-main.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { join, resolve } from "node:path"
|
|
|
5
5
|
import { parseCli } from "./src/cli-parser"
|
|
6
6
|
import { init, help as initHelp } from "./src/commands/init"
|
|
7
7
|
import { task, help as taskHelp } from "./src/commands/task"
|
|
8
|
-
import { pr, help as prHelp } from "./src/commands/pr"
|
|
8
|
+
import { pr, prCreate, help as prHelp } from "./src/commands/pr"
|
|
9
9
|
import { work, workPrepare, help as workHelp } from "./src/commands/work"
|
|
10
10
|
import { worktree, help as worktreeHelp } from "./src/commands/worktree"
|
|
11
11
|
import { status, help as statusHelp } from "./src/commands/status"
|
|
@@ -134,7 +134,7 @@ const resolveInvocationCwd = (
|
|
|
134
134
|
) =>
|
|
135
135
|
runEffect(
|
|
136
136
|
Effect.gen(function* () {
|
|
137
|
-
if (commandName === "pr") {
|
|
137
|
+
if (commandName === "pr" && options.passthrough) {
|
|
138
138
|
return resolve(options.cwd ?? process.cwd())
|
|
139
139
|
}
|
|
140
140
|
if (
|
|
@@ -287,7 +287,22 @@ const commands: Record<string, Command> = {
|
|
|
287
287
|
console.log(prHelp)
|
|
288
288
|
return
|
|
289
289
|
}
|
|
290
|
-
|
|
290
|
+
if (options.passthrough) {
|
|
291
|
+
process.exitCode = await runEffect(pr(args, options.cwd))
|
|
292
|
+
return
|
|
293
|
+
}
|
|
294
|
+
await runCommand(
|
|
295
|
+
prCreate({
|
|
296
|
+
taskId: args[1]!,
|
|
297
|
+
phaseId: args[2],
|
|
298
|
+
draft: options.draft,
|
|
299
|
+
force: options.force,
|
|
300
|
+
json: options.json,
|
|
301
|
+
silent: options.silent,
|
|
302
|
+
verbose: options.verbose,
|
|
303
|
+
cwd: options.cwd,
|
|
304
|
+
}),
|
|
305
|
+
)
|
|
291
306
|
},
|
|
292
307
|
},
|
|
293
308
|
phase: {
|
|
@@ -694,7 +709,6 @@ const commands: Record<string, Command> = {
|
|
|
694
709
|
}
|
|
695
710
|
await runCommand(
|
|
696
711
|
sync({
|
|
697
|
-
apply: options.apply,
|
|
698
712
|
dryRun: options["dry-run"],
|
|
699
713
|
json: options.json,
|
|
700
714
|
silent: options.silent,
|
|
@@ -727,7 +741,7 @@ Commands:
|
|
|
727
741
|
worktree <subcommand> Inspect and maintain managed workspaces
|
|
728
742
|
vcs <subcommand> Inspect or migrate the version-control backend
|
|
729
743
|
next List or select ready execution units
|
|
730
|
-
pr [
|
|
744
|
+
pr create / pr [...] Create an Agency PR or run gh pr with repository focus
|
|
731
745
|
review refresh Explicitly refresh a pinned review task
|
|
732
746
|
repo <subcommand> Manage workbase repositories
|
|
733
747
|
status Show status for the current workbase
|
|
@@ -762,7 +776,7 @@ const machineMode = process.argv
|
|
|
762
776
|
|
|
763
777
|
try {
|
|
764
778
|
const args = process.argv.slice(2)
|
|
765
|
-
const { commandName, args: commandArgs, values } = parseCli(args)
|
|
779
|
+
const { commandName, args: commandArgs, passthrough, values } = parseCli(args)
|
|
766
780
|
|
|
767
781
|
// Handle global flags
|
|
768
782
|
if (values.version) {
|
|
@@ -786,16 +800,29 @@ try {
|
|
|
786
800
|
!values.json &&
|
|
787
801
|
!values["no-input"] &&
|
|
788
802
|
Boolean(process.stdin.isTTY && process.stdout.isTTY)
|
|
789
|
-
const cwd = await resolveInvocationCwd(commandName,
|
|
803
|
+
const cwd = await resolveInvocationCwd(commandName, {
|
|
804
|
+
...values,
|
|
805
|
+
passthrough,
|
|
806
|
+
})
|
|
790
807
|
if (values.json || (values.jsonl && values.help)) {
|
|
791
808
|
const result = await collectCommandResult(() =>
|
|
792
|
-
command.run(commandArgs, { ...values, cwd, inputAllowed }),
|
|
809
|
+
command.run(commandArgs, { ...values, cwd, inputAllowed, passthrough }),
|
|
793
810
|
)
|
|
794
811
|
writeEnvelope(successEnvelope(result))
|
|
795
812
|
} else if (values.jsonl) {
|
|
796
|
-
await command.run(commandArgs, {
|
|
813
|
+
await command.run(commandArgs, {
|
|
814
|
+
...values,
|
|
815
|
+
cwd,
|
|
816
|
+
inputAllowed: false,
|
|
817
|
+
passthrough,
|
|
818
|
+
})
|
|
797
819
|
} else {
|
|
798
|
-
await command.run(commandArgs, {
|
|
820
|
+
await command.run(commandArgs, {
|
|
821
|
+
...values,
|
|
822
|
+
cwd,
|
|
823
|
+
inputAllowed,
|
|
824
|
+
passthrough,
|
|
825
|
+
})
|
|
799
826
|
}
|
|
800
827
|
} catch (error) {
|
|
801
828
|
if (machineMode) {
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
["work", "prepare", "--task", "checkout", "--phase", "ui", "--json"],
|
|
31
31
|
["work", "tasks/checkout/phases/ui", "--runner", "opencode"],
|
|
32
32
|
["sync", "--dry-run", "--json"],
|
|
33
|
-
["sync", "--
|
|
33
|
+
["sync", "--json"],
|
|
34
34
|
["pr", "create", "checkout", "ui", "--json"],
|
|
35
35
|
[
|
|
36
36
|
"finish",
|
package/package.json
CHANGED
package/src/cli-parser.test.ts
CHANGED
|
@@ -367,22 +367,44 @@ describe("strict CLI parsing", () => {
|
|
|
367
367
|
expect(parseCli(["--cwd", "/workbase", "pr", ...args])).toEqual({
|
|
368
368
|
commandName: "pr",
|
|
369
369
|
args,
|
|
370
|
+
passthrough: true,
|
|
370
371
|
values: { cwd: "/workbase" },
|
|
371
372
|
})
|
|
372
373
|
})
|
|
373
374
|
|
|
374
|
-
test("parses
|
|
375
|
-
expect(
|
|
375
|
+
test("parses task-aware pr create without routing it to passthrough", () => {
|
|
376
|
+
expect(
|
|
377
|
+
parseCli([
|
|
378
|
+
"pr",
|
|
379
|
+
"create",
|
|
380
|
+
"ship",
|
|
381
|
+
"release",
|
|
382
|
+
"--draft",
|
|
383
|
+
"--force",
|
|
384
|
+
"--json",
|
|
385
|
+
]),
|
|
386
|
+
).toEqual({
|
|
387
|
+
commandName: "pr",
|
|
388
|
+
args: ["create", "ship", "release"],
|
|
389
|
+
values: { draft: true, force: true, json: true },
|
|
390
|
+
})
|
|
391
|
+
const selected = parseCli(["pr", "create", "--task", "ship"])
|
|
392
|
+
expect(selected).toMatchObject({
|
|
393
|
+
args: ["create", "ship"],
|
|
394
|
+
})
|
|
395
|
+
expect(selected.passthrough).toBeUndefined()
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
test("parses explicit dry-run reconciliation", () => {
|
|
399
|
+
expect(parseCli(["sync"])).toMatchObject({
|
|
376
400
|
commandName: "sync",
|
|
377
|
-
values: {
|
|
401
|
+
values: {},
|
|
378
402
|
})
|
|
379
|
-
expect(parseCli(["sync", "--
|
|
403
|
+
expect(parseCli(["sync", "--dry-run", "--json"])).toMatchObject({
|
|
380
404
|
commandName: "sync",
|
|
381
|
-
values: {
|
|
405
|
+
values: { "dry-run": true, json: true },
|
|
382
406
|
})
|
|
383
|
-
expect(() => parseCli(["sync", "--
|
|
384
|
-
"cannot be combined",
|
|
385
|
-
)
|
|
407
|
+
expect(() => parseCli(["sync", "--apply"])).toThrow("Unknown option")
|
|
386
408
|
})
|
|
387
409
|
|
|
388
410
|
test("accepts archive dry-run", () => {
|
package/src/cli-parser.ts
CHANGED
|
@@ -678,18 +678,16 @@ const commands = {
|
|
|
678
678
|
},
|
|
679
679
|
},
|
|
680
680
|
sync: {
|
|
681
|
-
usage: "agency sync [--dry-run
|
|
681
|
+
usage: "agency sync [--dry-run] [--json]",
|
|
682
682
|
options: {
|
|
683
683
|
...outputOptions,
|
|
684
684
|
"dry-run": { type: "boolean" },
|
|
685
|
-
apply: { type: "boolean" },
|
|
686
685
|
},
|
|
687
686
|
command: {
|
|
688
|
-
usage: "agency sync [--dry-run
|
|
687
|
+
usage: "agency sync [--dry-run] [--json]",
|
|
689
688
|
minArgs: 0,
|
|
690
689
|
maxArgs: 0,
|
|
691
|
-
options: ["dry-run", "
|
|
692
|
-
conflicts: [["dry-run", "apply"]],
|
|
690
|
+
options: ["dry-run", "json"],
|
|
693
691
|
},
|
|
694
692
|
},
|
|
695
693
|
archive: {
|
|
@@ -897,12 +895,22 @@ const commands = {
|
|
|
897
895
|
},
|
|
898
896
|
},
|
|
899
897
|
pr: {
|
|
900
|
-
usage: "agency pr [args...]",
|
|
901
|
-
options:
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
898
|
+
usage: "agency pr create <task-id> [phase-id] | agency pr [args...]",
|
|
899
|
+
options: {
|
|
900
|
+
...outputOptions,
|
|
901
|
+
draft: { type: "boolean" },
|
|
902
|
+
force: { type: "boolean" },
|
|
903
|
+
task: { type: "string" },
|
|
904
|
+
phase: { type: "string" },
|
|
905
|
+
},
|
|
906
|
+
subcommands: {
|
|
907
|
+
create: {
|
|
908
|
+
usage:
|
|
909
|
+
"agency pr create <task-id> [phase-id] [--draft] [--force] [--json]",
|
|
910
|
+
minArgs: 1,
|
|
911
|
+
maxArgs: 2,
|
|
912
|
+
options: ["draft", "force", "json", "task", "phase"],
|
|
913
|
+
},
|
|
906
914
|
},
|
|
907
915
|
},
|
|
908
916
|
next: {
|
|
@@ -1021,6 +1029,7 @@ const preCommandValueOptions = new Set(["--workbase", "--cwd"])
|
|
|
1021
1029
|
export interface ParsedCli {
|
|
1022
1030
|
readonly commandName?: keyof typeof commands
|
|
1023
1031
|
readonly args: string[]
|
|
1032
|
+
readonly passthrough?: boolean
|
|
1024
1033
|
readonly values: Record<
|
|
1025
1034
|
string,
|
|
1026
1035
|
boolean | string | (boolean | string)[] | undefined
|
|
@@ -1093,6 +1102,7 @@ const targetSlots = (
|
|
|
1093
1102
|
if (commandName === "worktree" && subcommand !== "list") {
|
|
1094
1103
|
return ["task", "phase"]
|
|
1095
1104
|
}
|
|
1105
|
+
if (commandName === "pr" && subcommand === "create") return ["task", "phase"]
|
|
1096
1106
|
return []
|
|
1097
1107
|
}
|
|
1098
1108
|
|
|
@@ -1313,7 +1323,15 @@ export function parseCli(args: readonly string[]): ParsedCli {
|
|
|
1313
1323
|
"agency <command> [options]",
|
|
1314
1324
|
)
|
|
1315
1325
|
}
|
|
1316
|
-
|
|
1326
|
+
const prArgs = args.slice(commandIndex + 1)
|
|
1327
|
+
const taskAwarePrCreate =
|
|
1328
|
+
commandName === "pr" &&
|
|
1329
|
+
prArgs[0] === "create" &&
|
|
1330
|
+
((prArgs[1] !== undefined && !prArgs[1].startsWith("-")) ||
|
|
1331
|
+
prArgs.some(
|
|
1332
|
+
(argument) => argument === "--task" || argument.startsWith("--task="),
|
|
1333
|
+
))
|
|
1334
|
+
if (commandName === "pr" && !taskAwarePrCreate) {
|
|
1317
1335
|
const parsed = parse(
|
|
1318
1336
|
args.slice(0, commandIndex),
|
|
1319
1337
|
rootOptions,
|
|
@@ -1338,7 +1356,8 @@ export function parseCli(args: readonly string[]): ParsedCli {
|
|
|
1338
1356
|
}
|
|
1339
1357
|
return {
|
|
1340
1358
|
commandName,
|
|
1341
|
-
args:
|
|
1359
|
+
args: prArgs,
|
|
1360
|
+
passthrough: true,
|
|
1342
1361
|
values: parsed.values,
|
|
1343
1362
|
}
|
|
1344
1363
|
}
|
package/src/cli.test.ts
CHANGED
|
@@ -598,6 +598,20 @@ exit 23
|
|
|
598
598
|
blockers: [{ kind: "status", reason: "Task status is dropped" }],
|
|
599
599
|
},
|
|
600
600
|
])
|
|
601
|
+
|
|
602
|
+
const blockedPr = await runCli(["pr", "create", "finished", "--json"], root)
|
|
603
|
+
expect(blockedPr.exitCode).toBe(1)
|
|
604
|
+
expect(blockedPr.stderr).toBe("")
|
|
605
|
+
expect(JSON.parse(blockedPr.stdout)).toMatchObject({
|
|
606
|
+
ok: false,
|
|
607
|
+
error: {
|
|
608
|
+
code: "EXECUTION_BLOCKED",
|
|
609
|
+
fields: {
|
|
610
|
+
status: "dropped",
|
|
611
|
+
blockers: [{ kind: "status", reason: "Task status is dropped" }],
|
|
612
|
+
},
|
|
613
|
+
},
|
|
614
|
+
})
|
|
601
615
|
})
|
|
602
616
|
|
|
603
617
|
test("reports and synchronizes managed integration files", async () => {
|
package/src/commands/claim.ts
CHANGED
|
@@ -117,6 +117,6 @@ export const finishHelp = `
|
|
|
117
117
|
Usage: agency finish <task-id> [phase-id] --session-id <id> --revision <sha256> --outcome <done|dropped> [--no-pull-request --summary <text> [--evidence-url <url>]]
|
|
118
118
|
|
|
119
119
|
Finish a claim owned by the session. A done claim outcome leaves unmerged work
|
|
120
|
-
working; agency sync
|
|
120
|
+
working; agency sync marks the execution unit done after merge. Use
|
|
121
121
|
--no-pull-request with a summary for an explicit non-PR completion.
|
|
122
122
|
`
|
package/src/commands/pr.test.ts
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
|
2
|
+
import { Effect } from "effect"
|
|
2
3
|
import { chmod, mkdir, realpath } from "node:fs/promises"
|
|
3
4
|
import { dirname, join } from "node:path"
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
5
|
+
import { PullRequestService } from "../services/PullRequestService"
|
|
6
|
+
import {
|
|
7
|
+
captureLogs,
|
|
8
|
+
cleanupTempDir,
|
|
9
|
+
createTempDir,
|
|
10
|
+
runTestEffect,
|
|
11
|
+
} from "../test-utils"
|
|
12
|
+
import { pr, prCreate } from "./pr"
|
|
6
13
|
|
|
7
14
|
const write = async (root: string, path: string, content: string) => {
|
|
8
15
|
const fullPath = join(root, path)
|
|
@@ -95,6 +102,39 @@ status: open
|
|
|
95
102
|
return root
|
|
96
103
|
}
|
|
97
104
|
|
|
105
|
+
test("creates and records an Agency pull request", async () => {
|
|
106
|
+
const url = "https://github.com/markjaquith/agency/pull/123"
|
|
107
|
+
let received: unknown[] = []
|
|
108
|
+
const logs = await captureLogs(() =>
|
|
109
|
+
Effect.runPromise(
|
|
110
|
+
prCreate({
|
|
111
|
+
taskId: "example",
|
|
112
|
+
phaseId: "implementation",
|
|
113
|
+
draft: true,
|
|
114
|
+
force: true,
|
|
115
|
+
cwd: "/workbase",
|
|
116
|
+
json: true,
|
|
117
|
+
}).pipe(
|
|
118
|
+
Effect.provideService(PullRequestService, {
|
|
119
|
+
create: (...args: unknown[]) => {
|
|
120
|
+
received = args
|
|
121
|
+
return Effect.succeed(url)
|
|
122
|
+
},
|
|
123
|
+
} as never),
|
|
124
|
+
) as Effect.Effect<void, unknown, never>,
|
|
125
|
+
),
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
expect(JSON.parse(logs[0]!)).toEqual({ url })
|
|
129
|
+
expect(received).toEqual([
|
|
130
|
+
"example",
|
|
131
|
+
"implementation",
|
|
132
|
+
true,
|
|
133
|
+
"/workbase",
|
|
134
|
+
expect.objectContaining({ force: true, draft: true, json: true }),
|
|
135
|
+
])
|
|
136
|
+
})
|
|
137
|
+
|
|
98
138
|
test("focuses task and descendant invocations on the writable checkout", async () => {
|
|
99
139
|
const root = await createExecutionWorkbase()
|
|
100
140
|
const task = join(root, "tasks/single")
|
package/src/commands/pr.ts
CHANGED
|
@@ -2,6 +2,30 @@ import { Effect } from "effect"
|
|
|
2
2
|
import { resolve } from "node:path"
|
|
3
3
|
import { ContextService } from "../services/ContextService"
|
|
4
4
|
import { FileSystemService } from "../services/FileSystemService"
|
|
5
|
+
import { PullRequestService } from "../services/PullRequestService"
|
|
6
|
+
import type { BaseCommandOptions } from "../utils/command"
|
|
7
|
+
import { createLoggers } from "../utils/effect"
|
|
8
|
+
|
|
9
|
+
interface PrCreateOptions extends BaseCommandOptions {
|
|
10
|
+
readonly taskId: string
|
|
11
|
+
readonly phaseId?: string
|
|
12
|
+
readonly draft?: boolean
|
|
13
|
+
readonly force?: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const prCreate = (options: PrCreateOptions) =>
|
|
17
|
+
Effect.gen(function* () {
|
|
18
|
+
const pullRequests = yield* PullRequestService
|
|
19
|
+
const { log } = createLoggers(options)
|
|
20
|
+
const url = yield* pullRequests.create(
|
|
21
|
+
options.taskId,
|
|
22
|
+
options.phaseId,
|
|
23
|
+
options.draft,
|
|
24
|
+
options.cwd ?? process.cwd(),
|
|
25
|
+
options,
|
|
26
|
+
)
|
|
27
|
+
log(options.json ? JSON.stringify({ url }, null, 2) : url)
|
|
28
|
+
})
|
|
5
29
|
|
|
6
30
|
export const pr = (args: readonly string[], cwd: string = process.cwd()) =>
|
|
7
31
|
Effect.gen(function* () {
|
|
@@ -24,8 +48,10 @@ export const pr = (args: readonly string[], cwd: string = process.cwd()) =>
|
|
|
24
48
|
})
|
|
25
49
|
|
|
26
50
|
export const help = `
|
|
27
|
-
Usage: agency pr [
|
|
51
|
+
Usage: agency pr create <task-id> [phase-id] [--draft] [--force] [--json]
|
|
52
|
+
agency pr [args...]
|
|
28
53
|
|
|
29
|
-
|
|
30
|
-
|
|
54
|
+
Create records a pull request for an Agency execution unit. Other invocations
|
|
55
|
+
run gh pr unchanged, focusing the writable repository checkout when invoked from
|
|
56
|
+
an Agency execution task or phase.
|
|
31
57
|
`
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
|
2
|
+
import { Effect } from "effect"
|
|
3
|
+
import { mkdir } from "node:fs/promises"
|
|
4
|
+
import { join } from "node:path"
|
|
5
|
+
import { TaskService } from "../services/TaskService"
|
|
6
|
+
import {
|
|
7
|
+
captureLogs,
|
|
8
|
+
cleanupTempDir,
|
|
9
|
+
createTempDir,
|
|
10
|
+
runTestEffect,
|
|
11
|
+
} from "../test-utils"
|
|
12
|
+
import { sync } from "./sync"
|
|
13
|
+
|
|
14
|
+
const git = async (args: string[], cwd: string) => {
|
|
15
|
+
const process = Bun.spawn(["git", ...args], {
|
|
16
|
+
cwd,
|
|
17
|
+
stdout: "pipe",
|
|
18
|
+
stderr: "pipe",
|
|
19
|
+
})
|
|
20
|
+
await process.exited
|
|
21
|
+
if (process.exitCode !== 0) {
|
|
22
|
+
throw new Error(await new Response(process.stderr).text())
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe("sync command", () => {
|
|
27
|
+
let root: string
|
|
28
|
+
|
|
29
|
+
beforeEach(async () => {
|
|
30
|
+
root = await createTempDir()
|
|
31
|
+
const source = join(root, "source")
|
|
32
|
+
await mkdir(source)
|
|
33
|
+
await git(["init", "--initial-branch=main"], source)
|
|
34
|
+
await git(["config", "user.email", "test@example.com"], source)
|
|
35
|
+
await git(["config", "user.name", "Test"], source)
|
|
36
|
+
await Bun.write(join(source, "README.md"), "example\n")
|
|
37
|
+
await git(["add", "README.md"], source)
|
|
38
|
+
await git(["-c", "commit.gpgsign=false", "commit", "-m", "initial"], source)
|
|
39
|
+
await mkdir(join(root, "repos"))
|
|
40
|
+
await git(["clone", "--bare", source, join(root, "repos/agency")], root)
|
|
41
|
+
await Bun.write(join(root, "agency.json"), '{"version":2}\n')
|
|
42
|
+
await runTestEffect(
|
|
43
|
+
TaskService.pipe(
|
|
44
|
+
Effect.flatMap((service) =>
|
|
45
|
+
service.create(
|
|
46
|
+
{
|
|
47
|
+
id: "example",
|
|
48
|
+
ticketUrl: null,
|
|
49
|
+
repo: "agency",
|
|
50
|
+
branch: "task/example",
|
|
51
|
+
base: "main",
|
|
52
|
+
},
|
|
53
|
+
root,
|
|
54
|
+
),
|
|
55
|
+
),
|
|
56
|
+
),
|
|
57
|
+
)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
afterEach(async () => cleanupTempDir(root))
|
|
61
|
+
|
|
62
|
+
test("applies safe reconciliation by default", async () => {
|
|
63
|
+
const logs = await captureLogs(() => runTestEffect(sync({ cwd: root })))
|
|
64
|
+
|
|
65
|
+
expect(
|
|
66
|
+
await Bun.file(join(root, "tasks/example/code/agency/README.md")).text(),
|
|
67
|
+
).toBe("example\n")
|
|
68
|
+
expect(logs).toContainEqual(
|
|
69
|
+
expect.stringContaining("Applied materialize-workspace 'task:example'"),
|
|
70
|
+
)
|
|
71
|
+
expect(() => JSON.parse(logs.join("\n"))).toThrow()
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test("keeps explicit dry-run observational", async () => {
|
|
75
|
+
await runTestEffect(sync({ cwd: root, dryRun: true, silent: true }))
|
|
76
|
+
|
|
77
|
+
expect(
|
|
78
|
+
await Bun.file(
|
|
79
|
+
join(root, "tasks/example/code/agency/README.md"),
|
|
80
|
+
).exists(),
|
|
81
|
+
).toBe(false)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test("preserves structured output behind --json", async () => {
|
|
85
|
+
const logs = await captureLogs(() =>
|
|
86
|
+
runTestEffect(sync({ cwd: root, dryRun: true, json: true })),
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
expect(logs).toHaveLength(1)
|
|
90
|
+
expect(JSON.parse(logs[0]!)).toMatchObject({
|
|
91
|
+
mode: "dry-run",
|
|
92
|
+
changes: [
|
|
93
|
+
expect.objectContaining({
|
|
94
|
+
kind: "materialize-workspace",
|
|
95
|
+
status: "planned",
|
|
96
|
+
}),
|
|
97
|
+
],
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
})
|
package/src/commands/sync.ts
CHANGED
|
@@ -4,7 +4,6 @@ import type { BaseCommandOptions } from "../utils/command"
|
|
|
4
4
|
import { createLoggers } from "../utils/effect"
|
|
5
5
|
|
|
6
6
|
interface SyncCommandOptions extends BaseCommandOptions {
|
|
7
|
-
readonly apply?: boolean
|
|
8
7
|
readonly dryRun?: boolean
|
|
9
8
|
}
|
|
10
9
|
|
|
@@ -14,24 +13,62 @@ export const sync = (options: SyncCommandOptions = {}) =>
|
|
|
14
13
|
const { log } = createLoggers(options)
|
|
15
14
|
const result = yield* service.reconcile({
|
|
16
15
|
cwd: options.cwd,
|
|
17
|
-
apply: options.
|
|
16
|
+
apply: options.dryRun !== true,
|
|
18
17
|
})
|
|
19
|
-
log(JSON.stringify(result, null, 2))
|
|
18
|
+
if (options.json) return log(JSON.stringify(result, null, 2))
|
|
19
|
+
|
|
20
|
+
for (const action of result.repositories.actions) {
|
|
21
|
+
log(
|
|
22
|
+
`${action.status === "applied" ? "Applied" : "Planned"} repository ${action.kind} '${action.alias}' from ${action.remote}`,
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
for (const change of result.changes) {
|
|
26
|
+
log(
|
|
27
|
+
`${change.status === "applied" ? "Applied" : "Planned"} ${change.kind} '${change.target}': ${change.message}`,
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
for (const warning of result.warnings) {
|
|
31
|
+
log(
|
|
32
|
+
`Warning '${warning.target}': ${warning.message}${warning.action ? `. ${warning.action}` : ""}`,
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
for (const issue of result.repositories.unresolved) {
|
|
36
|
+
log(
|
|
37
|
+
`Unresolved repository '${issue.alias}': ${issue.message}. ${issue.action}`,
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
for (const issue of result.unresolved) {
|
|
41
|
+
log(
|
|
42
|
+
`Unresolved '${issue.target}': ${issue.message}${issue.action ? `. ${issue.action}` : ""}`,
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
if (
|
|
46
|
+
result.repositories.actions.length === 0 &&
|
|
47
|
+
result.changes.length === 0 &&
|
|
48
|
+
result.warnings.length === 0 &&
|
|
49
|
+
result.repositories.unresolved.length === 0 &&
|
|
50
|
+
result.unresolved.length === 0
|
|
51
|
+
) {
|
|
52
|
+
log(
|
|
53
|
+
result.mode === "apply"
|
|
54
|
+
? "Workbase sync is current"
|
|
55
|
+
: "Workbase sync plan is current",
|
|
56
|
+
)
|
|
57
|
+
}
|
|
20
58
|
})
|
|
21
59
|
|
|
22
60
|
export const help = `
|
|
23
|
-
Usage: agency sync [--dry-run
|
|
61
|
+
Usage: agency sync [--dry-run] [--json]
|
|
24
62
|
|
|
25
63
|
Compare portable repository declarations and execution state with local Git
|
|
26
64
|
repositories, worktrees, branches, references, claims, and pull requests.
|
|
27
|
-
|
|
65
|
+
Safe reconciliation transitions are applied by default.
|
|
28
66
|
|
|
29
67
|
Options:
|
|
30
68
|
--dry-run Report planned safe transitions without changing state
|
|
31
|
-
--apply Apply safe reconciliation transitions
|
|
32
69
|
--json Output one versioned machine result
|
|
33
70
|
|
|
34
|
-
|
|
71
|
+
Sync may materialize declared repositories and unambiguous missing checkouts,
|
|
35
72
|
adopt legacy repositories with portable origins, release expired claims,
|
|
36
73
|
record a uniquely matched PR, and mark merged work done. Dirty, stale, or
|
|
37
74
|
conflicting checkouts are always left unresolved.
|
|
@@ -282,7 +282,7 @@ describe("IntegrationService", () => {
|
|
|
282
282
|
expect(body).toMatch(/completing\s+a refinement loop/)
|
|
283
283
|
expect(body).toContain("pausing or handing off")
|
|
284
284
|
expect(body).toContain("`agency finish`")
|
|
285
|
-
expect(body).toContain("`agency sync
|
|
285
|
+
expect(body).toContain("`agency sync`")
|
|
286
286
|
expect(body).toContain("`--no-pull-request --summary <text>`")
|
|
287
287
|
expect(body).toContain("`TASK.md` or `PHASE.md`")
|
|
288
288
|
expect(body).toContain("PR state, current head, diff summary")
|
|
@@ -654,7 +654,7 @@ export class PhaseService extends Effect.Service<PhaseService>()(
|
|
|
654
654
|
if (validStatus === "done") {
|
|
655
655
|
return yield* new PhaseError({
|
|
656
656
|
message:
|
|
657
|
-
"Work becomes done after its authoritative pull request is merged; run 'agency sync
|
|
657
|
+
"Work becomes done after its authoritative pull request is merged; run 'agency sync', or explicitly complete a non-PR outcome with '--no-pull-request --summary <text>'",
|
|
658
658
|
})
|
|
659
659
|
}
|
|
660
660
|
return yield* new PhaseError({
|
|
@@ -381,7 +381,7 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
|
|
|
381
381
|
if (validStatus === "done") {
|
|
382
382
|
return yield* new TaskError({
|
|
383
383
|
message:
|
|
384
|
-
"Work becomes done after its authoritative pull request is merged; run 'agency sync
|
|
384
|
+
"Work becomes done after its authoritative pull request is merged; run 'agency sync', or explicitly complete a non-PR outcome with '--no-pull-request --summary <text>'",
|
|
385
385
|
})
|
|
386
386
|
}
|
|
387
387
|
return yield* new TaskError({
|
package/src/workbase/AGENTS.md
CHANGED
|
@@ -59,8 +59,8 @@ to override readiness.
|
|
|
59
59
|
- Do not run `agency work` from an active agent session unless the user
|
|
60
60
|
explicitly asks to launch another agent.
|
|
61
61
|
- Run `agency validate` before worktree or pull-request operations.
|
|
62
|
-
- Create a pull request only with explicit user intent. Run
|
|
63
|
-
|
|
62
|
+
- Create a pull request only with explicit user intent. Run
|
|
63
|
+
`agency pr create <task> [phase]` so the URL is recorded durably.
|
|
64
64
|
|
|
65
65
|
## Execution
|
|
66
66
|
|
|
@@ -91,7 +91,7 @@ a refinement loop, or pausing or handing off completed implementation work):
|
|
|
91
91
|
- Finish an active claim with the current revision via `agency finish`; a
|
|
92
92
|
successful claim outcome leaves unmerged work `working`. For unclaimed work,
|
|
93
93
|
keep the execution unit `working` through review and merge.
|
|
94
|
-
- After merge, run `agency sync
|
|
94
|
+
- After merge, run `agency sync` to reconcile the execution unit to
|
|
95
95
|
`done`.
|
|
96
96
|
- For an approved non-PR outcome, finish an active claim or update unclaimed
|
|
97
97
|
status with `--no-pull-request --summary <text>` and optional supporting URL.
|