@markjaquith/agency 2.30.0 → 2.30.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 +12 -6
- package/package.json +1 -1
- package/skills/agency/SKILL.md +5 -0
- package/skills/agency/references/commands.md +6 -1
- package/skills/agency/references/recipes.md +3 -1
- package/src/cli.test.ts +217 -0
- package/src/commands/doctor.test.ts +22 -0
- package/src/commands/init.test.ts +3 -2
- package/src/commands/integration.test.ts +21 -1
- package/src/commands/integration.ts +8 -4
- package/src/commands/work.test.ts +74 -1
- package/src/commands/work.ts +26 -3
- package/src/services/DoctorService.ts +10 -5
- package/src/services/IntegrationService.test.ts +37 -12
- package/src/services/IntegrationService.ts +70 -21
- package/src/workbase/AGENTS.md +4 -1
- package/src/workbase/opencode-file.ts +3 -13
package/README.md
CHANGED
|
@@ -43,7 +43,7 @@ frontmatter; prose below it supplies human and agent context.
|
|
|
43
43
|
workbase/
|
|
44
44
|
AGENTS.md # managed workbase instructions
|
|
45
45
|
.opencode/
|
|
46
|
-
opencode.jsonc # managed
|
|
46
|
+
opencode.jsonc # managed whole-workbase reference
|
|
47
47
|
agency.json # tracked config and portable repository declarations
|
|
48
48
|
repos/ # ignored local materializations
|
|
49
49
|
frontend/ # bare Git repository or symlink
|
|
@@ -76,11 +76,13 @@ Agency keeps discovery and other observational commands read-only. Run
|
|
|
76
76
|
files or refresh checksum-safe managed files. Customized files are reported but
|
|
77
77
|
never overwritten.
|
|
78
78
|
|
|
79
|
-
The OpenCode config advertises
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
79
|
+
The OpenCode config advertises the complete workbase as one portable relative
|
|
80
|
+
reference. `agency work` sets `OPENCODE_CONFIG` to the managed file and injects
|
|
81
|
+
runtime-only absolute rules that allow external access across the workbase while
|
|
82
|
+
denying edits outside the execution unit's writable checkout. This makes the
|
|
83
|
+
config effective from nested task and phase Git checkouts without persisting a
|
|
84
|
+
machine-specific path or granting access outside the workbase. Bash and Agency
|
|
85
|
+
operations must still follow the write authority reported by `agency context`.
|
|
84
86
|
|
|
85
87
|
Repository aliases and canonical fetch remotes are declared in tracked
|
|
86
88
|
`agency.json`; local bare clones and symlinks remain ignored under
|
|
@@ -201,6 +203,10 @@ Every runner receives the same `AGENCY_RUNNER`, `AGENCY_CLAIMANT`,
|
|
|
201
203
|
`AGENCY_SESSION_ID`, `AGENCY_CLAIM_REVISION`, `AGENCY_WORKBASE`, `AGENCY_TARGET`,
|
|
202
204
|
`AGENCY_TASK_ID`, `AGENCY_PHASE_ID`, and `AGENCY_PROMPT` environment. Configured
|
|
203
205
|
environment is added without overriding these normalized values.
|
|
206
|
+
The `opencode` runner additionally receives `OPENCODE_CONFIG` for the workbase's
|
|
207
|
+
managed integration and `OPENCODE_CONFIG_CONTENT` with runtime-only,
|
|
208
|
+
workbase-scoped access and edit rules. These values keep the Git-synced config
|
|
209
|
+
portable while providing whole-workbase read access at every launch location.
|
|
204
210
|
`--print-command` prints the exact cwd and argv plus non-secret environment keys
|
|
205
211
|
without launching the runner.
|
|
206
212
|
|
package/package.json
CHANGED
package/skills/agency/SKILL.md
CHANGED
|
@@ -132,6 +132,11 @@ integration files, then selects work and checks readiness. For an execution unit
|
|
|
132
132
|
it materializes managed checkouts, claims the unit, marks it working, and starts
|
|
133
133
|
the selected built-in or configured runner. Epic and multi-phase task launches
|
|
134
134
|
start in orchestration context without materializing or claiming execution work.
|
|
135
|
+
OpenCode launches expose the managed config file and inject runtime-only access
|
|
136
|
+
rules scoped to the workbase. These grant visibility from orchestration
|
|
137
|
+
directories and nested execution checkouts while denying direct edits outside
|
|
138
|
+
the writable checkout. They do not make the Git-synced config machine-specific
|
|
139
|
+
or replace write authority from `agency context`.
|
|
135
140
|
|
|
136
141
|
An agent already running in an Agency checkout must not call `agency work` to
|
|
137
142
|
start itself again. It should inspect context, perform the assigned work, and
|
|
@@ -26,7 +26,9 @@ checkout descendants, or a task ID. Complete output includes prose and Git
|
|
|
26
26
|
details; `--compact` intentionally omits them. `graph` computes readiness before
|
|
27
27
|
applying filters. `doctor` discovers required tools, integrations, repositories,
|
|
28
28
|
refs, worktrees, permissions, drift, and optional runner capabilities. `sync` is
|
|
29
|
-
observational unless `--apply` is explicit.
|
|
29
|
+
observational unless `--apply` is explicit. Integration status includes safe
|
|
30
|
+
remediation when customized OpenCode config prevents Agency from guaranteeing
|
|
31
|
+
whole-workbase access.
|
|
30
32
|
|
|
31
33
|
Where `[filters]` appears, use repeatable `--status <status>` and `--repository
|
|
32
34
|
<alias>`, plus `--ready` or `--blocked` and `--pr` or `--no-pr`. Each pair is
|
|
@@ -155,6 +157,9 @@ agency pr create <task-id> [phase-id] [--draft] [--force] [--json]
|
|
|
155
157
|
`work` is a launch flow, not an active-agent step. It synchronizes managed
|
|
156
158
|
integration files before launch. Execution targets are materialized and claimed;
|
|
157
159
|
epics and multi-phase tasks launch in orchestration context without those steps.
|
|
160
|
+
The OpenCode runner receives a runtime managed-config path plus absolute access
|
|
161
|
+
and edit rules scoped to the workbase; none are persisted, and `context` still
|
|
162
|
+
defines write authority.
|
|
158
163
|
`--print-command` suppresses only the final launch, so execution targets are still
|
|
159
164
|
materialized and claimed before the command is printed. `work prepare`
|
|
160
165
|
materializes without launching or changing status. Destructive remove and
|
|
@@ -89,7 +89,9 @@ agency work tasks/checkout/phases/api
|
|
|
89
89
|
|
|
90
90
|
`agency work` is intentionally last: it synchronizes managed integration files,
|
|
91
91
|
checks readiness, materializes worktrees, creates a claim, marks this execution
|
|
92
|
-
unit working, and launches the runner.
|
|
92
|
+
unit working, and launches the runner. OpenCode receives whole-workbase read
|
|
93
|
+
visibility through runtime-only config and permission values; this does not
|
|
94
|
+
expand the writable checkout reported by `agency context`.
|
|
93
95
|
|
|
94
96
|
## Active Agent: Execute Assigned Work
|
|
95
97
|
|
package/src/cli.test.ts
CHANGED
|
@@ -829,6 +829,223 @@ status: open
|
|
|
829
829
|
).toBe("example\n")
|
|
830
830
|
})
|
|
831
831
|
|
|
832
|
+
test.skipIf(Bun.which("opencode") === null)(
|
|
833
|
+
"provides effective whole-workbase OpenCode access from every launch topology",
|
|
834
|
+
async () => {
|
|
835
|
+
const parent = await createTempDir()
|
|
836
|
+
tempDirs.push(parent)
|
|
837
|
+
const root = join(parent, "workbase")
|
|
838
|
+
const source = join(parent, "source")
|
|
839
|
+
expect(
|
|
840
|
+
Bun.spawnSync(["git", "init", "--initial-branch=main", source])
|
|
841
|
+
.exitCode,
|
|
842
|
+
).toBe(0)
|
|
843
|
+
await Bun.write(join(source, "README.md"), "example\n")
|
|
844
|
+
for (const args of [
|
|
845
|
+
["config", "user.email", "test@example.com"],
|
|
846
|
+
["config", "user.name", "Test"],
|
|
847
|
+
["add", "README.md"],
|
|
848
|
+
["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
|
|
849
|
+
]) {
|
|
850
|
+
expect(Bun.spawnSync(["git", "-C", source, ...args]).exitCode).toBe(0)
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
parseJson(await runCli(["init", root, "--json"], parent))
|
|
854
|
+
parseJson(
|
|
855
|
+
await runCli(["repo", "link", "agency", source, "--json"], root),
|
|
856
|
+
)
|
|
857
|
+
parseJson(
|
|
858
|
+
await runCli(
|
|
859
|
+
[
|
|
860
|
+
"epic",
|
|
861
|
+
"create",
|
|
862
|
+
"delivery",
|
|
863
|
+
"--ticket-url",
|
|
864
|
+
"https://example.com/delivery",
|
|
865
|
+
"--repo",
|
|
866
|
+
"agency:main",
|
|
867
|
+
"--json",
|
|
868
|
+
],
|
|
869
|
+
root,
|
|
870
|
+
),
|
|
871
|
+
)
|
|
872
|
+
for (const [id, branch] of [
|
|
873
|
+
["example", "feat/example"],
|
|
874
|
+
["sibling", "feat/sibling"],
|
|
875
|
+
] as const) {
|
|
876
|
+
parseJson(
|
|
877
|
+
await runCli(
|
|
878
|
+
[
|
|
879
|
+
"task",
|
|
880
|
+
"create",
|
|
881
|
+
id,
|
|
882
|
+
"--repo",
|
|
883
|
+
"agency",
|
|
884
|
+
"--branch",
|
|
885
|
+
branch,
|
|
886
|
+
"--base",
|
|
887
|
+
"main",
|
|
888
|
+
"--epic",
|
|
889
|
+
"delivery",
|
|
890
|
+
"--json",
|
|
891
|
+
],
|
|
892
|
+
root,
|
|
893
|
+
),
|
|
894
|
+
)
|
|
895
|
+
}
|
|
896
|
+
parseJson(
|
|
897
|
+
await runCli(
|
|
898
|
+
["task", "create", "pipeline", "--multi-phase", "--json"],
|
|
899
|
+
root,
|
|
900
|
+
),
|
|
901
|
+
)
|
|
902
|
+
parseJson(
|
|
903
|
+
await runCli(
|
|
904
|
+
[
|
|
905
|
+
"phase",
|
|
906
|
+
"create",
|
|
907
|
+
"pipeline",
|
|
908
|
+
"build",
|
|
909
|
+
"--repo",
|
|
910
|
+
"agency",
|
|
911
|
+
"--branch",
|
|
912
|
+
"feat/pipeline-build",
|
|
913
|
+
"--base",
|
|
914
|
+
"main",
|
|
915
|
+
"--json",
|
|
916
|
+
],
|
|
917
|
+
root,
|
|
918
|
+
),
|
|
919
|
+
)
|
|
920
|
+
|
|
921
|
+
const taskWorkspace = parseJson(
|
|
922
|
+
await runCli(["work", "prepare", "example", "--json"], root),
|
|
923
|
+
)
|
|
924
|
+
const phaseWorkspace = parseJson(
|
|
925
|
+
await runCli(
|
|
926
|
+
[
|
|
927
|
+
"work",
|
|
928
|
+
"prepare",
|
|
929
|
+
"--task",
|
|
930
|
+
"pipeline",
|
|
931
|
+
"--phase",
|
|
932
|
+
"build",
|
|
933
|
+
"--json",
|
|
934
|
+
],
|
|
935
|
+
root,
|
|
936
|
+
),
|
|
937
|
+
)
|
|
938
|
+
const synced = parseJson(
|
|
939
|
+
await runCli(["integration", "sync", "--json"], root),
|
|
940
|
+
)
|
|
941
|
+
expect(
|
|
942
|
+
synced.files.every((file: { changed: boolean }) => !file.changed),
|
|
943
|
+
).toBe(true)
|
|
944
|
+
|
|
945
|
+
const workbaseRoot = await realpath(root)
|
|
946
|
+
const config = await Bun.file(
|
|
947
|
+
join(workbaseRoot, ".opencode/opencode.jsonc"),
|
|
948
|
+
).text()
|
|
949
|
+
expect(config).not.toContain(workbaseRoot)
|
|
950
|
+
const documents = [
|
|
951
|
+
join(workbaseRoot, "tasks/example/TASK.md"),
|
|
952
|
+
join(workbaseRoot, "epics/delivery/EPIC.md"),
|
|
953
|
+
join(workbaseRoot, "tasks/sibling/TASK.md"),
|
|
954
|
+
]
|
|
955
|
+
for (const document of documents) {
|
|
956
|
+
expect(await Bun.file(document).exists()).toBe(true)
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
const launches = [
|
|
960
|
+
{
|
|
961
|
+
args: ["--task", "example"],
|
|
962
|
+
cwd: taskWorkspace.writablePath,
|
|
963
|
+
writable: true,
|
|
964
|
+
},
|
|
965
|
+
{
|
|
966
|
+
args: ["--task", "pipeline", "--phase", "build"],
|
|
967
|
+
cwd: phaseWorkspace.writablePath,
|
|
968
|
+
writable: true,
|
|
969
|
+
},
|
|
970
|
+
{
|
|
971
|
+
args: ["--epic", "delivery"],
|
|
972
|
+
cwd: join(workbaseRoot, "epics/delivery"),
|
|
973
|
+
writable: false,
|
|
974
|
+
},
|
|
975
|
+
{
|
|
976
|
+
args: ["--task", "pipeline"],
|
|
977
|
+
cwd: join(workbaseRoot, "tasks/pipeline"),
|
|
978
|
+
writable: false,
|
|
979
|
+
},
|
|
980
|
+
]
|
|
981
|
+
for (const launch of launches) {
|
|
982
|
+
const printed = await runCli(
|
|
983
|
+
["work", ...launch.args, "--opencode", "--print-command", "--force"],
|
|
984
|
+
root,
|
|
985
|
+
)
|
|
986
|
+
expect(printed.exitCode).toBe(0)
|
|
987
|
+
expect(printed.stderr).toBe("")
|
|
988
|
+
const contract = JSON.parse(printed.stdout)
|
|
989
|
+
expect(contract.cwd).toBe(launch.cwd)
|
|
990
|
+
expect(contract.environment.OPENCODE_CONFIG).toBe(
|
|
991
|
+
join(workbaseRoot, ".opencode/opencode.jsonc"),
|
|
992
|
+
)
|
|
993
|
+
const environment = {
|
|
994
|
+
...process.env,
|
|
995
|
+
...contract.environment,
|
|
996
|
+
XDG_CONFIG_HOME: isolatedConfigHome,
|
|
997
|
+
OPENCODE_DISABLE_EXTERNAL_SKILLS: "1",
|
|
998
|
+
}
|
|
999
|
+
const probe = Bun.spawnSync(["opencode", "debug", "agent", "build"], {
|
|
1000
|
+
cwd: contract.cwd,
|
|
1001
|
+
env: environment,
|
|
1002
|
+
})
|
|
1003
|
+
expect(probe.exitCode).toBe(0)
|
|
1004
|
+
const agent = JSON.parse(probe.stdout.toString())
|
|
1005
|
+
expect(agent.permission).toEqual(
|
|
1006
|
+
expect.arrayContaining([
|
|
1007
|
+
expect.objectContaining({
|
|
1008
|
+
permission: "external_directory",
|
|
1009
|
+
pattern: join(workbaseRoot, "**"),
|
|
1010
|
+
action: "allow",
|
|
1011
|
+
}),
|
|
1012
|
+
]),
|
|
1013
|
+
)
|
|
1014
|
+
expect(agent.permission).toEqual(
|
|
1015
|
+
expect.arrayContaining([
|
|
1016
|
+
expect.objectContaining({
|
|
1017
|
+
permission: "edit",
|
|
1018
|
+
pattern: launch.writable ? "../**" : "*",
|
|
1019
|
+
action: "deny",
|
|
1020
|
+
}),
|
|
1021
|
+
]),
|
|
1022
|
+
)
|
|
1023
|
+
|
|
1024
|
+
if (launch === launches[0]) {
|
|
1025
|
+
for (const document of documents) {
|
|
1026
|
+
const read = Bun.spawnSync(
|
|
1027
|
+
[
|
|
1028
|
+
"opencode",
|
|
1029
|
+
"debug",
|
|
1030
|
+
"agent",
|
|
1031
|
+
"build",
|
|
1032
|
+
"--tool",
|
|
1033
|
+
"read",
|
|
1034
|
+
"--params",
|
|
1035
|
+
JSON.stringify({ filePath: document }),
|
|
1036
|
+
],
|
|
1037
|
+
{ cwd: contract.cwd, env: environment },
|
|
1038
|
+
)
|
|
1039
|
+
expect(read.exitCode).toBe(0)
|
|
1040
|
+
const result = JSON.parse(read.stdout.toString())
|
|
1041
|
+
expect(result.result.output).toContain(`<path>${document}</path>`)
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
},
|
|
1046
|
+
30_000,
|
|
1047
|
+
)
|
|
1048
|
+
|
|
832
1049
|
test("envelopes help and version output in machine mode", async () => {
|
|
833
1050
|
const help = await runCli(["status", "--help", "--json"])
|
|
834
1051
|
expect(parseJson(help)).toContain("Usage: agency status")
|
|
@@ -129,6 +129,28 @@ status: open
|
|
|
129
129
|
)
|
|
130
130
|
})
|
|
131
131
|
|
|
132
|
+
test("warns when customized OpenCode config cannot guarantee access", async () => {
|
|
133
|
+
const custom = '{"references":{}}\n'
|
|
134
|
+
await write(root, ".opencode/opencode.jsonc", custom)
|
|
135
|
+
|
|
136
|
+
const logs = await captureLogs(() =>
|
|
137
|
+
runTestEffect(doctor({ cwd: root, json: true })),
|
|
138
|
+
)
|
|
139
|
+
const check = JSON.parse(logs[0]!).checks.find(
|
|
140
|
+
(value: { id: string }) => value.id === "integration.file.opencode",
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
expect(check).toMatchObject({
|
|
144
|
+
level: "warning",
|
|
145
|
+
status: "fail",
|
|
146
|
+
message: expect.stringContaining("cannot guarantee whole-workbase"),
|
|
147
|
+
remediation: expect.stringContaining("global config"),
|
|
148
|
+
})
|
|
149
|
+
expect(await Bun.file(join(root, ".opencode/opencode.jsonc")).text()).toBe(
|
|
150
|
+
custom,
|
|
151
|
+
)
|
|
152
|
+
})
|
|
153
|
+
|
|
132
154
|
test("is safe when the workbase is read-only", async () => {
|
|
133
155
|
for (const path of [
|
|
134
156
|
join(root, "agency.json"),
|
|
@@ -38,9 +38,10 @@ describe("init command", () => {
|
|
|
38
38
|
join(root, ".opencode/opencode.jsonc"),
|
|
39
39
|
).text()
|
|
40
40
|
const config = JSON.parse(opencode.slice(opencode.indexOf("\n\n") + 2))
|
|
41
|
-
expect(config.
|
|
42
|
-
|
|
41
|
+
expect(config.references).toEqual({
|
|
42
|
+
workbase: expect.objectContaining({ path: ".." }),
|
|
43
43
|
})
|
|
44
|
+
expect(config.permission).toBeUndefined()
|
|
44
45
|
})
|
|
45
46
|
|
|
46
47
|
test("preserves existing gitignore entries", async () => {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
|
2
|
+
import { mkdir } from "node:fs/promises"
|
|
2
3
|
import { join } from "node:path"
|
|
3
4
|
import {
|
|
4
5
|
captureLogs,
|
|
@@ -29,11 +30,30 @@ describe("integration command", () => {
|
|
|
29
30
|
root,
|
|
30
31
|
files: [
|
|
31
32
|
{ name: "agents", state: "missing" },
|
|
32
|
-
{
|
|
33
|
+
{
|
|
34
|
+
name: "opencode",
|
|
35
|
+
state: "missing",
|
|
36
|
+
diagnostic: expect.stringContaining("cannot load"),
|
|
37
|
+
remediation: expect.stringContaining("integration sync"),
|
|
38
|
+
},
|
|
33
39
|
],
|
|
34
40
|
})
|
|
35
41
|
})
|
|
36
42
|
|
|
43
|
+
test("explains remediation for customized OpenCode config", async () => {
|
|
44
|
+
await mkdir(join(root, ".opencode"))
|
|
45
|
+
await Bun.write(
|
|
46
|
+
join(root, ".opencode", "opencode.json"),
|
|
47
|
+
'{"model":"test/model"}\n',
|
|
48
|
+
)
|
|
49
|
+
const logs = await captureLogs(() =>
|
|
50
|
+
runTestEffect(integration({ subcommand: "status", cwd: root })),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
expect(logs.join("\n")).toContain("cannot guarantee whole-workbase")
|
|
54
|
+
expect(logs.join("\n")).toContain("global config")
|
|
55
|
+
})
|
|
56
|
+
|
|
37
57
|
test("explicitly synchronizes integration files", async () => {
|
|
38
58
|
const logs = await captureLogs(() =>
|
|
39
59
|
runTestEffect(integration({ subcommand: "sync", cwd: root, json: true })),
|
|
@@ -22,7 +22,8 @@ export const integration = (options: IntegrationOptions) =>
|
|
|
22
22
|
return
|
|
23
23
|
}
|
|
24
24
|
for (const file of result.files) {
|
|
25
|
-
log(`${file.name}\t${file.state}\t${file.path}`)
|
|
25
|
+
log(`${file.name}\t${file.state}\t${file.path}\t${file.diagnostic}`)
|
|
26
|
+
if (file.remediation) log(` Remediation: ${file.remediation}`)
|
|
26
27
|
}
|
|
27
28
|
return
|
|
28
29
|
}
|
|
@@ -35,8 +36,9 @@ export const integration = (options: IntegrationOptions) =>
|
|
|
35
36
|
}
|
|
36
37
|
for (const file of result.files) {
|
|
37
38
|
log(
|
|
38
|
-
`${file.name}\t${file.changed ? "synced" : file.state}\t${file.path}`,
|
|
39
|
+
`${file.name}\t${file.changed ? "synced" : file.state}\t${file.path}\t${file.diagnostic}`,
|
|
39
40
|
)
|
|
41
|
+
if (file.remediation) log(` Remediation: ${file.remediation}`)
|
|
40
42
|
}
|
|
41
43
|
return
|
|
42
44
|
}
|
|
@@ -51,10 +53,12 @@ export const integration = (options: IntegrationOptions) =>
|
|
|
51
53
|
export const help = `
|
|
52
54
|
Usage: agency integration <subcommand>
|
|
53
55
|
|
|
54
|
-
Inspect or explicitly synchronize managed agent integration files.
|
|
56
|
+
Inspect or explicitly synchronize managed agent integration files. OpenCode
|
|
57
|
+
launches load the managed file at runtime to provide whole-workbase read
|
|
58
|
+
access without changing Agency write authority.
|
|
55
59
|
|
|
56
60
|
Subcommands:
|
|
57
|
-
status Report
|
|
61
|
+
status Report file state, access diagnostics, and safe remediation
|
|
58
62
|
sync Create or update checksum-safe managed files
|
|
59
63
|
|
|
60
64
|
Options:
|
|
@@ -67,6 +67,7 @@ interface HarnessOptions {
|
|
|
67
67
|
readonly existingDirectories?: readonly string[]
|
|
68
68
|
readonly guardError?: Error
|
|
69
69
|
readonly readyTargetIds?: readonly string[]
|
|
70
|
+
readonly opencodeIntegrationState?: "managed" | "customized"
|
|
70
71
|
}
|
|
71
72
|
|
|
72
73
|
const createHarness = (options: HarnessOptions = {}) => {
|
|
@@ -215,7 +216,15 @@ const createHarness = (options: HarnessOptions = {}) => {
|
|
|
215
216
|
const integrations = {
|
|
216
217
|
sync: () => {
|
|
217
218
|
integrationSyncs += 1
|
|
218
|
-
return Effect.succeed({
|
|
219
|
+
return Effect.succeed({
|
|
220
|
+
root: "/workbase",
|
|
221
|
+
files: [
|
|
222
|
+
{
|
|
223
|
+
name: "opencode",
|
|
224
|
+
state: options.opencodeIntegrationState ?? "managed",
|
|
225
|
+
},
|
|
226
|
+
],
|
|
227
|
+
})
|
|
219
228
|
},
|
|
220
229
|
}
|
|
221
230
|
const fs = {
|
|
@@ -395,6 +404,23 @@ describe("work command", () => {
|
|
|
395
404
|
],
|
|
396
405
|
cwd: "/workbase/epics/delivery",
|
|
397
406
|
})
|
|
407
|
+
expect(harness.launchEnvironments[0]?.OPENCODE_CONFIG).toBe(
|
|
408
|
+
"/workbase/.opencode/opencode.jsonc",
|
|
409
|
+
)
|
|
410
|
+
expect(
|
|
411
|
+
JSON.parse(harness.launchEnvironments[0]!.OPENCODE_CONFIG_CONTENT!),
|
|
412
|
+
).toEqual({
|
|
413
|
+
permission: {
|
|
414
|
+
external_directory: { "/workbase/**": "allow" },
|
|
415
|
+
edit: { "*": "deny" },
|
|
416
|
+
},
|
|
417
|
+
agent: {
|
|
418
|
+
build: { permission: { edit: { "*": "deny" } } },
|
|
419
|
+
plan: { permission: { edit: { "*": "deny" } } },
|
|
420
|
+
general: { permission: { edit: { "*": "deny" } } },
|
|
421
|
+
explore: { permission: { edit: { "*": "deny" } } },
|
|
422
|
+
},
|
|
423
|
+
})
|
|
398
424
|
})
|
|
399
425
|
|
|
400
426
|
test("resolves an existing positional path before treating it as a task ID", async () => {
|
|
@@ -444,6 +470,9 @@ describe("work command", () => {
|
|
|
444
470
|
],
|
|
445
471
|
cwd: "/workbase/tasks/delivery",
|
|
446
472
|
})
|
|
473
|
+
expect(harness.launchEnvironments[0]?.OPENCODE_CONFIG).toBe(
|
|
474
|
+
"/workbase/.opencode/opencode.jsonc",
|
|
475
|
+
)
|
|
447
476
|
})
|
|
448
477
|
|
|
449
478
|
test("infers a phase from a nested checkout directory", async () => {
|
|
@@ -472,6 +501,9 @@ describe("work command", () => {
|
|
|
472
501
|
expect(harness.statusUpdates).toEqual([
|
|
473
502
|
"phase:example:implementation:working",
|
|
474
503
|
])
|
|
504
|
+
expect(harness.launchEnvironments[0]?.OPENCODE_CONFIG).toBe(
|
|
505
|
+
"/workbase/.opencode/opencode.jsonc",
|
|
506
|
+
)
|
|
475
507
|
})
|
|
476
508
|
|
|
477
509
|
test("infers a single-phase task from a nested checkout directory", async () => {
|
|
@@ -485,6 +517,9 @@ describe("work command", () => {
|
|
|
485
517
|
|
|
486
518
|
expect(harness.events[0]).toBe("materialize")
|
|
487
519
|
expect(harness.launches[0]?.cwd).toBe(singlePhaseWorkspace.writablePath)
|
|
520
|
+
expect(harness.launchEnvironments[0]?.OPENCODE_CONFIG).toBe(
|
|
521
|
+
"/workbase/.opencode/opencode.jsonc",
|
|
522
|
+
)
|
|
488
523
|
})
|
|
489
524
|
|
|
490
525
|
test("selects a target when no directory is provided", async () => {
|
|
@@ -665,6 +700,9 @@ describe("work command", () => {
|
|
|
665
700
|
},
|
|
666
701
|
])
|
|
667
702
|
expect(harness.statusUpdates).toEqual(["task:example:working"])
|
|
703
|
+
expect(harness.launchEnvironments[0]?.OPENCODE_CONFIG).toBe(
|
|
704
|
+
"/workbase/.opencode/opencode.jsonc",
|
|
705
|
+
)
|
|
668
706
|
expect(harness.progressUpdates).toEqual([
|
|
669
707
|
"start:Preparing workspace...",
|
|
670
708
|
"succeed:Workspace ready",
|
|
@@ -768,6 +806,40 @@ describe("work command", () => {
|
|
|
768
806
|
expect(printed.environment.API_TOKEN).toBeUndefined()
|
|
769
807
|
})
|
|
770
808
|
|
|
809
|
+
test("prints the runtime OpenCode integration config", async () => {
|
|
810
|
+
const harness = createHarness()
|
|
811
|
+
const output = await captureLogs(() =>
|
|
812
|
+
harness.run({ taskId: "example", opencode: true, printCommand: true }),
|
|
813
|
+
)
|
|
814
|
+
const printed = JSON.parse(output.join("\n"))
|
|
815
|
+
|
|
816
|
+
expect(printed.environment.OPENCODE_CONFIG).toBe(
|
|
817
|
+
"/workbase/.opencode/opencode.jsonc",
|
|
818
|
+
)
|
|
819
|
+
expect(JSON.parse(printed.environment.OPENCODE_CONFIG_CONTENT)).toEqual({
|
|
820
|
+
permission: {
|
|
821
|
+
external_directory: { "/workbase/**": "allow" },
|
|
822
|
+
edit: { "../**": "deny" },
|
|
823
|
+
},
|
|
824
|
+
agent: {
|
|
825
|
+
build: { permission: { edit: { "../**": "deny" } } },
|
|
826
|
+
plan: { permission: { edit: { "../**": "deny" } } },
|
|
827
|
+
general: { permission: { edit: { "../**": "deny" } } },
|
|
828
|
+
explore: { permission: { edit: { "../**": "deny" } } },
|
|
829
|
+
},
|
|
830
|
+
})
|
|
831
|
+
})
|
|
832
|
+
|
|
833
|
+
test("does not override customized OpenCode access policy", async () => {
|
|
834
|
+
const harness = createHarness({ opencodeIntegrationState: "customized" })
|
|
835
|
+
await harness.run({ taskId: "example", opencode: true })
|
|
836
|
+
|
|
837
|
+
expect(harness.launchEnvironments[0]?.OPENCODE_CONFIG).toBeUndefined()
|
|
838
|
+
expect(
|
|
839
|
+
harness.launchEnvironments[0]?.OPENCODE_CONFIG_CONTENT,
|
|
840
|
+
).toBeUndefined()
|
|
841
|
+
})
|
|
842
|
+
|
|
771
843
|
test("automatically falls back to Claude", async () => {
|
|
772
844
|
const harness = createHarness({ available: { opencode: false } })
|
|
773
845
|
|
|
@@ -779,6 +851,7 @@ describe("work command", () => {
|
|
|
779
851
|
args: ["claude", "Start the task. Read /workbase/tasks/example/TASK.md."],
|
|
780
852
|
cwd: singlePhaseWorkspace.writablePath,
|
|
781
853
|
})
|
|
854
|
+
expect(harness.launchEnvironments[0]?.OPENCODE_CONFIG).toBeUndefined()
|
|
782
855
|
})
|
|
783
856
|
|
|
784
857
|
test("does not fall back when OpenCode is explicitly required", async () => {
|
package/src/commands/work.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Effect } from "effect"
|
|
2
|
-
import { dirname, isAbsolute, relative, resolve, sep } from "node:path"
|
|
2
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"
|
|
3
3
|
import type { BaseCommandOptions } from "../utils/command"
|
|
4
4
|
import { WorktreeService } from "../services/WorktreeService"
|
|
5
5
|
import { FileSystemService } from "../services/FileSystemService"
|
|
@@ -120,7 +120,10 @@ export const work = (
|
|
|
120
120
|
const inputAllowed = options.inputAllowed ?? true
|
|
121
121
|
const root = yield* resolveWorkbase(startPath, pickBase, inputAllowed)
|
|
122
122
|
if (!root) return
|
|
123
|
-
yield* integrations.sync(root)
|
|
123
|
+
const integration = yield* integrations.sync(root)
|
|
124
|
+
const managedOpencode = integration.files.some(
|
|
125
|
+
(file) => file.name === "opencode" && file.state === "managed",
|
|
126
|
+
)
|
|
124
127
|
const { config } = yield* workbase.loadConfig(root)
|
|
125
128
|
|
|
126
129
|
let target: WorkTarget | null = null
|
|
@@ -325,6 +328,25 @@ export const work = (
|
|
|
325
328
|
...resolved.environment,
|
|
326
329
|
...runnerEnvironment(runner, variables),
|
|
327
330
|
}
|
|
331
|
+
if (runner === "opencode" && managedOpencode) {
|
|
332
|
+
environment.OPENCODE_CONFIG = join(root, ".opencode", "opencode.jsonc")
|
|
333
|
+
const execution =
|
|
334
|
+
target.kind === "phase" ||
|
|
335
|
+
(target.kind === "task" && !target.multiPhase)
|
|
336
|
+
const edit = { [execution ? "../**" : "*"]: "deny" as const }
|
|
337
|
+
environment.OPENCODE_CONFIG_CONTENT = JSON.stringify({
|
|
338
|
+
permission: {
|
|
339
|
+
external_directory: { [join(root, "**")]: "allow" },
|
|
340
|
+
edit,
|
|
341
|
+
},
|
|
342
|
+
agent: {
|
|
343
|
+
build: { permission: { edit } },
|
|
344
|
+
plan: { permission: { edit } },
|
|
345
|
+
general: { permission: { edit } },
|
|
346
|
+
explore: { permission: { edit } },
|
|
347
|
+
},
|
|
348
|
+
})
|
|
349
|
+
}
|
|
328
350
|
if (options.printCommand) {
|
|
329
351
|
log(
|
|
330
352
|
JSON.stringify(
|
|
@@ -422,7 +444,8 @@ Usage: agency work [<directory-or-task-id> | --epic <epic-id>] [--runner <name>]
|
|
|
422
444
|
Launch an agent for an epic, task, or phase. With no directory, select one
|
|
423
445
|
interactively. A positional argument resolves as a directory first, then as a task
|
|
424
446
|
ID. Use '.' for the current directory. Outside a workbase, select a registered
|
|
425
|
-
workbase first.
|
|
447
|
+
workbase first. OpenCode launches receive whole-workbase read access through the
|
|
448
|
+
runtime environment; Agency context remains authoritative for writes.
|
|
426
449
|
|
|
427
450
|
The prepare subcommand resolves and materializes an execution workspace without
|
|
428
451
|
launching an agent or changing lifecycle status. --dry-run reports planned Git
|
|
@@ -242,15 +242,20 @@ export class DoctorService extends Effect.Service<DoctorService>()(
|
|
|
242
242
|
|
|
243
243
|
const integrationStatus = yield* integrations.status(root)
|
|
244
244
|
for (const file of integrationStatus.files) {
|
|
245
|
-
const failed =
|
|
245
|
+
const failed =
|
|
246
|
+
file.state === "missing" ||
|
|
247
|
+
file.state === "drifted" ||
|
|
248
|
+
(file.name === "opencode" && file.state === "customized")
|
|
246
249
|
add({
|
|
247
250
|
id: `integration.file.${file.name}`,
|
|
248
251
|
category: "integration",
|
|
249
|
-
level:
|
|
252
|
+
level:
|
|
253
|
+
file.name === "agents" && file.state === "customized"
|
|
254
|
+
? "optional"
|
|
255
|
+
: "warning",
|
|
250
256
|
status: failed ? "fail" : "pass",
|
|
251
|
-
message: `${file.name} integration file is ${file.state}: ${file.path}`,
|
|
252
|
-
remediation:
|
|
253
|
-
"Run 'agency integration sync' to restore managed content.",
|
|
257
|
+
message: `${file.name} integration file is ${file.state}: ${file.path}. ${file.diagnostic}`,
|
|
258
|
+
remediation: file.remediation ?? undefined,
|
|
254
259
|
})
|
|
255
260
|
}
|
|
256
261
|
|
|
@@ -97,30 +97,55 @@ describe("IntegrationService", () => {
|
|
|
97
97
|
const config = JSON.parse(managedBody(managedWorkbaseOpencode))
|
|
98
98
|
|
|
99
99
|
expect(config.references).toEqual({
|
|
100
|
-
|
|
101
|
-
path: "
|
|
100
|
+
workbase: {
|
|
101
|
+
path: "..",
|
|
102
102
|
description:
|
|
103
|
-
"Agency
|
|
103
|
+
"Complete Agency workbase context; write authority still comes only from agency context",
|
|
104
104
|
},
|
|
105
|
-
epics: {
|
|
106
|
-
path: "../epics",
|
|
107
|
-
description:
|
|
108
|
-
"Agency epic definitions and orchestration context; no implementation write authority",
|
|
109
|
-
},
|
|
110
|
-
})
|
|
111
|
-
expect(config.permission).toEqual({
|
|
112
|
-
external_directory: { "../**": "allow" },
|
|
113
105
|
})
|
|
106
|
+
expect(config.permission).toBeUndefined()
|
|
107
|
+
expect(managedBody(managedWorkbaseOpencode)).not.toContain(process.cwd())
|
|
114
108
|
})
|
|
115
109
|
|
|
116
110
|
test("treats an existing JSON OpenCode config as customized", async () => {
|
|
117
111
|
await write(root, ".opencode/opencode.json", '{"model":"test/model"}\n')
|
|
118
112
|
|
|
119
113
|
const result = await status(root)
|
|
120
|
-
expect(result.files[1]).
|
|
114
|
+
expect(result.files[1]).toMatchObject({
|
|
121
115
|
name: "opencode",
|
|
122
116
|
path: join(root, ".opencode/opencode.json"),
|
|
123
117
|
state: "customized",
|
|
118
|
+
diagnostic: expect.stringContaining("cannot guarantee"),
|
|
119
|
+
remediation: expect.stringContaining("global config"),
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
test("treats a JSON config beside managed JSONC as customized", async () => {
|
|
124
|
+
await write(root, ".opencode/opencode.jsonc", managedWorkbaseOpencode)
|
|
125
|
+
await write(root, ".opencode/opencode.json", '{"model":"test/model"}\n')
|
|
126
|
+
|
|
127
|
+
const result = await status(root)
|
|
128
|
+
expect(result.files[1]).toMatchObject({
|
|
129
|
+
name: "opencode",
|
|
130
|
+
path: join(root, ".opencode/opencode.json"),
|
|
131
|
+
state: "customized",
|
|
132
|
+
})
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
test("reports actionable whole-workbase access diagnostics", async () => {
|
|
136
|
+
let result = await status(root)
|
|
137
|
+
expect(result.files[1]).toMatchObject({
|
|
138
|
+
state: "missing",
|
|
139
|
+
diagnostic: expect.stringContaining("cannot load"),
|
|
140
|
+
remediation: expect.stringContaining("integration sync"),
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
await write(root, ".opencode/opencode.jsonc", '{"model":"test/model"}\n')
|
|
144
|
+
result = await status(root)
|
|
145
|
+
expect(result.files[1]).toMatchObject({
|
|
146
|
+
state: "customized",
|
|
147
|
+
diagnostic: expect.stringContaining("cannot guarantee"),
|
|
148
|
+
remediation: expect.stringContaining("global config"),
|
|
124
149
|
})
|
|
125
150
|
})
|
|
126
151
|
|
|
@@ -17,28 +17,79 @@ interface IntegrationFileStatus {
|
|
|
17
17
|
readonly name: "agents" | "opencode"
|
|
18
18
|
readonly path: string
|
|
19
19
|
readonly state: IntegrationFileState
|
|
20
|
+
readonly diagnostic: string
|
|
21
|
+
readonly remediation: string | null
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
interface IntegrationSyncFile extends IntegrationFileStatus {
|
|
23
25
|
readonly changed: boolean
|
|
24
26
|
}
|
|
25
27
|
|
|
28
|
+
const describe = (
|
|
29
|
+
name: IntegrationFileStatus["name"],
|
|
30
|
+
state: IntegrationFileState,
|
|
31
|
+
) => {
|
|
32
|
+
if (name === "opencode") {
|
|
33
|
+
if (state === "managed") {
|
|
34
|
+
return {
|
|
35
|
+
diagnostic:
|
|
36
|
+
"Agency's managed OpenCode launch config is ready to provide whole-workbase read access.",
|
|
37
|
+
remediation: null,
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (state === "customized") {
|
|
41
|
+
return {
|
|
42
|
+
diagnostic:
|
|
43
|
+
"Agency cannot guarantee whole-workbase read access from this customized OpenCode config.",
|
|
44
|
+
remediation:
|
|
45
|
+
"Back up and remove the customized file, run 'agency integration sync', then move any retained custom settings to OpenCode's global config.",
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
diagnostic:
|
|
50
|
+
"Agency OpenCode launches cannot load current whole-workbase access.",
|
|
51
|
+
remediation:
|
|
52
|
+
"Run 'agency integration sync' to install whole-workbase OpenCode access.",
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return state === "missing" || state === "drifted"
|
|
57
|
+
? {
|
|
58
|
+
diagnostic: "Managed workbase instructions need synchronization.",
|
|
59
|
+
remediation:
|
|
60
|
+
"Run 'agency integration sync' to restore managed instructions.",
|
|
61
|
+
}
|
|
62
|
+
: {
|
|
63
|
+
diagnostic:
|
|
64
|
+
state === "managed"
|
|
65
|
+
? "Managed workbase instructions are current."
|
|
66
|
+
: "Customized workbase instructions are preserved.",
|
|
67
|
+
remediation: null,
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const fileStatus = (
|
|
72
|
+
name: IntegrationFileStatus["name"],
|
|
73
|
+
path: string,
|
|
74
|
+
state: IntegrationFileState,
|
|
75
|
+
): IntegrationFileStatus => ({ name, path, state, ...describe(name, state) })
|
|
76
|
+
|
|
26
77
|
const classify = (
|
|
27
78
|
name: IntegrationFileStatus["name"],
|
|
28
79
|
path: string,
|
|
29
80
|
content: string,
|
|
30
81
|
managed: string,
|
|
31
82
|
canUpdate: (content: string) => boolean,
|
|
32
|
-
): IntegrationFileStatus =>
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
83
|
+
): IntegrationFileStatus =>
|
|
84
|
+
fileStatus(
|
|
85
|
+
name,
|
|
86
|
+
path,
|
|
36
87
|
content === managed
|
|
37
88
|
? "managed"
|
|
38
89
|
: canUpdate(content)
|
|
39
90
|
? "drifted"
|
|
40
91
|
: "customized",
|
|
41
|
-
|
|
92
|
+
)
|
|
42
93
|
|
|
43
94
|
const inspect = (root: string) =>
|
|
44
95
|
Effect.gen(function* () {
|
|
@@ -51,7 +102,7 @@ const inspect = (root: string) =>
|
|
|
51
102
|
|
|
52
103
|
files.push(
|
|
53
104
|
(yield* fs.readSymlinkTarget(agentsPath)) !== null
|
|
54
|
-
?
|
|
105
|
+
? fileStatus("agents", agentsPath, "customized")
|
|
55
106
|
: (yield* fs.exists(agentsPath))
|
|
56
107
|
? classify(
|
|
57
108
|
"agents",
|
|
@@ -60,15 +111,16 @@ const inspect = (root: string) =>
|
|
|
60
111
|
managedWorkbaseAgents,
|
|
61
112
|
canUpdateManagedWorkbaseAgents,
|
|
62
113
|
)
|
|
63
|
-
:
|
|
114
|
+
: fileStatus("agents", agentsPath, "missing"),
|
|
64
115
|
)
|
|
65
116
|
|
|
66
117
|
if ((yield* fs.readSymlinkTarget(opencodePath)) !== null) {
|
|
67
|
-
files.push(
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
118
|
+
files.push(fileStatus("opencode", opencodePath, "customized"))
|
|
119
|
+
} else if (
|
|
120
|
+
(yield* fs.readSymlinkTarget(opencodeJsonPath)) !== null ||
|
|
121
|
+
(yield* fs.exists(opencodeJsonPath))
|
|
122
|
+
) {
|
|
123
|
+
files.push(fileStatus("opencode", opencodeJsonPath, "customized"))
|
|
72
124
|
} else if (yield* fs.exists(opencodePath)) {
|
|
73
125
|
files.push(
|
|
74
126
|
classify(
|
|
@@ -79,14 +131,8 @@ const inspect = (root: string) =>
|
|
|
79
131
|
canUpdateManagedWorkbaseOpencode,
|
|
80
132
|
),
|
|
81
133
|
)
|
|
82
|
-
} else if (yield* fs.exists(opencodeJsonPath)) {
|
|
83
|
-
files.push({
|
|
84
|
-
name: "opencode",
|
|
85
|
-
path: opencodeJsonPath,
|
|
86
|
-
state: "customized",
|
|
87
|
-
})
|
|
88
134
|
} else {
|
|
89
|
-
files.push(
|
|
135
|
+
files.push(fileStatus("opencode", opencodePath, "missing"))
|
|
90
136
|
}
|
|
91
137
|
|
|
92
138
|
return files
|
|
@@ -123,8 +169,11 @@ export class IntegrationService extends Effect.Service<IntegrationService>()(
|
|
|
123
169
|
}
|
|
124
170
|
}
|
|
125
171
|
files.push({
|
|
126
|
-
...
|
|
127
|
-
|
|
172
|
+
...fileStatus(
|
|
173
|
+
status.name,
|
|
174
|
+
status.path,
|
|
175
|
+
changed ? "managed" : status.state,
|
|
176
|
+
),
|
|
128
177
|
changed,
|
|
129
178
|
})
|
|
130
179
|
}
|
package/src/workbase/AGENTS.md
CHANGED
|
@@ -73,4 +73,7 @@ creates these files, and `agency work` reconciles them before launching an agent
|
|
|
73
73
|
|
|
74
74
|
OpenCode can access the complete workbase tree, but this filesystem permission
|
|
75
75
|
does not expand Agency write authority beyond the checkout reported by
|
|
76
|
-
`agency context`.
|
|
76
|
+
`agency context`. Agency launches expose the managed OpenCode config file at
|
|
77
|
+
runtime and inject absolute access rules scoped to this workbase. Direct edits
|
|
78
|
+
remain denied outside the writable checkout, and no machine-specific path is
|
|
79
|
+
stored in the Git-synced config.
|
|
@@ -11,20 +11,10 @@ const body = () =>
|
|
|
11
11
|
{
|
|
12
12
|
$schema: "https://opencode.ai/config.json",
|
|
13
13
|
references: {
|
|
14
|
-
|
|
15
|
-
path: "
|
|
14
|
+
workbase: {
|
|
15
|
+
path: "..",
|
|
16
16
|
description:
|
|
17
|
-
"Agency
|
|
18
|
-
},
|
|
19
|
-
epics: {
|
|
20
|
-
path: "../epics",
|
|
21
|
-
description:
|
|
22
|
-
"Agency epic definitions and orchestration context; no implementation write authority",
|
|
23
|
-
},
|
|
24
|
-
},
|
|
25
|
-
permission: {
|
|
26
|
-
external_directory: {
|
|
27
|
-
"../**": "allow",
|
|
17
|
+
"Complete Agency workbase context; write authority still comes only from agency context",
|
|
28
18
|
},
|
|
29
19
|
},
|
|
30
20
|
},
|