@markjaquith/agency 2.40.0 → 2.41.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/README.md CHANGED
@@ -80,10 +80,10 @@ workbase/
80
80
 
81
81
  Agency keeps discovery and other observational commands read-only. Run
82
82
  `agency integration status` to inspect `.agency/AGENTS.md` and
83
- `.opencode/opencode.jsonc`, then `agency integration sync` to create missing
84
- files or refresh checksum-safe managed files. Customized files are reported but
85
- never overwritten. The root `AGENTS.md` is user-owned and is not inspected or
86
- modified by Agency.
83
+ `.opencode/opencode.jsonc`, and `.opencode/command/agency.md`, then `agency
84
+ integration sync` to create missing files or refresh checksum-safe managed
85
+ files. Customized files are reported but never overwritten. The root
86
+ `AGENTS.md` is user-owned and is not inspected or modified by Agency.
87
87
 
88
88
  When upgrading an existing workbase, synchronization moves a checksum-valid
89
89
  Agency-managed root `AGENTS.md` to `.agency/AGENTS.md` once the OpenCode config
@@ -96,6 +96,13 @@ reference. OpenCode discovers that config from task and epic launch directories.
96
96
  Agents receive whole-workbase visibility from that reference. Bash and Agency
97
97
  operations must still follow the write authority reported by `agency context`.
98
98
 
99
+ OpenCode also discovers a managed `/agency` command. Use `/agency status` for a
100
+ read-only current-work summary, `/agency start [target]` to begin or resume work
101
+ in the active session, `/agency next` to inspect ready work, `/agency validate`
102
+ to check the workbase, and `/agency finish [target]` for verified closeout. The
103
+ command uses OpenCode positional arguments internally and defaults to the safe
104
+ `status` workflow when no subcommand is supplied.
105
+
99
106
  Repository aliases and canonical fetch remotes are declared in tracked
100
107
  `agency.json`; local bare clones and symlinks remain ignored under
101
108
  `repos/{alias}`. A declaration contains no local path, symlink target, checkout,
@@ -569,7 +576,7 @@ Targeted commands accept `--epic`, `--task`, and `--phase` where those entity
569
576
  kinds apply. A phase selector requires a task selector. Entity selectors cannot
570
577
  be mixed with positional target IDs, and an epic selector cannot be mixed with
571
578
  task or phase selectors. This makes commands such as
572
- `agency phase status done --task ship --phase release --workbase primary --no-input`
579
+ `agency phase status working --task ship --phase release --workbase primary --no-input`
573
580
  fully independent of process cwd and prompts.
574
581
 
575
582
  Inspect tasks:
@@ -577,7 +584,7 @@ Inspect tasks:
577
584
  ```text
578
585
  agency task list [filters] [--json]
579
586
  agency task show <id> [--json]
580
- agency task status <id> <open|done|dropped> [--json]
587
+ agency task status <id> <open|working|dropped> [--json]
581
588
  agency task update <id> [metadata options] [--json]
582
589
  agency task rename <id> <new-id> [--json]
583
590
  agency task move <id> (--epic <epic-id> | --no-epic) [--json]
@@ -616,7 +623,7 @@ agency phase create <task-id> <phase-id>
616
623
 
617
624
  agency phase list <task-id> [filters] [--json]
618
625
  agency phase show <task-id> <phase-id> [--json]
619
- agency phase status <task-id> <phase-id> <open|done|dropped> [--json]
626
+ agency phase status <task-id> <phase-id> <open|working|dropped> [--json]
620
627
  agency phase update <task-id> <phase-id> [metadata options] [--json]
621
628
  agency phase rename <task-id> <phase-id> <new-id> [--json]
622
629
  agency phase dependency <add|remove> <task-id> <phase-id> <dependency-id>
@@ -639,6 +646,8 @@ writing anything.
639
646
  Single-phase tasks and phases store status in YAML. New execution units start
640
647
  `open`, and `agency work` marks the selected execution unit `working` immediately
641
648
  before launch. Running `agency work` again can relaunch unclaimed `working` work.
649
+ The `done` status is reserved for an authoritative merged pull request and is
650
+ applied by `agency sync --apply`, not by task or phase status commands.
642
651
  Use explicit claims only when an external orchestrator needs coordinated
643
652
  ownership. The interactive work selector displays status markers before
644
653
  execution units. Existing working and delegated work may be released to `open`
@@ -672,11 +681,12 @@ agency finish <task-id> [phase-id] --session-id <id>
672
681
  --revision <sha256> --outcome <done|dropped> [--json]
673
682
  ```
674
683
 
675
- An active claim sets status to `working`. Release returns it to `open`; finish
676
- sets the terminal outcome. Released and finished ownership metadata remains in
677
- frontmatter. Conflicts return the current revision and complete ownership record
678
- in the machine error envelope rather than overwriting it. Expired claims may be
679
- replaced with a revision-guarded claim.
684
+ An active claim sets status to `working`. Release returns it to `open`. Finish
685
+ records the claim outcome and ownership history; a `done` claim outcome leaves
686
+ the execution unit `working` until its pull request is merged, while `dropped`
687
+ remains terminal. Conflicts return the current revision and complete ownership
688
+ record in the machine error envelope rather than overwriting it. Expired claims
689
+ may be replaced with a revision-guarded claim.
680
690
 
681
691
  `agency work` does not claim execution units. It refuses active explicit claims,
682
692
  marks open execution work `working`, and launches the runner. External
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "2.40.0",
3
+ "version": "2.41.0",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -118,11 +118,12 @@ independently meaningful tasks need coordination.
118
118
 
119
119
  1. Re-run `agency validate` and repository checks.
120
120
  2. Create a PR only when requested: `agency pr create <task> [phase]`.
