@markjaquith/agency 2.7.2 → 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.
@@ -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>(
@@ -5,6 +5,8 @@ export interface BaseCommandOptions {
5
5
  readonly silent?: boolean
6
6
  readonly verbose?: boolean
7
7
  readonly json?: boolean
8
+ /** Whether this invocation may open prompts or selectors. */
9
+ readonly inputAllowed?: boolean
8
10
  /**
9
11
  * Working directory to use instead of process.cwd().
10
12
  * Primarily used for testing to enable concurrent test execution.
@@ -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
@@ -34,6 +34,7 @@ export const resolveWorkbase = (
34
34
  startPath: string,
35
35
  log: (message: string) => void,
36
36
  pick: PickWorkbase = pickWorkbase,
37
+ inputAllowed = true,
37
38
  ) =>
38
39
  Effect.gen(function* () {
39
40
  const fs = yield* FileSystemService
@@ -50,6 +51,13 @@ export const resolveWorkbase = (
50
51
  ),
51
52
  )
52
53
  }
54
+ if (!inputAllowed) {
55
+ return yield* Effect.fail(
56
+ new Error(
57
+ "Workbase selection requires interactive input; provide an explicit path or run Agency from a workbase",
58
+ ),
59
+ )
60
+ }
53
61
 
54
62
  const fzf = yield* fs.runCommand(["which", "fzf"], {
55
63
  captureOutput: true,