@markjaquith/agency 2.36.0 → 2.37.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
@@ -47,9 +47,11 @@ and customized documents remain valid.
47
47
 
48
48
  ```text
49
49
  workbase/
50
- AGENTS.md # managed workbase instructions
50
+ AGENTS.md # optional user-owned workbase instructions
51
+ .agency/
52
+ AGENTS.md # managed Agency instructions
51
53
  .opencode/
52
- opencode.jsonc # managed whole-workbase reference
54
+ opencode.jsonc # managed instructions and whole-workbase reference
53
55
  agency.json # tracked config and portable repository declarations
54
56
  repos/ # ignored local materializations
55
57
  frontend/ # bare Git repository or symlink
@@ -77,17 +79,21 @@ workbase/
77
79
  ```
78
80
 
79
81
  Agency keeps discovery and other observational commands read-only. Run
80
- `agency integration status` to inspect `AGENTS.md` and
82
+ `agency integration status` to inspect `.agency/AGENTS.md` and
81
83
  `.opencode/opencode.jsonc`, then `agency integration sync` to create missing
82
84
  files or refresh checksum-safe managed files. Customized files are reported but
83
- never overwritten.
84
-
85
- The OpenCode config advertises the complete workbase as one portable relative
86
- reference. `agency work` sets `OPENCODE_CONFIG` to the managed file and injects
87
- runtime-only absolute rules that allow external access across the workbase while
88
- denying edits outside the execution unit's writable checkout. This makes the
89
- config effective from nested task and phase Git checkouts without persisting a
90
- machine-specific path or granting access outside the workbase. Bash and Agency
85
+ never overwritten. The root `AGENTS.md` is user-owned and is not inspected or
86
+ modified by Agency.
87
+
88
+ When upgrading an existing workbase, synchronization moves a checksum-valid
89
+ Agency-managed root `AGENTS.md` to `.agency/AGENTS.md` once the OpenCode config
90
+ can load the hidden file. A customized root file, including a symlink, is
91
+ preserved as user-owned content.
92
+
93
+ The OpenCode config loads Agency's hidden instructions in addition to any
94
+ user-owned root `AGENTS.md` and advertises the complete workbase as one portable
95
+ reference. OpenCode discovers that config from task and epic launch directories.
96
+ Agents receive whole-workbase visibility from that reference. Bash and Agency
91
97
  operations must still follow the write authority reported by `agency context`.
92
98
 
93
99
  Repository aliases and canonical fetch remotes are declared in tracked
@@ -216,10 +222,8 @@ Every runner receives the same `AGENCY_RUNNER`, `AGENCY_CLAIMANT`,
216
222
  environment is added without overriding these normalized values.
217
223
  `AGENCY_CLAIM_REVISION` is empty for local `agency work` launches.
218
224
  `AGENCY_PROMPT` is empty unless `--auto` is set.
219
- The `opencode` runner additionally receives `OPENCODE_CONFIG` for the workbase's
220
- managed integration and `OPENCODE_CONFIG_CONTENT` with runtime-only,
221
- workbase-scoped access and edit rules. These values keep the Git-synced config
222
- portable while providing whole-workbase read access at every launch location.
225
+ The `opencode` runner discovers the managed project config from its task or epic
226
+ working directory; Agency does not inject OpenCode-specific configuration.
223
227
  `AGENCY_CLAIM_REVISION` is empty for local `agency work` launches.
224
228
  `AGENCY_PROMPT` is empty unless `--auto` is set.
225
229
  `--print-command` prints the exact cwd and argv plus non-secret environment keys
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "2.36.0",
3
+ "version": "2.37.1",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
package/src/cli.test.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { afterAll, afterEach, describe, expect, test } from "bun:test"
2
2
  import { access, mkdir, realpath } from "node:fs/promises"
3
- import { join, relative } from "node:path"
3
+ import { join } from "node:path"
4
4
  import errorFixture from "../fixtures/protocol/error.json"
5
5
  import successFixture from "../fixtures/protocol/success.json"
6
6
  import { cleanupTempDir, createTempDir } from "./test-utils"
@@ -862,6 +862,9 @@ status: open
862
862
  await runGit(["-C", source, "remote", "add", "origin", daemon.remote])
863
863
 
864
864
  parseJson(await runCli(["init", root, "--json"], parent))
865
+ expect(
866
+ Bun.spawnSync(["git", "init", "--initial-branch=main", root]).exitCode,
867
+ ).toBe(0)
865
868
  const agencyConfigPath = join(root, "agency.json")
