@markjaquith/agency 2.28.1 → 2.30.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.
Files changed (42) hide show
  1. package/README.md +63 -12
  2. package/cli.ts +15 -0
  3. package/fixtures/protocol/skill-setup-commands.json +18 -0
  4. package/package.json +1 -1
  5. package/skills/agency/SKILL.md +25 -15
  6. package/skills/agency/references/commands.md +34 -17
  7. package/skills/agency/references/contracts.md +46 -19
  8. package/skills/agency/references/recipes.md +50 -12
  9. package/src/cli-parser.test.ts +9 -0
  10. package/src/cli-parser.ts +20 -3
  11. package/src/cli.test.ts +205 -3
  12. package/src/commands/pr.test.ts +20 -1
  13. package/src/commands/repo.test.ts +66 -1
  14. package/src/commands/repo.ts +35 -8
  15. package/src/commands/status.test.ts +22 -0
  16. package/src/commands/status.ts +1 -0
  17. package/src/commands/sync.ts +5 -3
  18. package/src/commands/workbase.ts +2 -1
  19. package/src/graph-schema.test.ts +52 -4
  20. package/src/protocol.test.ts +41 -8
  21. package/src/readiness.test.ts +75 -17
  22. package/src/services/DoctorService.ts +22 -13
  23. package/src/services/EpicService.ts +1 -1
  24. package/src/services/GraphMutationService.ts +3 -3
  25. package/src/services/GraphService.ts +13 -4
  26. package/src/services/PhaseService.ts +1 -1
  27. package/src/services/ReadinessService.test.ts +47 -0
  28. package/src/services/RepositoryService.test.ts +299 -5
  29. package/src/services/RepositoryService.ts +725 -98
  30. package/src/services/SyncService.test.ts +36 -0
  31. package/src/services/SyncService.ts +20 -1
  32. package/src/services/TaskService.ts +1 -1
  33. package/src/services/WorkbaseService.test.ts +32 -0
  34. package/src/services/WorkbaseService.ts +22 -14
  35. package/src/services/WorktreeLock.test.ts +122 -0
  36. package/src/services/WorktreeService.test.ts +17 -2
  37. package/src/services/WorktreeService.ts +3 -3
  38. package/src/utils/process.test.ts +4 -3
  39. package/src/workbase/AGENTS.md +5 -0
  40. package/src/workbase/dependency-graph.test.ts +50 -0
  41. package/src/workbase/schemas.test.ts +52 -0
  42. package/src/workbase/schemas.ts +19 -0
@@ -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",
@@ -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
  })
@@ -295,31 +295,40 @@ export class DoctorService extends Effect.Service<DoctorService>()(
295
295
  })
296
296
  }
297
297
  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
298
+ const missing = repository.states.includes("missing")
299
+ const repositoryValid =
300
+ !missing && !repository.states.includes("invalid")
303
301
  add({
304
302
  id: `repository.${repository.alias}.valid`,
305
303
  category: "repository",
306
304
  level: "error",
307
305
  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.`,
306
+ message: missing
307
+ ? `Repository '${repository.alias}' is declared but not materialized`
308
+ : repositoryValid
309
+ ? `Repository '${repository.alias}' is a valid Git repository`
310
+ : `Repository '${repository.alias}' is not a valid Git repository`,
311
+ remediation: missing
312
+ ? "Run 'agency repo setup --apply'."
313
+ : `Run 'agency repo verify ${repository.alias}', then repair or relink the repository.`,
312
314
  })
313
315
  add({
314
316
  id: `repository.${repository.alias}.remote`,
315
317
  category: "repository",
316
318
  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`,
319
+ status:
320
+ repository.declaredRemote &&
321
+ !repository.states.includes("remote-drifted")
322
+ ? "pass"
323
+ : "fail",
324
+ message: repository.states.includes("remote-drifted")
325
+ ? `Repository '${repository.alias}' origin differs from ${repository.declaredRemote}`
326
+ : repository.declaredRemote
327
+ ? `Repository '${repository.alias}' portable origin is ${repository.declaredRemote}`
328
+ : `Repository '${repository.alias}' has no portable origin declaration`,
321
329
  remediation: `Run 'agency repo remote ${repository.alias} <url>' to configure origin.`,
322
330
  })
331
+ if (!repositoryValid) continue
323
332
 
324
333
  for (const ref of [...(refs.get(repository.alias) ?? [])].sort()) {
325
334
  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",
@@ -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)