@markjaquith/agency 2.29.0 → 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.
- package/README.md +60 -10
- package/cli.ts +3 -0
- package/fixtures/protocol/skill-setup-commands.json +18 -0
- package/package.json +1 -1
- package/skills/agency/SKILL.md +25 -15
- package/skills/agency/references/commands.md +32 -16
- package/skills/agency/references/contracts.md +46 -19
- package/skills/agency/references/recipes.md +50 -12
- package/src/cli-parser.test.ts +7 -0
- package/src/cli-parser.ts +13 -2
- package/src/cli.test.ts +205 -3
- package/src/commands/pr.test.ts +20 -1
- package/src/commands/repo.test.ts +66 -1
- package/src/commands/repo.ts +35 -8
- package/src/commands/status.test.ts +22 -0
- package/src/commands/status.ts +1 -0
- package/src/commands/sync.ts +5 -3
- package/src/graph-schema.test.ts +52 -4
- package/src/protocol.test.ts +41 -8
- package/src/readiness.test.ts +75 -17
- package/src/services/DoctorService.ts +22 -13
- package/src/services/EpicService.ts +1 -1
- package/src/services/GraphMutationService.ts +3 -3
- package/src/services/GraphService.ts +13 -4
- package/src/services/PhaseService.ts +1 -1
- package/src/services/ReadinessService.test.ts +47 -0
- package/src/services/RepositoryService.test.ts +299 -5
- package/src/services/RepositoryService.ts +725 -98
- package/src/services/SyncService.test.ts +36 -0
- package/src/services/SyncService.ts +20 -1
- package/src/services/TaskService.ts +1 -1
- package/src/services/WorkbaseService.test.ts +32 -0
- package/src/services/WorkbaseService.ts +22 -14
- package/src/services/WorktreeLock.test.ts +122 -0
- package/src/services/WorktreeService.test.ts +17 -2
- package/src/services/WorktreeService.ts +3 -3
- package/src/utils/process.test.ts +4 -3
- package/src/workbase/AGENTS.md +5 -0
- package/src/workbase/dependency-graph.test.ts +50 -0
- package/src/workbase/schemas.test.ts +52 -0
- package/src/workbase/schemas.ts +19 -0
package/src/protocol.test.ts
CHANGED
|
@@ -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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
}
|
|
77
|
-
)
|
|
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",
|
package/src/readiness.test.ts
CHANGED
|
@@ -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("
|
|
28
|
-
expect(
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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("
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
299
|
-
|
|
300
|
-
|
|
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:
|
|
309
|
-
? `Repository '${repository.alias}' is
|
|
310
|
-
:
|
|
311
|
-
|
|
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:
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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*
|
|
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*
|
|
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*
|
|
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*
|
|
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
|
|
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.
|
|
211
|
+
.filter((item) => !item.name.startsWith(".agency-"))
|
|
205
212
|
.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
206
|
-
repositoryRecords.
|
|
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*
|
|
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)
|