@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,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
|
+
) {}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
|
2
2
|
import { Effect } from "effect"
|
|
3
3
|
import { createHash } from "node:crypto"
|
|
4
|
-
import { mkdir, symlink, unlink } from "node:fs/promises"
|
|
4
|
+
import { mkdir, stat, symlink, unlink, utimes } from "node:fs/promises"
|
|
5
5
|
import { dirname, join } from "node:path"
|
|
6
6
|
import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
|
|
7
7
|
import { managedWorkbaseAgents } from "../workbase/agents-file"
|
|
@@ -19,6 +19,9 @@ const managed = (prefix: string, body: string, suffix = "") => {
|
|
|
19
19
|
return `${prefix}${checksum}${suffix}\n\n${body}`
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
const managedBody = (content: string) =>
|
|
23
|
+
content.slice(content.indexOf("\n\n") + 2)
|
|
24
|
+
|
|
22
25
|
const status = (root: string) =>
|
|
23
26
|
runTestEffect(
|
|
24
27
|
IntegrationService.pipe(Effect.flatMap((service) => service.status(root))),
|
|
@@ -47,7 +50,7 @@ describe("IntegrationService", () => {
|
|
|
47
50
|
expect(await Bun.file(join(root, "AGENTS.md")).exists()).toBe(false)
|
|
48
51
|
|
|
49
52
|
await write(root, "AGENTS.md", managedWorkbaseAgents)
|
|
50
|
-
await write(root, ".opencode/opencode.jsonc", managedWorkbaseOpencode
|
|
53
|
+
await write(root, ".opencode/opencode.jsonc", managedWorkbaseOpencode)
|
|
51
54
|
expect((await status(root)).files.map(({ state }) => state)).toEqual([
|
|
52
55
|
"managed",
|
|
53
56
|
"managed",
|
|
@@ -68,6 +71,48 @@ describe("IntegrationService", () => {
|
|
|
68
71
|
])
|
|
69
72
|
})
|
|
70
73
|
|
|
74
|
+
test("generates context-first safety and execution closeout guidance", () => {
|
|
75
|
+
const body = managedBody(managedWorkbaseAgents)
|
|
76
|
+
|
|
77
|
+
expect(body).toContain("agency context . --json")
|
|
78
|
+
expect(body).toContain("authority.writable.checkoutPath")
|
|
79
|
+
expect(body).toContain("Do not begin execution without a claim")
|
|
80
|
+
expect(body).toContain("Run `agency validate`")
|
|
81
|
+
expect(body).toContain("only with explicit user intent")
|
|
82
|
+
expect(body).toContain("An execution unit is `working`")
|
|
83
|
+
expect(body).toContain("It becomes `done`")
|
|
84
|
+
expect(body).toContain("solely because its PR")
|
|
85
|
+
expect(body).toContain("creating or updating a PR")
|
|
86
|
+
expect(body).toContain("marking it ready")
|
|
87
|
+
expect(body).toMatch(/completing\s+a refinement loop/)
|
|
88
|
+
expect(body).toContain("pausing or handing off")
|
|
89
|
+
expect(body).toContain("`agency task status` or `agency phase status`")
|
|
90
|
+
expect(body).toContain("`TASK.md` or `PHASE.md`")
|
|
91
|
+
expect(body).toContain("PR state, current head, diff summary")
|
|
92
|
+
expect(body).toContain("Run `agency validate` before reporting completion")
|
|
93
|
+
expect(body).toContain("agency integration status")
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test("grants OpenCode access to the complete workbase", () => {
|
|
97
|
+
const config = JSON.parse(managedBody(managedWorkbaseOpencode))
|
|
98
|
+
|
|
99
|
+
expect(config.references).toEqual({
|
|
100
|
+
tasks: {
|
|
101
|
+
path: "../tasks",
|
|
102
|
+
description:
|
|
103
|
+
"Agency task definitions and execution context; authority still comes from agency context",
|
|
104
|
+
},
|
|
105
|
+
epics: {
|
|
106
|
+
path: "../epics",
|
|
107
|
+
description:
|
|
108
|
+
"Agency epic definitions and orchestration context; no implementation write authority",
|
|
109
|
+
},
|
|
110
|
+
})
|
|
111
|
+
expect(config.permission).toEqual({
|
|
112
|
+
external_directory: { "../**": "allow" },
|
|
113
|
+
})
|
|
114
|
+
})
|
|
115
|
+
|
|
71
116
|
test("treats an existing JSON OpenCode config as customized", async () => {
|
|
72
117
|
await write(root, ".opencode/opencode.json", '{"model":"test/model"}\n')
|
|
73
118
|
|
|
@@ -95,7 +140,7 @@ describe("IntegrationService", () => {
|
|
|
95
140
|
])
|
|
96
141
|
expect(await Bun.file(join(root, "AGENTS.md")).text()).toBe(customAgents)
|
|
97
142
|
expect(await Bun.file(join(root, ".opencode/opencode.jsonc")).text()).toBe(
|
|
98
|
-
managedWorkbaseOpencode
|
|
143
|
+
managedWorkbaseOpencode,
|
|
99
144
|
)
|
|
100
145
|
|
|
101
146
|
await unlink(join(root, "AGENTS.md"))
|
|
@@ -106,6 +151,21 @@ describe("IntegrationService", () => {
|
|
|
106
151
|
)
|
|
107
152
|
})
|
|
108
153
|
|
|
154
|
+
test("does not rewrite an already-current OpenCode configuration", async () => {
|
|
155
|
+
const path = join(root, ".opencode/opencode.jsonc")
|
|
156
|
+
await write(root, ".opencode/opencode.jsonc", managedWorkbaseOpencode)
|
|
157
|
+
const timestamp = new Date("2000-01-01T00:00:00.000Z")
|
|
158
|
+
await utimes(path, timestamp, timestamp)
|
|
159
|
+
|
|
160
|
+
const result = await sync(root)
|
|
161
|
+
|
|
162
|
+
expect(result.files[1]).toMatchObject({
|
|
163
|
+
state: "managed",
|
|
164
|
+
changed: false,
|
|
165
|
+
})
|
|
166
|
+
expect((await stat(path)).mtimeMs).toBe(timestamp.getTime())
|
|
167
|
+
})
|
|
168
|
+
|
|
109
169
|
test("does not overwrite managed files whose checksums no longer match", async () => {
|
|
110
170
|
const tampered = `${managed(
|
|
111
171
|
"<!-- agency-managed: sha256=",
|
|
@@ -122,6 +182,23 @@ describe("IntegrationService", () => {
|
|
|
122
182
|
expect(await Bun.file(join(root, "AGENTS.md")).text()).toBe(tampered)
|
|
123
183
|
})
|
|
124
184
|
|
|
185
|
+
test("does not overwrite a user-modified OpenCode configuration", async () => {
|
|
186
|
+
const tampered = `${managed(
|
|
187
|
+
"// agency-managed: sha256=",
|
|
188
|
+
'{"references":{}}\n',
|
|
189
|
+
)}// User edit\n`
|
|
190
|
+
await write(root, ".opencode/opencode.jsonc", tampered)
|
|
191
|
+
|
|
192
|
+
const result = await sync(root)
|
|
193
|
+
expect(result.files[1]).toMatchObject({
|
|
194
|
+
state: "customized",
|
|
195
|
+
changed: false,
|
|
196
|
+
})
|
|
197
|
+
expect(await Bun.file(join(root, ".opencode/opencode.jsonc")).text()).toBe(
|
|
198
|
+
tampered,
|
|
199
|
+
)
|
|
200
|
+
})
|
|
201
|
+
|
|
125
202
|
test("does not follow symlinked integration files", async () => {
|
|
126
203
|
const target = join(root, "custom-agents.md")
|
|
127
204
|
await Bun.write(target, "# External instructions\n")
|
|
@@ -75,7 +75,7 @@ const inspect = (root: string) =>
|
|
|
75
75
|
"opencode",
|
|
76
76
|
opencodePath,
|
|
77
77
|
yield* fs.readFile(opencodePath),
|
|
78
|
-
managedWorkbaseOpencode
|
|
78
|
+
managedWorkbaseOpencode,
|
|
79
79
|
canUpdateManagedWorkbaseOpencode,
|
|
80
80
|
),
|
|
81
81
|
)
|
|
@@ -119,7 +119,7 @@ export class IntegrationService extends Effect.Service<IntegrationService>()(
|
|
|
119
119
|
yield* fs.writeFile(status.path, managedWorkbaseAgents)
|
|
120
120
|
} else {
|
|
121
121
|
yield* fs.createDirectory(join(root, ".opencode"))
|
|
122
|
-
yield* fs.writeFile(status.path, managedWorkbaseOpencode
|
|
122
|
+
yield* fs.writeFile(status.path, managedWorkbaseOpencode)
|
|
123
123
|
}
|
|
124
124
|
}
|
|
125
125
|
files.push({
|
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>(
|
package/src/workbase/AGENTS.md
CHANGED
|
@@ -4,34 +4,68 @@ This directory is an Agency workbase. Epics, tasks, and phases are durable
|
|
|
4
4
|
Markdown documents; repository aliases and generated Git worktrees provide code
|
|
5
5
|
access.
|
|
6
6
|
|
|
7
|
-
##
|
|
7
|
+
## Bootstrap
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
read its context:
|
|
9
|
+
Start every session with one read-only command:
|
|
11
10
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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.
|
|
11
|
+
```bash
|
|
12
|
+
agency context . --json
|
|
13
|
+
```
|
|
18
14
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
15
|
+
Use the returned target, document paths and revisions, dependency readiness,
|
|
16
|
+
authority, checkout state, PR state, and validation result. Do not infer these
|
|
17
|
+
from directory names or stale prose.
|
|
18
|
+
|
|
19
|
+
## Authority
|
|
20
|
+
|
|
21
|
+
- An epic or multi-phase task is orchestration context and has no implementation
|
|
22
|
+
write authority.
|
|
23
|
+
- For an execution unit, write code only at
|
|
24
|
+
`authority.writable.checkoutPath`. Every `authority.references` checkout is
|
|
25
|
+
read-only, even if filesystem permissions allow writes.
|
|
26
|
+
- Keep task-wide decisions in `TASK.md` and phase-specific delivery context in
|
|
27
|
+
`PHASE.md`. Use Agency commands for structural frontmatter mutations.
|
|
22
28
|
|
|
23
29
|
## Safety
|
|
24
30
|
|
|
25
|
-
-
|
|
26
|
-
|
|
27
|
-
-
|
|
28
|
-
`
|
|
29
|
-
- Coordinate execution ownership with `agency claim`, `agency release`, and
|
|
30
|
-
`agency finish`; `agency work` claims execution units before launch.
|
|
31
|
+
- Stop on validation errors, dependency blockers, an unexpected writable
|
|
32
|
+
repository, or a conflicting active claim.
|
|
33
|
+
- Do not begin execution without a claim. `agency work` claims before launch;
|
|
34
|
+
external orchestrators use `agency claim` with the revision from context.
|
|
31
35
|
- Do not manually create, move, or remove worktrees under `code/`.
|
|
32
36
|
- Use `agency archive`, rather than moving work item folders manually.
|
|
33
37
|
- Do not edit bare repositories or repository symlinks under `repos/`.
|
|
34
38
|
- Do not run `agency work` from an active agent session unless the user
|
|
35
39
|
explicitly asks to launch another agent.
|
|
36
40
|
- Run `agency validate` before worktree or pull-request operations.
|
|
37
|
-
- Create a pull request only
|
|
41
|
+
- Create a pull request only with explicit user intent, using
|
|
42
|
+
`agency pr create <task> [phase]` so the URL is recorded durably.
|
|
43
|
+
|
|
44
|
+
## Closeout
|
|
45
|
+
|
|
46
|
+
An execution unit is `working` while implementation or requested delivery work
|
|
47
|
+
remains. It becomes `done` when both are complete, even if its PR remains open
|
|
48
|
+
for review or merge. Do not leave a task or phase `working` solely because its PR
|
|
49
|
+
is open; if merge was requested, merge remains delivery work.
|
|
50
|
+
|
|
51
|
+
At each closeout trigger (creating or updating a PR, marking it ready, completing
|
|
52
|
+
a refinement loop, or pausing or handing off completed implementation work):
|
|
53
|
+
|
|
54
|
+
- Use `agency task status` or `agency phase status` to set the execution unit's
|
|
55
|
+
current status. Finish an active claim with the current revision via
|
|
56
|
+
`agency finish`.
|
|
57
|
+
- Refresh durable delivery context in `TASK.md` or `PHASE.md`, including recorded
|
|
58
|
+
PR state, current head, diff summary, and verification results after later
|
|
59
|
+
pushes when those details are maintained there.
|
|
60
|
+
- Run `agency validate` before reporting completion.
|
|
61
|
+
|
|
62
|
+
## Managed Integration
|
|
63
|
+
|
|
64
|
+
`agency integration status` reports `managed`, `drifted`, `customized`, or
|
|
65
|
+
`missing` generated files. `agency integration sync` updates only missing or
|
|
66
|
+
checksum-safe drifted files and preserves user-customized files. `agency init`
|
|
67
|
+
creates these files, and `agency work` reconciles them before launching an agent.
|
|
68
|
+
|
|
69
|
+
OpenCode can access the complete workbase tree, but this filesystem permission
|
|
70
|
+
does not expand Agency write authority beyond the checkout reported by
|
|
71
|
+
`agency context`.
|