@markjaquith/agency 3.1.0 → 3.2.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
@@ -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
@@ -1031,7 +1028,7 @@ until restored.
1031
1028
 
1032
1029
  ```text
1033
1030
  agency work [<directory> | --epic <epic-id>] [--agent <name>] [--auto] [--print-command]
1034
- agency work prepare [target] [--evidence <json-or-path>] [--dry-run] [--json]
1031
+ agency work prepare [target] [--evidence <json-or-path>] [--force] [--dry-run] [--json]
1035
1032
  agency worktree <list|inspect|prepare|remove|rebuild|repair>
1036
1033
  agency push [--json]
1037
1034
  agency pr create <task-id> [phase-id] [--draft] [--title <title>] [--head <branch>] [--base <branch>] [--label <label>] [--force] [--json]
@@ -1065,9 +1062,11 @@ work and reading context. It does not prescribe how callers present or execute
1065
1062
  prepared work.
1066
1063
  The evidence argument may be an evidence object, task-creation JSON, or a path to
1067
1064
  either. Use `--dry-run` to report planned fetch, branch, and worktree changes
1068
- without applying them. Validation reuse never skips readiness, active-claim,
1069
- repository, ownership, reference-drift, dirty-workspace, or worktree safety
1070
- checks.
1065
+ without applying them. Use `--force` to prepare work blocked by readiness, such
1066
+ as a follow-on phase whose dependency is still active; preparation still does
1067
+ not launch an agent or change lifecycle status. Validation reuse never skips
1068
+ readiness, active-claim, repository, ownership, reference-drift, dirty-workspace,
1069
+ or worktree safety checks.
1071
1070
 
1072
1071
  The authoritative implementation locations for this contract are
1073
1072
  `src/commands/task.ts` (creation output),
package/cli-main.ts CHANGED
@@ -618,6 +618,7 @@ const commands: Record<string, Command> = {
618
618
  subcommand: args[0],
619
619
  args: args.slice(1),
620
620
  dryRun: options["dry-run"],
621
+ force: options.force,
621
622
  json: options.json,
622
623
  silent: options.silent,
623
624
  verbose: options.verbose,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "3.1.0",
3
+ "version": "3.2.0",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -397,9 +397,11 @@ describe("strict CLI parsing", () => {
397
397
  commandName: "work",
398
398
  values: { force: true },
399
399
  })
400
- expect(() => parseCli(["work", "prepare", "example", "--force"])).toThrow(
401
- "cannot be combined",
402
- )
400
+ expect(parseCli(["work", "prepare", "example", "--force"])).toMatchObject({
401
+ commandName: "work",
402
+ args: ["prepare", "example"],
403
+ values: { force: true },
404
+ })
403
405
  })
404
406
 
405
407
  test("preserves every argument after pr without parsing it", () => {
@@ -664,11 +666,17 @@ describe("strict CLI parsing", () => {
664
666
  "--phase",
665
667
  "verify",
666
668
  "--dry-run",
669
+ "--force",
667
670
  ]),
668
671
  ).toMatchObject({
669
672
  commandName: "worktree",
670
673
  args: ["rebuild", "example", "verify"],
671
- values: { task: "example", phase: "verify", "dry-run": true },
674
+ values: {
675
+ task: "example",
676
+ phase: "verify",
677
+ "dry-run": true,
678
+ force: true,
679
+ },
672
680
  })
673
681
  expectUsageError(
674
682
  ["worktree", "inspect", "example", "--dry-run"],
package/src/cli-parser.ts CHANGED
@@ -870,6 +870,7 @@ const commands = {
870
870
  ...outputOptions,
871
871
  ...entitySelectorOptions,
872
872
  "dry-run": { type: "boolean" },
873
+ force: { type: "boolean" },
873
874
  },
874
875
  subcommands: {
875
876
  list: {
@@ -886,37 +887,37 @@ const commands = {
886
887
  },
887
888
  prepare: {
888
889
  usage:
889
- "agency worktree prepare <task-id> [phase-id] [--dry-run] [--json]",
890
+ "agency worktree prepare <task-id> [phase-id] [--dry-run] [--force] [--json]",
890
891
  minArgs: 1,
891
892
  maxArgs: 2,
892
- options: ["dry-run", "json", "task", "phase"],
893
+ options: ["dry-run", "force", "json", "task", "phase"],
893
894
  },
894
895
  remove: {
895
896
  usage:
896
- "agency worktree remove <task-id> [phase-id] [--dry-run] [--json]",
897
+ "agency worktree remove <task-id> [phase-id] [--dry-run] [--force] [--json]",
897
898
  minArgs: 1,
898
899
  maxArgs: 2,
899
- options: ["dry-run", "json", "task", "phase"],
900
+ options: ["dry-run", "force", "json", "task", "phase"],
900
901
  },
901
902
  rebuild: {
902
903
  usage:
903
- "agency worktree rebuild <task-id> [phase-id] [--dry-run] [--json]",
904
+ "agency worktree rebuild <task-id> [phase-id] [--dry-run] [--force] [--json]",
904
905
  minArgs: 1,
905
906
  maxArgs: 2,
906
- options: ["dry-run", "json", "task", "phase"],
907
+ options: ["dry-run", "force", "json", "task", "phase"],
907
908
  },
908
909
  repair: {
909
910
  usage:
910
- "agency worktree repair <task-id> [phase-id] [--dry-run] [--json]",
911
+ "agency worktree repair <task-id> [phase-id] [--dry-run] [--force] [--json]",
911
912
  minArgs: 1,
912
913
  maxArgs: 2,
913
- options: ["dry-run", "json", "task", "phase"],
914
+ options: ["dry-run", "force", "json", "task", "phase"],
914
915
  },
915
916
  },
916
917
  },
917
918
  work: {
918
919
  usage:
919
- "agency work [<directory-or-task-id> | --epic <epic-id>] [--agent <name>] [--auto] | agency work prepare [target] [--dry-run] [--json]",
920
+ "agency work [<directory-or-task-id> | --epic <epic-id>] [--agent <name>] [--auto] | agency work prepare [target] [--force] [--dry-run] [--json]",
920
921
  options: {
921
922
  ...commonOptions,
922
923
  ...entitySelectorOptions,
@@ -932,7 +933,7 @@ const commands = {
932
933
  },
933
934
  command: {
934
935
  usage:
935
- "agency work [<directory-or-task-id> | --epic <epic-id>] [--agent <name>] [--auto] | agency work prepare [target] [--dry-run] [--json]",
936
+ "agency work [<directory-or-task-id> | --epic <epic-id>] [--agent <name>] [--auto] | agency work prepare [target] [--force] [--dry-run] [--json]",
936
937
  minArgs: 0,
937
938
  maxArgs: 2,
938
939
  options: [
@@ -1690,8 +1691,7 @@ export function parseCli(args: readonly string[]): ParsedCli {
1690
1691
  parsed.values.claude ||
1691
1692
  parsed.values.agent ||
1692
1693
  parsed.values.auto ||
1693
- parsed.values["print-command"] ||
1694
- parsed.values.force))
1694
+ parsed.values["print-command"]))
1695
1695
  ) {
1696
1696
  throw usageError(
1697
1697
  preparing
package/src/cli.test.ts CHANGED
@@ -1310,7 +1310,12 @@ status: open
1310
1310
  ...process.env,
1311
1311
  ...contract.environment,
1312
1312
  XDG_CONFIG_HOME: isolatedConfigHome,
1313
+ OPENCODE_DISABLE_AUTOUPDATE: "1",
1313
1314
  OPENCODE_DISABLE_EXTERNAL_SKILLS: "1",
1315
+ OPENCODE_DISABLE_MODELS_FETCH: "1",
1316
+ OPENCODE_CONFIG_CONTENT: JSON.stringify({
1317
+ model: "opencode/big-pickle",
1318
+ }),
1314
1319
  }
1315
1320
  const probe = Bun.spawnSync(["opencode", "debug", "agent", "build"], {
1316
1321
  cwd: contract.cwd,
@@ -1373,12 +1378,7 @@ status: open
1373
1378
  }
1374
1379
  if (launch === launches[0]) {
1375
1380
  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
- })
1381
+ expect(effectiveConfig.agent.agency).toBeUndefined()
1382
1382
  expect(effectiveConfig.references).toEqual({
1383
1383
  workbase: {
1384
1384
  path: "..",
@@ -1386,18 +1386,6 @@ status: open
1386
1386
  "Complete Agency workbase context; write authority still comes only from agency context",
1387
1387
  },
1388
1388
  })
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
1389
  for (const document of documents) {
1402
1390
  const read = Bun.spawnSync(
1403
1391
  [
@@ -1426,7 +1414,9 @@ status: open
1426
1414
  ...process.env,
1427
1415
  PATH: `${agencyBin}:${process.env.PATH ?? ""}`,
1428
1416
  XDG_CONFIG_HOME: isolatedConfigHome,
1417
+ OPENCODE_DISABLE_AUTOUPDATE: "1",
1429
1418
  OPENCODE_DISABLE_EXTERNAL_SKILLS: "1",
1419
+ OPENCODE_DISABLE_MODELS_FETCH: "1",
1430
1420
  }
1431
1421
  delete directEnvironment.OPENCODE_CONFIG
1432
1422
  delete directEnvironment.OPENCODE_CONFIG_CONTENT
@@ -1497,6 +1487,7 @@ status: open
1497
1487
  env: {
1498
1488
  ...directEnvironment,
1499
1489
  OPENCODE_CONFIG_CONTENT: JSON.stringify({
1490
+ model: "opencode/big-pickle",
1500
1491
  permission: { external_directory: { "*": "deny" } },
1501
1492
  }),
1502
1493
  },
@@ -1595,7 +1586,7 @@ status: open
1595
1586
  graph.nodes.find((node: any) => node.id === "task:pipeline").status,
1596
1587
  ).toBe("working")
1597
1588
  },
1598
- 180_000,
1589
+ 360_000,
1599
1590
  )
1600
1591
 
1601
1592
  test("envelopes help and version output in machine mode", async () => {
@@ -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",
@@ -569,6 +569,49 @@ describe("work command", () => {
569
569
  ).rejects.toThrow("Recalled repository conflicts")
570
570
  })
571
571
 
572
+ test("force prepares a phase blocked by an active dependency without launching or changing status", async () => {
573
+ const blocked = createHarness({
574
+ workspace: multiPhaseWorkspace,
575
+ multiPhaseTasks: ["example"],
576
+ guardError: new Error("Phase dependency is working"),
577
+ })
578
+ await expect(
579
+ blocked.runPrepare({
580
+ cwd: "/workbase",
581
+ taskId: "example",
582
+ phaseId: "implementation",
583
+ }),
584
+ ).rejects.toThrow("Phase dependency is working")
585
+ expect(blocked.events).toEqual(["guard"])
586
+
587
+ const forced = createHarness({
588
+ workspace: multiPhaseWorkspace,
589
+ multiPhaseTasks: ["example"],
590
+ guardError: new Error("Phase dependency is working"),
591
+ })
592
+ await forced.runPrepare({
593
+ cwd: "/workbase",
594
+ taskId: "example",
595
+ phaseId: "implementation",
596
+ force: true,
597
+ silent: true,
598
+ })
599
+
600
+ expect(forced.guards).toEqual([
601
+ {
602
+ target: "execution-unit:phase/example/implementation",
603
+ override: true,
604
+ },
605
+ ])
606
+ expect(forced.events).toEqual(["guard", "materialize"])
607
+ expect(forced.materializeOptions[0]).toMatchObject({
608
+ force: true,
609
+ validationAlreadyPerformed: true,
610
+ })
611
+ expect(forced.launches).toEqual([])
612
+ expect(forced.statusUpdates).toEqual([])
613
+ })
614
+
572
615
  test("launches an epic agent from an epic directory", async () => {
573
616
  const harness = createHarness()
574
617
 
@@ -577,7 +577,7 @@ export const workPrepare = (options: WorkOptions = {}) =>
577
577
 
578
578
  export const help = `
