@markjaquith/agency 3.0.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.
package/README.md CHANGED
@@ -995,12 +995,17 @@ agency archive epic <epic-id> [--dry-run] [--json]
995
995
  agency archive task <task-id> [--dry-run] [--json]
996
996
  agency archive tasks [--dry-run] [--json]
997
997
  agency archive phase <task-id> <phase-id> [--dry-run] [--json]
998
+ agency archive <path> [--dry-run] [--json]
998
999
  agency restore epic <epic-id> [--dry-run] [--json]
999
1000
  agency restore task <task-id> [--dry-run] [--json]
1000
1001
  agency restore phase <task-id> <phase-id> [--dry-run] [--json]
1001
1002
  ```
1002
1003
 
1003
- 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
1004
1009
  listed tasks. A task can be archived only when its effective status is terminal
1005
1010
  (`done` or `dropped`). Multi-phase task status is derived from its phases, every
1006
1011
  phase must be terminal, and a task with no phases is not eligible.
package/cli-main.ts CHANGED
@@ -396,10 +396,18 @@ const commands: Record<string, Command> = {
396
396
  console.log(archiveHelp)
397
397
  return
398
398
  }
399
+ const explicitType = [
400
+ "list",
401
+ "show",
402
+ "epic",
403
+ "task",
404
+ "tasks",
405
+ "phase",
406
+ ].includes(args[0] ?? "")
399
407
  await runCommand(
400
408
  archive({
401
- type: args[0],
402
- args: args.slice(1),
409
+ type: explicitType ? args[0] : undefined,
410
+ args: explicitType ? args.slice(1) : args,
403
411
  json: options.json,
404
412
  dryRun: options["dry-run"],
405
413
  kinds: options.kind,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "3.0.0",
3
+ "version": "3.1.0",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -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",
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:
@@ -1474,9 +1480,8 @@ export function parseCli(args: readonly string[]): ParsedCli {
1474
1480
  values: parsed.values,
1475
1481
  }
1476
1482
  }
1477
- const spec = definition.subcommands
1478
- ? definition.subcommands[subcommand ?? ""]
1479
- : definition.command
1483
+ const selectedSubcommand = definition.subcommands?.[subcommand ?? ""]
1484
+ const spec = selectedSubcommand ?? definition.command
1480
1485
  if (!spec) {
1481
1486
  const message = subcommand
1482
1487
  ? `Unknown subcommand '${subcommand}' for 'agency ${commandName}'.`
@@ -1484,7 +1489,7 @@ export function parseCli(args: readonly string[]): ParsedCli {
1484
1489
  throw usageError(message, definition.usage)
1485
1490
  }
1486
1491
 
1487
- let commandPositionals = definition.subcommands
1492
+ let commandPositionals = selectedSubcommand
1488
1493
  ? parsed.positionals.slice(1)
1489
1494
  : parsed.positionals
1490
1495
  const allowed = new Set([...commonOptionNames, ...(spec.options ?? [])])
@@ -1535,7 +1540,7 @@ export function parseCli(args: readonly string[]): ParsedCli {
1535
1540
 
1536
1541
  commandPositionals = applyEntitySelectors(
1537
1542
  commandName,
1538
- subcommand,
1543
+ selectedSubcommand ? subcommand : undefined,
1539
1544
  commandPositionals,
1540
1545
  parsed.values,
1541
1546
  spec,
@@ -1708,7 +1713,7 @@ export function parseCli(args: readonly string[]): ParsedCli {
1708
1713
 
1709
1714
  return {
1710
1715
  commandName: commandName as keyof typeof commands,
1711
- args: definition.subcommands
1716
+ args: selectedSubcommand
1712
1717
  ? [subcommand!, ...commandPositionals]
1713
1718
  : commandPositionals,
1714
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)
@@ -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
@@ -1,7 +1,7 @@
1
1
  import { Schema, TreeFormatter } from "@effect/schema"
2
2
  import { Data, Effect, Either, Layer } from "effect"
3
3
  import { lstat, mkdir, open, rename, rm } from "node:fs/promises"
4
- import { dirname, join, relative } from "node:path"
4
+ import { dirname, join, relative, resolve, sep } from "node:path"
5
5
  import { EpicService, type EpicRecord } from "./EpicService"
6
6
  import { FileSystemService } from "./FileSystemService"
7
7
  import { PhaseService, type PhaseRecord } from "./PhaseService"
@@ -52,6 +52,11 @@ class ArchiveError extends Data.TaggedError("ArchiveError")<{
52
52
 
53
53
  export type ArchiveKind = "epic" | "task" | "phase"
54
54
 
55
+ interface ArchivePathTarget {
56
+ readonly kind: "epic" | "task"
57
+ readonly id: string
58
+ }
59
+
55
60
  const LifecycleEventSchema = Schema.Struct({
56
61
  operation: Schema.Literal("archive", "restore"),
57
62
  at: Schema.String,
@@ -478,6 +483,59 @@ export class ArchiveService extends Effect.Service<ArchiveService>()(
478
483
  "ArchiveService",
479
484
  {
480
485
  sync: () => ({
486
+ resolvePathTarget: (path: string, cwd: string = process.cwd()) =>
487
+ Effect.gen(function* () {
488
+ const fs = yield* FileSystemService
489
+ const workbase = yield* WorkbaseService
490
+ const candidate = resolve(cwd, path)
491
+ if (!(yield* fs.exists(candidate))) {
492
+ return yield* new ArchiveError({
493
+ message: `Archive path does not exist: ${candidate}`,
494
+ })
495
+ }
496
+
497
+ const canonicalPath = yield* fs.realPath(candidate)
498
+ const root = yield* workbase.discover(canonicalPath)
499
+ const child = relative(root, canonicalPath)
500
+ const parts = child.split(sep)
501
+ if (child === "" || parts.length === 1) {
502
+ return yield* new ArchiveError({
503
+ message: `Archive path is ambiguous; it does not identify a single epic or task: ${canonicalPath}`,
504
+ })
505
+ }
506
+
507
+ const [collection, id] = parts
508
+ const kind =
509
+ collection === "epics"
510
+ ? "epic"
511
+ : collection === "tasks"
512
+ ? "task"
513
+ : undefined
514
+ if (!kind || !id) {
515
+ return yield* new ArchiveError({
516
+ message: `Archive path must be within an active epic or task: ${canonicalPath}`,
517
+ })
518
+ }
519
+ if (kind === "task" && parts[2] === "phases") {
520
+ return yield* new ArchiveError({
521
+ message: `Archive path identifies a phase; use 'agency archive phase <task-id> <phase-id>': ${canonicalPath}`,
522
+ })
523
+ }
524
+
525
+ const document = join(
526
+ root,
527
+ kind === "epic" ? "epics" : "tasks",
528
+ id,
529
+ kind === "epic" ? "EPIC.md" : "TASK.md",
530
+ )
531
+ if (!(yield* fs.exists(document))) {
532
+ return yield* new ArchiveError({
533
+ message: `Archive path does not identify an active ${kind}: ${canonicalPath}`,
534
+ })
535
+ }
536
+ return { kind, id } satisfies ArchivePathTarget
537
+ }),
538
+
481
539
  list: (filters: ArchiveFilters = {}, startPath: string = process.cwd()) =>
482
540
  Effect.gen(function* () {
483
541
  const fs = yield* FileSystemService