@markjaquith/agency 2.43.1 → 2.45.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
@@ -52,6 +52,8 @@ workbase/
52
52
  AGENTS.md # managed Agency instructions
53
53
  .opencode/
54
54
  opencode.jsonc # managed @agency subagent, instructions, and reference
55
+ command/agency.md # managed /agency workflow command
56
+ plugin/agency-repository-skills.ts # managed checkout skill discovery
55
57
  agency.json # tracked config and portable repository declarations
56
58
  repos/ # ignored local materializations
57
59
  frontend/ # bare Git repository or symlink
@@ -232,12 +234,18 @@ Every runner receives the same `AGENCY_RUNNER`, `AGENCY_CLAIMANT`,
232
234
  `AGENCY_SESSION_ID`, `AGENCY_CLAIM_REVISION`, `AGENCY_WORKBASE`, `AGENCY_TARGET`,
233
235
  `AGENCY_TASK_ID`, `AGENCY_PHASE_ID`, and `AGENCY_PROMPT` environment. Configured
234
236
  environment is added without overriding these normalized values.
237
+ Execution-unit runners also receive `AGENCY_WRITABLE_CHECKOUT` with the
238
+ authoritative writable checkout path.
235
239
  `AGENCY_CLAIM_REVISION` is empty for local `agency work` launches.
236
240
  `AGENCY_PROMPT` is empty unless `--auto` is set.
237
- The `opencode` runner discovers the managed project config from its task or epic
238
- working directory; Agency does not inject OpenCode-specific configuration.
239
- `AGENCY_CLAIM_REVISION` is empty for local `agency work` launches.
240
- `AGENCY_PROMPT` is empty unless `--auto` is set.
241
+ The `opencode` runner remains rooted in its task or epic working directory so
242
+ the workbase `AGENTS.md` and managed OpenCode config are discovered normally.
243
+ Agency's managed OpenCode plugin adds existing checkout-local `.claude/skills`,
244
+ `.agents/skills`, and `.opencode/{skill,skills}` directories to `skills.paths`.
245
+ `agency work` supplies the checkout directly; plain OpenCode launches resolve a
246
+ materialized execution-unit checkout through `agency context`. A multi-phase
247
+ task root has no single checkout, so launch from its phase directory when using
248
+ plain OpenCode. Other checkout-local configuration is not composed.
241
249
  `--print-command` prints the exact cwd and argv plus non-secret environment keys
242
250
  without launching the runner.
243
251
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "2.43.1",
3
+ "version": "2.45.0",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
package/src/cli.test.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { afterAll, afterEach, describe, expect, test } from "bun:test"
2
- import { access, mkdir, realpath } from "node:fs/promises"
2
+ import { access, mkdir, realpath, symlink } from "node:fs/promises"
3
3
  import { join } from "node:path"
4
4
  import errorFixture from "../fixtures/protocol/error.json"
5
5
  import successFixture from "../fixtures/protocol/success.json"
@@ -527,6 +527,7 @@ describe("CLI", () => {
527
527
  { name: "agents", state: "managed" },
528
528
  { name: "opencode", state: "managed" },
529
529
  { name: "opencode-command", state: "managed" },
530
+ { name: "opencode-plugin", state: "managed" },
530
531
  ])
531
532
 
532
533
  const synced = parseJson(
@@ -536,6 +537,7 @@ describe("CLI", () => {
536
537
  { name: "agents", state: "managed", changed: false },
537
538
  { name: "opencode", state: "managed", changed: false },
538
539
  { name: "opencode-command", state: "managed", changed: false },
540
+ { name: "opencode-plugin", state: "managed", changed: false },
539
541
  ])
540
542
  })
541
543
 
@@ -864,10 +866,17 @@ status: open
864
866
  .exitCode,
865
867
  ).toBe(0)
866
868
  await Bun.write(join(source, "README.md"), "example\n")
