@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
@@ -1,10 +1,21 @@
1
1
  import { describe, expect, test } from "bun:test"
2
2
  import {
3
+ WORK_STATUS_TRANSITIONS,
3
4
  aggregateProgress,
4
5
  canTransitionStatus,
5
6
  isDependencySatisfied,
7
+ isTerminalStatus,
6
8
  readinessState,
7
9
  } from "./readiness"
10
+ import type { WorkStatus } from "./workbase/schemas"
11
+
12
+ const statuses: readonly WorkStatus[] = [
13
+ "open",
14
+ "working",
15
+ "delegated",
16
+ "done",
17
+ "dropped",
18
+ ]
8
19
 
9
20
  describe("readiness model", () => {
10
21
  test("only done satisfies dependencies and terminal states remain distinct", () => {
@@ -24,24 +35,71 @@ describe("readiness model", () => {
24
35
  })
25
36
  })
26
37
 
27
- test("rolls child statuses into deterministic aggregate progress", () => {
28
- expect(aggregateProgress(["done", "dropped"])).toEqual({
29
- status: "dropped",
30
- total: 2,
31
- open: 0,
32
- working: 0,
33
- delegated: 0,
34
- done: 1,
35
- dropped: 1,
36
- terminal: 2,
37
- })
38
+ test("classifies every status and dependency state", () => {
39
+ expect(statuses.map(isTerminalStatus)).toEqual([
40
+ false,
41
+ false,
42
+ false,
43
+ true,
44
+ true,
45
+ ])
46
+ expect([...statuses, undefined].map(isDependencySatisfied)).toEqual([
47
+ false,
48
+ false,
49
+ false,
50
+ true,
51
+ false,
52
+ false,
53
+ ])
38
54
  })
39
55
 
40
- test("requires terminal work to reopen before changing its outcome", () => {
41
- expect(canTransitionStatus("open", "done")).toBe(true)
42
- expect(canTransitionStatus("working", "delegated")).toBe(true)
43
- expect(canTransitionStatus("done", "dropped")).toBe(false)
44
- expect(canTransitionStatus("dropped", "done")).toBe(false)
45
- expect(canTransitionStatus("done", "open")).toBe(true)
56
+ test("covers the complete status transition matrix", () => {
57
+ for (const from of statuses) {
58
+ for (const to of statuses) {
59
+ expect(canTransitionStatus(from, to), `${from} -> ${to}`).toBe(
60
+ WORK_STATUS_TRANSITIONS[from].includes(to as never),
61
+ )
62
+ }
63
+ }
64
+ })
65
+
66
+ test("rolls every status-precedence branch into aggregate progress", () => {
67
+ const cases: readonly [readonly WorkStatus[], WorkStatus][] = [
68
+ [[], "open"],
69
+ [["done", "done"], "done"],
70
+ [["done", "dropped"], "dropped"],
71
+ [["open", "working", "delegated"], "working"],
72
+ [["open", "delegated"], "delegated"],
73
+ [["open", "done"], "open"],
74
+ ]
75
+
76
+ for (const [input, status] of cases) {
77
+ const result = aggregateProgress(input)
78
+ expect(result.status, input.join(",") || "empty").toBe(status)
79
+ expect(result.total).toBe(input.length)
80
+ expect(result.terminal).toBe(
81
+ input.filter((value) => value === "done" || value === "dropped").length,
82
+ )
83
+ for (const value of statuses) {
84
+ expect(result[value]).toBe(
85
+ input.filter((candidate) => candidate === value).length,
86
+ )
87
+ }
88
+ }
89
+ })
90
+
91
+ test("deduplicates and sorts blockers while honoring explicit readiness", () => {
92
+ expect(
93
+ readinessState(
94
+ "open",
95
+ [{ id: "task:z" }, { id: "task:a" }, { id: "task:z" }],
96
+ true,
97
+ ),
98
+ ).toEqual({
99
+ ready: true,
100
+ blocked: false,
101
+ blockedBy: ["task:a", "task:z"],
102
+ terminal: false,
103
+ })
46
104
  })
47
105
  })
@@ -242,15 +242,20 @@ export class DoctorService extends Effect.Service<DoctorService>()(
242
242
 
243
243
  const integrationStatus = yield* integrations.status(root)
244
244
  for (const file of integrationStatus.files) {
245
- const failed = file.state === "missing" || file.state === "drifted"
245
+ const failed =
246
+ file.state === "missing" ||
247
+ file.state === "drifted" ||
248
+ (file.name === "opencode" && file.state === "customized")
246
249
  add({
247
250
  id: `integration.file.${file.name}`,
248
251
  category: "integration",
249
- level: file.state === "customized" ? "optional" : "warning",
252
+ level:
253
+ file.name === "agents" && file.state === "customized"
254
+ ? "optional"
255
+ : "warning",
250
256
  status: failed ? "fail" : "pass",
251
- message: `${file.name} integration file is ${file.state}: ${file.path}`,
252
- remediation:
253
- "Run 'agency integration sync' to restore managed content.",
257
+ message: `${file.name} integration file is ${file.state}: ${file.path}. ${file.diagnostic}`,
258
+ remediation: file.remediation ?? undefined,
254
259
  })
255
260
  }
256
261
 
@@ -295,31 +300,40 @@ export class DoctorService extends Effect.Service<DoctorService>()(
295
300
  })
296
301
  }
297
302
  for (const repository of repositoryList) {
298
- const verified = yield* fs.runCommand(
299
- ["git", "-C", repository.path, "rev-parse", "--git-dir"],
300
- { captureOutput: true },
301
- )
302
- const repositoryValid = verified.exitCode === 0
303
+ const missing = repository.states.includes("missing")
304
+ const repositoryValid =
305
+ !missing && !repository.states.includes("invalid")
303
306
  add({
304
307
  id: `repository.${repository.alias}.valid`,
305
308
  category: "repository",
306
309
  level: "error",
307
310
  status: repositoryValid ? "pass" : "fail",
308
- message: repositoryValid
309
- ? `Repository '${repository.alias}' is a valid Git repository`
310
- : `Repository '${repository.alias}' is not a valid Git repository`,
311
- remediation: `Run 'agency repo verify ${repository.alias}', then repair or relink the repository.`,
311
+ message: missing
312
+ ? `Repository '${repository.alias}' is declared but not materialized`
313
+ : repositoryValid
314
+ ? `Repository '${repository.alias}' is a valid Git repository`
315
+ : `Repository '${repository.alias}' is not a valid Git repository`,
316
+ remediation: missing
317
+ ? "Run 'agency repo setup --apply'."
318
+ : `Run 'agency repo verify ${repository.alias}', then repair or relink the repository.`,
312
319
  })
313
320
  add({
314
321
  id: `repository.${repository.alias}.remote`,
315
322
  category: "repository",
316
323
  level: "warning",
317
- status: repository.remote ? "pass" : "fail",
318
- message: repository.remote
319
- ? `Repository '${repository.alias}' origin is ${repository.remote}`
320
- : `Repository '${repository.alias}' has no origin remote`,
324
+ status:
325
+ repository.declaredRemote &&
326
+ !repository.states.includes("remote-drifted")
327
+ ? "pass"
328
+ : "fail",
329
+ message: repository.states.includes("remote-drifted")
330
+ ? `Repository '${repository.alias}' origin differs from ${repository.declaredRemote}`
331
+ : repository.declaredRemote
332
+ ? `Repository '${repository.alias}' portable origin is ${repository.declaredRemote}`
333
+ : `Repository '${repository.alias}' has no portable origin declaration`,
321
334
  remediation: `Run 'agency repo remote ${repository.alias} <url>' to configure origin.`,
322
335
  })
336
+ if (!repositoryValid) continue
323
337
 
324
338
  for (const ref of [...(refs.get(repository.alias) ?? [])].sort()) {
325
339
  const local = yield* fs.runCommand(
@@ -81,7 +81,7 @@ export class EpicService extends Effect.Service<EpicService>()("EpicService", {
81
81
  }
82
82
 
83
83
  for (const { repo: alias } of data.repos) {
84
- if (!(yield* fs.exists(join(root, "repos", alias)))) {
84
+ if (!(yield* workbase.hasRepositoryAlias(alias, root))) {
85
85
  return yield* new EpicError({
86
86
  message: `Unknown repository alias '${alias}'`,
87
87
  })
@@ -309,7 +309,7 @@ export class GraphMutationService extends Effect.Service<GraphMutationService>()
309
309
  "epic metadata",
310
310
  )
311
311
  for (const reference of data.repos) {
312
- if (!(yield* fs.exists(join(root, "repos", reference.repo)))) {
312
+ if (!(yield* workbase.hasRepositoryAlias(reference.repo, root))) {
313
313
  return yield* new GraphMutationError({
314
314
  message: `Unknown repository alias '${reference.repo}'`,
315
315
  })
@@ -420,7 +420,7 @@ export class GraphMutationService extends Effect.Service<GraphMutationService>()
420
420
  })
421
421
  }
422
422
  for (const alias of aliases) {
423
- if (!(yield* fs.exists(join(root, "repos", alias)))) {
423
+ if (!(yield* workbase.hasRepositoryAlias(alias, root))) {
424
424
  return yield* new GraphMutationError({
425
425
  message: `Unknown repository alias '${alias}'`,
426
426
  })
@@ -538,7 +538,7 @@ export class GraphMutationService extends Effect.Service<GraphMutationService>()
538
538
  })
539
539
  }
540
540
  for (const alias of aliases) {
541
- if (!(yield* fs.exists(join(root, "repos", alias)))) {
541
+ if (!(yield* workbase.hasRepositoryAlias(alias, root))) {
542
542
  return yield* new GraphMutationError({
543
543
  message: `Unknown repository alias '${alias}'`,
544
544
  })
@@ -197,13 +197,20 @@ export class GraphService extends Effect.Service<GraphService>()(
197
197
  }
198
198
  }
199
199
 
200
- const repositoryRecords: RepositoryRecord[] = []
200
+ const repositoryRecords = new Map<string, RepositoryRecord>()
201
201
  const reposPath = join(root, "repos")
202
+ for (const alias of Object.keys(config.repositories ?? {}).sort()) {
203
+ repositoryRecords.set(alias, {
204
+ alias,
205
+ path: join(reposPath, alias),
206
+ target: null,
207
+ })
208
+ }
202
209
  if (yield* fs.isDirectory(reposPath)) {
203
210
  for (const entry of (yield* fs.readDirectory(reposPath))
204
- .filter((item) => item.isDirectory || item.isSymlink)
211
+ .filter((item) => !item.name.startsWith(".agency-"))
205
212
  .sort((a, b) => a.name.localeCompare(b.name))) {
206
- repositoryRecords.push({
213
+ repositoryRecords.set(entry.name, {
207
214
  alias: entry.name,
208
215
  path: join(reposPath, entry.name),
209
216
  target: entry.isSymlink
@@ -760,7 +767,9 @@ export class GraphService extends Effect.Service<GraphService>()(
760
767
  ...(yield* executionDetails(phase.path, phase.data)),
761
768
  })
762
769
  }
763
- for (const repository of repositoryRecords) {
770
+ for (const repository of [...repositoryRecords.values()].sort(
771
+ (a, b) => a.alias.localeCompare(b.alias),
772
+ )) {
764
773
  nodes.push({
765
774
  id: repositoryNodeId(repository.alias),
766
775
  kind: "repository",
@@ -97,30 +97,55 @@ describe("IntegrationService", () => {
97
97
  const config = JSON.parse(managedBody(managedWorkbaseOpencode))
98
98
 
99
99
  expect(config.references).toEqual({
100
- tasks: {
101
- path: "../tasks",
100
+ workbase: {
101
+ path: "..",
102
102
  description:
103
- "Agency task definitions and execution context; authority still comes from agency context",
103
+ "Complete Agency workbase context; write authority still comes only from agency context",
104
104
  },
105
- epics: {
106
- path: "../epics",
107
- description:
108
- "Agency epic definitions and orchestration context; no implementation write authority",
109
- },
110
- })
111
- expect(config.permission).toEqual({
112
- external_directory: { "../**": "allow" },
113
105
  })
106
+ expect(config.permission).toBeUndefined()
107
+ expect(managedBody(managedWorkbaseOpencode)).not.toContain(process.cwd())
114
108
  })
115
109
 
116
110
  test("treats an existing JSON OpenCode config as customized", async () => {
117
111
  await write(root, ".opencode/opencode.json", '{"model":"test/model"}\n')
118
112
 
119
113
  const result = await status(root)
120
- expect(result.files[1]).toEqual({
114
+ expect(result.files[1]).toMatchObject({
121
115
  name: "opencode",
122
116
  path: join(root, ".opencode/opencode.json"),
123
117
  state: "customized",
118
+ diagnostic: expect.stringContaining("cannot guarantee"),
119
+ remediation: expect.stringContaining("global config"),
120
+ })
121
+ })
122
+
123
+ test("treats a JSON config beside managed JSONC as customized", async () => {
124
+ await write(root, ".opencode/opencode.jsonc", managedWorkbaseOpencode)
125
+ await write(root, ".opencode/opencode.json", '{"model":"test/model"}\n')
126
+
127
+ const result = await status(root)
128
+ expect(result.files[1]).toMatchObject({
129
+ name: "opencode",
130
+ path: join(root, ".opencode/opencode.json"),
131
+ state: "customized",
132
+ })
133
+ })
134
+
135
+ test("reports actionable whole-workbase access diagnostics", async () => {
136
+ let result = await status(root)
137
+ expect(result.files[1]).toMatchObject({
138
+ state: "missing",
139
+ diagnostic: expect.stringContaining("cannot load"),
140
+ remediation: expect.stringContaining("integration sync"),
141
+ })
142
+
143
+ await write(root, ".opencode/opencode.jsonc", '{"model":"test/model"}\n')
144
+ result = await status(root)
145
+ expect(result.files[1]).toMatchObject({
146
+ state: "customized",
147
+ diagnostic: expect.stringContaining("cannot guarantee"),
148
+ remediation: expect.stringContaining("global config"),
124
149
  })
125
150
  })
126
151
 
@@ -17,28 +17,79 @@ interface IntegrationFileStatus {
17
17
  readonly name: "agents" | "opencode"
18
18
  readonly path: string
19
19
  readonly state: IntegrationFileState
20
+ readonly diagnostic: string
21
+ readonly remediation: string | null
20
22
  }
21
23
 
22
24
  interface IntegrationSyncFile extends IntegrationFileStatus {
23
25
  readonly changed: boolean
24
26
  }
25
27
 
28
+ const describe = (
29
+ name: IntegrationFileStatus["name"],
30
+ state: IntegrationFileState,
31
+ ) => {
32
+ if (name === "opencode") {
33
+ if (state === "managed") {
34
+ return {
35
+ diagnostic:
36
+ "Agency's managed OpenCode launch config is ready to provide whole-workbase read access.",
37
+ remediation: null,
38
+ }
39
+ }
40
+ if (state === "customized") {
41
+ return {
42
+ diagnostic:
43
+ "Agency cannot guarantee whole-workbase read access from this customized OpenCode config.",
44
+ remediation:
45
+ "Back up and remove the customized file, run 'agency integration sync', then move any retained custom settings to OpenCode's global config.",
46
+ }
47
+ }
48
+ return {
49
+ diagnostic:
50
+ "Agency OpenCode launches cannot load current whole-workbase access.",
51
+ remediation:
52
+ "Run 'agency integration sync' to install whole-workbase OpenCode access.",
53
+ }
54
+ }
55
+
56
+ return state === "missing" || state === "drifted"
57
+ ? {
58
+ diagnostic: "Managed workbase instructions need synchronization.",
59
+ remediation:
60
+ "Run 'agency integration sync' to restore managed instructions.",
61
+ }
62
+ : {
63
+ diagnostic:
64
+ state === "managed"
65
+ ? "Managed workbase instructions are current."
66
+ : "Customized workbase instructions are preserved.",
67
+ remediation: null,
68
+ }
69
+ }
70
+
71
+ const fileStatus = (
72
+ name: IntegrationFileStatus["name"],
73
+ path: string,
74
+ state: IntegrationFileState,
75
+ ): IntegrationFileStatus => ({ name, path, state, ...describe(name, state) })
76
+
26
77
  const classify = (
27
78
  name: IntegrationFileStatus["name"],
28
79
  path: string,
29
80
  content: string,
30
81
  managed: string,
31
82
  canUpdate: (content: string) => boolean,
32
- ): IntegrationFileStatus => ({
33
- name,
34
- path,
35
- state:
83
+ ): IntegrationFileStatus =>
84
+ fileStatus(
85
+ name,
86
+ path,
36
87
  content === managed
37
88
  ? "managed"
38
89
  : canUpdate(content)
39
90
  ? "drifted"
40
91
  : "customized",
41
- })
92
+ )
42
93
 
43
94
  const inspect = (root: string) =>
44
95
  Effect.gen(function* () {
@@ -51,7 +102,7 @@ const inspect = (root: string) =>
51
102
 
52
103
  files.push(
53
104
  (yield* fs.readSymlinkTarget(agentsPath)) !== null
54
- ? { name: "agents", path: agentsPath, state: "customized" }
105
+ ? fileStatus("agents", agentsPath, "customized")
55
106
  : (yield* fs.exists(agentsPath))
56
107
  ? classify(
57
108
  "agents",
@@ -60,15 +111,16 @@ const inspect = (root: string) =>
60
111
  managedWorkbaseAgents,
61
112
  canUpdateManagedWorkbaseAgents,
62
113
  )
63
- : { name: "agents", path: agentsPath, state: "missing" },
114
+ : fileStatus("agents", agentsPath, "missing"),
64
115
  )
65
116
 
66
117
  if ((yield* fs.readSymlinkTarget(opencodePath)) !== null) {
67
- files.push({
68
- name: "opencode",
69
- path: opencodePath,
70
- state: "customized",
71
- })
118
+ files.push(fileStatus("opencode", opencodePath, "customized"))
119
+ } else if (
120
+ (yield* fs.readSymlinkTarget(opencodeJsonPath)) !== null ||
121
+ (yield* fs.exists(opencodeJsonPath))
122
+ ) {
123
+ files.push(fileStatus("opencode", opencodeJsonPath, "customized"))
72
124
  } else if (yield* fs.exists(opencodePath)) {
73
125
  files.push(
74
126
  classify(
@@ -79,14 +131,8 @@ const inspect = (root: string) =>
79
131
  canUpdateManagedWorkbaseOpencode,
80
132
  ),
81
133
  )
82
- } else if (yield* fs.exists(opencodeJsonPath)) {
83
- files.push({
84
- name: "opencode",
85
- path: opencodeJsonPath,
86
- state: "customized",
87
- })
88
134
  } else {
89
- files.push({ name: "opencode", path: opencodePath, state: "missing" })
135
+ files.push(fileStatus("opencode", opencodePath, "missing"))
90
136
  }
91
137
 
92
138
  return files
@@ -123,8 +169,11 @@ export class IntegrationService extends Effect.Service<IntegrationService>()(
123
169
  }
124
170
  }
125
171
  files.push({
126
- ...status,
127
- state: changed ? "managed" : status.state,
172
+ ...fileStatus(
173
+ status.name,
174
+ status.path,
175
+ changed ? "managed" : status.state,
176
+ ),
128
177
  changed,
129
178
  })
130
179
  }
@@ -174,7 +174,7 @@ export class PhaseService extends Effect.Service<PhaseService>()(
174
174
  })
175
175
  }
176
176
  for (const alias of aliases) {
177
- if (!(yield* fs.exists(join(root, "repos", alias)))) {
177
+ if (!(yield* workbase.hasRepositoryAlias(alias, root))) {
178
178
  return yield* new PhaseError({
179
179
  message: `Unknown repository alias '${alias}'`,
180
180
  })
@@ -187,6 +187,53 @@ describe("ReadinessService", () => {
187
187
  })
188
188
  })
189
189
 
190
+ test("returns ready target IDs and guards structural work targets", async () => {
191
+ const root = await createWorkbase()
192
+ roots.push(root)
193
+
194
+ const readyIds = await service((readiness) =>
195
+ readiness.getReadyWorkTargetIds(root),
196
+ )
197
+ expect([...readyIds].sort()).toEqual([
198
+ "epic:delivery",
199
+ "execution-unit:phase/ship/implement",
200
+ "phase:ship/implement",
201
+ "task:ship",
202
+ ])
203
+ await service((readiness) =>
204
+ readiness.guardWorkTarget("phase:ship/implement", root),
205
+ )
206
+
207
+ const blocked = await service((readiness) =>
208
+ Effect.either(readiness.guardWorkTarget("phase:ship/verify", root)),
209
+ )
210
+ expect(blocked).toMatchObject({
211
+ _tag: "Left",
212
+ left: {
213
+ _tag: "ExecutionGuardError",
214
+ action: "work",
215
+ target: "phase:ship/verify",
216
+ status: "open",
217
+ blockedBy: ["phase:ship/implement"],
218
+ },
219
+ })
220
+
221
+ const missing = await service((readiness) =>
222
+ Effect.either(readiness.guardWorkTarget("task:missing", root)),
223
+ )
224
+ expect(missing).toMatchObject({
225
+ _tag: "Left",
226
+ left: {
227
+ _tag: "ExecutionGuardError",
228
+ target: "task:missing",
229
+ blockers: [],
230
+ },
231
+ })
232
+ await service((readiness) =>
233
+ readiness.guardWorkTarget("task:missing", root, true),
234
+ )
235
+ })
236
+
190
237
  test("uses the same readiness for work and PR guards", async () => {
191
238
  const root = await createWorkbase()
192
239
  roots.push(root)