@markjaquith/agency 2.2.0 → 2.4.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
@@ -42,6 +42,7 @@ frontmatter; prose below it supplies human and agent context.
42
42
 
43
43
  ```text
44
44
  workbase/
45
+ AGENTS.md # managed workbase instructions
45
46
  agency.json
46
47
  repos/
47
48
  frontend/ # bare Git repository or symlink
@@ -68,6 +69,11 @@ workbase/
68
69
  backend/
69
70
  ```
70
71
 
72
+ Agency creates `AGENTS.md` during initialization and ensures it exists whenever
73
+ the workbase is discovered. A checksum in the generated file lets newer Agency
74
+ versions refresh unmodified instructions while preserving custom or edited
75
+ files.
76
+
71
77
  Repository metadata comes directly from Git under `repos/{alias}`. Workbase
72
78
  configuration may provide a custom writable-worktree creation command.
73
79
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "2.2.0",
3
+ "version": "2.4.0",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -33,6 +33,9 @@ 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
+ )
36
39
  })
37
40
 
38
41
  test("preserves existing gitignore entries", async () => {
@@ -24,6 +24,7 @@ export const pr = (options: PrOptions) =>
24
24
  options.phaseId,
25
25
  options.draft,
26
26
  options.cwd ?? process.cwd(),
27
+ options,
27
28
  )
28
29
  log(options.json ? JSON.stringify({ url }, null, 2) : url)
29
30
  })
@@ -52,9 +52,18 @@ const createHarness = (options: HarnessOptions = {}) => {
52
52
  args: readonly string[]
53
53
  cwd: string
54
54
  }> = []