121
- 3. Record terminal state only when the requested outcome is true. A created PR
122
- alone does not make work `done` if completion requires merge.
123
- 4. If the session has a claim, use revision-guarded `agency finish`; otherwise
124
- use the task or phase status command. Use `dropped` only for intentionally
125
- abandoned work.
121
+ 3. Keep committed work `working` while its pull request is open. After the
122
+ authoritative pull request merges, use `agency sync --apply` to reconcile it
123
+ to `done`.
124
+ 4. If the session has a claim, use revision-guarded `agency finish`; a successful
125
+ claim outcome closes ownership without marking unmerged work `done`. Use
126
+ `dropped` only for intentionally abandoned work.
126
127
  5. Report the durable status and PR URL. Do not manually remove the worktree.
127
128
 
128
129
  ## Human Launch vs Active Agent
@@ -89,7 +89,7 @@ agency task create <id> --multi-phase [--ticket-url <url>]
89
89
  [--description <text>] [--epic <id>] [--json]
90
90
  agency task list [filters] [--json]
91
91
  agency task show <id> [--json]
92
- agency task status <id> <open|done|dropped> [--json]
92
+ agency task status <id> <open|working|dropped> [--json]
93
93
  agency task update <id> [metadata options] [--if-revision <hash>] [--json]
94
94
  agency task rename <id> <new-id> [--if-revision <hash>] [--json]
95
95
  agency task move <id> (--epic <epic-id> | --no-epic)
@@ -102,7 +102,7 @@ agency phase create <task-id> <phase-id> --repo <alias> --branch <name>
102
102
  [--depends-on <phase-id>...] [--first-phase <phase-id>] [--json]
103
103
  agency phase list <task-id> [filters] [--json]
104
104
  agency phase show <task-id> <phase-id> [--json]
105
- agency phase status <task-id> <phase-id> <open|done|dropped> [--json]
105
+ agency phase status <task-id> <phase-id> <open|working|dropped> [--json]
106
106
  agency phase update <task-id> <phase-id> [metadata options]
107
107
  [--if-revision <hash>] [--json]
108
108
  agency phase rename <task-id> <phase-id> <new-id>
@@ -137,6 +137,8 @@ readable legacy state but cannot be newly assigned.
137
137
  - An execution unit has one writable `repo`; plural `repos` are read-only
138
138
  `{ repo, ref }` entries and cannot repeat the writable alias.
139
139
  - A writable `(repo, branch)` pair belongs to exactly one active execution unit.
140
+ - `done` represents an authoritative merged pull request and is reconciled by
141
+ `agency sync --apply`; committed or review-ready work remains `working`.
140
142
  - Only `done` satisfies dependencies. `dropped` is terminal but blocks dependents.
141
143
  - Epic task dependencies live in `EPIC.md`; phase dependencies live in `TASK.md`.
142
144
  - IDs remain stable; use `dependsOn`, not numeric directory prefixes, for order.
@@ -184,15 +184,17 @@ agency release checkout ui --session-id session-123 \
184
184
 
185
185
  ## Finish Verified Work
186
186
 
187
- Only after the assigned completion condition is true:
187
+ After the claimed run completes successfully:
188
188
 
189
189
  ```bash
190
190
  agency finish checkout ui --session-id session-123 \
191
191
  --revision <current-sha256> --outcome done --json
192
192
  ```
193
193
 
194
- Do not substitute `phase status done` for `finish` when an active claim exists;
195
- `finish` preserves ownership history and revision safety.
194
+ Do not substitute a phase status mutation for `finish`; `finish` preserves
195
+ ownership history and revision safety. A `done` claim outcome leaves unmerged
196
+ execution work `working`. After the authoritative pull request merges, use
197
+ `agency sync --apply` to reconcile the execution unit to `done`.
196
198
 
197
199
  ## Create A Pull Request
198
200
 
package/src/cli.test.ts CHANGED
@@ -244,6 +244,10 @@ describe("CLI", () => {
244
244
  ),
245
245
  )
246
246
  expect(finished.claim).toMatchObject({ state: "finished", outcome: "done" })
247
+ expect(
248
+ parseJson(await runCli(["task", "show", "claimed", "--json"], root)).data
249
+ .status,
250
+ ).toBe("working")
247
251
  })
248
252
 