579
579
  Usage: agency work [<directory-or-task-id> | --epic <epic-id>] [--agent <name>] [--auto]
580
- agency work prepare [target] [--evidence <json-or-path>] [--dry-run] [--json]
580
+ agency work prepare [target] [--evidence <json-or-path>] [--force] [--dry-run] [--json]
581
581
 
582
582
  Launch an agent for an epic, task, or phase. With no directory, select one
583
583
  interactively. A positional argument resolves as a directory first, then as a task
@@ -592,7 +592,8 @@ changes without fetching, creating branches, or creating worktrees.
592
592
  It emits revision-bound validation evidence and an idempotent external-orchestrator
593
593
  contract. Evidence is reused only while the target, workbase, configuration, and
594
594
  repository mapping remain unchanged. Dynamic readiness and workspace safety checks
595
- always run.
595
+ always run. With prepare, --force overrides readiness without launching or changing
596
+ lifecycle status.
596
597
 
597
598
  Options:
598
599
  --epic <id> Work on an epic
@@ -605,8 +606,8 @@ Options:
605
606
  --print-command Print cwd, argv, and non-secret environment without launch
606
607
  --opencode Require the OpenCode preset
607
608
  --claude Require the Claude Code preset
608
- --force Override readiness; reopen terminal execution units
609
- --evidence <value> Validation evidence JSON or a path to JSON (prepare only)
609
+ --force Override readiness; launched terminal work is reopened
610
+ --evidence <value> Validation evidence JSON or a path to JSON (prepare only)
610
611
  --no-input Never open an interactive selector
611
612
 
612
613
  Without interactive input, provide an explicit workbase or cwd and an entity
@@ -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
  `
@@ -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
  },