869
+ await mkdir(join(source, ".claude/skills/repository-skill"), {
870
+ recursive: true,
871
+ })
872
+ await Bun.write(
873
+ join(source, ".claude/skills/repository-skill/SKILL.md"),
874
+ "---\nname: repository-skill\ndescription: Repository discovery test.\n---\n\nRepository skill content.\n",
875
+ )
867
876
  for (const args of [
868
877
  ["config", "user.email", "test@example.com"],
869
878
  ["config", "user.name", "Test"],
870
- ["add", "README.md"],
879
+ ["add", "."],
871
880
  ["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
872
881
  ]) {
873
882
  expect(Bun.spawnSync(["git", "-C", source, ...args]).exitCode).toBe(0)
@@ -1001,18 +1010,35 @@ status: open
1001
1010
  {
1002
1011
  args: ["--task", "example"],
1003
1012
  cwd: join(workbaseRoot, "tasks/example"),
1013
+ checkoutPath: join(workbaseRoot, "tasks/example/code/agency"),
1014
+ skillPath: join(
1015
+ workbaseRoot,
1016
+ "tasks/example/code/agency/.claude/skills",
1017
+ ),
1004
1018
  },
1005
1019
  {
1006
1020
  args: ["--task", "pipeline", "--phase", "build"],
1007
1021
  cwd: join(workbaseRoot, "tasks/pipeline"),
1022
+ checkoutPath: join(
1023
+ workbaseRoot,
1024
+ "tasks/pipeline/phases/build/code/agency",
1025
+ ),
1026
+ skillPath: join(
1027
+ workbaseRoot,
1028
+ "tasks/pipeline/phases/build/code/agency/.claude/skills",
1029
+ ),
1008
1030
  },
1009
1031
  {
1010
1032
  args: ["--epic", "delivery"],
1011
1033
  cwd: join(workbaseRoot, "epics/delivery"),
1034
+ checkoutPath: undefined,
1035
+ skillPath: undefined,
1012
1036
  },
1013
1037
  {
1014
1038
  args: ["--task", "pipeline"],
1015
1039
  cwd: join(workbaseRoot, "tasks/pipeline"),
1040
+ checkoutPath: undefined,
1041
+ skillPath: undefined,
1016
1042
  },
1017
1043
  ]
1018
1044
  for (const launch of launches) {
@@ -1024,7 +1050,11 @@ status: open
1024
1050
  expect(printed.stderr).toBe("")
1025
1051
  const contract = JSON.parse(printed.stdout)
1026
1052
  expect(contract.cwd).toBe(launch.cwd)
1053
+ expect(contract.argv).toEqual(["opencode"])
1027
1054
  expect(contract.environment.OPENCODE_CONFIG).toBeUndefined()
1055
+ expect(contract.environment.AGENCY_WRITABLE_CHECKOUT).toBe(
1056
+ launch.checkoutPath,
1057
+ )
1028
1058
  expect(
1029
1059
  JSON.parse(contract.environment.OPENCODE_CONFIG_CONTENT),
1030
1060
  ).toEqual({
@@ -1053,13 +1083,41 @@ status: open
1053
1083
  }),
1054
1084
  ]),
1055
1085
  )
