@markjaquith/agency 2.52.2 → 2.54.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 +15 -13
- package/cli-main.ts +5 -15
- package/fixtures/protocol/orchestration-recipes.json +1 -1
- package/package.json +1 -1
- package/src/cli-parser.test.ts +15 -13
- package/src/cli-parser.ts +38 -22
- package/src/cli.test.ts +30 -16
- package/src/commands/claim.ts +1 -1
- package/src/commands/pr.test.ts +139 -37
- package/src/commands/pr.ts +23 -35
- package/src/commands/sync.test.ts +100 -0
- package/src/commands/sync.ts +44 -7
- package/src/services/FileSystemService.ts +17 -8
- 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
|
@@ -10,7 +10,7 @@ read or write.
|
|
|
10
10
|
- [Bun](https://bun.sh) 1.0 or newer
|
|
11
11
|
- Git
|
|
12
12
|
- [Jujutsu](https://jj-vcs.github.io/jj/) is preferred when available
|
|
13
|
-
- [GitHub CLI](https://cli.github.com/) for `agency pr
|
|
13
|
+
- [GitHub CLI](https://cli.github.com/) for `agency pr`
|
|
14
14
|
- OpenCode, Claude Code, or a configured runner for `agency work`
|
|
15
15
|
|
|
16
16
|
## Installation
|
|
@@ -401,7 +401,8 @@ agency task new
|
|
|
401
401
|
agency validate
|
|
402
402
|
agency context tasks/refresh-copy --json
|
|
403
403
|
agency work tasks/refresh-copy
|
|
404
|
-
|
|
404
|
+
cd tasks/refresh-copy
|
|
405
|
+
agency pr create --fill
|
|
405
406
|
```
|
|
406
407
|
|
|
407
408
|
After cloning an existing workbase on another machine, restore its declared
|
|
@@ -476,10 +477,10 @@ priority ready unit in human output.
|
|
|
476
477
|
execution unit. Excluded entries retain status, terminal state, `blockedBy`, and
|
|
477
478
|
detailed dependency, validation, or status blockers for orchestrators.
|
|
478
479
|
|
|
479
|
-
`agency work`
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
480
|
+
`agency work` consults this shared readiness model before materializing. Blocked,
|
|
481
|
+
done, and dropped targets are rejected unless `--force` is supplied explicitly.
|
|
482
|
+
`agency pr` leaves command semantics to `gh`; run `agency context . --json` first
|
|
483
|
+
when readiness or validation state needs inspection.
|
|
483
484
|
|
|
484
485
|
### Reconciliation
|
|
485
486
|
|
|
@@ -488,9 +489,10 @@ materializations, then compares every execution declaration with local branch
|
|
|
488
489
|
and worktree registration, checkout dirtiness, resolved reference commits, claim
|
|
489
490
|
expiry, and pull request state, merge state, and mergeability. It reports
|
|
490
491
|
structured `changes`, `warnings`, `unresolved`, and per-execution evidence. The
|
|
491
|
-
default
|
|
492
|
+
default mode applies safe reconciliation transitions; `--dry-run` is explicitly
|
|
493
|
+
observational.
|
|
492
494
|
|
|
493
|
-
`agency sync
|
|
495
|
+
`agency sync` performs only these safe transitions:
|
|
494
496
|
|
|
495
497
|
- materialize declared but missing repositories from their canonical remotes;
|
|
496
498
|
- adopt legacy materializations only when they have an unambiguous portable origin;
|
|
@@ -721,7 +723,7 @@ Single-phase tasks and phases store status in YAML. New execution units start
|
|
|
721
723
|
`open`, and `agency work` marks the selected execution unit `working` immediately
|
|
722
724
|
before launch. Running `agency work` again can relaunch unclaimed `working` work.
|
|
723
725
|
By default, `done` requires an authoritative merged pull request and is applied
|
|
724
|
-
by `agency sync
|
|
726
|
+
by `agency sync`. Work whose intended outcome genuinely requires no pull
|
|
725
727
|
request may instead use an explicit `--no-pull-request --summary <text>` status
|
|
726
728
|
transition. Agency records the summary, completion time, and optional evidence
|
|
727
729
|
URL durably; reopening removes that evidence. This exceptional path refuses work
|
|
@@ -803,7 +805,7 @@ for restoration. Archived IDs are reserved until restored.
|
|
|
803
805
|
agency work [<directory> | --epic <epic-id>] [--runner <name>] [--auto] [--print-command]
|
|
804
806
|
agency work prepare [target] [--dry-run] [--json]
|
|
805
807
|
agency worktree <list|inspect|prepare|remove|rebuild|repair>
|
|
806
|
-
agency pr
|
|
808
|
+
agency pr [args...]
|
|
807
809
|
```
|
|
808
810
|
|
|
809
811
|
`agency work` presents the full hierarchy in the native OpenTUI selector or the
|
|
@@ -849,9 +851,9 @@ Agency resolves the ref to a commit and creates a detached worktree. Existing
|
|
|
849
851
|
reference worktrees are reused only while their commit still matches the declared
|
|
850
852
|
ref; use a commit SHA as `ref` when reproducibility matters.
|
|
851
853
|
|
|
852
|
-
`agency pr
|
|
853
|
-
|
|
854
|
-
the
|
|
854
|
+
`agency pr` forwards every argument to `gh pr`. From an execution task or phase
|
|
855
|
+
directory, including descendants, it runs in that execution unit's authoritative
|
|
856
|
+
writable checkout. Otherwise it runs in the caller's current directory.
|
|
855
857
|
|
|
856
858
|
### Status and Validation
|
|
857
859
|
|
package/cli-main.ts
CHANGED
|
@@ -134,6 +134,9 @@ const resolveInvocationCwd = (
|
|
|
134
134
|
) =>
|
|
135
135
|
runEffect(
|
|
136
136
|
Effect.gen(function* () {
|
|
137
|
+
if (commandName === "pr") {
|
|
138
|
+
return resolve(options.cwd ?? process.cwd())
|
|
139
|
+
}
|
|
137
140
|
if (
|
|
138
141
|
options.help ||
|
|
139
142
|
commandName === "init" ||
|
|
@@ -284,19 +287,7 @@ const commands: Record<string, Command> = {
|
|
|
284
287
|
console.log(prHelp)
|
|
285
288
|
return
|
|
286
289
|
}
|
|
287
|
-
await
|
|
288
|
-
pr({
|
|
289
|
-
subcommand: args[0],
|
|
290
|
-
taskId: args[1],
|
|
291
|
-
phaseId: args[2],
|
|
292
|
-
draft: options.draft,
|
|
293
|
-
force: options.force,
|
|
294
|
-
json: options.json,
|
|
295
|
-
silent: options.silent,
|
|
296
|
-
verbose: options.verbose,
|
|
297
|
-
cwd: options.cwd,
|
|
298
|
-
}),
|
|
299
|
-
)
|
|
290
|
+
process.exitCode = await runEffect(pr(args, options.cwd))
|
|
300
291
|
},
|
|
301
292
|
},
|
|
302
293
|
phase: {
|
|
@@ -703,7 +694,6 @@ const commands: Record<string, Command> = {
|
|
|
703
694
|
}
|
|
704
695
|
await runCommand(
|
|
705
696
|
sync({
|
|
706
|
-
apply: options.apply,
|
|
707
697
|
dryRun: options["dry-run"],
|
|
708
698
|
json: options.json,
|
|
709
699
|
silent: options.silent,
|
|
@@ -736,7 +726,7 @@ Commands:
|
|
|
736
726
|
worktree <subcommand> Inspect and maintain managed workspaces
|
|
737
727
|
vcs <subcommand> Inspect or migrate the version-control backend
|
|
738
728
|
next List or select ready execution units
|
|
739
|
-
pr
|
|
729
|
+
pr [args...] Run gh pr with Agency repository focus
|
|
740
730
|
review refresh Explicitly refresh a pinned review task
|
|
741
731
|
repo <subcommand> Manage workbase repositories
|
|
742
732
|
status Show status for the current workbase
|
|
@@ -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
|
@@ -292,7 +292,6 @@ describe("strict CLI parsing", () => {
|
|
|
292
292
|
[["restore", "task", "one", "two"], "agency restore task"],
|
|
293
293
|
[["restore", "phase", "one", "two", "three"], "agency restore phase"],
|
|
294
294
|
[["work", "one", "two"], "agency work"],
|
|
295
|
-
[["pr", "create", "one", "two", "three"], "agency pr create"],
|
|
296
295
|
[["status", "extra"], "agency status"],
|
|
297
296
|
[["validate", "one", "two"], "agency validate"],
|
|
298
297
|
[["context", "one", "two"], "agency context"],
|
|
@@ -358,27 +357,30 @@ describe("strict CLI parsing", () => {
|
|
|
358
357
|
commandName: "work",
|
|
359
358
|
values: { force: true },
|
|
360
359
|
})
|
|
361
|
-
expect(parseCli(["pr", "create", "example", "--force"])).toMatchObject({
|
|
362
|
-
commandName: "pr",
|
|
363
|
-
values: { force: true },
|
|
364
|
-
})
|
|
365
360
|
expect(() => parseCli(["work", "prepare", "example", "--force"])).toThrow(
|
|
366
361
|
"cannot be combined",
|
|
367
362
|
)
|
|
368
363
|
})
|
|
369
364
|
|
|
370
|
-
test("
|
|
371
|
-
|
|
365
|
+
test("preserves every argument after pr without parsing it", () => {
|
|
366
|
+
const args = ["create", "--title", "two words", "--", "--literal"]
|
|
367
|
+
expect(parseCli(["--cwd", "/workbase", "pr", ...args])).toEqual({
|
|
368
|
+
commandName: "pr",
|
|
369
|
+
args,
|
|
370
|
+
values: { cwd: "/workbase" },
|
|
371
|
+
})
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
test("parses explicit dry-run reconciliation", () => {
|
|
375
|
+
expect(parseCli(["sync"])).toMatchObject({
|
|
372
376
|
commandName: "sync",
|
|
373
|
-
values: {
|
|
377
|
+
values: {},
|
|
374
378
|
})
|
|
375
|
-
expect(parseCli(["sync", "--
|
|
379
|
+
expect(parseCli(["sync", "--dry-run", "--json"])).toMatchObject({
|
|
376
380
|
commandName: "sync",
|
|
377
|
-
values: {
|
|
381
|
+
values: { "dry-run": true, json: true },
|
|
378
382
|
})
|
|
379
|
-
expect(() => parseCli(["sync", "--
|
|
380
|
-
"cannot be combined",
|
|
381
|
-
)
|
|
383
|
+
expect(() => parseCli(["sync", "--apply"])).toThrow("Unknown option")
|
|
382
384
|
})
|
|
383
385
|
|
|
384
386
|
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,22 +895,12 @@ const commands = {
|
|
|
897
895
|
},
|
|
898
896
|
},
|
|
899
897
|
pr: {
|
|
900
|
-
usage: "agency pr
|
|
901
|
-
options:
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
phase: { type: "string" },
|
|
907
|
-
},
|
|
908
|
-
subcommands: {
|
|
909
|
-
create: {
|
|
910
|
-
usage:
|
|
911
|
-
"agency pr create <task-id> [phase-id] [--draft] [--force] [--json]",
|
|
912
|
-
minArgs: 1,
|
|
913
|
-
maxArgs: 2,
|
|
914
|
-
options: ["draft", "force", "json", "task", "phase"],
|
|
915
|
-
},
|
|
898
|
+
usage: "agency pr [args...]",
|
|
899
|
+
options: commonOptions,
|
|
900
|
+
command: {
|
|
901
|
+
usage: "agency pr [args...]",
|
|
902
|
+
minArgs: 0,
|
|
903
|
+
maxArgs: Number.POSITIVE_INFINITY,
|
|
916
904
|
},
|
|
917
905
|
},
|
|
918
906
|
next: {
|
|
@@ -1103,7 +1091,6 @@ const targetSlots = (
|
|
|
1103
1091
|
if (commandName === "worktree" && subcommand !== "list") {
|
|
1104
1092
|
return ["task", "phase"]
|
|
1105
1093
|
}
|
|
1106
|
-
if (commandName === "pr" && subcommand === "create") return ["task", "phase"]
|
|
1107
1094
|
return []
|
|
1108
1095
|
}
|
|
1109
1096
|
|
|
@@ -1324,6 +1311,35 @@ export function parseCli(args: readonly string[]): ParsedCli {
|
|
|
1324
1311
|
"agency <command> [options]",
|
|
1325
1312
|
)
|
|
1326
1313
|
}
|
|
1314
|
+
if (commandName === "pr") {
|
|
1315
|
+
const parsed = parse(
|
|
1316
|
+
args.slice(0, commandIndex),
|
|
1317
|
+
rootOptions,
|
|
1318
|
+
"agency <command> [options]",
|
|
1319
|
+
)
|
|
1320
|
+
assertNoDuplicateOptions(
|
|
1321
|
+
parsed.tokens,
|
|
1322
|
+
new Set(),
|
|
1323
|
+
"agency <command> [options]",
|
|
1324
|
+
)
|
|
1325
|
+
if (parsed.values.silent && parsed.values.verbose) {
|
|
1326
|
+
throw usageError(
|
|
1327
|
+
"Options '--silent' and '--verbose' cannot be combined.",
|
|
1328
|
+
"agency <command> [options]",
|
|
1329
|
+
)
|
|
1330
|
+
}
|
|
1331
|
+
if (parsed.values.workbase && parsed.values.cwd) {
|
|
1332
|
+
throw usageError(
|
|
1333
|
+
"Options '--workbase' and '--cwd' cannot be combined.",
|
|
1334
|
+
"agency <command> [options]",
|
|
1335
|
+
)
|
|
1336
|
+
}
|
|
1337
|
+
return {
|
|
1338
|
+
commandName,
|
|
1339
|
+
args: [...args.slice(commandIndex + 1)],
|
|
1340
|
+
values: parsed.values,
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1327
1343
|
const commandArgs = [
|
|
1328
1344
|
...args.slice(0, commandIndex),
|
|
1329
1345
|
...args.slice(commandIndex + 1),
|
package/src/cli.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { afterAll, afterEach, describe, expect, test } from "bun:test"
|
|
2
|
-
import { access, mkdir, realpath, stat, symlink } from "node:fs/promises"
|
|
2
|
+
import { access, chmod, mkdir, realpath, stat, symlink } from "node:fs/promises"
|
|
3
3
|
import { join, sep } from "node:path"
|
|
4
4
|
import errorFixture from "../fixtures/protocol/error.json"
|
|
5
5
|
import successFixture from "../fixtures/protocol/success.json"
|
|
@@ -502,7 +502,7 @@ describe("CLI", () => {
|
|
|
502
502
|
["archive", "Usage: agency archive"],
|
|
503
503
|
["restore", "Usage: agency restore"],
|
|
504
504
|
["work", "Usage: agency work"],
|
|
505
|
-
["pr", "
|
|
505
|
+
["pr", "Work with GitHub pull requests."],
|
|
506
506
|
["status", "Usage: agency status"],
|
|
507
507
|
["validate", "Usage: agency validate"],
|
|
508
508
|
["context", "Usage: agency context"],
|
|
@@ -537,6 +537,34 @@ describe("CLI", () => {
|
|
|
537
537
|
expect(after).toEqual({ exitCode: 0, stdout: "", stderr: "" })
|
|
538
538
|
}, 30_000)
|
|
539
539
|
|
|
540
|
+
test("passes pr arguments and exit status through to gh", async () => {
|
|
541
|
+
const root = await createTempDir()
|
|
542
|
+
tempDirs.push(root)
|
|
543
|
+
const bin = join(root, "bin")
|
|
544
|
+
const capture = join(root, "capture")
|
|
545
|
+
await mkdir(bin)
|
|
546
|
+
await Bun.write(
|
|
547
|
+
join(bin, "gh"),
|
|
548
|
+
`#!/bin/sh
|
|
549
|
+
printf '%s\n' "$PWD" "$@" > "$GH_CAPTURE"
|
|
550
|
+
exit 23
|
|
551
|
+
`,
|
|
552
|
+
)
|
|
553
|
+
await chmod(join(bin, "gh"), 0o755)
|
|
554
|
+
const args = ["pr", "create", "--title", "two words", "--", "literal"]
|
|
555
|
+
|
|
556
|
+
const result = await runCli(args, root, {
|
|
557
|
+
PATH: `${bin}:${process.env.PATH ?? ""}`,
|
|
558
|
+
GH_CAPTURE: capture,
|
|
559
|
+
})
|
|
560
|
+
|
|
561
|
+
expect(result).toEqual({ exitCode: 23, stdout: "", stderr: "" })
|
|
562
|
+
expect((await Bun.file(capture).text()).trim().split("\n")).toEqual([
|
|
563
|
+
await realpath(root),
|
|
564
|
+
...args,
|
|
565
|
+
])
|
|
566
|
+
})
|
|
567
|
+
|
|
540
568
|
test("lists ready work and exposes excluded blockers through one result", async () => {
|
|
541
569
|
const root = await createTempDir()
|
|
542
570
|
tempDirs.push(root)
|
|
@@ -570,20 +598,6 @@ describe("CLI", () => {
|
|
|
570
598
|
blockers: [{ kind: "status", reason: "Task status is dropped" }],
|
|
571
599
|
},
|
|
572
600
|
])
|
|
573
|
-
|
|
574
|
-
const blockedPr = await runCli(["pr", "create", "finished", "--json"], root)
|
|
575
|
-
expect(blockedPr.exitCode).toBe(1)
|
|
576
|
-
expect(blockedPr.stderr).toBe("")
|
|
577
|
-
expect(JSON.parse(blockedPr.stdout)).toMatchObject({
|
|
578
|
-
ok: false,
|
|
579
|
-
error: {
|
|
580
|
-
code: "EXECUTION_BLOCKED",
|
|
581
|
-
fields: {
|
|
582
|
-
status: "dropped",
|
|
583
|
-
blockers: [{ kind: "status", reason: "Task status is dropped" }],
|
|
584
|
-
},
|
|
585
|
-
},
|
|
586
|
-
})
|
|
587
601
|
})
|
|
588
602
|
|
|
589
603
|
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,45 +1,147 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test"
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
|
2
|
+
import { chmod, mkdir, realpath } from "node:fs/promises"
|
|
3
|
+
import { dirname, join } from "node:path"
|
|
4
|
+
import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
|
|
5
5
|
import { pr } from "./pr"
|
|
6
6
|
|
|
7
|
+
const write = async (root: string, path: string, content: string) => {
|
|
8
|
+
const fullPath = join(root, path)
|
|
9
|
+
await mkdir(dirname(fullPath), { recursive: true })
|
|
10
|
+
await Bun.write(fullPath, content)
|
|
11
|
+
}
|
|
12
|
+
|
|
7
13
|
describe("pr command", () => {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
14
|
+
let tempRoot: string
|
|
15
|
+
let capturePath: string
|
|
16
|
+
let originalPath: string | undefined
|
|
17
|
+
|
|
18
|
+
beforeEach(async () => {
|
|
19
|
+
tempRoot = await createTempDir()
|
|
20
|
+
capturePath = join(tempRoot, "capture")
|
|
21
|
+
const bin = join(tempRoot, "bin")
|
|
22
|
+
await mkdir(bin)
|
|
23
|
+
await Bun.write(
|
|
24
|
+
join(bin, "gh"),
|
|
25
|
+
`#!/bin/sh
|
|
26
|
+
printf '%s\n' "$PWD" "$@" > "$GH_CAPTURE"
|
|
27
|
+
exit "\${GH_EXIT:-0}"
|
|
28
|
+
`,
|
|
29
|
+
)
|
|
30
|
+
await chmod(join(bin, "gh"), 0o755)
|
|
31
|
+
originalPath = process.env.PATH
|
|
32
|
+
process.env.PATH = `${bin}:${originalPath ?? ""}`
|
|
33
|
+
process.env.GH_CAPTURE = capturePath
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
afterEach(async () => {
|
|
37
|
+
process.env.PATH = originalPath
|
|
38
|
+
delete process.env.GH_CAPTURE
|
|
39
|
+
delete process.env.GH_EXIT
|
|
40
|
+
await cleanupTempDir(tempRoot)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
const captured = async () =>
|
|
44
|
+
(await Bun.file(capturePath).text()).trim().split("\n")
|
|
45
|
+
|
|
46
|
+
const createExecutionWorkbase = async () => {
|
|
47
|
+
const root = join(tempRoot, "workbase")
|
|
48
|
+
await write(root, "agency.json", '{"version":2}\n')
|
|
49
|
+
await mkdir(join(root, "repos/agency"), { recursive: true })
|
|
50
|
+
await mkdir(join(root, "tasks/single/code/agency"), { recursive: true })
|
|
51
|
+
await write(
|
|
52
|
+
root,
|
|
53
|
+
"tasks/single/TASK.md",
|
|
54
|
+
`---
|
|
55
|
+
ticketUrl: null
|
|
56
|
+
repo: agency
|
|
57
|
+
branch: feat/single
|
|
58
|
+
base: main
|
|
59
|
+
pr: null
|
|
60
|
+
status: open
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
# Single
|
|
64
|
+
`,
|
|
30
65
|
)
|
|
66
|
+
await mkdir(join(root, "tasks/multi/phases/build/code/agency"), {
|
|
67
|
+
recursive: true,
|
|
68
|
+
})
|
|
69
|
+
await write(
|
|
70
|
+
root,
|
|
71
|
+
"tasks/multi/TASK.md",
|
|
72
|
+
`---
|
|
73
|
+
ticketUrl: null
|
|
74
|
+
phases:
|
|
75
|
+
- id: build
|
|
76
|
+
---
|
|
31
77
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
"/
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
78
|
+
# Multi
|
|
79
|
+
`,
|
|
80
|
+
)
|
|
81
|
+
await write(
|
|
82
|
+
root,
|
|
83
|
+
"tasks/multi/phases/build/PHASE.md",
|
|
84
|
+
`---
|
|
85
|
+
repo: agency
|
|
86
|
+
branch: feat/build
|
|
87
|
+
base: main
|
|
88
|
+
pr: null
|
|
89
|
+
status: open
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
# Build
|
|
93
|
+
`,
|
|
94
|
+
)
|
|
95
|
+
return root
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
test("focuses task and descendant invocations on the writable checkout", async () => {
|
|
99
|
+
const root = await createExecutionWorkbase()
|
|
100
|
+
const task = join(root, "tasks/single")
|
|
101
|
+
const descendant = join(task, "notes/deep")
|
|
102
|
+
await mkdir(descendant, { recursive: true })
|
|
103
|
+
|
|
104
|
+
for (const cwd of [task, descendant]) {
|
|
105
|
+
expect(await runTestEffect(pr(["view"], cwd))).toBe(0)
|
|
106
|
+
expect(await captured()).toEqual([
|
|
107
|
+
await realpath(join(task, "code/agency")),
|
|
108
|
+
"pr",
|
|
109
|
+
"view",
|
|
110
|
+
])
|
|
111
|
+
}
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
test("focuses a phase invocation on its writable checkout", async () => {
|
|
115
|
+
const root = await createExecutionWorkbase()
|
|
116
|
+
const phase = join(root, "tasks/multi/phases/build")
|
|
117
|
+
|
|
118
|
+
expect(await runTestEffect(pr(["status"], phase))).toBe(0)
|
|
119
|
+
expect(await captured()).toEqual([
|
|
120
|
+
await realpath(join(phase, "code/agency")),
|
|
121
|
+
"pr",
|
|
122
|
+
"status",
|
|
43
123
|
])
|
|
44
124
|
})
|
|
125
|
+
|
|
126
|
+
test("falls back to the invocation directory without execution authority", async () => {
|
|
127
|
+
const root = await createExecutionWorkbase()
|
|
128
|
+
const orchestration = join(root, "tasks/multi")
|
|
129
|
+
const outside = join(tempRoot, "outside")
|
|
130
|
+
await mkdir(outside)
|
|
131
|
+
|
|
132
|
+
for (const cwd of [orchestration, outside]) {
|
|
133
|
+
expect(await runTestEffect(pr(["list"], cwd))).toBe(0)
|
|
134
|
+
expect(await captured()).toEqual([await realpath(cwd), "pr", "list"])
|
|
135
|
+
}
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test("forwards arguments unchanged and returns the gh exit code", async () => {
|
|
139
|
+
const outside = join(tempRoot, "outside")
|
|
140
|
+
await mkdir(outside)
|
|
141
|
+
process.env.GH_EXIT = "23"
|
|
142
|
+
const args = ["create", "--title", "two words", "--", "literal"]
|
|
143
|
+
|
|
144
|
+
expect(await runTestEffect(pr(args, outside))).toBe(23)
|
|
145
|
+
expect(await captured()).toEqual([await realpath(outside), "pr", ...args])
|
|
146
|
+
})
|
|
45
147
|
})
|
package/src/commands/pr.ts
CHANGED
|
@@ -1,43 +1,31 @@
|
|
|
1
1
|
import { Effect } from "effect"
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
2
|
+
import { resolve } from "node:path"
|
|
3
|
+
import { ContextService } from "../services/ContextService"
|
|
4
|
+
import { FileSystemService } from "../services/FileSystemService"
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
readonly subcommand?: string
|
|
8
|
-
readonly taskId?: string
|
|
9
|
-
readonly phaseId?: string
|
|
10
|
-
readonly draft?: boolean
|
|
11
|
-
readonly force?: boolean
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export const pr = (options: PrOptions) =>
|
|
6
|
+
export const pr = (args: readonly string[], cwd: string = process.cwd()) =>
|
|
15
7
|
Effect.gen(function* () {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
8
|
+
const contexts = yield* ContextService
|
|
9
|
+
const fs = yield* FileSystemService
|
|
10
|
+
const invocationCwd = resolve(cwd)
|
|
11
|
+
const context = yield* contexts
|
|
12
|
+
.get({ cwd: invocationCwd, target: ".", compact: true })
|
|
13
|
+
.pipe(Effect.catchAll(() => Effect.succeed(null)))
|
|
14
|
+
const writableCheckout =
|
|
15
|
+
context?.validation.valid && context.workspace?.writable?.materialized
|
|
16
|
+
? context.authority.writable?.checkoutPath
|
|
17
|
+
: null
|
|
18
|
+
const focusedCwd = writableCheckout ?? invocationCwd
|
|
19
|
+
const result = yield* fs.runCommand(["gh", "pr", ...args], {
|
|
20
|
+
cwd: focusedCwd,
|
|
21
|
+
passthrough: true,
|
|
22
|
+
})
|
|
23
|
+
return result.exitCode
|
|
31
24
|
})
|
|
32
25
|
|
|
33
26
|
export const help = `
|
|
34
|
-
Usage: agency pr
|
|
35
|
-
|
|
36
|
-
Push the execution branch, create a pull request with the delivery provider,
|
|
37
|
-
and update its task or phase document.
|
|
27
|
+
Usage: agency pr [args...]
|
|
38
28
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
--force Override readiness and terminal-state guards
|
|
42
|
-
--json Output the pull request URL as JSON
|
|
29
|
+
Run gh pr unchanged, focusing the writable repository checkout when invoked
|
|
30
|
+
from an Agency execution task or phase.
|
|
43
31
|
`
|
|
@@ -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.
|
|
@@ -218,25 +218,34 @@ export class FileSystemService extends Effect.Service<FileSystemService>()(
|
|
|
218
218
|
readonly cwd?: string
|
|
219
219
|
readonly captureOutput?: boolean
|
|
220
220
|
readonly forwardOutput?: boolean
|
|
221
|
+
readonly passthrough?: boolean
|
|
221
222
|
readonly env?: Record<string, string>
|
|
222
223
|
},
|
|
223
224
|
) =>
|
|
224
225
|
pipe(
|
|
225
226
|
spawnProcess(args, {
|
|
226
227
|
cwd: options?.cwd,
|
|
227
|
-
stdin: "pipe",
|
|
228
|
-
stdout: options?.
|
|
229
|
-
? "
|
|
230
|
-
: options?.
|
|
231
|
-
? "
|
|
232
|
-
:
|
|
233
|
-
|
|
228
|
+
stdin: options?.passthrough ? "inherit" : "pipe",
|
|
229
|
+
stdout: options?.passthrough
|
|
230
|
+
? "inherit"
|
|
231
|
+
: options?.forwardOutput
|
|
232
|
+
? "tee"
|
|
233
|
+
: options?.captureOutput
|
|
234
|
+
? "pipe"
|
|
235
|
+
: "inherit",
|
|
236
|
+
stderr: options?.passthrough
|
|
237
|
+
? "inherit"
|
|
238
|
+
: options?.forwardOutput
|
|
239
|
+
? "tee"
|
|
240
|
+
: "pipe",
|
|
234
241
|
env: options?.env,
|
|
235
242
|
}),
|
|
236
243
|
Effect.mapError(
|
|
237
244
|
(processError) =>
|
|
238
245
|
new FileSystemError({
|
|
239
|
-
message:
|
|
246
|
+
message: options?.passthrough
|
|
247
|
+
? processError.message
|
|
248
|
+
: `Failed to run command: ${args.join(" ")}`,
|
|
240
249
|
cause: processError,
|
|
241
250
|
}),
|
|
242
251
|
),
|
|
@@ -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
|
|
63
|
-
|
|
62
|
+
- Create a pull request only with explicit user intent. Run `agency pr create`
|
|
63
|
+
from its execution unit, then use `agency sync --apply` to reconcile PR state.
|
|
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.
|