@markjaquith/agency 2.40.1 → 2.42.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
@@ -91,11 +91,20 @@ can load the hidden file. A customized root file, including a symlink, is
91
91
  preserved as user-owned content.
92
92
 
93
93
  The OpenCode config loads Agency's hidden instructions in addition to any
94
- user-owned root `AGENTS.md` and advertises the complete workbase as one portable
95
- reference. OpenCode discovers that config from task and epic launch directories.
94
+ user-owned root `AGENTS.md`, advertises the complete workbase as one portable
95
+ reference, and replaces the built-in Plan agent with `agency-plan`. That planning
96
+ agent can update `TASK.md`, `PHASE.md`, and `EPIC.md` while other edits remain
97
+ disabled. OpenCode discovers the config from task and epic launch directories.
96
98
  Agents receive whole-workbase visibility from that reference. Bash and Agency
97
99
  operations must still follow the write authority reported by `agency context`.
98
100
 
101
+ OpenCode also discovers a managed `/agency` command. Use `/agency status` for a
102
+ read-only current-work summary, `/agency start [target]` to begin or resume work
103
+ in the active session, `/agency next` to inspect ready work, `/agency validate`
104
+ to check the workbase, and `/agency finish [target]` for verified closeout. The
105
+ command uses OpenCode positional arguments internally and defaults to the safe
106
+ `status` workflow when no subcommand is supplied.
107
+
99
108
  Repository aliases and canonical fetch remotes are declared in tracked
100
109
  `agency.json`; local bare clones and symlinks remain ignored under
101
110
  `repos/{alias}`. A declaration contains no local path, symlink target, checkout,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "2.40.1",
3
+ "version": "2.42.0",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
package/src/cli.test.ts CHANGED
@@ -526,6 +526,7 @@ describe("CLI", () => {
526
526
  expect(before.files).toMatchObject([
527
527
  { name: "agents", state: "managed" },
528
528
  { name: "opencode", state: "managed" },
529
+ { name: "opencode-command", state: "managed" },
529
530
  ])
530
531
 
531
532
  const synced = parseJson(
@@ -534,6 +535,7 @@ describe("CLI", () => {
534
535
  expect(synced.files).toMatchObject([
535
536
  { name: "agents", state: "managed", changed: false },
536
537
  { name: "opencode", state: "managed", changed: false },
538
+ { name: "opencode-command", state: "managed", changed: false },
537
539
  ])
538
540
  })
539
541
 
@@ -40,10 +40,27 @@ describe("init command", () => {
40
40
  ).text()
41
41
  const config = JSON.parse(opencode.slice(opencode.indexOf("\n\n") + 2))
42
42
  expect(config.instructions).toEqual([".agency/AGENTS.md"])
43
+ expect(config.agent.plan).toEqual({ disable: true })
44
+ expect(config.agent["agency-plan"]).toMatchObject({
45
+ mode: "primary",
46
+ permission: {
47
+ edit: {
48
+ "*": "deny",
49
+ "tasks/*/TASK.md": "allow",
50
+ "tasks/*/phases/*/PHASE.md": "allow",
51
+ "epics/*/EPIC.md": "allow",
52
+ },
53
+ },
54
+ })
43
55
  expect(config.references).toEqual({
44
56
  workbase: expect.objectContaining({ path: ".." }),
45
57
  })
46
58
  expect(config.permission).toBeUndefined()
59
+ const command = await Bun.file(
60
+ join(root, ".opencode/command/agency.md"),
61
+ ).text()
62
+ expect(command).toContain("Workflow: `$1`")
63
+ expect(command).toContain("Optional target: `$2`")
47
64
  })
48
65
 
49
66
  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
@@ -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
  })
@@ -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
 
@@ -93,12 +135,33 @@ describe("IntegrationService", () => {
93
135
  expect(body).toContain("PR state, current head, diff summary")
94
136
  expect(body).toContain("Run `agency validate` before reporting completion")
95
137
  expect(body).toContain("agency integration status")
138
+ expect(body).toContain(".opencode/command/agency.md")
96
139
  })
97
140
 
