@markjaquith/agency 2.30.0 → 2.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -37,13 +37,19 @@ For development, run `bun link` from this repository.
37
37
  Entity IDs come from directory names. Structured metadata lives in YAML 1.2
38
38
  frontmatter; prose below it supplies human and agent context.
39
39
 
40
+ New epic, task, and phase documents use the same core prose sections:
41
+ `Outcome` states the intended result, `Plan` describes the current approach, and
42
+ `Important Decisions` preserves consequential choices and their rationale. These
43
+ sections are creation defaults rather than validation requirements, so existing
44
+ and customized documents remain valid.
45
+
40
46
  ## Workbase Layout
41
47
 
42
48
  ```text
43
49
  workbase/
44
50
  AGENTS.md # managed workbase instructions
45
51
  .opencode/
46
- opencode.jsonc # managed task and epic references
52
+ opencode.jsonc # managed whole-workbase reference
47
53
  agency.json # tracked config and portable repository declarations
48
54
  repos/ # ignored local materializations
49
55
  frontend/ # bare Git repository or symlink
@@ -76,11 +82,13 @@ Agency keeps discovery and other observational commands read-only. Run
76
82
  files or refresh checksum-safe managed files. Customized files are reported but
77
83
  never overwritten.
78
84
 
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`.
85
+ The OpenCode config advertises the complete workbase as one portable relative
86
+ reference. `agency work` sets `OPENCODE_CONFIG` to the managed file and injects
87
+ runtime-only absolute rules that allow external access across the workbase while
88
+ denying edits outside the execution unit's writable checkout. This makes the
89
+ config effective from nested task and phase Git checkouts without persisting a
90
+ machine-specific path or granting access outside the workbase. Bash and Agency
91
+ operations must still follow the write authority reported by `agency context`.
84
92
 
85
93
  Repository aliases and canonical fetch remotes are declared in tracked
86
94
  `agency.json`; local bare clones and symlinks remain ignored under
@@ -201,6 +209,10 @@ Every runner receives the same `AGENCY_RUNNER`, `AGENCY_CLAIMANT`,
201
209
  `AGENCY_SESSION_ID`, `AGENCY_CLAIM_REVISION`, `AGENCY_WORKBASE`, `AGENCY_TARGET`,
202
210
  `AGENCY_TASK_ID`, `AGENCY_PHASE_ID`, and `AGENCY_PROMPT` environment. Configured
203
211
  environment is added without overriding these normalized values.
212
+ The `opencode` runner additionally receives `OPENCODE_CONFIG` for the workbase's
213
+ managed integration and `OPENCODE_CONFIG_CONTENT` with runtime-only,
214
+ workbase-scoped access and edit rules. These values keep the Git-synced config
215
+ portable while providing whole-workbase read access at every launch location.
204
216
  `--print-command` prints the exact cwd and argv plus non-secret environment keys
205
217
  without launching the runner.
206
218
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "2.30.0",
3
+ "version": "2.31.0",
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
 