55
+ const materializeOptions: Array<
56
+ Parameters<WorktreeService["materialize"]>[3]
57
+ > = []
55
58
  const worktrees = {
56
- materialize: () => {
59
+ materialize: (
60
+ _taskId: string,
61
+ _phaseId?: string,
62
+ _root?: string,
63
+ commandOptions?: Parameters<WorktreeService["materialize"]>[3],
64
+ ) => {
57
65
  events.push("materialize")
66
+ materializeOptions.push(commandOptions)
58
67
  return options.materializeError
59
68
  ? Effect.fail(options.materializeError)
60
69
  : Effect.succeed(options.workspace ?? singlePhaseWorkspace)
@@ -125,7 +134,7 @@ const createHarness = (options: HarnessOptions = {}) => {
125
134
  ) as Effect.Effect<void, unknown, never>,
126
135
  )
127
136
 
128
- return { events, probes, launches, run }
137
+ return { events, probes, launches, materializeOptions, run }
129
138
  }
130
139
 
131
140
  describe("work command", () => {
@@ -375,6 +384,7 @@ describe("work command", () => {
375
384
  expect(verboseLogs).toEqual([
376
385
  "Launching opencode in /workbase/tasks/example/code/agency",
377
386
  ])
387
+ expect(verboseHarness.materializeOptions[0]?.verbose).toBe(true)
378
388
 
379
389
  const silentHarness = createHarness()
380
390
  const silentLogs = await captureLogs(() =>
@@ -155,7 +155,12 @@ export const work = (
155
155
  } else {
156
156
  const taskId = target.taskId
157
157
  const phaseId = target.kind === "phase" ? target.phaseId : undefined
158
- const workspace = yield* worktrees.materialize(taskId, phaseId, root)
158
+ const workspace = yield* worktrees.materialize(
159
+ taskId,
160
+ phaseId,
161
+ root,
162
+ options,
163
+ )
159
164
  prompt = workspace.phasePath
160
165
  ? `Start the task. Read ${workspace.taskPath} and ${workspace.phasePath}.`
161
166
  : `Start the task. Read ${workspace.taskPath}.`
@@ -0,0 +1,4 @@
1
+ declare module "*.md" {
2
+ const content: string
3
+ export default content
4
+ }
@@ -180,6 +180,7 @@ export class FileSystemService extends Effect.Service<FileSystemService>()(
180
180
  options?: {
181
181
  readonly cwd?: string
182
182
  readonly captureOutput?: boolean
183
+ readonly forwardOutput?: boolean
183
184
  readonly env?: Record<string, string>
184
185
  },
185
186
  ) =>
@@ -187,8 +188,12 @@ export class FileSystemService extends Effect.Service<FileSystemService>()(
187
188
  spawnProcess(args, {
188
189
  cwd: options?.cwd,
189
190
  stdin: "pipe",
190
- stdout: options?.captureOutput ? "pipe" : "inherit",
191
- stderr: "pipe",
191
+ stdout: options?.forwardOutput
192
+ ? "tee"
193
+ : options?.captureOutput
194
+ ? "pipe"
195
+ : "inherit",
196
+ stderr: options?.forwardOutput ? "tee" : "pipe",
192
197
  env: options?.env,
193
198
  }),
194
199
  Effect.mapError(
@@ -2,6 +2,7 @@ import { Data, Effect } from "effect"
2
2
  import { FileSystemService } from "./FileSystemService"
3
3
  import { WorkbaseService } from "./WorkbaseService"
4
4
  import { WorktreeService } from "./WorktreeService"
5
+ import type { BaseCommandOptions } from "../utils/command"
5
6
  import { TaskService } from "./TaskService"
6
7
  import { PhaseService } from "./PhaseService"
7
8
  import {
@@ -59,6 +60,7 @@ export class PullRequestService extends Effect.Service<PullRequestService>()(
59
60
  phaseId?: string,
60
61
  draft = false,
61
62
  startPath: string = process.cwd(),
63
+ options: BaseCommandOptions = {},
62
64
  ) =>
63
65
  Effect.gen(function* () {
64
66
  const service = yield* PullRequestService
@@ -70,6 +72,7 @@ export class PullRequestService extends Effect.Service<PullRequestService>()(
70
72
  taskId,
71
73
  phaseId,
72
74
  startPath,
75
+ options,
73
76
  )
74
77
  const task = yield* tasks.show(taskId, workspace.root)
75
78
  const execution =
@@ -1,8 +1,10 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test"
2
2
  import { Effect } from "effect"
3
+ import { createHash } from "node:crypto"
3
4
  import { mkdir } from "node:fs/promises"
4
5
  import { dirname, join } from "node:path"
5
6
  import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
7
+ import { managedWorkbaseAgents } from "../workbase/agents-file"
6
8
  import { WorkbaseService } from "./WorkbaseService"
7
9
 
8
10
  const write = async (root: string, path: string, content: string) => {
@@ -11,6 +13,11 @@ const write = async (root: string, path: string, content: string) => {
11
13
  await Bun.write(fullPath, content)
12
14
  }
13
15
 
16
+ const managedAgents = (body: string) => {
17
+ const checksum = createHash("sha256").update(body).digest("hex")
18
+ return `<!-- agency-managed: sha256=${checksum} -->\n\n${body}`
19
+ }
20
+
14
21
  describe("WorkbaseService", () => {
15
22
  let root: string
16
23
 
@@ -35,6 +42,51 @@ describe("WorkbaseService", () => {
35
42
  )
36
43
 
37
44
  expect(discovered).toBe(root)
45
+ expect(await Bun.file(join(root, "AGENTS.md")).text()).toBe(
46
+ managedWorkbaseAgents,
47
+ )
48
+ })
49
+
50
+ test("preserves an unmanaged workbase AGENTS.md", async () => {
51
+ await write(root, "agency.json", '{"version":2}\n')
52
+ await write(root, "AGENTS.md", "# Custom instructions\n")
53
+
54
+ await runTestEffect(
55
+ WorkbaseService.pipe(Effect.flatMap((service) => service.discover(root))),
56
+ )
57
+
58
+ expect(await Bun.file(join(root, "AGENTS.md")).text()).toBe(
59
+ "# Custom instructions\n",
60
+ )
61
+ })
62
+
63
+ test("updates an unmodified managed workbase AGENTS.md", async () => {
64
+ await write(root, "agency.json", '{"version":2}\n')
65
+ await write(
66
+ root,
67
+ "AGENTS.md",
68
+ managedAgents("# Previous Agency instructions\n"),
69
+ )
70
+
71
+ await runTestEffect(
72
+ WorkbaseService.pipe(Effect.flatMap((service) => service.discover(root))),
73
+ )
74
+
75
+ expect(await Bun.file(join(root, "AGENTS.md")).text()).toBe(
76
+ managedWorkbaseAgents,
77
+ )
78
+ })
79
+
80
+ test("preserves a modified managed workbase AGENTS.md", async () => {
81
+ await write(root, "agency.json", '{"version":2}\n')
82
+ const content = `${managedAgents("# Previous Agency instructions\n")}\nUser edit\n`
83
+ await write(root, "AGENTS.md", content)
84
+
85
+ await runTestEffect(
86
+ WorkbaseService.pipe(Effect.flatMap((service) => service.discover(root))),
87
+ )
88
+
89
+ expect(await Bun.file(join(root, "AGENTS.md")).text()).toBe(content)
38
90
  })
39
91
 
40
92
  test("rejects an invalid worktree command template", async () => {
@@ -15,6 +15,10 @@ import {
15
15
  type TaskFrontmatter as TaskData,
16
16
  } from "../workbase/schemas"
17
17
  import { validateWorktreeCreateCommand } from "../workbase/worktree-command"
18
+ import {
19
+ canUpdateManagedWorkbaseAgents,
20
+ managedWorkbaseAgents,
21
+ } from "../workbase/agents-file"
18
22
 
19
23
  class WorkbaseNotFoundError extends Data.TaggedError("WorkbaseNotFoundError")<{
20
24
  readonly message: string
@@ -64,6 +68,24 @@ const decode = <S extends Schema.Schema.AnyNoContext>(
64
68
  : { success: true, value: result.right }
65
69
  }
66
70
 
71
+ const ensureWorkbaseAgents = (root: string) =>
72
+ Effect.gen(function* () {
73
+ const fs = yield* FileSystemService
74
+ const path = join(root, "AGENTS.md")
75
+ if (!(yield* fs.exists(path))) {
76
+ yield* fs.writeFile(path, managedWorkbaseAgents)
77
+ return
78
+ }
79
+
80
+ const content = yield* fs.readFile(path)
81
+ if (
82
+ content !== managedWorkbaseAgents &&
83
+ canUpdateManagedWorkbaseAgents(content)
84
+ ) {
85
+ yield* fs.writeFile(path, managedWorkbaseAgents)
86
+ }
87
+ })
88
+
67
89
  const findCycles = (nodes: readonly Dependency[]): readonly string[] => {
68
90
  const dependencies = new Map(
69
91
  nodes.map((node) => [node.id, [...(node.dependsOn ?? [])]]),
@@ -142,6 +164,7 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
142
164
  `${existing}${prefix}${missing.join("\n")}\n`,
143
165
  )
144
166
  }
167
+ yield* ensureWorkbaseAgents(root)
145
168
 
146
169
  return root
147
170
  }),
@@ -198,6 +221,7 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
198
221
  })
199
222
  }
200
223
  }
224
+ yield* ensureWorkbaseAgents(current)
201
225
  return current
202
226
  }
203
227
  }
@@ -2,7 +2,12 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"
2
2
  import { Effect } from "effect"
3
3
  import { mkdir } from "node:fs/promises"
4
4
  import { join } from "node:path"
5
- import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
5
+ import {
6
+ captureLogs,
7
+ cleanupTempDir,
8
+ createTempDir,
9
+ runTestEffect,
10
+ } from "../test-utils"
6
11
  import { TaskService } from "./TaskService"
7
12
  import { PhaseService } from "./PhaseService"
8
13
  import { WorktreeService } from "./WorktreeService"
@@ -127,6 +132,60 @@ describe("WorktreeService", () => {
127
132
  ).toBe("example\n")
128
133
  })
129
134
 
135
+ test("logs the expanded configured command in verbose mode", async () => {
136
+ await Bun.write(
137
+ join(root, "agency.json"),
138
+ JSON.stringify({
139
+ version: 2,
140
+ worktreeCreateCommand: [
141
+ "sh",
142
+ "-c",
143
+ 'git -C "$1" worktree add -b "$3" "$2" "$4" >/dev/null 2>&1',
144
+ "agency-worktree",
145
+ "{repo}",
146
+ "{worktree}",
147
+ "{branch}",
148
+ "{base}",
149
+ ],
150
+ }),
151
+ )
152
+ await runTestEffect(
153
+ TaskService.pipe(
154
+ Effect.flatMap((service) =>
155
+ service.create(
156
+ {
157
+ id: "verbose-command",
158
+ ticketUrl: "https://example.com/task",
159
+ repo: "agency",
160
+ branch: "task/verbose-command",
161
+ base: "main",
162
+ },
163
+ root,
164
+ ),
165
+ ),
166
+ ),
167
+ )
168
+
169
+ const logs = await captureLogs(() =>
170
+ runTestEffect(
171
+ WorktreeService.pipe(
172
+ Effect.flatMap((service) =>
173
+ service.materialize("verbose-command", undefined, root, {
174
+ verbose: true,
175
+ }),
176
+ ),
177
+ ),
178
+ ),
179
+ )
180
+
181
+ expect(logs).toHaveLength(1)
182
+ expect(logs[0]).toStartWith("Running worktree command: sh -c ")
183
+ expect(logs[0]).toContain(join(root, "repos", "agency"))
184
+ expect(logs[0]).toContain(
185
+ join(root, "tasks", "verbose-command", "code", "agency"),
186
+ )
187
+ })
188
+
130
189
  test("selects phases and rejects missing or unexpected phase IDs", async () => {
131
190
  await runTestEffect(
132
191
  TaskService.pipe(
@@ -9,6 +9,8 @@ import {
9
9
  worktreeCommandEnvironment,
10
10
  } from "../workbase/worktree-command"
11
11
  import type { RepositoryReference } from "../workbase/schemas"
12
+ import type { BaseCommandOptions } from "../utils/command"
13
+ import { createLoggers } from "../utils/effect"
12
14
 
13
15
  class WorktreeError extends Data.TaggedError("WorktreeError")<{
14
16
  readonly message: string
@@ -49,6 +51,15 @@ const parseWorktreeList = (output: string): readonly GitWorktree[] => {
49
51
  return worktrees
50
52
  }
51
53
 
54
+ const formatCommand = (args: readonly string[]) =>
55
+ args
56
+ .map((argument) =>
57
+ /^[A-Za-z0-9_./:=+@%-]+$/.test(argument)
58
+ ? argument
59
+ : `'${argument.replaceAll("'", `'\\''`)}'`,
60
+ )
61
+ .join(" ")
62
+
52
63
  export class WorktreeService extends Effect.Service<WorktreeService>()(
53
64
  "WorktreeService",
54
65
  {
@@ -57,12 +68,16 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
57
68
  taskId: string,
58
69
  phaseId?: string,
59
70
  startPath: string = process.cwd(),
71
+ options: BaseCommandOptions = {},
60
72
  ) =>
61
73
  Effect.gen(function* () {
62
74
  const fs = yield* FileSystemService
63
75
  const workbase = yield* WorkbaseService
64
76
  const tasks = yield* TaskService
65
77
  const phases = yield* PhaseService
78
+ const { verboseLog } = createLoggers(options)
79
+ const forwardCommandOutput =
80
+ options.verbose === true && !options.silent && !options.json
66
81
  const { root, config } = yield* workbase.loadConfig(startPath)
67
82
  const report = yield* workbase.validate(root)
68
83
  const ownershipIssue = report.issues.find((issue) =>
@@ -258,9 +273,14 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
258
273
  ]
259
274
  }
260
275
 
276
+ if (config.worktreeCreateCommand) {
277
+ verboseLog(`Running worktree command: ${formatCommand(args)}`)
278
+ }
261
279
  const result = yield* fs.runCommand(args, {
262
280
  cwd: repositoryPath,
263
281
  captureOutput: true,
282
+ forwardOutput:
283
+ config.worktreeCreateCommand && forwardCommandOutput,
264
284
  env,
265
285
  })
266
286
  if (result.exitCode !== 0) {
@@ -1,8 +1,45 @@
1
- import { describe, expect, test } from "bun:test"
1
+ import { describe, expect, spyOn, test } from "bun:test"
2
2
  import { Effect } from "effect"
3
3
  import { spawnProcess } from "./process"
4
4
 
5
5
  describe("spawnProcess", () => {
6
+ test("forwards and captures output in tee mode", async () => {
7
+ const forwardedStdout: Uint8Array[] = []
8
+ const forwardedStderr: Uint8Array[] = []
9
+ const stdout = spyOn(process.stdout, "write").mockImplementation(((
10
+ chunk: Uint8Array,
11
+ ) => {
12
+ forwardedStdout.push(chunk)
13
+ return true
14
+ }) as never)
15
+ const stderr = spyOn(process.stderr, "write").mockImplementation(((
16
+ chunk: Uint8Array,
17
+ ) => {
18
+ forwardedStderr.push(chunk)
19
+ return true
20
+ }) as never)
21
+
22
+ try {
23
+ const result = await Effect.runPromise(
24
+ spawnProcess(
25
+ ["sh", "-c", "printf 'standard output'; printf 'standard error' >&2"],
26
+ { stdout: "tee", stderr: "tee" },
27
+ ),
28
+ )
29
+
30
+ expect(result).toEqual({
31
+ stdout: "standard output",
32
+ stderr: "standard error",
33
+ exitCode: 0,
34
+ })
35
+ expect(Buffer.concat(forwardedStdout).toString()).toBe("standard output")
36
+ expect(Buffer.concat(forwardedStderr).toString()).toBe("standard error")
37
+ } finally {
38
+ stdout.mockRestore()
39
+ stderr.mockRestore()
40
+ }
41
+ })
42
+
6
43
  test("captures large stdout and stderr without hanging", async () => {
7
44
  const line = "x".repeat(4096)
8
45
  const script = [
@@ -15,11 +15,29 @@ interface ProcessResult {
15
15
  interface SpawnOptions {
16
16
  readonly cwd?: string
17
17
  readonly stdin?: "pipe" | "inherit"
18
- readonly stdout?: "pipe" | "inherit"
19
- readonly stderr?: "pipe" | "inherit"
18
+ readonly stdout?: "pipe" | "inherit" | "tee"
19
+ readonly stderr?: "pipe" | "inherit" | "tee"
20
20
  readonly env?: Record<string, string>
21
21
  }
22
22
 
23
+ const readOutput = async (
24
+ stream: ReadableStream<Uint8Array> | null | undefined,
25
+ target?: { write(chunk: Uint8Array): unknown },
26
+ ) => {
27
+ if (!stream) return ""
28
+
29
+ const reader = stream.getReader()
30
+ const decoder = new TextDecoder()
31
+ let output = ""
32
+ while (true) {
33
+ const { done, value } = await reader.read()
34
+ if (done) break
35
+ target?.write(value)
36
+ output += decoder.decode(value, { stream: true })
37
+ }
38
+ return output + decoder.decode()
39
+ }
40
+
23
41
  /**
24
42
  * Generic error for process execution failures
25
43
  */
@@ -50,8 +68,8 @@ export const spawnProcess = (
50
68
  const proc = Bun.spawn([...args], {
51
69
  cwd: options?.cwd ?? process.cwd(),
52
70
  stdin: options?.stdin ?? "pipe",
53
- stdout: options?.stdout ?? "pipe",
54
- stderr: options?.stderr ?? "pipe",
71
+ stdout: options?.stdout === "inherit" ? "inherit" : "pipe",
72
+ stderr: options?.stderr === "inherit" ? "inherit" : "pipe",
55
73
  env: options?.env ? { ...process.env, ...options.env } : process.env,
56
74
  })
57
75
  // Start draining stdout/stderr immediately so verbose subprocesses
@@ -59,11 +77,17 @@ export const spawnProcess = (
59
77
  const stdoutPromise =
60
78
  options?.stdout === "inherit"
61
79
  ? Promise.resolve("")
62
- : new Response(proc.stdout ?? "").text()
80
+ : readOutput(
81
+ proc.stdout,
82
+ options?.stdout === "tee" ? process.stdout : undefined,
83
+ )
63
84
  const stderrPromise =
64
85
  options?.stderr === "inherit"
65
86
  ? Promise.resolve("")
66
- : new Response(proc.stderr ?? "").text()
87
+ : readOutput(
88
+ proc.stderr,
89
+ options?.stderr === "tee" ? process.stderr : undefined,
90
+ )
67
91
 
68
92
  const [exitCode, stdout, stderr] = await Promise.all([
69
93
  proc.exited,
@@ -0,0 +1,32 @@
1
+ # Agency Workbase
2
+
3
+ This directory is an Agency workbase. Epics, tasks, and phases are durable
4
+ Markdown documents; repository aliases and generated Git worktrees provide code
5
+ access.
6
+
7
+ ## Session Context
8
+
9
+ Before doing work, identify the current entity from the working directory and
10
+ read its context:
11
+
12
+ - In `epics/<epic>/`, read `EPIC.md`. Coordinate the epic's tasks without
13
+ writing implementation code.
14
+ - In `tasks/<task>/`, read `TASK.md`. A task with `phases` is an orchestration
15
+ session; a task without `phases` is a single execution unit.
16
+ - In `tasks/<task>/phases/<phase>/`, read both `../../TASK.md` and `PHASE.md`.
17
+ The phase is the execution unit.
18
+
19
+ For execution units, writable and reference checkouts live under `code/` when
20
+ materialized. Write only through the checkout named by the singular `repo`
21
+ field. Repositories listed in plural `repos` are read-only references.
22
+
23
+ ## Safety
24
+
25
+ - Keep task-level decisions in `TASK.md` and phase-specific delivery context in
26
+ `PHASE.md`.
27
+ - Do not manually create, move, or remove worktrees under `code/`.
28
+ - Do not edit bare repositories or repository symlinks under `repos/`.
29
+ - Do not run `agency work` from an active agent session unless the user
30
+ explicitly asks to launch another agent.
31
+ - Run `agency validate` before worktree or pull-request operations.
32
+ - Create a pull request only when the user explicitly requests it.
@@ -0,0 +1,24 @@
1
+ import { createHash } from "node:crypto"
2
+ import agentsTemplate from "./AGENTS.md" with { type: "text" }
3
+
4
+ const managedHeaderPattern =
5
+ /^<!-- agency-managed: sha256=([a-f0-9]{64}) -->\r?\n\r?\n/
6
+
7
+ const checksum = (content: string) =>
8
+ createHash("sha256").update(content).digest("hex")
9
+
10
+ const canonicalBody = agentsTemplate.endsWith("\n")
11
+ ? agentsTemplate
12
+ : `${agentsTemplate}\n`
13
+
14
+ const renderManagedWorkbaseAgents = (body: string = canonicalBody) =>
15
+ `<!-- agency-managed: sha256=${checksum(body)} -->\n\n${body}`
16
+
17
+ export const managedWorkbaseAgents = renderManagedWorkbaseAgents()
18
+
19
+ export const canUpdateManagedWorkbaseAgents = (content: string) => {
20
+ const match = content.match(managedHeaderPattern)
21
+ if (!match?.[1]) return false
22
+
23
+ return checksum(content.slice(match[0].length)) === match[1]
24
+ }