@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 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 create`
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
- agency pr create refresh-copy
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` and `agency pr create` consult this shared readiness model before
480
- materializing or pushing. Blocked, done, and dropped targets are rejected unless
481
- `--force` is supplied explicitly. PR creation permits active `working` and
482
- `delegated` targets when they have no dependency or validation blocker.
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 and `--dry-run` modes are observational.
492
+ default mode applies safe reconciliation transitions; `--dry-run` is explicitly
493
+ observational.
492
494
 
493
- `agency sync --apply` performs only these safe transitions:
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 --apply`. Work whose intended outcome genuinely requires no pull
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 create <task-id> [phase-id] [--draft] [--json]
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 create` requires a clean writable worktree. It pushes the branch,
853
- runs `gh pr create --fill`, and writes the returned GitHub PR URL into `pr` in
854
- the owning `TASK.md` or `PHASE.md`.
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 runCommand(
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 create Create a pull request for an execution unit
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", "--apply", "--json"],
33
+ ["sync", "--json"],
34
34
  ["pr", "create", "checkout", "ui", "--json"],
35
35
  [
36
36
  "finish",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "2.52.2",
3
+ "version": "2.54.0",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -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("parses reconciliation modes and rejects conflicting modes", () => {
371
- expect(parseCli(["sync", "--dry-run", "--json"])).toMatchObject({
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: { "dry-run": true, json: true },
377
+ values: {},
374
378
  })
375
- expect(parseCli(["sync", "--apply"])).toMatchObject({
379
+ expect(parseCli(["sync", "--dry-run", "--json"])).toMatchObject({
376
380
  commandName: "sync",
377
- values: { apply: true },
381
+ values: { "dry-run": true, json: true },
378
382
  })
379
- expect(() => parseCli(["sync", "--dry-run", "--apply"])).toThrow(
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 | --apply] [--json]",
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 | --apply] [--json]",
687
+ usage: "agency sync [--dry-run] [--json]",
689
688
  minArgs: 0,
690
689
  maxArgs: 0,
691
- options: ["dry-run", "apply", "json"],
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 create <task-id> [phase-id]",
901
- options: {
902
- ...outputOptions,
903
- draft: { type: "boolean" },
904
- force: { type: "boolean" },
905
- task: { type: "string" },
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", "Usage: agency 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 () => {
@@ -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 --apply marks the execution unit done after merge. Use
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
  `
@@ -1,45 +1,147 @@
1
- import { describe, expect, test } from "bun:test"
2
- import { Effect } from "effect"
3
- import { captureLogs } from "../test-utils"
4
- import { PullRequestService } from "../services/PullRequestService"
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
- test("outputs the created pull request URL as JSON", async () => {
9
- const url = "https://github.com/markjaquith/agency/pull/123"
10
- let received: unknown[] = []
11
- const logs = await captureLogs(() =>
12
- Effect.runPromise(
13
- pr({
14
- subcommand: "create",
15
- taskId: "example",
16
- phaseId: "implementation",
17
- draft: true,
18
- force: true,
19
- cwd: "/workbase",
20
- json: true,
21
- }).pipe(
22
- Effect.provideService(PullRequestService, {
23
- create: (...args: unknown[]) => {
24
- received = args
25
- return Effect.succeed(url)
26
- },
27
- } as never),
28
- ) as Effect.Effect<void, unknown, never>,
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
- expect(JSON.parse(logs[0]!)).toEqual({ url })
33
- expect(received).toEqual([
34
- "example",
35
- "implementation",
36
- true,
37
- "/workbase",
38
- expect.objectContaining({
39
- force: true,
40
- draft: true,
41
- json: true,
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
  })
@@ -1,43 +1,31 @@
1
1
  import { Effect } from "effect"
2
- import type { BaseCommandOptions } from "../utils/command"
3
- import { PullRequestService } from "../services/PullRequestService"
4
- import { createLoggers } from "../utils/effect"
2
+ import { resolve } from "node:path"
3
+ import { ContextService } from "../services/ContextService"
4
+ import { FileSystemService } from "../services/FileSystemService"
5
5
 
6
- interface PrOptions extends BaseCommandOptions {
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
- if (options.subcommand !== "create" || !options.taskId) {
17
- return yield* Effect.fail(
18
- new Error("Usage: agency pr create <task-id> [phase-id]"),
19
- )
20
- }
21
- const pullRequests = yield* PullRequestService
22
- const { log } = createLoggers(options)
23
- const url = yield* pullRequests.create(
24
- options.taskId,
25
- options.phaseId,
26
- options.draft,
27
- options.cwd ?? process.cwd(),
28
- options,
29
- )
30
- log(options.json ? JSON.stringify({ url }, null, 2) : url)
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 create <task-id> [phase-id]
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
- Options:
40
- --draft Create a draft pull request
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
+ })
@@ -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.apply === true,
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 | --apply] [--json]
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
- Dry-run is the default.
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
- Apply may materialize declared repositories and unambiguous missing checkouts,
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?.forwardOutput
229
- ? "tee"
230
- : options?.captureOutput
231
- ? "pipe"
232
- : "inherit",
233
- stderr: options?.forwardOutput ? "tee" : "pipe",
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: `Failed to run command: ${args.join(" ")}`,
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 --apply`")
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 --apply', or explicitly complete a non-PR outcome with '--no-pull-request --summary <text>'",
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 --apply', or explicitly complete a non-PR outcome with '--no-pull-request --summary <text>'",
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({
@@ -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, using
63
- `agency pr create <task> [phase]` so the URL is recorded durably.
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 --apply` to reconcile the execution unit to
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.