@markjaquith/agency 2.29.0 → 2.30.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.
Files changed (50) hide show
  1. package/README.md +72 -16
  2. package/cli.ts +3 -0
  3. package/fixtures/protocol/skill-setup-commands.json +18 -0
  4. package/package.json +1 -1
  5. package/skills/agency/SKILL.md +30 -15
  6. package/skills/agency/references/commands.md +38 -17
  7. package/skills/agency/references/contracts.md +46 -19
  8. package/skills/agency/references/recipes.md +52 -12
  9. package/src/cli-parser.test.ts +7 -0
  10. package/src/cli-parser.ts +13 -2
  11. package/src/cli.test.ts +422 -3
  12. package/src/commands/doctor.test.ts +22 -0
  13. package/src/commands/init.test.ts +3 -2
  14. package/src/commands/integration.test.ts +21 -1
  15. package/src/commands/integration.ts +8 -4
  16. package/src/commands/pr.test.ts +20 -1
  17. package/src/commands/repo.test.ts +66 -1
  18. package/src/commands/repo.ts +35 -8
  19. package/src/commands/status.test.ts +22 -0
  20. package/src/commands/status.ts +1 -0
  21. package/src/commands/sync.ts +5 -3
  22. package/src/commands/work.test.ts +74 -1
  23. package/src/commands/work.ts +26 -3
  24. package/src/graph-schema.test.ts +52 -4
  25. package/src/protocol.test.ts +41 -8
  26. package/src/readiness.test.ts +75 -17
  27. package/src/services/DoctorService.ts +32 -18
  28. package/src/services/EpicService.ts +1 -1
  29. package/src/services/GraphMutationService.ts +3 -3
  30. package/src/services/GraphService.ts +13 -4
  31. package/src/services/IntegrationService.test.ts +37 -12
  32. package/src/services/IntegrationService.ts +70 -21
  33. package/src/services/PhaseService.ts +1 -1
  34. package/src/services/ReadinessService.test.ts +47 -0
  35. package/src/services/RepositoryService.test.ts +299 -5
  36. package/src/services/RepositoryService.ts +725 -98
  37. package/src/services/SyncService.test.ts +36 -0
  38. package/src/services/SyncService.ts +20 -1
  39. package/src/services/TaskService.ts +1 -1
  40. package/src/services/WorkbaseService.test.ts +32 -0
  41. package/src/services/WorkbaseService.ts +22 -14
  42. package/src/services/WorktreeLock.test.ts +122 -0
  43. package/src/services/WorktreeService.test.ts +17 -2
  44. package/src/services/WorktreeService.ts +3 -3
  45. package/src/utils/process.test.ts +4 -3
  46. package/src/workbase/AGENTS.md +9 -1
  47. package/src/workbase/dependency-graph.test.ts +50 -0
  48. package/src/workbase/opencode-file.ts +3 -13
  49. package/src/workbase/schemas.test.ts +52 -0
  50. package/src/workbase/schemas.ts +19 -0
@@ -23,7 +23,7 @@ describe("repo command", () => {
23
23
  test("requires a subcommand", async () => {
24
24
  await expect(
25
25
  runTestEffect(repo({ args: [], silent: true })),
26
- ).rejects.toThrow("Available subcommands: add, link, list")
26
+ ).rejects.toThrow("Available subcommands: setup, add, link, list")
27
27
  })
28
28
 
29
29
  test("requires add arguments", async () => {
@@ -47,7 +47,9 @@ describe("repo command", () => {
47
47
  path: join(root, "repos/agency"),
48
48
  kind: "repository",
49
49
  remote: null,
50
+ declaredRemote: null,
50
51
  target: null,
52
+ states: ["materialized", "invalid"],
51
53
  },
52
54
  ])
53
55
  })
@@ -75,6 +77,19 @@ describe("repo command", () => {
75
77
  stderr: "ignore",
76
78
  })
77
79
  expect(await git.exited).toBe(0)
80
+ const remote = Bun.spawn(
81
+ [
82
+ "git",
83
+ "-C",
84
+ target,
85
+ "remote",
86
+ "add",
87
+ "origin",
88
+ "https://example.com/linked.git",
89
+ ],
90
+ { stdout: "ignore", stderr: "ignore" },
91
+ )
92
+ expect(await remote.exited).toBe(0)
78
93
 
