@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.
@@ -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(false)
37
- expect(
38
- await Bun.file(join(root, ".opencode/opencode.jsonc")).exists(),
39
- ).toBe(false)
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 () => {
@@ -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(
@@ -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