@markjaquith/agency 2.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "2.3.0",
3
+ "version": "2.4.0",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -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}.`
@@ -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 =
@@ -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,