@markjaquith/agency 2.56.1 → 2.58.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 (34) hide show
  1. package/README.md +35 -9
  2. package/package.json +1 -1
  3. package/src/cli-parser.test.ts +8 -0
  4. package/src/cli-parser.ts +7 -1
  5. package/src/commands/archive.test.ts +39 -1
  6. package/src/commands/archive.ts +25 -2
  7. package/src/commands/pr.test.ts +40 -1
  8. package/src/commands/pr.ts +6 -0
  9. package/src/commands/restore.test.ts +8 -0
  10. package/src/services/ArchiveBulkService.test.ts +485 -0
  11. package/src/services/ArchiveService.test.ts +164 -1
  12. package/src/services/ArchiveService.ts +447 -29
  13. package/src/services/ContextService.ts +27 -10
  14. package/src/services/DoctorService.ts +6 -11
  15. package/src/services/GraphService.ts +60 -102
  16. package/src/services/PullRequestService.test.ts +45 -1
  17. package/src/services/PullRequestService.ts +18 -1
  18. package/src/services/PushService.test.ts +10 -13
  19. package/src/services/RepositoryService.test.ts +6 -1
  20. package/src/services/RepositoryService.ts +57 -114
  21. package/src/services/ReviewService.test.ts +2 -2
  22. package/src/services/ReviewService.ts +47 -28
  23. package/src/services/SyncService.test.ts +17 -2
  24. package/src/services/SyncService.ts +41 -18
  25. package/src/services/TaskService.ts +18 -2
  26. package/src/services/VcsMigrationService.test.ts +58 -1
  27. package/src/services/VcsMigrationService.ts +9 -0
  28. package/src/services/VersionControlService.test.ts +38 -0
  29. package/src/services/VersionControlService.ts +211 -2
  30. package/src/services/WorktreeService.test.ts +4 -0
  31. package/src/services/WorktreeService.ts +1 -5
  32. package/src/vcs-status-fast.ts +3 -8
  33. package/src/workbase/delivery-command.test.ts +11 -2
  34. package/src/workbase/delivery-command.ts +5 -1
package/README.md CHANGED
@@ -142,9 +142,17 @@ or credential:
142
142
 
143
143
  New workbases select `"jj"` when the `jj` executable is available and otherwise
144
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.
145
+ The selected backend is a workbase-level invariant: jj workbases create
146
+ non-colocated managed clones and jj workspaces, while Git workbases continue to
147
+ use bare repositories and Git worktrees. Existing colocated jj repositories are
148
+ also supported.
149
+
150
+ Agency uses jj-native commands for repository validity, workspace registration,
151
+ working-copy state, revisions, bookmarks, ancestry, fetch, and push. Raw Git is
152
+ limited to backing-store plumbing such as durable review refs and integrations
153
+ that require Git, including GitHub CLI commit discovery. For those operations,
154
+ Agency obtains the backing repository with `jj git root` and supplies it as
155
+ `GIT_DIR`; it does not use raw Git to infer jj workspace state.
148
156
 
149
157
  Inspect the current backend and migration readiness with:
150
158
 
@@ -162,6 +170,9 @@ target backend, and updates `agency.json` last. Migration from jj to Git also
162
170
  blocks jj-only heads that are not preserved by a bookmark or workspace. Failed
163
171
  migrations restore the source repositories and workspaces when rollback is
164
172
  possible and report explicit manual recovery paths otherwise.
173
+ Converting a non-colocated jj workbase to Git is currently blocked before any
174
+ mutation because its backing Git object store is inside `.jj`; first convert the
175
+ repositories to colocated jj so the Git store survives metadata removal.
165
176
 
166
177
  Existing version 2 workbases without `repositories` remain valid. Run
167
178
  `agency repo setup` to preview deterministic adoption of legacy local aliases;
@@ -848,6 +859,7 @@ agency archive show <epic|task> <id>
848
859
  agency archive show phase <task-id> <phase-id>
849
860
  agency archive epic <epic-id> [--dry-run] [--json]
850
861
  agency archive task <task-id> [--dry-run] [--json]
862
+ agency archive tasks [--dry-run] [--json]
851
863
  agency archive phase <task-id> <phase-id> [--dry-run] [--json]
852
864
  agency restore epic <epic-id> [--dry-run] [--json]
853
865
  agency restore task <task-id> [--dry-run] [--json]
