@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 +6 -0
- package/package.json +1 -1
- package/src/commands/init.test.ts +3 -0
- package/src/commands/pr.ts +1 -0
- package/src/commands/work.test.ts +12 -2
- package/src/commands/work.ts +6 -1
- package/src/markdown.d.ts +4 -0
- package/src/services/FileSystemService.ts +7 -2
- package/src/services/PullRequestService.ts +3 -0
- package/src/services/WorkbaseService.test.ts +52 -0
- package/src/services/WorkbaseService.ts +24 -0
- package/src/services/WorktreeService.test.ts +60 -1
- package/src/services/WorktreeService.ts +20 -0
- package/src/utils/process.test.ts +38 -1
- package/src/utils/process.ts +30 -6
- package/src/workbase/AGENTS.md +32 -0
- package/src/workbase/agents-file.ts +24 -0
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
|
@@ -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 () => {
|
package/src/commands/pr.ts
CHANGED
|
@@ -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(() =>
|
package/src/commands/work.ts
CHANGED
|
@@ -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(
|
|
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?.
|
|
191
|
-
|
|
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 {
|
|
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 = [
|
package/src/utils/process.ts
CHANGED
|
@@ -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
|
|
54
|
-
stderr: options?.stderr
|
|
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
|
-
:
|
|
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
|
-
:
|
|
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
|
+
}
|