866
869
  const agencyConfig = JSON.parse(await Bun.file(agencyConfigPath).text())
867
870
  await Bun.write(
@@ -942,10 +945,8 @@ status: open
942
945
  ),
943
946
  )
944
947
 
945
- const taskWorkspace = parseJson(
946
- await runCli(["work", "prepare", "example", "--json"], root),
947
- )
948
- const phaseWorkspace = parseJson(
948
+ parseJson(await runCli(["work", "prepare", "example", "--json"], root))
949
+ parseJson(
949
950
  await runCli(
950
951
  [
951
952
  "work",
@@ -984,22 +985,18 @@ status: open
984
985
  {
985
986
  args: ["--task", "example"],
986
987
  cwd: join(workbaseRoot, "tasks/example"),
987
- writable: taskWorkspace.writablePath,
988
988
  },
989
989
  {
990
990
  args: ["--task", "pipeline", "--phase", "build"],
991
991
  cwd: join(workbaseRoot, "tasks/pipeline"),
992
- writable: phaseWorkspace.writablePath,
993
992
  },
994
993
  {
995
994
  args: ["--epic", "delivery"],
996
995
  cwd: join(workbaseRoot, "epics/delivery"),
997
- writable: null,
998
996
  },
999
997
  {
1000
998
  args: ["--task", "pipeline"],
1001
999
  cwd: join(workbaseRoot, "tasks/pipeline"),
1002
- writable: null,
1003
1000
  },
1004
1001
  ]
1005
1002
  for (const launch of launches) {
@@ -1011,9 +1008,8 @@ status: open
1011
1008
  expect(printed.stderr).toBe("")
1012
1009
  const contract = JSON.parse(printed.stdout)
1013
1010
  expect(contract.cwd).toBe(launch.cwd)
1014
- expect(contract.environment.OPENCODE_CONFIG).toBe(
1015
- join(workbaseRoot, ".opencode/opencode.jsonc"),
1016
- )
1011
+ expect(contract.environment.OPENCODE_CONFIG).toBeUndefined()
1012
+ expect(contract.environment.OPENCODE_CONFIG_CONTENT).toBeUndefined()
1017
1013
  const environment = {
1018
1014
  ...process.env,
1019
1015
  ...contract.environment,
@@ -1030,35 +1026,24 @@ status: open
1030
1026
  expect.arrayContaining([
1031
1027
  expect.objectContaining({
1032
1028
  permission: "external_directory",
1033
- pattern: join(workbaseRoot, "**"),
1029
+ pattern: join(workbaseRoot, "*"),
1034
1030
  action: "allow",
1035
1031
  }),
1036
1032
  ]),
1037
1033
  )
1038
- expect(agent.permission).toEqual(
1039
- expect.arrayContaining([
1040
- expect.objectContaining({
1041
- permission: "edit",
1042
- pattern: "*",
1043
- action: "deny",
1044
- }),
1045
- ]),
1046
- )
1047
- if (launch.writable) {
1048
- expect(agent.permission).toEqual(
1049
- expect.arrayContaining(
1050
- [workbaseRoot, launch.cwd].map((base) =>
1051
- expect.objectContaining({
1052
- permission: "edit",
1053
- pattern: join(relative(base, launch.writable!), "**"),
1054
- action: "allow",
1055
- }),
1056
- ),
1057
- ),
1058
- )
1059
- }
1060
-
1061
1034
  if (launch === launches[0]) {
1035
+ const configProbe = Bun.spawnSync(["opencode", "debug", "config"], {
1036
+ cwd: contract.cwd,
1037
+ env: environment,
1038
+ })
1039
+ expect(configProbe.exitCode).toBe(0)
1040
+ expect(JSON.parse(configProbe.stdout.toString()).references).toEqual({
1041
+ workbase: {
1042
+ path: "..",
1043
+ description:
1044
+ "Complete Agency workbase context; write authority still comes only from agency context",
1045
+ },
1046
+ })
1062
1047
  for (const document of documents) {
1063
1048
  const read = Bun.spawnSync(
1064
1049
  [
@@ -1080,6 +1065,55 @@ status: open
1080
1065
  }
1081
1066
  }
1082
1067
 
1068
+ const directEnvironment: Record<string, string | undefined> = {
1069
+ ...process.env,
1070
+ XDG_CONFIG_HOME: isolatedConfigHome,
1071
+ OPENCODE_DISABLE_EXTERNAL_SKILLS: "1",
1072
+ }
1073
+ delete directEnvironment.OPENCODE_CONFIG
1074
+ delete directEnvironment.OPENCODE_CONFIG_CONTENT
1075
+ delete directEnvironment.AGENCY_WORKBASE
1076
+ for (const cwd of [
1077
+ join(workbaseRoot, "tasks/example"),
1078
+ join(workbaseRoot, "tasks/pipeline"),
1079
+ join(workbaseRoot, "epics/delivery"),
1080
+ ]) {
1081
+ const probe = Bun.spawnSync(["opencode", "debug", "agent", "build"], {
1082
+ cwd,
1083
+ env: directEnvironment,
1084
+ })
1085
+ expect(probe.exitCode).toBe(0)
1086
+ const agent = JSON.parse(probe.stdout.toString())
1087
+ expect(agent.permission).toEqual(
1088
+ expect.arrayContaining([
1089
+ expect.objectContaining({
1090
+ permission: "external_directory",
1091
+ pattern: join(workbaseRoot, "*"),
1092
+ action: "allow",
1093
+ }),
1094
+ ]),
1095
+ )
1096
+ for (const document of documents) {
1097
+ const read = Bun.spawnSync(
1098
+ [
1099
+ "opencode",
1100
+ "debug",
1101
+ "agent",
1102
+ "build",
1103
+ "--tool",
1104
+ "read",
1105
+ "--params",
1106
+ JSON.stringify({ filePath: document }),
1107
+ ],
1108
+ { cwd, env: directEnvironment },
1109
+ )
1110
+ expect(read.exitCode).toBe(0)
1111
+ expect(JSON.parse(read.stdout.toString()).result.output).toContain(
1112
+ `<path>${document}</path>`,
1113
+ )
1114
+ }
1115
+ }
1116
+
1083
1117
  parseJson(
1084
1118
  await runCli(["task", "status", "example", "done", "--json"], root),
1085
1119
  )
@@ -1135,7 +1169,7 @@ status: open
1135
1169
  graph.nodes.find((node: any) => node.id === "task:pipeline").status,
1136
1170
  ).toBe("working")
1137
1171
  },
1138
- 30_000,
1172
+ 90_000,
1139
1173
  )
1140
1174
 
1141
1175
  test("envelopes help and version output in machine mode", async () => {
@@ -114,7 +114,7 @@ status: open
114
114
  test("reports repository, ref, remote, and managed-file failures", async () => {
115
115
  await Bun.$`git -C ${repository} remote remove origin`
116
116
  await Bun.$`git -C ${repository} branch -m other`
117
- await write(root, "AGENTS.md", managedAgents("old\n"))
117
+ await write(root, ".agency/AGENTS.md", managedAgents("old\n"))
118
118
 
119
119
  const logs = await captureLogs(() =>
120
120
  runTestEffect(doctor({ cwd: root, json: true })),
@@ -153,7 +153,7 @@ status: open
153
153
  expect(check).toMatchObject({
154
154
  level: "warning",
155
155
  status: "fail",
156
- message: expect.stringContaining("cannot guarantee whole-workbase"),
156
+ message: expect.stringContaining("cannot guarantee its instructions"),
157
157
  remediation: expect.stringContaining("global config"),
158
158
  })
159
159
  expect(await Bun.file(join(root, ".opencode/opencode.jsonc")).text()).toBe(
@@ -181,6 +181,7 @@ status: open
181
181
  }
182
182
 
183
183
  expect(await Bun.file(join(root, "AGENTS.md")).exists()).toBe(false)
184
+ expect(await Bun.file(join(root, ".agency/AGENTS.md")).exists()).toBe(false)
184
185
  expect(
185
186
  await Bun.file(join(root, ".opencode/opencode.jsonc")).exists(),
186
187
  ).toBe(false)
@@ -33,11 +33,13 @@ describe("init command", () => {
33
33
  expect(await Bun.file(join(root, ".gitignore")).text()).toBe(
34
34
  "/repos/\n/tasks/*/code/\n/tasks/*/phases/*/code/\n",
35
35
  )
36
- expect(await Bun.file(join(root, "AGENTS.md")).exists()).toBe(true)
36
+ expect(await Bun.file(join(root, "AGENTS.md")).exists()).toBe(false)
37
+ expect(await Bun.file(join(root, ".agency/AGENTS.md")).exists()).toBe(true)
37
38
  const opencode = await Bun.file(
38
39
  join(root, ".opencode/opencode.jsonc"),
39
40
  ).text()
40
41
  const config = JSON.parse(opencode.slice(opencode.indexOf("\n\n") + 2))
42
+ expect(config.instructions).toEqual([".agency/AGENTS.md"])
41
43
  expect(config.references).toEqual({
42
44
  workbase: expect.objectContaining({ path: ".." }),
43
45
  })
@@ -50,7 +50,7 @@ describe("integration command", () => {
50
50
  runTestEffect(integration({ subcommand: "status", cwd: root })),
51
51
  )
52
52
 
53
- expect(logs.join("\n")).toContain("cannot guarantee whole-workbase")
53
+ expect(logs.join("\n")).toContain("cannot guarantee its instructions")
54
54
  expect(logs.join("\n")).toContain("global config")
55
55
  })
56
56
 
@@ -63,7 +63,8 @@ describe("integration command", () => {
63
63
  { name: "agents", state: "managed", changed: true },
64
64
  { name: "opencode", state: "managed", changed: true },
65
65
  ])
66
- expect(await Bun.file(join(root, "AGENTS.md")).exists()).toBe(true)
66
+ expect(await Bun.file(join(root, "AGENTS.md")).exists()).toBe(false)
67
+ expect(await Bun.file(join(root, ".agency/AGENTS.md")).exists()).toBe(true)
67
68
  expect(
68
69
  await Bun.file(join(root, ".opencode/opencode.jsonc")).exists(),
69
70
  ).toBe(true)
@@ -486,23 +486,10 @@ describe("work command", () => {
486
486
  ],
487
487
  cwd: "/workbase/epics/delivery",
488
488
  })
489
- expect(harness.launchEnvironments[0]?.OPENCODE_CONFIG).toBe(
490
- "/workbase/.opencode/opencode.jsonc",
491
- )
489
+ expect(harness.launchEnvironments[0]?.OPENCODE_CONFIG).toBeUndefined()
492
490
  expect(
493
- JSON.parse(harness.launchEnvironments[0]!.OPENCODE_CONFIG_CONTENT!),
494
- ).toEqual({
495
- permission: {
496
- external_directory: { "/workbase/**": "allow" },
497
- edit: { "*": "deny" },
498
- },
499
- agent: {
500
- build: { permission: { edit: { "*": "deny" } } },
501
- plan: { permission: { edit: { "*": "deny" } } },
502
- general: { permission: { edit: { "*": "deny" } } },
503
- explore: { permission: { edit: { "*": "deny" } } },
504
- },
505
- })
491
+ harness.launchEnvironments[0]?.OPENCODE_CONFIG_CONTENT,
492
+ ).toBeUndefined()
506
493
  })
507
494
 
508
495
  test("resolves an existing positional path before treating it as a task ID", async () => {
@@ -553,9 +540,7 @@ describe("work command", () => {
553
540
  ],
554
541
  cwd: "/workbase/tasks/delivery",
555
542
  })
556
- expect(harness.launchEnvironments[0]?.OPENCODE_CONFIG).toBe(
557
- "/workbase/.opencode/opencode.jsonc",
558
- )
543
+ expect(harness.launchEnvironments[0]?.OPENCODE_CONFIG).toBeUndefined()
559
544
  })
560
545
 
561
546
  test("infers a phase from a nested checkout directory", async () => {
@@ -585,9 +570,7 @@ describe("work command", () => {
585
570
  expect(harness.statusUpdates).toEqual([
586
571
  "phase:example:implementation:working",
587
572
  ])
588
- expect(harness.launchEnvironments[0]?.OPENCODE_CONFIG).toBe(
589
- "/workbase/.opencode/opencode.jsonc",
590
- )
573
+ expect(harness.launchEnvironments[0]?.OPENCODE_CONFIG).toBeUndefined()
591
574
  })
592
575
 
593
576
  test("infers a single-phase task from a nested checkout directory", async () => {
@@ -601,9 +584,7 @@ describe("work command", () => {
601
584
 
602
585
  expect(harness.events[0]).toBe("materialize")
603
586
  expect(harness.launches[0]?.cwd).toBe(taskDirectory)
604
- expect(harness.launchEnvironments[0]?.OPENCODE_CONFIG).toBe(
605
- "/workbase/.opencode/opencode.jsonc",
606
- )
587
+ expect(harness.launchEnvironments[0]?.OPENCODE_CONFIG).toBeUndefined()
607
588
  })
608
589
 
609
590
  test("selects a target when no directory is provided", async () => {
@@ -781,9 +762,7 @@ describe("work command", () => {
781
762
  ])
782
763
  expect(harness.launchEnvironments[0]?.AGENCY_PROMPT).toBe("")
783
764
  expect(harness.statusUpdates).toEqual(["task:example:working"])
784
- expect(harness.launchEnvironments[0]?.OPENCODE_CONFIG).toBe(
785
- "/workbase/.opencode/opencode.jsonc",
786
- )
765
+ expect(harness.launchEnvironments[0]?.OPENCODE_CONFIG).toBeUndefined()
787
766
  expect(harness.progressUpdates).toEqual([
788
767
  "start:Preparing workspace...",
789
768
  "succeed:Workspace ready",
@@ -936,33 +915,15 @@ describe("work command", () => {
936
915
  expect(harness.taskStatuses.example).toBe("done")
937
916
  })
938
917
 
939
- test("prints the runtime OpenCode integration config", async () => {
918
+ test("does not inject runtime OpenCode configuration", async () => {
940
919
  const harness = createHarness()
941
920
  const output = await captureLogs(() =>
942
921
  harness.run({ taskId: "example", opencode: true, printCommand: true }),
943
922
  )
944
923
  const printed = JSON.parse(output.join("\n"))
945
924
 
946
- expect(printed.environment.OPENCODE_CONFIG).toBe(
947
- "/workbase/.opencode/opencode.jsonc",
948
- )
949
- const edit = {
950
- "*": "deny",
951
- "tasks/example/code/agency/**": "allow",
952
- "code/agency/**": "allow",
953
- }
954
- expect(JSON.parse(printed.environment.OPENCODE_CONFIG_CONTENT)).toEqual({
955
- permission: {
956
- external_directory: { "/workbase/**": "allow" },
957
- edit,
958
- },
959
- agent: {
960
- build: { permission: { edit } },
961
- plan: { permission: { edit } },
962
- general: { permission: { edit } },
963
- explore: { permission: { edit } },
964
- },
965
- })
925
+ expect(printed.environment.OPENCODE_CONFIG).toBeUndefined()
926
+ expect(printed.environment.OPENCODE_CONFIG_CONTENT).toBeUndefined()
966
927
  })
967
928
 
968
929
  test("does not override customized OpenCode access policy", async () => {
@@ -122,10 +122,7 @@ export const work = (
122
122
  const inputAllowed = options.inputAllowed ?? true
123
123
  const root = yield* resolveWorkbase(startPath, pickBase, inputAllowed)
124
124
  if (!root) return
125
- const integration = yield* integrations.sync(root)
126
- const managedOpencode = integration.files.some(
127
- (file) => file.name === "opencode" && file.state === "managed",
128
- )
125
+ yield* integrations.sync(root)
129
126
  const { config } = yield* workbase.loadConfig(root)
130
127
 
131
128
  let target: WorkTarget | null = null
@@ -239,7 +236,6 @@ export const work = (
239
236
  (target.status !== undefined && target.status !== "open"))
240
237
  let prompt: string
241
238
  let launchPath: string
242
- let writablePath: string | undefined
243
239
  if (target.kind === "epic") {
244
240
  prompt = `Work on the epic. Read ${target.path}.`
245
241
  launchPath = dirname(target.path)
@@ -265,7 +261,6 @@ export const work = (
265
261
  ? `${action} the task. Read ${workspace.taskPath} and ${workspace.phasePath}.`
266
262
  : `${action} the task. Read ${workspace.taskPath}.`
267
263
  launchPath = dirname(workspace.taskPath)
268
- writablePath = workspace.writablePath
269
264
  }
270
265
 
271
266
  const explicitlyRequested = Boolean(
@@ -336,35 +331,6 @@ export const work = (
336
331
  ...resolved.environment,
337
332
  ...runnerEnvironment(runner, variables),
338
333
  }
339
- if (runner === "opencode" && managedOpencode) {
340
- environment.OPENCODE_CONFIG = join(root, ".opencode", "opencode.jsonc")
341
- const execution =
342
- target.kind === "phase" ||
343
- (target.kind === "task" && !target.multiPhase)
344
- const edit = execution
345
- ? {
346
- "*": "deny" as const,
347
- ...Object.fromEntries(
348
- [root, launchPath].map((base) => [
349
- join(relative(base, writablePath!), "**").split(sep).join("/"),
350
- "allow" as const,
351
- ]),
352
- ),
353
- }
354
- : { "*": "deny" as const }
355
- environment.OPENCODE_CONFIG_CONTENT = JSON.stringify({
356
- permission: {
357
- external_directory: { [join(root, "**")]: "allow" },
358
- edit,
359
- },
360
- agent: {
361
- build: { permission: { edit } },
362
- plan: { permission: { edit } },
363
- general: { permission: { edit } },
364
- explore: { permission: { edit } },
365
- },
366
- })
367
- }
368
334
  if (options.printCommand) {
369
335
  log(
370
336
  JSON.stringify(
@@ -9,6 +9,7 @@ import {
9
9
  rmdir,
10
10
  stat,
11
11
  symlink,
12
+ unlink,
12
13
  } from "node:fs/promises"
13
14
  import { spawnProcess } from "../utils/process"
14
15
 
@@ -117,6 +118,16 @@ export class FileSystemService extends Effect.Service<FileSystemService>()(
117
118
  }),
118
119
  }),
119
120
 
121
+ deleteFile: (path: string) =>
122
+ Effect.tryPromise({
123
+ try: () => unlink(path),
124
+ catch: (error) =>
125
+ new FileSystemError({
126
+ message: `Failed to delete file: ${path}`,
127
+ cause: error,
128
+ }),
129
+ }),
130
+
120
131
  createDirectory: (path: string) =>
121
132
  Effect.tryPromise({
122
133
  try: () => mkdir(path, { recursive: true }),
@@ -47,9 +47,9 @@ describe("IntegrationService", () => {
47
47
  "missing",
48
48
  "missing",
49
49
  ])
50
- expect(await Bun.file(join(root, "AGENTS.md")).exists()).toBe(false)
50
+ expect(await Bun.file(join(root, ".agency/AGENTS.md")).exists()).toBe(false)
51
51
 
52
- await write(root, "AGENTS.md", managedWorkbaseAgents)
52
+ await write(root, ".agency/AGENTS.md", managedWorkbaseAgents)
53
53
  await write(root, ".opencode/opencode.jsonc", managedWorkbaseOpencode)
54
54
  expect((await status(root)).files.map(({ state }) => state)).toEqual([
55
55
  "managed",
@@ -58,7 +58,7 @@ describe("IntegrationService", () => {
58
58
  })
59
59
 
60
60
  test("reports customized and checksum-safe drifted files", async () => {
61
- await write(root, "AGENTS.md", "# Custom instructions\n")
61
+ await write(root, ".agency/AGENTS.md", "# Custom instructions\n")
62
62
  await write(
63
63
  root,
64
64
  ".opencode/opencode.jsonc",
@@ -97,6 +97,7 @@ describe("IntegrationService", () => {
97
97
  test("grants OpenCode access to the complete workbase", () => {
98
98
  const config = JSON.parse(managedBody(managedWorkbaseOpencode))
99
99
 
100
+ expect(config.instructions).toEqual([".agency/AGENTS.md"])
100
101
  expect(config.references).toEqual({
101
102
  workbase: {
102
103
  path: "..",
@@ -151,8 +152,10 @@ describe("IntegrationService", () => {
151
152
  })
152
153
 
153
154
  test("syncs missing and drifted files while preserving customized files", async () => {
154
- const customAgents = "# Custom instructions\n"
155
- await write(root, "AGENTS.md", customAgents)
155
+ const customRootAgents = "# User-owned workbase instructions\n"
156
+ const customManagedAgents = "# Customized Agency instructions\n"
157
+ await write(root, "AGENTS.md", customRootAgents)
158
+ await write(root, ".agency/AGENTS.md", customManagedAgents)
156
159
  await write(
157
160
  root,
158
161
  ".opencode/opencode.jsonc",
@@ -164,14 +167,50 @@ describe("IntegrationService", () => {
164
167
  { name: "agents", state: "customized", changed: false },
165
168
  { name: "opencode", state: "managed", changed: true },
166
169
  ])
167
- expect(await Bun.file(join(root, "AGENTS.md")).text()).toBe(customAgents)
170
+ expect(await Bun.file(join(root, "AGENTS.md")).text()).toBe(
171
+ customRootAgents,
172
+ )
173
+ expect(await Bun.file(join(root, ".agency/AGENTS.md")).text()).toBe(
174
+ customManagedAgents,
175
+ )
168
176
  expect(await Bun.file(join(root, ".opencode/opencode.jsonc")).text()).toBe(
169
177
  managedWorkbaseOpencode,
170
178
  )
171
179
 
172
- await unlink(join(root, "AGENTS.md"))
180
+ await unlink(join(root, ".agency/AGENTS.md"))
173
181
  const second = await sync(root)
174
182
  expect(second.files[0]).toMatchObject({ state: "managed", changed: true })
183
+ expect(await Bun.file(join(root, ".agency/AGENTS.md")).text()).toBe(
184
+ managedWorkbaseAgents,
185
+ )
186
+ expect(await Bun.file(join(root, "AGENTS.md")).text()).toBe(
187
+ customRootAgents,
188
+ )
189
+ })
190
+
191
+ test("migrates checksum-valid root instructions", async () => {
192
+ await write(root, "AGENTS.md", managedWorkbaseAgents)
193
+
194
+ const result = await sync(root)
195
+
196
+ expect(result.files[0]).toMatchObject({ state: "managed", changed: true })
197
+ expect(await Bun.file(join(root, ".agency/AGENTS.md")).text()).toBe(
198
+ managedWorkbaseAgents,
199
+ )
200
+ expect(await Bun.file(join(root, "AGENTS.md")).exists()).toBe(false)
201
+ })
202
+
203
+ test("preserves legacy instructions when the OpenCode config is customized", async () => {
204
+ await write(root, "AGENTS.md", managedWorkbaseAgents)
205
+ await write(root, ".opencode/opencode.json", '{"model":"test/model"}\n')
206
+
207
+ const result = await sync(root)
208
+
209
+ expect(result.files[0]).toMatchObject({ state: "managed", changed: true })
210
+ expect(result.files[1]).toMatchObject({
211
+ state: "customized",
212
+ changed: false,
213
+ })
175
214
  expect(await Bun.file(join(root, "AGENTS.md")).text()).toBe(
176
215
  managedWorkbaseAgents,
177
216
  )
@@ -198,14 +237,16 @@ describe("IntegrationService", () => {
198
237
  "# Previous Agency instructions\n",
199
238
  " -->",
200
239
  )}User edit\n`
201
- await write(root, "AGENTS.md", tampered)
240
+ await write(root, ".agency/AGENTS.md", tampered)
202
241
 
203
242
  const result = await sync(root)
204
243
  expect(result.files[0]).toMatchObject({
205
244
  state: "customized",
206
245
  changed: false,
207
246
  })
208
- expect(await Bun.file(join(root, "AGENTS.md")).text()).toBe(tampered)
247
+ expect(await Bun.file(join(root, ".agency/AGENTS.md")).text()).toBe(
248
+ tampered,
249
+ )
209
250
  })
210
251
 
211
252
  test("does not overwrite a user-modified OpenCode configuration", async () => {
@@ -228,7 +269,8 @@ describe("IntegrationService", () => {
228
269
  test("does not follow symlinked integration files", async () => {
229
270
  const target = join(root, "custom-agents.md")
230
271
  await Bun.write(target, "# External instructions\n")
231
- await symlink(target, join(root, "AGENTS.md"))
272
+ await mkdir(join(root, ".agency"), { recursive: true })
273
+ await symlink(target, join(root, ".agency/AGENTS.md"))
232
274
 
233
275
  const result = await sync(root)
234
276
  expect(result.files[0]).toMatchObject({
@@ -33,23 +33,23 @@ const describe = (
33
33
  if (state === "managed") {
34
34
  return {
35
35
  diagnostic:
36
- "Agency's managed OpenCode launch config is ready to provide whole-workbase read access.",
36
+ "Agency's managed OpenCode launch config is ready to load Agency instructions and provide whole-workbase read access.",
37
37
  remediation: null,
38
38
  }
39
39
  }
40
40
  if (state === "customized") {
41
41
  return {
42
42
  diagnostic:
43
- "Agency cannot guarantee whole-workbase read access from this customized OpenCode config.",
43
+ "Agency cannot guarantee its instructions or whole-workbase read access from this customized OpenCode config.",
44
44
  remediation:
45
45
  "Back up and remove the customized file, run 'agency integration sync', then move any retained custom settings to OpenCode's global config.",
46
46
  }
47
47
  }
48
48
  return {
49
49
  diagnostic:
50
- "Agency OpenCode launches cannot load current whole-workbase access.",
50
+ "Agency OpenCode launches cannot load current Agency instructions or whole-workbase access.",
51
51
  remediation:
52
- "Run 'agency integration sync' to install whole-workbase OpenCode access.",
52
+ "Run 'agency integration sync' to install Agency instructions and whole-workbase OpenCode access.",
53
53
  }
54
54
  }
55
55
 
@@ -94,7 +94,7 @@ const classify = (
94
94
  const inspect = (root: string) =>
95
95
  Effect.gen(function* () {
96
96
  const fs = yield* FileSystemService
97
- const agentsPath = join(root, "AGENTS.md")
97
+ const agentsPath = join(root, ".agency", "AGENTS.md")
98
98
  const opencodeDirectory = join(root, ".opencode")
99
99
  const opencodePath = join(opencodeDirectory, "opencode.jsonc")
100
100
  const opencodeJsonPath = join(opencodeDirectory, "opencode.json")
@@ -138,6 +138,18 @@ const inspect = (root: string) =>
138
138
  return files
139
139
  })
140
140
 
141
+ const canRemoveLegacyAgents = (root: string) =>
142
+ Effect.gen(function* () {
143
+ const fs = yield* FileSystemService
144
+ const path = join(root, "AGENTS.md")
145
+ if (
146
+ (yield* fs.readSymlinkTarget(path)) !== null ||
147
+ !(yield* fs.exists(path))
148
+ )
149
+ return false
150
+ return canUpdateManagedWorkbaseAgents(yield* fs.readFile(path))
151
+ })
152
+
141
153
  export class IntegrationService extends Effect.Service<IntegrationService>()(
142
154
  "IntegrationService",
143
155
  {
@@ -155,13 +167,19 @@ export class IntegrationService extends Effect.Service<IntegrationService>()(
155
167
  const workbase = yield* WorkbaseService
156
168
  const root = yield* workbase.discover(startPath)
157
169
  const statuses = yield* inspect(root)
170
+ const removeLegacyAgents =
171
+ statuses.some(
172
+ (status) =>
173
+ status.name === "opencode" && status.state !== "customized",
174
+ ) && (yield* canRemoveLegacyAgents(root))
158
175
  const files: IntegrationSyncFile[] = []
159
176
 
160
177
  for (const status of statuses) {
161
- const changed =
178
+ const needsWrite =
162
179
  status.state === "missing" || status.state === "drifted"
163
- if (changed) {
180
+ if (needsWrite) {
164
181
  if (status.name === "agents") {
182
+ yield* fs.createDirectory(join(root, ".agency"))
165
183
  yield* fs.writeFile(status.path, managedWorkbaseAgents)
166
184
  } else {
167
185
  yield* fs.createDirectory(join(root, ".opencode"))
@@ -172,12 +190,15 @@ export class IntegrationService extends Effect.Service<IntegrationService>()(
172
190
  ...fileStatus(
173
191
  status.name,
174
192
  status.path,
175
- changed ? "managed" : status.state,
193
+ needsWrite ? "managed" : status.state,
176
194
  ),
177
- changed,
195
+ changed:
196
+ needsWrite || (status.name === "agents" && removeLegacyAgents),
178
197
  })
179
198
  }
180
199
 
200
+ if (removeLegacyAgents) yield* fs.deleteFile(join(root, "AGENTS.md"))
201
+
181
202
  return { root, files }
182
203
  }),
183
204
  }),
@@ -67,13 +67,16 @@ a refinement loop, or pausing or handing off completed implementation work):
67
67
  ## Managed Integration
68
68
 
69
69
  `agency integration status` reports `managed`, `drifted`, `customized`, or
70
- `missing` generated files. `agency integration sync` updates only missing or
71
- checksum-safe drifted files and preserves user-customized files. `agency init`
72
- creates these files, and `agency work` reconciles them before launching an agent.
70
+ `missing` generated files. Agency keeps these instructions in
71
+ `.agency/AGENTS.md`, and its managed OpenCode config loads them automatically.
72
+ The workbase-root `AGENTS.md`, when present, belongs entirely to the workbase
73
+ owner and composes with these instructions through OpenCode's normal discovery.
74
+ `agency integration sync` updates only missing or checksum-safe drifted managed
75
+ files and preserves user-customized files. `agency init` creates the managed
76
+ files, and `agency work` reconciles them before launching an agent.
73
77
 
74
78
  OpenCode can access the complete workbase tree, but this filesystem permission
75
79
  does not expand Agency write authority beyond the checkout reported by
76
- `agency context`. Agency launches expose the managed OpenCode config file at
77
- runtime and inject absolute access rules scoped to this workbase. Direct edits
78
- remain denied outside the writable checkout, and no machine-specific path is
79
- stored in the Git-synced config.
80
+ `agency context`. OpenCode discovers the managed project config from task and
81
+ epic launch directories. No machine-specific path or runtime permission overlay
82
+ is required; agents must follow the authority reported by `agency context`.
@@ -10,6 +10,7 @@ const body = () =>
10
10
  `${JSON.stringify(
11
11
  {
12
12
  $schema: "https://opencode.ai/config.json",
13
+ instructions: [".agency/AGENTS.md"],
13
14
  references: {
14
15
  workbase: {
15
16
  path: "..",