@markjaquith/agency 2.27.0 → 2.28.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/cli.ts CHANGED
@@ -9,6 +9,7 @@ import { pr, help as prHelp } from "./src/commands/pr"
9
9
  import { work, workPrepare, help as workHelp } from "./src/commands/work"
10
10
  import { worktree, help as worktreeHelp } from "./src/commands/worktree"
11
11
  import { status, help as statusHelp } from "./src/commands/status"
12
+ import { doctor, help as doctorHelp } from "./src/commands/doctor"
12
13
  import { validate, help as validateHelp } from "./src/commands/validate"
13
14
  import { context, help as contextHelp } from "./src/commands/context"
14
15
  import { graph, help as graphHelp } from "./src/commands/graph"
@@ -41,6 +42,7 @@ import { ClaimService } from "./src/services/ClaimService"
41
42
  import { SyncService } from "./src/services/SyncService"
42
43
  import { ReadinessService } from "./src/services/ReadinessService"
43
44
  import { GraphMutationService } from "./src/services/GraphMutationService"
45
+ import { DoctorService } from "./src/services/DoctorService"
44
46
  import {
45
47
  claimCommand,
46
48
  claimHelp,
@@ -72,6 +74,7 @@ const CliLayer = Layer.mergeAll(
72
74
  SyncService.Default,
73
75
  ReadinessService.Default,
74
76
  GraphMutationService.Default,
77
+ DoctorService.Default,
75
78
  )
76
79
 
77
80
  /**
@@ -524,6 +527,22 @@ const commands: Record<string, Command> = {
524
527
  )
525
528
  },
526
529
  },
530
+ doctor: {
531
+ run: async (_args: string[], options: Record<string, any>) => {
532
+ if (options.help) {
533
+ console.log(doctorHelp)
534
+ return
535
+ }
536
+ await runCommand(
537
+ doctor({
538
+ silent: options.silent,
539
+ verbose: options.verbose,
540
+ json: options.json,
541
+ cwd: options.cwd,
542
+ }),
543
+ )
544
+ },
545
+ },
527
546
  validate: {
528
547
  run: async (args: string[], options: Record<string, any>) => {
529
548
  if (options.help) {
@@ -632,6 +651,7 @@ Commands:
632
651
  pr create Create a pull request for an execution unit
633
652
  repo <subcommand> Manage workbase repositories
634
653
  status Show status for the current workbase
654
+ doctor Diagnose workbase health and integrations
635
655
  validate [path] Validate a workbase
636
656
  context [target] Return complete target context
637
657
  graph Export the complete workbase graph
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "2.27.0",
3
+ "version": "2.28.0",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -123,6 +123,7 @@ describe("strict CLI parsing", () => {
123
123
 
124
124
  test("parses addressable resource maintenance commands", () => {
125
125
  for (const args of [
126
+ ["doctor", "--json"],
126
127
  ["repo", "show", "agency", "--json"],
127
128
  ["repo", "fetch", "agency"],
128
129
  ["repo", "remove", "agency"],
package/src/cli-parser.ts CHANGED
@@ -804,6 +804,16 @@ const commands = {
804
804
  conflicts: viewConflicts,
805
805
  },
806
806
  },
807
+ doctor: {
808
+ usage: "agency doctor [--json]",
809
+ options: outputOptions,
810
+ command: {
811
+ usage: "agency doctor [--json]",
812
+ minArgs: 0,
813
+ maxArgs: 0,
814
+ options: ["json"],
815
+ },
816
+ },
807
817
  validate: {
808
818
  usage: "agency validate [path] [--json] [--no-input]",
809
819
  options: {
package/src/cli.test.ts CHANGED
@@ -935,6 +935,15 @@ status: open
935
935
  valid: true,
936
936
  issues: [],
937
937
  })
938
+ const doctor = parseJson(await runCli(["doctor", "--json"], root))
939
+ expect(doctor).toMatchObject({
940
+ version: 1,
941
+ root: workbaseRoot,
942
+ checks: expect.arrayContaining([
943
+ expect.objectContaining({ id: "tool.git", status: "pass" }),
944
+ expect.objectContaining({ id: "workbase.validation", status: "pass" }),
945
+ ]),
946
+ })
938
947
 
939
948
  const validation = parseJson(
940
949
  await runCli(["validate", root, "--json"], parent),
@@ -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
+ `
@@ -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(
@@ -0,0 +1,419 @@
1
+ import { Effect, Either } from "effect"
2
+ import { constants } from "node:fs"
3
+ import { access } from "node:fs/promises"
4
+ import { isAbsolute, join, resolve } from "node:path"
5
+ import { EpicService } from "./EpicService"
6
+ import { FileSystemService } from "./FileSystemService"
7
+ import { IntegrationService } from "./IntegrationService"
8
+ import { PhaseService } from "./PhaseService"
9
+ import { RepositoryService } from "./RepositoryService"
10
+ import { TaskService } from "./TaskService"
11
+ import { WorkbaseService } from "./WorkbaseService"
12
+ import { WorktreeService } from "./WorktreeService"
13
+
14
+ type DoctorCheckLevel = "error" | "warning" | "optional"
15
+
16
+ interface DoctorCheck {
17
+ readonly id: string
18
+ readonly category:
19
+ | "tool"
20
+ | "integration"
21
+ | "workbase"
22
+ | "repository"
23
+ | "ref"
24
+ | "worktree"
25
+ | "permission"
26
+ readonly level: DoctorCheckLevel
27
+ readonly status: "pass" | "fail"
28
+ readonly message: string
29
+ readonly remediation: string | null
30
+ }
31
+
32
+ interface DoctorReport {
33
+ readonly version: 1
34
+ readonly root: string
35
+ readonly healthy: boolean
36
+ readonly summary: {
37
+ readonly passed: number
38
+ readonly errors: number
39
+ readonly warnings: number
40
+ readonly optional: number
41
+ }
42
+ readonly checks: readonly DoctorCheck[]
43
+ }
44
+
45
+ const executableAvailable = (executable: string, root: string) =>
46
+ Effect.tryPromise({
47
+ try: async () => {
48
+ if (executable.includes("/")) {
49
+ const path = isAbsolute(executable)
50
+ ? executable
51
+ : resolve(root, executable)
52
+ await access(path, constants.X_OK)
53
+ return true
54
+ }
55
+ return Bun.which(executable) !== null
56
+ },
57
+ catch: () => false,
58
+ }).pipe(Effect.catchAll(() => Effect.succeed(false)))
59
+
60
+ const permissionAvailable = (path: string, mode: number) =>
61
+ Effect.tryPromise({
62
+ try: () => access(path, mode).then(() => true),
63
+ catch: () => false,
64
+ }).pipe(Effect.catchAll(() => Effect.succeed(false)))
65
+
66
+ const messageOf = (error: unknown) =>
67
+ error instanceof Error
68
+ ? error.message
69
+ : typeof error === "object" &&
70
+ error !== null &&
71
+ "message" in error &&
72
+ typeof error.message === "string"
73
+ ? error.message
74
+ : String(error)
75
+
76
+ export class DoctorService extends Effect.Service<DoctorService>()(
77
+ "DoctorService",
78
+ {
79
+ sync: () => ({
80
+ inspect: (startPath: string = process.cwd()) =>
81
+ Effect.gen(function* () {
82
+ const epics = yield* EpicService
83
+ const fs = yield* FileSystemService
84
+ const integrations = yield* IntegrationService
85
+ const phases = yield* PhaseService
86
+ const repositories = yield* RepositoryService
87
+ const tasks = yield* TaskService
88
+ const workbases = yield* WorkbaseService
89
+ const worktrees = yield* WorktreeService
90
+ const { root, config } = yield* workbases.loadConfig(startPath)
91
+ const checks: DoctorCheck[] = []
92
+ const add = (
93
+ check: Omit<DoctorCheck, "remediation"> & {
94
+ readonly remediation?: string
95
+ },
96
+ ) =>
97
+ checks.push({
98
+ ...check,
99
+ remediation:
100
+ check.status === "fail"
101
+ ? (check.remediation ?? "Remediation is unknown.")
102
+ : null,
103
+ })
104
+
105
+ const tool = function* (
106
+ id: string,
107
+ executable: string,
108
+ level: DoctorCheckLevel,
109
+ label: string,
110
+ ) {
111
+ const available = yield* executableAvailable(executable, root)
112
+ add({
113
+ id,
114
+ category: id.startsWith("tool.") ? "tool" : "integration",
115
+ level,
116
+ status: available ? "pass" : "fail",
117
+ message: available
118
+ ? `${label} executable '${executable}' is available`
119
+ : `${label} executable '${executable}' is unavailable`,
120
+ remediation:
121
+ level === "optional"
122
+ ? `Install '${executable}' to enable ${label.toLowerCase()}, or leave it unavailable if unused.`
123
+ : `Install '${executable}' and ensure it is executable on PATH.`,
124
+ })
125
+ return available
126
+ }
127
+
128
+ const gitAvailable = yield* tool("tool.git", "git", "error", "Git")
129
+ yield* tool(
130
+ "capability.runner.opencode",
131
+ "opencode",
132
+ "optional",
133
+ "OpenCode runner",
134
+ )
135
+ yield* tool(
136
+ "capability.runner.claude",
137
+ "claude",
138
+ "optional",
139
+ "Claude runner",
140
+ )
141
+
142
+ const configuredCommands: readonly (readonly [
143
+ string,
144
+ readonly string[],
145
+ string,
146
+ ])[] = [
147
+ ...(config.chooserCommand
148
+ ? [
149
+ [
150
+ "integration.chooser",
151
+ config.chooserCommand,
152
+ "Chooser",
153
+ ] as const,
154
+ ]
155
+ : []),
156
+ ...(config.worktreeCreateCommand
157
+ ? [
158
+ [
159
+ "integration.worktree-create",
160
+ config.worktreeCreateCommand,
161
+ "Worktree creator",
162
+ ] as const,
163
+ ]
164
+ : []),
165
+ ...Object.entries(config.runners ?? {}).map(
166
+ ([name, runner]) =>
167
+ [
168
+ `integration.runner.${name}`,
169
+ runner.command,
170
+ `Configured runner '${name}'`,
171
+ ] as const,
172
+ ),
173
+ ...Object.entries(config.runners ?? {}).flatMap(([name, runner]) =>
174
+ runner.resumeCommand
175
+ ? [
176
+ [
177
+ `integration.runner.${name}.resume`,
178
+ runner.resumeCommand,
179
+ `Configured runner '${name}' resume`,
180
+ ] as const,
181
+ ]
182
+ : [],
183
+ ),
184
+ ...(config.delivery
185
+ ? [
186
+ [
187
+ `integration.delivery.${config.delivery.provider}`,
188
+ config.delivery.createCommand,
189
+ `Delivery provider '${config.delivery.provider}'`,
190
+ ] as const,
191
+ [
192
+ `integration.delivery.${config.delivery.provider}.query`,
193
+ config.delivery.queryCommand,
194
+ `Delivery provider '${config.delivery.provider}' query`,
195
+ ] as const,
196
+ ]
197
+ : []),
198
+ ]
199
+ for (const [id, command, label] of configuredCommands) {
200
+ yield* tool(id, command[0]!, "error", label)
201
+ }
202
+
203
+ const validation = yield* workbases.validate(root)
204
+ add({
205
+ id: "workbase.validation",
206
+ category: "workbase",
207
+ level: "error",
208
+ status: validation.valid ? "pass" : "fail",
209
+ message: validation.valid
210
+ ? "Workbase documents and relationships are valid"
211
+ : `Workbase validation found ${validation.issues.length} issue(s): ${validation.issues.map((issue) => `${issue.path}: ${issue.message}`).join("; ")}`,
212
+ remediation:
213
+ "Run 'agency validate' and correct every reported issue.",
214
+ })
215
+
216
+ for (const [id, mode, level, label, remediation] of [
217
+ [
218
+ "permission.workbase.read",
219
+ constants.R_OK,
220
+ "error",
221
+ "readable",
222
+ `Grant the current user read access to ${root}.`,
223
+ ],
224
+ [
225
+ "permission.workbase.write",
226
+ constants.W_OK,
227
+ "warning",
228
+ "writable",
229
+ `Grant the current user write access to ${root} before running mutation commands.`,
230
+ ],
231
+ ] as const) {
232
+ const available = yield* permissionAvailable(root, mode)
233
+ add({
234
+ id,
235
+ category: "permission",
236
+ level,
237
+ status: available ? "pass" : "fail",
238
+ message: `Workbase root is ${available ? "" : "not "}${label}`,
239
+ remediation,
240
+ })
241
+ }
242
+
243
+ const integrationStatus = yield* integrations.status(root)
244
+ for (const file of integrationStatus.files) {
245
+ const failed = file.state === "missing" || file.state === "drifted"
246
+ add({
247
+ id: `integration.file.${file.name}`,
248
+ category: "integration",
249
+ level: file.state === "customized" ? "optional" : "warning",
250
+ status: failed ? "fail" : "pass",
251
+ message: `${file.name} integration file is ${file.state}: ${file.path}`,
252
+ remediation:
253
+ "Run 'agency integration sync' to restore managed content.",
254
+ })
255
+ }
256
+
257
+ const refs = new Map<string, Set<string>>()
258
+ const declareRef = (repo: string, ref: string) => {
259
+ const values = refs.get(repo) ?? new Set<string>()
260
+ values.add(ref)
261
+ refs.set(repo, values)
262
+ }
263
+ if (validation.valid) {
264
+ for (const epic of yield* epics.list(root)) {
265
+ for (const reference of epic.data.repos)
266
+ declareRef(reference.repo, reference.ref)
267
+ }
268
+ for (const task of yield* tasks.list(root)) {
269
+ if ("repo" in task.data) {
270
+ declareRef(task.data.repo, task.data.base)
271
+ for (const reference of task.data.repos ?? [])
272
+ declareRef(reference.repo, reference.ref)
273
+ } else {
274
+ for (const phase of yield* phases.list(task.id, root)) {
275
+ declareRef(phase.data.repo, phase.data.base)
276
+ for (const reference of phase.data.repos ?? [])
277
+ declareRef(reference.repo, reference.ref)
278
+ }
279
+ }
280
+ }
281
+ }
282
+
283
+ const repositoryList = gitAvailable
284
+ ? yield* repositories.list(root)
285
+ : []
286
+ if (!gitAvailable) {
287
+ add({
288
+ id: "repository.inspection",
289
+ category: "repository",
290
+ level: "warning",
291
+ status: "fail",
292
+ message:
293
+ "Repository, ref, remote, and worktree checks were skipped because Git is unavailable",
294
+ remediation: "Install 'git' and rerun 'agency doctor'.",
295
+ })
296
+ }
297
+ for (const repository of repositoryList) {
298
+ const verified = yield* fs.runCommand(
299
+ ["git", "-C", repository.path, "rev-parse", "--git-dir"],
300
+ { captureOutput: true },
301
+ )
302
+ const repositoryValid = verified.exitCode === 0
303
+ add({
304
+ id: `repository.${repository.alias}.valid`,
305
+ category: "repository",
306
+ level: "error",
307
+ status: repositoryValid ? "pass" : "fail",
308
+ message: repositoryValid
309
+ ? `Repository '${repository.alias}' is a valid Git repository`
310
+ : `Repository '${repository.alias}' is not a valid Git repository`,
311
+ remediation: `Run 'agency repo verify ${repository.alias}', then repair or relink the repository.`,
312
+ })
313
+ add({
314
+ id: `repository.${repository.alias}.remote`,
315
+ category: "repository",
316
+ level: "warning",
317
+ status: repository.remote ? "pass" : "fail",
318
+ message: repository.remote
319
+ ? `Repository '${repository.alias}' origin is ${repository.remote}`
320
+ : `Repository '${repository.alias}' has no origin remote`,
321
+ remediation: `Run 'agency repo remote ${repository.alias} <url>' to configure origin.`,
322
+ })
323
+
324
+ for (const ref of [...(refs.get(repository.alias) ?? [])].sort()) {
325
+ const local = yield* fs.runCommand(
326
+ [
327
+ "git",
328
+ "-C",
329
+ repository.path,
330
+ "rev-parse",
331
+ "--verify",
332
+ `${ref}^{commit}`,
333
+ ],
334
+ { captureOutput: true },
335
+ )
336
+ const remote =
337
+ local.exitCode === 0
338
+ ? local
339
+ : yield* fs.runCommand(
340
+ [
341
+ "git",
342
+ "-C",
343
+ repository.path,
344
+ "rev-parse",
345
+ "--verify",
346
+ `origin/${ref}^{commit}`,
347
+ ],
348
+ { captureOutput: true },
349
+ )
350
+ const found = local.exitCode === 0 || remote.exitCode === 0
351
+ add({
352
+ id: `ref.${repository.alias}.${ref}`,
353
+ category: "ref",
354
+ level: "error",
355
+ status: found ? "pass" : "fail",
356
+ message: `Declared ref '${ref}' for '${repository.alias}' is ${found ? "available" : "missing"}`,
357
+ remediation: `Run 'agency repo fetch ${repository.alias}' and verify that ref '${ref}' exists on origin.`,
358
+ })
359
+ }
360
+ }
361
+
362
+ if (validation.valid && gitAvailable) {
363
+ const inspected = yield* Effect.either(worktrees.list(root))
364
+ if (Either.isLeft(inspected)) {
365
+ add({
366
+ id: "worktree.inspection",
367
+ category: "worktree",
368
+ level: "warning",
369
+ status: "fail",
370
+ message: `Worktree inspection failed: ${messageOf(inspected.left)}`,
371
+ remediation:
372
+ "Remediation is unknown; inspect repositories with 'agency worktree list --json'.",
373
+ })
374
+ } else {
375
+ for (const inspection of inspected.right) {
376
+ const target =
377
+ inspection.owner.kind === "phase"
378
+ ? `${inspection.owner.taskId}.${inspection.owner.phaseId}`
379
+ : inspection.owner.taskId
380
+ add({
381
+ id: `worktree.${inspection.owner.kind}.${target}`,
382
+ category: "worktree",
383
+ level: "error",
384
+ status: inspection.conflicts.length === 0 ? "pass" : "fail",
385
+ message:
386
+ inspection.conflicts.length === 0
387
+ ? `Worktree registrations for '${target}' are consistent`
388
+ : inspection.conflicts
389
+ .map((conflict) => conflict.message)
390
+ .join("; "),
391
+ remediation: `Run 'agency worktree inspect ${inspection.owner.taskId}${inspection.owner.phaseId ? ` ${inspection.owner.phaseId}` : ""}', then use 'agency worktree repair' if appropriate.`,
392
+ })
393
+ }
394
+ }
395
+ }
396
+
397
+ const summary = {
398
+ passed: checks.filter((check) => check.status === "pass").length,
399
+ errors: checks.filter(
400
+ (check) => check.status === "fail" && check.level === "error",
401
+ ).length,
402
+ warnings: checks.filter(
403
+ (check) => check.status === "fail" && check.level === "warning",
404
+ ).length,
405
+ optional: checks.filter(
406
+ (check) => check.status === "fail" && check.level === "optional",
407
+ ).length,
408
+ }
409
+ return {
410
+ version: 1,
411
+ root,
412
+ healthy: summary.errors === 0,
413
+ summary,
414
+ checks,
415
+ } satisfies DoctorReport
416
+ }),
417
+ }),
418
+ },
419
+ ) {}
package/src/test-utils.ts CHANGED
@@ -19,6 +19,7 @@ import { ClaimService } from "./services/ClaimService"
19
19
  import { SyncService } from "./services/SyncService"
20
20
  import { ReadinessService } from "./services/ReadinessService"
21
21
  import { GraphMutationService } from "./services/GraphMutationService"
22
+ import { DoctorService } from "./services/DoctorService"
22
23
 
23
24
  export const createTempDir = () => mkdtemp(join(tmpdir(), "agency-test-"))
24
25
 
@@ -42,6 +43,7 @@ const TestLayer = Layer.mergeAll(
42
43
  SyncService.Default,
43
44
  ReadinessService.Default,
44
45
  GraphMutationService.Default,
46
+ DoctorService.Default,
45
47
  )
46
48
 
47
49
  export async function runTestEffect<A, E>(