98
- test("grants OpenCode access to the complete workbase", () => {
141
+ test("configures Agency planning and complete workbase access", () => {
99
142
  const config = JSON.parse(managedBody(managedWorkbaseOpencode))
100
143
 
101
144
  expect(config.instructions).toEqual([".agency/AGENTS.md"])
145
+ expect(config.agent).toEqual({
146
+ plan: {
147
+ disable: true,
148
+ },
149
+ "agency-plan": {
150
+ description:
151
+ "Agency planning mode. May edit only Agency planning documents.",
152
+ mode: "primary",
153
+ prompt: expect.stringContaining("You are in Agency Plan mode"),
154
+ permission: {
155
+ question: "allow",
156
+ edit: {
157
+ "*": "deny",
158
+ "tasks/*/TASK.md": "allow",
159
+ "tasks/*/phases/*/PHASE.md": "allow",
160
+ "epics/*/EPIC.md": "allow",
161
+ },
162
+ },
163
+ },
164
+ })
102
165
  expect(config.references).toEqual({
103
166
  workbase: {
104
167
  path: "..",
@@ -167,6 +230,7 @@ describe("IntegrationService", () => {
167
230
  expect(first.files).toMatchObject([
168
231
  { name: "agents", state: "customized", changed: false },
169
232
  { name: "opencode", state: "managed", changed: true },
233
+ { name: "opencode-command", state: "managed", changed: true },
170
234
  ])
171
235
  expect(await Bun.file(join(root, "AGENTS.md")).text()).toBe(
172
236
  customRootAgents,
@@ -177,6 +241,9 @@ describe("IntegrationService", () => {
177
241
  expect(await Bun.file(join(root, ".opencode/opencode.jsonc")).text()).toBe(
178
242
  managedWorkbaseOpencode,
179
243
  )
244
+ expect(
245
+ await Bun.file(join(root, ".opencode/command/agency.md")).text(),
246
+ ).toBe(managedWorkbaseOpencodeCommand)
180
247
 
181
248
  await unlink(join(root, ".agency/AGENTS.md"))
182
249
  const second = await sync(root)
@@ -189,6 +256,34 @@ describe("IntegrationService", () => {
189
256
  )
190
257
  })
191
258
 
259
+ test("preserves user-owned OpenCode commands at either supported path", async () => {
260
+ const custom = "---\ndescription: Custom Agency command\n---\n\nCustom.\n"
261
+ await write(root, ".opencode/commands/agency.md", custom)
262
+
263
+ let result = await sync(root)
264
+ expect(result.files[2]).toMatchObject({
265
+ name: "opencode-command",
266
+ path: join(root, ".opencode/commands/agency.md"),
267
+ state: "customized",
268
+ changed: false,
269
+ })
270
+ expect(
271
+ await Bun.file(join(root, ".opencode/command/agency.md")).exists(),
272
+ ).toBe(false)
273
+
274
+ await unlink(join(root, ".opencode/commands/agency.md"))
275
+ await write(root, ".opencode/command/agency.md", custom)
276
+ result = await sync(root)
277
+ expect(result.files[2]).toMatchObject({
278
+ path: join(root, ".opencode/command/agency.md"),
279
+ state: "customized",
280
+ changed: false,
281
+ })
282
+ expect(
283
+ await Bun.file(join(root, ".opencode/command/agency.md")).text(),
284
+ ).toBe(custom)
285
+ })
286
+
192
287
  test("migrates checksum-valid root instructions", async () => {
193
288
  await write(root, "AGENTS.md", managedWorkbaseAgents)
194
289
 
@@ -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({
@@ -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]`.
@@ -71,6 +71,8 @@ a refinement loop, or pausing or handing off completed implementation work):
71
71
  `agency integration status` reports `managed`, `drifted`, `customized`, or
72
72
  `missing` generated files. Agency keeps these instructions in
73
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.
74
76
  The workbase-root `AGENTS.md`, when present, belongs entirely to the workbase
75
77
  owner and composes with these instructions through OpenCode's normal discovery.
76
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
+ }
@@ -6,11 +6,35 @@ const managedHeaderPattern =
6
6
  const checksum = (content: string) =>
7
7
  createHash("sha256").update(content).digest("hex")
8
8
 
9
+ const agencyPlanPrompt = `You are in Agency Plan mode. Think, read, search, and delegate exploration to construct a well-formed plan for the user's goal. Keep the plan comprehensive but concise, and ask clarifying questions when important tradeoffs or intent are unclear.
10
+
11
+ You may edit TASK.md, PHASE.md, and EPIC.md to record the outcome, current approach, and important decisions. Do not edit any other file or use shell commands to modify the system.`
12
+
9
13
  const body = () =>
10
14
  `${JSON.stringify(
11
15
  {
12
16
  $schema: "https://opencode.ai/config.json",
13
17
  instructions: [".agency/AGENTS.md"],
18
+ agent: {
19
+ plan: {
20
+ disable: true,
21
+ },
22
+ "agency-plan": {
23
+ description:
24
+ "Agency planning mode. May edit only Agency planning documents.",
25
+ mode: "primary",
26
+ prompt: agencyPlanPrompt,
27
+ permission: {
28
+ question: "allow",
29
+ edit: {
30
+ "*": "deny",
31
+ "tasks/*/TASK.md": "allow",
32
+ "tasks/*/phases/*/PHASE.md": "allow",
33
+ "epics/*/EPIC.md": "allow",
34
+ },
35
+ },
36
+ },
37
+ },
14
38
  references: {
15
39
  workbase: {
16
40
  path: "..",