@markjaquith/agency 3.0.0 → 3.1.1

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
@@ -66,7 +66,7 @@ workbase/
66
66
  .agency/
67
67
  AGENTS.md # managed Agency instructions
68
68
  .opencode/
69
- opencode.jsonc # managed @agency subagent, instructions, and reference
69
+ opencode.jsonc # managed planning agent, instructions, and reference
70
70
  tui.jsonc # managed TUI plugin registration
71
71
  plugins/agency-repository-skills.ts # managed workbase access and checkout skills
72
72
  tui/agency-debug.ts # managed /agency-debug TUI diagnostic
@@ -116,17 +116,14 @@ Agency-managed root `AGENTS.md` to `.agency/AGENTS.md` once the OpenCode config
116
116
  can load the hidden file. A customized root file, including a symlink, is
117
117
  preserved as user-owned content.
118
118
 
119
- The OpenCode config defines an `@agency` subagent for delegated workbase
120
- orchestration, loads Agency's hidden instructions in addition to any user-owned
121
- root `AGENTS.md`, advertises the complete workbase as one portable reference,
122
- and replaces the built-in Plan agent with `agency-plan`. That planning agent can
123
- update `TASK.md`, `PHASE.md`, and `EPIC.md`, inspect the workbase through
124
- read-only Agency commands, and use explicit Agency CLI permissions to create or
125
- update planning structure. Its normal research tools and the complete Agency CLI
126
- remain available; managed Agency instructions and reported authority govern each
127
- operation.
128
- When the subagent launches work in another agent, it verifies that the agent
129
- started and returns without waiting for the task to finish.
119
+ The OpenCode config loads Agency's hidden instructions in addition to any
120
+ user-owned root `AGENTS.md`, advertises the complete workbase as one portable
121
+ reference, and replaces the built-in Plan agent with `agency-plan`. That
122
+ planning agent can update `TASK.md`, `PHASE.md`, and `EPIC.md`, inspect the
123
+ workbase through read-only Agency commands, and use explicit Agency CLI
124
+ permissions to create or update planning structure. Its normal research tools
125
+ and the complete Agency CLI remain available; managed Agency instructions and
126
+ reported authority govern each operation.
130
127
  The TUI-only `/agency-debug` command reports TUI companion initialization and
131
128
  whether the server plugin registered writable-checkout skills. It uses a native
132
129
  toast and does not submit a prompt to an LLM. When no writable checkout skill
@@ -995,12 +992,17 @@ agency archive epic <epic-id> [--dry-run] [--json]
995
992
  agency archive task <task-id> [--dry-run] [--json]
996
993
  agency archive tasks [--dry-run] [--json]
997
994
  agency archive phase <task-id> <phase-id> [--dry-run] [--json]
995
+ agency archive <path> [--dry-run] [--json]
998
996
  agency restore epic <epic-id> [--dry-run] [--json]
999
997
  agency restore task <task-id> [--dry-run] [--json]
1000
998
  agency restore phase <task-id> <phase-id> [--dry-run] [--json]
