@markjaquith/agency 2.74.0 → 3.1.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 (59) hide show
  1. package/README.md +29 -122
  2. package/cli-main.ts +10 -28
  3. package/cli.ts +1 -17
  4. package/package.json +1 -2
  5. package/schemas/agency-graph-v1.schema.json +1 -1
  6. package/src/cli-parser.test.ts +5 -19
  7. package/src/cli-parser.ts +12 -30
  8. package/src/cli.test.ts +6 -6
  9. package/src/commands/archive.test.ts +73 -1
  10. package/src/commands/archive.ts +18 -7
  11. package/src/commands/context.test.ts +0 -67
  12. package/src/commands/init.test.ts +1 -2
  13. package/src/commands/pr.test.ts +2 -87
  14. package/src/commands/pr.ts +2 -79
  15. package/src/commands/push.test.ts +1 -2
  16. package/src/graph-schema.ts +1 -1
  17. package/src/services/ArchiveBulkService.test.ts +1 -142
  18. package/src/services/ArchiveService.test.ts +1 -172
  19. package/src/services/ArchiveService.ts +59 -27
  20. package/src/services/ContextService.ts +3 -47
  21. package/src/services/DoctorService.ts +8 -24
  22. package/src/services/GraphService.test.ts +1 -0
  23. package/src/services/GraphService.ts +2 -5
  24. package/src/services/PhaseService.ts +70 -191
  25. package/src/services/PullRequestService.test.ts +1 -45
  26. package/src/services/PullRequestService.ts +1 -22
  27. package/src/services/PushService.test.ts +29 -295
  28. package/src/services/PushService.ts +10 -239
  29. package/src/services/RepositoryService.test.ts +1 -61
  30. package/src/services/RepositoryService.ts +2 -13
  31. package/src/services/ReviewService.test.ts +1 -84
  32. package/src/services/ReviewService.ts +9 -48
  33. package/src/services/SyncService.test.ts +0 -81
  34. package/src/services/SyncService.ts +12 -39
  35. package/src/services/TaskPhaseService.test.ts +0 -80
  36. package/src/services/TaskService.ts +1 -9
  37. package/src/services/VersionControlService.test.ts +4 -117
  38. package/src/services/VersionControlService.ts +3 -426
  39. package/src/services/WorkbaseService.test.ts +0 -20
  40. package/src/services/WorkbaseService.ts +1 -18
  41. package/src/services/WorktreeService.test.ts +1 -705
  42. package/src/services/WorktreeService.ts +395 -1604
  43. package/src/services/push-validation.ts +0 -7
  44. package/src/test-utils.ts +0 -4
  45. package/src/workbase/AGENTS.md +4 -5
  46. package/src/workbase/checkout-command.test.ts +1 -1
  47. package/src/workbase/checkout-command.ts +1 -1
  48. package/src/workbase/delivery-command.test.ts +4 -35
  49. package/src/workbase/delivery-command.ts +2 -15
  50. package/src/workbase/schemas.test.ts +0 -19
  51. package/src/workbase/schemas.ts +1 -2
  52. package/src/commands/vcs.test.ts +0 -75
  53. package/src/commands/vcs.ts +0 -72
  54. package/src/services/VcsMigrationService.test.ts +0 -348
  55. package/src/services/VcsMigrationService.ts +0 -857
  56. package/src/vcs-status-fast.ts +0 -310
  57. package/src/workbase/version-control.ts +0 -5
  58. package/src/workbase/workspace-command.test.ts +0 -70
  59. package/src/workbase/workspace-command.ts +0 -63
