@markjaquith/agency 2.36.0 → 2.37.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
@@ -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,18 +79,26 @@ 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
91
- operations must still follow the write authority reported by `agency context`.
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
+ relative reference. `agency work` sets `OPENCODE_CONFIG` to the managed file and
96
+ injects runtime-only absolute rules that allow external access across the
97
+ workbase while denying edits outside the execution unit's writable checkout.
98
+ This makes the config effective from nested task and phase Git checkouts without
99
+ persisting a machine-specific path or granting access outside the workbase. Bash
100
+ and Agency operations must still follow the write authority reported by
101
+ `agency context`.
92
102
 
93
103
  Repository aliases and canonical fetch remotes are declared in tracked
94
104
  `agency.json`; local bare clones and symlinks remain ignored under
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "2.36.0",
3
+ "version": "2.37.0",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -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)
@@ -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,9 +67,13 @@ 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
@@ -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: "..",