@markjaquith/agency 2.48.0 → 2.49.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.
@@ -38,6 +38,9 @@ interface TaskOptions extends BaseCommandOptions {
38
38
  readonly pr?: boolean
39
39
  readonly work?: boolean
40
40
  readonly auto?: boolean
41
+ readonly noPullRequest?: boolean
42
+ readonly summary?: string
43
+ readonly evidenceUrl?: string
41
44
  }
42
45
 
43
46
  export interface TaskInteraction {
@@ -304,7 +307,19 @@ export const task = (
304
307
  new Error("Usage: agency task status <id> <status>"),
305
308
  )
306
309
  }
307
- const record = yield* tasks.setStatus(id, status, cwd)
310
+ const record = yield* tasks.setStatus(
311
+ id,
312
+ status,
313
+ cwd,
314
+ options.noPullRequest
315
+ ? {
316
+ summary: options.summary ?? "",
317
+ ...(options.evidenceUrl
318
+ ? { evidenceUrl: options.evidenceUrl }
319
+ : {}),
320
+ }
321
+ : undefined,
322
+ )
308
323
  const { content: _, ...output } = record
309
324
  log(
310
325
  options.json
@@ -423,7 +438,7 @@ Subcommands:
423
438
  create <id> Create a task without prompting
424
439
  list List tasks
425
440
  show <id> Show a task
426
- status <id> <status> Set open, working, or dropped
441
+ status <id> <status> Set open, working, dropped, or explicit non-PR done
427
442
  update <id> Update task metadata
428
443
  rename <id> <new-id> Rename a task and update graph references
429
444
  move <id> Move a task with --epic or --no-epic
@@ -433,6 +448,11 @@ Subcommands:
433
448
  Mutation option:
434
449
  --if-revision <hash> Require the target's current revision
435
450
 
451
+ Non-PR completion options (status done only):
452
+ --no-pull-request Complete without a pull request
453
+ --summary <text> Required durable outcome summary
454
+ --evidence-url <url> Optional supporting record
455
+
436
456
  Create options:
437
457
  --ticket-url <url> External ticket URL (optional)
438
458
  --description <text> Short description of the task
@@ -45,6 +45,8 @@ const multiPhaseWorkspace: ExecutionWorkspace = {
45
45
  }
46
46
 
47
47
  const taskDirectory = "/workbase/tasks/example"
48
+ const phaseDirectory = `${taskDirectory}/phases/implementation`
49
+
48
50
  interface HarnessOptions {
49
51
  readonly workspace?: ExecutionWorkspace
50
52
  readonly materializeError?: Error
@@ -563,7 +565,7 @@ describe("work command", () => {
563
565
  "--prompt",
564
566
  "Start the task. Read /workbase/tasks/example/TASK.md and /workbase/tasks/example/phases/implementation/PHASE.md.",
565
567
  ],
566
- cwd: taskDirectory,
568
+ cwd: phaseDirectory,
567
569
  })
568
570
  expect(harness.statusUpdates).toEqual([
569
571
  "phase:example:implementation:working",
@@ -593,7 +595,11 @@ describe("work command", () => {
593
595
  data: {},
594
596
  }
595
597
  const harness = createHarness({
596
- workspace: multiPhaseWorkspace,
598
+ workspace: {
599
+ ...multiPhaseWorkspace,
600
+ taskPath: "/workbase/tasks/delivery/TASK.md",
601
+ phasePath: "/workbase/tasks/delivery/phases/build/PHASE.md",
602
+ },
597
603
  taskRecords: [
598
604
  {
599
605
  id: "delivery",
@@ -615,7 +621,9 @@ describe("work command", () => {
615
621
  "probe:opencode",
616
622
  "launch:opencode",
617
623
  ])
618
- expect(harness.launches[0]?.cwd).toBe(taskDirectory)
624
+ expect(harness.launches[0]?.cwd).toBe(
625
+ "/workbase/tasks/delivery/phases/build",
626
+ )
619
627
  })
620
628
 
621
629
  test("requires an explicit target when input is disabled", async () => {
@@ -767,6 +775,18 @@ describe("work command", () => {
767
775
  ])
768
776
  })
769
777
 
778
+ test("launches OpenCode in the phase directory with explicit context", async () => {
779
+ const harness = createHarness({ workspace: multiPhaseWorkspace })
780
+
781
+ await harness.run({
782
+ taskId: "example",
783
+ phaseId: "implementation",
784
+ opencode: true,
785
+ })
786
+
787
+ expect(harness.launches[0]?.cwd).toBe(phaseDirectory)
788
+ })
789
+
770
790
  test("sends the generated prompt only with --auto", async () => {
771
791
  const harness = createHarness()
772
792
 
@@ -820,7 +840,7 @@ describe("work command", () => {
820
840
  "--prompt",
821
841
  "Continue the task. Read /workbase/tasks/example/TASK.md and /workbase/tasks/example/phases/implementation/PHASE.md.",
822
842
  ],
823
- cwd: taskDirectory,
843
+ cwd: phaseDirectory,
824
844
  })
825
845
  })
826
846
 
@@ -897,6 +917,20 @@ describe("work command", () => {
897
917
  expect(printed.environment.API_TOKEN).toBeUndefined()
898
918
  })
899
919
 
920
+ test("prints the phase directory in the command contract", async () => {
921
+ const harness = createHarness({ workspace: multiPhaseWorkspace })
922
+ const output = await captureLogs(() =>
923
+ harness.run({
924
+ taskId: "example",
925
+ phaseId: "implementation",
926
+ opencode: true,
927
+ printCommand: true,
928
+ }),
929
+ )
930
+
931
+ expect(JSON.parse(output.join("\n")).cwd).toBe(phaseDirectory)
932
+ })
933
+
900
934
  test("does not reopen a forced terminal target in print-only mode", async () => {
901
935
  const harness = createHarness({ taskStatuses: { example: "done" } })
902
936
 
@@ -261,7 +261,7 @@ export const work = (
261
261
  prompt = workspace.phasePath
262
262
  ? `${action} the task. Read ${workspace.taskPath} and ${workspace.phasePath}.`
263
263
  : `${action} the task. Read ${workspace.taskPath}.`
264
- launchPath = dirname(workspace.taskPath)
264
+ launchPath = dirname(workspace.phasePath ?? workspace.taskPath)
265
265
  writablePath = workspace.writablePath
266
266
  }
267
267
 
@@ -147,6 +147,65 @@ describe("claim service", () => {
147
147
  })
148
148
  })
149
149
 
150
+ test("finishes claimed non-PR work with durable completion evidence", async () => {
151
+ const initial = await inspect()
152
+ const acquired = await claim(initial.revision)
153
+ const finished = await runTestEffect(
154
+ ClaimService.pipe(
155
+ Effect.flatMap((service) =>
156
+ service.finish(
157
+ {
158
+ taskId: "single",
159
+ sessionId: "session-1",
160
+ revision: acquired.revision,
161
+ outcome: "done",
162
+ nonPrCompletion: {
163
+ summary: "Review completed; no code change was needed.",
164
+ evidenceUrl: "https://example.com/review",
165
+ },
166
+ now: at("2026-07-17T12:45:00.000Z"),
167
+ },
168
+ root,
169
+ ),
170
+ ),
171
+ ),
172
+ )
173
+
174
+ expect(finished.data).toMatchObject({
175
+ status: "done",
176
+ claim: { state: "finished", outcome: "done" },
177
+ completion: {
178
+ mode: "non-pr",
179
+ completedAt: "2026-07-17T12:45:00.000Z",
180
+ summary: "Review completed; no code change was needed.",
181
+ evidenceUrl: "https://example.com/review",
182
+ },
183
+ })
184
+ })
185
+
186
+ test("rejects invalid claimed non-PR completion", async () => {
187
+ const initial = await inspect()
188
+ const acquired = await claim(initial.revision)
189
+ await expect(
190
+ runTestEffect(
191
+ ClaimService.pipe(
192
+ Effect.flatMap((service) =>
193
+ service.finish(
194
+ {
195
+ taskId: "single",
196
+ sessionId: "session-1",
197
+ revision: acquired.revision,
198
+ outcome: "done",
199
+ nonPrCompletion: { summary: " " },
200
+ },
201
+ root,
202
+ ),
203
+ ),
204
+ ),
205
+ ),
206
+ ).rejects.toThrow("summary must not be empty")
207
+ })
208
+
150
209
  test("returns structured ownership and revision conflicts", async () => {
151
210
  const initial = await inspect()
152
211
  const acquired = await claim(initial.revision)
@@ -31,6 +31,10 @@ import {
31
31
  type PhaseFrontmatter as PhaseData,
32
32
  type TaskFrontmatter as TaskData,
33
33
  } from "../workbase/schemas"
34
+ import {
35
+ buildNonPrCompletion,
36
+ type NonPrCompletionInput,
37
+ } from "../workbase/completion"
34
38
 
35
39
  class ClaimError extends Data.TaggedError("ClaimError")<{
36
40
  readonly message: string
@@ -83,6 +87,7 @@ interface OwnedClaimInput {
83
87
 
84
88
  interface FinishInput extends OwnedClaimInput {
85
89
  readonly outcome: "done" | "dropped"
90
+ readonly nonPrCompletion?: NonPrCompletionInput
86
91
  }
87
92
 
88
93
  interface ExpireClaimInput {
@@ -507,6 +512,11 @@ export class ClaimService extends Effect.Service<ClaimService>()(
507
512
  finish: (input: FinishInput, startPath: string = process.cwd()) =>
508
513
  Effect.gen(function* () {
509
514
  assertIdentity("Session ID", input.sessionId)
515
+ if (input.nonPrCompletion && input.outcome !== "done") {
516
+ return yield* new ClaimError({
517
+ message: "Non-PR completion is valid only with a done outcome",
518
+ })
519
+ }
510
520
  const service = yield* ClaimService
511
521
  const inspected = yield* service.inspect(
512
522
  input.taskId,
@@ -531,6 +541,22 @@ export class ClaimService extends Effect.Service<ClaimService>()(
531
541
  message: `Session '${input.sessionId}' does not own ${inspected.target.label}`,
532
542
  })
533
543
  }
544
+ if (input.nonPrCompletion && data.pr !== null) {
545
+ throw new ClaimError({
546
+ target: inspected.target.label,
547
+ message:
548
+ "Cannot complete without a pull request while an authoritative pull request is recorded",
549
+ })
550
+ }
551
+ const completionResult = input.nonPrCompletion
552
+ ? buildNonPrCompletion(input.nonPrCompletion, now)
553
+ : undefined
554
+ if (completionResult && "error" in completionResult) {
555
+ throw new ClaimError({
556
+ target: inspected.target.label,
557
+ message: completionResult.error,
558
+ })
559
+ }
534
560
  const claim: ClaimRecord = {
535
561
  ...data.claim,
536
562
  state: "finished",
@@ -540,8 +566,15 @@ export class ClaimService extends Effect.Service<ClaimService>()(
540
566
  return {
541
567
  data: {
542
568
  ...data,
543
- status: input.outcome === "done" ? "working" : "dropped",
569
+ status: completionResult
570
+ ? "done"
571
+ : input.outcome === "done"
572
+ ? "working"
573
+ : "dropped",
544
574
  claim,
575
+ ...(completionResult
576
+ ? { completion: completionResult.value }
577
+ : {}),
545
578
  },
546
579
  claim,
547
580
  }
@@ -374,6 +374,16 @@ export class GraphMutationService extends Effect.Service<GraphMutationService>()
374
374
  message: `Task '${id}' has an active claim; release or finish it before changing execution metadata`,
375
375
  })
376
376
  }
377
+ if (
378
+ updates.pr &&
379
+ "completion" in record.data &&
380
+ record.data.completion
381
+ ) {
382
+ return yield* new GraphMutationError({
383
+ message:
384
+ "Reopen non-PR completed work before recording a pull request",
385
+ })
386
+ }
377
387
  if (
378
388
  executionChange &&
379
389
  (yield* fs.isDirectory(join(dirname(record.path), "code")))
@@ -504,6 +514,12 @@ export class GraphMutationService extends Effect.Service<GraphMutationService>()
504
514
  message: `Phase '${id}' has an active claim; release or finish it before changing execution metadata`,
505
515
  })
506
516
  }
517
+ if (updates.pr && record.data.completion) {
518
+ return yield* new GraphMutationError({
519
+ message:
520
+ "Reopen non-PR completed work before recording a pull request",
521
+ })
522
+ }
507
523
  const data: PhaseData = yield* decode(
508
524
  PhaseFrontmatter,
509
525
  {
@@ -6,10 +6,6 @@ import { dirname, join } from "node:path"
6
6
  import { pathToFileURL } from "node:url"
7
7
  import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
8
8
  import { managedWorkbaseAgents } from "../workbase/agents-file"
9
- import {
10
- canUpdateManagedWorkbaseOpencodeCommand,
11
- managedWorkbaseOpencodeCommand,
12
- } from "../workbase/opencode-command-file"
13
9
  import { managedWorkbaseOpencode } from "../workbase/opencode-file"
14
10
  import {
15
11
  canUpdateManagedWorkbaseOpencodePlugin,
@@ -36,6 +32,12 @@ const managed = (prefix: string, body: string, suffix = "") => {
36
32
  const managedBody = (content: string) =>
37
33
  content.slice(content.indexOf("\n\n") + 2)
38
34
 
35
+ const legacyCommand = (content: string) =>
36
+ content.replace(
37
+ /^---\n/,
38
+ `---\n# agency-managed: sha256=${createHash("sha256").update(content).digest("hex")}\n`,
39
+ )
40
+
39
41
  const status = (root: string) =>
40
42
  runTestEffect(
41
43
  IntegrationService.pipe(Effect.flatMap((service) => service.status(root))),
@@ -63,17 +65,11 @@ describe("IntegrationService", () => {
63
65
  "missing",
64
66
  "missing",
65
67
  "missing",
66
- "missing",
67
68
  ])
68
69
  expect(await Bun.file(join(root, ".agency/AGENTS.md")).exists()).toBe(false)
69
70
 
70
71
  await write(root, ".agency/AGENTS.md", managedWorkbaseAgents)
71
72
  await write(root, ".opencode/opencode.jsonc", managedWorkbaseOpencode)
72
- await write(
73
- root,
74
- ".opencode/command/agency.md",
75
- managedWorkbaseOpencodeCommand,
76
- )
77
73
  await write(
78
74
  root,
79
75
  ".opencode/plugin/agency-repository-skills.ts",
@@ -91,7 +87,6 @@ describe("IntegrationService", () => {
91
87
  "managed",
92
88
  "managed",
93
89
  "managed",
94
- "managed",
95
90
  ])
96
91
  })
97
92
 
@@ -109,7 +104,6 @@ describe("IntegrationService", () => {
109
104
  "missing",
110
105
  "missing",
111
106
  "missing",
112
- "missing",
113
107
  ])
114
108
  })
115
109
 
@@ -257,36 +251,6 @@ describe("IntegrationService", () => {
257
251
  ).toBe(true)
258
252
  })
259
253
 
260
- test("generates a positional OpenCode command for Agency workflows", () => {
261
- expect(managedWorkbaseOpencodeCommand).toContain(
262
- "description: Operate Agency work",
263
- )
264
- expect(managedWorkbaseOpencodeCommand).toContain("Workflow: `$1`")
265
- expect(managedWorkbaseOpencodeCommand).toContain("Optional target: `$2`")
266
- expect(managedWorkbaseOpencodeCommand).toContain(
267
- "Complete request: `$ARGUMENTS`",
268
- )
269
- for (const workflow of [
270
- "start",
271
- "status",
272
- "next",
273
- "validate",
274
- "finish",
275
- "help",
276
- ]) {
277
- expect(managedWorkbaseOpencodeCommand).toContain(`- \`${workflow}\`:`)
278
- }
279
- expect(managedWorkbaseOpencodeCommand).toContain("Never run `agency work`")
280
- expect(
281
- canUpdateManagedWorkbaseOpencodeCommand(managedWorkbaseOpencodeCommand),
282
- ).toBe(true)
283
- expect(
284
- canUpdateManagedWorkbaseOpencodeCommand(
285
- managedWorkbaseOpencodeCommand.replace("Workflow: `$1`", "Workflow"),
286
- ),
287
- ).toBe(false)
288
- })
289
-
290
254
  test("generates context-first safety and execution closeout guidance", () => {
291
255
  const body = managedBody(managedWorkbaseAgents)
292
256
 
@@ -297,7 +261,9 @@ describe("IntegrationService", () => {
297
261
  expect(body).toContain("Only `done` satisfies a dependency")
298
262
  expect(body).toContain("Require explicit user intent")
299
263
  expect(body).toContain("changing repository")
300
- expect(body).toMatch(/archiving, restoring,\s+dropping, or/)
264
+ expect(body).toMatch(
265
+ /archiving, restoring,\s+dropping, reopening, or completing work without a pull request/,
266
+ )
301
267
  expect(body).toContain("Never invent entity IDs")
302
268
  expect(body).toContain("Preserve parent backlinks")
303
269
  expect(body).toContain("dirty-worktree, active-claim, revision")
@@ -317,13 +283,14 @@ describe("IntegrationService", () => {
317
283
  expect(body).toContain("pausing or handing off")
318
284
  expect(body).toContain("`agency finish`")
319
285
  expect(body).toContain("`agency sync --apply`")
286
+ expect(body).toContain("`--no-pull-request --summary <text>`")
320
287
  expect(body).toContain("`TASK.md` or `PHASE.md`")
321
288
  expect(body).toContain("PR state, current head, diff summary")
322
289
  expect(body).toContain("Run `agency validate` before reporting completion")
323
290
  expect(body).toContain("agency integration status")
324
291
  expect(body).not.toContain("SKILL.md")
325
292
  expect(body).not.toContain("~/.agents")
326
- expect(body).toContain(".opencode/command/agency.md")
293
+ expect(body).not.toContain(".opencode/command/agency.md")
327
294
  })
328
295
 
329
296
  test("configures Agency agents with complete workbase access", () => {
@@ -454,7 +421,6 @@ describe("IntegrationService", () => {
454
421
  expect(first.files).toMatchObject([
455
422
  { name: "agents", state: "customized", changed: false },
456
423
  { name: "opencode", state: "managed", changed: true },
457
- { name: "opencode-command", state: "managed", changed: true },
458
424
  { name: "opencode-plugin", state: "managed", changed: true },
459
425
  { name: "opencode-tui", state: "managed", changed: true },
460
426
  { name: "opencode-tui-plugin", state: "managed", changed: true },
@@ -469,8 +435,8 @@ describe("IntegrationService", () => {
469
435
  managedWorkbaseOpencode,
470
436
  )
471
437
  expect(
472
- await Bun.file(join(root, ".opencode/command/agency.md")).text(),
473
- ).toBe(managedWorkbaseOpencodeCommand)
438
+ await Bun.file(join(root, ".opencode/command/agency.md")).exists(),
439
+ ).toBe(false)
474
440
  expect(
475
441
  await Bun.file(
476
442
  join(root, ".opencode/plugin/agency-repository-skills.ts"),
@@ -524,7 +490,7 @@ describe("IntegrationService", () => {
524
490
  await write(root, ".opencode/plugins/agency-repository-skills.ts", custom)
525
491
 
526
492
  let result = await sync(root)
527
- expect(result.files[3]).toMatchObject({
493
+ expect(result.files[2]).toMatchObject({
528
494
  name: "opencode-plugin",
529
495
  path: join(root, ".opencode/plugins/agency-repository-skills.ts"),
530
496
  state: "customized",
@@ -539,7 +505,7 @@ describe("IntegrationService", () => {
539
505
  await unlink(join(root, ".opencode/plugins/agency-repository-skills.ts"))
540
506
  await write(root, ".opencode/plugin/agency-repository-skills.ts", custom)
541
507
  result = await sync(root)
542
- expect(result.files[3]).toMatchObject({
508
+ expect(result.files[2]).toMatchObject({
543
509
  path: join(root, ".opencode/plugin/agency-repository-skills.ts"),
544
510
  state: "customized",
545
511
  changed: false,
@@ -554,14 +520,14 @@ describe("IntegrationService", () => {
554
520
  await write(root, ".opencode/tui/agency-debug.ts", customPlugin)
555
521
 
556
522
  const result = await sync(root)
557
- expect(result.files[4]).toMatchObject({
523
+ expect(result.files[3]).toMatchObject({
558
524
  name: "opencode-tui",
559
525
  path: join(root, ".opencode/tui.json"),
560
526
  state: "customized",
561
527
  changed: false,
562
528
  remediation: expect.stringContaining("plugin list"),
563
529
  })
564
- expect(result.files[5]).toMatchObject({
530
+ expect(result.files[4]).toMatchObject({
565
531
  name: "opencode-tui-plugin",
566
532
  state: "customized",
567
533
  changed: false,
@@ -574,32 +540,23 @@ describe("IntegrationService", () => {
574
540
  ).toBe(customPlugin)
575
541
  })
576
542
 
577
- test("preserves user-owned OpenCode commands at either supported path", async () => {
578
- const custom = "---\ndescription: Custom Agency command\n---\n\nCustom.\n"
579
- await write(root, ".opencode/commands/agency.md", custom)
543
+ test("removes a checksum-valid legacy command and preserves a customized file", async () => {
544
+ const path = ".opencode/command/agency.md"
545
+ const legacy = legacyCommand(
546
+ "---\ndescription: Operate Agency work\n---\n\nLegacy command.\n",
547
+ )
548
+ await write(root, path, legacy)
580
549
 
581
- let result = await sync(root)
582
- expect(result.files[2]).toMatchObject({
583
- name: "opencode-command",
584
- path: join(root, ".opencode/commands/agency.md"),
585
- state: "customized",
586
- changed: false,
587
- })
588
- expect(
589
- await Bun.file(join(root, ".opencode/command/agency.md")).exists(),
590
- ).toBe(false)
550
+ await status(root)
551
+ expect(await Bun.file(join(root, path)).text()).toBe(legacy)
591
552
 
592
- await unlink(join(root, ".opencode/commands/agency.md"))
593
- await write(root, ".opencode/command/agency.md", custom)
594
- result = await sync(root)
595
- expect(result.files[2]).toMatchObject({
596
- path: join(root, ".opencode/command/agency.md"),
597
- state: "customized",
598
- changed: false,
599
- })
600
- expect(
601
- await Bun.file(join(root, ".opencode/command/agency.md")).text(),
602
- ).toBe(custom)
553
+ await sync(root)
554
+ expect(await Bun.file(join(root, path)).exists()).toBe(false)
555
+
556
+ const custom = `${legacy}User edit.\n`
557
+ await write(root, path, custom)
558
+ await sync(root)
559
+ expect(await Bun.file(join(root, path)).text()).toBe(custom)
603
560
  })
604
561
 
605
562
  test("migrates checksum-valid root instructions", async () => {
@@ -1,4 +1,5 @@
1
1
  import { Effect } from "effect"
2
+ import { createHash } from "node:crypto"
2
3
  import { join } from "node:path"
3
4
  import { FileSystemService } from "./FileSystemService"
4
5
  import { WorkbaseService } from "./WorkbaseService"
@@ -10,10 +11,6 @@ import {
10
11
  canUpdateManagedWorkbaseOpencode,
11
12
  managedWorkbaseOpencode,
12
13
  } from "../workbase/opencode-file"
13
- import {
14
- canUpdateManagedWorkbaseOpencodeCommand,
15
- managedWorkbaseOpencodeCommand,
16
- } from "../workbase/opencode-command-file"
17
14
  import {
18
15
  canUpdateManagedWorkbaseOpencodePlugin,
19
16
  managedWorkbaseOpencodePlugin,
@@ -33,7 +30,6 @@ interface IntegrationFileStatus {
33
30
  readonly name:
34
31
  | "agents"
35
32
  | "opencode"
36
- | "opencode-command"
37
33
  | "opencode-plugin"
38
34
  | "opencode-tui"
39
35
  | "opencode-tui-plugin"
@@ -74,25 +70,6 @@ const describe = (
74
70
  "Run 'agency integration sync' to install Agency instructions and whole-workbase OpenCode access.",
75
71
  }
76
72
  }
77
- if (name === "opencode-command") {
78
- return state === "managed"
79
- ? {
80
- diagnostic: "Agency's managed OpenCode /agency command is current.",
81
- remediation: null,
82
- }
83
- : state === "customized"
84
- ? {
85
- diagnostic:
86
- "A user-owned OpenCode /agency command is present and was preserved.",
87
- remediation: null,
88
- }
89
- : {
90
- diagnostic:
91
- "The managed OpenCode /agency command needs synchronization.",
92
- remediation:
93
- "Run 'agency integration sync' to install the managed /agency command.",
94
- }
95
- }
96
73
  if (name === "opencode-plugin") {
97
74
  return state === "managed"
98
75
  ? {
@@ -202,8 +179,6 @@ const inspect = (root: string) =>
202
179
  const opencodeJsonPath = join(opencodeDirectory, "opencode.json")
203
180
  const tuiPath = join(opencodeDirectory, "tui.jsonc")
204
181
  const tuiJsonPath = join(opencodeDirectory, "tui.json")
205
- const commandPath = join(opencodeDirectory, "command", "agency.md")
206
- const pluralCommandPath = join(opencodeDirectory, "commands", "agency.md")
207
182
  const pluginPath = join(
208
183
  opencodeDirectory,
209
184
  "plugin",
@@ -252,29 +227,6 @@ const inspect = (root: string) =>
252
227
  files.push(fileStatus("opencode", opencodePath, "missing"))
253
228
  }
254
229
 
255
- if ((yield* fs.readSymlinkTarget(commandPath)) !== null) {
256
- files.push(fileStatus("opencode-command", commandPath, "customized"))
257
- } else if (
258
- (yield* fs.readSymlinkTarget(pluralCommandPath)) !== null ||
259
- (yield* fs.exists(pluralCommandPath))
260
- ) {
261
- files.push(
262
- fileStatus("opencode-command", pluralCommandPath, "customized"),
263
- )
264
- } else if (yield* fs.exists(commandPath)) {
265
- files.push(
266
- classify(
267
- "opencode-command",
268
- commandPath,
269
- yield* fs.readFile(commandPath),
270
- managedWorkbaseOpencodeCommand,
271
- canUpdateManagedWorkbaseOpencodeCommand,
272
- ),
273
- )
274
- } else {
275
- files.push(fileStatus("opencode-command", commandPath, "missing"))
276
- }
277
-
278
230
  if ((yield* fs.readSymlinkTarget(pluginPath)) !== null) {
279
231
  files.push(fileStatus("opencode-plugin", pluginPath, "customized"))
280
232
  } else if (
@@ -348,6 +300,25 @@ const canRemoveLegacyAgents = (root: string) =>
348
300
  return canUpdateManagedWorkbaseAgents(yield* fs.readFile(path))
349
301
  })
350
302
 
303
+ const canRemoveLegacyOpencodeCommand = (root: string) =>
304
+ Effect.gen(function* () {
305
+ const fs = yield* FileSystemService
306
+ const path = join(root, ".opencode", "command", "agency.md")
307
+ if (
308
+ (yield* fs.readSymlinkTarget(path)) !== null ||
309
+ !(yield* fs.exists(path))
310
+ )
311
+ return false
312
+
313
+ const content = yield* fs.readFile(path)
314
+ const header = /^---\r?\n# agency-managed: sha256=([a-f0-9]{64})\r?\n/
315
+ const match = content.match(header)
316
+ if (!match?.[1]) return false
317
+
318
+ const canonical = content.replace(header, "---\n")
319
+ return createHash("sha256").update(canonical).digest("hex") === match[1]
320
+ })
321
+
351
322
  export class IntegrationService extends Effect.Service<IntegrationService>()(
352
323
  "IntegrationService",
353
324
  {
@@ -370,6 +341,8 @@ export class IntegrationService extends Effect.Service<IntegrationService>()(
370
341
  (status) =>
371
342
  status.name === "opencode" && status.state !== "customized",
372
343
  ) && (yield* canRemoveLegacyAgents(root))
344
+ const removeLegacyOpencodeCommand =
345
+ yield* canRemoveLegacyOpencodeCommand(root)
373
346
  const files: IntegrationSyncFile[] = []
374
347
 
375
348
  for (const status of statuses) {
@@ -382,9 +355,6 @@ export class IntegrationService extends Effect.Service<IntegrationService>()(
382
355
  } else if (status.name === "opencode") {
383
356
  yield* fs.createDirectory(join(root, ".opencode"))
384
357
  yield* fs.writeFile(status.path, managedWorkbaseOpencode)
385
- } else if (status.name === "opencode-command") {
386
- yield* fs.createDirectory(join(root, ".opencode", "command"))
387
- yield* fs.writeFile(status.path, managedWorkbaseOpencodeCommand)
388
358
  } else if (status.name === "opencode-plugin") {
389
359
  yield* fs.createDirectory(join(root, ".opencode", "plugin"))
390
360
  yield* fs.writeFile(status.path, managedWorkbaseOpencodePlugin)
@@ -411,6 +381,11 @@ export class IntegrationService extends Effect.Service<IntegrationService>()(
411
381
  }
412
382
 
413
383
  if (removeLegacyAgents) yield* fs.deleteFile(join(root, "AGENTS.md"))
384
+ if (removeLegacyOpencodeCommand) {
385
+ yield* fs.deleteFile(
386
+ join(root, ".opencode", "command", "agency.md"),
387
+ )
388
+ }
414
389
 
415
390
  return { root, files }
416
391
  }),