package/README.md CHANGED
@@ -9,7 +9,6 @@ read or write.
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
13
12
  - [GitHub CLI](https://cli.github.com/) for `agency pr`
14
13
  - OpenCode, Claude Code, or a configured agent for `agency work`
15
14
 
@@ -151,15 +150,16 @@ the writable checkout's `.claude/skills`, `.agents/skills`,
151
150
  `.opencode/{skill,skills}`, and `.pi/skills` directories. `agency context`
152
151
  remains the authority for writes; reference checkouts remain read-only.
153
152
 
154
- Repository aliases, the version-control backend, and canonical fetch remotes are
155
- declared in tracked `agency.json`; local clones and symlinks remain ignored under
156
- `repos/{alias}`. A declaration contains no local path, symlink target, checkout,
157
- or credential:
153
+ Repository aliases and canonical fetch remotes are declared in tracked
154
+ `agency.json`; local Git clones and symlinks remain ignored under
155
+ `repos/{alias}`. A declaration contains no local path, symlink target, worktree,
156
+ or credential. The optional `vcs` field accepts only `"git"`; omitting it also
157
+ selects Git:
158
158
 
159
159
  ```json
160
160
  {
161
161
  "version": 2,
162
- "vcs": "jj",
162
+ "vcs": "git",
163
163
  "repositories": {
164
164
  "frontend": {
165
165
  "remote": "git@example.com:team/frontend.git"
@@ -168,39 +168,9 @@ or credential:
168
168
  }
169
169
  ```
170
170
 
171
- New workbases select `"jj"` when the `jj` executable is available and otherwise
172
- select `"git"`. Existing version 2 workbases without `vcs` remain Git workbases.
173
- The selected backend is a workbase-level invariant: jj workbases create
174
- non-colocated managed clones and jj workspaces, while Git workbases continue to
175
- use bare repositories and Git worktrees. Existing colocated jj repositories are
176
- also supported.
177
-
178
- Agency uses jj-native commands for repository validity, workspace registration,
179
- working-copy state, revisions, bookmarks, ancestry, fetch, and push. Raw Git is
180
- limited to backing-store plumbing such as durable review refs and integrations
181
- that require Git, including GitHub CLI commit discovery. For those operations,
182
- Agency obtains the backing repository with `jj git root` and supplies it as
183
- `GIT_DIR`; it does not use raw Git to infer jj workspace state.
184
-
185
- Inspect the current backend and migration readiness with:
186
-
187
- ```bash
188
- agency vcs status
189
- agency vcs migrate jj # dry-run
190
- agency vcs migrate jj --apply
191
- agency vcs migrate git --apply
192
- ```
193
-
194
- Migration is workbase-wide and transactional. Agency locks every execution
195
- unit, requires clean registered workspaces, blocks active claims and working or
196
- delegated units, converts repository metadata, recreates checkout paths with the
197
- target backend, and updates `agency.json` last. Migration from jj to Git also
198
- blocks jj-only heads that are not preserved by a bookmark or workspace. Failed
199
- migrations restore the source repositories and workspaces when rollback is
200
- possible and report explicit manual recovery paths otherwise.
201
- Converting a non-colocated jj workbase to Git is currently blocked before any
202
- mutation because its backing Git object store is inside `.jj`; first convert the
203
- repositories to colocated jj so the Git store survives metadata removal.
171
+ Agency materializes declared remotes as bare Git repositories and creates
172
+ managed Git worktrees for execution units. Linked aliases remain symlinks to
173
+ existing Git repositories.
204
174
 
205
175
  Existing version 2 workbases without `repositories` remain valid. Run
206
176
  `agency repo setup` to preview deterministic adoption of legacy local aliases;
@@ -274,63 +244,13 @@ Agency also reconciles known tool-owned artifacts such as Worktrunk's
274
244
  new and existing managed worktrees clean without changing the repository's
275
245
  tracked ignore configuration or overwriting user-maintained local excludes.
276
246
 
277
- The configured command applies only to the writable checkout of a Git workbase.
247
+ The configured command applies only to the writable checkout.
278
248
  Supplemental read-only repositories remain detached Git worktrees at their
279
- declared refs so they do not acquire writable branches. Jj workbases always use
280
- jj workspaces and ignore this Git-specific customization.
281
-
282
- ### Custom Jj Workspace Command
283
-
284
- Jj workbases normally create managed workspaces with `jj workspace add`. Set
285
- `workspaceCreateCommand` to an argv template when another tool should create or
286
- adopt a prepared workspace directly at Agency's destination:
287
-
288
- ```json
289
- {
290
- "version": 2,
291
- "vcs": "jj",
292
- "workspaceCreateCommand": [
293
- "my-prewarm-tool",
294
- "adopt",
295
- "--repo",
296
- "{repo}",
297
- "--destination",
298
- "{workspace}",
299
- "--name",
300
- "{name}",
301
- "--revision",
302
- "{revision}"
303
- ]
304
- }
305
- ```
306
-
307
- Available placeholders are:
308
-
309
- - `{repo}`: absolute repository alias path under `repos/`
310
- - `{workspace}`: absolute managed workspace path Agency requires
311
- - `{name}`: unique jj workspace name Agency requires
312
- - `{revision}`: exact commit the new working copy must be based on
313
- - `{kind}`: `writable` or `reference`
314
- - `{requestedRef}`: configured branch, reference, or review commit
315
-
316
- `{repo}`, `{workspace}`, `{name}`, and `{revision}` are required. Agency invokes
317
- the command directly without a shell and sets matching `AGENCY_REPO`,
318
- `AGENCY_WORKSPACE`, `AGENCY_WORKSPACE_NAME`, `AGENCY_REVISION`,
319
- `AGENCY_CHECKOUT_KIND`, and `AGENCY_REQUESTED_REF` environment variables. The
320
- command applies to each new jj checkout and must leave `{workspace}` registered
321
- under `{name}` with its working-copy parent at `{revision}`. This lets a prewarm
322
- tool move or adopt a prepared workspace without first paying for Agency's normal
323
- full checkout.
324
-
325
- Agency validates the registration, name, path, and revision before running any
326
- `postCheckoutCommand`. A failed command or validation removes a partially
327
- created workspace when possible and otherwise reports manual recovery. Resume
328
- restoration continues to use Agency's built-in exact-target recovery path.
329
- Git workbases ignore this jj-specific customization.
249
+ declared refs so they do not acquire writable branches.
330
250
 
331
251
  ### Post-checkout Commands
332
252
 
333
- Each repository declaration may provide a VCS-neutral `postCheckoutCommand` argv
253
+ Each repository declaration may provide a `postCheckoutCommand` argv
334
254
  template for repository-specific setup. Agency invokes it directly, without a
335
255
  shell, with the new checkout as its working directory:
336
256
 
@@ -346,11 +266,10 @@ shell, with the new checkout as its working directory:
346
266
  }