@@ -34,6 +34,9 @@ describe("EpicService", () => {
34
34
 
35
35
  expect(created.content).toContain("ticketUrl:")
36
36
  expect(created.content).toContain("tasks: []")
37
+ expect(created.content).toContain(
38
+ "# Workspace Orchestration\n\n## Outcome\n\nDescribe the epic outcome.\n\n## Plan\n\nDescribe the current approach.\n\n## Important Decisions\n\nRecord consequential decisions and their rationale.",
39
+ )
37
40
 
38
41
  const records = await runTestEffect(
39
42
  EpicService.pipe(Effect.flatMap((service) => service.list(root))),
@@ -10,6 +10,7 @@ import {
10
10
  } from "../workbase/schemas"
11
11
  import {
12
12
  formatMarkdownDocument,
13
+ formatWorkDocumentBody,
13
14
  parseFrontmatter,
14
15
  } from "../workbase/frontmatter"
15
16
  import { documentRevision } from "../workbase/document-revision"
@@ -95,7 +96,7 @@ export class EpicService extends Effect.Service<EpicService>()("EpicService", {
95
96
  .join(" ")
96
97
  const content = formatMarkdownDocument(
97
98
  data,
98
- `# ${title}\n\nDescribe the epic outcome.`,
99
+ formatWorkDocumentBody(title, "epic"),
99
100
  )
100
101
  yield* fs.writeFile(path, content)
101
102
  return {
@@ -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
  }
@@ -14,6 +14,7 @@ import {
14
14
  } from "../workbase/schemas"
15
15
  import {
16
16
  formatMarkdownDocument,
17
+ formatWorkDocumentBody,
17
18
  parseFrontmatter,
18
19
  } from "../workbase/frontmatter"
19
20
  import { canTransitionStatus } from "../readiness"
@@ -196,7 +197,7 @@ export class PhaseService extends Effect.Service<PhaseService>()(
196
197
  .join(" ")
197
198
  const content = formatMarkdownDocument(
198
199
  data,
199
- `# ${title}\n\nDescribe the phase outcome.`,
200
+ formatWorkDocumentBody(title, "phase"),
200
201
  )
201
202
 
202
203
  if (!isMultiPhase) {
@@ -246,7 +247,7 @@ export class PhaseService extends Effect.Service<PhaseService>()(
246
247
  const firstPhasePath = join(firstDirectory, "PHASE.md")
247
248
  const firstContent = formatMarkdownDocument(
248
249
  firstData,
249
- `# ${firstTitle}\n\nDescribe the phase outcome.`,
250
+ formatWorkDocumentBody(firstTitle, "phase"),
250
251
  )
251
252
  const steps: TransactionStep[] = []
252
253
  if (yield* fs.isDirectory(oldCodePath)) {
@@ -220,7 +220,10 @@ process.exit(${exitCode})
220
220
  const original = await Bun.file(taskPath).text()
221
221
  await Bun.write(
222
222
  taskPath,
223
- original.replace("# Example\n\nDescribe the task outcome.", body),
223
+ original.replace(
224
+ "# Example\n\n## Outcome\n\nDescribe the task outcome.\n\n## Plan\n\nDescribe the current approach.\n\n## Important Decisions\n\nRecord consequential decisions and their rationale.",
225
+ body,
226
+ ),
224
227
  )
225
228
  const url = "https://github.com/example/agency/pull/42"
226
229
  await writeFakeGh({ stdout: `${url}\n` })
@@ -33,7 +33,7 @@ describe("task and phase services", () => {
33
33
  ),
34
34
  ),
35
35
  )
36
- await runTestEffect(
36
+ const createdTask = await runTestEffect(
37
37
  TaskService.pipe(
38
38
  Effect.flatMap((service) =>
39
39
  service.create(
@@ -51,6 +51,9 @@ describe("task and phase services", () => {
51
51
  ),
52
52
  ),
53
53
  )
54
+ expect(createdTask.content).toContain(
55
+ "# Task One\n\n## Outcome\n\nDescribe the task outcome.\n\n## Plan\n\nDescribe the current approach.\n\n## Important Decisions\n\nRecord consequential decisions and their rationale.",
56
+ )
54
57
 
55
58
  const epic = await runTestEffect(
56
59
  EpicService.pipe(
@@ -159,6 +162,14 @@ describe("task and phase services", () => {
159
162
  ),
160
163
  )
161
164
  expect(phases.map((phase) => phase.id)).toEqual(["first", "second"])
165
+ const firstPhase = await runTestEffect(
166
+ PhaseService.pipe(
167
+ Effect.flatMap((service) => service.show("multi", "first", root)),
168
+ ),
169
+ )
170
+ expect(firstPhase.content).toContain(
171
+ "# First\n\n## Outcome\n\nDescribe the phase outcome.\n\n## Plan\n\nDescribe the current approach.\n\n## Important Decisions\n\nRecord consequential decisions and their rationale.",
172
+ )
162
173
  })
163
174
 
164
175
  test("converts a single-phase task when the existing phase ID is provided", async () => {
@@ -243,7 +254,9 @@ describe("task and phase services", () => {
243
254
  { id: "extra", dependsOn: ["implementation"] },
244
255
  ],
245
256
  })
246
- expect(task.content).toContain("Describe the task outcome.")
257
+ expect(task.content).toContain(
258
+ "## Outcome\n\nDescribe the task outcome.\n\n## Plan\n\nDescribe the current approach.\n\n## Important Decisions",
259
+ )
247
260
 
248
261
  const firstPhase = await runTestEffect(
249
262
  PhaseService.pipe(
@@ -13,6 +13,7 @@ import {
13
13
  } from "../workbase/schemas"
14
14
  import {
15
15
  formatMarkdownDocument,
16
+ formatWorkDocumentBody,
16
17
  parseFrontmatter,
17
18
  } from "../workbase/frontmatter"
18
19
  import { canTransitionStatus } from "../readiness"
@@ -163,7 +164,7 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
163
164
  .join(" ")
164
165
  const content = formatMarkdownDocument(
165
166
  data,
166
- `# ${title}\n\nDescribe the task outcome.`,
167
+ formatWorkDocumentBody(title, "task"),
167
168
  )
168
169
  const writes: {
169
170
  path: string
@@ -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.
@@ -81,3 +81,20 @@ export const parseFrontmatter = (content: string, path: string) =>
81
81
 
82
82
  export const formatMarkdownDocument = (data: object, body: string) =>
83
83
  `---\n${stringify(data, { lineWidth: 0 }).trimEnd()}\n---\n\n${body.trim()}\n`
84
+
85
+ export const formatWorkDocumentBody = (
86
+ title: string,
87
+ kind: "epic" | "task" | "phase",
88
+ ) => `# ${title}
89
+
90
+ ## Outcome
91
+
92
+ Describe the ${kind} outcome.
93
+
94
+ ## Plan
95
+
96
+ Describe the current approach.
97
+
98
+ ## Important Decisions
99
+
100
+ Record consequential decisions and their rationale.`
@@ -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
  },