249
253
  test("routes graph mutations through structured output", async () => {
@@ -476,7 +480,7 @@ describe("CLI", () => {
476
480
  )
477
481
  }
478
482
  parseJson(
479
- await runCli(["task", "status", "finished", "done", "--json"], root),
483
+ await runCli(["task", "status", "finished", "dropped", "--json"], root),
480
484
  )
481
485
 
482
486
  const human = await runCli(["next"], root)
@@ -490,9 +494,9 @@ describe("CLI", () => {
490
494
  expect(result.excluded).toMatchObject([
491
495
  {
492
496
  key: "task/finished",
493
- status: "done",
497
+ status: "dropped",
494
498
  terminal: true,
495
- blockers: [{ kind: "status", reason: "Task status is done" }],
499
+ blockers: [{ kind: "status", reason: "Task status is dropped" }],
496
500
  },
497
501
  ])
498
502
 
@@ -504,8 +508,8 @@ describe("CLI", () => {
504
508
  error: {
505
509
  code: "EXECUTION_BLOCKED",
506
510
  fields: {
507
- status: "done",
508
- blockers: [{ kind: "status", reason: "Task status is done" }],
511
+ status: "dropped",
512
+ blockers: [{ kind: "status", reason: "Task status is dropped" }],
509
513
  },
510
514
  },
511
515
  })
@@ -522,6 +526,7 @@ describe("CLI", () => {
522
526
  expect(before.files).toMatchObject([
523
527
  { name: "agents", state: "managed" },
524
528
  { name: "opencode", state: "managed" },
529
+ { name: "opencode-command", state: "managed" },
525
530
  ])
526
531
 
527
532
  const synced = parseJson(
@@ -530,6 +535,7 @@ describe("CLI", () => {
530
535
  expect(synced.files).toMatchObject([
531
536
  { name: "agents", state: "managed", changed: false },
532
537
  { name: "opencode", state: "managed", changed: false },
538
+ { name: "opencode-command", state: "managed", changed: false },
533
539
  ])
534
540
  })
535
541
 
@@ -1131,18 +1137,18 @@ status: open
1131
1137
  }
1132
1138
 
1133
1139
  parseJson(
1134
- await runCli(["task", "status", "example", "done", "--json"], root),
1140
+ await runCli(["task", "status", "example", "dropped", "--json"], root),
1135
1141
  )
1136
1142
  const blocked = await runCli(
1137
1143
  ["work", "--task", "example", "--runner", "noop"],
1138
1144
  root,
1139
1145
  )
1140
1146
  expect(blocked.exitCode).toBe(1)
1141
- expect(blocked.stderr).toContain("Task status is done")
1147
+ expect(blocked.stderr).toContain("Task status is dropped")
1142
1148
  expect(
1143
1149
  parseJson(await runCli(["task", "show", "example", "--json"], root))
1144
1150
  .data.status,
1145
- ).toBe("done")
1151
+ ).toBe("dropped")
1146
1152
 
1147
1153
  const resumedTask = await runCli(
1148
1154
  ["work", "--task", "example", "--runner", "noop", "--force"],
@@ -1150,7 +1156,7 @@ status: open
1150
1156
  )
1151
1157
  expect(resumedTask).toMatchObject({ exitCode: 0, stderr: "" })
1152
1158
  expect(resumedTask.stdout).toContain(
1153
- "Reopened task/example from done as working",
1159
+ "Reopened task/example from dropped as working",
1154
1160
  )
1155
1161
  expect(
1156
1162
  parseJson(await runCli(["task", "show", "example", "--json"], root))
@@ -93,5 +93,6 @@ Release an execution unit owned by the session.
93
93
  export const finishHelp = `
94
94
  Usage: agency finish <task-id> [phase-id] --session-id <id> --revision <sha256> --outcome <done|dropped>
95
95
 
96
- Finish an execution unit owned by the session.
96
+ Finish a claim owned by the session. A done claim outcome leaves unmerged work
97
+ working; agency sync --apply marks the execution unit done after merge.
97
98
  `
@@ -44,6 +44,11 @@ describe("init command", () => {
44
44
  workbase: expect.objectContaining({ path: ".." }),
45
45
  })
46
46
  expect(config.permission).toBeUndefined()
47
+ const command = await Bun.file(
48
+ join(root, ".opencode/command/agency.md"),
49
+ ).text()
50
+ expect(command).toContain("Workflow: `$1`")
51
+ expect(command).toContain("Optional target: `$2`")
47
52
  })
48
53
 
49
54
  test("preserves existing gitignore entries", async () => {
@@ -36,6 +36,11 @@ describe("integration command", () => {
36
36
  diagnostic: expect.stringContaining("cannot load"),
37
37
  remediation: expect.stringContaining("integration sync"),
38
38
  },
39
+ {
40
+ name: "opencode-command",
41
+ state: "missing",
42
+ remediation: expect.stringContaining("integration sync"),
43
+ },
39
44
  ],
40
45
  })
41
46
  })
@@ -62,11 +67,15 @@ describe("integration command", () => {
62
67
  expect(JSON.parse(logs[0]!).files).toMatchObject([
63
68
  { name: "agents", state: "managed", changed: true },
64
69
  { name: "opencode", state: "managed", changed: true },
70
+ { name: "opencode-command", state: "managed", changed: true },
65
71
  ])
66
72
  expect(await Bun.file(join(root, "AGENTS.md")).exists()).toBe(false)
67
73
  expect(await Bun.file(join(root, ".agency/AGENTS.md")).exists()).toBe(true)
68
74
  expect(
69
75
  await Bun.file(join(root, ".opencode/opencode.jsonc")).exists(),
70
76
  ).toBe(true)
77
+ expect(
78
+ await Bun.file(join(root, ".opencode/command/agency.md")).exists(),
79
+ ).toBe(true)
71
80
  })
72
81
  })