347
267
  ```
348
268
 
349
- The hook runs for each newly created managed checkout, including writable and
350
- reference checkouts, after Git worktree or jj workspace creation has completed
351
- and Agency has validated the checkout. It does not run for a reused checkout or
352
- for inspection-only commands. A custom `worktreeCreateCommand` or
353
- `workspaceCreateCommand` completes and is validated before this hook runs.
269
+ The hook runs for each newly created managed Git worktree, including writable
270
+ and reference worktrees, after Agency has validated it. It does not run for a
271
+ reused worktree or for inspection-only commands. A custom
272
+ `worktreeCreateCommand` completes and is validated before this hook runs.
354
273
 
355
274
  Available placeholders and matching environment variables are:
356
275
 
@@ -362,7 +281,7 @@ Available placeholders and matching environment variables are:
362
281
  | `{checkoutKind}` | `AGENCY_CHECKOUT_KIND` | `writable` or `reference` |
363
282
  | `{requestedRef}` | `AGENCY_REQUESTED_REF` | Requested branch, reference, or review commit |
364
283
  | `{base}` | `AGENCY_BASE` | Configured execution base |
365
- | `{vcs}` | `AGENCY_VCS` | `git` or `jj` |
284
+ | `{vcs}` | `AGENCY_VCS` | `git` |
366
285
  | `{workbaseRoot}` | `AGENCY_WORKBASE_ROOT` | Absolute workbase root |
367
286
  | `{taskId}` | `AGENCY_TASK_ID` | Task ID |
368
287
  | `{phaseId}` | `AGENCY_PHASE_ID` | Phase ID |
@@ -1076,12 +995,17 @@ agency archive epic <epic-id> [--dry-run] [--json]
1076
995
  agency archive task <task-id> [--dry-run] [--json]
1077
996
  agency archive tasks [--dry-run] [--json]
1078
997
  agency archive phase <task-id> <phase-id> [--dry-run] [--json]
998
+ agency archive <path> [--dry-run] [--json]
1079
999
  agency restore epic <epic-id> [--dry-run] [--json]
1080
1000
  agency restore task <task-id> [--dry-run] [--json]
1081
1001
  agency restore phase <task-id> <phase-id> [--dry-run] [--json]
1082
1002
  ```
1083
1003
 
