@markjaquith/agency 2.37.0 → 2.38.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
@@ -92,13 +92,9 @@ preserved as user-owned content.
92
92
 
93
93
  The OpenCode config loads Agency's hidden instructions in addition to any
94
94
  user-owned root `AGENTS.md` and advertises the complete workbase as one portable
95
- relative reference. `agency work` sets `OPENCODE_CONFIG` to the managed file and
96
- injects runtime-only absolute rules that allow external access across the
97
- workbase while denying edits outside the execution unit's writable checkout.
98
- This makes the config effective from nested task and phase Git checkouts without
99
- persisting a machine-specific path or granting access outside the workbase. Bash
100
- and Agency operations must still follow the write authority reported by
101
- `agency context`.
95
+ reference. OpenCode discovers that config from task and epic launch directories.
96
+ Agents receive whole-workbase visibility from that reference. Bash and Agency
97
+ operations must still follow the write authority reported by `agency context`.
102
98
 
103
99
  Repository aliases and canonical fetch remotes are declared in tracked
104
100
  `agency.json`; local bare clones and symlinks remain ignored under
@@ -226,10 +222,8 @@ Every runner receives the same `AGENCY_RUNNER`, `AGENCY_CLAIMANT`,
226
222
  environment is added without overriding these normalized values.
227
223
  `AGENCY_CLAIM_REVISION` is empty for local `agency work` launches.
228
224
  `AGENCY_PROMPT` is empty unless `--auto` is set.
229
- The `opencode` runner additionally receives `OPENCODE_CONFIG` for the workbase's
230
- managed integration and `OPENCODE_CONFIG_CONTENT` with runtime-only,
231
- workbase-scoped access and edit rules. These values keep the Git-synced config
232
- portable while providing whole-workbase read access at every launch location.
225
+ The `opencode` runner discovers the managed project config from its task or epic
226
+ working directory; Agency does not inject OpenCode-specific configuration.
233
227
  `AGENCY_CLAIM_REVISION` is empty for local `agency work` launches.
234
228
  `AGENCY_PROMPT` is empty unless `--auto` is set.
235
229
  `--print-command` prints the exact cwd and argv plus non-secret environment keys
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "2.37.0",
3
+ "version": "2.38.0",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
package/src/cli.test.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { afterAll, afterEach, describe, expect, test } from "bun:test"
2
2
  import { access, mkdir, realpath } from "node:fs/promises"
3
- import { join, relative } from "node:path"
3
+ import { join } from "node:path"
4
4
  import errorFixture from "../fixtures/protocol/error.json"
5
5
  import successFixture from "../fixtures/protocol/success.json"
6
6
  import { cleanupTempDir, createTempDir } from "./test-utils"
@@ -862,6 +862,9 @@ status: open
862
862
  await runGit(["-C", source, "remote", "add", "origin", daemon.remote])
863
863
 
864
864
  parseJson(await runCli(["init", root, "--json"], parent))
865
+ expect(
866
+ Bun.spawnSync(["git", "init", "--initial-branch=main", root]).exitCode,
867
+ ).toBe(0)
865
868
  const agencyConfigPath = join(root, "agency.json")
866
869
  const agencyConfig = JSON.parse(await Bun.file(agencyConfigPath).text())
867
870
  await Bun.write(
@@ -942,10 +945,8 @@ status: open
942
945
  ),
943
946
  )
944
947
 