@@ -855,12 +867,26 @@ agency restore phase <task-id> <phase-id> [--dry-run] [--json]
855
867
  ```
856
868
 
857
869
  Archived work keeps its hierarchy under `archive/`. Epic archiving includes its
858
- listed tasks. Task and phase archiving update the active parent document and
859
- reject items that active siblings depend on. Agency removes registered worktrees
860
- before moving files, refuses dirty worktrees, and preserves branches. Archive and
861
- restore preflight all destinations and graph references before changing files.
862
- Versioned lifecycle provenance preserves parent declarations and dependency edges
863
- for restoration. Archived IDs are reserved until restored.
870
+ listed tasks. A task can be archived only when its effective status is terminal
871
+ (`done` or `dropped`). Multi-phase task status is derived from its phases, every
872
+ phase must be terminal, and a task with no phases is not eligible.
873
+
874
+ `archive tasks` plans the maximal safe cohort of terminal tasks and applies by
875
+ default; `--dry-run` performs the same preflight without mutation. A dependency
876
+ is work required by a candidate, while a dependent is work that requires the
877
+ candidate. Dependencies within the selected cohort can be archived together,
878
+ but a retained dependent excludes its dependency, including exclusions that
879
+ propagate through a dependency chain. Active claims, dirty or otherwise unsafe
880
+ managed checkouts, and occupied archive destinations are reported as per-task
881
+ skips. Invalid workbase structure and infrastructure failures abort the command.
882
+
883
+ Task and phase archiving update active parent documents. Agency removes
884
+ registered worktrees before moving files, refuses dirty worktrees, and preserves
885
+ branches. Bulk application updates shared parents once and archives the entire
886
+ selected cohort in one rollback-capable transaction; it never archives an empty
887
+ parent epic implicitly. Versioned lifecycle provenance preserves parent
888
+ declarations and dependency edges for restoration. Archived IDs are reserved
889
+ until restored.
864
890
 
865
891
  ### Work, Publication, and Pull Requests
866
892
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "2.56.1",
3
+ "version": "2.58.0",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -282,6 +282,7 @@ describe("strict CLI parsing", () => {
282
282
  ],
283
283
  [["archive", "epic", "one", "two"], "agency archive epic"],
284
284
  [["archive", "task", "one", "two"], "agency archive task"],
285
+ [["archive", "tasks", "one"], "agency archive tasks"],
285
286
  [["archive", "phase", "one", "two", "three"], "agency archive phase"],
286
287
  [["archive", "list", "extra"], "agency archive list"],
287
288
  [
@@ -414,6 +415,13 @@ describe("strict CLI parsing", () => {
414
415
  values: { "dry-run": true },
415
416
  },
416
417
  )
418
+ expect(parseCli(["archive", "tasks", "--dry-run", "--json"])).toMatchObject(
419
+ {
420
+ commandName: "archive",
421
+ args: ["tasks"],
422
+ values: { "dry-run": true, json: true },
423
+ },
424
+ )
417
425
  })
418
426
 
419
427
  test("validates revision-guarded claim operations", () => {
package/src/cli-parser.ts CHANGED
@@ -691,7 +691,7 @@ const commands = {
691
691
  },
692
692
  },
693
693
  archive: {
694
- usage: "agency archive <list|show|epic|task|phase>",
694
+ usage: "agency archive <list|show|epic|task|tasks|phase>",
695
695
  options: {
696
696
  ...outputOptions,
697
697
  ...entitySelectorOptions,
@@ -728,6 +728,12 @@ const commands = {
728
728
  maxArgs: 1,
729
729
  options: ["dry-run", "json", "task"],
730
730
  },
731
+ tasks: {
732
+ usage: "agency archive tasks [--dry-run] [--json]",
733
+ minArgs: 0,
734
+ maxArgs: 0,
735
+ options: ["dry-run", "json"],
736
+ },
731
737
  phase: {
732
738
  usage: "agency archive phase <task-id> <phase-id> [--dry-run] [--json]",
733
739
  minArgs: 2,
@@ -36,6 +36,14 @@ describe("archive command", () => {
36
36
  silent: true,
37
37
  }),
38
38
  )
39
+ await runTestEffect(
40
+ task({
41
+ subcommand: "status",
42
+ args: ["example", "dropped"],
43
+ cwd: root,
44
+ silent: true,
45
+ }),
46
+ )
39
47
  })
40
48
 
41
49
  afterEach(async () => cleanupTempDir(root))
@@ -85,10 +93,40 @@ describe("archive command", () => {
85
93
  )
86
94
  })
87
95
 
96
+ test("outputs one deterministic bulk archive object and a concise human summary", async () => {
97
+ const jsonLogs = await captureLogs(() =>
98
+ runTestEffect(
99
+ archive({
100
+ type: "tasks",
101
+ args: [],
102
+ cwd: root,
103
+ dryRun: true,
104
+ json: true,
105
+ }),
106
+ ),
107
+ )
108
+ expect(jsonLogs).toHaveLength(1)
109
+ expect(JSON.parse(jsonLogs[0]!)).toMatchObject({
110
+ operation: "archive",
111
+ kind: "tasks",
112
+ dryRun: true,
113
+ tasks: [{ id: "example", disposition: "planned" }],
114
+ })
115
+
116
+ const humanLogs = await captureLogs(() =>
117
+ runTestEffect(
118
+ archive({ type: "tasks", args: [], cwd: root, dryRun: true }),
119
+ ),
120
+ )
121
+ expect(humanLogs).toEqual([
122
+ "Would archive 1 task: example\nSkipped 0 tasks",
123
+ ])
124
+ })
125
+
88
126
  test("requires a supported work item type", async () => {
89
127
  await expect(
90
128
  runTestEffect(archive({ args: [], cwd: root, silent: true })),
91
- ).rejects.toThrow("Available: list, show, epic, task, phase")
129
+ ).rejects.toThrow("Available: list, show, epic, task, tasks, phase")
92
130
  })
93
131
 
94
132
  test("rejects an extra archive show identifier", async () => {
@@ -92,6 +92,11 @@ export const archive = (options: ArchiveOptions) =>
92
92
  dryRun: options.dryRun,
93
93
  })
94
94
  break
95
+ case "tasks":
96
+ result = yield* archives.archiveTasks(cwd, {
97
+ dryRun: options.dryRun,
98
+ })
99
+ break
95
100
  case "phase":
96
101
  if (!id || !phaseId)
97
102
  return yield* Effect.fail(
@@ -104,11 +109,28 @@ export const archive = (options: ArchiveOptions) =>
104
109
  default:
105
110
  return yield* Effect.fail(
106
111
  new Error(
107
- "Archive operation is required. Available: list, show, epic, task, phase",
112
+ "Archive operation is required. Available: list, show, epic, task, tasks, phase",
108
113
  ),
109
114
  )
110
115
  }
111
116
 
117
+ if (result.kind === "tasks") {
118
+ const selected = result.tasks.filter(
119
+ (task) => task.disposition !== "skipped",
120
+ )
121
+ const skipped = result.tasks.filter(
122
+ (task) => task.disposition === "skipped",
123
+ )
124
+ log(
125
+ options.json
126
+ ? JSON.stringify(result, null, 2)
127
+ : [
128
+ `${result.dryRun ? "Would archive" : "Archived"} ${selected.length} task${selected.length === 1 ? "" : "s"}${selected.length ? `: ${selected.map((task) => task.id).join(", ")}` : ""}`,
129
+ `Skipped ${skipped.length} task${skipped.length === 1 ? "" : "s"}${skipped.length ? `: ${skipped.map((task) => `${task.id} (${task.reason!.code})`).join(", ")}` : ""}`,
130
+ ].join("\n"),
131
+ )
132
+ return
133
+ }
112
134
  log(
113
135
  options.json
114
136
  ? JSON.stringify(result, null, 2)
@@ -119,7 +141,7 @@ export const archive = (options: ArchiveOptions) =>
119
141
  })
120
142
 
121
143
  export const help = `
