@markjaquith/agency 2.27.0 → 2.28.1
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 +42 -24
- package/cli.ts +20 -0
- package/fixtures/protocol/orchestration-recipes.json +59 -0
- package/package.json +1 -1
- package/skills/agency/SKILL.md +112 -331
- package/skills/agency/references/commands.md +161 -0
- package/skills/agency/references/contracts.md +288 -0
- package/skills/agency/references/recipes.md +219 -0
- package/src/cli-parser.test.ts +8 -0
- package/src/cli-parser.ts +10 -0
- package/src/cli.test.ts +37 -4
- package/src/commands/doctor.test.ts +156 -0
- package/src/commands/doctor.ts +47 -0
- package/src/commands/init.test.ts +8 -4
- package/src/commands/init.ts +3 -0
- package/src/commands/read-only.test.ts +2 -0
- package/src/commands/work.test.ts +20 -0
- package/src/commands/work.ts +3 -0
- package/src/services/DoctorService.ts +419 -0
- package/src/services/IntegrationService.test.ts +80 -3
- package/src/services/IntegrationService.ts +2 -2
- package/src/test-utils.ts +2 -0
- package/src/workbase/AGENTS.md +53 -19
- package/src/workbase/opencode-file.ts +7 -8
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
|
2
|
+
import { createHash } from "node:crypto"
|
|
3
|
+
import { chmod, mkdir } from "node:fs/promises"
|
|
4
|
+
import { dirname, join } from "node:path"
|
|
5
|
+
import {
|
|
6
|
+
captureLogs,
|
|
7
|
+
cleanupTempDir,
|
|
8
|
+
createTempDir,
|
|
9
|
+
runTestEffect,
|
|
10
|
+
} from "../test-utils"
|
|
11
|
+
import { doctor } from "./doctor"
|
|
12
|
+
|
|
13
|
+
const write = async (root: string, path: string, content: string) => {
|
|
14
|
+
const fullPath = join(root, path)
|
|
15
|
+
await mkdir(dirname(fullPath), { recursive: true })
|
|
16
|
+
await Bun.write(fullPath, content)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const managedAgents = (body: string) =>
|
|
20
|
+
`<!-- agency-managed: sha256=${createHash("sha256").update(body).digest("hex")} -->\n\n${body}`
|
|
21
|
+
|
|
22
|
+
describe("doctor command", () => {
|
|
23
|
+
let root: string
|
|
24
|
+
let repository: string
|
|
25
|
+
|
|
26
|
+
beforeEach(async () => {
|
|
27
|
+
root = await createTempDir()
|
|
28
|
+
repository = join(root, "source")
|
|
29
|
+
await mkdir(repository)
|
|
30
|
+
await Bun.$`git init -q -b main ${repository}`
|
|
31
|
+
await Bun.$`git -C ${repository} config user.email test@example.com`
|
|
32
|
+
await Bun.$`git -C ${repository} config user.name Test`
|
|
33
|
+
await write(repository, "README.md", "test\n")
|
|
34
|
+
await Bun.$`git -C ${repository} add README.md`
|
|
35
|
+
await Bun.$`git -C ${repository} commit -q -m initial`
|
|
36
|
+
await Bun.$`git -C ${repository} remote add origin https://example.com/agency.git`
|
|
37
|
+
await write(
|
|
38
|
+
root,
|
|
39
|
+
"agency.json",
|
|
40
|
+
JSON.stringify({
|
|
41
|
+
version: 2,
|
|
42
|
+
runners: { missing: { command: ["definitely-not-installed"] } },
|
|
43
|
+
}),
|
|
44
|
+
)
|
|
45
|
+
await mkdir(join(root, "repos"), { recursive: true })
|
|
46
|
+
await Bun.$`ln -s ${repository} ${join(root, "repos/agency")}`
|
|
47
|
+
await write(
|
|
48
|
+
root,
|
|
49
|
+
"tasks/example/TASK.md",
|
|
50
|
+
`---
|
|
51
|
+
ticketUrl: null
|
|
52
|
+
repo: agency
|
|
53
|
+
branch: feat/example
|
|
54
|
+
base: main
|
|
55
|
+
pr: null
|
|
56
|
+
status: open
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
# Example
|
|
60
|
+
`,
|
|
61
|
+
)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
afterEach(async () => cleanupTempDir(root))
|
|
65
|
+
|
|
66
|
+
test("returns stable checks, severities, and remediation in JSON", async () => {
|
|
67
|
+
const logs = await captureLogs(() =>
|
|
68
|
+
runTestEffect(doctor({ cwd: root, json: true })),
|
|
69
|
+
)
|
|
70
|
+
const report = JSON.parse(logs[0]!)
|
|
71
|
+
|
|
72
|
+
expect(report).toMatchObject({
|
|
73
|
+
version: 1,
|
|
74
|
+
root,
|
|
75
|
+
healthy: false,
|
|
76
|
+
})
|
|
77
|
+
expect(report.checks).toEqual(
|
|
78
|
+
expect.arrayContaining([
|
|
79
|
+
expect.objectContaining({
|
|
80
|
+
id: "tool.git",
|
|
81
|
+
level: "error",
|
|
82
|
+
status: "pass",
|
|
83
|
+
}),
|
|
84
|
+
expect.objectContaining({
|
|
85
|
+
id: "integration.runner.missing",
|
|
86
|
+
level: "error",
|
|
87
|
+
status: "fail",
|
|
88
|
+
}),
|
|
89
|
+
expect.objectContaining({
|
|
90
|
+
id: "ref.agency.main",
|
|
91
|
+
status: "pass",
|
|
92
|
+
}),
|
|
93
|
+
expect.objectContaining({
|
|
94
|
+
id: "worktree.task.example",
|
|
95
|
+
status: "pass",
|
|
96
|
+
}),
|
|
97
|
+
]),
|
|
98
|
+
)
|
|
99
|
+
for (const check of report.checks) {
|
|
100
|
+
if (check.status === "fail") expect(check.remediation).toBeTruthy()
|
|
101
|
+
}
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test("reports repository, ref, remote, and managed-file failures", async () => {
|
|
105
|
+
await Bun.$`git -C ${repository} remote remove origin`
|
|
106
|
+
await Bun.$`git -C ${repository} branch -m other`
|
|
107
|
+
await write(root, "AGENTS.md", managedAgents("old\n"))
|
|
108
|
+
|
|
109
|
+
const logs = await captureLogs(() =>
|
|
110
|
+
runTestEffect(doctor({ cwd: root, json: true })),
|
|
111
|
+
)
|
|
112
|
+
const checks = JSON.parse(logs[0]!).checks
|
|
113
|
+
|
|
114
|
+
expect(checks).toEqual(
|
|
115
|
+
expect.arrayContaining([
|
|
116
|
+
expect.objectContaining({
|
|
117
|
+
id: "repository.agency.remote",
|
|
118
|
+
status: "fail",
|
|
119
|
+
}),
|
|
120
|
+
expect.objectContaining({
|
|
121
|
+
id: "ref.agency.main",
|
|
122
|
+
status: "fail",
|
|
123
|
+
}),
|
|
124
|
+
expect.objectContaining({
|
|
125
|
+
id: "integration.file.agents",
|
|
126
|
+
status: "fail",
|
|
127
|
+
}),
|
|
128
|
+
]),
|
|
129
|
+
)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
test("is safe when the workbase is read-only", async () => {
|
|
133
|
+
for (const path of [
|
|
134
|
+
join(root, "agency.json"),
|
|
135
|
+
join(root, "tasks/example/TASK.md"),
|
|
136
|
+
]) {
|
|
137
|
+
await chmod(path, 0o444)
|
|
138
|
+
}
|
|
139
|
+
await chmod(join(root, "tasks/example"), 0o555)
|
|
140
|
+
await chmod(join(root, "tasks"), 0o555)
|
|
141
|
+
await chmod(root, 0o555)
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
await runTestEffect(doctor({ cwd: root, silent: true }))
|
|
145
|
+
} finally {
|
|
146
|
+
await chmod(root, 0o755)
|
|
147
|
+
await chmod(join(root, "tasks"), 0o755)
|
|
148
|
+
await chmod(join(root, "tasks/example"), 0o755)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
expect(await Bun.file(join(root, "AGENTS.md")).exists()).toBe(false)
|
|
152
|
+
expect(
|
|
153
|
+
await Bun.file(join(root, ".opencode/opencode.jsonc")).exists(),
|
|
154
|
+
).toBe(false)
|
|
155
|
+
})
|
|
156
|
+
})
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { Effect } from "effect"
|
|
2
|
+
import { DoctorService } from "../services/DoctorService"
|
|
3
|
+
import type { BaseCommandOptions } from "../utils/command"
|
|
4
|
+
import { createLoggers } from "../utils/effect"
|
|
5
|
+
|
|
6
|
+
interface DoctorOptions extends BaseCommandOptions {
|
|
7
|
+
readonly json?: boolean
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const doctor = (options: DoctorOptions = {}) =>
|
|
11
|
+
Effect.gen(function* () {
|
|
12
|
+
const service = yield* DoctorService
|
|
13
|
+
const { log } = createLoggers(options)
|
|
14
|
+
const report = yield* service.inspect(options.cwd ?? process.cwd())
|
|
15
|
+
|
|
16
|
+
if (options.json) {
|
|
17
|
+
log(JSON.stringify(report, null, 2))
|
|
18
|
+
return
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
for (const check of report.checks) {
|
|
22
|
+
const marker =
|
|
23
|
+
check.status === "pass"
|
|
24
|
+
? "PASS"
|
|
25
|
+
: check.level === "error"
|
|
26
|
+
? "ERROR"
|
|
27
|
+
: check.level === "warning"
|
|
28
|
+
? "WARN"
|
|
29
|
+
: "OPTIONAL"
|
|
30
|
+
log(`${marker}\t${check.id}\t${check.message}`)
|
|
31
|
+
if (check.remediation) log(` Remediation: ${check.remediation}`)
|
|
32
|
+
}
|
|
33
|
+
log("")
|
|
34
|
+
log(
|
|
35
|
+
`${report.healthy ? "Healthy" : "Unhealthy"}: ${report.summary.errors} error(s), ${report.summary.warnings} warning(s), ${report.summary.optional} unavailable optional capability(s)`,
|
|
36
|
+
)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
export const help = `
|
|
40
|
+
Usage: agency doctor [options]
|
|
41
|
+
|
|
42
|
+
Diagnose tools, integrations, repositories, refs, worktrees, permissions, and
|
|
43
|
+
managed-file drift without changing the workbase.
|
|
44
|
+
|
|
45
|
+
Options:
|
|
46
|
+
--json Output the health report as JSON
|
|
47
|
+
`
|
|
@@ -33,10 +33,14 @@ 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")).exists()).toBe(
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
).
|
|
36
|
+
expect(await Bun.file(join(root, "AGENTS.md")).exists()).toBe(true)
|
|
37
|
+
const opencode = await Bun.file(
|
|
38
|
+
join(root, ".opencode/opencode.jsonc"),
|
|
39
|
+
).text()
|
|
40
|
+
const config = JSON.parse(opencode.slice(opencode.indexOf("\n\n") + 2))
|
|
41
|
+
expect(config.permission.external_directory).toEqual({
|
|
42
|
+
"../**": "allow",
|
|
43
|
+
})
|
|
40
44
|
})
|
|
41
45
|
|
|
42
46
|
test("preserves existing gitignore entries", async () => {
|
package/src/commands/init.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Effect } from "effect"
|
|
2
2
|
import { resolve } from "node:path"
|
|
3
3
|
import type { BaseCommandOptions } from "../utils/command"
|
|
4
|
+
import { IntegrationService } from "../services/IntegrationService"
|
|
4
5
|
import { WorkbaseService } from "../services/WorkbaseService"
|
|
5
6
|
import { createLoggers } from "../utils/effect"
|
|
6
7
|
|
|
@@ -10,12 +11,14 @@ interface InitOptions extends BaseCommandOptions {
|
|
|
10
11
|
|
|
11
12
|
export const init = (options: InitOptions = {}) =>
|
|
12
13
|
Effect.gen(function* () {
|
|
14
|
+
const integrations = yield* IntegrationService
|
|
13
15
|
const workbase = yield* WorkbaseService
|
|
14
16
|
const { log } = createLoggers(options)
|
|
15
17
|
const cwd = options.cwd ?? process.cwd()
|
|
16
18
|
const root = yield* workbase.initialize(
|
|
17
19
|
options.path ? resolve(cwd, options.path) : cwd,
|
|
18
20
|
)
|
|
21
|
+
yield* integrations.sync(root)
|
|
19
22
|
log(
|
|
20
23
|
options.json
|
|
21
24
|
? JSON.stringify({ root }, null, 2)
|
|
@@ -14,6 +14,7 @@ import { validate } from "./validate"
|
|
|
14
14
|
import { context } from "./context"
|
|
15
15
|
import { graph } from "./graph"
|
|
16
16
|
import { next } from "./next"
|
|
17
|
+
import { doctor } from "./doctor"
|
|
17
18
|
|
|
18
19
|
const write = async (root: string, path: string, content: string) => {
|
|
19
20
|
const fullPath = join(root, path)
|
|
@@ -147,6 +148,7 @@ status: open
|
|
|
147
148
|
)
|
|
148
149
|
await runTestEffect(graph({ cwd: root, silent: true }))
|
|
149
150
|
await runTestEffect(next({ cwd: root, silent: true }))
|
|
151
|
+
await runTestEffect(doctor({ cwd: root, silent: true }))
|
|
150
152
|
|
|
151
153
|
expect(await Bun.file(join(root, "AGENTS.md")).exists()).toBe(false)
|
|
152
154
|
expect(
|
|
@@ -8,6 +8,7 @@ import { PhaseService } from "../services/PhaseService"
|
|
|
8
8
|
import { WorktreeService } from "../services/WorktreeService"
|
|
9
9
|
import { ClaimService } from "../services/ClaimService"
|
|
10
10
|
import { ReadinessService } from "../services/ReadinessService"
|
|
11
|
+
import { IntegrationService } from "../services/IntegrationService"
|
|
11
12
|
import { captureErrors, captureLogs } from "../test-utils"
|
|
12
13
|
import { work, workPrepare } from "./work"
|
|
13
14
|
import type { PickWorkTarget } from "../workbase/work-target"
|
|
@@ -74,6 +75,7 @@ const createHarness = (options: HarnessOptions = {}) => {
|
|
|
74
75
|
const statusUpdates: string[] = []
|
|
75
76
|
const shownTasks: string[] = []
|
|
76
77
|
const progressUpdates: string[] = []
|
|
78
|
+
let integrationSyncs = 0
|
|
77
79
|
const guards: Array<{ target: string; override?: boolean }> = []
|
|
78
80
|
const launches: Array<{
|
|
79
81
|
cli: string
|
|
@@ -210,6 +212,12 @@ const createHarness = (options: HarnessOptions = {}) => {
|
|
|
210
212
|
: Effect.void
|
|
211
213
|
},
|
|
212
214
|
}
|
|
215
|
+
const integrations = {
|
|
216
|
+
sync: () => {
|
|
217
|
+
integrationSyncs += 1
|
|
218
|
+
return Effect.succeed({ root: "/workbase", files: [] })
|
|
219
|
+
},
|
|
220
|
+
}
|
|
213
221
|
const fs = {
|
|
214
222
|
isDirectory: (path: string) =>
|
|
215
223
|
Effect.succeed(options.existingDirectories?.includes(path) ?? true),
|
|
@@ -256,6 +264,7 @@ const createHarness = (options: HarnessOptions = {}) => {
|
|
|
256
264
|
Effect.provideService(PhaseService, phases as never),
|
|
257
265
|
Effect.provideService(ClaimService, claims as never),
|
|
258
266
|
Effect.provideService(ReadinessService, readiness as never),
|
|
267
|
+
Effect.provideService(IntegrationService, integrations as never),
|
|
259
268
|
) as Effect.Effect<void, unknown, never>,
|
|
260
269
|
)
|
|
261
270
|
const runPrepare = (commandOptions: Parameters<typeof workPrepare>[0]) =>
|
|
@@ -279,12 +288,23 @@ const createHarness = (options: HarnessOptions = {}) => {
|
|
|
279
288
|
shownTasks,
|
|
280
289
|
progressUpdates,
|
|
281
290
|
guards,
|
|
291
|
+
get integrationSyncs() {
|
|
292
|
+
return integrationSyncs
|
|
293
|
+
},
|
|
282
294
|
run,
|
|
283
295
|
runPrepare,
|
|
284
296
|
}
|
|
285
297
|
}
|
|
286
298
|
|
|
287
299
|
describe("work command", () => {
|
|
300
|
+
test("reconciles managed integration files before preparing work", async () => {
|
|
301
|
+
const harness = createHarness()
|
|
302
|
+
|
|
303
|
+
await harness.run({ taskId: "example", opencode: true })
|
|
304
|
+
|
|
305
|
+
expect(harness.integrationSyncs).toBe(1)
|
|
306
|
+
})
|
|
307
|
+
|
|
288
308
|
test("guards execution targets before materialization and honors --force", async () => {
|
|
289
309
|
const blocked = createHarness({ guardError: new Error("blocked") })
|
|
290
310
|
await expect(
|
package/src/commands/work.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { TaskService } from "../services/TaskService"
|
|
|
9
9
|
import { PhaseService } from "../services/PhaseService"
|
|
10
10
|
import { ClaimService } from "../services/ClaimService"
|
|
11
11
|
import { ReadinessService } from "../services/ReadinessService"
|
|
12
|
+
import { IntegrationService } from "../services/IntegrationService"
|
|
12
13
|
import { createLoggers } from "../utils/effect"
|
|
13
14
|
import { execvp } from "../utils/exec"
|
|
14
15
|
import { createProgress, type Progress } from "../utils/progress"
|
|
@@ -106,6 +107,7 @@ export const work = (
|
|
|
106
107
|
const phases = yield* PhaseService
|
|
107
108
|
const claims = yield* ClaimService
|
|
108
109
|
const readiness = yield* ReadinessService
|
|
110
|
+
const integrations = yield* IntegrationService
|
|
109
111
|
const { log, verboseLog } = createLoggers(options)
|
|
110
112
|
const cwd = options.cwd ?? process.cwd()
|
|
111
113
|
const directoryPath = options.directory
|
|
@@ -118,6 +120,7 @@ export const work = (
|
|
|
118
120
|
const inputAllowed = options.inputAllowed ?? true
|
|
119
121
|
const root = yield* resolveWorkbase(startPath, pickBase, inputAllowed)
|
|
120
122
|
if (!root) return
|
|
123
|
+
yield* integrations.sync(root)
|
|
121
124
|
const { config } = yield* workbase.loadConfig(root)
|
|
122
125
|
|
|
123
126
|
let target: WorkTarget | null = null
|