@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 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 task and epic references
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 only the task and epic directories as documented
80
- references. OpenCode automatically grants those references scoped
81
- external-directory access, so Agency does not add blanket permission rules that
82
- could hide missing tool permissions. References provide context and never expand
83
- the write authority reported by `agency context`.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "2.30.0",
3
+ "version": "2.30.1",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -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.permission.external_directory).toEqual({
42
- "../**": "allow",
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
- { name: "opencode", state: "missing" },
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 managed, customized, missing, and drifted files
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({ root: "/workbase", files: [] })
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 () => {
@@ -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 = file.state === "missing" || file.state === "drifted"
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: file.state === "customized" ? "optional" : "warning",
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
- tasks: {
101
- path: "../tasks",
100
+ workbase: {
101
+ path: "..",
102
102
  description:
103
- "Agency task definitions and execution context; authority still comes from agency context",
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]).toEqual({
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
- name,
34
- path,
35
- state:
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
- ? { name: "agents", path: agentsPath, state: "customized" }
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
- : { name: "agents", path: agentsPath, state: "missing" },
114
+ : fileStatus("agents", agentsPath, "missing"),
64
115
  )
65
116
 
66
117
  if ((yield* fs.readSymlinkTarget(opencodePath)) !== null) {
67
- files.push({
68
- name: "opencode",
69
- path: opencodePath,
70
- state: "customized",
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({ name: "opencode", path: opencodePath, state: "missing" })
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
- ...status,
127
- state: changed ? "managed" : status.state,
172
+ ...fileStatus(
173
+ status.name,
174
+ status.path,
175
+ changed ? "managed" : status.state,
176
+ ),
128
177
  changed,
129
178
  })
130
179
  }
@@ -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
- tasks: {
15
- path: "../tasks",
14
+ workbase: {
15
+ path: "..",
16
16
  description:
17
- "Agency task definitions and execution context; authority still comes from agency context",
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
  },