1086
+ const configProbe = Bun.spawnSync(["opencode", "debug", "config"], {
1087
+ cwd: contract.cwd,
1088
+ env: environment,
1089
+ })
1090
+ expect(configProbe.exitCode).toBe(0)
1091
+ const effectiveConfig = JSON.parse(configProbe.stdout.toString())
1092
+ expect(effectiveConfig.plugin_origins).toEqual(
1093
+ expect.arrayContaining([
1094
+ expect.objectContaining({
1095
+ spec: expect.stringContaining("agency-repository-skills.ts"),
1096
+ }),
1097
+ ]),
1098
+ )
1099
+ if (launch.skillPath) {
1100
+ expect(effectiveConfig.skills.paths).toContain(launch.skillPath)
1101
+ const skillProbe = Bun.spawnSync(
1102
+ [
1103
+ "opencode",
1104
+ "debug",
1105
+ "agent",
1106
+ "build",
1107
+ "--tool",
1108
+ "skill",
1109
+ "--params",
1110
+ JSON.stringify({ name: "repository-skill" }),
1111
+ ],
1112
+ { cwd: contract.cwd, env: environment },
1113
+ )
1114
+ expect(skillProbe.exitCode).toBe(0)
1115
+ expect(
1116
+ JSON.parse(skillProbe.stdout.toString()).result.output,
1117
+ ).toContain("Repository skill content.")
1118
+ }
1056
1119
  if (launch === launches[0]) {
1057
- const configProbe = Bun.spawnSync(["opencode", "debug", "config"], {
1058
- cwd: contract.cwd,
1059
- env: environment,
1060
- })
1061
- expect(configProbe.exitCode).toBe(0)
1062
- const effectiveConfig = JSON.parse(configProbe.stdout.toString())
1120
+ expect(effectiveConfig.instructions).toContain(".agency/AGENTS.md")
1063
1121
  expect(effectiveConfig.agent.agency).toMatchObject({
1064
1122
  description: expect.stringContaining(
1065
1123
  "Agency workbase orchestration",
@@ -1106,19 +1164,28 @@ status: open
1106
1164
  }
1107
1165
  }
1108
1166
 
1167
+ const agencyBin = join(parent, "bin")
1168
+ await mkdir(agencyBin)
1169
+ await symlink(cliPath, join(agencyBin, "agency"))
1109
1170
  const directEnvironment: Record<string, string | undefined> = {
1110
1171
  ...process.env,
1172
+ PATH: `${agencyBin}:${process.env.PATH ?? ""}`,
1111
1173
  XDG_CONFIG_HOME: isolatedConfigHome,
1112
1174
  OPENCODE_DISABLE_EXTERNAL_SKILLS: "1",
1113
1175
  }
1114
1176
  delete directEnvironment.OPENCODE_CONFIG
1115
1177
  delete directEnvironment.OPENCODE_CONFIG_CONTENT
1116
1178
  delete directEnvironment.AGENCY_WORKBASE
1117
- for (const cwd of [
1179
+ delete directEnvironment.AGENCY_TASK_ID
1180
+ delete directEnvironment.AGENCY_PHASE_ID
1181
+ delete directEnvironment.AGENCY_WRITABLE_CHECKOUT
1182
+ const directDirectories = [
1118
1183
  join(workbaseRoot, "tasks/example"),
1119
1184
  join(workbaseRoot, "tasks/pipeline"),
1185
+ join(workbaseRoot, "tasks/pipeline/phases/build"),
1120
1186
  join(workbaseRoot, "epics/delivery"),
1121
- ]) {
1187
+ ]
1188
+ for (const cwd of directDirectories) {
1122
1189
  const probe = Bun.spawnSync(["opencode", "debug", "agent", "build"], {
1123
1190
  cwd,
1124
1191
  env: directEnvironment,
@@ -1134,6 +1201,20 @@ status: open
1134
1201
  }),
1135
1202
  ]),
1136
1203
  )
1204
+ const configProbe = Bun.spawnSync(["opencode", "debug", "config"], {
1205
+ cwd,
1206
+ env: directEnvironment,
1207
+ })
1208
+ expect(configProbe.exitCode).toBe(0)
1209
+ expect(
1210
+ JSON.parse(configProbe.stdout.toString()).plugin_origins,
1211
+ ).toEqual(
1212
+ expect.arrayContaining([
1213
+ expect.objectContaining({
1214
+ spec: expect.stringContaining("agency-repository-skills.ts"),
1215
+ }),
1216
+ ]),
1217
+ )
1137
1218
  for (const document of documents) {
1138
1219
  const read = Bun.spawnSync(
1139
1220
  [
@@ -1154,6 +1235,28 @@ status: open
1154
1235
  )
1155
1236
  }
1156
1237
  }
1238
+ for (const cwd of [
1239
+ join(workbaseRoot, "tasks/example"),
1240
+ join(workbaseRoot, "tasks/pipeline/phases/build"),
1241
+ ]) {
1242
+ const manualSkill = Bun.spawnSync(
1243
+ [
1244
+ "opencode",
1245
+ "debug",
1246
+ "agent",
1247
+ "build",
1248
+ "--tool",
1249
+ "skill",
1250
+ "--params",
1251
+ JSON.stringify({ name: "repository-skill" }),
1252
+ ],
1253
+ { cwd, env: directEnvironment },
1254
+ )
1255
+ expect(manualSkill.exitCode, manualSkill.stderr.toString()).toBe(0)
1256
+ expect(
1257
+ JSON.parse(manualSkill.stdout.toString()).result.output,
1258
+ ).toContain("Repository skill content.")
1259
+ }
1157
1260
 
1158
1261
  parseJson(
1159
1262
  await runCli(["task", "status", "example", "dropped", "--json"], root),
@@ -66,6 +66,11 @@ describe("init command", () => {
66
66
  ).text()
67
67
  expect(command).toContain("Workflow: `$1`")
68
68
  expect(command).toContain("Optional target: `$2`")
69
+ const plugin = await Bun.file(
70
+ join(root, ".opencode/plugin/agency-repository-skills.ts"),
71
+ ).text()
72
+ expect(plugin).toContain("AGENCY_WRITABLE_CHECKOUT")
73
+ expect(plugin).toContain("config.skills.paths")
69
74
  })
70
75
 
71
76
  test("preserves existing gitignore entries", async () => {
@@ -41,6 +41,11 @@ describe("integration command", () => {
41
41
  state: "missing",
42
42
  remediation: expect.stringContaining("integration sync"),
43
43
  },
44
+ {
45
+ name: "opencode-plugin",
46
+ state: "missing",
47
+ remediation: expect.stringContaining("integration sync"),
48
+ },
44
49
  ],
45
50
  })
46
51
  })
@@ -59,6 +64,34 @@ describe("integration command", () => {
59
64
  expect(logs.join("\n")).toContain("global config")
60
65
  })
61
66
 
