@markjaquith/agency 2.52.2 → 2.53.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
 
@@ -803,7 +804,7 @@ for restoration. Archived IDs are reserved until restored.
803
804
  agency work [<directory> | --epic <epic-id>] [--runner <name>] [--auto] [--print-command]
804
805
  agency work prepare [target] [--dry-run] [--json]
805
806
  agency worktree <list|inspect|prepare|remove|rebuild|repair>
806
- agency pr create <task-id> [phase-id] [--draft] [--json]
807
+ agency pr [args...]
807
808
  ```
808
809
 
809
810
  `agency work` presents the full hierarchy in the native OpenTUI selector or the
@@ -849,9 +850,9 @@ Agency resolves the ref to a commit and creates a detached worktree. Existing
849
850
  reference worktrees are reused only while their commit still matches the declared
850
851
  ref; use a commit SHA as `ref` when reproducibility matters.
851
852
 
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`.
853
+ `agency pr` forwards every argument to `gh pr`. From an execution task or phase
854
+ directory, including descendants, it runs in that execution unit's authoritative
855
+ writable checkout. Otherwise it runs in the caller's current directory.
855
856
 
856
857
  ### Status and Validation
857
858
 
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: {
@@ -736,7 +727,7 @@ Commands:
736
727
  worktree <subcommand> Inspect and maintain managed workspaces
737
728
  vcs <subcommand> Inspect or migrate the version-control backend
738
729
  next List or select ready execution units
739
- pr create Create a pull request for an execution unit
730
+ pr [args...] Run gh pr with Agency repository focus
740
731
  review refresh Explicitly refresh a pinned review task
741
732
  repo <subcommand> Manage workbase repositories
742
733
  status Show status for the current workbase
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "2.52.2",
3
+ "version": "2.53.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,15 +357,20 @@ 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
 
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
+
370
374
  test("parses reconciliation modes and rejects conflicting modes", () => {
371
375
  expect(parseCli(["sync", "--dry-run", "--json"])).toMatchObject({
372
376
  commandName: "sync",
package/src/cli-parser.ts CHANGED
@@ -897,22 +897,12 @@ const commands = {
897
897
  },
898
898
  },
899
899
  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
- },
900
+ usage: "agency pr [args...]",
901
+ options: commonOptions,
902
+ command: {
903
+ usage: "agency pr [args...]",
904
+ minArgs: 0,
905
+ maxArgs: Number.POSITIVE_INFINITY,
916
906
  },
917
907
  },
918
908
  next: {
@@ -1103,7 +1093,6 @@ const targetSlots = (
1103
1093
  if (commandName === "worktree" && subcommand !== "list") {
1104
1094
  return ["task", "phase"]
1105
1095
  }
1106
- if (commandName === "pr" && subcommand === "create") return ["task", "phase"]
1107
1096
  return []
1108
1097
  }
1109
1098
 
@@ -1324,6 +1313,35 @@ export function parseCli(args: readonly string[]): ParsedCli {
1324
1313
  "agency <command> [options]",
1325
1314
  )
1326
1315
  }
1316
+ if (commandName === "pr") {
1317
+ const parsed = parse(
1318
+ args.slice(0, commandIndex),
1319
+ rootOptions,
1320
+ "agency <command> [options]",
1321
+ )
1322
+ assertNoDuplicateOptions(
1323
+ parsed.tokens,
1324
+ new Set(),
1325
+ "agency <command> [options]",
1326
+ )
1327
+ if (parsed.values.silent && parsed.values.verbose) {
1328
+ throw usageError(
1329
+ "Options '--silent' and '--verbose' cannot be combined.",
1330
+ "agency <command> [options]",
1331
+ )
1332
+ }
1333
+ if (parsed.values.workbase && parsed.values.cwd) {
1334
+ throw usageError(
1335
+ "Options '--workbase' and '--cwd' cannot be combined.",
1336
+ "agency <command> [options]",
1337
+ )
1338
+ }
1339
+ return {
1340
+ commandName,
1341
+ args: [...args.slice(commandIndex + 1)],
1342
+ values: parsed.values,
1343
+ }
1344
+ }
1327
1345
  const commandArgs = [
1328
1346
  ...args.slice(0, commandIndex),
1329
1347
  ...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 () => {
@@ -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
  `
@@ -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
  ),
@@ -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