1001
999
  ```
1002
1000
 
1003
- Archived work keeps its hierarchy under `archive/`. Epic archiving includes its
1001
+ An existing path within an active epic or task infers that work item, so
1002
+ `agency archive .` works from its directory. Collection roots are ambiguous,
1003
+ phase paths require the explicit `archive phase` form, and paths outside active
1004
+ epic or task trees are rejected. Archived work keeps its hierarchy under
1005
+ `archive/`. Epic archiving includes its
1004
1006
  listed tasks. A task can be archived only when its effective status is terminal
1005
1007
  (`done` or `dropped`). Multi-phase task status is derived from its phases, every
1006
1008
  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,
@@ -610,6 +618,7 @@ const commands: Record<string, Command> = {
610
618
  subcommand: args[0],
611
619
  args: args.slice(1),
612
620
  dryRun: options["dry-run"],
621
+ force: options.force,
613
622
  json: options.json,
614
623
  silent: options.silent,
615
624
  verbose: options.verbose,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "3.0.0",
3
+ "version": "3.1.1",
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",
@@ -659,11 +664,17 @@ describe("strict CLI parsing", () => {
659
664
  "--phase",
660
665
  "verify",
661
666
  "--dry-run",
667
+ "--force",
662
668
  ]),
663
669
  ).toMatchObject({
664
670
  commandName: "worktree",
665
671
  args: ["rebuild", "example", "verify"],
666
- values: { task: "example", phase: "verify", "dry-run": true },
672
+ values: {
673
+ task: "example",
674
+ phase: "verify",
675
+ "dry-run": true,
676
+ force: true,
677
+ },
667
678
  })
668
679
  expectUsageError(
669
680
  ["worktree", "inspect", "example", "--dry-run"],
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:
@@ -864,6 +870,7 @@ const commands = {
864
870
  ...outputOptions,
865
871
  ...entitySelectorOptions,
866
872
  "dry-run": { type: "boolean" },
873
+ force: { type: "boolean" },
867
874
  },
868
875
  subcommands: {
869
876
  list: {
@@ -880,31 +887,31 @@ const commands = {
880
887
  },
881
888
  prepare: {
882
889
  usage:
883
- "agency worktree prepare <task-id> [phase-id] [--dry-run] [--json]",
890
+ "agency worktree prepare <task-id> [phase-id] [--dry-run] [--force] [--json]",
884
891
  minArgs: 1,
885
892
  maxArgs: 2,
886
- options: ["dry-run", "json", "task", "phase"],
893
+ options: ["dry-run", "force", "json", "task", "phase"],
887
894
  },
888
895
  remove: {
889
896
  usage:
890
- "agency worktree remove <task-id> [phase-id] [--dry-run] [--json]",
897
+ "agency worktree remove <task-id> [phase-id] [--dry-run] [--force] [--json]",
891
898
  minArgs: 1,
892
899
  maxArgs: 2,
893
- options: ["dry-run", "json", "task", "phase"],
900
+ options: ["dry-run", "force", "json", "task", "phase"],
894
901
  },
895
902
  rebuild: {
896
903
  usage:
897
- "agency worktree rebuild <task-id> [phase-id] [--dry-run] [--json]",
904
+ "agency worktree rebuild <task-id> [phase-id] [--dry-run] [--force] [--json]",
898
905
  minArgs: 1,
899
906
  maxArgs: 2,
900
- options: ["dry-run", "json", "task", "phase"],
907
+ options: ["dry-run", "force", "json", "task", "phase"],
901
908
  },
902
909
  repair: {
903
910
  usage:
904
- "agency worktree repair <task-id> [phase-id] [--dry-run] [--json]",
911
+ "agency worktree repair <task-id> [phase-id] [--dry-run] [--force] [--json]",
905
912
  minArgs: 1,
906
913
  maxArgs: 2,
907
- options: ["dry-run", "json", "task", "phase"],
914
+ options: ["dry-run", "force", "json", "task", "phase"],
908
915
  },
909
916
  },
910
917
  },
@@ -1474,9 +1481,8 @@ export function parseCli(args: readonly string[]): ParsedCli {
1474
1481
  values: parsed.values,
1475
1482
  }
1476
1483
  }
1477
- const spec = definition.subcommands
1478
- ? definition.subcommands[subcommand ?? ""]
1479
- : definition.command
1484
+ const selectedSubcommand = definition.subcommands?.[subcommand ?? ""]
1485
+ const spec = selectedSubcommand ?? definition.command
1480
1486
  if (!spec) {
1481
1487
  const message = subcommand
1482
1488
  ? `Unknown subcommand '${subcommand}' for 'agency ${commandName}'.`
@@ -1484,7 +1490,7 @@ export function parseCli(args: readonly string[]): ParsedCli {
1484
1490
  throw usageError(message, definition.usage)
1485
1491
  }
1486
1492
 
1487
- let commandPositionals = definition.subcommands
1493
+ let commandPositionals = selectedSubcommand
1488
1494
  ? parsed.positionals.slice(1)
1489
1495
  : parsed.positionals
1490
1496
  const allowed = new Set([...commonOptionNames, ...(spec.options ?? [])])
@@ -1535,7 +1541,7 @@ export function parseCli(args: readonly string[]): ParsedCli {
1535
1541
 
1536
1542
  commandPositionals = applyEntitySelectors(
1537
1543
  commandName,
1538
- subcommand,
1544
+ selectedSubcommand ? subcommand : undefined,
1539
1545
  commandPositionals,
1540
1546
  parsed.values,
1541
1547
  spec,
@@ -1708,7 +1714,7 @@ export function parseCli(args: readonly string[]): ParsedCli {
1708
1714
 
1709
1715
  return {
1710
1716
  commandName: commandName as keyof typeof commands,
1711
- args: definition.subcommands
1717
+ args: selectedSubcommand
1712
1718
  ? [subcommand!, ...commandPositionals]
1713
1719
  : commandPositionals,
1714
1720
  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)
@@ -1373,12 +1373,7 @@ status: open
1373
1373
  }
1374
1374
  if (launch === launches[0]) {
1375
1375
  expect(effectiveConfig.instructions).toContain(".agency/AGENTS.md")
1376
- expect(effectiveConfig.agent.agency).toMatchObject({
1377
- description: expect.stringContaining(
1378
- "Agency workbase orchestration",
1379
- ),
1380
- mode: "subagent",
1381
- })
1376
+ expect(effectiveConfig.agent.agency).toBeUndefined()
1382
1377
  expect(effectiveConfig.references).toEqual({
1383
1378
  workbase: {
1384
1379
  path: "..",
@@ -1386,18 +1381,6 @@ status: open
1386
1381
  "Complete Agency workbase context; write authority still comes only from agency context",
1387
1382
  },
1388
1383
  })
1389
- const agencyProbe = Bun.spawnSync(
1390
- ["opencode", "debug", "agent", "agency"],
1391
- { cwd: contract.cwd, env: environment },
1392
- )
1393
- expect(agencyProbe.exitCode).toBe(0)
1394
- expect(JSON.parse(agencyProbe.stdout.toString())).toMatchObject({
1395
- name: "agency",
1396
- description: expect.stringContaining(
1397
- "Agency workbase orchestration",
1398
- ),
1399
- mode: "subagent",
1400
- })
1401
1384
  for (const document of documents) {
1402
1385
  const read = Bun.spawnSync(
1403
1386
  [
@@ -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
@@ -45,11 +45,7 @@ describe("init command", () => {
45
45
  ).text()
46
46
  const config = JSON.parse(opencode.slice(opencode.indexOf("\n\n") + 2))
47
47
  expect(config.instructions).toEqual([".agency/AGENTS.md"])
48
- expect(config.agent.agency).toMatchObject({
49
- description: expect.stringContaining("Agency workbase orchestration"),
50
- mode: "subagent",
51
- prompt: expect.stringContaining("agency context . --json"),
52
- })
48
+ expect(config.agent.agency).toBeUndefined()
53
49
  expect(config.agent.plan).toEqual({ disable: true })
54
50
  expect(config.agent["agency-plan"]).toMatchObject({
55
51
  mode: "primary",
@@ -6,6 +6,7 @@ import { WorktreeService } from "../services/WorktreeService"
6
6
  interface WorktreeOptions extends BaseCommandOptions {
7
7
  readonly subcommand?: string
8
8
  readonly args?: readonly string[]
9
+ readonly force?: boolean
9
10
  }
10
11
 
11
12
  const targetLabel = (owner: {
@@ -134,5 +135,6 @@ Options:
134
135
  --task <id> Select a task without positional IDs
135
136
  --phase <id> Select a phase with --task
136
137
  --dry-run Preflight and report changes without applying them
138
+ --force Override an existing worktree operation lock
137
139
  --json Print structured output
138
140
  `