67
+ test("formats integration status for people", async () => {
68
+ const logs = await captureLogs(() =>
69
+ runTestEffect(integration({ subcommand: "status", cwd: root })),
70
+ )
71
+
72
+ expect(logs.join("\n")).toBe(`Integration status: ${root}
73
+
74
+ Agent instructions: missing
75
+ Path: .agency/AGENTS.md
76
+ Managed workbase instructions need synchronization.
77
+ Action: Run 'agency integration sync' to restore managed instructions.
78
+
79
+ OpenCode config: missing
80
+ Path: .opencode/opencode.jsonc
81
+ Agency OpenCode launches cannot load current Agency instructions or whole-workbase access.
82
+ Action: Run 'agency integration sync' to install Agency instructions and whole-workbase OpenCode access.
83
+
84
+ OpenCode /agency command: missing
85
+ Path: .opencode/command/agency.md
86
+ The managed OpenCode /agency command needs synchronization.
87
+ Action: Run 'agency integration sync' to install the managed /agency command.
88
+
89
+ OpenCode checkout skills: missing
90
+ Path: .opencode/plugin/agency-repository-skills.ts
91
+ The managed OpenCode checkout-skill plugin needs synchronization.
92
+ Action: Run 'agency integration sync' to expose writable-checkout skills in OpenCode.`)
93
+ })
94
+
62
95
  test("explicitly synchronizes integration files", async () => {
63
96
  const logs = await captureLogs(() =>
64
97
  runTestEffect(integration({ subcommand: "sync", cwd: root, json: true })),
@@ -68,6 +101,7 @@ describe("integration command", () => {
68
101
  { name: "agents", state: "managed", changed: true },
69
102
  { name: "opencode", state: "managed", changed: true },
70
103
  { name: "opencode-command", state: "managed", changed: true },
104
+ { name: "opencode-plugin", state: "managed", changed: true },
71
105
  ])
72
106
  expect(await Bun.file(join(root, "AGENTS.md")).exists()).toBe(false)
73
107
  expect(await Bun.file(join(root, ".agency/AGENTS.md")).exists()).toBe(true)
@@ -77,5 +111,34 @@ describe("integration command", () => {
77
111
  expect(
78
112
  await Bun.file(join(root, ".opencode/command/agency.md")).exists(),
79
113
  ).toBe(true)
114
+ expect(
115
+ await Bun.file(
116
+ join(root, ".opencode/plugin/agency-repository-skills.ts"),
117
+ ).exists(),
118
+ ).toBe(true)
119
+ })
120
+
121
+ test("formats integration sync results for people", async () => {
122
+ const logs = await captureLogs(() =>
123
+ runTestEffect(integration({ subcommand: "sync", cwd: root })),
124
+ )
125
+
126
+ expect(logs.join("\n")).toBe(`Integration sync: ${root}
127
+
128
+ Agent instructions: synced
129
+ Path: .agency/AGENTS.md
130
+ Managed workbase instructions are current.
131
+
132
+ OpenCode config: synced
133
+ Path: .opencode/opencode.jsonc
134
+ Agency's managed OpenCode launch config is ready to load Agency instructions and provide whole-workbase read access.
135
+
136
+ OpenCode /agency command: synced
137
+ Path: .opencode/command/agency.md
138
+ Agency's managed OpenCode /agency command is current.
139
+
140
+ OpenCode checkout skills: synced
141
+ Path: .opencode/plugin/agency-repository-skills.ts
142
+ Agency's managed OpenCode plugin exposes writable-checkout skills to work-item sessions.`)
80
143
  })
81
144
  })
@@ -1,4 +1,5 @@
1
1
  import { Effect } from "effect"
2
+ import { relative } from "node:path"
2
3
  import type { BaseCommandOptions } from "../utils/command"
3
4
  import { IntegrationService } from "../services/IntegrationService"
4
5
  import { createLoggers } from "../utils/effect"
@@ -8,6 +9,46 @@ interface IntegrationOptions extends BaseCommandOptions {
8
9
  readonly json?: boolean
9
10
  }
10
11
 
12
+ interface IntegrationResult {
13
+ readonly root: string
14
+ readonly files: readonly {
15
+ readonly name:
16
+ | "agents"
17
+ | "opencode"
18
+ | "opencode-command"
19
+ | "opencode-plugin"
20
+ readonly path: string
21
+ readonly state: string
22
+ readonly diagnostic: string
23
+ readonly remediation: string | null
24
+ readonly changed?: boolean
25
+ }[]
26
+ }
27
+
28
+ const integrationNames = {
29
+ agents: "Agent instructions",
30
+ opencode: "OpenCode config",
31
+ "opencode-command": "OpenCode /agency command",
32
+ "opencode-plugin": "OpenCode checkout skills",
33
+ } as const
34
+
35
+ const logHumanResult = (
36
+ log: (message: string) => void,
37
+ subcommand: "status" | "sync",
38
+ result: IntegrationResult,
39
+ ) => {
40
+ log(`Integration ${subcommand}: ${result.root}`)
41
+ for (const file of result.files) {
42
+ log("")
43
+ log(
44
+ `${integrationNames[file.name]}: ${file.changed ? "synced" : file.state}`,
45
+ )
46
+ log(` Path: ${relative(result.root, file.path)}`)
47
+ log(` ${file.diagnostic}`)
48
+ if (file.remediation) log(` Action: ${file.remediation}`)
49
+ }
50
+ }
51
+
11
52
  export const integration = (options: IntegrationOptions) =>
12
53
  Effect.gen(function* () {
13
54
  const service = yield* IntegrationService
@@ -21,10 +62,7 @@ export const integration = (options: IntegrationOptions) =>
21
62
  log(JSON.stringify(result, null, 2))
22
63
  return
23
64
  }
24
- for (const file of result.files) {
25
- log(`${file.name}\t${file.state}\t${file.path}\t${file.diagnostic}`)
26
- if (file.remediation) log(` Remediation: ${file.remediation}`)
27
- }
65
+ logHumanResult(log, "status", result)
28
66
  return
29
67
  }
30
68
 
@@ -34,12 +72,7 @@ export const integration = (options: IntegrationOptions) =>
34
72
  log(JSON.stringify(result, null, 2))
35
73
  return
36
74
  }