79
94
  const logs = await captureLogs(() =>
80
95
  runTestEffect(
@@ -101,6 +116,19 @@ describe("repo command", () => {
101
116
  stderr: "ignore",
102
117
  })
103
118
  expect(await git.exited).toBe(0)
119
+ const remote = Bun.spawn(
120
+ [
121
+ "git",
122
+ "-C",
123
+ target,
124
+ "remote",
125
+ "add",
126
+ "origin",
127
+ "https://example.com/unlink.git",
128
+ ],
129
+ { stdout: "ignore", stderr: "ignore" },
130
+ )
131
+ expect(await remote.exited).toBe(0)
104
132
  await runTestEffect(
105
133
  repo({
106
134
  subcommand: "link",
@@ -120,5 +148,42 @@ describe("repo command", () => {
120
148
  )
121
149
 
122
150
  expect(await Bun.file(join(target, ".git/HEAD")).exists()).toBe(true)
151
+ expect(await Bun.file(join(root, "repos/linked")).exists()).toBe(false)
152
+ const logs = await captureLogs(() =>
153
+ runTestEffect(
154
+ repo({ subcommand: "list", args: [], cwd: root, json: true }),
155
+ ),
156
+ )
157
+ expect(
158
+ JSON.parse(logs[0]!).map(({ alias }: { alias: string }) => alias),
159
+ ).toEqual(["agency", "linked"])
160
+ })
161
+
162
+ test("reports setup plans as JSON without mutating", async () => {
163
+ await Bun.write(
164
+ join(root, "agency.json"),
165
+ JSON.stringify({
166
+ version: 2,
167
+ repositories: {
168
+ missing: { remote: "https://example.com/missing.git" },
169
+ },
170
+ }),
171
+ )
172
+ const logs = await captureLogs(() =>
173
+ runTestEffect(
174
+ repo({ subcommand: "setup", args: [], cwd: root, json: true }),
175
+ ),
176
+ )
177
+ const result = JSON.parse(logs[0]!)
178
+ expect(result.mode).toBe("dry-run")
179
+ expect(result.actions).toEqual([
180
+ {
181
+ kind: "materialize",
182
+ alias: "missing",
183
+ remote: "https://example.com/missing.git",
184
+ status: "planned",
185
+ },
186
+ ])
187
+ expect(await Bun.file(join(root, "repos/missing")).exists()).toBe(false)
123
188
  })
124
189
  })
@@ -7,6 +7,8 @@ interface RepoOptions extends BaseCommandOptions {
7
7
  readonly subcommand?: string
8
8
  readonly args: readonly string[]
9
9
  readonly json?: boolean
10
+ readonly apply?: boolean
11
+ readonly dryRun?: boolean
10
12
  }
11
13
 
12
14
  const requireArg = (args: readonly string[], index: number, usage: string) =>
@@ -19,6 +21,27 @@ export const repo = (options: RepoOptions) =>
19
21
  const cwd = options.cwd ?? process.cwd()
20
22
 
21
23
  switch (options.subcommand) {
24
+ case "setup": {
25
+ const result = yield* repositories.setup({
26
+ cwd,
27
+ apply: options.apply === true,
28
+ })
29
+ if (options.json) {
30
+ log(JSON.stringify(result, null, 2))
31
+ return
32
+ }
33
+ if (result.actions.length === 0) log("Repository setup is current")
34
+ for (const action of result.actions) {
35
+ log(
36
+ `${action.status === "applied" ? "Applied" : "Planned"} ${action.kind} '${action.alias}' from ${action.remote}`,
37
+ )
38
+ }
39
+ for (const issue of result.unresolved) {
40
+ log(`Unresolved '${issue.alias}': ${issue.message}. ${issue.action}`)
41
+ }
42
+ return
43
+ }
44
+
22
45
  case "add": {
23
46
  const [alias, remote] = options.args
24
47
  if (!alias || !remote) {
@@ -58,8 +81,9 @@ export const repo = (options: RepoOptions) =>
58
81
  return
59
82
  }
60
83
  for (const item of items) {
61
- const detail = item.target ?? item.remote ?? item.path
62
- log(`${item.alias}\t${item.kind}\t${detail}`)
84
+ const detail =
85
+ item.target ?? item.remote ?? item.declaredRemote ?? item.path
86
+ log(`${item.alias}\t${item.states.join(",")}\t${detail}`)
63
87
  }
64
88
  return
65
89
  }
@@ -74,7 +98,7 @@ export const repo = (options: RepoOptions) =>
74
98
  log(
75
99
  options.json
76
100
  ? JSON.stringify(item, null, 2)
77
- : `${item.alias}\t${item.kind}\t${item.target ?? item.remote ?? item.path}`,
101
+ : `${item.alias}\t${item.states.join(",")}\t${item.target ?? item.remote ?? item.declaredRemote ?? item.path}`,
78
102
  )
79
103
  return
80
104
  }
@@ -136,7 +160,7 @@ export const repo = (options: RepoOptions) =>
136
160
  log(
137
161
  options.json
138
162
  ? JSON.stringify(item, null, 2)
139
- : (item.remote ?? "No origin remote configured"),
163
+ : (item.declaredRemote ?? "No portable remote declared"),
140
164
  )
141
165
  return
142
166
  }
@@ -163,7 +187,7 @@ export const repo = (options: RepoOptions) =>
163
187
  default:
164
188
  return yield* Effect.fail(
165
189
  new Error(
166
- "Subcommand is required. Available subcommands: add, link, list, show, fetch, remove, unlink, rename, remote, verify",
190
+ "Subcommand is required. Available subcommands: setup, add, link, list, show, fetch, remove, unlink, rename, remote, verify",
167
191
  ),
168
192
  )
169
193
  }
