@markjaquith/agency 2.44.0 → 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 +12 -4
- package/package.json +1 -1
- package/src/cli.test.ts +113 -10
- package/src/commands/init.test.ts +5 -0
- package/src/commands/integration.test.ts +22 -2
- package/src/commands/integration.ts +8 -2
- package/src/commands/work.test.ts +17 -0
- package/src/commands/work.ts +3 -0
- package/src/services/IntegrationService.test.ts +74 -0
- package/src/services/IntegrationService.ts +60 -2
- package/src/workbase/AGENTS.md +8 -4
- package/src/workbase/opencode-plugin-file.ts +61 -0
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
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
`
|
|
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
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", "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
})
|
|
@@ -79,7 +84,12 @@ OpenCode config: missing
|
|
|
79
84
|
OpenCode /agency command: missing
|
|
80
85
|
Path: .opencode/command/agency.md
|
|
81
86
|
The managed OpenCode /agency command needs synchronization.
|
|
82
|
-
Action: Run 'agency integration sync' to install the managed /agency command
|
|
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.`)
|
|
83
93
|
})
|
|
84
94
|
|
|
85
95
|
test("explicitly synchronizes integration files", async () => {
|
|
@@ -91,6 +101,7 @@ OpenCode /agency command: missing
|
|
|
91
101
|
{ name: "agents", state: "managed", changed: true },
|
|
92
102
|
{ name: "opencode", state: "managed", changed: true },
|
|
93
103
|
{ name: "opencode-command", state: "managed", changed: true },
|
|
104
|
+
{ name: "opencode-plugin", state: "managed", changed: true },
|
|
94
105
|
])
|
|
95
106
|
expect(await Bun.file(join(root, "AGENTS.md")).exists()).toBe(false)
|
|
96
107
|
expect(await Bun.file(join(root, ".agency/AGENTS.md")).exists()).toBe(true)
|
|
@@ -100,6 +111,11 @@ OpenCode /agency command: missing
|
|
|
100
111
|
expect(
|
|
101
112
|
await Bun.file(join(root, ".opencode/command/agency.md")).exists(),
|
|
102
113
|
).toBe(true)
|
|
114
|
+
expect(
|
|
115
|
+
await Bun.file(
|
|
116
|
+
join(root, ".opencode/plugin/agency-repository-skills.ts"),
|
|
117
|
+
).exists(),
|
|
118
|
+
).toBe(true)
|
|
103
119
|
})
|
|
104
120
|
|
|
105
121
|
test("formats integration sync results for people", async () => {
|
|
@@ -119,6 +135,10 @@ OpenCode config: synced
|
|
|
119
135
|
|
|
120
136
|
OpenCode /agency command: synced
|
|
121
137
|
Path: .opencode/command/agency.md
|
|
122
|
-
Agency's managed OpenCode /agency command is current
|
|
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.`)
|
|
123
143
|
})
|
|
124
144
|
})
|
|
@@ -12,7 +12,11 @@ interface IntegrationOptions extends BaseCommandOptions {
|
|
|
12
12
|
interface IntegrationResult {
|
|
13
13
|
readonly root: string
|
|
14
14
|
readonly files: readonly {
|
|
15
|
-
readonly name:
|
|
15
|
+
readonly name:
|
|
16
|
+
| "agents"
|
|
17
|
+
| "opencode"
|
|
18
|
+
| "opencode-command"
|
|
19
|
+
| "opencode-plugin"
|
|
16
20
|
readonly path: string
|
|
17
21
|
readonly state: string
|
|
18
22
|
readonly diagnostic: string
|
|
@@ -25,6 +29,7 @@ const integrationNames = {
|
|
|
25
29
|
agents: "Agent instructions",
|
|
26
30
|
opencode: "OpenCode config",
|
|
27
31
|
"opencode-command": "OpenCode /agency command",
|
|
32
|
+
"opencode-plugin": "OpenCode checkout skills",
|
|
28
33
|
} as const
|
|
29
34
|
|
|
30
35
|
const logHumanResult = (
|
|
@@ -83,7 +88,8 @@ Usage: agency integration <subcommand>
|
|
|
83
88
|
|
|
84
89
|
Inspect or explicitly synchronize managed agent integration files. OpenCode
|
|
85
90
|
launches load the managed instructions and project config at runtime, and expose
|
|
86
|
-
the managed /agency command, without changing
|
|
91
|
+
the managed /agency command and writable-checkout skills, without changing
|
|
92
|
+
Agency write authority.
|
|
87
93
|
|
|
88
94
|
Subcommands:
|
|
89
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 })
|
package/src/commands/work.ts
CHANGED
|
@@ -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({
|
package/src/workbase/AGENTS.md
CHANGED
|
@@ -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
|
|
85
|
-
|
|
86
|
-
|
|
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
|
+
}
|