@markjaquith/agency 2.7.3 → 2.8.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
@@ -71,11 +71,12 @@ workbase/
71
71
  backend/
72
72
  ```
73
73
 
74
- Agency creates `AGENTS.md` and `.opencode/opencode.jsonc` during initialization
75
- and ensures they exist whenever the workbase is discovered. The OpenCode config
76
- grants external-directory access to task and epic references. Checksums in the
77
- generated files let newer Agency versions refresh unmodified content while
78
- preserving custom or edited files.
74
+ Agency keeps discovery and other observational commands read-only. Run
75
+ `agency integration status` to inspect `AGENTS.md` and
76
+ `.opencode/opencode.jsonc`, then `agency integration sync` to create missing
77
+ files or refresh checksum-safe managed files. The OpenCode config grants
78
+ external-directory access to task and epic references. Customized files are
79
+ reported but never overwritten.
79
80
 
80
81
  Repository metadata comes directly from Git under `repos/{alias}`. Workbase
81
82
  configuration may provide a custom writable-worktree creation command.
@@ -242,6 +243,8 @@ agency pr create refresh-copy
242
243
  agency init [path] [--json]
243
244
  agency workbase add <path> [--json]
244
245
  agency workbase list [--json]
246
+ agency integration status [--json]
247
+ agency integration sync [--json]
245
248
  agency repo add <alias> <remote> [--json]
246
249
  agency repo link <alias> <path> [--json]
247
250
  agency repo list [--json]
@@ -253,7 +256,8 @@ Registered workbases are stored in
253
256
  repository. Alias names are then used by all documents and commands.
254
257
 
255
258
  Commands that print Agency-owned results accept `--json`, including initialization,
256
- repository mutations, entity creation/list/show, status, validation, and PR creation.
259
+ integration inspection/sync, repository mutations, entity creation/list/show,
260
+ status, validation, and PR creation.
257
261
 
258
262
  ### Epics
259
263
 
package/cli.ts CHANGED
@@ -13,6 +13,10 @@ import { epic, help as epicHelp } from "./src/commands/epic"
13
13
  import { phase, help as phaseHelp } from "./src/commands/phase"
14
14
  import { archive, help as archiveHelp } from "./src/commands/archive"
15
15
  import { workbase, help as workbaseHelp } from "./src/commands/workbase"
16
+ import {
17
+ integration,
18
+ help as integrationHelp,
19
+ } from "./src/commands/integration"
16
20
  import type { Command } from "./src/types"
17
21
  import { FileSystemService } from "./src/services/FileSystemService"
18
22
  import { WorkbaseService } from "./src/services/WorkbaseService"
@@ -23,6 +27,7 @@ import { PhaseService } from "./src/services/PhaseService"
23
27
  import { WorktreeService } from "./src/services/WorktreeService"
24
28
  import { PullRequestService } from "./src/services/PullRequestService"
25
29
  import { ArchiveService } from "./src/services/ArchiveService"
30
+ import { IntegrationService } from "./src/services/IntegrationService"
26
31
 
27
32
  // Create CLI layer with all services
28
33
  const CliLayer = Layer.mergeAll(
@@ -35,6 +40,7 @@ const CliLayer = Layer.mergeAll(
35
40
  WorktreeService.Default,
36
41
  PullRequestService.Default,
37
42
  ArchiveService.Default,
43
+ IntegrationService.Default,
38
44
  )
39
45
 
40
46
  /**
@@ -191,6 +197,22 @@ const commands: Record<string, Command> = {
191
197
  )
192
198
  },
193
199
  },
200
+ integration: {
201
+ run: async (args: string[], options: Record<string, any>) => {
202
+ if (options.help) {
203
+ console.log(integrationHelp)
204
+ return
205
+ }
206
+ await runCommand(
207
+ integration({
208
+ subcommand: args[0],
209
+ json: options.json,
210
+ silent: options.silent,
211
+ verbose: options.verbose,
212
+ }),
213
+ )
214
+ },
215
+ },
194
216
  repo: {
195
217
  run: async (args: string[], options: Record<string, any>) => {
196
218
  if (options.help) {
@@ -296,6 +318,7 @@ Usage: agency <command> [options]
296
318
  Commands:
297
319
  init [path] Initialize an Agency workbase
298
320
  workbase <subcommand> Manage registered workbases
321
+ integration <command> Inspect or sync managed integration files
299
322
  epic <subcommand> Manage epics
300
323
  phase <subcommand> Manage task phases
301
324
  archive <type> Archive a work item
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "2.7.3",
3
+ "version": "2.8.0",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -39,6 +39,7 @@ From anywhere beneath a workbase, run:
39
39
  ```bash
40
40
  agency status --json
41
41
  agency validate --json
42
+ agency integration status --json
42
43
  agency repo list --json
43
44
  agency epic list --json
44
45
  agency task list --json
@@ -56,6 +57,7 @@ If no workbase is found, do not initialize one without user intent. When asked:
56
57
 
