@markjaquith/agency 2.54.0 → 2.54.2

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
@@ -805,6 +805,7 @@ for restoration. Archived IDs are reserved until restored.
805
805
  agency work [<directory> | --epic <epic-id>] [--runner <name>] [--auto] [--print-command]
806
806
  agency work prepare [target] [--dry-run] [--json]
807
807
  agency worktree <list|inspect|prepare|remove|rebuild|repair>
808
+ agency pr create <task-id> [phase-id] [--draft] [--force] [--json]
808
809
  agency pr [args...]
809
810
  ```
810
811
 
@@ -851,9 +852,12 @@ Agency resolves the ref to a commit and creates a detached worktree. Existing
851
852
  reference worktrees are reused only while their commit still matches the declared
852
853
  ref; use a commit SHA as `ref` when reproducibility matters.
853
854
 
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
+ 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.
857
861
 
858
862
  ### Status and Validation
859
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
- process.exitCode = await runEffect(pr(args, options.cwd))
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: {
@@ -726,7 +741,7 @@ Commands:
726
741
  worktree <subcommand> Inspect and maintain managed workspaces
727
742
  vcs <subcommand> Inspect or migrate the version-control backend
728
743
  next List or select ready execution units
729
- pr [args...] Run gh pr with Agency repository focus
744
+ pr create / pr [...] Create an Agency PR or run gh pr with repository focus
730
745
  review refresh Explicitly refresh a pinned review task
731
746
  repo <subcommand> Manage workbase repositories
732
747
  status Show status for the current workbase
@@ -761,7 +776,7 @@ const machineMode = process.argv
761
776
 
762
777
  try {
763
778
  const args = process.argv.slice(2)
764
- const { commandName, args: commandArgs, values } = parseCli(args)
779
+ const { commandName, args: commandArgs, passthrough, values } = parseCli(args)
765
780
 
766
781
  // Handle global flags
767
782
  if (values.version) {
@@ -785,16 +800,29 @@ try {
785
800
  !values.json &&
786
801
  !values["no-input"] &&
787
802
  Boolean(process.stdin.isTTY && process.stdout.isTTY)
788
- const cwd = await resolveInvocationCwd(commandName, values)
803
+ const cwd = await resolveInvocationCwd(commandName, {
804
+ ...values,
805
+ passthrough,
806
+ })
789
807
  if (values.json || (values.jsonl && values.help)) {
790
808
  const result = await collectCommandResult(() =>
791
- command.run(commandArgs, { ...values, cwd, inputAllowed }),
809
+ command.run(commandArgs, { ...values, cwd, inputAllowed, passthrough }),
792
810
  )
793
811
  writeEnvelope(successEnvelope(result))
794
812
  } else if (values.jsonl) {
795
- await command.run(commandArgs, { ...values, cwd, inputAllowed: false })
813
+ await command.run(commandArgs, {
814
+ ...values,
815
+ cwd,
816
+ inputAllowed: false,
817
+ passthrough,
818
+ })
796
819
  } else {
797
- await command.run(commandArgs, { ...values, cwd, inputAllowed })
820
+ await command.run(commandArgs, {
821
+ ...values,
822
+ cwd,
823
+ inputAllowed,
824
+ passthrough,
825
+ })
798
826
  }
799
827
  } catch (error) {
800
828
  if (machineMode) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "2.54.0",
3
+ "version": "2.54.2",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -367,10 +367,34 @@ 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
 
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
+
374
398
  test("parses explicit dry-run reconciliation", () => {
375
399
  expect(parseCli(["sync"])).toMatchObject({
376
400
  commandName: "sync",
package/src/cli-parser.ts CHANGED
@@ -895,12 +895,22 @@ const commands = {
895
895
  },
896
896
  },
897
897
  pr: {
898
- usage: "agency pr [args...]",
899
- options: commonOptions,
900
- command: {
901
- usage: "agency pr [args...]",
902
- minArgs: 0,
903
- maxArgs: Number.POSITIVE_INFINITY,
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
+ },
904
914
  },
905
915
  },
906
916
  next: {
@@ -1019,6 +1029,7 @@ const preCommandValueOptions = new Set(["--workbase", "--cwd"])
1019
1029
  export interface ParsedCli {
1020
1030
  readonly commandName?: keyof typeof commands
1021
1031
  readonly args: string[]
1032
+ readonly passthrough?: boolean
1022
1033
  readonly values: Record<
1023
1034
  string,
1024
1035
  boolean | string | (boolean | string)[] | undefined
@@ -1091,6 +1102,7 @@ const targetSlots = (
1091
1102
  if (commandName === "worktree" && subcommand !== "list") {
1092
1103
  return ["task", "phase"]
1093
1104
  }
1105
+ if (commandName === "pr" && subcommand === "create") return ["task", "phase"]
1094
1106
  return []
1095
1107
  }
1096
1108
 
@@ -1311,7 +1323,15 @@ export function parseCli(args: readonly string[]): ParsedCli {
1311
1323
  "agency <command> [options]",
1312
1324
  )
1313
1325
  }
1314
- if (commandName === "pr") {
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) {
1315
1335
  const parsed = parse(
1316
1336
  args.slice(0, commandIndex),
1317
1337
  rootOptions,
@@ -1336,7 +1356,8 @@ export function parseCli(args: readonly string[]): ParsedCli {
1336
1356
  }
1337
1357
  return {
1338
1358
  commandName,
1339
- args: [...args.slice(commandIndex + 1)],
1359
+ args: prArgs,
1360
+ passthrough: true,
1340
1361
  values: parsed.values,
1341
1362
  }
1342
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 () => {
@@ -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 { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
5
- import { pr } from "./pr"
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")
@@ -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 [args...]
51
+ Usage: agency pr create <task-id> [phase-id] [--draft] [--force] [--json]
52
+ agency pr [args...]
28
53
 
29
- Run gh pr unchanged, focusing the writable repository checkout when invoked
30
- from an Agency execution task or phase.
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
  `