@@ -54,8 +54,8 @@ export const help = `
54
54
  Usage: agency integration <subcommand>
55
55
 
56
56
  Inspect or explicitly synchronize managed agent integration files. OpenCode
57
- launches load the managed file at runtime to provide whole-workbase read
58
- access without changing Agency write authority.
57
+ launches load the managed instructions and project config at runtime, and expose
58
+ the managed /agency command, without changing Agency write authority.
59
59
 
60
60
  Subcommands:
61
61
  status Report file state, access diagnostics, and safe remediation
@@ -275,7 +275,7 @@ Subcommands:
275
275
  list <task> List task phases
276
276
  show <task> <phase> Show a phase
277
277
  status <task> <phase> <status>
278
- Set open, working, done, or dropped
278
+ Set open, working, or dropped
279
279
  update <task> <phase> Update phase metadata
280
280
  rename <task> <phase> <new-id>
281
281
  Rename a phase and update dependencies
@@ -154,5 +154,8 @@ status: open
154
154
  expect(
155
155
  await Bun.file(join(root, ".opencode/opencode.jsonc")).exists(),
156
156
  ).toBe(false)
157
+ expect(
158
+ await Bun.file(join(root, ".opencode/command/agency.md")).exists(),
159
+ ).toBe(false)
157
160
  })
158
161
  })
@@ -74,7 +74,7 @@ describe("status command", () => {
74
74
  await runTestEffect(
75
75
  task({
76
76
  subcommand: "status",
77
- args: ["finished", "done"],
77
+ args: ["finished", "dropped"],
78
78
  cwd: root,
79
79
  silent: true,
80
80
  }),
@@ -239,13 +239,13 @@ describe("task and phase command JSON output", () => {
239
239
  runTestEffect(
240
240
  phase({
241
241
  subcommand: "status",
242
- args: ["multi", "first", "done"],
242
+ args: ["multi", "first", "working"],
243
243
  cwd: root,
244
244
  json: true,
245
245
  }),
246
246
  ),
247
247
  )
248
- expect(JSON.parse(phaseLogs[0]!).data.status).toBe("done")
248
+ expect(JSON.parse(phaseLogs[0]!).data.status).toBe("working")
249
249
 
250
250
  await runTestEffect(
251
251
  task({
@@ -263,13 +263,13 @@ describe("task and phase command JSON output", () => {
263
263
  runTestEffect(
264
264
  task({
265
265
  subcommand: "status",
266
- args: ["single-status", "done"],
266
+ args: ["single-status", "working"],
267
267
  cwd: root,
268
268
  json: true,
269
269
  }),
270
270
  ),
271
271
  )
272
- expect(JSON.parse(taskLogs[0]!).data.status).toBe("done")
272
+ expect(JSON.parse(taskLogs[0]!).data.status).toBe("working")
273
273
  })
274
274
 
275
275
  test("converts a single-phase task with an explicit first phase ID", async () => {
@@ -423,7 +423,7 @@ Subcommands:
423
423
  create <id> Create a task without prompting
424
424
  list List tasks
425
425
  show <id> Show a task
426
- status <id> <status> Set open, working, done, or dropped
426
+ status <id> <status> Set open, working, or dropped
427
427
  update <id> Update task metadata
428
428
  rename <id> <new-id> Rename a task and update graph references
429
429
  move <id> Move a task with --epic or --no-epic
package/src/readiness.ts CHANGED
@@ -5,9 +5,9 @@ export interface ReadinessBlocker {
5
5
  }
6
6
 
7
7
  export const WORK_STATUS_TRANSITIONS = {
8
- open: ["open", "working", "delegated", "done", "dropped"],
9
- working: ["open", "working", "delegated", "done", "dropped"],
10
- delegated: ["open", "working", "delegated", "done", "dropped"],
8
+ open: ["open", "working", "delegated", "dropped"],
9
+ working: ["open", "working", "delegated", "dropped"],
10
+ delegated: ["open", "working", "delegated", "dropped"],
11
11
  done: ["open", "done"],
12
12
  dropped: ["open", "dropped"],
13
13
  } as const satisfies Record<WorkStatus, readonly WorkStatus[]>
@@ -139,7 +139,7 @@ describe("claim service", () => {
139
139
  ),
140
140
  ),
141
141
  )
142
- expect(finished.data.status).toBe("done")
142
+ expect(finished.data.status).toBe("working")
143
143
  expect(finished.claim).toMatchObject({
144
144
  state: "finished",
145
145
  finishedAt: "2026-07-17T12:45:00.000Z",
@@ -538,7 +538,11 @@ export class ClaimService extends Effect.Service<ClaimService>()(
538
538
  outcome: input.outcome,
539
539
  }
540
540
  return {
541
- data: { ...data, status: input.outcome, claim },
541
+ data: {
542
+ ...data,
543
+ status: input.outcome === "done" ? "working" : "dropped",
544
+ claim,
545
+ },
542
546
  claim,
543
547
  }
544
548
  },
@@ -5,6 +5,10 @@ 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"
8
+ import {
9
+ canUpdateManagedWorkbaseOpencodeCommand,
10
+ managedWorkbaseOpencodeCommand,
11
+ } from "../workbase/opencode-command-file"
8
12
  import { managedWorkbaseOpencode } from "../workbase/opencode-file"
9
13
  import { IntegrationService } from "./IntegrationService"
10
14
 
@@ -46,14 +50,21 @@ describe("IntegrationService", () => {
46
50
  expect((await status(root)).files.map(({ state }) => state)).toEqual([
47
51
  "missing",
48
52
  "missing",
53
+ "missing",
49
54
  ])
50
55
  expect(await Bun.file(join(root, ".agency/AGENTS.md")).exists()).toBe(false)
51
56
 
52
57
  await write(root, ".agency/AGENTS.md", managedWorkbaseAgents)
53
58
  await write(root, ".opencode/opencode.jsonc", managedWorkbaseOpencode)
59
+ await write(
60
+ root,
61
+ ".opencode/command/agency.md",
62
+ managedWorkbaseOpencodeCommand,
63
+ )
54
64
  expect((await status(root)).files.map(({ state }) => state)).toEqual([
55
65
  "managed",
56
66
  "managed",
67
+ "managed",
57
68
  ])
58
69
  })
59
70
 
@@ -68,9 +79,40 @@ describe("IntegrationService", () => {
68
79
  expect((await status(root)).files.map(({ state }) => state)).toEqual([
69
80
  "customized",
70
81
  "drifted",
82
+ "missing",
71
83
  ])
72
84
  })
73
85
 
86
+ test("generates a positional OpenCode command for Agency workflows", () => {
87
+ expect(managedWorkbaseOpencodeCommand).toContain(
88
+ "description: Operate Agency work",
89
+ )
90
+ expect(managedWorkbaseOpencodeCommand).toContain("Workflow: `$1`")
91
+ expect(managedWorkbaseOpencodeCommand).toContain("Optional target: `$2`")
92
+ expect(managedWorkbaseOpencodeCommand).toContain(
93
+ "Complete request: `$ARGUMENTS`",
94
+ )
95
+ for (const workflow of [
96
+ "start",
97
+ "status",
98
+ "next",
99
+ "validate",
100
+ "finish",
101
+ "help",
102
+ ]) {
103
+ expect(managedWorkbaseOpencodeCommand).toContain(`- \`${workflow}\`:`)
104
+ }
105
+ expect(managedWorkbaseOpencodeCommand).toContain("Never run `agency work`")
106
+ expect(
107
+ canUpdateManagedWorkbaseOpencodeCommand(managedWorkbaseOpencodeCommand),
108
+ ).toBe(true)
109
+ expect(
110
+ canUpdateManagedWorkbaseOpencodeCommand(
111
+ managedWorkbaseOpencodeCommand.replace("Workflow: `$1`", "Workflow"),
112
+ ),
113
+ ).toBe(false)
114
+ })
115
+
74
116
  test("generates context-first safety and execution closeout guidance", () => {
75
117
  const body = managedBody(managedWorkbaseAgents)
76
118
 
@@ -80,18 +122,20 @@ describe("IntegrationService", () => {
80
122
  expect(body).toContain("External orchestrators claim before launching")
81
123
  expect(body).toContain("Run `agency validate`")
82
124
  expect(body).toContain("only with explicit user intent")
83
- expect(body).toContain("An execution unit is `working`")
125
+ expect(body).toContain("An execution unit remains `working`")
84
126
  expect(body).toContain("It becomes `done`")
85
- expect(body).toContain("solely because its PR")
127
+ expect(body).toContain("Do not mark committed")
86
128
  expect(body).toContain("creating or updating a PR")
87
129
  expect(body).toContain("marking it ready")
88
130
  expect(body).toMatch(/completing\s+a refinement loop/)
89
131
  expect(body).toContain("pausing or handing off")
90
- expect(body).toContain("`agency task status` or `agency phase status`")
132
+ expect(body).toContain("`agency finish`")
133
+ expect(body).toContain("`agency sync --apply`")
91
134
  expect(body).toContain("`TASK.md` or `PHASE.md`")
92
135
  expect(body).toContain("PR state, current head, diff summary")
93
136
  expect(body).toContain("Run `agency validate` before reporting completion")
94
137
  expect(body).toContain("agency integration status")
138
+ expect(body).toContain(".opencode/command/agency.md")
95
139
  })
96
140
 
97
141
  test("grants OpenCode access to the complete workbase", () => {
@@ -166,6 +210,7 @@ describe("IntegrationService", () => {
166
210
  expect(first.files).toMatchObject([
167
211
  { name: "agents", state: "customized", changed: false },
168
212
  { name: "opencode", state: "managed", changed: true },
213
+ { name: "opencode-command", state: "managed", changed: true },
169
214
  ])
170
215
  expect(await Bun.file(join(root, "AGENTS.md")).text()).toBe(
171
216
  customRootAgents,
@@ -176,6 +221,9 @@ describe("IntegrationService", () => {
176
221
  expect(await Bun.file(join(root, ".opencode/opencode.jsonc")).text()).toBe(
177
222
  managedWorkbaseOpencode,
178
223
  )
224
+ expect(
225
+ await Bun.file(join(root, ".opencode/command/agency.md")).text(),
226
+ ).toBe(managedWorkbaseOpencodeCommand)
179
227
 
180
228
  await unlink(join(root, ".agency/AGENTS.md"))
181
229
  const second = await sync(root)
@@ -188,6 +236,34 @@ describe("IntegrationService", () => {
188
236
  )
189
237
  })
190
238
 
239
+ test("preserves user-owned OpenCode commands at either supported path", async () => {
240
+ const custom = "---\ndescription: Custom Agency command\n---\n\nCustom.\n"
241
+ await write(root, ".opencode/commands/agency.md", custom)
242
+
243
+ let result = await sync(root)
244
+ expect(result.files[2]).toMatchObject({
245
+ name: "opencode-command",
246
+ path: join(root, ".opencode/commands/agency.md"),
247
+ state: "customized",
248
+ changed: false,
249
+ })
250
+ expect(
251
+ await Bun.file(join(root, ".opencode/command/agency.md")).exists(),
252
+ ).toBe(false)
253
+
254
+ await unlink(join(root, ".opencode/commands/agency.md"))
255
+ await write(root, ".opencode/command/agency.md", custom)
256
+ result = await sync(root)
257
+ expect(result.files[2]).toMatchObject({
258
+ path: join(root, ".opencode/command/agency.md"),
259
+ state: "customized",
260
+ changed: false,
261
+ })
262
+ expect(
263
+ await Bun.file(join(root, ".opencode/command/agency.md")).text(),
264
+ ).toBe(custom)
265
+ })
266
+
191
267
  test("migrates checksum-valid root instructions", async () => {
192
268
  await write(root, "AGENTS.md", managedWorkbaseAgents)
193
269
 
@@ -10,11 +10,15 @@ import {
10
10
  canUpdateManagedWorkbaseOpencode,
11
11
  managedWorkbaseOpencode,
12
12
  } from "../workbase/opencode-file"
13
+ import {
14
+ canUpdateManagedWorkbaseOpencodeCommand,
15
+ managedWorkbaseOpencodeCommand,
16
+ } from "../workbase/opencode-command-file"
13
17
 
14
18
  type IntegrationFileState = "managed" | "customized" | "missing" | "drifted"
15
19
 
16
20
  interface IntegrationFileStatus {
17
- readonly name: "agents" | "opencode"
21
+ readonly name: "agents" | "opencode" | "opencode-command"
18
22
  readonly path: string
19
23
  readonly state: IntegrationFileState
20
24
  readonly diagnostic: string
@@ -52,6 +56,25 @@ const describe = (
52
56
  "Run 'agency integration sync' to install Agency instructions and whole-workbase OpenCode access.",
53
57
  }
54
58
  }
59
+ if (name === "opencode-command") {
60
+ return state === "managed"
61
+ ? {
62
+ diagnostic: "Agency's managed OpenCode /agency command is current.",
63
+ remediation: null,
64
+ }
65
+ : state === "customized"
66
+ ? {
67
+ diagnostic:
68
+ "A user-owned OpenCode /agency command is present and was preserved.",
69
+ remediation: null,
70
+ }
71
+ : {
72
+ diagnostic:
73
+ "The managed OpenCode /agency command needs synchronization.",
74
+ remediation:
75
+ "Run 'agency integration sync' to install the managed /agency command.",
76
+ }
77
+ }
55
78
 
56
79
  return state === "missing" || state === "drifted"
57
80
  ? {
@@ -98,6 +121,8 @@ const inspect = (root: string) =>
98
121
  const opencodeDirectory = join(root, ".opencode")
99
122
  const opencodePath = join(opencodeDirectory, "opencode.jsonc")
100
123
  const opencodeJsonPath = join(opencodeDirectory, "opencode.json")
124
+ const commandPath = join(opencodeDirectory, "command", "agency.md")
125
+ const pluralCommandPath = join(opencodeDirectory, "commands", "agency.md")
101
126
  const files: IntegrationFileStatus[] = []
102
127
 
103
128
  files.push(
@@ -135,6 +160,29 @@ const inspect = (root: string) =>
135
160
  files.push(fileStatus("opencode", opencodePath, "missing"))
136
161
  }
137
162
 
163
+ if ((yield* fs.readSymlinkTarget(commandPath)) !== null) {
164
+ files.push(fileStatus("opencode-command", commandPath, "customized"))
165
+ } else if (
166
+ (yield* fs.readSymlinkTarget(pluralCommandPath)) !== null ||
167
+ (yield* fs.exists(pluralCommandPath))
168
+ ) {
169
+ files.push(
170
+ fileStatus("opencode-command", pluralCommandPath, "customized"),
171
+ )
172
+ } else if (yield* fs.exists(commandPath)) {
173
+ files.push(
174
+ classify(
175
+ "opencode-command",
176
+ commandPath,
177
+ yield* fs.readFile(commandPath),
178
+ managedWorkbaseOpencodeCommand,
179
+ canUpdateManagedWorkbaseOpencodeCommand,
180
+ ),
181
+ )
182
+ } else {
183
+ files.push(fileStatus("opencode-command", commandPath, "missing"))
184
+ }
185
+
138
186
  return files
139
187
  })
140
188
 
@@ -181,9 +229,12 @@ export class IntegrationService extends Effect.Service<IntegrationService>()(
181
229
  if (status.name === "agents") {
182
230
  yield* fs.createDirectory(join(root, ".agency"))
183
231
  yield* fs.writeFile(status.path, managedWorkbaseAgents)
184
- } else {
232
+ } else if (status.name === "opencode") {
185
233
  yield* fs.createDirectory(join(root, ".opencode"))
186
234
  yield* fs.writeFile(status.path, managedWorkbaseOpencode)
235
+ } else {
236
+ yield* fs.createDirectory(join(root, ".opencode", "command"))
237
+ yield* fs.writeFile(status.path, managedWorkbaseOpencodeCommand)
187
238
  }
188
239
  }
189
240
  files.push({
@@ -487,6 +487,12 @@ export class PhaseService extends Effect.Service<PhaseService>()(
487
487
  })
488
488
  }
489
489
  if (!canTransitionStatus(record.data.status, validStatus)) {
490
+ if (validStatus === "done") {
491
+ return yield* new PhaseError({
492
+ message:
493
+ "Work becomes done after its authoritative pull request is merged; run 'agency sync --apply'",
494
+ })
495
+ }
490
496
  return yield* new PhaseError({
491
497
  message: `Cannot transition phase '${id}' from ${record.data.status} to ${validStatus}; reopen it first`,
492
498
  })
@@ -263,11 +263,13 @@ process.exit(${exitCode})
263
263
  await createTask()
264
264
  await runTestEffect(
265
265
  TaskService.pipe(
266
- Effect.flatMap((service) => service.setStatus("example", "done", root)),
266
+ Effect.flatMap((service) =>
267
+ service.setStatus("example", "dropped", root),
268
+ ),
267
269
  ),
268
270
  )
269
271
 
270
- await expect(createPullRequest()).rejects.toThrow("Task status is done")
272
+ await expect(createPullRequest()).rejects.toThrow("Task status is dropped")
271
273
  expect(
272
274
  await Bun.file(join(root, "tasks/example/code/agency")).exists(),
273
275
  ).toBe(false)
@@ -390,6 +390,99 @@ process.stdout.write(${JSON.stringify(JSON.stringify(record))})
390
390
  expect("pr" in task.data && task.data.pr).toEqual(record)
391
391
  })
392
392
 
393
+ test("marks a successfully finished claim done only after merge", async () => {
394
+ await runTestEffect(
395
+ TaskService.pipe(
396
+ Effect.flatMap((service) =>
397
+ service.create(
398
+ {
399
+ id: "finished-claim",
400
+ ticketUrl: null,
401
+ repo: "agency",
402
+ branch: "feat/example",
403
+ base: "main",
404
+ },
405
+ root,
406
+ ),
407
+ ),
408
+ ),
409
+ )
410
+ await runTestEffect(
411
+ WorktreeService.pipe(
412
+ Effect.flatMap((service) =>
413
+ service.materialize("finished-claim", undefined, root),
414
+ ),
415
+ ),
416
+ )
417
+ await git(
418
+ ["remote", "set-url", "origin", "git@github.com:example/agency.git"],
419
+ join(root, "repos/agency"),
420
+ )
421
+ const initial = await runTestEffect(
422
+ ClaimService.pipe(
423
+ Effect.flatMap((service) =>
424
+ service.inspect("finished-claim", undefined, root),
425
+ ),
426
+ ),
427
+ )
428
+ const claimed = await runTestEffect(
429
+ ClaimService.pipe(
430
+ Effect.flatMap((service) =>
431
+ service.claim(
432
+ {
433
+ taskId: "finished-claim",
434
+ claimant: "orchestrator",
435
+ runner: "agent",
436
+ sessionId: "session-1",
437
+ revision: initial.revision,
438
+ },
439
+ root,
440
+ ),
441
+ ),
442
+ ),
443
+ )
444
+ const finished = await runTestEffect(
445
+ ClaimService.pipe(
446
+ Effect.flatMap((service) =>
447
+ service.finish(
448
+ {
449
+ taskId: "finished-claim",
450
+ sessionId: "session-1",
451
+ revision: claimed.revision,
452
+ outcome: "done",
453
+ },
454
+ root,
455
+ ),
456
+ ),
457
+ ),
458
+ )
459
+ expect(finished.data).toMatchObject({
460
+ status: "working",
461
+ claim: { state: "finished", outcome: "done" },
462
+ })
463
+
464
+ const synced = await runTestEffect(
465
+ SyncService.pipe(
466
+ Effect.flatMap((service) =>
467
+ service.reconcile({ cwd: root, apply: true }),
468
+ ),
469
+ ),
470
+ )
471
+ expect(synced.changes.map((change) => change.kind)).toContain("mark-done")
472
+ expect(
473
+ await runTestEffect(
474
+ TaskService.pipe(
475
+ Effect.flatMap((service) => service.show("finished-claim", root)),
476
+ ),
477
+ ),
478
+ ).toMatchObject({
479
+ data: {
480
+ status: "done",
481
+ claim: { state: "finished", outcome: "done" },
482
+ },
483
+ })
484
+ })
485
+
393
486
  test("materializes missing workspaces but leaves branch conflicts unresolved", async () => {
394
487
  for (const [id, branch] of [
395
488
  ["missing", "feat/missing"],
@@ -318,22 +318,23 @@ describe("task and phase services", () => {
318
318
  ),
319
319
  ),
320
320
  ).rejects.toThrow("Delegation requires explicit ownership")
321
- await runTestEffect(
322
- TaskService.pipe(
323
- Effect.flatMap((service) =>
324
- service.setStatus("single-status", "done", root),
325
- ),
326
- ),
327
- )
328
321
  await expect(
329
322
  runTestEffect(
330
323
  TaskService.pipe(
331
324
  Effect.flatMap((service) =>
332
- service.setStatus("single-status", "dropped", root),
325
+ service.setStatus("single-status", "done", root),
333
326
  ),
334
327
  ),
335
328
  ),
336
- ).rejects.toThrow("reopen it first")
329
+ ).rejects.toThrow("authoritative pull request is merged")
330
+ const droppedTask = await runTestEffect(
331
+ TaskService.pipe(
332
+ Effect.flatMap((service) =>
333
+ service.setStatus("single-status", "dropped", root),
334
+ ),
335
+ ),
336
+ )
337
+ expect(droppedTask.data.status).toBe("dropped")
337
338
 
338
339
  await runTestEffect(
339
340
  TaskService.pipe(
@@ -389,7 +390,7 @@ describe("task and phase services", () => {
389
390
  ),
390
391
  ),
391
392
  ),
392
- ).rejects.toThrow("reopen it first")
393
+ ).rejects.toThrow("authoritative pull request is merged")
393
394
  await runTestEffect(
394
395
  PhaseService.pipe(
395
396
  Effect.flatMap((service) =>
@@ -397,17 +398,15 @@ describe("task and phase services", () => {
397
398
  ),
398
399
  ),
399
400
  )
400
- expect(
401
- (
402
- await runTestEffect(
403
- PhaseService.pipe(
404
- Effect.flatMap((service) =>
405
- service.setStatus("multi-status", "implementation", "done", root),
406
- ),
401
+ await expect(
402
+ runTestEffect(
403
+ PhaseService.pipe(
404
+ Effect.flatMap((service) =>
405
+ service.setStatus("multi-status", "implementation", "done", root),
407
406
  ),
408
- )
409
- ).data.status,
410
- ).toBe("done")
407
+ ),
408
+ ),
409
+ ).rejects.toThrow("authoritative pull request is merged")
411
410
  await expect(
412
411
  runTestEffect(
413
412
  TaskService.pipe(
@@ -270,6 +270,12 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
270
270
  })
271
271
  }
272
272
  if (!canTransitionStatus(record.data.status, validStatus)) {
273
+ if (validStatus === "done") {
274
+ return yield* new TaskError({
275
+ message:
276
+ "Work becomes done after its authoritative pull request is merged; run 'agency sync --apply'",
277
+ })
278
+ }
273
279
  return yield* new TaskError({
274
280
  message: `Cannot transition task '${id}' from ${record.data.status} to ${validStatus}; reopen it first`,
275
281
  })
@@ -0,0 +1,48 @@
1
+ ---
2
+ description: Operate Agency work with safe start, status, next, validate, and finish workflows
3
+ ---
4
+
5
+ Operate the current Agency workbase using the managed Agency instructions.
6
+
7
+ Invocation inputs:
8
+
9
+ - Workflow: `$1`
10
+ - Optional target: `$2`
11
+ - Complete request: `$ARGUMENTS`
12
+
13
+ Use `status` when the workflow is empty. Treat words after the optional target as
14
+ additional user instructions. If the workflow is unknown, make no changes and
15
+ list the supported workflows.
16
+
17
+ Always follow these rules:
18
+
19
+ - Run `agency context . --json` first when no target is provided. With a target,
20
+ pass that target to `agency context` instead.
21
+ - Use the returned document paths, readiness, authority, checkout state, claim,
22
+ and validation result. Do not infer them from directory names.
23
+ - Stop on validation errors, dependency blockers, an unexpected writable
24
+ repository, or a conflicting active claim.
25
+ - Write code only in `authority.writable.checkoutPath`; references are read-only.
26
+ - Never run `agency work` to start the current agent again.
27
+ - Create a pull request only when the complete request explicitly asks for one.
28
+
29
+ Dispatch the workflow as follows:
30
+
31
+ - `start`: Read the returned task and phase documents, inspect the writable
32
+ checkout, then begin or resume the requested work in this session. Keep durable
33
+ decisions current and carry the work through focused verification.
34
+ - `status`: Make no changes. Summarize the target, readiness, authority, durable
35
+ status, claim and PR state, checkout state, current Git changes, blockers, and
36
+ the most useful next action.
37
+ - `next`: Run `agency next --json`, summarize ready and blocked execution units,
38
+ and recommend the highest-priority ready unit. Do not launch another agent.
39
+ - `validate`: Run Agency validation for the discovered workbase. Explain every
40
+ issue and, when safe and within authority, repair requested non-structural
41
+ problems before validating again.
42
+ - `finish`: Complete any remaining requested implementation first. Run focused
43
+ repository checks, refresh the task or phase delivery context, and run
44
+ `agency validate`. Finish an active claim with its current revision; otherwise
45
+ update unclaimed status only when the requested outcome and delivery work are
46
+ actually complete. Do not create a PR unless explicitly requested.
47
+ - `help`: Make no changes. Briefly explain these workflows and the positional
48
+ form `/agency <workflow> [target] [additional instructions]`.
@@ -48,17 +48,19 @@ reason to edit `agency.json` or `repos/` by hand.
48
48
 
49
49
  ## Closeout
50
50
 
51
- An execution unit is `working` while implementation or requested delivery work
52
- remains. It becomes `done` when both are complete, even if its PR remains open
53
- for review or merge. Do not leave a task or phase `working` solely because its PR
54
- is open; if merge was requested, merge remains delivery work.
51
+ An execution unit remains `working` after implementation is committed and while
52
+ its pull request is open. It becomes `done` only after its authoritative pull
53
+ request is merged and Agency reconciles that state. Do not mark committed or
54
+ review-ready work `done` manually.
55
55
 
56
56
  At each closeout trigger (creating or updating a PR, marking it ready, completing
57
57
  a refinement loop, or pausing or handing off completed implementation work):
58
58
 
59
- - Finish an active claim with the current revision via `agency finish`.
60
- Otherwise use `agency task status` or `agency phase status` to set the
61
- execution unit's current status.
59
+ - Finish an active claim with the current revision via `agency finish`; a
60
+ successful claim outcome leaves unmerged work `working`. For unclaimed work,
61
+ keep the execution unit `working` through review and merge.
62
+ - After merge, run `agency sync --apply` to reconcile the execution unit to
63
+ `done`.
62
64
  - Refresh durable delivery context in `TASK.md` or `PHASE.md`, including recorded
63
65
  PR state, current head, diff summary, and verification results after later
64
66
  pushes when those details are maintained there.
@@ -69,6 +71,8 @@ a refinement loop, or pausing or handing off completed implementation work):
69
71
  `agency integration status` reports `managed`, `drifted`, `customized`, or
70
72
  `missing` generated files. Agency keeps these instructions in
71
73
  `.agency/AGENTS.md`, and its managed OpenCode config loads them automatically.
74
+ It also installs `.opencode/command/agency.md`, which provides safe `/agency`
75
+ workflows for active OpenCode sessions.
72
76
  The workbase-root `AGENTS.md`, when present, belongs entirely to the workbase
73
77
  owner and composes with these instructions through OpenCode's normal discovery.
74
78
  `agency integration sync` updates only missing or checksum-safe drifted managed
@@ -0,0 +1,30 @@
1
+ import { createHash } from "node:crypto"
2
+ import commandTemplate from "./AGENCY_COMMAND.md" with { type: "text" }
3
+
4
+ const managedHeaderPattern =
5
+ /^---\r?\n# agency-managed: sha256=([a-f0-9]{64})\r?\n/
6
+
7
+ const checksum = (content: string) =>
8
+ createHash("sha256").update(content).digest("hex")
9
+
10
+ const canonicalBody = commandTemplate.endsWith("\n")
11
+ ? commandTemplate
12
+ : `${commandTemplate}\n`
13
+
14
+ const renderManagedWorkbaseOpencodeCommand = (
15
+ content: string = canonicalBody,
16
+ ) =>
17
+ content.replace(
18
+ /^---\n/,
19
+ `---\n# agency-managed: sha256=${checksum(content)}\n`,
20
+ )
21
+
22
+ export const managedWorkbaseOpencodeCommand =
23
+ renderManagedWorkbaseOpencodeCommand()
24
+
25
+ export const canUpdateManagedWorkbaseOpencodeCommand = (content: string) => {
26
+ const match = content.match(managedHeaderPattern)
27
+ if (!match?.[1]) return false
28
+
29
+ return checksum(content.replace(managedHeaderPattern, "---\n")) === match[1]
30
+ }