945
- const taskWorkspace = parseJson(
946
- await runCli(["work", "prepare", "example", "--json"], root),
947
- )
948
- const phaseWorkspace = parseJson(
948
+ parseJson(await runCli(["work", "prepare", "example", "--json"], root))
949
+ parseJson(
949
950
  await runCli(
950
951
  [
951
952
  "work",
@@ -984,22 +985,18 @@ status: open
984
985
  {
985
986
  args: ["--task", "example"],
986
987
  cwd: join(workbaseRoot, "tasks/example"),
987
- writable: taskWorkspace.writablePath,
988
988
  },
989
989
  {
990
990
  args: ["--task", "pipeline", "--phase", "build"],
991
991
  cwd: join(workbaseRoot, "tasks/pipeline"),
992
- writable: phaseWorkspace.writablePath,
993
992
  },
994
993
  {
995
994
  args: ["--epic", "delivery"],
996
995
  cwd: join(workbaseRoot, "epics/delivery"),
997
- writable: null,
998
996
  },
999
997
  {
1000
998
  args: ["--task", "pipeline"],
1001
999
  cwd: join(workbaseRoot, "tasks/pipeline"),
1002
- writable: null,
1003
1000
  },
1004
1001
  ]
1005
1002
  for (const launch of launches) {
@@ -1011,9 +1008,8 @@ status: open
1011
1008
  expect(printed.stderr).toBe("")
1012
1009
  const contract = JSON.parse(printed.stdout)
1013
1010
  expect(contract.cwd).toBe(launch.cwd)
1014
- expect(contract.environment.OPENCODE_CONFIG).toBe(
1015
- join(workbaseRoot, ".opencode/opencode.jsonc"),
1016
- )
1011
+ expect(contract.environment.OPENCODE_CONFIG).toBeUndefined()
1012
+ expect(contract.environment.OPENCODE_CONFIG_CONTENT).toBeUndefined()
1017
1013
  const environment = {
1018
1014
  ...process.env,
1019
1015
  ...contract.environment,
@@ -1030,35 +1026,24 @@ status: open
1030
1026
  expect.arrayContaining([
1031
1027
  expect.objectContaining({
1032
1028
  permission: "external_directory",
1033
- pattern: join(workbaseRoot, "**"),
1029
+ pattern: join(workbaseRoot, "*"),
1034
1030
  action: "allow",
1035
1031
  }),
1036
1032
  ]),
1037
1033
  )
1038
- expect(agent.permission).toEqual(
1039
- expect.arrayContaining([
1040
- expect.objectContaining({
1041
- permission: "edit",
1042
- pattern: "*",
1043
- action: "deny",
1044
- }),
1045
- ]),
1046
- )
1047
- if (launch.writable) {
1048
- expect(agent.permission).toEqual(
1049
- expect.arrayContaining(
1050
- [workbaseRoot, launch.cwd].map((base) =>
1051
- expect.objectContaining({
1052
- permission: "edit",
1053
- pattern: join(relative(base, launch.writable!), "**"),
1054
- action: "allow",
1055
- }),
1056
- ),
1057
- ),
1058
- )
1059
- }
1060
-
1061
1034
  if (launch === launches[0]) {
1035
+ const configProbe = Bun.spawnSync(["opencode", "debug", "config"], {
1036
+ cwd: contract.cwd,
1037
+ env: environment,
1038
+ })
1039
+ expect(configProbe.exitCode).toBe(0)
1040
+ expect(JSON.parse(configProbe.stdout.toString()).references).toEqual({
1041
+ workbase: {
1042
+ path: "..",
1043
+ description:
1044
+ "Complete Agency workbase context; write authority still comes only from agency context",
1045
+ },
1046
+ })
1062
1047
  for (const document of documents) {
1063
1048
  const read = Bun.spawnSync(
1064
1049
  [
@@ -1080,6 +1065,55 @@ status: open
1080
1065
  }
1081
1066
  }
1082
1067
 
1068
+ const directEnvironment: Record<string, string | undefined> = {
1069
+ ...process.env,
1070
+ XDG_CONFIG_HOME: isolatedConfigHome,
1071
+ OPENCODE_DISABLE_EXTERNAL_SKILLS: "1",
1072
+ }
1073
+ delete directEnvironment.OPENCODE_CONFIG
1074
+ delete directEnvironment.OPENCODE_CONFIG_CONTENT
1075
+ delete directEnvironment.AGENCY_WORKBASE
1076
+ for (const cwd of [
1077
+ join(workbaseRoot, "tasks/example"),
1078
+ join(workbaseRoot, "tasks/pipeline"),
1079
+ join(workbaseRoot, "epics/delivery"),
1080
+ ]) {
1081
+ const probe = Bun.spawnSync(["opencode", "debug", "agent", "build"], {
1082
+ cwd,
1083
+ env: directEnvironment,
1084
+ })
1085
+ expect(probe.exitCode).toBe(0)
1086
+ const agent = JSON.parse(probe.stdout.toString())
1087
+ expect(agent.permission).toEqual(
1088
+ expect.arrayContaining([
1089
+ expect.objectContaining({
1090
+ permission: "external_directory",
1091
+ pattern: join(workbaseRoot, "*"),
1092
+ action: "allow",
1093
+ }),
1094
+ ]),
1095
+ )
1096
+ for (const document of documents) {
1097
+ const read = Bun.spawnSync(
1098
+ [
1099
+ "opencode",
1100
+ "debug",
1101
+ "agent",
1102
+ "build",
1103
+ "--tool",
1104
+ "read",
1105
+ "--params",
1106
+ JSON.stringify({ filePath: document }),
1107
+ ],
1108
+ { cwd, env: directEnvironment },
1109
+ )
1110
+ expect(read.exitCode).toBe(0)
1111
+ expect(JSON.parse(read.stdout.toString()).result.output).toContain(
1112
+ `<path>${document}</path>`,
1113
+ )
1114
+ }
1115
+ }
1116
+
1083
1117
  parseJson(
1084
1118
  await runCli(["task", "status", "example", "done", "--json"], root),
1085
1119
  )
@@ -1135,7 +1169,7 @@ status: open
1135
1169
  graph.nodes.find((node: any) => node.id === "task:pipeline").status,
1136
1170
  ).toBe("working")
1137
1171
  },
1138
- 30_000,
1172
+ 90_000,
1139
1173
  )
1140
1174
 
1141
1175
  test("envelopes help and version output in machine mode", async () => {
@@ -486,23 +486,10 @@ describe("work command", () => {
486
486
  ],
487
487
  cwd: "/workbase/epics/delivery",
488
488
  })
489
- expect(harness.launchEnvironments[0]?.OPENCODE_CONFIG).toBe(
490
- "/workbase/.opencode/opencode.jsonc",
491
- )
489
+ expect(harness.launchEnvironments[0]?.OPENCODE_CONFIG).toBeUndefined()
492
490
  expect(
493
- JSON.parse(harness.launchEnvironments[0]!.OPENCODE_CONFIG_CONTENT!),
494
- ).toEqual({
495
- permission: {
496
- external_directory: { "/workbase/**": "allow" },
497
- edit: { "*": "deny" },
498
- },
499
- agent: {
500
- build: { permission: { edit: { "*": "deny" } } },
501
- plan: { permission: { edit: { "*": "deny" } } },
502
- general: { permission: { edit: { "*": "deny" } } },
503
- explore: { permission: { edit: { "*": "deny" } } },
504
- },
505
- })
491
+ harness.launchEnvironments[0]?.OPENCODE_CONFIG_CONTENT,
492
+ ).toBeUndefined()
506
493
  })
507
494
 
508
495
  test("resolves an existing positional path before treating it as a task ID", async () => {
@@ -553,9 +540,7 @@ describe("work command", () => {
553
540
  ],
554
541
  cwd: "/workbase/tasks/delivery",
555
542
  })
556
- expect(harness.launchEnvironments[0]?.OPENCODE_CONFIG).toBe(
557
- "/workbase/.opencode/opencode.jsonc",
558
- )
543
+ expect(harness.launchEnvironments[0]?.OPENCODE_CONFIG).toBeUndefined()
559
544
  })
