@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
|
@@ -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
|
})
|
package/src/commands/repo.ts
CHANGED
|
@@ -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 =
|
|
62
|
-
|
|
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.
|
|
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.
|
|
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
|
|
182
|
-
unlink <alias> Remove
|
|
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
|
-
|
|
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 () => {
|
package/src/commands/status.ts
CHANGED
|
@@ -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}`)
|
package/src/commands/sync.ts
CHANGED
|
@@ -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
|
|
26
|
-
|
|
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,
|
|
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({
|
|
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 () => {
|
package/src/commands/work.ts
CHANGED
|
@@ -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
|
package/src/graph-schema.test.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
})
|
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",
|