@markjaquith/agency 2.50.0 → 2.52.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.
Files changed (36) hide show
  1. package/README.md +34 -8
  2. package/cli.ts +33 -1
  3. package/package.json +1 -1
  4. package/schemas/agency-graph-v1.schema.json +1 -0
  5. package/src/cli-parser.test.ts +19 -0
  6. package/src/cli-parser.ts +23 -0
  7. package/src/cli.test.ts +4 -4
  8. package/src/commands/init.test.ts +2 -0
  9. package/src/commands/vcs.test.ts +75 -0
  10. package/src/commands/vcs.ts +72 -0
  11. package/src/commands/worktree.ts +1 -1
  12. package/src/graph-schema.test.ts +1 -1
  13. package/src/graph-schema.ts +1 -0
  14. package/src/services/ArchiveService.ts +23 -0
  15. package/src/services/ContextService.ts +62 -13
  16. package/src/services/DoctorService.ts +13 -6
  17. package/src/services/GraphService.ts +1 -0
  18. package/src/services/PhaseService.ts +191 -70
  19. package/src/services/PullRequestService.ts +14 -30
  20. package/src/services/RepositoryService.test.ts +31 -1
  21. package/src/services/RepositoryService.ts +63 -31
  22. package/src/services/ReviewService.ts +23 -0
  23. package/src/services/SyncService.test.ts +67 -0
  24. package/src/services/SyncService.ts +51 -84
  25. package/src/services/TaskPhaseService.test.ts +80 -0
  26. package/src/services/VcsMigrationService.test.ts +245 -0
  27. package/src/services/VcsMigrationService.ts +812 -0
  28. package/src/services/VersionControlService.test.ts +100 -0
  29. package/src/services/VersionControlService.ts +479 -0
  30. package/src/services/WorkbaseService.ts +5 -1
  31. package/src/services/WorktreeService.test.ts +72 -1
  32. package/src/services/WorktreeService.ts +808 -308
  33. package/src/test-utils.ts +10 -0
  34. package/src/workbase/AGENTS.md +2 -2
  35. package/src/workbase/schemas.ts +1 -0
  36. package/src/workbase/version-control.ts +5 -0
package/README.md CHANGED
@@ -2,13 +2,14 @@
2
2
 
3
3
  Agency manages durable agentic work across repositories. Epics, tasks, and
4
4
  phases live as Markdown documents in a filesystem-backed workbase. Repository
5
- aliases and Git worktrees provide each execution unit with the code it may read
6
- or write.
5
+ aliases and managed workspaces provide each execution unit with the code it may
6
+ read or write.
7
7
 
8
8
  ## Requirements
9
9
 