1084
- Archived work keeps its hierarchy under `archive/`. Epic archiving includes its
1004
+ An existing path within an active epic or task infers that work item, so
1005
+ `agency archive .` works from its directory. Collection roots are ambiguous,
1006
+ phase paths require the explicit `archive phase` form, and paths outside active
1007
+ epic or task trees are rejected. Archived work keeps its hierarchy under
1008
+ `archive/`. Epic archiving includes its
1085
1009
  listed tasks. A task can be archived only when its effective status is terminal
1086
1010
  (`done` or `dropped`). Multi-phase task status is derived from its phases, every
1087
1011
  phase must be terminal, and a task with no phases is not eligible.
@@ -1162,15 +1086,6 @@ conflicting worktrees. Repair is deliberately conservative: it repairs safe Git
1162
1086
  registration issues and materializes missing checkouts, but never switches a
1163
1087
  branch, resets a commit, or discards uncommitted work.
1164
1088
 
1165
- For jj, explicit removal is a suspend operation. Agency records each workspace's
1166
- exact `@` commit and stable change ID in `.agency-jj-resume.json`, roots that
1167
- commit with an internal local bookmark, and only then forgets the workspace.
1168
- The next `work prepare` validates all three identities, restores the workspace by
1169
- editing the recorded commit, and consumes the resume metadata and bookmarks.
1170
- Missing, ambiguous, or conflicting resume state stops instead of falling back to
1171
- the declared delivery bookmark, `@-`, or the base. Unknown checkout cleanliness
1172
- also stops removal. Git worktree removal and preparation are unchanged.
1173
-
1174
1089
  Agency launches every agent beside its epic or task document. Single-phase tasks
1175
1090
  and phases first fetch repositories and create or reuse worktrees under `code/`,
1176
1091
  then launch the execution agent from the task directory with absolute context
@@ -1195,18 +1110,11 @@ that the declared base is in the publication history, validates every outgoing
1195
1110
  commit's description, author, and conflict state, and refuses non-fast-forward
1196
1111
  updates.
1197
1112
 
1198
- For Git, YAML `branch` must exactly match the checked-out local branch, the
1199
- worktree must be clean, and `HEAD` is pushed with upstream tracking. For jj, YAML
1200
- `branch` is the authoritative delivery bookmark and need not exist before
1201
- publication. Agency publishes `@` unless it is the canonical empty, undescribed
1202
- post-commit working copy, in which case it publishes `@-`; described empty
1203
- changes remain intentional publication tips. Missing descriptions or authors
1204
- stop with exact change IDs and remediation commands. Agency creates or safely
1205
- advances only the declared bookmark and never invents a `push-*` bookmark.
1206
- If the fetched remote base advanced outside the local stack, Agency prints the
1207
- exact `jj rebase` command needed to move the stack onto `<base>@<remote>`. Push
1208
- reports deterministic fetch, inspection, validation, and publication progress
1209
- on stderr, including while `--json` reserves stdout for one machine result.
1113
+ YAML `branch` must exactly match the checked-out local branch, the worktree must
1114
+ be clean, and `HEAD` is pushed with upstream tracking. Missing commit descriptions
1115
+ or authors stop publication with exact commits and remediation commands. Push
1116
+ reports deterministic fetch, inspection, validation, and publication progress on
1117
+ stderr, including while `--json` reserves stdout for one machine result.
1210
1118
 
1211
1119
  Task-aware `agency pr create <task-id> [phase-id]` uses Agency's delivery flow,
1212
1120
  including readiness checks and durable PR recording. It accepts draft, title,
@@ -1216,8 +1124,7 @@ Other `agency pr`
1216
1124
  invocations forward every argument to `gh pr`. From an execution task or phase
1217
1125
  directory, including descendants, passthrough runs in that execution unit's
1218
1126
  authoritative writable checkout. Otherwise it runs in the caller's current
1219
- directory. In jj workbases, Agency also supplies the repository and the work
1220
- item's declared branch to subcommands that would otherwise infer Git context.
1127
+ directory.
1221
1128
 
1222
1129
  ### Status and Validation
1223
1130
 
package/cli-main.ts CHANGED
@@ -48,12 +48,9 @@ import { DoctorService } from "./src/services/DoctorService"
48
48
  import { ReviewService } from "./src/services/ReviewService"
49
49
  import {
50
50
  GitVersionControlService,
51
- JjVersionControlService,
52
51
  VersionControlService,
53
52
  } from "./src/services/VersionControlService"
54
53
  import { review, help as reviewHelp } from "./src/commands/review"