57
58
  ```bash
58
59
  agency init [path]
60
+ agency integration sync
59
61
  ```
60
62
 
61
63
  Register known workbases so `agency work` can select one when run elsewhere:
@@ -82,6 +82,8 @@ describe("strict CLI parsing", () => {
82
82
  [["init", "one", "two"], "agency init"],
83
83
  [["workbase", "add", "one", "two"], "agency workbase add"],
84
84
  [["workbase", "list", "extra"], "agency workbase list"],
85
+ [["integration", "status", "extra"], "agency integration status"],
86
+ [["integration", "sync", "extra"], "agency integration sync"],
85
87
  [["repo", "add", "a", "b", "extra"], "agency repo add"],
86
88
  [["repo", "link", "a", "b", "extra"], "agency repo link"],
87
89
  [["repo", "list", "extra"], "agency repo list"],
package/src/cli-parser.ts CHANGED
@@ -88,6 +88,24 @@ const commands = {
88
88
  },
89
89
  },
90
90
  },
91
+ integration: {
92
+ usage: "agency integration <status|sync>",
93
+ options: outputOptions,
94
+ subcommands: {
95
+ status: {
96
+ usage: "agency integration status [--json]",
97
+ minArgs: 0,
98
+ maxArgs: 0,
99
+ options: ["json"],
100
+ },
101
+ sync: {
102
+ usage: "agency integration sync [--json]",
103
+ minArgs: 0,
104
+ maxArgs: 0,
105
+ options: ["json"],
106
+ },
107
+ },
108
+ },
91
109
  repo: {
92
110
  usage: "agency repo <add|link|list>",
93
111
  options: outputOptions,
package/src/cli.test.ts CHANGED
@@ -106,6 +106,7 @@ describe("CLI", () => {
106
106
  for (const [command, usage] of [
107
107
  ["init", "Usage: agency init"],
108
108
  ["workbase", "Usage: agency workbase"],
109
+ ["integration", "Usage: agency integration"],
109
110
  ["repo", "Usage: agency repo"],
110
111
  ["epic", "Usage: agency epic"],
111
112
  ["task", "Usage: agency task"],
@@ -136,6 +137,28 @@ describe("CLI", () => {
136
137
  expect(after).toEqual({ exitCode: 0, stdout: "", stderr: "" })
137
138
  })
138
139
 
140
+ test("reports and synchronizes managed integration files", async () => {
141
+ const root = await createTempDir()
142
+ tempDirs.push(root)
143
+ expect((await runCli(["init", root])).exitCode).toBe(0)
144
+
145
+ const before = parseJson(
146
+ await runCli(["integration", "status", "--json"], root),
147
+ )
148
+ expect(before.files).toMatchObject([
149
+ { name: "agents", state: "missing" },
150
+ { name: "opencode", state: "missing" },
151
+ ])
152
+
153
+ const synced = parseJson(
154
+ await runCli(["integration", "sync", "--json"], root),
155
+ )
156
+ expect(synced.files).toMatchObject([
157
+ { name: "agents", state: "managed", changed: true },
158
+ { name: "opencode", state: "managed", changed: true },
159
+ ])
160
+ })
161
+
139
162
  test("registers and lists workbases", async () => {
140
163
  const parent = await createTempDir()
141
164
  tempDirs.push(parent)
@@ -33,15 +33,10 @@ describe("init command", () => {
33
33
  expect(await Bun.file(join(root, ".gitignore")).text()).toBe(
34
34
  "/repos/\n/tasks/*/code/\n/tasks/*/phases/*/code/\n",
35
35
  )
36
- expect(await Bun.file(join(root, "AGENTS.md")).text()).toContain(
37
- "# Agency Workbase",
38
- )
39
- expect(
40
- await Bun.file(join(root, ".opencode/opencode.jsonc")).text(),
41
- ).toContain('"path": "../tasks"')
36
+ expect(await Bun.file(join(root, "AGENTS.md")).exists()).toBe(false)
42
37
  expect(
43
- await Bun.file(join(root, ".opencode/opencode.jsonc")).text(),
44
- ).toContain(`"${join(root, "tasks")}/*": "allow"`)
38
+ await Bun.file(join(root, ".opencode/opencode.jsonc")).exists(),
39
+ ).toBe(false)
45
40
  })
46
41
 
47
42
  test("preserves existing gitignore entries", async () => {
@@ -0,0 +1,51 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test"
2
+ import { join } from "node:path"
3
+ import {
4
+ captureLogs,
5
+ cleanupTempDir,
6
+ createTempDir,
7
+ runTestEffect,
8
+ } from "../test-utils"
9
+ import { integration } from "./integration"
10
+
11
+ describe("integration command", () => {
12
+ let root: string
13
+
14
+ beforeEach(async () => {
15
+ root = await createTempDir()
16
+ await Bun.write(join(root, "agency.json"), '{"version":2}\n')
17
+ })
18
+
19
+ afterEach(async () => cleanupTempDir(root))
20
+
21
+ test("reports integration status as JSON", async () => {
22
+ const logs = await captureLogs(() =>
23
+ runTestEffect(
24
+ integration({ subcommand: "status", cwd: root, json: true }),
25
+ ),
26
+ )
27
+
28
+ expect(JSON.parse(logs[0]!)).toMatchObject({
29
+ root,
30
+ files: [
31
+ { name: "agents", state: "missing" },
32
+ { name: "opencode", state: "missing" },
33
+ ],
34
+ })
35
+ })
36
+
37
+ test("explicitly synchronizes integration files", async () => {
38
+ const logs = await captureLogs(() =>
39
+ runTestEffect(integration({ subcommand: "sync", cwd: root, json: true })),
40
+ )
41
+
42
+ expect(JSON.parse(logs[0]!).files).toMatchObject([
43
+ { name: "agents", state: "managed", changed: true },
44
+ { name: "opencode", state: "managed", changed: true },
45
+ ])
46
+ expect(await Bun.file(join(root, "AGENTS.md")).exists()).toBe(true)
47
+ expect(
48
+ await Bun.file(join(root, ".opencode/opencode.jsonc")).exists(),
49
+ ).toBe(true)
50
+ })
51
+ })
@@ -0,0 +1,62 @@
1
+ import { Effect } from "effect"
2
+ import type { BaseCommandOptions } from "../utils/command"
3
+ import { IntegrationService } from "../services/IntegrationService"
4
+ import { createLoggers } from "../utils/effect"
5
+
6
+ interface IntegrationOptions extends BaseCommandOptions {
7
+ readonly subcommand?: string
8
+ readonly json?: boolean
9
+ }
10
+
11
+ export const integration = (options: IntegrationOptions) =>
12
+ Effect.gen(function* () {
13
+ const service = yield* IntegrationService
14
+ const { log } = createLoggers(options)
15
+ const cwd = options.cwd ?? process.cwd()
16
+
17
+ switch (options.subcommand) {
18
+ case "status": {
19
+ const result = yield* service.status(cwd)
20
+ if (options.json) {
21
+ log(JSON.stringify(result, null, 2))
22
+ return
23
+ }
24
+ for (const file of result.files) {
25
+ log(`${file.name}\t${file.state}\t${file.path}`)
26
+ }
27
+ return
28
+ }
29
+
30
+ case "sync": {
31
+ const result = yield* service.sync(cwd)
32
+ if (options.json) {
33
+ log(JSON.stringify(result, null, 2))
34
+ return
35
+ }
36
+ for (const file of result.files) {
37
+ log(
38
+ `${file.name}\t${file.changed ? "synced" : file.state}\t${file.path}`,
39
+ )
40
+ }
41
+ return
42
+ }
43
+
44
+ default:
45
+ return yield* Effect.fail(
46
+ new Error("Subcommand is required. Available: status, sync"),
47
+ )
48
+ }
49
+ })
50
+
51
+ export const help = `
52
+ Usage: agency integration <subcommand>
53
+
54
+ Inspect or explicitly synchronize managed agent integration files.
55
+
56
+ Subcommands:
57
+ status Report managed, customized, missing, and drifted files
58
+ sync Create or update checksum-safe managed files
59
+
60
+ Options:
61
+ --json Output results as JSON
62
+ `
@@ -0,0 +1,144 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test"
2
+ import { Effect } from "effect"
3
+ import { chmod, lstat, mkdir, readdir } from "node:fs/promises"
4
+ import { dirname, join } from "node:path"
5
+ import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
6
+ import { WorkbaseService } from "../services/WorkbaseService"
7
+ import { epic } from "./epic"
8
+ import { integration } from "./integration"
9
+ import { phase } from "./phase"
10
+ import { repo } from "./repo"
11
+ import { status } from "./status"
12
+ import { task } from "./task"
13
+ import { validate } from "./validate"
14
+
15
+ const write = async (root: string, path: string, content: string) => {
16
+ const fullPath = join(root, path)
17
+ await mkdir(dirname(fullPath), { recursive: true })
18
+ await Bun.write(fullPath, content)
19
+ }
20
+
21
+ const setReadOnly = async (path: string, readOnly: boolean): Promise<void> => {
22
+ const metadata = await lstat(path)
23
+ if (!metadata.isDirectory()) {
24
+ await chmod(path, readOnly ? 0o444 : 0o644)
25
+ return
26
+ }
27
+
28
+ if (!readOnly) await chmod(path, 0o755)
29
+ for (const entry of await readdir(path)) {
30
+ await setReadOnly(join(path, entry), readOnly)
31
+ }
32
+ if (readOnly) await chmod(path, 0o555)
33
+ }
34
+
35
+ describe("observational commands", () => {
36
+ let root: string
37
+
38
+ beforeEach(async () => {
39
+ root = await createTempDir()
40
+ await write(root, "agency.json", '{"version":2}\n')
41
+ await mkdir(join(root, "repos/agency"), { recursive: true })
42
+ await write(
43
+ root,
44
+ "epics/example/EPIC.md",
45
+ `---
46
+ ticketUrl: https://example.com/epics/example
47
+ repos:
48
+ - repo: agency
49
+ ref: main
50
+ tasks:
51
+ - id: example-task
52
+ ---
53
+
54
+ # Example
55
+ `,
56
+ )
57
+ await write(
58
+ root,
59
+ "tasks/example-task/TASK.md",
60
+ `---
61
+ ticketUrl: null
62
+ epic: example
63
+ phases:
64
+ - id: implementation
65
+ ---
66
+
67
+ # Example task
68
+ `,
69
+ )
70
+ await write(
71
+ root,
72
+ "tasks/example-task/phases/implementation/PHASE.md",
73
+ `---
74
+ repo: agency
75
+ branch: feat/example
76
+ base: main
77
+ pr: null
78
+ status: open
79
+ ---
80
+
81
+ # Implementation
82
+ `,
83
+ )
84
+ })
85
+
86
+ afterEach(async () => {
87
+ await setReadOnly(root, false)
88
+ await cleanupTempDir(root)
89
+ })
90
+
91
+ test("remain usable when the workbase is read-only", async () => {
92
+ await setReadOnly(root, true)
93
+
94
+ await runTestEffect(
95
+ WorkbaseService.pipe(Effect.flatMap((service) => service.discover(root))),
96
+ )
97
+ await runTestEffect(
98
+ integration({ subcommand: "status", cwd: root, silent: true }),
99
+ )
100
+ await runTestEffect(
101
+ epic({ subcommand: "list", args: [], cwd: root, silent: true }),
102
+ )
103
+ await runTestEffect(
104
+ epic({ subcommand: "show", args: ["example"], cwd: root, silent: true }),
105
+ )
106
+ await runTestEffect(
107
+ task({ subcommand: "list", args: [], cwd: root, silent: true }),
108
+ )
109
+ await runTestEffect(
110
+ task({
111
+ subcommand: "show",
112
+ args: ["example-task"],
113
+ cwd: root,
114
+ silent: true,
115
+ }),
116
+ )
117
+ await runTestEffect(
118
+ phase({
119
+ subcommand: "list",
120
+ args: ["example-task"],
121
+ cwd: root,
122
+ silent: true,
123
+ }),
124
+ )
125
+ await runTestEffect(
126
+ phase({
127
+ subcommand: "show",
128
+ args: ["example-task", "implementation"],
129
+ cwd: root,
130
+ silent: true,
131
+ }),
132
+ )
133
+ await runTestEffect(
134
+ repo({ subcommand: "list", args: [], cwd: root, silent: true }),
135
+ )
136
+ await runTestEffect(status({ cwd: root, silent: true }))
137
+ await runTestEffect(validate({ path: root, silent: true }))
138
+
139
+ expect(await Bun.file(join(root, "AGENTS.md")).exists()).toBe(false)
140
+ expect(
141
+ await Bun.file(join(root, ".opencode/opencode.jsonc")).exists(),
142
+ ).toBe(false)
143
+ })
144
+ })
@@ -0,0 +1,137 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test"
2
+ import { Effect } from "effect"
3
+ import { createHash } from "node:crypto"
4
+ import { mkdir, symlink, unlink } from "node:fs/promises"
5
+ import { dirname, join } from "node:path"
6
+ import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
7
+ import { managedWorkbaseAgents } from "../workbase/agents-file"
8
+ import { managedWorkbaseOpencode } from "../workbase/opencode-file"
9
+ import { IntegrationService } from "./IntegrationService"
10
+
11
+ const write = async (root: string, path: string, content: string) => {
12
+ const fullPath = join(root, path)
13
+ await mkdir(dirname(fullPath), { recursive: true })
14
+ await Bun.write(fullPath, content)
15
+ }
16
+
17
+ const managed = (prefix: string, body: string, suffix = "") => {
18
+ const checksum = createHash("sha256").update(body).digest("hex")
19
+ return `${prefix}${checksum}${suffix}\n\n${body}`
20
+ }
21
+
22
+ const status = (root: string) =>
23
+ runTestEffect(
24
+ IntegrationService.pipe(Effect.flatMap((service) => service.status(root))),
25
+ )
26
+
27
+ const sync = (root: string) =>
28
+ runTestEffect(
29
+ IntegrationService.pipe(Effect.flatMap((service) => service.sync(root))),
30
+ )
31
+
32
+ describe("IntegrationService", () => {
33
+ let root: string
34
+
35
+ beforeEach(async () => {
36
+ root = await createTempDir()
37
+ await write(root, "agency.json", '{"version":2}\n')
38
+ })
39
+
40
+ afterEach(async () => cleanupTempDir(root))
41
+
42
+ test("reports missing and current managed files without writing", async () => {
43
+ expect((await status(root)).files.map(({ state }) => state)).toEqual([
44
+ "missing",
45
+ "missing",
46
+ ])
47
+ expect(await Bun.file(join(root, "AGENTS.md")).exists()).toBe(false)
48
+
49
+ await write(root, "AGENTS.md", managedWorkbaseAgents)
50
+ await write(root, ".opencode/opencode.jsonc", managedWorkbaseOpencode(root))
51
+ expect((await status(root)).files.map(({ state }) => state)).toEqual([
52
+ "managed",
53
+ "managed",
54
+ ])
55
+ })
56
+
57
+ test("reports customized and checksum-safe drifted files", async () => {
58
+ await write(root, "AGENTS.md", "# Custom instructions\n")
59
+ await write(
60
+ root,
61
+ ".opencode/opencode.jsonc",
62
+ managed("// agency-managed: sha256=", '{"references":{}}\n'),
63
+ )
64
+
65
+ expect((await status(root)).files.map(({ state }) => state)).toEqual([
66
+ "customized",
67
+ "drifted",
68
+ ])
69
+ })
70
+
71
+ test("treats an existing JSON OpenCode config as customized", async () => {
72
+ await write(root, ".opencode/opencode.json", '{"model":"test/model"}\n')
73
+
74
+ const result = await status(root)
75
+ expect(result.files[1]).toEqual({
76
+ name: "opencode",
77
+ path: join(root, ".opencode/opencode.json"),
78
+ state: "customized",
79
+ })
80
+ })
81
+
82
+ test("syncs missing and drifted files while preserving customized files", async () => {
83
+ const customAgents = "# Custom instructions\n"
84
+ await write(root, "AGENTS.md", customAgents)
85
+ await write(
86
+ root,
87
+ ".opencode/opencode.jsonc",
88
+ managed("// agency-managed: sha256=", '{"references":{}}\n'),
89
+ )
90
+
91
+ const first = await sync(root)
92
+ expect(first.files).toMatchObject([
93
+ { name: "agents", state: "customized", changed: false },
94
+ { name: "opencode", state: "managed", changed: true },
95
+ ])
96
+ expect(await Bun.file(join(root, "AGENTS.md")).text()).toBe(customAgents)
97
+ expect(await Bun.file(join(root, ".opencode/opencode.jsonc")).text()).toBe(
98
+ managedWorkbaseOpencode(root),
99
+ )
100
+
101
+ await unlink(join(root, "AGENTS.md"))
102
+ const second = await sync(root)
103
+ expect(second.files[0]).toMatchObject({ state: "managed", changed: true })
104
+ expect(await Bun.file(join(root, "AGENTS.md")).text()).toBe(
105
+ managedWorkbaseAgents,
106
+ )
107
+ })
108
+
109
+ test("does not overwrite managed files whose checksums no longer match", async () => {
110
+ const tampered = `${managed(
111
+ "<!-- agency-managed: sha256=",
112
+ "# Previous Agency instructions\n",
113
+ " -->",
114
+ )}User edit\n`
115
+ await write(root, "AGENTS.md", tampered)
116
+
117
+ const result = await sync(root)
118
+ expect(result.files[0]).toMatchObject({
119
+ state: "customized",
120
+ changed: false,
121
+ })
122
+ expect(await Bun.file(join(root, "AGENTS.md")).text()).toBe(tampered)
123
+ })
124
+
125
+ test("does not follow symlinked integration files", async () => {
126
+ const target = join(root, "custom-agents.md")
127
+ await Bun.write(target, "# External instructions\n")
128
+ await symlink(target, join(root, "AGENTS.md"))
129
+
130
+ const result = await sync(root)
131
+ expect(result.files[0]).toMatchObject({
132
+ state: "customized",
133
+ changed: false,
134
+ })
135
+ expect(await Bun.file(target).text()).toBe("# External instructions\n")
136
+ })
137
+ })
@@ -0,0 +1,136 @@
1
+ import { Effect } from "effect"
2
+ import { join } from "node:path"
3
+ import { FileSystemService } from "./FileSystemService"
4
+ import { WorkbaseService } from "./WorkbaseService"
5
+ import {
6
+ canUpdateManagedWorkbaseAgents,
7
+ managedWorkbaseAgents,
8
+ } from "../workbase/agents-file"
9
+ import {
10
+ canUpdateManagedWorkbaseOpencode,
11
+ managedWorkbaseOpencode,
12
+ } from "../workbase/opencode-file"
13
+
14
+ type IntegrationFileState = "managed" | "customized" | "missing" | "drifted"
15
+
16
+ interface IntegrationFileStatus {
17
+ readonly name: "agents" | "opencode"
18
+ readonly path: string
19
+ readonly state: IntegrationFileState
20
+ }
21
+
22
+ interface IntegrationSyncFile extends IntegrationFileStatus {
23
+ readonly changed: boolean
24
+ }
25
+
26
+ const classify = (
27
+ name: IntegrationFileStatus["name"],
28
+ path: string,
29
+ content: string,
30
+ managed: string,
31
+ canUpdate: (content: string) => boolean,
32
+ ): IntegrationFileStatus => ({
33
+ name,
34
+ path,
35
+ state:
36
+ content === managed
37
+ ? "managed"
38
+ : canUpdate(content)
39
+ ? "drifted"
40
+ : "customized",
41
+ })
42
+
43
+ const inspect = (root: string) =>
44
+ Effect.gen(function* () {
45
+ const fs = yield* FileSystemService
46
+ const agentsPath = join(root, "AGENTS.md")
47
+ const opencodeDirectory = join(root, ".opencode")
48
+ const opencodePath = join(opencodeDirectory, "opencode.jsonc")
49
+ const opencodeJsonPath = join(opencodeDirectory, "opencode.json")
50
+ const files: IntegrationFileStatus[] = []
51
+
52
+ files.push(
53
+ (yield* fs.readSymlinkTarget(agentsPath)) !== null
54
+ ? { name: "agents", path: agentsPath, state: "customized" }
55
+ : (yield* fs.exists(agentsPath))
56
+ ? classify(
57
+ "agents",
58
+ agentsPath,
59
+ yield* fs.readFile(agentsPath),
60
+ managedWorkbaseAgents,
61
+ canUpdateManagedWorkbaseAgents,
62
+ )
63
+ : { name: "agents", path: agentsPath, state: "missing" },
64
+ )
65
+
66
+ if ((yield* fs.readSymlinkTarget(opencodePath)) !== null) {
67
+ files.push({
68
+ name: "opencode",
69
+ path: opencodePath,
70
+ state: "customized",
71
+ })
72
+ } else if (yield* fs.exists(opencodePath)) {
73
+ files.push(
74
+ classify(
75
+ "opencode",
76
+ opencodePath,
77
+ yield* fs.readFile(opencodePath),
78
+ managedWorkbaseOpencode(root),
79
+ canUpdateManagedWorkbaseOpencode,
80
+ ),
81
+ )
82
+ } else if (yield* fs.exists(opencodeJsonPath)) {
83
+ files.push({
84
+ name: "opencode",
85
+ path: opencodeJsonPath,
86
+ state: "customized",
87
+ })
88
+ } else {
89
+ files.push({ name: "opencode", path: opencodePath, state: "missing" })
90
+ }
91
+
92
+ return files
93
+ })
94
+
95
+ export class IntegrationService extends Effect.Service<IntegrationService>()(
96
+ "IntegrationService",
97
+ {
98
+ sync: () => ({
99
+ status: (startPath: string = process.cwd()) =>
100
+ Effect.gen(function* () {
101
+ const workbase = yield* WorkbaseService
102
+ const root = yield* workbase.discover(startPath)
103
+ return { root, files: yield* inspect(root) }
104
+ }),
105
+
106
+ sync: (startPath: string = process.cwd()) =>
107
+ Effect.gen(function* () {
108
+ const fs = yield* FileSystemService
109
+ const workbase = yield* WorkbaseService
110
+ const root = yield* workbase.discover(startPath)
111
+ const statuses = yield* inspect(root)
112
+ const files: IntegrationSyncFile[] = []
113
+
114
+ for (const status of statuses) {
115
+ const changed =
116
+ status.state === "missing" || status.state === "drifted"
117
+ if (changed) {
118
+ if (status.name === "agents") {
119
+ yield* fs.writeFile(status.path, managedWorkbaseAgents)
120
+ } else {
121
+ yield* fs.createDirectory(join(root, ".opencode"))
122
+ yield* fs.writeFile(status.path, managedWorkbaseOpencode(root))
123
+ }
124
+ }
125
+ files.push({
126
+ ...status,
127
+ state: changed ? "managed" : status.state,
128
+ changed,
129
+ })
130
+ }
131
+
132
+ return { root, files }
133
+ }),
134
+ }),
135
+ },
136
+ ) {}
@@ -1,11 +1,8 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test"
2
2
  import { Effect } from "effect"
3
- import { createHash } from "node:crypto"
4
3
  import { mkdir, realpath } from "node:fs/promises"
5
4
  import { dirname, join } from "node:path"
6
5
  import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
7
- import { managedWorkbaseAgents } from "../workbase/agents-file"
8
- import { managedWorkbaseOpencode } from "../workbase/opencode-file"
9
6
  import { WorkbaseService } from "./WorkbaseService"
10
7
 
11
8
  const write = async (root: string, path: string, content: string) => {
@@ -14,16 +11,6 @@ const write = async (root: string, path: string, content: string) => {
14
11
  await Bun.write(fullPath, content)
15
12
  }
16
13
 
17
- const managedAgents = (body: string) => {
18
- const checksum = createHash("sha256").update(body).digest("hex")
19
- return `<!-- agency-managed: sha256=${checksum} -->\n\n${body}`
20
- }
21
-
22
- const managedOpencode = (body: string) => {
23
- const checksum = createHash("sha256").update(body).digest("hex")
24
- return `// agency-managed: sha256=${checksum}\n\n${body}`
25
- }
26
-
27
14
  describe("WorkbaseService", () => {
28
15
  let root: string
29
16
 
@@ -48,12 +35,10 @@ describe("WorkbaseService", () => {
48
35
  )
49
36
 
50
37
  expect(discovered).toBe(root)
51
- expect(await Bun.file(join(root, "AGENTS.md")).text()).toBe(
52
- managedWorkbaseAgents,
53
- )
54
- expect(await Bun.file(join(root, ".opencode/opencode.jsonc")).text()).toBe(
55
- managedWorkbaseOpencode(root),
56
- )
38
+ expect(await Bun.file(join(root, "AGENTS.md")).exists()).toBe(false)
39
+ expect(
40
+ await Bun.file(join(root, ".opencode/opencode.jsonc")).exists(),
41
+ ).toBe(false)
57
42
  })
58
43
 
59
44
  test("registers canonical workbase paths without duplicates", async () => {
@@ -88,105 +73,6 @@ describe("WorkbaseService", () => {
88
73
  ).toEqual({ version: 1, workbases: [first] })
89
74
  })
90
75
 
91
- test("preserves an unmanaged workbase OpenCode config", async () => {
92
- await write(root, "agency.json", '{"version":2}\n')
93
- await write(root, ".opencode/opencode.jsonc", '{"model":"test/model"}\n')
94
-
95
- await runTestEffect(
96
- WorkbaseService.pipe(Effect.flatMap((service) => service.discover(root))),
97
- )
98
-
99
- expect(await Bun.file(join(root, ".opencode/opencode.jsonc")).text()).toBe(
100
- '{"model":"test/model"}\n',
101
- )
102
- })
103
-
104
- test("does not override an existing JSON OpenCode config", async () => {
105
- await write(root, "agency.json", '{"version":2}\n')
106
- await write(root, ".opencode/opencode.json", '{"model":"test/model"}\n')
107
-
108
- await runTestEffect(
109
- WorkbaseService.pipe(Effect.flatMap((service) => service.discover(root))),
110
- )
111
-
112
- expect(
113
- await Bun.file(join(root, ".opencode/opencode.jsonc")).exists(),
114
- ).toBe(false)
115
- })
116
-
117
- test("updates an unmodified managed workbase OpenCode config", async () => {
118
- await write(root, "agency.json", '{"version":2}\n')
119
- await write(
120
- root,
121
- ".opencode/opencode.jsonc",
122
- managedOpencode('{"references":{}}\n'),
123
- )
124
-
125
- await runTestEffect(
126
- WorkbaseService.pipe(Effect.flatMap((service) => service.discover(root))),
127
- )
128
-
129
- expect(await Bun.file(join(root, ".opencode/opencode.jsonc")).text()).toBe(
130
- managedWorkbaseOpencode(root),
131
- )
132
- })
133
-
134
- test("preserves a modified managed workbase OpenCode config", async () => {
135
- await write(root, "agency.json", '{"version":2}\n')
136
- const content = `${managedOpencode('{"references":{}}\n')}\n// User edit\n`
137
- await write(root, ".opencode/opencode.jsonc", content)
138
-
139
- await runTestEffect(
140
- WorkbaseService.pipe(Effect.flatMap((service) => service.discover(root))),
141
- )
142
-
143
- expect(await Bun.file(join(root, ".opencode/opencode.jsonc")).text()).toBe(
144
- content,
145
- )
146
- })
147
-
148
- test("preserves an unmanaged workbase AGENTS.md", async () => {
149
- await write(root, "agency.json", '{"version":2}\n')
150
- await write(root, "AGENTS.md", "# Custom instructions\n")
151
-
152
- await runTestEffect(
153
- WorkbaseService.pipe(Effect.flatMap((service) => service.discover(root))),
154
- )
155
-
156
- expect(await Bun.file(join(root, "AGENTS.md")).text()).toBe(
157
- "# Custom instructions\n",
158
- )
159
- })
160
-
161
- test("updates an unmodified managed workbase AGENTS.md", async () => {
162
- await write(root, "agency.json", '{"version":2}\n')
163
- await write(
164
- root,
165
- "AGENTS.md",
166
- managedAgents("# Previous Agency instructions\n"),
167
- )
168
-
169
- await runTestEffect(
170
- WorkbaseService.pipe(Effect.flatMap((service) => service.discover(root))),
171
- )
172
-
173
- expect(await Bun.file(join(root, "AGENTS.md")).text()).toBe(
174
- managedWorkbaseAgents,
175
- )
176
- })
177
-
178
- test("preserves a modified managed workbase AGENTS.md", async () => {
179
- await write(root, "agency.json", '{"version":2}\n')
180
- const content = `${managedAgents("# Previous Agency instructions\n")}\nUser edit\n`
181
- await write(root, "AGENTS.md", content)
182
-
183
- await runTestEffect(
184
- WorkbaseService.pipe(Effect.flatMap((service) => service.discover(root))),
185
- )
186
-
187
- expect(await Bun.file(join(root, "AGENTS.md")).text()).toBe(content)
188
- })
189
-
190
76
  test("rejects an invalid worktree command template", async () => {
191
77
  await write(
192
78
  root,
@@ -17,14 +17,6 @@ import {
17
17
  type TaskFrontmatter as TaskData,
18
18
  } from "../workbase/schemas"
19
19
  import { validateWorktreeCreateCommand } from "../workbase/worktree-command"
20
- import {
21
- canUpdateManagedWorkbaseAgents,
22
- managedWorkbaseAgents,
23
- } from "../workbase/agents-file"
24
- import {
25
- canUpdateManagedWorkbaseOpencode,
26
- managedWorkbaseOpencode,
27
- } from "../workbase/opencode-file"
28
20
 
29
21
  class WorkbaseNotFoundError extends Data.TaggedError("WorkbaseNotFoundError")<{
30
22
  readonly message: string
@@ -119,48 +111,6 @@ const readRegistry = (configDirectory?: string) =>
119
111
  return { path, registry: decoded.value }
120
112
  })
121
113
 
122
- const ensureWorkbaseAgents = (root: string) =>
123
- Effect.gen(function* () {
124
- const fs = yield* FileSystemService
125
- const path = join(root, "AGENTS.md")
126
- if (!(yield* fs.exists(path))) {
127
- yield* fs.writeFile(path, managedWorkbaseAgents)
128
- return
129
- }
130
-
131
- const content = yield* fs.readFile(path)
132
- if (
133
- content !== managedWorkbaseAgents &&
134
- canUpdateManagedWorkbaseAgents(content)
135
- ) {
136
- yield* fs.writeFile(path, managedWorkbaseAgents)
137
- }
138
- })
139
-
140
- const ensureWorkbaseOpencode = (root: string) =>
141
- Effect.gen(function* () {
142
- const fs = yield* FileSystemService
143
- const directory = join(root, ".opencode")
144
- const path = join(directory, "opencode.jsonc")
145
- const managed = managedWorkbaseOpencode(root)
146
- if (!(yield* fs.exists(path))) {
147
- if (yield* fs.exists(join(directory, "opencode.json"))) return
148
- yield* fs.createDirectory(directory)
149
- yield* fs.writeFile(path, managed)
150
- return
151
- }
152
-
153
- const content = yield* fs.readFile(path)
154
- if (content !== managed && canUpdateManagedWorkbaseOpencode(content)) {
155
- yield* fs.writeFile(path, managed)
156
- }
157
- })
158
-
159
- const ensureWorkbaseAgentFiles = (root: string) =>
160
- Effect.all([ensureWorkbaseAgents(root), ensureWorkbaseOpencode(root)], {
161
- concurrency: "unbounded",
162
- })
163
-
164
114
  const findCycles = (nodes: readonly Dependency[]): readonly string[] => {
165
115
  const dependencies = new Map(
166
116
  nodes.map((node) => [node.id, [...(node.dependsOn ?? [])]]),
@@ -239,8 +189,6 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
239
189
  `${existing}${prefix}${missing.join("\n")}\n`,
240
190
  )
241
191
  }
242
- yield* ensureWorkbaseAgentFiles(root)
243
-
244
192
  return root
245
193
  }),
246
194
 
@@ -296,7 +244,6 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
296
244
  })
297
245
  }
298
246
  }
299
- yield* ensureWorkbaseAgentFiles(current)
300
247
  return current
301
248
  }
302
249
  }
package/src/test-utils.ts CHANGED
@@ -12,6 +12,7 @@ import { PhaseService } from "./services/PhaseService"
12
12
  import { WorktreeService } from "./services/WorktreeService"
13
13
  import { PullRequestService } from "./services/PullRequestService"
14
14
  import { ArchiveService } from "./services/ArchiveService"
15
+ import { IntegrationService } from "./services/IntegrationService"
15
16
 
16
17
  export const createTempDir = () => mkdtemp(join(tmpdir(), "agency-test-"))
17
18
 
@@ -28,6 +29,7 @@ const TestLayer = Layer.mergeAll(
28
29
  WorktreeService.Default,
29
30
  PullRequestService.Default,
30
31
  ArchiveService.Default,
32
+ IntegrationService.Default,
31
33
  )
32
34
 
33
35
  export async function runTestEffect<A, E>(
@@ -22,6 +22,8 @@ field. Repositories listed in plural `repos` are read-only references.
22
22
 
23
23
  ## Safety
24
24
 
25
+ - Discovery and observational commands are read-only; use
26
+ `agency integration sync` to update managed agent files explicitly.
25
27
  - Keep task-level decisions in `TASK.md` and phase-specific delivery context in
26
28
  `PHASE.md`.
27
29
  - Keep execution-unit `status` current with `agency task status` or