10
10
  - [Bun](https://bun.sh) 1.0 or newer
11
11
  - Git
12
+ - [Jujutsu](https://jj-vcs.github.io/jj/) is preferred when available
12
13
  - [GitHub CLI](https://cli.github.com/) for `agency pr create`
13
14
  - OpenCode, Claude Code, or a configured runner for `agency work`
14
15
 
@@ -122,14 +123,15 @@ The plugin grants whole-workbase access dynamically, while the portable
122
123
  reference advertises that context to agents. Bash and Agency operations must
123
124
  still follow the write authority reported by `agency context`.
124
125
 
125
- Repository aliases and canonical fetch remotes are declared in tracked
126
- `agency.json`; local bare clones and symlinks remain ignored under
126
+ Repository aliases, the version-control backend, and canonical fetch remotes are
127
+ declared in tracked `agency.json`; local clones and symlinks remain ignored under
127
128
  `repos/{alias}`. A declaration contains no local path, symlink target, checkout,
128
129
  or credential:
129
130
 
130
131
  ```json
131
132
  {
132
133
  "version": 2,
134
+ "vcs": "jj",
133
135
  "repositories": {
134
136
  "frontend": {
135
137
  "remote": "git@example.com:team/frontend.git"
@@ -138,6 +140,29 @@ or credential:
138
140
  }
139
141
  ```
140
142
 
143
+ New workbases select `"jj"` when the `jj` executable is available and otherwise
144
+ select `"git"`. Existing version 2 workbases without `vcs` remain Git workbases.
145
+ The selected backend is a workbase-level invariant: jj workbases initialize
146
+ managed clones with `jj git init` and create jj workspaces, while Git workbases
147
+ continue to use Git worktrees.
148
+
149
+ Inspect the current backend and migration readiness with:
150
+
151
+ ```bash
152
+ agency vcs status
153
+ agency vcs migrate jj # dry-run
154
+ agency vcs migrate jj --apply
155
+ agency vcs migrate git --apply
156
+ ```
157
+
158
+ Migration is workbase-wide and transactional. Agency locks every execution
159
+ unit, requires clean registered workspaces, blocks active claims and working or
160
+ delegated units, converts repository metadata, recreates checkout paths with the
161
+ target backend, and updates `agency.json` last. Migration from jj to Git also
162
+ blocks jj-only heads that are not preserved by a bookmark or workspace. Failed
163
+ migrations restore the source repositories and workspaces when rollback is
164
+ possible and report explicit manual recovery paths otherwise.
165
+
141
166
  Existing version 2 workbases without `repositories` remain valid. Run
142
167
  `agency repo setup` to preview deterministic adoption of legacy local aliases;
143
168
  `agency repo setup --apply` writes declarations only when a portable origin is
@@ -146,7 +171,7 @@ creation command.
146
171
 
147
172
  ### Custom Worktree Command
148
173
 
149
- By default, Agency creates worktrees with Git. Set `worktreeCreateCommand` to an
174
+ Git workbases create worktrees with Git. Set `worktreeCreateCommand` to an
150
175
  argv template when another tool should create writable worktrees:
151
176
 
152
177
  ```json
@@ -205,9 +230,10 @@ Custom commands own writable branch creation. Agency checks for conflicting
205
230
  worktrees first, invokes the command only when the branch is not checked out,
206
231
  and verifies that `{worktree}` exists afterward.
207
232
 
208
- The configured command applies only to the writable checkout. Supplemental
209
- read-only repositories remain detached Git worktrees at their declared refs so
210
- they do not acquire writable branches.
233
+ The configured command applies only to the writable checkout of a Git workbase.
234
+ Supplemental read-only repositories remain detached Git worktrees at their
235
+ declared refs so they do not acquire writable branches. Jj workbases always use
236
+ jj workspaces and ignore this Git-specific customization.
211
237
 
212
238
  ### Agent Runners
213
239
 
package/cli.ts CHANGED
@@ -44,7 +44,14 @@ import { ReadinessService } from "./src/services/ReadinessService"
44
44
  import { GraphMutationService } from "./src/services/GraphMutationService"
45
45
  import { DoctorService } from "./src/services/DoctorService"
46
46
  import { ReviewService } from "./src/services/ReviewService"
47
+ import {
48
+ GitVersionControlService,
49
+ JjVersionControlService,
50
+ VersionControlService,
51
+ } from "./src/services/VersionControlService"
47
52
  import { review, help as reviewHelp } from "./src/commands/review"
53
+ import { vcs, help as vcsHelp } from "./src/commands/vcs"
54
+ import { VcsMigrationService } from "./src/services/VcsMigrationService"
48
55
  import {
49
56
  claimCommand,
50
57
  claimHelp,
@@ -62,6 +69,10 @@ import {
62
69
  const CliLayer = Layer.mergeAll(
63
70
  FileSystemService.Default,
64
71
  WorkbaseService.Default,
72
+ GitVersionControlService.Default,
73
+ JjVersionControlService.Default,
74
+ VersionControlService.Default,
75
+ VcsMigrationService.Default,
65
76
  RepositoryService.Default,
66
77
  EpicService.Default,
67
78
  TaskService.Default,
@@ -547,6 +558,26 @@ const commands: Record<string, Command> = {
547
558
  )
548
559
  },
549
560
  },
561
+ vcs: {
562
+ run: async (args: string[], options: Record<string, any>) => {
563
+ if (options.help) {
564
+ console.log(vcsHelp)
565
+ return
566
+ }
567
+ await runCommand(
568
+ vcs({
569
+ subcommand: args[0],
570
+ target: args[1],
571
+ apply: options.apply,
572
+ dryRun: options["dry-run"],
573
+ json: options.json,
574
+ silent: options.silent,
575
+ verbose: options.verbose,
576
+ cwd: options.cwd,
577
+ }),
578
+ )
579
+ },
580
+ },
550
581
  next: {
551
582
  run: async (_args: string[], options: Record<string, any>) => {
552
583
  if (options.help) return console.log(nextHelp)
@@ -702,7 +733,8 @@ Commands:
702
733
  archive <type> Archive a work item
703
734
  task <subcommand> Manage tasks
704
735
  work [directory|task] Work on an epic, task, or phase
705
- worktree <subcommand> Inspect and maintain managed worktrees
736
+ worktree <subcommand> Inspect and maintain managed workspaces
737
+ vcs <subcommand> Inspect or migrate the version-control backend
706
738
  next List or select ready execution units
707
739
  pr create Create a pull request for an execution unit
708
740
  review refresh Explicitly refresh a pinned review task
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "2.50.0",
3
+ "version": "2.52.0",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -22,6 +22,7 @@
22
22
  "required": ["version"],
23
23
  "properties": {
24
24
  "version": { "const": 2 },
25
+ "vcs": { "enum": ["git", "jj"] },
25
26
  "root": { "type": "string" }
26
27
  }
27
28
  },
@@ -571,6 +571,25 @@ describe("strict CLI parsing", () => {
571
571
  )
572
572
  })
573
573
 
574
+ test("parses VCS status and migration commands", () => {
575
+ expect(parseCli(["vcs", "status", "--json"])).toMatchObject({
576
+ commandName: "vcs",
577
+ args: ["status"],
578
+ values: { json: true },
579
+ })
580
+ expect(
581
+ parseCli(["vcs", "migrate", "jj", "--apply", "--json"]),
582
+ ).toMatchObject({
583
+ commandName: "vcs",
584
+ args: ["migrate", "jj"],
585
+ values: { apply: true, json: true },
586
+ })
587
+ expectUsageError(["vcs", "migrate"], "agency vcs migrate")
588
+ expect(() =>
589
+ parseCli(["vcs", "migrate", "jj", "--dry-run", "--apply"]),
590
+ ).toThrow("cannot be combined")
591
+ })
592
+
574
593
  test("accepts runner selection and command inspection for work", () => {
575
594
  expect(
576
595
  parseCli([
package/src/cli-parser.ts CHANGED
@@ -832,6 +832,29 @@ const commands = {
832
832
  },
833
833
  },
834
834
  },
835
+ vcs: {
836
+ usage: "agency vcs <status|migrate>",
837
+ options: {
838
+ ...outputOptions,
839
+ apply: { type: "boolean" },
840
+ "dry-run": { type: "boolean" },
841
+ },
842
+ subcommands: {
843
+ status: {
844
+ usage: "agency vcs status [--json]",
845
+ minArgs: 0,
846
+ maxArgs: 0,
847
+ options: ["json"],
848
+ },
849
+ migrate: {
850
+ usage: "agency vcs migrate <git|jj> [--dry-run | --apply] [--json]",
851
+ minArgs: 1,
852
+ maxArgs: 1,
853
+ options: ["apply", "dry-run", "json"],
854
+ conflicts: [["apply", "dry-run"]],
855
+ },
856
+ },
857
+ },
835
858
  work: {
836
859
  usage:
837
860
  "agency work [<directory-or-task-id> | --epic <epic-id>] [--runner <name>] [--auto] | agency work prepare [target] [--dry-run] [--json]",
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, symlink } from "node:fs/promises"
2
+ import { access, 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"
@@ -1762,9 +1762,9 @@ status: open
1762
1762
  alias: "agency",
1763
1763
  status: "applied",
1764
1764
  })
1765
- expect(await Bun.file(join(restored, "repos/agency/HEAD")).exists()).toBe(
1766
- true,
1767
- )
1765
+ expect(
1766
+ (await stat(join(restored, "repos/agency/.jj"))).isDirectory(),
1767
+ ).toBe(true)
1768
1768
 
1769
1769
  const prepared = parseJson(
1770
1770
  await runCli(["work", "prepare", "portable", "--json"], restored),
@@ -8,6 +8,7 @@ import {
8
8
  runTestEffect,
9
9
  } from "../test-utils"
10
10
  import { init } from "./init"
11
+ import { preferredVersionControl } from "../workbase/version-control"
11
12
 
12
13
  describe("init command", () => {
13
14
  let parent: string
@@ -26,6 +27,7 @@ describe("init command", () => {
26
27
 
27
28
  expect(await Bun.file(join(root, "agency.json")).json()).toEqual({
28
29
  version: 2,
30
+ vcs: preferredVersionControl(),
29
31
  })
30
32
  for (const directory of ["repos", "epics", "tasks"]) {
31
33
  expect((await stat(join(root, directory))).isDirectory()).toBe(true)
@@ -0,0 +1,75 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test"
2
+ import {
3
+ captureLogs,
4
+ cleanupTempDir,
5
+ createTempDir,
6
+ runTestEffect,
7
+ } from "../test-utils"
8
+ import { vcs } from "./vcs"
9
+
10
+ describe("vcs command", () => {
11
+ let root: string
12
+
13
+ beforeEach(async () => {
14
+ root = await createTempDir()
15
+ await Bun.write(
16
+ `${root}/agency.json`,
17
+ JSON.stringify({ version: 2, vcs: "git" }),
18
+ )
19
+ })
20
+
21
+ afterEach(async () => cleanupTempDir(root))
22
+
23
+ test("reports structured backend status", async () => {
24
+ const logs = await captureLogs(() =>
25
+ runTestEffect(vcs({ subcommand: "status", cwd: root, json: true })),
26
+ )
27
+ expect(JSON.parse(logs[0]!)).toMatchObject({
28
+ root,
29
+ configured: "git",
30
+ source: "git",
31
+ target: "git",
32
+ workspaceCount: 0,
33
+ blockers: [],
34
+ })
35
+ })
36
+
37
+ test("persists the inferred Git backend for a legacy workbase", async () => {
38
+ await Bun.write(`${root}/agency.json`, JSON.stringify({ version: 2 }))
39
+ await captureLogs(() =>
40
+ runTestEffect(
41
+ vcs({
42
+ subcommand: "migrate",
43
+ target: "git",
44
+ cwd: root,
45
+ apply: true,
46
+ json: true,
47
+ }),
48
+ ),
49
+ )
50
+ expect(await Bun.file(`${root}/agency.json`).json()).toEqual({
51
+ version: 2,
52
+ vcs: "git",
53
+ })
54
+ })
55
+
56
+ test("treats migration to the configured backend as a no-op", async () => {
57
+ const logs = await captureLogs(() =>
58
+ runTestEffect(
59
+ vcs({
60
+ subcommand: "migrate",
61
+ target: "git",
62
+ cwd: root,
63
+ apply: true,
64
+ json: true,
65
+ }),
66
+ ),
67
+ )
68
+ expect(JSON.parse(logs[0]!)).toMatchObject({
69
+ source: "git",
70
+ target: "git",
71
+ mode: "apply",
72
+ actions: [],
73
+ })
74
+ })
75
+ })
@@ -0,0 +1,72 @@
1
+ import { Effect } from "effect"
2
+ import { VcsMigrationService } from "../services/VcsMigrationService"
3
+ import type { BaseCommandOptions } from "../utils/command"
4
+ import { createLoggers } from "../utils/effect"
5
+
6
+ interface VcsOptions extends BaseCommandOptions {
7
+ readonly subcommand?: string
8
+ readonly target?: string
9
+ readonly apply?: boolean
10
+ }
11
+
12
+ export const vcs = (options: VcsOptions = {}) =>
13
+ Effect.gen(function* () {
14
+ const migrations = yield* VcsMigrationService
15
+ const { log } = createLoggers(options)
16
+ const root = options.cwd ?? process.cwd()
17
+ if (options.subcommand === "status") {
18
+ const status = yield* migrations.status(root)
19
+ if (options.json) return log(JSON.stringify(status, null, 2))
20
+ log(
21
+ `Version control: ${status.source}${status.configured ? "" : " (inferred for legacy workbase)"}`,
22
+ )
23
+ log(
24
+ `Tools: git=${status.available.git ? "available" : "missing"} jj=${status.available.jj ? "available" : "missing"}`,
25
+ )
26
+ log(
27
+ `Repositories: ${status.repositories.length}; managed workspaces: ${status.workspaceCount}; blockers: ${status.blockers.length}`,
28
+ )
29
+ for (const blocker of status.blockers)
30
+ log(`blocker ${blocker.kind} ${blocker.target}: ${blocker.message}`)
31
+ return
32
+ }
33
+ if (options.subcommand === "migrate") {
34
+ if (options.target !== "git" && options.target !== "jj") {
35
+ return yield* Effect.fail(
36
+ new Error("VCS migration target must be 'git' or 'jj'"),
37
+ )
38
+ }
39
+ const result = yield* migrations.migrate(options.target, root, {
40
+ apply: options.apply,
41
+ })
42
+ if (options.json) return log(JSON.stringify(result, null, 2))
43
+ log(
44
+ result.mode === "apply"
45
+ ? `Migrated workbase to ${options.target}`
46
+ : `Migration plan: ${result.source} -> ${options.target}`,
47
+ )
48
+ for (const action of result.actions) log(`- ${action}`)
49
+ if (result.mode === "dry-run" && result.actions.length > 0)
50
+ log("Run again with --apply to perform the migration.")
51
+ return
52
+ }
53
+ return yield* Effect.fail(
54
+ new Error(`Unknown vcs subcommand '${options.subcommand ?? ""}'`),
55
+ )
56
+ })
57
+
58
+ export const help = `
59
+ Usage: agency vcs <status|migrate>
60
+
61
+ Inspect or migrate the workbase-wide version-control backend.
62
+
63
+ Commands:
64
+ status Show the configured backend, tools, repositories, and blockers
65
+ migrate <git|jj> [--dry-run | --apply]
66
+ Preview or apply a clean, transactional backend migration
67
+
68
+ Options:
69
+ --apply Apply the migration; omission performs a dry run
70
+ --dry-run Explicitly preview the migration without applying it
71
+ --json Print structured output
72
+ `
@@ -120,7 +120,7 @@ export const worktree = (options: WorktreeOptions = {}) =>
120
120
  export const help = `
121
121
  Usage: agency worktree <list|inspect|prepare|remove|rebuild|repair>
122
122
 
123
- Inspect and maintain Agency-managed writable and reference worktrees.
123
+ Inspect and maintain Agency-managed writable and reference workspaces.
124
124
 
125
125
  Commands:
126
126
  list List every managed checkout
@@ -64,7 +64,7 @@ describe("graph contract", () => {
64
64
  ]
65
65
  const graph = {
66
66
  version: 1,
67
- workbase: { version: 2 },
67
+ workbase: { version: 2, vcs: "git" },
68
68
  filters: {
69
69
  ready: null,
70
70
  blocked: null,
@@ -56,6 +56,7 @@ export const GraphReadiness = Schema.Struct({
56
56
 
57
57
  export const GraphWorkbase = Schema.Struct({
58
58
  version: Schema.Literal(2),
59
+ vcs: Schema.optional(Schema.Literal("git", "jj")),
59
60
  root: Schema.optional(Schema.String),
60
61
  })
61
62
 
@@ -11,6 +11,11 @@ import {
11
11
  WorktreeService,
12
12
  type WorktreeRemovalSnapshot,
13
13
  } from "./WorktreeService"
14
+ import {
15
+ GitVersionControlService,
16
+ JjVersionControlService,
17
+ VersionControlService,
18
+ } from "./VersionControlService"
14
19
  import {
15
20
  formatMarkdownDocument,
16
21
  parseFrontmatter,
@@ -268,6 +273,9 @@ const applyMutation = (moves: readonly Move[], writes: readonly Write[]) =>
268
273
  const WorktreeLayer = Layer.mergeAll(
269
274
  FileSystemService.Default,
270
275
  WorkbaseService.Default,
276
+ GitVersionControlService.Default,
277
+ JjVersionControlService.Default,
278
+ VersionControlService.Default,
271
279
  TaskService.Default,
272
280
  PhaseService.Default,
273
281
  WorktreeService.Default,
@@ -298,6 +306,21 @@ const restoreWorktreeSnapshots = async (
298
306
  continue
299
307
  } catch {}
300
308
  await mkdir(dirname(snapshot.path), { recursive: true })
309
+ if (snapshot.vcs === "jj") {
310
+ await runGit([
311
+ "jj",
312
+ "-R",
313
+ snapshot.repositoryPath,
314
+ "workspace",
315
+ "add",
316
+ "--name",
317
+ snapshot.workspaceName!,
318
+ "-r",
319
+ snapshot.head,
320
+ snapshot.path,
321
+ ])
322
+ continue
323
+ }
301
324
  await runGit(
302
325
  snapshot.branch
303
326
  ? [
@@ -8,6 +8,7 @@ import { RepositoryService } from "./RepositoryService"
8
8
  import { aggregateProgress, readinessState } from "../readiness"
9
9
  import { parseFrontmatter } from "../workbase/frontmatter"
10
10
  import { documentRevision } from "../workbase/document-revision"
11
+ import { VersionControlService } from "./VersionControlService"
11
12
  import {
12
13
  EpicFrontmatter,
13
14
  PhaseFrontmatter,
@@ -127,6 +128,7 @@ export class ContextService extends Effect.Service<ContextService>()(
127
128
  const fs = yield* FileSystemService
128
129
  const workbase = yield* WorkbaseService
129
130
  const repositoryService = yield* RepositoryService
131
+ const versionControl = yield* VersionControlService
130
132
  const cwd = resolve(options.cwd ?? process.cwd())
131
133
  const suppliedTarget = options.target ?? "."
132
134
  const candidate = resolve(cwd, suppliedTarget)
@@ -134,6 +136,7 @@ export class ContextService extends Effect.Service<ContextService>()(
134
136
  const { root, config } = yield* workbase.loadConfig(
135
137
  candidateExists ? candidate : cwd,
136
138
  )
139
+ const backend = yield* versionControl.forWorkbase(root)
137
140
 
138
141
  if (relative(root, candidate) === "") {
139
142
  const compact = !options.full
@@ -214,6 +217,7 @@ export class ContextService extends Effect.Service<ContextService>()(
214
217
  root,
215
218
  configPath: join(root, "agency.json"),
216
219
  version: config.version,
220
+ vcs: config.vcs ?? "git",
217
221
  },
218
222
  target: { kind: "workbase", path: root },
219
223
  hint: compact
@@ -801,9 +805,45 @@ export class ContextService extends Effect.Service<ContextService>()(
801
805
  const inspectCheckout = (
802
806
  repositoryPath: string,
803
807
  checkoutPath: string,
808
+ expectedBranch: string | null = null,
804
809
  ) =>
805
810
  Effect.gen(function* (): Generator<any, CheckoutInspection, any> {
806
811
  const materialized = yield* fs.isDirectory(checkoutPath)
812
+ if (backend.kind === "jj") {
813
+ const listed = yield* Effect.either(
814
+ backend.listWorkspaces(repositoryPath),
815
+ )
816
+ const workspaces = Either.isRight(listed) ? listed.right : []
817
+ if (Either.isLeft(listed)) {
818
+ inspectionWarnings.push(
819
+ `Unable to inspect jj workspace registrations for ${repositoryPath}`,
820
+ )
821
+ }
822
+ const canonicalCheckoutPath = materialized
823
+ ? yield* fs.realPath(checkoutPath)
824
+ : resolve(checkoutPath)
825
+ const registered = workspaces.some(
826
+ (workspace) => workspace.path === canonicalCheckoutPath,
827
+ )
828
+ if (!materialized) {
829
+ return {
830
+ materialized: false,
831
+ registered,
832
+ checkoutCommit: null,
833
+ checkoutBranch: null,
834
+ detached: null,
835
+ dirty: null,
836
+ }
837
+ }
838
+ return {
839
+ materialized: true,
840
+ registered,
841
+ checkoutCommit: yield* backend.workspaceHead(checkoutPath),
842
+ checkoutBranch: expectedBranch,
843
+ detached: expectedBranch === null,
844
+ dirty: yield* backend.workspaceDirty(checkoutPath),
845
+ }
846
+ }
807
847
  const listed = yield* runGit(fs, repositoryPath, [
808
848
  "worktree",
809
849
  "list",
@@ -870,14 +910,22 @@ export class ContextService extends Effect.Service<ContextService>()(
870
910
  const repositoryPath =
871
911
  repository?.path ?? join(root, "repos", executionData.repo)
872
912
  const checkoutPath = join(codePath, executionData.repo)
873
- const branchCommit = yield* runGit(fs, repositoryPath, [
874
- "rev-parse",
875
- `${executionData.branch}^{commit}`,
876
- ])
877
- const baseCommit = yield* runGit(fs, repositoryPath, [
878
- "rev-parse",
879
- `${executionData.base}^{commit}`,
880
- ])
913
+ let branchCommit = yield* backend.resolveRevision(
914
+ repositoryPath,
915
+ executionData.branch,
916
+ )
917
+ const baseCommit = yield* backend.resolveRevision(
918
+ repositoryPath,
919
+ executionData.base,
920
+ )
921
+ const checkout = yield* inspectCheckout(
922
+ repositoryPath,
923
+ checkoutPath,
924
+ executionData.branch,
925
+ )
926
+ if (backend.kind === "jj" && branchCommit === null) {
927
+ branchCommit = checkout.checkoutCommit
928
+ }
881
929
  if (branchCommit === null) {
882
930
  inspectionWarnings.push(
883
931
  `Unable to resolve branch '${executionData.branch}' in ${repositoryPath}`,
@@ -896,7 +944,7 @@ export class ContextService extends Effect.Service<ContextService>()(
896
944
  base: executionData.base,
897
945
  branchCommit,
898
946
  baseCommit,
899
- ...(yield* inspectCheckout(repositoryPath, checkoutPath)),
947
+ ...checkout,
900
948
  }
901
949
  })
902
950
  : null
@@ -907,10 +955,10 @@ export class ContextService extends Effect.Service<ContextService>()(
907
955
  const repositoryPath =
908
956
  repository?.path ?? join(root, "repos", reference.repo)
909
957
  const checkoutPath = join(codePath, reference.repo)
910
- const resolvedCommit = yield* runGit(fs, repositoryPath, [
911
- "rev-parse",
912
- `${reference.ref}^{commit}`,
913
- ])
958
+ const resolvedCommit = yield* backend.resolveRevision(
959
+ repositoryPath,
960
+ reference.ref,
961
+ )
914
962
  if (resolvedCommit === null) {
915
963
  inspectionWarnings.push(
916
964
  `Unable to resolve reference '${reference.ref}' in ${repositoryPath}`,
@@ -968,6 +1016,7 @@ export class ContextService extends Effect.Service<ContextService>()(
968
1016
  root,
969
1017
  configPath: join(root, "agency.json"),
970
1018
  version: config.version,
1019
+ vcs: config.vcs ?? "git",
971
1020
  },
972
1021
  target,
973
1022
  documents: {
@@ -126,6 +126,14 @@ export class DoctorService extends Effect.Service<DoctorService>()(
126
126
  }
127
127
 
128
128
  const gitAvailable = yield* tool("tool.git", "git", "error", "Git")
129
+ const jjAvailable = yield* tool(
130
+ "tool.jj",
131
+ "jj",
132
+ config.vcs === "jj" ? "error" : "optional",
133
+ "Jujutsu",
134
+ )
135
+ const versionControlAvailable =
136
+ gitAvailable && (config.vcs !== "jj" || jjAvailable)
129
137
  yield* tool(
130
138
  "capability.runner.opencode",
131
139
  "opencode",
@@ -321,18 +329,17 @@ export class DoctorService extends Effect.Service<DoctorService>()(
321
329
  }
322
330
  }
323
331
 
324
- const repositoryList = gitAvailable
332
+ const repositoryList = versionControlAvailable
325
333
  ? yield* repositories.list(root)
326
334
  : []
327
- if (!gitAvailable) {
335
+ if (!versionControlAvailable) {
328
336
  add({
329
337
  id: "repository.inspection",
330
338
  category: "repository",
331
339
  level: "warning",
332
340
  status: "fail",
333
- message:
334
- "Repository, ref, remote, and worktree checks were skipped because Git is unavailable",
335
- remediation: "Install 'git' and rerun 'agency doctor'.",
341
+ message: `Repository, ref, remote, and workspace checks were skipped because the ${config.vcs ?? "git"} backend is unavailable`,
342
+ remediation: `Install '${config.vcs === "jj" ? "jj" : "git"}' and rerun 'agency doctor'.`,
336
343
  })
337
344
  }
338
345
  for (const repository of repositoryList) {
@@ -439,7 +446,7 @@ export class DoctorService extends Effect.Service<DoctorService>()(
439
446
  }
440
447
  }
441
448
 
442
- if (validation.valid && gitAvailable) {
449
+ if (validation.valid && versionControlAvailable) {
443
450
  const inspected = yield* Effect.either(worktrees.list(root))
444
451
  if (Either.isLeft(inspected)) {
445
452
  add({
@@ -943,6 +943,7 @@ export class GraphService extends Effect.Service<GraphService>()(
943
943
  version: GRAPH_VERSION,
944
944
  workbase: {
945
945
  version: config.version,
946
+ vcs: config.vcs ?? "git",
946
947
  ...(include.has("workspace") ? { root } : {}),
947
948
  },
948
949
  filters,