55
- import { vcs, help as vcsHelp } from "./src/commands/vcs"
56
- import { VcsMigrationService } from "./src/services/VcsMigrationService"
57
54
  import { act, help as actHelp } from "./src/commands/act"
58
55
  import {
59
56
  claimCommand,
@@ -74,9 +71,7 @@ const CliLayer = Layer.mergeAll(
74
71
  FileSystemService.Default,
75
72
  WorkbaseService.Default,
76
73
  GitVersionControlService.Default,
77
- JjVersionControlService.Default,
78
74
  VersionControlService.Default,
79
- VcsMigrationService.Default,
80
75
  RepositoryService.Default,
81
76
  EpicService.Default,
82
77
  TaskService.Default,
@@ -401,10 +396,18 @@ const commands: Record<string, Command> = {
401
396
  console.log(archiveHelp)
402
397
  return
403
398
  }
399
+ const explicitType = [
400
+ "list",
401
+ "show",
402
+ "epic",
403
+ "task",
404
+ "tasks",
405
+ "phase",
406
+ ].includes(args[0] ?? "")
404
407
  await runCommand(
405
408
  archive({
406
- type: args[0],
407
- args: args.slice(1),
409
+ type: explicitType ? args[0] : undefined,
410
+ args: explicitType ? args.slice(1) : args,
408
411
  json: options.json,
409
412
  dryRun: options["dry-run"],
410
413
  kinds: options.kind,
@@ -623,26 +626,6 @@ const commands: Record<string, Command> = {
623
626
  )
624
627
  },
625
628
  },
626
- vcs: {
627
- run: async (args: string[], options: Record<string, any>) => {
628
- if (options.help) {
629
- console.log(vcsHelp)
630
- return
631
- }
632
- await runCommand(
633
- vcs({
634
- subcommand: args[0],
635
- target: args[1],
636
- apply: options.apply,
637
- dryRun: options["dry-run"],
638
- json: options.json,
639
- silent: options.silent,
640
- verbose: options.verbose,
641
- cwd: options.cwd,
642
- }),
643
- )
644
- },
645
- },
646
629
  next: {
647
630
  run: async (_args: string[], options: Record<string, any>) => {
648
631
  if (options.help) return console.log(nextHelp)
@@ -802,7 +785,6 @@ Commands:
802
785
  task <subcommand> Manage tasks
803
786
  work [directory|task] Work on an epic, task, or phase
804
787
  worktree <subcommand> Inspect and maintain managed workspaces
805
- vcs <subcommand> Inspect or migrate the version-control backend
806
788
  next List or select ready execution units
807
789
  pr create / pr [...] Create an Agency PR or run gh pr with repository focus
808
790
  push Validate and publish the current execution unit
package/cli.ts CHANGED
@@ -1,19 +1,3 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
- const args = Bun.argv.slice(2)
4
- const fastVcsStatus =
5
- args[0] === "vcs" &&
6
- args[1] === "status" &&
7
- (args.length === 2 || (args.length === 3 && args[2] === "--json"))
8
-
9
- if (fastVcsStatus) {
10
- try {
11
- const { runVcsStatusFast } = await import("./src/vcs-status-fast")
12
- if (!(await runVcsStatusFast(args[2] === "--json")))
13
- await import("./cli-main")
14
- } catch {
15
- await import("./cli-main")
16
- }
17
- } else {
18
- await import("./cli-main")
19
- }
3
+ await import("./cli-main")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "2.74.0",
3
+ "version": "3.1.0",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -83,7 +83,6 @@
83
83
  "benchmark:graph": "bun scripts/benchmark-graph.ts",
84
84
  "benchmark:push": "bun scripts/benchmark-push.ts",
85
85
  "benchmark:archive": "bun scripts/benchmark-archive.ts",
86
- "benchmark:vcs": "bun scripts/benchmark-vcs.ts",
87
86
  "benchmark:sync": "bun scripts/benchmark-sync.ts",
88
87
  "benchmark:task": "bun scripts/benchmark-task.ts",
89
88
  "benchmark:validate": "bun scripts/benchmark-validate.ts",
@@ -22,7 +22,7 @@
22
22
  "required": ["version"],
23
23
  "properties": {
24
24
  "version": { "const": 2 },
25
- "vcs": { "enum": ["git", "jj"] },
25
+ "vcs": { "enum": ["git"] },
26
26
  "root": { "type": "string" }
27
27
  }
28
28
  },
@@ -478,6 +478,11 @@ describe("strict CLI parsing", () => {
478
478
  })
479
479
 
480
480
  test("accepts archive dry-run", () => {
481
+ expect(parseCli(["archive", ".", "--dry-run"])).toMatchObject({
482
+ commandName: "archive",
483
+ args: ["."],
484
+ values: { "dry-run": true },
485
+ })
481
486
  expect(parseCli(["archive", "task", "example", "--dry-run"])).toMatchObject(
482
487
  {
483
488
  commandName: "archive",
@@ -674,25 +679,6 @@ describe("strict CLI parsing", () => {
674
679
  )
675
680
  })
676
681
 
677
- test("parses VCS status and migration commands", () => {
678
- expect(parseCli(["vcs", "status", "--json"])).toMatchObject({
679
- commandName: "vcs",
680
- args: ["status"],
681
- values: { json: true },
682
- })
683
- expect(
684
- parseCli(["vcs", "migrate", "jj", "--apply", "--json"]),
685
- ).toMatchObject({
686
- commandName: "vcs",
687
- args: ["migrate", "jj"],
688
- values: { apply: true, json: true },
689
- })
690
- expectUsageError(["vcs", "migrate"], "agency vcs migrate")
691
- expect(() =>
692
- parseCli(["vcs", "migrate", "jj", "--dry-run", "--apply"]),
693
- ).toThrow("cannot be combined")
694
- })
695
-
696
682
  test("parses push without accepting an alternate target", () => {
697
683
  expect(parseCli(["push", "--json"])).toMatchObject({
698
684
  commandName: "push",
package/src/cli-parser.ts CHANGED
@@ -763,7 +763,7 @@ const commands = {
763
763
  },
764
764
  },
765
765
  archive: {
766
- usage: "agency archive <list|show|epic|task|tasks|phase>",
766
+ usage: "agency archive <path|list|show|epic|task|tasks|phase>",
767
767
  options: {
768
768
  ...outputOptions,
769
769
  ...entitySelectorOptions,
@@ -772,6 +772,12 @@ const commands = {
772
772
  status: { type: "string", multiple: true },
773
773
  repository: { type: "string", multiple: true },
774
774
  },
775
+ command: {
776
+ usage: "agency archive <path> [--dry-run] [--json]",
777
+ minArgs: 1,
778
+ maxArgs: 1,
779
+ options: ["dry-run", "json"],
780
+ },
775
781
  subcommands: {
776
782
  list: {
777
783
  usage:
@@ -908,29 +914,6 @@ const commands = {
908
914
  },
909
915
  },
910
916
  },
911
- vcs: {
912
- usage: "agency vcs <status|migrate>",
913
- options: {
914
- ...outputOptions,
915
- apply: { type: "boolean" },
916
- "dry-run": { type: "boolean" },
917
- },
918
- subcommands: {
919
- status: {
920
- usage: "agency vcs status [--json]",
921
- minArgs: 0,
922
- maxArgs: 0,
923
- options: ["json"],
924
- },
925
- migrate: {
926
- usage: "agency vcs migrate <git|jj> [--dry-run | --apply] [--json]",
927
- minArgs: 1,
928
- maxArgs: 1,
929
- options: ["apply", "dry-run", "json"],
930
- conflicts: [["apply", "dry-run"]],
931
- },
932
- },
933
- },
934
917
  work: {
935
918
  usage:
936
919
  "agency work [<directory-or-task-id> | --epic <epic-id>] [--agent <name>] [--auto] | agency work prepare [target] [--dry-run] [--json]",
@@ -1497,9 +1480,8 @@ export function parseCli(args: readonly string[]): ParsedCli {
1497
1480
  values: parsed.values,
1498
1481
  }
1499
1482
  }
1500
- const spec = definition.subcommands
1501
- ? definition.subcommands[subcommand ?? ""]
1502
- : definition.command
1483
+ const selectedSubcommand = definition.subcommands?.[subcommand ?? ""]
1484
+ const spec = selectedSubcommand ?? definition.command
1503
1485
  if (!spec) {
1504
1486
  const message = subcommand
1505
1487
  ? `Unknown subcommand '${subcommand}' for 'agency ${commandName}'.`
@@ -1507,7 +1489,7 @@ export function parseCli(args: readonly string[]): ParsedCli {
1507
1489
  throw usageError(message, definition.usage)
1508
1490
  }
1509
1491
 
1510
- let commandPositionals = definition.subcommands
1492
+ let commandPositionals = selectedSubcommand
1511
1493
  ? parsed.positionals.slice(1)
1512
1494
  : parsed.positionals
1513
1495
  const allowed = new Set([...commonOptionNames, ...(spec.options ?? [])])
@@ -1558,7 +1540,7 @@ export function parseCli(args: readonly string[]): ParsedCli {
1558
1540
 
1559
1541
  commandPositionals = applyEntitySelectors(
1560
1542
  commandName,
1561
- subcommand,
1543
+ selectedSubcommand ? subcommand : undefined,
1562
1544
  commandPositionals,
1563
1545
  parsed.values,
1564
1546
  spec,
@@ -1731,7 +1713,7 @@ export function parseCli(args: readonly string[]): ParsedCli {
1731
1713
 
1732
1714
  return {
1733
1715
  commandName: commandName as keyof typeof commands,
1734
- args: definition.subcommands
1716
+ args: selectedSubcommand
1735
1717
  ? [subcommand!, ...commandPositionals]
1736
1718
  : commandPositionals,
1737
1719
  values: parsed.values,
package/src/cli.test.ts CHANGED
@@ -531,9 +531,9 @@ status: dropped
531
531
  `,
532
532
  )
533
533
 
534
- expect((await runCli(["archive", "task", "example"], root)).exitCode).toBe(
535
- 0,
536
- )
534
+ expect(
535
+ (await runCli(["archive", "."], join(root, "tasks/example"))).exitCode,
536
+ ).toBe(0)
537
537
  const result = await runCli(["archive", "task", "example", "--json"], root)
538
538
 
539
539
  expect(result.exitCode).toBe(1)
@@ -1929,9 +1929,9 @@ status: open
1929
1929
  alias: "agency",
1930
1930
  status: "applied",
1931
1931
  })
1932
- expect(
1933
- (await stat(join(restored, "repos/agency/.jj"))).isDirectory(),
1934
- ).toBe(true)
1932
+ expect((await stat(join(restored, "repos/agency/HEAD"))).isFile()).toBe(
1933
+ true,
1934
+ )
1935
1935
 
1936
1936
  const prepared = parseJson(
1937
1937
  await runCli(["work", "prepare", "portable", "--json"], restored),
@@ -1,4 +1,5 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test"
2
+ import { mkdir } from "node:fs/promises"
2
3
  import { join } from "node:path"
3
4
  import {
4
5
  captureLogs,
@@ -93,6 +94,75 @@ describe("archive command", () => {
93
94
  )
94
95
  })
95
96
 
97
+ test("infers a task from a filesystem path", async () => {
98
+ const logs = await captureLogs(() =>
99
+ runTestEffect(
100
+ archive({
101
+ args: ["."],
102
+ cwd: join(root, "tasks/example"),
103
+ dryRun: true,
104
+ json: true,
105
+ }),
106
+ ),
107
+ )
108
+
109
+ expect(JSON.parse(logs[0]!)).toMatchObject({
110
+ operation: "archive",
111
+ kind: "task",
112
+ id: "example",
113
+ dryRun: true,
114
+ })
115
+ })
116
+
117
+ test("infers an epic from a filesystem path", async () => {
118
+ const directory = join(root, "epics/delivery")
119
+ await mkdir(directory, { recursive: true })
120
+ await Bun.write(
121
+ join(directory, "EPIC.md"),
122
+ `---
123
+ ticketUrl: https://example.com/epic
124
+ repos:
125
+ - repo: agency
126
+ ref: main
127
+ tasks: []
128
+ ---
129
+
130
+ # Delivery
131
+ `,
132
+ )
133
+ const logs = await captureLogs(() =>
134
+ runTestEffect(
135
+ archive({ args: ["."], cwd: directory, dryRun: true, json: true }),
136
+ ),
137
+ )
138
+
139
+ expect(JSON.parse(logs[0]!)).toMatchObject({
140
+ operation: "archive",
141
+ kind: "epic",
142
+ id: "delivery",
143
+ dryRun: true,
144
+ })
145
+ })
146
+
147
+ test("rejects ambiguous and unsupported archive paths", async () => {
148
+ await expect(
149
+ runTestEffect(archive({ args: ["."], cwd: root, silent: true })),
150
+ ).rejects.toThrow("Archive path is ambiguous")
151
+ await expect(
152
+ runTestEffect(
153
+ archive({ args: ["repos/agency"], cwd: root, silent: true }),
154
+ ),
155
+ ).rejects.toThrow("Archive path must be within an active epic or task")
156
+
157
+ const phaseDirectory = join(root, "tasks/example/phases/build")
158
+ await mkdir(phaseDirectory, { recursive: true })
159
+ await expect(
160
+ runTestEffect(
161
+ archive({ args: ["."], cwd: phaseDirectory, silent: true }),
162
+ ),
163
+ ).rejects.toThrow("Archive path identifies a phase")
164
+ })
165
+
96
166
  test("reports an already archived task", async () => {
97
167
  await runTestEffect(
98
168
  archive({ type: "task", args: ["example"], cwd: root, silent: true }),
@@ -143,7 +213,9 @@ describe("archive command", () => {
143
213
  test("requires a supported work item type", async () => {
144
214
  await expect(
145
215
  runTestEffect(archive({ args: [], cwd: root, silent: true })),
146
- ).rejects.toThrow("Available: list, show, epic, task, tasks, phase")
216
+ ).rejects.toThrow(
217
+ "Provide a path or use: list, show, epic, task, tasks, phase",
218
+ )
147
219
  })
148
220
 
149
221
  test("rejects an extra archive show identifier", async () => {
@@ -24,6 +24,14 @@ export const archive = (options: ArchiveOptions) =>
24
24
  const { log } = createLoggers(options)
25
25
  const cwd = options.cwd ?? process.cwd()
26
26
  const [id, phaseId] = options.args
27
+ let archiveType = options.type
28
+ let archiveId = id
29
+
30
+ if (!archiveType && id) {
31
+ const target = yield* archives.resolvePathTarget(id, cwd)
32
+ archiveType = target.kind
33
+ archiveId = target.id
34
+ }
27
35
 
28
36
  if (options.type === "list") {
29
37
  const records = yield* archives.list(
@@ -73,22 +81,22 @@ export const archive = (options: ArchiveOptions) =>
73
81
  }
74
82
 
75
83
  let result
76
- switch (options.type) {
84
+ switch (archiveType) {
77
85
  case "epic":
78
- if (!id)
86
+ if (!archiveId)
79
87
  return yield* Effect.fail(
80
88
  new Error("Usage: agency archive epic <epic-id>"),
81
89
  )
82
- result = yield* archives.archiveEpic(id, cwd, {
90
+ result = yield* archives.archiveEpic(archiveId, cwd, {
83
91
  dryRun: options.dryRun,
84
92
  })
85
93
  break
86
94
  case "task":
87
- if (!id)
95
+ if (!archiveId)
88
96
  return yield* Effect.fail(
89
97
  new Error("Usage: agency archive task <task-id>"),
90
98
  )
91
- result = yield* archives.archiveTask(id, cwd, {
99
+ result = yield* archives.archiveTask(archiveId, cwd, {
92
100
  dryRun: options.dryRun,
93
101
  })
94
102
  break
@@ -109,7 +117,7 @@ export const archive = (options: ArchiveOptions) =>
109
117
  default:
110
118
  return yield* Effect.fail(
111
119
  new Error(
112
- "Archive operation is required. Available: list, show, epic, task, tasks, phase",
120
+ "Archive target is required. Provide a path or use: list, show, epic, task, tasks, phase",
113
121
  ),
114
122
  )
115
123
  }
@@ -141,11 +149,14 @@ export const archive = (options: ArchiveOptions) =>
141
149
  })
142
150
 
143
151
  export const help = `
144
- Usage: agency archive <list|show|epic|task|tasks|phase>
152
+ Usage: agency archive <path|list|show|epic|task|tasks|phase>
145
153
 
146
154
  Browse or archive work items after preflighting worktrees and graph references.
147
155
 
156
+ An existing path within an active epic or task infers that work item.
157
+
148
158
  Commands:
159
+ <path> Archive the containing epic or task
149
160
  list [filters] List archived work
150
161
  show <type> <id> Show an archived epic or task
151
162
  show phase <task-id> <phase-id> Show an archived phase