@@ -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
@@ -186,9 +186,12 @@ describe("IntegrationService", () => {
186
186
  }
187
187
  expect(managedWorkbaseAgents).toContain("agency push --json")
188
188
  expect(managedWorkbaseAgents).toContain("agency-execution-v1")
189
- expect(managedWorkbaseAgents).toContain("agency context . --full --json")
189
+ expect(managedWorkbaseAgents).toContain("agency context . --json")
190
190
  expect(managedWorkbaseAgents).toContain(
191
- "Workers use full context here because they need the assigned document prose",
191
+ "Pass `--full` only when document prose or low-level VCS details are needed",
192
+ )
193
+ expect(managedWorkbaseAgents).not.toContain(
194
+ "agency context . --full --json",
192
195
  )
193
196
  expect(managedWorkbaseAgents).toContain(
194
197
  "Never pass `--work` or `--auto` to `agency task create`",
@@ -681,19 +684,11 @@ describe("IntegrationService", () => {
681
684
  )
682
685
  })
683
686
 
684
- test("configures Agency agents with complete workbase access", () => {
687
+ test("configures Agency planning with complete workbase access", () => {
685
688
  const config = JSON.parse(managedBody(managedWorkbaseOpencode))
686
689
 
687
690
  expect(config.instructions).toEqual([".agency/AGENTS.md"])
688
691
  expect(config.agent).toEqual({
689
- agency: {
690
- description:
691
- "Handles Agency workbase orchestration and workflow operations with the Agency CLI",
692
- mode: "subagent",
693
- prompt: expect.stringMatching(
694
- /agency context \. --json[\s\S]+agency work prepare[\s\S]+never pass `--work` or `--auto`/,
695
- ),
696
- },
697
692
  plan: {
698
693
  disable: true,
699
694
  },
@@ -716,19 +711,16 @@ describe("IntegrationService", () => {
716
711
  },
717
712
  },
718
713
  })
719
- expect(config.agent.agency.model).toBeUndefined()
720
- expect(config.agent.agency.permission).toBeUndefined()
721
714
  expect(config.agent["agency-plan"].prompt).toContain(
722
715
  "Explicit-new intent overrides reuse",
723
716
  )
724
- expect(config.agent.agency.hidden).toBeUndefined()
725
- expect(config.agent.agency.steps).toBeUndefined()
726
- expect(config.agent.agency.prompt).toContain(
727
- "Return the prepared execution contract to the caller",
728
- )
717
+ expect(config.agent.agency).toBeUndefined()
729
718
  expect(config.agent["agency-plan"].prompt).toContain(
730
719
  "Start with `agency context . --json`",
731
720
  )
721
+ expect(config.agent["agency-plan"].prompt).toContain(
722
+ "Pass `--full` only when document prose or low-level VCS details are needed",
723
+ )
732
724
  expect(config.agent["agency-plan"].prompt).toContain(
733
725
  "decompose it into independently deliverable tasks",
734
726
  )
@@ -1,6 +1,6 @@
1
1
  import { afterEach, describe, expect, test } from "bun:test"
2
2
  import { Effect } from "effect"
3
- import { mkdtemp, readdir, rm } from "node:fs/promises"
3
+ import { mkdtemp, readdir, rm, utimes } from "node:fs/promises"
4
4
  import { tmpdir } from "node:os"
5
5
  import { join } from "node:path"
6
6
  import { withWorktreeLocks } from "./WorktreeLock"
@@ -54,11 +54,90 @@ describe("withWorktreeLocks", () => {
54
54
  _tag: "Left",
55
55
  left: {
56
56
  _tag: "WorktreeLockError",
57
- message: `Another worktree operation is in progress for 'alpha'. If no operation is active, remove the stale sentinel with: rm '${lockPath}'`,
57
+ message: `Another worktree operation is in progress for 'alpha'. Retry with --force or remove the stale sentinel with: rm '${lockPath}'`,
58
58
  },
59
59
  })
60
60
  })
61
61
 
62
+ test("removes stale locks before acquiring them", async () => {
63
+ const root = await createTempDir()
64
+ tempDirs.push(root)
65
+ const lockPath = join(
66
+ root,
67
+ `.agency-worktree-${Buffer.from("alpha:task").toString("hex")}.lock`,
68
+ )
69
+ await Bun.write(lockPath, "")
70
+ const staleAt = new Date(Date.now() - 11 * 60 * 1000)
71
+ await utimes(lockPath, staleAt, staleAt)
72
+
73
+ await expect(
74
+ Effect.runPromise(
75
+ withWorktreeLocks(root, [{ taskId: "alpha" }], Effect.void),
76
+ ),
77
+ ).resolves.toBeUndefined()
78
+ })
79
+
80
+ test("force overrides an active lock", async () => {
81
+ const root = await createTempDir()
82
+ tempDirs.push(root)
83
+ let firstEntered!: () => void
84
+ let firstRelease!: () => void
85
+ const firstEnteredPromise = new Promise<void>((resolve) => {
86
+ firstEntered = resolve
87
+ })
88
+ const firstReleasePromise = new Promise<void>((resolve) => {
89
+ firstRelease = resolve
90
+ })
91
+ const first = Effect.runPromise(
92
+ withWorktreeLocks(
93
+ root,
94
+ [{ taskId: "alpha" }],
95
+ Effect.promise(async () => {
96
+ firstEntered()
97
+ await firstReleasePromise
98
+ }),
99
+ ),
100
+ )
101
+ await firstEnteredPromise
102
+
103
+ let forcedEntered!: () => void
104
+ let forcedRelease!: () => void
105
+ const forcedEnteredPromise = new Promise<void>((resolve) => {
106
+ forcedEntered = resolve
107
+ })
108
+ const forcedReleasePromise = new Promise<void>((resolve) => {
109
+ forcedRelease = resolve
110
+ })
111
+ const forced = Effect.runPromise(
112
+ withWorktreeLocks(
113
+ root,
114
+ [{ taskId: "alpha" }],
115
+ Effect.promise(async () => {
116
+ forcedEntered()
117
+ await forcedReleasePromise
118
+ }),
119
+ { force: true },
120
+ ),
121
+ )
122
+ await forcedEnteredPromise
123
+
124
+ firstRelease()
125
+ await first
126
+
127
+ const conflict = await Effect.runPromise(
128
+ Effect.either(
129
+ withWorktreeLocks(root, [{ taskId: "alpha" }], Effect.void),
130
+ ),
131
+ )
132
+ expect(conflict).toMatchObject({
133
+ _tag: "Left",
134
+ left: { _tag: "WorktreeLockError" },
135
+ })
136
+
137
+ forcedRelease()
138
+ await forced
139
+ })
140
+
62
141
  test("releases locks when the protected operation fails", async () => {
63
142
  const root = await createTempDir()
64
143
  tempDirs.push(root)
@@ -1,5 +1,5 @@
1
1
  import { Data, Effect } from "effect"
2
- import { open, rm } from "node:fs/promises"
2
+ import { open, rm, stat } from "node:fs/promises"
3
3
  import { join } from "node:path"
4
4
 
5
5
  class WorktreeLockError extends Data.TaggedError("WorktreeLockError")<{
@@ -12,10 +12,23 @@ export interface WorktreeLockTarget {
12
12
  readonly phaseId?: string
13
13
  }
14
14
 
15
+ export interface WorktreeLockOptions {
16
+ readonly force?: boolean
17
+ }
18
+
19
+ const lockTimeoutMs = 10 * 60 * 1000
20
+
21
+ const isErrorCode = (cause: unknown, code: string) =>
22
+ typeof cause === "object" &&
23
+ cause !== null &&
24
+ "code" in cause &&
25
+ cause.code === code
26
+
15
27
  const withWorktreeLock = <A, E, R>(
16
28
  root: string,
17
29
  target: WorktreeLockTarget,
18
30
  effect: Effect.Effect<A, E, R>,
31
+ options: WorktreeLockOptions,
19
32
  ): Effect.Effect<A, E | WorktreeLockError, R> => {
20
33
  const key = Buffer.from(
21
34
  `${target.taskId}:${target.phaseId ?? "task"}`,
@@ -24,18 +37,41 @@ const withWorktreeLock = <A, E, R>(
24
37
  const removalCommand = `rm '${lockPath.replaceAll("'", `'\\''`)}'`
25
38
  return Effect.acquireUseRelease(
26
39
  Effect.tryPromise({
27
- try: () => open(lockPath, "wx"),
40
+ try: async () => {
41
+ try {
42
+ return await open(lockPath, "wx")
43
+ } catch (cause) {
44
+ if (!isErrorCode(cause, "EEXIST")) throw cause
45
+ let stale = false
46
+ try {
47
+ stale = Date.now() - (await stat(lockPath)).mtimeMs >= lockTimeoutMs
48
+ } catch (statCause) {
49
+ if (!isErrorCode(statCause, "ENOENT")) throw statCause
50
+ }
51
+ if (!options.force && !stale) throw cause
52
+ await rm(lockPath, { force: true })
53
+ return open(lockPath, "wx")
54
+ }
55
+ },
28
56
  catch: (cause) =>
29
57
  new WorktreeLockError({
30
- message: `Another worktree operation is in progress for '${target.taskId}${target.phaseId ? `/${target.phaseId}` : ""}'. If no operation is active, remove the stale sentinel with: ${removalCommand}`,
58
+ message: `Another worktree operation is in progress for '${target.taskId}${target.phaseId ? `/${target.phaseId}` : ""}'. Retry with --force or remove the stale sentinel with: ${removalCommand}`,
31
59
  cause,
32
60
  }),
33
61
  }),
34
62
  () => effect,
35
63
  (lock) =>
36
64
  Effect.promise(async () => {
65
+ let ownsLock = false
66
+ try {
67
+ const [held, current] = await Promise.all([
68
+ lock.stat(),
69
+ stat(lockPath),
70
+ ])
71
+ ownsLock = held.dev === current.dev && held.ino === current.ino
72
+ } catch {}
37
73
  await lock.close().catch(() => undefined)
38
- await rm(lockPath, { force: true }).catch(() => undefined)
74
+ if (ownsLock) await rm(lockPath, { force: true }).catch(() => undefined)
39
75
  }),
40
76
  )
41
77
  }
@@ -44,6 +80,7 @@ export const withWorktreeLocks = <A, E, R>(
44
80
  root: string,
45
81
  targets: readonly WorktreeLockTarget[],
46
82
  effect: Effect.Effect<A, E, R>,
83
+ options: WorktreeLockOptions = {},
47
84
  ): Effect.Effect<A, E | WorktreeLockError, R> => {
48
85
  const unique = new Map(
49
86
  targets.map((target) => [
@@ -55,7 +92,7 @@ export const withWorktreeLocks = <A, E, R>(
55
92
  for (const [, target] of [...unique.entries()]
56
93
  .sort(([left], [right]) => left.localeCompare(right))
57
94
  .reverse()) {
58
- current = withWorktreeLock(root, target, current)
95
+ current = withWorktreeLock(root, target, current, options)
59
96
  }
60
97
  return current
61
98
  }
@@ -259,6 +259,7 @@ interface MaterializeOptions extends BaseCommandOptions {
259
259
  }
260
260
 
261
261
  interface RemoveOptions extends BaseCommandOptions {
262
+ readonly force?: boolean
262
263
  readonly snapshots?: WorktreeRemovalSnapshot[]
263
264
  readonly lockHeld?: boolean
264
265
  readonly allowReferenceDrift?: boolean
@@ -267,6 +268,7 @@ interface RemoveOptions extends BaseCommandOptions {
267
268
  }
268
269
 
269
270
  interface LifecycleOptions extends BaseCommandOptions {
271
+ readonly force?: boolean
270
272
  readonly lockHeld?: boolean
271
273
  }
272
274
 
@@ -1825,6 +1827,7 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
1825
1827
  root,
1826
1828
  [{ taskId, ...(phaseId ? { phaseId } : {}) }],
1827
1829
  materialization,
1830
+ { force: options.force },
1828
1831
  )
1829
1832
  }),
1830
1833
 
@@ -2148,6 +2151,7 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
2148
2151
  root,
2149
2152
  [{ taskId, ...(phaseId ? { phaseId } : {}) }],
2150
2153
  removal,
2154
+ { force: options.force },
2151
2155
  )
2152
2156
  }),
2153
2157
 
@@ -2170,6 +2174,7 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
2170
2174
  ...options,
2171
2175
  lockHeld: true,
2172
2176
  }),
2177
+ { force: options.force },
2173
2178
  )
2174
2179
  }
2175
2180
  const inspection = yield* inspectExecution(taskId, phaseId, root)
@@ -2316,6 +2321,7 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
2316
2321
  ...options,
2317
2322
  lockHeld: true,
2318
2323
  }),
2324
+ { force: options.force },
2319
2325
  )
2320
2326
  }
2321
2327
  const inspection = yield* inspectExecution(taskId, phaseId, root)
@@ -79,10 +79,10 @@ commands without prescribing an execution environment.
79
79
  Start every session with one read-only command:
80
80
 
81
81
  ```bash
82
- agency context . --full --json
82
+ agency context . --json
83
83
  ```
84
84
 
85
- Workers use full context here because they need the assigned document prose. Use
85
+ Pass `--full` only when document prose or low-level VCS details are needed. Use
86
86
  the returned target, document paths and revisions, dependency readiness,
87
87
  authority, checkout state, PR state, and validation result. Do not infer these
88
88
  from directory names or stale prose.
@@ -8,7 +8,7 @@ const checksum = (content: string) =>
8
8
 
9
9
  const agencyPlanPrompt = `You are in Agency Plan mode. Think, read, search, and delegate exploration to construct a well-formed plan for the user's goal. Keep the plan comprehensive but concise, and ask clarifying questions when important tradeoffs or intent are unclear.
10
10
 
11
- Start with \`agency context . --json\`. Use its document paths and revisions, then inspect the graph, related epics, tasks, phases, linked tickets, and repository declarations needed to understand the work. Use machine-readable Agency output when available instead of inferring structure from directory names.
11
+ Start with \`agency context . --json\`. Pass \`--full\` only when document prose or low-level VCS details are needed. Use its document paths and revisions, then inspect the graph, related epics, tasks, phases, linked tickets, and repository declarations needed to understand the work. Use machine-readable Agency output when available instead of inferring structure from directory names.
12
12
 
13
13
  When planning an epic, decompose it into independently deliverable tasks with explicit dependencies. Add phases only when one task genuinely requires multiple ordered delivery units. Reuse or update existing work instead of creating duplicate tasks or phases, except when the user explicitly requests a new, separate, or follow-up item. Explicit-new intent overrides reuse of active and archived work even when the subject or suggested ID matches.
14
14
 
@@ -20,24 +20,12 @@ const agencyPlanBashPermissions = {
20
20
  "agency *": "allow",
21
21
  }
22
22
 
23
- const agencyAgentPrompt = `You are the Agency workflow specialist. Use the Agency CLI to handle delegated workbase orchestration and workflow operations. Always start with \`agency context . --json\` and follow the managed Agency instructions and reported authority.
24
-
25
- When the intent and parameters are known, use the matching managed Command Fast Path exactly. The recipes cover creation, preparation, synchronization, phase conversion, archiving, review work, inspection, dropping, continuation, publication, pull requests, non-PR completion, multi-phase setup, investigation handoff, and review refresh. Preparation recipes call \`agency work prepare <task-or-document> --json\`. Do not probe help or list unrelated state, never pass \`--work\` or \`--auto\` to \`agency task create\`, and use \`--dry-run\` only where the recipe or caller requests it. Use CLI discovery only when no fast-path recipe matches or a prescribed command rejects known-current syntax.
26
-
27
- Return the prepared execution contract to the caller. Agency owns the target, validation, materialized workspace facts, and native commands; the caller owns presentation and process orchestration.`
28
-
29
23
  const body = () =>
30
24
  `${JSON.stringify(
31
25
  {
32
26
  $schema: "https://opencode.ai/config.json",
33
27
  instructions: [".agency/AGENTS.md"],
34
28
  agent: {
35
- agency: {
36
- description:
37
- "Handles Agency workbase orchestration and workflow operations with the Agency CLI",
38
- mode: "subagent",
39
- prompt: agencyAgentPrompt,
40
- },
41
29
  plan: {
42
30
  disable: true,
43
31
  },