122
- Usage: agency archive <list|show|epic|task|phase>
144
+ Usage: agency archive <list|show|epic|task|tasks|phase>
123
145
 
124
146
  Browse or archive work items after preflighting worktrees and graph references.
125
147
 
@@ -129,6 +151,7 @@ Commands:
129
151
  show phase <task-id> <phase-id> Show an archived phase
130
152
  epic <epic-id> Archive an epic and its tasks
131
153
  task <task-id> Archive a task
154
+ tasks Archive all eligible terminal tasks
132
155
  phase <task-id> <phase-id> Archive a phase
133
156
 
134
157
  Options:
@@ -1,6 +1,6 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test"
2
2
  import { Effect } from "effect"
3
- import { chmod, mkdir, realpath } from "node:fs/promises"
3
+ import { chmod, mkdir, realpath, rm } from "node:fs/promises"
4
4
  import { dirname, join } from "node:path"
5
5
  import { PullRequestService } from "../services/PullRequestService"
6
6
  import {
@@ -31,6 +31,7 @@ describe("pr command", () => {
31
31
  join(bin, "gh"),
32
32
  `#!/bin/sh
33
33
  printf '%s\n' "$PWD" "$@" > "$GH_CAPTURE"
34
+ if [ -n "$GIT_DIR" ]; then printf 'GIT_DIR=%s\n' "$GIT_DIR" >> "$GH_CAPTURE"; fi
34
35
  exit "\${GH_EXIT:-0}"
35
36
  `,
36
37
  )
@@ -109,6 +110,41 @@ status: open
109
110
  # Build
110
111
  `,
111
112
  )
113
+ if (vcs === "jj") {
114
+ const repository = join(root, "repos/agency")
115
+ await rm(join(root, "tasks/single/code/agency"), { recursive: true })
116
+ await rm(join(root, "tasks/multi/phases/build/code/agency"), {
117
+ recursive: true,
118
+ })
119
+ const initialized = Bun.spawn(
120
+ ["jj", "git", "init", "--no-colocate", repository],
121
+ { stdout: "pipe", stderr: "pipe" },
122
+ )
123
+ if ((await initialized.exited) !== 0)
124
+ throw new Error(await new Response(initialized.stderr).text())
125
+ for (const [name, path] of [
126
+ ["single", join(root, "tasks/single/code/agency")],
127
+ ["build", join(root, "tasks/multi/phases/build/code/agency")],
128
+ ] as const) {
129
+ const workspace = Bun.spawn(
130
+ [
131
+ "jj",
132
+ "-R",
133
+ repository,
134
+ "workspace",
135
+ "add",
136
+ "--name",
137
+ name,
138
+ "-r",
139
+ "root()",
140
+ path,
141
+ ],
142
+ { stdout: "pipe", stderr: "pipe" },
143
+ )
144
+ if ((await workspace.exited) !== 0)
145
+ throw new Error(await new Response(workspace.stderr).text())
146
+ }
147
+ }
112
148
  return root
113
149
  }
114
150
 
@@ -187,6 +223,7 @@ status: open
187
223
  "--repo",
188
224
  "example/agency",
189
225
  "--web",
226
+ expect.stringMatching(/^GIT_DIR=.*\.jj/),
190
227
  ])
191
228
 
192
229
  expect(
@@ -202,6 +239,7 @@ status: open
202
239
  "example/agency",
203
240
  "--title",
204
241
  "Example",
242
+ expect.stringMatching(/^GIT_DIR=.*\.jj/),
205
243
  ])
206
244
  })
207
245
 
@@ -215,6 +253,7 @@ status: open
215
253
  await realpath(join(task, "code/agency")),
216
254
  "pr",
217
255
  ...args,
256
+ expect.stringMatching(/^GIT_DIR=.*\.jj/),
218
257
  ])
219
258
  })
220
259
 
@@ -3,6 +3,7 @@ import { resolve } from "node:path"
3
3
  import { ContextService } from "../services/ContextService"
4
4
  import { FileSystemService } from "../services/FileSystemService"
5
5
  import { PullRequestService } from "../services/PullRequestService"
6
+ import { VersionControlService } from "../services/VersionControlService"
6
7
  import { WorkbaseService } from "../services/WorkbaseService"
7
8
  import type { BaseCommandOptions } from "../utils/command"
8
9
  import { createLoggers } from "../utils/effect"
@@ -84,6 +85,7 @@ export const pr = (args: readonly string[], cwd: string = process.cwd()) =>
84
85
  const contexts = yield* ContextService
85
86
  const fs = yield* FileSystemService
86
87
  const workbase = yield* WorkbaseService
88
+ const versionControl = yield* VersionControlService
87
89
  const invocationCwd = resolve(cwd)
88
90
  const context = yield* contexts
89
91
  .get({ cwd: invocationCwd, target: ".", compact: true })
@@ -94,6 +96,7 @@ export const pr = (args: readonly string[], cwd: string = process.cwd()) =>
94
96
  : null
95
97
  const focusedCwd = writableCheckout ?? invocationCwd
96
98
  let forwardedArgs = args
99
+ let environment: Record<string, string> = {}
97
100
  if (
98
101
  writableCheckout &&
99
102
  context?.workbase.vcs === "jj" &&
@@ -111,10 +114,13 @@ export const pr = (args: readonly string[], cwd: string = process.cwd()) =>
111
114
  repositoryFromRemote(remote),
112
115
  )
113
116
  }
117
+ const backend = yield* versionControl.forWorkbase(context.workbase.root)
118
+ environment = yield* backend.gitEnvironment(writableCheckout)
114
119
  }
115
120
  const result = yield* fs.runCommand(["gh", "pr", ...forwardedArgs], {
116
121
  cwd: focusedCwd,
117
122
  passthrough: true,
123
+ env: environment,
118
124
  })
119
125
  return result.exitCode
120
126
  })
@@ -35,6 +35,14 @@ describe("restore command", () => {
35
35
  silent: true,
36
36
  }),
37
37
  )
38
+ await runTestEffect(
39
+ task({
40
+ subcommand: "status",
41
+ args: ["example", "dropped"],
42
+ cwd: root,
43
+ silent: true,
44
+ }),
45
+ )
38
46
  await runTestEffect(
39
47
  archive({ type: "task", args: ["example"], cwd: root, silent: true }),
40
48
  )