@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.
- package/README.md +72 -16
- package/cli.ts +3 -0
- package/fixtures/protocol/skill-setup-commands.json +18 -0
- package/package.json +1 -1
- package/skills/agency/SKILL.md +30 -15
- package/skills/agency/references/commands.md +38 -17
- package/skills/agency/references/contracts.md +46 -19
- package/skills/agency/references/recipes.md +52 -12
- package/src/cli-parser.test.ts +7 -0
- package/src/cli-parser.ts +13 -2
- package/src/cli.test.ts +422 -3
- package/src/commands/doctor.test.ts +22 -0
- package/src/commands/init.test.ts +3 -2
- package/src/commands/integration.test.ts +21 -1
- package/src/commands/integration.ts +8 -4
- 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/commands/work.test.ts +74 -1
- package/src/commands/work.ts +26 -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 +32 -18
- package/src/services/EpicService.ts +1 -1
- package/src/services/GraphMutationService.ts +3 -3
- package/src/services/GraphService.ts +13 -4
- package/src/services/IntegrationService.test.ts +37 -12
- package/src/services/IntegrationService.ts +70 -21
- 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 +9 -1
- package/src/workbase/dependency-graph.test.ts +50 -0
- package/src/workbase/opencode-file.ts +3 -13
- package/src/workbase/schemas.test.ts +52 -0
- package/src/workbase/schemas.ts +19 -0
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
|
})
|
|
@@ -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 =
|
|
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:
|
|
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
|
|
299
|
-
|
|
300
|
-
|
|
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:
|
|
309
|
-
? `Repository '${repository.alias}' is
|
|
310
|
-
:
|
|
311
|
-
|
|
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:
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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*
|
|
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",
|
|
@@ -97,30 +97,55 @@ describe("IntegrationService", () => {
|
|
|
97
97
|
const config = JSON.parse(managedBody(managedWorkbaseOpencode))
|
|
98
98
|
|
|
99
99
|
expect(config.references).toEqual({
|
|
100
|
-
|
|
101
|
-
path: "
|
|
100
|
+
workbase: {
|
|
101
|
+
path: "..",
|
|
102
102
|
description:
|
|
103
|
-
"Agency
|
|
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]).
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
?
|
|
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
|
-
:
|
|
114
|
+
: fileStatus("agents", agentsPath, "missing"),
|
|
64
115
|
)
|
|
65
116
|
|
|
66
117
|
if ((yield* fs.readSymlinkTarget(opencodePath)) !== null) {
|
|
67
|
-
files.push(
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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(
|
|
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
|
-
...
|
|
127
|
-
|
|
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*
|
|
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)
|