@@ -173,17 +197,20 @@ export const help = `
173
197
  Usage: agency repo <subcommand>
174
198
 
175
199
  Subcommands:
200
+ setup Plan or apply portable repository setup
176
201
  add <alias> <remote> Create a bare clone
177
202
  link <alias> <path> Link an existing Git repository
178
203
  list List repository aliases
179
204
  show <alias> Show a repository alias
180
205
  fetch <alias> Fetch and prune a repository
181
- remove <alias> Remove an unused repository alias
182
- unlink <alias> Remove an unused linked alias
206
+ remove <alias> Remove a declaration and local materialization
207
+ unlink <alias> Remove only this machine's linked checkout
183
208
  rename <old> <new> Rename an unused repository alias
184
- remote <alias> [url] Show or update the origin remote
209
+ remote <alias> [url] Show or update the portable remote declaration
185
210
  verify <alias> Verify repository operation
186
211
 
187
212
  Options:
213
+ --dry-run Report setup changes without applying them (default)
214
+ --apply Apply safe setup changes
188
215
  --json Output repository aliases as JSON
189
216
  `
@@ -41,7 +41,9 @@ describe("status command", () => {
41
41
  path: join(root, "repos/agency"),
42
42
  kind: "repository",
43
43
  remote: null,
44
+ declaredRemote: null,
44
45
  target: null,
46
+ states: ["materialized", "invalid"],
45
47
  },
46
48
  ])
47
49
  })
@@ -58,6 +60,25 @@ describe("status command", () => {
58
60
  silent: true,
59
61
  }),
60
62
  )
63
+ await runTestEffect(
64
+ task({
65
+ subcommand: "create",
66
+ args: ["finished"],
67
+ repo: "agency",
68
+ branch: "feat/finished",
69
+ base: "main",
70
+ cwd: root,
71
+ silent: true,
72
+ }),
73
+ )
74
+ await runTestEffect(
75
+ task({
76
+ subcommand: "status",
77
+ args: ["finished", "done"],
78
+ cwd: root,
79
+ silent: true,
80
+ }),
81
+ )
61
82
 