37
- for (const file of result.files) {
38
- log(
39
- `${file.name}\t${file.changed ? "synced" : file.state}\t${file.path}\t${file.diagnostic}`,
40
- )
41
- if (file.remediation) log(` Remediation: ${file.remediation}`)
42
- }
75
+ logHumanResult(log, "sync", result)
43
76
  return
44
77
  }
45
78
 
@@ -55,7 +88,8 @@ Usage: agency integration <subcommand>
55
88
 
56
89
  Inspect or explicitly synchronize managed agent integration files. OpenCode
57
90
  launches load the managed instructions and project config at runtime, and expose
58
- the managed /agency command, without changing Agency write authority.
91
+ the managed /agency command and writable-checkout skills, without changing
92
+ Agency write authority.
59
93
 
60
94
  Subcommands:
61
95
  status Report file state, access diagnostics, and safe remediation
@@ -929,6 +929,23 @@ describe("work command", () => {
929
929
  expect(printed.environment.OPENCODE_CONFIG_CONTENT).toBe(workbasePermission)
930
930
  })
931
931
 
932
+ test("provides the writable checkout to plugins without changing the project", async () => {
933
+ const harness = createHarness()
934
+ await harness.run({ taskId: "example", opencode: true })
935
+
936
+ expect(harness.launches[0]).toEqual({
937
+ cli: "opencode",
938
+ args: ["opencode"],
939
+ cwd: taskDirectory,
940
+ })
941
+ expect(harness.launchEnvironments[0]?.AGENCY_WRITABLE_CHECKOUT).toBe(
942
+ "/workbase/tasks/example/code/agency",
943
+ )
944
+ expect(harness.launchEnvironments[0]?.OPENCODE_CONFIG_CONTENT).toBe(
945
+ workbasePermission,
946
+ )
947
+ })
948
+
932
949
  test("does not override customized OpenCode access policy", async () => {
933
950
  const harness = createHarness({ opencodeIntegrationState: "customized" })
934
951
  await harness.run({ taskId: "example", opencode: true })
@@ -239,6 +239,7 @@ export const work = (
239
239
  (target.status !== undefined && target.status !== "open"))
240
240
  let prompt: string
241
241
  let launchPath: string
242
+ let writablePath: string | undefined
242
243
  if (target.kind === "epic") {
243
244
  prompt = `Work on the epic. Read ${target.path}.`
244
245
  launchPath = dirname(target.path)
@@ -264,6 +265,7 @@ export const work = (
264
265
  ? `${action} the task. Read ${workspace.taskPath} and ${workspace.phasePath}.`
265
266
  : `${action} the task. Read ${workspace.taskPath}.`
266
267
  launchPath = dirname(workspace.taskPath)
268
+ writablePath = workspace.writablePath
267
269
  }
268
270
 
269
271
  const explicitlyRequested = Boolean(
@@ -334,6 +336,7 @@ export const work = (
334
336
  ...resolved.environment,
335
337
  ...runnerEnvironment(runner, variables),
336
338
  }
339
+ if (writablePath) environment.AGENCY_WRITABLE_CHECKOUT = writablePath
337
340
  if (runner === "opencode" && managedOpencode) {
338
341
  environment.OPENCODE_CONFIG_CONTENT = JSON.stringify({
339
342
  permission: {
@@ -10,6 +10,10 @@ import {
10
10
  managedWorkbaseOpencodeCommand,
11
11
  } from "../workbase/opencode-command-file"
12
12
  import { managedWorkbaseOpencode } from "../workbase/opencode-file"
13
+ import {
14
+ canUpdateManagedWorkbaseOpencodePlugin,
15
+ managedWorkbaseOpencodePlugin,
16
+ } from "../workbase/opencode-plugin-file"
13
17
  import { IntegrationService } from "./IntegrationService"
14
18
 
15
19
  const write = async (root: string, path: string, content: string) => {
@@ -51,6 +55,7 @@ describe("IntegrationService", () => {
51
55
  "missing",
52
56
  "missing",
53
57
  "missing",
58
+ "missing",
54
59
  ])
55
60
  expect(await Bun.file(join(root, ".agency/AGENTS.md")).exists()).toBe(false)
56
61
 
@@ -61,10 +66,16 @@ describe("IntegrationService", () => {
61
66
  ".opencode/command/agency.md",
62
67
  managedWorkbaseOpencodeCommand,
63
68
  )
69
+ await write(
70
+ root,
71
+ ".opencode/plugin/agency-repository-skills.ts",
72
+ managedWorkbaseOpencodePlugin,
73
+ )
64
74
  expect((await status(root)).files.map(({ state }) => state)).toEqual([
65
75
  "managed",
66
76
  "managed",
67
77
  "managed",
78
+ "managed",
68
79
  ])
69
80
  })
70
81
 
@@ -80,9 +91,39 @@ describe("IntegrationService", () => {
80
91
  "customized",
81
92
  "drifted",
82
93
  "missing",
94
+ "missing",
83
95
  ])
84
96
  })
85
97
 
98
+ test("generates a dynamic repository skill plugin", () => {
99
+ expect(managedWorkbaseOpencodePlugin).toContain(
100
+ "process.env.AGENCY_WRITABLE_CHECKOUT",
101
+ )
102
+ expect(managedWorkbaseOpencodePlugin).toContain(
103
+ '["agency", "context", ".", "--compact", "--json"]',
104
+ )
105
+ expect(managedWorkbaseOpencodePlugin).toContain(
106
+ 'join(checkout, ".claude", "skills")',
107
+ )
108
+ expect(managedWorkbaseOpencodePlugin).toContain(
109
+ 'join(checkout, ".agents", "skills")',
110
+ )
111
+ expect(managedWorkbaseOpencodePlugin).toContain(
112
+ 'join(checkout, ".opencode", "skills")',
113
+ )
114
+ expect(managedWorkbaseOpencodePlugin).toContain(
115
+ "config.skills.paths = [...new Set",
116
+ )
117
+ expect(
118
+ canUpdateManagedWorkbaseOpencodePlugin(managedWorkbaseOpencodePlugin),
119
+ ).toBe(true)
120
+ expect(
121
+ canUpdateManagedWorkbaseOpencodePlugin(
122
+ managedWorkbaseOpencodePlugin.replace("config.skills ??= {}", ""),
123
+ ),
124
+ ).toBe(false)
125
+ })
126
+
86
127
  test("generates a positional OpenCode command for Agency workflows", () => {
87
128
  expect(managedWorkbaseOpencodeCommand).toContain(
88
129
  "description: Operate Agency work",
@@ -247,6 +288,7 @@ describe("IntegrationService", () => {
247
288
  { name: "agents", state: "customized", changed: false },
248
289
  { name: "opencode", state: "managed", changed: true },
249
290
  { name: "opencode-command", state: "managed", changed: true },
291
+ { name: "opencode-plugin", state: "managed", changed: true },
250
292
  ])
251
293
  expect(await Bun.file(join(root, "AGENTS.md")).text()).toBe(
252
294
  customRootAgents,
@@ -260,6 +302,11 @@ describe("IntegrationService", () => {
260
302
  expect(
261
303
  await Bun.file(join(root, ".opencode/command/agency.md")).text(),
262
304
  ).toBe(managedWorkbaseOpencodeCommand)
305
+ expect(
306
+ await Bun.file(
307
+ join(root, ".opencode/plugin/agency-repository-skills.ts"),
308
+ ).text(),
309
+ ).toBe(managedWorkbaseOpencodePlugin)
263
310
 
264
311
  await unlink(join(root, ".agency/AGENTS.md"))
265
312
  const second = await sync(root)
@@ -272,6 +319,33 @@ describe("IntegrationService", () => {
272
319
  )
273
320
  })
274
321
 
322
+ test("preserves user-owned checkout-skill plugins at either supported path", async () => {
323
+ const custom = "export default async () => ({})\n"
324
+ await write(root, ".opencode/plugins/agency-repository-skills.ts", custom)
325
+
326
+ let result = await sync(root)
327
+ expect(result.files[3]).toMatchObject({
328
+ name: "opencode-plugin",
329
+ path: join(root, ".opencode/plugins/agency-repository-skills.ts"),
330
+ state: "customized",
331
+ changed: false,
332
+ })
333
+ expect(
334
+ await Bun.file(
335
+ join(root, ".opencode/plugin/agency-repository-skills.ts"),
336
+ ).exists(),
337
+ ).toBe(false)
338
+
339
+ await unlink(join(root, ".opencode/plugins/agency-repository-skills.ts"))
340
+ await write(root, ".opencode/plugin/agency-repository-skills.ts", custom)
341
+ result = await sync(root)
342
+ expect(result.files[3]).toMatchObject({
343
+ path: join(root, ".opencode/plugin/agency-repository-skills.ts"),
344
+ state: "customized",
345
+ changed: false,
346
+ })
347
+ })
348
+
275
349
  test("preserves user-owned OpenCode commands at either supported path", async () => {
276
350
  const custom = "---\ndescription: Custom Agency command\n---\n\nCustom.\n"
277
351
  await write(root, ".opencode/commands/agency.md", custom)
@@ -14,11 +14,15 @@ import {
14
14
  canUpdateManagedWorkbaseOpencodeCommand,
15
15
  managedWorkbaseOpencodeCommand,
16
16
  } from "../workbase/opencode-command-file"
17
+ import {
18
+ canUpdateManagedWorkbaseOpencodePlugin,
19
+ managedWorkbaseOpencodePlugin,
20
+ } from "../workbase/opencode-plugin-file"
17
21
 
18
22
  type IntegrationFileState = "managed" | "customized" | "missing" | "drifted"
19
23
 
20
24
  interface IntegrationFileStatus {
21
- readonly name: "agents" | "opencode" | "opencode-command"
25
+ readonly name: "agents" | "opencode" | "opencode-command" | "opencode-plugin"
22
26
  readonly path: string
23
27
  readonly state: IntegrationFileState
24
28
  readonly diagnostic: string
@@ -75,6 +79,26 @@ const describe = (
75
79
  "Run 'agency integration sync' to install the managed /agency command.",
76
80
  }
77
81
  }
82
+ if (name === "opencode-plugin") {
83
+ return state === "managed"
84
+ ? {
85
+ diagnostic:
86
+ "Agency's managed OpenCode plugin exposes writable-checkout skills to work-item sessions.",
87
+ remediation: null,
88
+ }
89
+ : state === "customized"
90
+ ? {
91
+ diagnostic:
92
+ "A user-owned OpenCode checkout-skill plugin is present and was preserved.",
93
+ remediation: null,
94
+ }
95
+ : {
96
+ diagnostic:
97
+ "The managed OpenCode checkout-skill plugin needs synchronization.",
98
+ remediation:
99
+ "Run 'agency integration sync' to expose writable-checkout skills in OpenCode.",
100
+ }
101
+ }
78
102
 
79
103
  return state === "missing" || state === "drifted"
80
104
  ? {
@@ -123,6 +147,16 @@ const inspect = (root: string) =>
123
147
  const opencodeJsonPath = join(opencodeDirectory, "opencode.json")
124
148
  const commandPath = join(opencodeDirectory, "command", "agency.md")
125
149
  const pluralCommandPath = join(opencodeDirectory, "commands", "agency.md")
150
+ const pluginPath = join(
151
+ opencodeDirectory,
152
+ "plugin",
153
+ "agency-repository-skills.ts",
154
+ )
155
+ const pluralPluginPath = join(
156
+ opencodeDirectory,
157
+ "plugins",
158
+ "agency-repository-skills.ts",
159
+ )
126
160
  const files: IntegrationFileStatus[] = []
127
161
 
128
162
  files.push(
@@ -183,6 +217,27 @@ const inspect = (root: string) =>
183
217
  files.push(fileStatus("opencode-command", commandPath, "missing"))
184
218
  }
185
219
 
220
+ if ((yield* fs.readSymlinkTarget(pluginPath)) !== null) {
221
+ files.push(fileStatus("opencode-plugin", pluginPath, "customized"))
222
+ } else if (
223
+ (yield* fs.readSymlinkTarget(pluralPluginPath)) !== null ||
224
+ (yield* fs.exists(pluralPluginPath))
225
+ ) {
226
+ files.push(fileStatus("opencode-plugin", pluralPluginPath, "customized"))
227
+ } else if (yield* fs.exists(pluginPath)) {
228
+ files.push(
229
+ classify(
230
+ "opencode-plugin",
231
+ pluginPath,
232
+ yield* fs.readFile(pluginPath),
233
+ managedWorkbaseOpencodePlugin,
234
+ canUpdateManagedWorkbaseOpencodePlugin,
235
+ ),
236
+ )
237
+ } else {
238
+ files.push(fileStatus("opencode-plugin", pluginPath, "missing"))
239
+ }
240
+
186
241
  return files
187
242
  })
188
243
 
@@ -232,9 +287,12 @@ export class IntegrationService extends Effect.Service<IntegrationService>()(
232
287
  } else if (status.name === "opencode") {
233
288
  yield* fs.createDirectory(join(root, ".opencode"))
234
289
  yield* fs.writeFile(status.path, managedWorkbaseOpencode)
235
- } else {
290
+ } else if (status.name === "opencode-command") {
236
291
  yield* fs.createDirectory(join(root, ".opencode", "command"))
237
292
  yield* fs.writeFile(status.path, managedWorkbaseOpencodeCommand)
293
+ } else {
294
+ yield* fs.createDirectory(join(root, ".opencode", "plugin"))
295
+ yield* fs.writeFile(status.path, managedWorkbaseOpencodePlugin)
238
296
  }
239
297
  }
240
298
  files.push({
@@ -72,7 +72,8 @@ a refinement loop, or pausing or handing off completed implementation work):
72
72
  `missing` generated files. Agency keeps these instructions in
73
73
  `.agency/AGENTS.md`, and its managed OpenCode config loads them automatically.
74
74
  It also installs `.opencode/command/agency.md`, which provides safe `/agency`
75
- workflows for active OpenCode sessions.
75
+ workflows for active OpenCode sessions, and a managed plugin that exposes skills
76
+ from the authoritative writable checkout without changing the project root.
76
77
  The workbase-root `AGENTS.md`, when present, belongs entirely to the workbase
77
78
  owner and composes with these instructions through OpenCode's normal discovery.
78
79
  `agency integration sync` updates only missing or checksum-safe drifted managed
@@ -81,6 +82,9 @@ files, and `agency work` reconciles them before launching an agent.
81
82
 
82
83
  OpenCode can access the complete workbase tree, but this filesystem permission
83
84
  does not expand Agency write authority beyond the checkout reported by
84
- `agency context`. OpenCode discovers the managed project config from task and
85
- epic launch directories. No machine-specific path or runtime permission overlay
86
- is required; agents must follow the authority reported by `agency context`.
85
+ `agency context`. OpenCode remains rooted in the task or epic directory so the
86
+ workbase instructions and config compose normally. The managed plugin resolves
87
+ the writable checkout from launch context or `agency context`, then adds its
88
+ supported skill directories through `skills.paths`; this does not make other
89
+ checkout-local OpenCode configuration authoritative. Agents must follow the
90
+ authority reported by `agency context`.
@@ -0,0 +1,61 @@
1
+ import { createHash } from "node:crypto"
2
+
3
+ const managedHeaderPattern =
4
+ /^\/\/ agency-managed: sha256=([a-f0-9]{64})\r?\n\r?\n/
5
+
6
+ const checksum = (content: string) =>
7
+ createHash("sha256").update(content).digest("hex")
8
+
9
+ const body = `import { existsSync } from "node:fs"
10
+ import { join } from "node:path"
11
+ import type { Plugin } from "@opencode-ai/plugin"
12
+
13
+ const contextCheckout = async (directory: string) => {
14
+ const task = process.env.AGENCY_TASK_ID
15
+ const phase = process.env.AGENCY_PHASE_ID
16
+ const args = task
17
+ ? ["agency", "context", "--task", task, ...(phase ? ["--phase", phase] : []), "--compact", "--json"]
18
+ : ["agency", "context", ".", "--compact", "--json"]
19
+ const child = Bun.spawn(args, { cwd: directory, stdout: "pipe", stderr: "ignore" })
20
+ const output = await new Response(child.stdout).text()
21
+ if ((await child.exited) !== 0) return
22
+ const envelope = JSON.parse(output)
23
+ if (envelope.ok !== true) return
24
+ return envelope.result?.authority?.writable?.checkoutPath as string | undefined
25
+ }
26
+
27
+ const plugin: Plugin = async ({ directory }) => ({
28
+ config: async (config) => {
29
+ const checkout =
30
+ process.env.AGENCY_WRITABLE_CHECKOUT ??
31
+ (await contextCheckout(directory).catch(() => undefined))
32
+ if (!checkout) return
33
+
34
+ const paths = [
35
+ join(checkout, ".claude", "skills"),
36
+ join(checkout, ".agents", "skills"),
37
+ join(checkout, ".opencode", "skill"),
38
+ join(checkout, ".opencode", "skills"),
39
+ ].filter(existsSync)
40
+ if (paths.length === 0) return
41
+
42
+ config.skills ??= {}
43
+ config.skills.paths = [...new Set([...(config.skills.paths ?? []), ...paths])]
44
+ },
45
+ })
46
+
47
+ export default plugin
48
+ `
49
+
50
+ const renderManagedWorkbaseOpencodePlugin = (content: string) =>
51
+ `// agency-managed: sha256=${checksum(content)}\n\n${content}`
52
+
53
+ export const managedWorkbaseOpencodePlugin =
54
+ renderManagedWorkbaseOpencodePlugin(body)
55
+
56
+ export const canUpdateManagedWorkbaseOpencodePlugin = (content: string) => {
57
+ const match = content.match(managedHeaderPattern)
58
+ if (!match?.[1]) return false
59
+
60
+ return checksum(content.slice(match[0].length)) === match[1]
61
+ }