@markjaquith/agency 2.62.0 → 2.64.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 +11 -6
- package/cli-main.ts +2 -1
- package/package.json +1 -1
- package/src/cli-parser.test.ts +4 -1
- package/src/cli-parser.ts +4 -4
- package/src/commands/act.test.ts +95 -0
- package/src/commands/act.ts +89 -8
- package/src/commands/context.test.ts +118 -1
- package/src/commands/context.ts +3 -1
- package/src/services/ContextService.ts +125 -39
- package/src/services/IntegrationService.test.ts +70 -21
- package/src/workbase/opencode-plugin-file.ts +6 -1
package/README.md
CHANGED
|
@@ -517,12 +517,13 @@ pull request, dropping, reopening, or archiving. Cancelling either chooser makes
|
|
|
517
517
|
no changes, and the command refreshes the graph before dispatch so a stale
|
|
518
518
|
selection cannot act on changed work.
|
|
519
519
|
|
|
520
|
-
Use `--epic <id>`, `--task <id>`,
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
520
|
+
Use an existing directory, a positional task ID, `--epic <id>`, `--task <id>`,
|
|
521
|
+
or `--task <id> --phase <id>` to skip work-item selection. For example,
|
|
522
|
+
`agency act .` selects the current task or phase. `--dry-run` still prompts for
|
|
523
|
+
an action but prints the exact Agency command instead of executing it. `--json`
|
|
524
|
+
never prompts or executes; it returns matching targets, status and readiness
|
|
525
|
+
details, document revisions, and each available action's command argv. With no
|
|
526
|
+
selector, JSON includes every active work item.
|
|
526
527
|
|
|
527
528
|
`--auto` is included in generated or executed work commands, and `--draft` is
|
|
528
529
|
included in generated or executed pull request commands.
|
|
@@ -535,6 +536,10 @@ a discovery catalog of all epics, tasks, and phases, including frontmatter,
|
|
|
535
536
|
paths, and document revisions. Elsewhere it returns context for an epic, task,
|
|
536
537
|
or phase. The target defaults to the current directory; entity directories,
|
|
537
538
|
document paths, checkout descendants, and bare task IDs are accepted.
|
|
539
|
+
Archived entity paths and selectors are also accepted. Archived context is
|
|
540
|
+
explicitly marked with `target.archived: true` and never grants writable
|
|
541
|
+
document, repository checkout, or reference authority; restore the item before
|
|
542
|
+
attempting mutation or execution.
|
|
538
543
|
|
|
539
544
|
Root discovery is compact by default and includes a hint to run `agency context
|
|
540
545
|
. --full --json` when document prose is needed. Entity context remains complete
|
package/cli-main.ts
CHANGED
|
@@ -177,10 +177,11 @@ const VERSION = packageJson.version
|
|
|
177
177
|
// Define commands
|
|
178
178
|
const commands: Record<string, Command> = {
|
|
179
179
|
act: {
|
|
180
|
-
run: async (
|
|
180
|
+
run: async (args: string[], options: Record<string, any>) => {
|
|
181
181
|
if (options.help) return console.log(actHelp)
|
|
182
182
|
await runCommand(
|
|
183
183
|
act({
|
|
184
|
+
directory: args[0],
|
|
184
185
|
auto: options.auto,
|
|
185
186
|
draft: options.draft,
|
|
186
187
|
dryRun: options["dry-run"],
|
package/package.json
CHANGED
package/src/cli-parser.test.ts
CHANGED
|
@@ -31,7 +31,10 @@ describe("strict CLI parsing", () => {
|
|
|
31
31
|
},
|
|
32
32
|
})
|
|
33
33
|
expect(parseCli(["act", "--json"]).values.json).toBe(true)
|
|
34
|
-
|
|
34
|
+
expect(parseCli(["act", "task-id"]).args).toEqual(["task-id"])
|
|
35
|
+
expect(parseCli(["act", "."]).args).toEqual(["."])
|
|
36
|
+
expectUsageError(["act", "one", "two"], "agency act")
|
|
37
|
+
expectUsageError(["act", ".", "--task", "example"], "agency act")
|
|
35
38
|
expectUsageError(["act", "--phase", "build"], "agency act")
|
|
36
39
|
expectUsageError(
|
|
37
40
|
["act", "--epic", "roadmap", "--task", "example"],
|
package/src/cli-parser.ts
CHANGED
|
@@ -148,7 +148,7 @@ const commands = {
|
|
|
148
148
|
},
|
|
149
149
|
act: {
|
|
150
150
|
usage:
|
|
151
|
-
"agency act [--epic <id> | --task <id> [--phase <id>]] [--dry-run | --json] [--auto] [--draft]",
|
|
151
|
+
"agency act [<directory-or-task-id> | --epic <id> | --task <id> [--phase <id>]] [--dry-run | --json] [--auto] [--draft]",
|
|
152
152
|
options: {
|
|
153
153
|
...outputOptions,
|
|
154
154
|
...entitySelectorOptions,
|
|
@@ -158,9 +158,9 @@ const commands = {
|
|
|
158
158
|
},
|
|
159
159
|
command: {
|
|
160
160
|
usage:
|
|
161
|
-
"agency act [--epic <id> | --task <id> [--phase <id>]] [--dry-run | --json] [--auto] [--draft]",
|
|
161
|
+
"agency act [<directory-or-task-id> | --epic <id> | --task <id> [--phase <id>]] [--dry-run | --json] [--auto] [--draft]",
|
|
162
162
|
minArgs: 0,
|
|
163
|
-
maxArgs:
|
|
163
|
+
maxArgs: 1,
|
|
164
164
|
options: ["epic", "task", "phase", "dry-run", "json", "auto", "draft"],
|
|
165
165
|
conflicts: [
|
|
166
166
|
["dry-run", "json"],
|
|
@@ -1198,7 +1198,7 @@ function applyEntitySelectors(
|
|
|
1198
1198
|
spec.usage,
|
|
1199
1199
|
)
|
|
1200
1200
|
}
|
|
1201
|
-
if (
|
|
1201
|
+
if (["act", "work", "context"].includes(commandName)) {
|
|
1202
1202
|
if (
|
|
1203
1203
|
positionals.length >
|
|
1204
1204
|
(commandName === "work" && positionals[0] === "prepare" ? 1 : 0)
|
package/src/commands/act.test.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
|
2
2
|
import { Effect } from "effect"
|
|
3
|
+
import { mkdir } from "node:fs/promises"
|
|
3
4
|
import { join } from "node:path"
|
|
4
5
|
import {
|
|
5
6
|
captureLogs,
|
|
@@ -118,6 +119,100 @@ describe("act command", () => {
|
|
|
118
119
|
expect(await readTaskStatus("example")).toBe("open")
|
|
119
120
|
})
|
|
120
121
|
|
|
122
|
+
test("resolves a positional path and task ID without prompting for an item", async () => {
|
|
123
|
+
await createTask("example")
|
|
124
|
+
await mkdir(join(root, "tasks/example/code/agency"), { recursive: true })
|
|
125
|
+
for (const options of [
|
|
126
|
+
{ cwd: join(root, "tasks/example"), directory: "." },
|
|
127
|
+
{ cwd: join(root, "tasks/example"), directory: "code/agency" },
|
|
128
|
+
{ cwd: root, directory: "example" },
|
|
129
|
+
]) {
|
|
130
|
+
const prompts: string[] = []
|
|
131
|
+
await runTestEffect(
|
|
132
|
+
act(
|
|
133
|
+
{ ...options, inputAllowed: true, dryRun: true, silent: true },
|
|
134
|
+
scriptedInteraction(["drop"], (prompt) => prompts.push(prompt)),
|
|
135
|
+
),
|
|
136
|
+
)
|
|
137
|
+
expect(prompts).toEqual(["Act on task example"])
|
|
138
|
+
}
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
test("resolves a positional phase path without prompting for an item", async () => {
|
|
142
|
+
await runTestEffect(
|
|
143
|
+
task({
|
|
144
|
+
subcommand: "create",
|
|
145
|
+
args: ["multi"],
|
|
146
|
+
multiPhase: true,
|
|
147
|
+
cwd: root,
|
|
148
|
+
silent: true,
|
|
149
|
+
}),
|
|
150
|
+
)
|
|
151
|
+
await runTestEffect(
|
|
152
|
+
phase({
|
|
153
|
+
subcommand: "create",
|
|
154
|
+
args: ["multi", "build"],
|
|
155
|
+
repo: "agency",
|
|
156
|
+
branch: "task/multi-build",
|
|
157
|
+
base: "main",
|
|
158
|
+
cwd: root,
|
|
159
|
+
silent: true,
|
|
160
|
+
}),
|
|
161
|
+
)
|
|
162
|
+
const prompts: string[] = []
|
|
163
|
+
await runTestEffect(
|
|
164
|
+
act(
|
|
165
|
+
{
|
|
166
|
+
cwd: join(root, "tasks/multi/phases/build"),
|
|
167
|
+
directory: ".",
|
|
168
|
+
inputAllowed: true,
|
|
169
|
+
dryRun: true,
|
|
170
|
+
silent: true,
|
|
171
|
+
},
|
|
172
|
+
scriptedInteraction(["drop"], (prompt) => prompts.push(prompt)),
|
|
173
|
+
),
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
expect(prompts).toEqual(["Act on phase multi/build"])
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
test("orders owned items hierarchically and omits items without actions", async () => {
|
|
180
|
+
await runTestEffect(
|
|
181
|
+
task({
|
|
182
|
+
subcommand: "create",
|
|
183
|
+
args: ["multi"],
|
|
184
|
+
multiPhase: true,
|
|
185
|
+
cwd: root,
|
|
186
|
+
silent: true,
|
|
187
|
+
}),
|
|
188
|
+
)
|
|
189
|
+
await runTestEffect(
|
|
190
|
+
phase({
|
|
191
|
+
subcommand: "create",
|
|
192
|
+
args: ["multi", "build"],
|
|
193
|
+
repo: "agency",
|
|
194
|
+
branch: "task/multi-build",
|
|
195
|
+
base: "main",
|
|
196
|
+
cwd: root,
|
|
197
|
+
silent: true,
|
|
198
|
+
}),
|
|
199
|
+
)
|
|
200
|
+
let choices: readonly Choice<unknown>[] = []
|
|
201
|
+
await runTestEffect(
|
|
202
|
+
act(
|
|
203
|
+
{ cwd: root, inputAllowed: true },
|
|
204
|
+
scriptedInteraction([null], (_prompt, offered) => {
|
|
205
|
+
choices = offered
|
|
206
|
+
}),
|
|
207
|
+
),
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
expect(choices.map((choice) => [choice.value, choice.depth])).toEqual([
|
|
211
|
+
["task:multi", 0],
|
|
212
|
+
["phase:multi/build", 1],
|
|
213
|
+
])
|
|
214
|
+
})
|
|
215
|
+
|
|
121
216
|
test("returns structured actions and argv for agents", async () => {
|
|
122
217
|
await createTask("example")
|
|
123
218
|
const logs = await captureLogs(() =>
|
package/src/commands/act.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { Effect } from "effect"
|
|
2
|
+
import { isAbsolute, relative, resolve, sep } from "node:path"
|
|
2
3
|
import type { GraphNode } from "../graph-schema"
|
|
3
4
|
import { isTerminalStatus } from "../readiness"
|
|
5
|
+
import { FileSystemService } from "../services/FileSystemService"
|
|
4
6
|
import { GraphService } from "../services/GraphService"
|
|
5
7
|
import { WorkbaseService } from "../services/WorkbaseService"
|
|
6
8
|
import type { BaseCommandOptions } from "../utils/command"
|
|
@@ -29,6 +31,7 @@ export interface ActInteraction {
|
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
interface ActOptions extends BaseCommandOptions {
|
|
34
|
+
readonly directory?: string
|
|
32
35
|
readonly auto?: boolean
|
|
33
36
|
readonly draft?: boolean
|
|
34
37
|
readonly dryRun?: boolean
|
|
@@ -49,13 +52,49 @@ const entityDescription = (node: EntityNode) =>
|
|
|
49
52
|
? ` - ${node.data.description}`
|
|
50
53
|
: ""
|
|
51
54
|
|
|
55
|
+
const orderedEntities = (
|
|
56
|
+
nodes: readonly EntityNode[],
|
|
57
|
+
edges: readonly {
|
|
58
|
+
readonly kind: string
|
|
59
|
+
readonly from: string
|
|
60
|
+
readonly to: string
|
|
61
|
+
}[],
|
|
62
|
+
) => {
|
|
63
|
+
const byId = new Map(nodes.map((node) => [node.id, node]))
|
|
64
|
+
const children = new Map<string, EntityNode[]>()
|
|
65
|
+
const owned = new Set<string>()
|
|
66
|
+
for (const edge of edges) {
|
|
67
|
+
if (edge.kind !== "owns") continue
|
|
68
|
+
const parent = byId.get(edge.from)
|
|
69
|
+
const child = byId.get(edge.to)
|
|
70
|
+
if (!parent || !child) continue
|
|
71
|
+
children.set(edge.from, [...(children.get(edge.from) ?? []), child])
|
|
72
|
+
owned.add(child.id)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const ordered: { readonly node: EntityNode; readonly depth: number }[] = []
|
|
76
|
+
const append = (node: EntityNode, depth: number) => {
|
|
77
|
+
ordered.push({ node, depth })
|
|
78
|
+
for (const child of children.get(node.id) ?? []) append(child, depth + 1)
|
|
79
|
+
}
|
|
80
|
+
for (const node of nodes) {
|
|
81
|
+
if (!owned.has(node.id)) append(node, 0)
|
|
82
|
+
}
|
|
83
|
+
return ordered
|
|
84
|
+
}
|
|
85
|
+
|
|
52
86
|
const entityChoices = (
|
|
53
87
|
nodes: readonly EntityNode[],
|
|
88
|
+
edges: readonly {
|
|
89
|
+
readonly kind: string
|
|
90
|
+
readonly from: string
|
|
91
|
+
readonly to: string
|
|
92
|
+
}[],
|
|
54
93
|
): readonly Choice<string>[] =>
|
|
55
|
-
nodes.map((node, index) => ({
|
|
94
|
+
orderedEntities(nodes, edges).map(({ node, depth }, index) => ({
|
|
56
95
|
key: String(index),
|
|
57
96
|
label: `[${node.status}] ${node.kind} ${node.key}${entityDescription(node)}`,
|
|
58
|
-
depth
|
|
97
|
+
depth,
|
|
59
98
|
segments: [
|
|
60
99
|
{ text: `[${node.status}] `, color: macchiato.overlay1 },
|
|
61
100
|
{ text: `${node.kind} `, color: macchiato.sapphire },
|
|
@@ -229,6 +268,26 @@ const selectedEntityKey = (options: ActOptions) =>
|
|
|
229
268
|
? `task:${options.taskId}`
|
|
230
269
|
: undefined
|
|
231
270
|
|
|
271
|
+
const pathEntityKey = (
|
|
272
|
+
directory: string | undefined,
|
|
273
|
+
isDirectory: boolean,
|
|
274
|
+
root: string,
|
|
275
|
+
startPath: string,
|
|
276
|
+
) => {
|
|
277
|
+
if (!directory) return undefined
|
|
278
|
+
if (!isDirectory) return `task:${directory}`
|
|
279
|
+
const path = relative(root, startPath)
|
|
280
|
+
const parts =
|
|
281
|
+
!path || isAbsolute(path) || path.startsWith(`..${sep}`)
|
|
282
|
+
? []
|
|
283
|
+
: path.split(sep)
|
|
284
|
+
if (parts[0] === "epics" && parts[1]) return `epic:${parts[1]}`
|
|
285
|
+
if (parts[0] !== "tasks" || !parts[1]) return undefined
|
|
286
|
+
return parts[2] === "phases" && parts[3]
|
|
287
|
+
? `phase:${parts[1]}/${parts[3]}`
|
|
288
|
+
: `task:${parts[1]}`
|
|
289
|
+
}
|
|
290
|
+
|
|
232
291
|
const sameActions = (
|
|
233
292
|
left: readonly Choice<ActAction>[],
|
|
234
293
|
right: readonly Choice<ActAction>[],
|
|
@@ -250,11 +309,19 @@ export const act = (
|
|
|
250
309
|
)
|
|
251
310
|
}
|
|
252
311
|
const cwd = options.cwd ?? process.cwd()
|
|
312
|
+
const fs = yield* FileSystemService
|
|
253
313
|
const workbase = yield* WorkbaseService
|
|
254
314
|
const graphs = yield* GraphService
|
|
255
315
|
const { log } = createLoggers(options)
|
|
256
|
-
const
|
|
257
|
-
|
|
316
|
+
const directoryPath = options.directory
|
|
317
|
+
? resolve(cwd, options.directory)
|
|
318
|
+
: undefined
|
|
319
|
+
const isDirectory = directoryPath
|
|
320
|
+
? yield* fs.isDirectory(directoryPath)
|
|
321
|
+
: false
|
|
322
|
+
const startPath = isDirectory && directoryPath ? directoryPath : cwd
|
|
323
|
+
const { root, config } = yield* workbase.loadConfig(startPath)
|
|
324
|
+
const graph = yield* graphs.get({ cwd: root })
|
|
258
325
|
const nodes = graph.nodes.filter(
|
|
259
326
|
(node): node is EntityNode =>
|
|
260
327
|
node.kind === "epic" || node.kind === "task" || node.kind === "phase",
|
|
@@ -269,7 +336,9 @@ export const act = (
|
|
|
269
336
|
)
|
|
270
337
|
}
|
|
271
338
|
|
|
272
|
-
const requestedKey =
|
|
339
|
+
const requestedKey =
|
|
340
|
+
selectedEntityKey(options) ??
|
|
341
|
+
pathEntityKey(options.directory, isDirectory, root, startPath)
|
|
273
342
|
const matchingNodes = requestedKey
|
|
274
343
|
? nodes.filter((node) => entityKey(node) === requestedKey)
|
|
275
344
|
: nodes
|
|
@@ -293,11 +362,21 @@ export const act = (
|
|
|
293
362
|
return
|
|
294
363
|
}
|
|
295
364
|
|
|
365
|
+
const selectableNodes = nodes.filter(
|
|
366
|
+
(node) => actionChoices(node, graph.nodes).length > 0,
|
|
367
|
+
)
|
|
368
|
+
if (!requestedKey && selectableNodes.length === 0) {
|
|
369
|
+
return yield* Effect.fail(
|
|
370
|
+
new Error(
|
|
371
|
+
"No work items with available actions found in this workbase",
|
|
372
|
+
),
|
|
373
|
+
)
|
|
374
|
+
}
|
|
296
375
|
const selectedKey =
|
|
297
376
|
requestedKey ??
|
|
298
377
|
(yield* interaction.select(
|
|
299
378
|
"Act on",
|
|
300
|
-
entityChoices(
|
|
379
|
+
entityChoices(selectableNodes, graph.edges),
|
|
301
380
|
config.chooserCommand,
|
|
302
381
|
))
|
|
303
382
|
if (selectedKey === null) return
|
|
@@ -424,10 +503,12 @@ export const act = (
|
|
|
424
503
|
})
|
|
425
504
|
|
|
426
505
|
export const help = `
|
|
427
|
-
Usage: agency act [--epic <id> | --task <id> [--phase <id>]] [--dry-run | --json] [--auto] [--draft]
|
|
506
|
+
Usage: agency act [<directory-or-task-id> | --epic <id> | --task <id> [--phase <id>]] [--dry-run | --json] [--auto] [--draft]
|
|
428
507
|
|
|
429
508
|
Interactively choose an active work item and a state-aware lifecycle action.
|
|
430
|
-
|
|
509
|
+
An existing positional directory selects its containing epic, task, or phase;
|
|
510
|
+
otherwise the positional value is a task ID. Selectors skip work-item selection.
|
|
511
|
+
--dry-run prints the selected action's exact
|
|
431
512
|
Agency command without executing it. --json lists targets, available actions,
|
|
432
513
|
and command argv without prompting or executing.
|
|
433
514
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
|
2
|
-
import { mkdir, rm } from "node:fs/promises"
|
|
2
|
+
import { mkdir, rename, rm } from "node:fs/promises"
|
|
3
3
|
import { dirname, join } from "node:path"
|
|
4
4
|
import {
|
|
5
5
|
captureLogs,
|
|
@@ -342,6 +342,123 @@ status: dropped
|
|
|
342
342
|
expect(result.authority.writable.branch).toBe("foundations")
|
|
343
343
|
})
|
|
344
344
|
|
|
345
|
+
test("resolves archived tasks without granting mutation or execution authority", async () => {
|
|
346
|
+
await mkdir(join(root, "archive/tasks"), { recursive: true })
|
|
347
|
+
await rename(
|
|
348
|
+
join(root, "tasks/foundations"),
|
|
349
|
+
join(root, "archive/tasks/foundations"),
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
const result = await readContext(root, "foundations")
|
|
353
|
+
|
|
354
|
+
expect(result.target).toMatchObject({
|
|
355
|
+
kind: "task",
|
|
356
|
+
taskId: "foundations",
|
|
357
|
+
archived: true,
|
|
358
|
+
path: join(root, "archive/tasks/foundations/TASK.md"),
|
|
359
|
+
})
|
|
360
|
+
expect(result.documents.task.body).toContain("# Foundations")
|
|
361
|
+
expect(result.authority).toMatchObject({
|
|
362
|
+
mode: "orchestration",
|
|
363
|
+
writable: null,
|
|
364
|
+
references: [],
|
|
365
|
+
documents: { writable: [] },
|
|
366
|
+
})
|
|
367
|
+
expect(result.workspace).toMatchObject({
|
|
368
|
+
materialization: "absent",
|
|
369
|
+
writable: null,
|
|
370
|
+
references: [],
|
|
371
|
+
})
|
|
372
|
+
expect(result.graph.readiness).toMatchObject({
|
|
373
|
+
ready: false,
|
|
374
|
+
blocked: true,
|
|
375
|
+
})
|
|
376
|
+
expect(result.graph.readiness.blockers).toContainEqual({
|
|
377
|
+
kind: "status",
|
|
378
|
+
id: "foundations",
|
|
379
|
+
reason: "Target is archived; restore it before mutation or execution",
|
|
380
|
+
})
|
|
381
|
+
expect(result.pr).toMatchObject({
|
|
382
|
+
url: "https://github.com/example/agency/pull/1",
|
|
383
|
+
state: "open",
|
|
384
|
+
})
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
test("resolves archived phases with their active parent context", async () => {
|
|
388
|
+
await mkdir(join(root, "archive/tasks/agent-contract/phases"), {
|
|
389
|
+
recursive: true,
|
|
390
|
+
})
|
|
391
|
+
await rename(
|
|
392
|
+
join(root, "tasks/agent-contract/phases/context-command"),
|
|
393
|
+
join(root, "archive/tasks/agent-contract/phases/context-command"),
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
const result = await readContext(
|
|
397
|
+
root,
|
|
398
|
+
"archive/tasks/agent-contract/phases/context-command",
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
expect(result.target).toMatchObject({
|
|
402
|
+
kind: "phase",
|
|
403
|
+
taskId: "agent-contract",
|
|
404
|
+
phaseId: "context-command",
|
|
405
|
+
archived: true,
|
|
406
|
+
path: join(
|
|
407
|
+
root,
|
|
408
|
+
"archive/tasks/agent-contract/phases/context-command/PHASE.md",
|
|
409
|
+
),
|
|
410
|
+
})
|
|
411
|
+
expect(result.documents.task.body).toContain("Task prose.")
|
|
412
|
+
expect(result.documents.phase.body).toContain("Phase prose.")
|
|
413
|
+
expect(result.graph.parent).toEqual({ kind: "task", id: "agent-contract" })
|
|
414
|
+
expect(result.graph.readiness).toMatchObject({
|
|
415
|
+
ready: false,
|
|
416
|
+
blocked: true,
|
|
417
|
+
})
|
|
418
|
+
expect(result.authority).toMatchObject({
|
|
419
|
+
mode: "orchestration",
|
|
420
|
+
writable: null,
|
|
421
|
+
references: [],
|
|
422
|
+
documents: { writable: [] },
|
|
423
|
+
})
|
|
424
|
+
})
|
|
425
|
+
|
|
426
|
+
test("resolves archived epics with archived descendant graph context", async () => {
|
|
427
|
+
await mkdir(join(root, "archive/epics"), { recursive: true })
|
|
428
|
+
await mkdir(join(root, "archive/tasks"), { recursive: true })
|
|
429
|
+
await rename(
|
|
430
|
+
join(root, "epics/contract"),
|
|
431
|
+
join(root, "archive/epics/contract"),
|
|
432
|
+
)
|
|
433
|
+
for (const taskId of ["agent-contract", "foundations"]) {
|
|
434
|
+
await rename(
|
|
435
|
+
join(root, "tasks", taskId),
|
|
436
|
+
join(root, "archive/tasks", taskId),
|
|
437
|
+
)
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const result = await readContext(root, "epics/contract")
|
|
441
|
+
|
|
442
|
+
expect(result.target).toMatchObject({
|
|
443
|
+
kind: "epic",
|
|
444
|
+
epicId: "contract",
|
|
445
|
+
archived: true,
|
|
446
|
+
path: join(root, "archive/epics/contract/EPIC.md"),
|
|
447
|
+
})
|
|
448
|
+
expect(result.documents.epic.body).toContain("# Contract")
|
|
449
|
+
expect(result.graph.aggregate).toMatchObject({ total: 3, done: 2, open: 1 })
|
|
450
|
+
expect(result.graph.readiness).toMatchObject({
|
|
451
|
+
ready: false,
|
|
452
|
+
blocked: true,
|
|
453
|
+
})
|
|
454
|
+
expect(result.authority).toMatchObject({
|
|
455
|
+
mode: "orchestration",
|
|
456
|
+
writable: null,
|
|
457
|
+
references: [],
|
|
458
|
+
documents: { writable: [] },
|
|
459
|
+
})
|
|
460
|
+
})
|
|
461
|
+
|
|
345
462
|
test("computes orchestration readiness from runnable descendants", async () => {
|
|
346
463
|
await write(
|
|
347
464
|
root,
|
package/src/commands/context.ts
CHANGED
|
@@ -23,7 +23,9 @@ Usage: agency context [target] [options]
|
|
|
23
23
|
|
|
24
24
|
Return complete, read-only context for a workbase, epic, task, or phase. The
|
|
25
25
|
target defaults to the current directory and may be the workbase root, an entity
|
|
26
|
-
path, or a task ID.
|
|
26
|
+
path, or a task ID. Archived entities are inspectable but never receive mutation
|
|
27
|
+
or execution authority. Workbase context catalogs all active epics, tasks, and
|
|
28
|
+
phases.
|
|
27
29
|
|
|
28
30
|
Options:
|
|
29
31
|
--json Output a versioned machine result
|
|
@@ -20,6 +20,11 @@ import {
|
|
|
20
20
|
type TaskFrontmatter as TaskData,
|
|
21
21
|
type WorkStatus,
|
|
22
22
|
} from "../workbase/schemas"
|
|
23
|
+
import {
|
|
24
|
+
archivedEpicDirectory,
|
|
25
|
+
archivedPhaseDirectory,
|
|
26
|
+
archivedTaskDirectory,
|
|
27
|
+
} from "../workbase/archive"
|
|
23
28
|
|
|
24
29
|
class ContextError extends Data.TaggedError("ContextError")<{
|
|
25
30
|
readonly message: string
|
|
@@ -36,6 +41,7 @@ interface Document<T> {
|
|
|
36
41
|
|
|
37
42
|
interface Target {
|
|
38
43
|
readonly kind: "epic" | "task" | "phase"
|
|
44
|
+
readonly archived: boolean
|
|
39
45
|
readonly epicId?: string
|
|
40
46
|
readonly taskId?: string
|
|
41
47
|
readonly phaseId?: string
|
|
@@ -240,15 +246,45 @@ export class ContextService extends Effect.Service<ContextService>()(
|
|
|
240
246
|
if (!candidateExists && !suppliedTarget.includes(sep)) {
|
|
241
247
|
return {
|
|
242
248
|
kind: "task",
|
|
249
|
+
archived: false,
|
|
243
250
|
taskId: suppliedTarget,
|
|
244
251
|
path: join(root, "tasks", suppliedTarget, "TASK.md"),
|
|
245
252
|
}
|
|
246
253
|
}
|
|
247
254
|
if (!isWithin(root, candidate)) return null
|
|
248
255
|
const parts = relative(root, candidate).split(sep)
|
|
256
|
+
if (parts[0] === "archive" && parts[1] === "epics" && parts[2]) {
|
|
257
|
+
return {
|
|
258
|
+
kind: "epic",
|
|
259
|
+
archived: true,
|
|
260
|
+
epicId: parts[2],
|
|
261
|
+
path: join(archivedEpicDirectory(root, parts[2]), "EPIC.md"),
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
if (parts[0] === "archive" && parts[1] === "tasks" && parts[2]) {
|
|
265
|
+
if (parts[3] === "phases" && parts[4]) {
|
|
266
|
+
return {
|
|
267
|
+
kind: "phase",
|
|
268
|
+
archived: true,
|
|
269
|
+
taskId: parts[2],
|
|
270
|
+
phaseId: parts[4],
|
|
271
|
+
path: join(
|
|
272
|
+
archivedPhaseDirectory(root, parts[2], parts[4]),
|
|
273
|
+
"PHASE.md",
|
|
274
|
+
),
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return {
|
|
278
|
+
kind: "task",
|
|
279
|
+
archived: true,
|
|
280
|
+
taskId: parts[2],
|
|
281
|
+
path: join(archivedTaskDirectory(root, parts[2]), "TASK.md"),
|
|
282
|
+
}
|
|
283
|
+
}
|
|
249
284
|
if (parts[0] === "epics" && parts[1]) {
|
|
250
285
|
return {
|
|
251
286
|
kind: "epic",
|
|
287
|
+
archived: false,
|
|
252
288
|
epicId: parts[1],
|
|
253
289
|
path: join(root, "epics", parts[1], "EPIC.md"),
|
|
254
290
|
}
|
|
@@ -257,6 +293,7 @@ export class ContextService extends Effect.Service<ContextService>()(
|
|
|
257
293
|
if (parts[2] === "phases" && parts[3]) {
|
|
258
294
|
return {
|
|
259
295
|
kind: "phase",
|
|
296
|
+
archived: false,
|
|
260
297
|
taskId: parts[1],
|
|
261
298
|
phaseId: parts[3],
|
|
262
299
|
path: join(
|
|
@@ -271,6 +308,7 @@ export class ContextService extends Effect.Service<ContextService>()(
|
|
|
271
308
|
}
|
|
272
309
|
return {
|
|
273
310
|
kind: "task",
|
|
311
|
+
archived: false,
|
|
274
312
|
taskId: parts[1],
|
|
275
313
|
path: join(root, "tasks", parts[1], "TASK.md"),
|
|
276
314
|
}
|
|
@@ -278,13 +316,32 @@ export class ContextService extends Effect.Service<ContextService>()(
|
|
|
278
316
|
return null
|
|
279
317
|
}
|
|
280
318
|
|
|
281
|
-
const
|
|
282
|
-
if (!
|
|
319
|
+
const inferredTarget = inferTarget()
|
|
320
|
+
if (!inferredTarget) {
|
|
283
321
|
return yield* new ContextError({
|
|
284
322
|
target: suppliedTarget,
|
|
285
323
|
message: `Cannot infer an Agency target from ${candidate}`,
|
|
286
324
|
})
|
|
287
325
|
}
|
|
326
|
+
let target = inferredTarget
|
|
327
|
+
if (!target.archived && !(yield* fs.exists(target.path))) {
|
|
328
|
+
const archivedPath =
|
|
329
|
+
target.kind === "epic"
|
|
330
|
+
? join(archivedEpicDirectory(root, target.epicId!), "EPIC.md")
|
|
331
|
+
: target.kind === "phase"
|
|
332
|
+
? join(
|
|
333
|
+
archivedPhaseDirectory(
|
|
334
|
+
root,
|
|
335
|
+
target.taskId!,
|
|
336
|
+
target.phaseId!,
|
|
337
|
+
),
|
|
338
|
+
"PHASE.md",
|
|
339
|
+
)
|
|
340
|
+
: join(archivedTaskDirectory(root, target.taskId!), "TASK.md")
|
|
341
|
+
if (yield* fs.exists(archivedPath)) {
|
|
342
|
+
target = { ...target, archived: true, path: archivedPath }
|
|
343
|
+
}
|
|
344
|
+
}
|
|
288
345
|
|
|
289
346
|
const readDocument = <S extends Schema.Schema.AnyNoContext>(
|
|
290
347
|
id: string,
|
|
@@ -334,26 +391,22 @@ export class ContextService extends Effect.Service<ContextService>()(
|
|
|
334
391
|
return yield* readDocument(id, path, schema)
|
|
335
392
|
})
|
|
336
393
|
|
|
337
|
-
const
|
|
338
|
-
?
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
394
|
+
const activeTaskPath = target.taskId
|
|
395
|
+
? join(root, "tasks", target.taskId, "TASK.md")
|
|
396
|
+
: null
|
|
397
|
+
const taskPath = target.taskId
|
|
398
|
+
? target.kind === "task"
|
|
399
|
+
? target.path
|
|
400
|
+
: activeTaskPath && (yield* fs.exists(activeTaskPath))
|
|
401
|
+
? activeTaskPath
|
|
402
|
+
: join(archivedTaskDirectory(root, target.taskId), "TASK.md")
|
|
343
403
|
: null
|
|
404
|
+
const task =
|
|
405
|
+
target.taskId && taskPath
|
|
406
|
+
? yield* readDocument(target.taskId, taskPath, TaskFrontmatter)
|
|
407
|
+
: null
|
|
344
408
|
const phase = target.phaseId
|
|
345
|
-
? yield* readDocument(
|
|
346
|
-
target.phaseId,
|
|
347
|
-
join(
|
|
348
|
-
root,
|
|
349
|
-
"tasks",
|
|
350
|
-
target.taskId!,
|
|
351
|
-
"phases",
|
|
352
|
-
target.phaseId,
|
|
353
|
-
"PHASE.md",
|
|
354
|
-
),
|
|
355
|
-
PhaseFrontmatter,
|
|
356
|
-
)
|
|
409
|
+
? yield* readDocument(target.phaseId, target.path, PhaseFrontmatter)
|
|
357
410
|
: null
|
|
358
411
|
const epicId =
|
|
359
412
|
target.epicId ??
|
|
@@ -361,7 +414,11 @@ export class ContextService extends Effect.Service<ContextService>()(
|
|
|
361
414
|
const epic = epicId
|
|
362
415
|
? yield* readOptionalDocument(
|
|
363
416
|
epicId,
|
|
364
|
-
|
|
417
|
+
target.kind === "epic"
|
|
418
|
+
? target.path
|
|
419
|
+
: (yield* fs.exists(join(root, "epics", epicId, "EPIC.md")))
|
|
420
|
+
? join(root, "epics", epicId, "EPIC.md")
|
|
421
|
+
: join(archivedEpicDirectory(root, epicId), "EPIC.md"),
|
|
365
422
|
EpicFrontmatter,
|
|
366
423
|
)
|
|
367
424
|
: null
|
|
@@ -374,8 +431,11 @@ export class ContextService extends Effect.Service<ContextService>()(
|
|
|
374
431
|
|
|
375
432
|
const taskDocuments = new Map<string, Document<TaskData>>()
|
|
376
433
|
const phaseDocuments = new Map<string, Document<PhaseData>>()
|
|
377
|
-
const taskRoot
|
|
378
|
-
|
|
434
|
+
for (const taskRoot of [
|
|
435
|
+
join(root, "tasks"),
|
|
436
|
+
join(root, "archive", "tasks"),
|
|
437
|
+
]) {
|
|
438
|
+
if (!(yield* fs.isDirectory(taskRoot))) continue
|
|
379
439
|
const entries = (yield* fs.readDirectory(taskRoot))
|
|
380
440
|
.filter((entry) => entry.isDirectory)
|
|
381
441
|
.sort((a, b) => a.name.localeCompare(b.name))
|
|
@@ -455,14 +515,18 @@ export class ContextService extends Effect.Service<ContextService>()(
|
|
|
455
515
|
{ concurrency: "unbounded" },
|
|
456
516
|
)
|
|
457
517
|
for (const document of documents) {
|
|
458
|
-
if (
|
|
518
|
+
if (
|
|
519
|
+
document.taskDocument &&
|
|
520
|
+
!taskDocuments.has(document.taskDocument.id)
|
|
521
|
+
) {
|
|
459
522
|
taskDocuments.set(
|
|
460
523
|
document.taskDocument.id,
|
|
461
524
|
document.taskDocument,
|
|
462
525
|
)
|
|
463
526
|
}
|
|
464
527
|
for (const [key, phaseDocument] of document.phaseDocuments) {
|
|
465
|
-
phaseDocuments.
|
|
528
|
+
if (!phaseDocuments.has(key))
|
|
529
|
+
phaseDocuments.set(key, phaseDocument)
|
|
466
530
|
}
|
|
467
531
|
}
|
|
468
532
|
}
|
|
@@ -751,6 +815,18 @@ export class ContextService extends Effect.Service<ContextService>()(
|
|
|
751
815
|
const orchestrationTarget =
|
|
752
816
|
target.kind === "epic" ||
|
|
753
817
|
(target.kind === "task" && task !== null && "phases" in task.data)
|
|
818
|
+
if (target.archived) {
|
|
819
|
+
blockers.push({
|
|
820
|
+
kind: "status",
|
|
821
|
+
id:
|
|
822
|
+
target.phaseId ??
|
|
823
|
+
target.taskId ??
|
|
824
|
+
target.epicId ??
|
|
825
|
+
suppliedTarget,
|
|
826
|
+
reason:
|
|
827
|
+
"Target is archived; restore it before mutation or execution",
|
|
828
|
+
})
|
|
829
|
+
}
|
|
754
830
|
if (!orchestrationTarget && targetStatus !== "open") {
|
|
755
831
|
blockers.push({
|
|
756
832
|
kind: "status",
|
|
@@ -773,17 +849,22 @@ export class ContextService extends Effect.Service<ContextService>()(
|
|
|
773
849
|
})
|
|
774
850
|
}
|
|
775
851
|
const ready = orchestrationTarget
|
|
776
|
-
?
|
|
852
|
+
? !target.archived &&
|
|
853
|
+
descendantsReady &&
|
|
777
854
|
!blockers.some((blocker) => blocker.kind === "validation")
|
|
778
855
|
: targetStatus === "open" && blockers.length === 0
|
|
779
856
|
|
|
780
|
-
const executionData: ExecutionData | null =
|
|
781
|
-
?
|
|
782
|
-
:
|
|
783
|
-
?
|
|
784
|
-
:
|
|
857
|
+
const executionData: ExecutionData | null = target.archived
|
|
858
|
+
? null
|
|
859
|
+
: phase?.data
|
|
860
|
+
? phase.data
|
|
861
|
+
: task?.data && "repo" in task.data
|
|
862
|
+
? (task.data as ExecutionData)
|
|
863
|
+
: null
|
|
785
864
|
const reviewData =
|
|
786
|
-
task?.data && "review" in task.data
|
|
865
|
+
!target.archived && task?.data && "review" in task.data
|
|
866
|
+
? task.data.review
|
|
867
|
+
: null
|
|
787
868
|
const writableDocuments = reviewData
|
|
788
869
|
? task
|
|
789
870
|
? [task.path]
|
|
@@ -793,11 +874,13 @@ export class ContextService extends Effect.Service<ContextService>()(
|
|
|
793
874
|
? [task.path, phase.path]
|
|
794
875
|
: [task.path]
|
|
795
876
|
: []
|
|
796
|
-
const references: readonly RepositoryReference[] =
|
|
797
|
-
? [
|
|
798
|
-
:
|
|
799
|
-
?
|
|
800
|
-
:
|
|
877
|
+
const references: readonly RepositoryReference[] = target.archived
|
|
878
|
+
? []
|
|
879
|
+
: reviewData
|
|
880
|
+
? [{ repo: reviewData.repo, ref: reviewData.commit }]
|
|
881
|
+
: executionData
|
|
882
|
+
? (executionData.repos ?? [])
|
|
883
|
+
: (epic?.data.repos ?? [])
|
|
801
884
|
const entityDirectory = target.path.replace(
|
|
802
885
|
/\/(?:EPIC|TASK|PHASE)\.md$/,
|
|
803
886
|
"",
|
|
@@ -1035,6 +1118,9 @@ export class ContextService extends Effect.Service<ContextService>()(
|
|
|
1035
1118
|
}
|
|
1036
1119
|
: document
|
|
1037
1120
|
: null
|
|
1121
|
+
const pullRequestData =
|
|
1122
|
+
phase?.data ??
|
|
1123
|
+
(task?.data && "repo" in task.data ? task.data : null)
|
|
1038
1124
|
|
|
1039
1125
|
return {
|
|
1040
1126
|
projection: options.compact ? "compact" : "complete",
|
|
@@ -1124,8 +1210,8 @@ export class ContextService extends Effect.Service<ContextService>()(
|
|
|
1124
1210
|
checkout: referenceCheckouts[0] ?? null,
|
|
1125
1211
|
}
|
|
1126
1212
|
: null,
|
|
1127
|
-
pr:
|
|
1128
|
-
? normalizePullRequestRecord(
|
|
1213
|
+
pr: pullRequestData?.pr
|
|
1214
|
+
? normalizePullRequestRecord(pullRequestData.pr)
|
|
1129
1215
|
: { url: null, state: "none" },
|
|
1130
1216
|
validation: {
|
|
1131
1217
|
valid: validation.valid,
|
|
@@ -174,6 +174,18 @@ describe("IntegrationService", () => {
|
|
|
174
174
|
expect(managedWorkbaseOpencodePlugin).toContain(
|
|
175
175
|
"Do not invoke agency work for this target",
|
|
176
176
|
)
|
|
177
|
+
expect(managedWorkbaseOpencodePlugin).toContain(
|
|
178
|
+
"as the default implementation directory",
|
|
179
|
+
)
|
|
180
|
+
expect(managedWorkbaseOpencodePlugin).toContain(
|
|
181
|
+
"Set each tool's working directory to that checkout when supported",
|
|
182
|
+
)
|
|
183
|
+
expect(managedWorkbaseOpencodePlugin).toContain(
|
|
184
|
+
"Run Agency lifecycle and context commands from the task or phase directory",
|
|
185
|
+
)
|
|
186
|
+
expect(managedWorkbaseOpencodePlugin).toContain(
|
|
187
|
+
"reference checkouts reported by Agency context are read-only",
|
|
188
|
+
)
|
|
177
189
|
expect(managedWorkbaseOpencodePlugin).toContain(
|
|
178
190
|
".map((path) => `${path}${sep}.`)",
|
|
179
191
|
)
|
|
@@ -187,23 +199,39 @@ describe("IntegrationService", () => {
|
|
|
187
199
|
).toBe(false)
|
|
188
200
|
})
|
|
189
201
|
|
|
190
|
-
|
|
202
|
+
const testWorkerIdentity = async ({
|
|
203
|
+
target,
|
|
204
|
+
launchTarget,
|
|
205
|
+
phase,
|
|
206
|
+
}: {
|
|
207
|
+
readonly target:
|
|
208
|
+
| { readonly kind: "task"; readonly taskId: string }
|
|
209
|
+
| {
|
|
210
|
+
readonly kind: "phase"
|
|
211
|
+
readonly taskId: string
|
|
212
|
+
readonly phaseId: string
|
|
213
|
+
}
|
|
214
|
+
readonly launchTarget: string
|
|
215
|
+
readonly phase?: string
|
|
216
|
+
}) => {
|
|
191
217
|
const path = join(root, "agency-repository-skills.ts")
|
|
218
|
+
const checkoutPath = join(root, "code/agency")
|
|
192
219
|
const contextResponse = JSON.stringify({
|
|
193
220
|
version: 1,
|
|
194
221
|
ok: true,
|
|
195
222
|
result: {
|
|
196
223
|
workbase: { root },
|
|
197
224
|
target: {
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
path: join(root, "TASK.md"),
|
|
225
|
+
...target,
|
|
226
|
+
path: join(root, phase ? "PHASE.md" : "TASK.md"),
|
|
201
227
|
},
|
|
202
228
|
authority: {
|
|
203
229
|
mode: "execution",
|
|
204
|
-
writable: { checkoutPath
|
|
230
|
+
writable: { checkoutPath },
|
|
205
231
|
},
|
|
206
|
-
documents:
|
|
232
|
+
documents: phase
|
|
233
|
+
? { phase: { data: { status: "working" } } }
|
|
234
|
+
: { task: { data: { status: "working" } } },
|
|
207
235
|
validation: { valid: true, warnings: [] },
|
|
208
236
|
},
|
|
209
237
|
})
|
|
@@ -225,27 +253,28 @@ describe("IntegrationService", () => {
|
|
|
225
253
|
}) as ReturnType<typeof Bun.spawn>) as typeof Bun.spawn
|
|
226
254
|
try {
|
|
227
255
|
const generated = await import(
|
|
228
|
-
`${pathToFileURL(path).href}?worker-identity`
|
|
256
|
+
`${pathToFileURL(path).href}?worker-identity=${encodeURIComponent(launchTarget)}`
|
|
229
257
|
)
|
|
230
258
|
expect(
|
|
231
259
|
generated.workerLaunchTarget([
|
|
232
260
|
{
|
|
233
261
|
type: "text",
|
|
234
|
-
text:
|
|
262
|
+
text: `Agency worker launch target: ${launchTarget}. Start the task.`,
|
|
235
263
|
},
|
|
236
264
|
]),
|
|
237
|
-
).toBe(
|
|
265
|
+
).toBe(launchTarget)
|
|
238
266
|
expect(generated.workerLaunchTarget(undefined)).toBeUndefined()
|
|
239
267
|
expect(
|
|
240
268
|
generated.contextTarget({
|
|
241
|
-
target
|
|
269
|
+
target,
|
|
242
270
|
authority: { mode: "execution" },
|
|
243
271
|
}),
|
|
244
|
-
).toBe(
|
|
272
|
+
).toBe(launchTarget)
|
|
245
273
|
expect(await generated.agencyContext(root)).toMatchObject({
|
|
246
274
|
root,
|
|
247
|
-
target:
|
|
275
|
+
target: launchTarget,
|
|
248
276
|
task: "example",
|
|
277
|
+
phase,
|
|
249
278
|
})
|
|
250
279
|
const hooks = await generated.default({ directory: root } as never)
|
|
251
280
|
await hooks["chat.message"]!(
|
|
@@ -254,7 +283,7 @@ describe("IntegrationService", () => {
|
|
|
254
283
|
parts: [
|
|
255
284
|
{
|
|
256
285
|
type: "text",
|
|
257
|
-
text:
|
|
286
|
+
text: `Agency worker launch target: ${launchTarget}. Start the task.`,
|
|
258
287
|
},
|
|
259
288
|
],
|
|
260
289
|
} as never,
|
|
@@ -276,11 +305,17 @@ describe("IntegrationService", () => {
|
|
|
276
305
|
{ sessionID: "worker-session" } as never,
|
|
277
306
|
system,
|
|
278
307
|
)
|
|
279
|
-
expect(system.system).
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
308
|
+
expect(system.system).toHaveLength(1)
|
|
309
|
+
expect(system.system[0]).toContain(`active worker for ${launchTarget}`)
|
|
310
|
+
expect(system.system[0]).toContain(
|
|
311
|
+
`${checkoutPath} as the default implementation directory`,
|
|
312
|
+
)
|
|
313
|
+
expect(system.system[0]).toContain(
|
|
314
|
+
"Run Agency lifecycle and context commands from the task or phase directory",
|
|
315
|
+
)
|
|
316
|
+
expect(system.system[0]).toContain(
|
|
317
|
+
"reference checkouts reported by Agency context are read-only",
|
|
318
|
+
)
|
|
284
319
|
const mismatchedSystem = { system: [] as string[] }
|
|
285
320
|
await hooks["experimental.chat.system.transform"]!(
|
|
286
321
|
{ sessionID: "mismatched-session" } as never,
|
|
@@ -292,15 +327,29 @@ describe("IntegrationService", () => {
|
|
|
292
327
|
await hooks["shell.env"]!({ sessionID: "worker-session" } as never, shell)
|
|
293
328
|
expect(shell.env).toMatchObject({
|
|
294
329
|
AGENCY_SESSION_ID: "worker-session",
|
|
295
|
-
AGENCY_TARGET:
|
|
330
|
+
AGENCY_TARGET: launchTarget,
|
|
296
331
|
AGENCY_WORKBASE: root,
|
|
297
332
|
AGENCY_TASK_ID: "example",
|
|
298
|
-
AGENCY_WRITABLE_CHECKOUT:
|
|
333
|
+
AGENCY_WRITABLE_CHECKOUT: checkoutPath,
|
|
299
334
|
})
|
|
335
|
+
if (phase) expect(shell.env.AGENCY_PHASE_ID).toBe(phase)
|
|
300
336
|
} finally {
|
|
301
337
|
Bun.spawn = originalSpawn
|
|
302
338
|
}
|
|
303
|
-
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
test("binds validated task worker identity to an OpenCode session", () =>
|
|
342
|
+
testWorkerIdentity({
|
|
343
|
+
target: { kind: "task", taskId: "example" },
|
|
344
|
+
launchTarget: "execution-unit:task/example",
|
|
345
|
+
}))
|
|
346
|
+
|
|
347
|
+
test("binds validated phase worker identity to an OpenCode session", () =>
|
|
348
|
+
testWorkerIdentity({
|
|
349
|
+
target: { kind: "phase", taskId: "example", phaseId: "build" },
|
|
350
|
+
launchTarget: "execution-unit:phase/example/build",
|
|
351
|
+
phase: "build",
|
|
352
|
+
}))
|
|
304
353
|
|
|
305
354
|
test("registers a TUI-only /agency-debug diagnostic", async () => {
|
|
306
355
|
const config = JSON.parse(managedBody(managedWorkbaseOpencodeTui))
|
|
@@ -164,7 +164,12 @@ const plugin: Plugin = async ({ directory }) => {
|
|
|
164
164
|
const context = workerSessions.get(sessionID)
|
|
165
165
|
if (!context?.target) return
|
|
166
166
|
output.system.push(
|
|
167
|
-
|
|
167
|
+
[
|
|
168
|
+
\`Agency verified this OpenCode session as the active worker for \${context.target}. Perform the assigned work directly. Do not invoke agency work for this target or launch a replacement worker.\`,
|
|
169
|
+
context.checkout
|
|
170
|
+
? \`OpenCode remains rooted in the task or phase directory for Agency instructions and context. Treat \${context.checkout} as the default implementation directory: use it for source reads, edits, repository status, builds, tests, formatting, and other repository-local commands. Set each tool's working directory to that checkout when supported; otherwise use absolute paths. Run Agency lifecycle and context commands from the task or phase directory. Any reference checkouts reported by Agency context are read-only.\`
|
|
171
|
+
: undefined,
|
|
172
|
+
].filter(Boolean).join(" "),
|
|
168
173
|
)
|
|
169
174
|
},
|
|
170
175
|
"shell.env": async ({ sessionID }, output) => {
|