@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 +19 -15
- package/package.json +1 -1
- package/src/cli.test.ts +71 -37
- package/src/commands/doctor.test.ts +3 -2
- package/src/commands/init.test.ts +3 -1
- package/src/commands/integration.test.ts +3 -2
- package/src/commands/work.test.ts +10 -49
- package/src/commands/work.ts +1 -35
- package/src/services/FileSystemService.ts +11 -0
- package/src/services/IntegrationService.test.ts +52 -10
- package/src/services/IntegrationService.ts +30 -9
- package/src/workbase/AGENTS.md +10 -7
- package/src/workbase/opencode-file.ts +1 -0
package/README.md
CHANGED
|
@@ -47,9 +47,11 @@ and customized documents remain valid.
|
|
|
47
47
|
|
|
48
48
|
```text
|
|
49
49
|
workbase/
|
|
50
|
-
AGENTS.md #
|
|
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
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|
220
|
-
|
|
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
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
|
|
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
|
-
|
|
946
|
-
|
|
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).
|
|
1015
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
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).
|
|
490
|
-
"/workbase/.opencode/opencode.jsonc",
|
|
491
|
-
)
|
|
489
|
+
expect(harness.launchEnvironments[0]?.OPENCODE_CONFIG).toBeUndefined()
|
|
492
490
|
expect(
|
|
493
|
-
|
|
494
|
-
).
|
|
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).
|
|
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).
|
|
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).
|
|
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).
|
|
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("
|
|
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).
|
|
947
|
-
|
|
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 () => {
|
package/src/commands/work.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
155
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
178
|
+
const needsWrite =
|
|
162
179
|
status.state === "missing" || status.state === "drifted"
|
|
163
|
-
if (
|
|
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
|
-
|
|
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
|
}),
|
package/src/workbase/AGENTS.md
CHANGED
|
@@ -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.
|
|
71
|
-
|
|
72
|
-
|
|
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`.
|
|
77
|
-
|
|
78
|
-
|
|
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`.
|