560
545
 
561
546
  test("infers a phase from a nested checkout directory", async () => {
@@ -585,9 +570,7 @@ describe("work command", () => {
585
570
  expect(harness.statusUpdates).toEqual([
586
571
  "phase:example:implementation:working",
587
572
  ])
588
- expect(harness.launchEnvironments[0]?.OPENCODE_CONFIG).toBe(
589
- "/workbase/.opencode/opencode.jsonc",
590
- )
573
+ expect(harness.launchEnvironments[0]?.OPENCODE_CONFIG).toBeUndefined()
591
574
  })
592
575
 
593
576
  test("infers a single-phase task from a nested checkout directory", async () => {
@@ -601,9 +584,7 @@ describe("work command", () => {
601
584
 
602
585
  expect(harness.events[0]).toBe("materialize")
603
586
  expect(harness.launches[0]?.cwd).toBe(taskDirectory)
604
- expect(harness.launchEnvironments[0]?.OPENCODE_CONFIG).toBe(
605
- "/workbase/.opencode/opencode.jsonc",
606
- )
587
+ expect(harness.launchEnvironments[0]?.OPENCODE_CONFIG).toBeUndefined()
607
588
  })
608
589
 
609
590
  test("selects a target when no directory is provided", async () => {
@@ -781,9 +762,7 @@ describe("work command", () => {
781
762
  ])
782
763
  expect(harness.launchEnvironments[0]?.AGENCY_PROMPT).toBe("")
783
764
  expect(harness.statusUpdates).toEqual(["task:example:working"])
784
- expect(harness.launchEnvironments[0]?.OPENCODE_CONFIG).toBe(
785
- "/workbase/.opencode/opencode.jsonc",
786
- )
765
+ expect(harness.launchEnvironments[0]?.OPENCODE_CONFIG).toBeUndefined()
787
766
  expect(harness.progressUpdates).toEqual([
788
767
  "start:Preparing workspace...",
789
768
  "succeed:Workspace ready",
@@ -936,33 +915,15 @@ describe("work command", () => {
936
915
  expect(harness.taskStatuses.example).toBe("done")
937
916
  })
938
917
 
939
- test("prints the runtime OpenCode integration config", async () => {
918
+ test("does not inject runtime OpenCode configuration", async () => {
940
919
  const harness = createHarness()
941
920
  const output = await captureLogs(() =>
942
921
  harness.run({ taskId: "example", opencode: true, printCommand: true }),
943
922
  )
944
923
  const printed = JSON.parse(output.join("\n"))
945
924
 
946
- expect(printed.environment.OPENCODE_CONFIG).toBe(
947
- "/workbase/.opencode/opencode.jsonc",
948
- )
949
- const edit = {
950
- "*": "deny",
951
- "tasks/example/code/agency/**": "allow",
952
- "code/agency/**": "allow",
953
- }
954
- expect(JSON.parse(printed.environment.OPENCODE_CONFIG_CONTENT)).toEqual({
955
- permission: {
956
- external_directory: { "/workbase/**": "allow" },
957
- edit,
958
- },
959
- agent: {
960
- build: { permission: { edit } },
961
- plan: { permission: { edit } },
962
- general: { permission: { edit } },
963
- explore: { permission: { edit } },
964
- },
965
- })
925
+ expect(printed.environment.OPENCODE_CONFIG).toBeUndefined()
926
+ expect(printed.environment.OPENCODE_CONFIG_CONTENT).toBeUndefined()
966
927
  })
967
928
 
968
929
  test("does not override customized OpenCode access policy", async () => {
@@ -122,10 +122,7 @@ export const work = (
122
122
  const inputAllowed = options.inputAllowed ?? true
123
123
  const root = yield* resolveWorkbase(startPath, pickBase, inputAllowed)
124
124
  if (!root) return
125
- const integration = yield* integrations.sync(root)
126
- const managedOpencode = integration.files.some(
127
- (file) => file.name === "opencode" && file.state === "managed",
128
- )
125
+ yield* integrations.sync(root)
129
126
  const { config } = yield* workbase.loadConfig(root)
130
127
 
131
128
  let target: WorkTarget | null = null
@@ -239,7 +236,6 @@ export const work = (
239
236
  (target.status !== undefined && target.status !== "open"))
240
237
  let prompt: string
241
238
  let launchPath: string
242
- let writablePath: string | undefined
243
239
  if (target.kind === "epic") {
244
240
  prompt = `Work on the epic. Read ${target.path}.`
245
241
  launchPath = dirname(target.path)
@@ -265,7 +261,6 @@ export const work = (
265
261
  ? `${action} the task. Read ${workspace.taskPath} and ${workspace.phasePath}.`
266
262
  : `${action} the task. Read ${workspace.taskPath}.`
267
263
  launchPath = dirname(workspace.taskPath)
268
- writablePath = workspace.writablePath
269
264
  }
270
265
 
271
266
  const explicitlyRequested = Boolean(
@@ -336,35 +331,6 @@ export const work = (
336
331
  ...resolved.environment,
337
332
  ...runnerEnvironment(runner, variables),
338
333
  }
339
- if (runner === "opencode" && managedOpencode) {
340
- environment.OPENCODE_CONFIG = join(root, ".opencode", "opencode.jsonc")
341
- const execution =
342
- target.kind === "phase" ||
343
- (target.kind === "task" && !target.multiPhase)
344
- const edit = execution
345
- ? {
346
- "*": "deny" as const,
347
- ...Object.fromEntries(
348
- [root, launchPath].map((base) => [
349
- join(relative(base, writablePath!), "**").split(sep).join("/"),
350
- "allow" as const,
351
- ]),
352
- ),
353
- }
354
- : { "*": "deny" as const }
355
- environment.OPENCODE_CONFIG_CONTENT = JSON.stringify({
356
- permission: {
357
- external_directory: { [join(root, "**")]: "allow" },
358
- edit,
359
- },
360
- agent: {
361
- build: { permission: { edit } },
362
- plan: { permission: { edit } },
363
- general: { permission: { edit } },
364
- explore: { permission: { edit } },
365
- },
366
- })
367
- }
368
334
  if (options.printCommand) {
369
335
  log(
370
336
  JSON.stringify(
@@ -77,6 +77,53 @@ describe("chooser", () => {
77
77
  })
78
78
  })
79
79
 
80
+ test("passes hierarchy depth only to the native renderer", async () => {
81
+ let offered: readonly {
82
+ readonly key: string
83
+ readonly label: string
84
+ readonly depth?: number
85
+ readonly segments?: readonly {
86
+ readonly text: string
87
+ readonly color?: string
88
+ }[]
89
+ }[] = []
90
+ const io = createIO({
91
+ select: async (_prompt, choices) => {
92
+ offered = choices
93
+ return choices[0]!.key
94
+ },
95
+ })
96
+
97
+ await Effect.runPromise(
98
+ choose(
99
+ "Pick one",
100
+ [
101
+ {
102
+ key: "parent",
103
+ label: "Parent",
104
+ depth: 0,
105
+ segments: [{ text: "P", color: "#c6a0f6" }],
106
+ value: 1,
107
+ },
108
+ { key: "child", label: "Child", depth: 1, value: 2 },
109
+ ],
110
+ undefined,
111
+ io,
112
+ ),
113
+ )
114
+
115
+ expect(offered).toEqual([
116
+ {
117
+ key: "parent",
118
+ label: "Parent",
119
+ depth: 0,
120
+ segments: [{ text: "P", color: "#c6a0f6" }],
121
+ },
122
+ { key: "child", label: "Child", depth: 1 },
123
+ ])
124
+ expect(io.inputs).toEqual([])
125
+ })
126
+
80
127
  test("preserves colors for configured external choosers", async () => {
81
128
  const io = createIO({ color: true })
82
129
 
@@ -4,9 +4,16 @@ export interface Choice<T> {
4
4
  readonly key: string
5
5
  readonly label: string
6
6
  readonly plainLabel?: string
7
+ readonly depth?: number
8
+ readonly segments?: readonly ChoiceSegment[]
7
9
  readonly value: T
8
10
  }
9
11
 
12
+ export interface ChoiceSegment {
13
+ readonly text: string
14
+ readonly color?: string
15
+ }
16
+
10
17
  export type ChooserErrorReason =
11
18
  | "invalid-choices"
12
19
  | "input-unavailable"
@@ -36,7 +43,12 @@ export interface ChooserIO {
36
43
  readonly color: boolean
37
44
  readonly select: (
38
45
  prompt: string,
39
- choices: readonly { readonly key: string; readonly label: string }[],
46
+ choices: readonly {
47
+ readonly key: string
48
+ readonly label: string
49
+ readonly depth?: number
50
+ readonly segments?: readonly ChoiceSegment[]
51
+ }[],
40
52
  ) => Promise<string | null>
41
53
  readonly run: (
42
54
  command: readonly string[],
@@ -175,6 +187,8 @@ const nativeChoice = async <T>(
175
187
  choices.map((choice) => ({
176
188
  key: choice.key,
177
189
  label: displayLabel(choice, false),
190
+ ...(choice.depth === undefined ? {} : { depth: choice.depth }),
191
+ ...(choice.segments === undefined ? {} : { segments: choice.segments }),
178
192
  })),
179
193
  )
180
194
  } catch (cause) {