@markjaquith/agency 2.7.3 → 2.9.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,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
  }
@@ -3,7 +3,7 @@ import { Effect } from "effect"
3
3
  import { mkdir, rm } from "node:fs/promises"
4
4
  import { join } from "node:path"
5
5
  import {
6
- captureLogs,
6
+ captureErrors,
7
7
  cleanupTempDir,
8
8
  createTempDir,
9
9
  runTestEffect,
@@ -287,7 +287,7 @@ describe("WorktreeService", () => {
287
287
  ),
288
288
  )
289
289
 
290
- const logs = await captureLogs(() =>
290
+ const logs = await captureErrors(() =>
291
291
  runTestEffect(
292
292
  WorktreeService.pipe(
293
293
  Effect.flatMap((service) =>
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>(
@@ -43,17 +45,24 @@ export async function runTestEffect<A, E>(
43
45
  return Effect.runPromise(program)
44
46
  }
45
47
 
46
- export async function captureLogs(
48
+ async function captureConsole(
49
+ method: "log" | "error",
47
50
  run: () => Promise<unknown>,
48
51
  ): Promise<string[]> {
49
52
  const logs: string[] = []
50
- const log = spyOn(console, "log").mockImplementation((...args) => {
53
+ const output = spyOn(console, method).mockImplementation((...args) => {
51
54
  logs.push(args.join(" "))
52
55
  })
53
56
  try {
54
57
  await run()
55
58
  return logs
56
59
  } finally {
57
- log.mockRestore()
60
+ output.mockRestore()
58
61
  }
59
62
  }
63
+
64
+ export const captureLogs = (run: () => Promise<unknown>) =>
65
+ captureConsole("log", run)
66
+
67
+ export const captureErrors = (run: () => Promise<unknown>) =>
68
+ captureConsole("error", run)
@@ -1,24 +1,30 @@
1
- import { describe, expect, test } from "bun:test"
1
+ import { describe, expect, spyOn, test } from "bun:test"
2
2
  import { captureLogs } from "../test-utils"
3
3
  import { createLoggers } from "./effect"
4
4
 
5
5
  describe("createLoggers", () => {
6
6
  test("keeps JSON output machine-readable when verbose is enabled", async () => {
7
+ const errors: string[] = []
8
+ const error = spyOn(console, "error").mockImplementation((...args) => {
9
+ errors.push(args.join(" "))
10
+ })
7
11
  const logs = await captureLogs(async () => {
8
12
  const { log, verboseLog } = createLoggers({ json: true, verbose: true })
9
13
  verboseLog("debug")
10
14
  log('{"ok":true}')
11
15
  })
16
+ error.mockRestore()
12
17
 
13
18
  expect(logs).toEqual(['{"ok":true}'])
19
+ expect(errors).toEqual(["debug"])
14
20
  })
15
21
 
16
- test("lets silent suppress JSON output", async () => {
22
+ test("lets JSON output override silent", async () => {
17
23
  const logs = await captureLogs(async () => {
18
24
  const { log } = createLoggers({ json: true, silent: true })
19
25
  log('{"ok":true}')
20
26
  })
21
27
 
22
- expect(logs).toEqual([])
28
+ expect(logs).toEqual(['{"ok":true}'])
23
29
  })
24
30
  })
@@ -1,3 +1,5 @@
1
+ import { emitCommandResult } from "../protocol"
2
+
1
3
  export function createLoggers(options: {
2
4
  readonly silent?: boolean
3
5
  readonly verbose?: boolean
@@ -5,7 +7,7 @@ export function createLoggers(options: {
5
7
  }) {
6
8
  const { silent = false, verbose = false, json = false } = options
7
9
  return {
8
- log: silent ? () => {} : console.log,
9
- verboseLog: verbose && !silent && !json ? console.log : () => {},
10
+ log: json ? emitCommandResult : silent ? () => {} : console.log,
11
+ verboseLog: verbose && !silent ? console.error : () => {},
10
12
  }
11
13
  }
@@ -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