@@ -10,6 +10,7 @@ import { RepositoryService } from "./RepositoryService"
10
10
  import { TaskService } from "./TaskService"
11
11
  import { WorkbaseService } from "./WorkbaseService"
12
12
  import { WorktreeService } from "./WorktreeService"
13
+ import { VersionControlService } from "./VersionControlService"
13
14
 
14
15
  type DoctorCheckLevel = "error" | "warning" | "optional"
15
16
 
@@ -87,7 +88,9 @@ export class DoctorService extends Effect.Service<DoctorService>()(
87
88
  const tasks = yield* TaskService
88
89
  const workbases = yield* WorkbaseService
89
90
  const worktrees = yield* WorktreeService
91
+ const versionControl = yield* VersionControlService
90
92
  const { root, config } = yield* workbases.loadConfig(startPath)
93
+ const backend = yield* versionControl.forWorkbase(root)
91
94
  const checks: DoctorCheck[] = []
92
95
  const add = (
93
96
  check: Omit<DoctorCheck, "remediation"> & {
@@ -409,32 +412,8 @@ export class DoctorService extends Effect.Service<DoctorService>()(
409
412
  }
410
413
 
411
414
  for (const ref of [...(refs.get(repository.alias) ?? [])].sort()) {
412
- const local = yield* fs.runCommand(
413
- [
414
- "git",
415
- "-C",
416
- repository.path,
417
- "rev-parse",
418
- "--verify",
419
- `${ref}^{commit}`,
420
- ],
421
- { captureOutput: true },
422
- )
423
- const remote =
424
- local.exitCode === 0
425
- ? local
426
- : yield* fs.runCommand(
427
- [
428
- "git",
429
- "-C",
430
- repository.path,
431
- "rev-parse",
432
- "--verify",
433
- `origin/${ref}^{commit}`,
434
- ],
435
- { captureOutput: true },
436
- )
437
- const found = local.exitCode === 0 || remote.exitCode === 0
415
+ const found =
416
+ (yield* backend.resolveRevision(repository.path, ref)) !== null
438
417
  add({
439
418
  id: `ref.${repository.alias}.${ref}`,
440
419
  category: "ref",
@@ -1,6 +1,6 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test"
2
2
  import { Effect } from "effect"
3
- import { mkdir } from "node:fs/promises"
3
+ import { mkdir, rm } from "node:fs/promises"
4
4
  import { join } from "node:path"
5
5
  import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
6
6
  import { TaskService } from "./TaskService"
@@ -13,6 +13,7 @@ import { PhaseService } from "./PhaseService"
13
13
  import { ArchiveService } from "./ArchiveService"
14
14
  import { ClaimService } from "./ClaimService"
15
15
  import { SyncService } from "./SyncService"
16
+ import { DoctorService } from "./DoctorService"
16
17
  import { task as taskCommand } from "../commands/task"
17
18
 
18
19
  const git = async (args: string[], cwd?: string) => {
@@ -26,6 +27,16 @@ const git = async (args: string[], cwd?: string) => {
26
27
  throw new Error(await new Response(child.stderr).text())
27
28
  }
28
29
 
30
+ const jj = async (args: string[]) => {
31
+ const child = Bun.spawn(["jj", ...args], {
32
+ stdout: "pipe",
33
+ stderr: "pipe",
34
+ })
35
+ await child.exited
36
+ if (child.exitCode !== 0)
37
+ throw new Error(await new Response(child.stderr).text())
38
+ }
39
+
29
40
  describe("ReviewService", () => {
30
41
  let root: string
31
42
  let source: string
@@ -151,6 +162,78 @@ describe("ReviewService", () => {
151
162
  expect(new TextDecoder().decode(oldPins.stdout).trim()).toBe("")
152
163
  })
153
164
 
165
+ test("imports jj reviews for creation, diagnostics, materialization, and refresh", async () => {
166
+ if (!Bun.which("jj")) return
167
+ const repository = join(root, "repos/agency")
168
+ await rm(repository, { recursive: true, force: true })
169
+ await git(["clone", source, repository])
170
+ await git(["switch", "-c", "jj-review"], source)
171
+ await Bun.write(join(source, "README.md"), "jj review one\n")
172
+ await git(["commit", "-am", "jj review one"], source)
173
+ await jj(["git", "init", "--colocate", repository])
174
+ await Bun.write(
175
+ join(root, "agency.json"),
176
+ JSON.stringify({ version: 2, vcs: "jj" }),
177
+ )
178
+
179
+ await runTestEffect(
180
+ taskCommand({
181
+ subcommand: "create",
182
+ args: ["jj-review"],
183
+ review: "agency",
184
+ ref: "jj-review",
185
+ cwd: root,
186
+ silent: true,
187
+ }),
188
+ )
189
+ const created = await runTestEffect(
190
+ TaskService.pipe(
191
+ Effect.flatMap((service) => service.show("jj-review", root)),
192
+ ),
193
+ )
194
+ const original = "review" in created.data ? created.data.review.commit : ""
195
+
196
+ const context = await runTestEffect(
197
+ ContextService.pipe(
198
+ Effect.flatMap((service) =>
199
+ service.get({ target: "jj-review", cwd: root }),
200
+ ),
201
+ ),
202
+ )
203
+ expect(context.workspace!.warnings).toEqual([])
204
+ expect(context.review?.checkout?.resolvedCommit).toBe(original)
205
+
206
+ const doctor = await runTestEffect(
207
+ DoctorService.pipe(Effect.flatMap((service) => service.inspect(root))),
208
+ )
209
+ expect(
210
+ doctor.checks.find((check) => check.id === `ref.agency.${original}`),
211
+ ).toMatchObject({ status: "pass" })
212
+
213
+ const workspace = await runTestEffect(
214
+ WorktreeService.pipe(
215
+ Effect.flatMap((service) =>
216
+ service.materialize("jj-review", undefined, root),
217
+ ),
218
+ ),
219
+ )
220
+ expect(
221
+ await Bun.file(join(workspace.reviewPath!, "README.md")).text(),
222
+ ).toBe("jj review one\n")
223
+
224
+ await Bun.write(join(source, "README.md"), "jj review two\n")
225
+ await git(["commit", "-am", "jj review two"], source)
226
+ const refreshed = await runTestEffect(
227
+ ReviewService.pipe(
228
+ Effect.flatMap((service) => service.refresh("jj-review", root)),
229
+ ),
230
+ )
231
+ expect(refreshed.changed).toBe(true)
232
+ expect(
233
+ await Bun.file(join(workspace.reviewPath!, "README.md")).text(),
234
+ ).toBe("jj review two\n")
235
+ })
236
+
154
237
  test("rejects delivery and phase operations even when forced", async () => {
155
238
  await createReview()
156
239
  await expect(
@@ -1,4 +1,4 @@
1
- import { Data, Effect, Layer } from "effect"
1
+ import { Data, Effect, Either, Layer } from "effect"
2
2
  import { randomUUID } from "node:crypto"
3
3
  import { lstat, mkdir } from "node:fs/promises"
4
4
  import { dirname } from "node:path"
@@ -15,6 +15,7 @@ import {
15
15
  GitVersionControlService,
16
16
  JjVersionControlService,
17
17
  VersionControlService,
18
+ type VersionControlBackend,
18
19
  } from "./VersionControlService"
19
20
  import { withWorktreeLocks } from "./WorktreeLock"
20
21
  import {
@@ -160,7 +161,11 @@ const normalizeBranch = (input: string, repositoryPath: string) =>
160
161
  return `refs/heads/${name}`
161
162
  })
162
163
 
163
- const fetchCommit = (repoPath: string, sourceRef: string) =>
164
+ const fetchCommit = (
165
+ repoPath: string,
166
+ sourceRef: string,
167
+ backend: VersionControlBackend,
168
+ ) =>
164
169
  Effect.gen(function* () {
165
170
  const fs = yield* FileSystemService
166
171
  const temporaryRef = `refs/agency/review-fetch/${process.pid}-${randomUUID()}`
@@ -196,6 +201,17 @@ const fetchCommit = (repoPath: string, sourceRef: string) =>
196
201
  ],
197
202
  { captureOutput: true },
198
203
  )
204
+ const commit = resolved.stdout.trim()
205
+ if (resolved.exitCode !== 0 || !/^[a-f0-9]{40}$/.test(commit)) {
206
+ yield* fs.runCommand(
207
+ ["git", "-C", repoPath, "update-ref", "-d", temporaryRef],
208
+ { captureOutput: true },
209
+ )
210
+ return yield* new ReviewError({
211
+ message: `Review source '${sourceRef}' did not resolve to a commit`,
212
+ })
213
+ }
214
+ const imported = yield* Effect.either(backend.importGitRefs(repoPath))
199
215
  const cleanup = yield* fs.runCommand(
200
216
  ["git", "-C", repoPath, "update-ref", "-d", temporaryRef],
201
217
  { captureOutput: true },
@@ -205,10 +221,10 @@ const fetchCommit = (repoPath: string, sourceRef: string) =>
205
221
  message: `Failed to remove temporary review fetch ref: ${cleanup.stderr.trim()}`,
206
222
  })
207
223
  }
208
- const commit = resolved.stdout.trim()
209
- if (resolved.exitCode !== 0 || !/^[a-f0-9]{40}$/.test(commit)) {
224
+ if (Either.isLeft(imported)) {
210
225
  return yield* new ReviewError({
211
- message: `Review source '${sourceRef}' did not resolve to a commit`,
226
+ message: `Review source '${sourceRef}' was fetched but could not be imported into ${backend.kind}`,
227
+ cause: imported.left,
212
228
  })
213
229
  }
214
230
  return commit
@@ -225,6 +241,8 @@ export class ReviewService extends Effect.Service<ReviewService>()(
225
241
  ) =>
226
242
  Effect.gen(function* () {
227
243
  const repositories = yield* RepositoryService
244
+ const versionControl = yield* VersionControlService
245
+ const backend = yield* versionControl.forWorkbase(startPath)
228
246
  const repository = yield* repositories.show(repo, startPath)
229
247
  if (!repository.remote || repository.states.includes("missing")) {
230
248
  return yield* new ReviewError({
@@ -273,7 +291,7 @@ export class ReviewService extends Effect.Service<ReviewService>()(
273
291
  message: "Exactly one review source is required",
274
292
  })
275
293
  }
276
- const commit = yield* fetchCommit(repository.path, sourceRef)
294
+ const commit = yield* fetchCommit(repository.path, sourceRef, backend)
277
295
  return {
278
296
  repo,
279
297
  source,
@@ -47,6 +47,9 @@ export interface VersionControlBackend {
47
47
  remote?: string,
48
48
  branch?: string,
49
49
  ) => Effect.Effect<void, unknown, any>
50
+ readonly importGitRefs: (
51
+ repositoryPath: string,
52
+ ) => Effect.Effect<void, unknown, any>
50
53
  readonly push: (
51
54
  workspacePath: string,
52
55
  remote: string,
@@ -240,6 +243,7 @@ export class GitVersionControlService extends Effect.Service<GitVersionControlSe
240
243
  ),
241
244
  )
242
245
  }),
246
+ importGitRefs: () => Effect.void,
243
247
  push: (workspacePath, remote, branch) =>
244
248
  Effect.gen(function* () {
245
249
  const fs = yield* FileSystemService
@@ -333,18 +337,27 @@ export class JjVersionControlService extends Effect.Service<JjVersionControlServ
333
337
  resolveRevision: (repositoryPath, revision) =>
334
338
  Effect.gen(function* () {
335
339
  const fs = yield* FileSystemService
336
- const result = yield* fs.runCommand(
337
- jjCommand(repositoryPath, [
338
- "log",
339
- "--ignore-working-copy",
340
- "--no-graph",
341
- "-r",
342
- revision,
343
- "-T",
344
- 'commit_id ++ "\\n"',
345
- ]),
346
- { captureOutput: true },
347
- )
340
+ const resolve = () =>
341
+ fs.runCommand(
342
+ jjCommand(repositoryPath, [
343
+ "log",
344
+ "--ignore-working-copy",
345
+ "--no-graph",
346
+ "-r",
347
+ revision,
348
+ "-T",
349
+ 'commit_id ++ "\\n"',
350
+ ]),
351
+ { captureOutput: true },
352
+ )
353
+ let result = yield* resolve()
354
+ if (result.exitCode !== 0 || !result.stdout.trim()) {
355
+ const imported = yield* fs.runCommand(
356
+ jjCommand(repositoryPath, ["git", "import"]),
357
+ { captureOutput: true },
358
+ )
359
+ if (imported.exitCode === 0) result = yield* resolve()
360
+ }
348
361
  return result.exitCode === 0 ? result.stdout.trim() || null : null
349
362
  }),
350
363
  workspaceHead: (workspacePath) =>
@@ -430,6 +443,16 @@ export class JjVersionControlService extends Effect.Service<JjVersionControlServ
430
443
  ),
431
444
  )
432
445
  }),
446
+ importGitRefs: (repositoryPath) =>
447
+ Effect.gen(function* () {
448
+ const fs = yield* FileSystemService
449
+ yield* requireSuccess(
450
+ "Failed to import Git refs into jj",
451
+ fs.runCommand(jjCommand(repositoryPath, ["git", "import"]), {
452
+ captureOutput: true,
453
+ }),
454
+ )
455
+ }),
433
456
  push: (workspacePath, remote, branch) =>
434
457
  Effect.gen(function* () {
435
458
  const fs = yield* FileSystemService
@@ -164,6 +164,44 @@ describe("WorktreeService", () => {
164
164
  expect(await Bun.file(workspace.writablePath!).exists()).toBe(false)
165
165
  })
166
166
 
167
+ test("recommends a repository fetch when jj cannot resolve a base", async () => {
168
+ if (!Bun.which("jj")) return
169
+ const repository = join(root, "repos/agency")
170
+ await rm(repository, { recursive: true, force: true })
171
+ await git(["clone", source, repository])
172
+ await jj(["git", "init", "--colocate", repository])
173
+ await Bun.write(
174
+ join(root, "agency.json"),
175
+ JSON.stringify({ version: 2, vcs: "jj" }),
176
+ )
177
+ await runTestEffect(
178
+ TaskService.pipe(
179
+ Effect.flatMap((service) =>
180
+ service.create(
181
+ {
182
+ id: "jj-missing",
183
+ ticketUrl: null,
184
+ repo: "agency",
185
+ branch: "task/jj-missing",
186
+ base: "absent-base",
187
+ },
188
+ root,
189
+ ),
190
+ ),
191
+ ),
192
+ )
193
+
194
+ await expect(
195
+ runTestEffect(
196
+ WorktreeService.pipe(
197
+ Effect.flatMap((service) =>
198
+ service.materialize("jj-missing", undefined, root),
199
+ ),
200
+ ),
201
+ ),
202
+ ).rejects.toThrow("run 'agency repo fetch agency' and retry")
203
+ })
204
+
167
205
  test("does not fetch the origin for an existing writable worktree", async () => {
168
206
  await runTestEffect(
169
207
  TaskService.pipe(
@@ -884,8 +884,12 @@ const materializeJj = (options: {
884
884
  revision = yield* backend.resolveRevision(repositoryPath, base)
885
885
  }
886
886
  if (!revision) {
887
+ const recovery =
888
+ backend.kind === "jj"
889
+ ? `; run 'agency repo fetch ${checkout.repo}' and retry`
890
+ : ""
887
891
  return yield* new WorktreeError({
888
- message: `${"branch" in checkout ? "Base" : "Reference"} '${"branch" in checkout ? base : checkout.ref}' for repository '${checkout.repo}' does not resolve to a commit`,
892
+ message: `${"branch" in checkout ? "Base" : "Reference"} '${"branch" in checkout ? base : checkout.ref}' for repository '${checkout.repo}' does not resolve to a commit${recovery}`,
889
893
  })
890
894
  }
891
895
 
@@ -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 `agency pr create`
63
- from its execution unit, then use `agency sync --apply` to reconcile PR state.
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