@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,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(root))
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(root),
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(root),
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(root))
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>(
@@ -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
- ## Session Context
7
+ ## Bootstrap
8
8
 
9
- Before doing work, identify the current entity from the working directory and
10
- read its context:
9
+ Start every session with one read-only command:
11
10
 
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.
11
+ ```bash
12
+ agency context . --json
13
+ ```
18
14
 
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.
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
- - Discovery and observational commands are read-only; use
26
- `agency integration sync` to update managed agent files explicitly.
27
- - Keep task-level decisions in `TASK.md` and phase-specific delivery context in
28
- `PHASE.md`.
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 when the user explicitly requests it.
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`.