62
83
  const logs = await captureLogs(() =>
63
84
  runTestEffect(
@@ -71,6 +92,7 @@ describe("status command", () => {
71
92
  expect(logs.at(-1)).toContain(
72
93
  "task example - open ready agency feat/example absent absent",
73
94
  )
95
+ expect(logs.at(-1)).not.toContain("finished")
74
96
  })
75
97
 
76
98
  test("reports validation issues without requiring a decodable graph", async () => {
@@ -42,6 +42,7 @@ export const status = (options: StatusOptions = {}) =>
42
42
 
43
43
  log(`Workbase: ${report.root}`)
44
44
  log(`Repositories: ${repos.length}`)
45
+ for (const repo of repos) log(` ${repo.alias}: ${repo.states.join(", ")}`)
45
46
  log(`Epics: ${report.epicCount}`)
46
47
  log(`Tasks: ${report.taskCount}`)
47
48
  log(`Phases: ${report.phaseCount}`)
@@ -22,15 +22,17 @@ export const sync = (options: SyncCommandOptions = {}) =>
22
22
  export const help = `
23
23
  Usage: agency sync [--dry-run | --apply] [--json]
24
24
 
25
- Compare declared execution state with Git worktrees, branches, references, claims,
26
- and GitHub pull requests. Dry-run is the default.
25
+ Compare portable repository declarations and execution state with local Git
26
+ repositories, worktrees, branches, references, claims, and pull requests.
27
+ Dry-run is the default.
27
28
 
28
29
  Options:
29
30
  --dry-run Report planned safe transitions without changing state
30
31
  --apply Apply safe reconciliation transitions
31
32
  --json Output one versioned machine result
32
33
 
33
- Apply may materialize unambiguous missing checkouts, release expired claims,
34
+ Apply may materialize declared repositories and unambiguous missing checkouts,
35
+ adopt legacy repositories with portable origins, release expired claims,
34
36
  record a uniquely matched PR, and mark merged work done. Dirty, stale, or
35
37
  conflicting checkouts are always left unresolved.
36
38
  `
@@ -67,6 +67,7 @@ interface HarnessOptions {
67
67
  readonly existingDirectories?: readonly string[]
68
68
  readonly guardError?: Error
69
69
  readonly readyTargetIds?: readonly string[]
70
+ readonly opencodeIntegrationState?: "managed" | "customized"
70
71
  }
71
72
 
72
73
  const createHarness = (options: HarnessOptions = {}) => {
@@ -215,7 +216,15 @@ const createHarness = (options: HarnessOptions = {}) => {
215
216
  const integrations = {
216
217
  sync: () => {
217
218
  integrationSyncs += 1
218
- return Effect.succeed({ root: "/workbase", files: [] })
219
+ return Effect.succeed({
220
+ root: "/workbase",
221
+ files: [
222
+ {
223
+ name: "opencode",
224
+ state: options.opencodeIntegrationState ?? "managed",
225
+ },
226
+ ],
227
+ })
219
228
  },
220
229
  }
221
230
  const fs = {
@@ -395,6 +404,23 @@ describe("work command", () => {
395
404
  ],
396
405
  cwd: "/workbase/epics/delivery",
397
406
  })
407
+ expect(harness.launchEnvironments[0]?.OPENCODE_CONFIG).toBe(
408
+ "/workbase/.opencode/opencode.jsonc",
409
+ )
410
+ expect(
411
+ JSON.parse(harness.launchEnvironments[0]!.OPENCODE_CONFIG_CONTENT!),
412
+ ).toEqual({
413
+ permission: {
414
+ external_directory: { "/workbase/**": "allow" },
415
+ edit: { "*": "deny" },
416
+ },
417
+ agent: {
418
+ build: { permission: { edit: { "*": "deny" } } },
419
+ plan: { permission: { edit: { "*": "deny" } } },
420
+ general: { permission: { edit: { "*": "deny" } } },
421
+ explore: { permission: { edit: { "*": "deny" } } },
422
+ },
423
+ })
398
424
  })
399
425
 
400
426
  test("resolves an existing positional path before treating it as a task ID", async () => {
@@ -444,6 +470,9 @@ describe("work command", () => {
444
470
  ],
445
471
  cwd: "/workbase/tasks/delivery",
446
472
  })
473
+ expect(harness.launchEnvironments[0]?.OPENCODE_CONFIG).toBe(
474
+ "/workbase/.opencode/opencode.jsonc",
475
+ )
447
476
  })
448
477
 
449
478
  test("infers a phase from a nested checkout directory", async () => {
@@ -472,6 +501,9 @@ describe("work command", () => {
472
501
  expect(harness.statusUpdates).toEqual([
473
502
  "phase:example:implementation:working",
474
503
  ])
504
+ expect(harness.launchEnvironments[0]?.OPENCODE_CONFIG).toBe(
505
+ "/workbase/.opencode/opencode.jsonc",
506
+ )
475
507
  })
476
508
 
477
509
  test("infers a single-phase task from a nested checkout directory", async () => {
@@ -485,6 +517,9 @@ describe("work command", () => {
485
517
 
486
518
  expect(harness.events[0]).toBe("materialize")
487
519
  expect(harness.launches[0]?.cwd).toBe(singlePhaseWorkspace.writablePath)
520
+ expect(harness.launchEnvironments[0]?.OPENCODE_CONFIG).toBe(
521
+ "/workbase/.opencode/opencode.jsonc",
522
+ )
488
523
  })
489
524
 
490
525
  test("selects a target when no directory is provided", async () => {
@@ -665,6 +700,9 @@ describe("work command", () => {
665
700
  },
666
701
  ])
667
702
  expect(harness.statusUpdates).toEqual(["task:example:working"])
703
+ expect(harness.launchEnvironments[0]?.OPENCODE_CONFIG).toBe(
704
+ "/workbase/.opencode/opencode.jsonc",
705
+ )
668
706
  expect(harness.progressUpdates).toEqual([
669
707
  "start:Preparing workspace...",
670
708
  "succeed:Workspace ready",
@@ -768,6 +806,40 @@ describe("work command", () => {
768
806
  expect(printed.environment.API_TOKEN).toBeUndefined()
769
807
  })
770
808
 
809
+ test("prints the runtime OpenCode integration config", async () => {
810
+ const harness = createHarness()
811
+ const output = await captureLogs(() =>
812
+ harness.run({ taskId: "example", opencode: true, printCommand: true }),
813
+ )
814
+ const printed = JSON.parse(output.join("\n"))
815
+
816
+ expect(printed.environment.OPENCODE_CONFIG).toBe(
817
+ "/workbase/.opencode/opencode.jsonc",
818
+ )
819
+ expect(JSON.parse(printed.environment.OPENCODE_CONFIG_CONTENT)).toEqual({
820
+ permission: {
821
+ external_directory: { "/workbase/**": "allow" },
822
+ edit: { "../**": "deny" },
823
+ },
824
+ agent: {
825
+ build: { permission: { edit: { "../**": "deny" } } },
826
+ plan: { permission: { edit: { "../**": "deny" } } },
827
+ general: { permission: { edit: { "../**": "deny" } } },
828
+ explore: { permission: { edit: { "../**": "deny" } } },
829
+ },
830
+ })
831
+ })
832
+
833
+ test("does not override customized OpenCode access policy", async () => {
834
+ const harness = createHarness({ opencodeIntegrationState: "customized" })
835
+ await harness.run({ taskId: "example", opencode: true })
836
+
837
+ expect(harness.launchEnvironments[0]?.OPENCODE_CONFIG).toBeUndefined()
838
+ expect(
839
+ harness.launchEnvironments[0]?.OPENCODE_CONFIG_CONTENT,
840
+ ).toBeUndefined()
841
+ })
842
+
771
843
  test("automatically falls back to Claude", async () => {
772
844
  const harness = createHarness({ available: { opencode: false } })
773
845
 
@@ -779,6 +851,7 @@ describe("work command", () => {
779
851
  args: ["claude", "Start the task. Read /workbase/tasks/example/TASK.md."],
780
852
  cwd: singlePhaseWorkspace.writablePath,
781
853
  })
854
+ expect(harness.launchEnvironments[0]?.OPENCODE_CONFIG).toBeUndefined()
782
855
  })
783
856
 
784
857
  test("does not fall back when OpenCode is explicitly required", async () => {
@@ -1,5 +1,5 @@
1
1
  import { Effect } from "effect"
2
- import { dirname, isAbsolute, relative, resolve, sep } from "node:path"
2
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"
3
3
  import type { BaseCommandOptions } from "../utils/command"
4
4
  import { WorktreeService } from "../services/WorktreeService"
5
5
  import { FileSystemService } from "../services/FileSystemService"
@@ -120,7 +120,10 @@ export const work = (
120
120
  const inputAllowed = options.inputAllowed ?? true
121
121
  const root = yield* resolveWorkbase(startPath, pickBase, inputAllowed)
122
122
  if (!root) return
123
- yield* integrations.sync(root)
123
+ const integration = yield* integrations.sync(root)
124
+ const managedOpencode = integration.files.some(
125
+ (file) => file.name === "opencode" && file.state === "managed",
126
+ )
124
127
  const { config } = yield* workbase.loadConfig(root)
125
128
 
126
129
  let target: WorkTarget | null = null
@@ -325,6 +328,25 @@ export const work = (
325
328
  ...resolved.environment,
326
329
  ...runnerEnvironment(runner, variables),
327
330
  }
331
+ if (runner === "opencode" && managedOpencode) {
332
+ environment.OPENCODE_CONFIG = join(root, ".opencode", "opencode.jsonc")
333
+ const execution =
334
+ target.kind === "phase" ||
335
+ (target.kind === "task" && !target.multiPhase)
336
+ const edit = { [execution ? "../**" : "*"]: "deny" as const }
337
+ environment.OPENCODE_CONFIG_CONTENT = JSON.stringify({
338
+ permission: {
339
+ external_directory: { [join(root, "**")]: "allow" },
340
+ edit,
341
+ },
342
+ agent: {
343
+ build: { permission: { edit } },
344
+ plan: { permission: { edit } },
345
+ general: { permission: { edit } },
346
+ explore: { permission: { edit } },
347
+ },
348
+ })
349
+ }
328
350
  if (options.printCommand) {
329
351
  log(
330
352
  JSON.stringify(
@@ -422,7 +444,8 @@ Usage: agency work [<directory-or-task-id> | --epic <epic-id>] [--runner <name>]
422
444
  Launch an agent for an epic, task, or phase. With no directory, select one
423
445
  interactively. A positional argument resolves as a directory first, then as a task
424
446
  ID. Use '.' for the current directory. Outside a workbase, select a registered
425
- workbase first.
447
+ workbase first. OpenCode launches receive whole-workbase read access through the
448
+ runtime environment; Agency context remains authoritative for writes.
426
449
 
427
450
  The prepare subcommand resolves and materializes an execution workspace without
428
451
  launching an agent or changing lifecycle status. --dry-run reports planned Git
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, test } from "bun:test"
2
+ import { Schema } from "@effect/schema"
2
3
  import jsonSchema from "../schemas/agency-graph-v1.schema.json"
3
- import { graphJsonlRecords, type AgencyGraph } from "./graph-schema"
4
+ import { AgencyGraph, graphJsonlRecords } from "./graph-schema"
4
5
 
5
6
  describe("graph contract", () => {
6
7
  test("publishes the v1 JSON Schema", () => {
@@ -29,6 +30,38 @@ describe("graph contract", () => {
29
30
  })
30
31
 
31
32
  test("streams records that reconstruct graph semantics", () => {
33
+ const nodes = [
34
+ {
35
+ id: "repository:agency",
36
+ key: "agency",
37
+ kind: "repository" as const,
38
+ dependents: ["repository:effect"],
39
+ repositories: ["agency"],
40
+ status: null,
41
+ readiness: null,
42
+ aggregate: null,
43
+ data: { alias: "agency" },
44
+ },
45
+ {
46
+ id: "repository:effect",
47
+ key: "effect",
48
+ kind: "repository" as const,
49
+ dependents: [],
50
+ repositories: ["effect"],
51
+ status: null,
52
+ readiness: null,
53
+ aggregate: null,
54
+ data: { alias: "effect" },
55
+ },
56
+ ]
57
+ const edges = [
58
+ {
59
+ id: "references:repository:agency:repository:effect",
60
+ kind: "references" as const,
61
+ from: "repository:agency",
62
+ to: "repository:effect",
63
+ },
64
+ ]
32
65
  const graph = {
33
66
  version: 1,
34
67
  workbase: { version: 2 },
@@ -40,8 +73,8 @@ describe("graph contract", () => {
40
73
  kinds: [],
41
74
  },
42
75
  includes: [],
43
- nodes: [],
44
- edges: [],
76
+ nodes,
77
+ edges,
45
78
  summary: {
46
79
  status: "open",
47
80
  total: 0,
@@ -54,6 +87,11 @@ describe("graph contract", () => {
54
87
  },
55
88
  validation: { valid: true, issues: [] },
56
89
  } satisfies AgencyGraph
90
+ expect(
91
+ Schema.decodeUnknownSync(AgencyGraph, { onExcessProperty: "error" })(
92
+ graph,
93
+ ),
94
+ ).toEqual(graph)
57
95
  const records = [...graphJsonlRecords(graph)]
58
96
  const { nodes: _nodes, edges: _edges, ...metadata } = graph
59
97
  expect(records).toEqual([
@@ -62,7 +100,17 @@ describe("graph contract", () => {
62
100
  type: "meta",
63
101
  graph: metadata,
64
102
  },
65
- { version: 1, type: "end", nodeCount: 0, edgeCount: 0 },
103
+ ...nodes.map((node) => ({
104
+ version: 1 as const,
105
+ type: "node" as const,
106
+ node,
107
+ })),
108
+ ...edges.map((edge) => ({
109
+ version: 1 as const,
110
+ type: "edge" as const,
111
+ edge,
112
+ })),
113
+ { version: 1, type: "end", nodeCount: 2, edgeCount: 1 },
66
114
  ])
67
115
  })
68
116
  })
@@ -53,6 +53,34 @@ describe("machine protocol", () => {
53
53
  ).rejects.toThrow("more than one result")
54
54
  })
55
55
 
56
+ test("restores collection state after a command throws", async () => {
57
+ const originalLog = console.log
58
+ await expect(
59
+ collectCommandResult(async () => {
60
+ emitCommandResult("partial")
61
+ throw new Error("command failed")
62
+ }),
63
+ ).rejects.toThrow("command failed")
64
+ expect(console.log).toBe(originalLog)
65
+
66
+ await expect(
67
+ collectCommandResult(async () => {
68
+ emitCommandResult("recovered")
69
+ }),
70
+ ).resolves.toBe("recovered")
71
+ })
72
+
73
+ test("rejects nested result collectors without disrupting the outer one", async () => {
74
+ const result = await collectCommandResult(async () => {
75
+ await expect(collectCommandResult(async () => {})).rejects.toThrow(
76
+ "already active",
77
+ )
78
+ emitCommandResult("outer")
79
+ })
80
+
81
+ expect(result).toBe("outer")
82
+ })
83
+
56
84
  test("normalizes unknown failures into stable error details", () => {
57
85
  expect(errorEnvelope(new Error("boom"))).toEqual({
58
86
  version: 1,
@@ -67,14 +95,15 @@ describe("machine protocol", () => {
67
95
  })
68
96
 
69
97
  test("preserves relevant fields from classified errors", () => {
70
- expect(
71
- errorEnvelope({
72
- _tag: "ValidationFailedError",
73
- message: "invalid workbase",
74
- root: "/work/agency",
75
- issues: [{ path: "TASK.md", message: "invalid status" }],
76
- }),
77
- ).toMatchObject({
98
+ const validation = errorEnvelope({
99
+ _tag: "ValidationFailedError",
100
+ message: "invalid workbase",
101
+ cause: new Error("private cause"),
102
+ optional: undefined,
103
+ root: "/work/agency",
104
+ issues: [{ path: "TASK.md", message: "invalid status" }],
105
+ })
106
+ expect(validation).toMatchObject({
78
107
  error: {
79
108
  code: "VALIDATION_FAILED",
80
109
  fields: {
@@ -83,6 +112,10 @@ describe("machine protocol", () => {
83
112
  },
84
113
  },
85
114
  })
115
+ expect(Object.keys(validation.error.fields).sort()).toEqual([
116
+ "issues",
117
+ "root",
118
+ ])
86
119
  expect(
87
120
  errorEnvelope({
88
121
  _tag: "ClaimConflictError",