@markjaquith/agency 2.61.3 → 2.63.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 +23 -0
- package/cli-main.ts +22 -0
- package/package.json +1 -1
- package/src/cli-parser.test.ts +33 -0
- package/src/cli-parser.ts +23 -0
- package/src/cli.test.ts +4 -0
- package/src/commands/act.test.ts +323 -0
- package/src/commands/act.ts +442 -0
- package/src/commands/context.test.ts +118 -1
- package/src/commands/context.ts +3 -1
- package/src/services/ContextService.ts +125 -39
package/README.md
CHANGED
|
@@ -508,6 +508,25 @@ agency validate
|
|
|
508
508
|
|
|
509
509
|
## Commands
|
|
510
510
|
|
|
511
|
+
### Interactive Actions
|
|
512
|
+
|
|
513
|
+
`agency act` opens a filterable work-item chooser followed by an action palette
|
|
514
|
+
derived from the selected item's current state and readiness. It offers only
|
|
515
|
+
actions that can use existing lifecycle semantics, such as working, creating a
|
|
516
|
+
pull request, dropping, reopening, or archiving. Cancelling either chooser makes
|
|
517
|
+
no changes, and the command refreshes the graph before dispatch so a stale
|
|
518
|
+
selection cannot act on changed work.
|
|
519
|
+
|
|
520
|
+
Use `--epic <id>`, `--task <id>`, or `--task <id> --phase <id>` to skip work-item
|
|
521
|
+
selection. `--dry-run` still prompts for an action but prints the exact Agency
|
|
522
|
+
command instead of executing it. `--json` never prompts or executes; it returns
|
|
523
|
+
matching targets, status and readiness details, document revisions, and each
|
|
524
|
+
available action's command argv. With no selector, JSON includes every active
|
|
525
|
+
work item.
|
|
526
|
+
|
|
527
|
+
`--auto` is included in generated or executed work commands, and `--draft` is
|
|
528
|
+
included in generated or executed pull request commands.
|
|
529
|
+
|
|
511
530
|
### Target Context
|
|
512
531
|
|
|
513
532
|
`agency context [target] --json` returns complete bootstrap context without
|
|
@@ -516,6 +535,10 @@ a discovery catalog of all epics, tasks, and phases, including frontmatter,
|
|
|
516
535
|
paths, and document revisions. Elsewhere it returns context for an epic, task,
|
|
517
536
|
or phase. The target defaults to the current directory; entity directories,
|
|
518
537
|
document paths, checkout descendants, and bare task IDs are accepted.
|
|
538
|
+
Archived entity paths and selectors are also accepted. Archived context is
|
|
539
|
+
explicitly marked with `target.archived: true` and never grants writable
|
|
540
|
+
document, repository checkout, or reference authority; restore the item before
|
|
541
|
+
attempting mutation or execution.
|
|
519
542
|
|
|
520
543
|
Root discovery is compact by default and includes a hint to run `agency context
|
|
521
544
|
. --full --json` when document prose is needed. Entity context remains complete
|
package/cli-main.ts
CHANGED
|
@@ -54,6 +54,7 @@ import {
|
|
|
54
54
|
import { review, help as reviewHelp } from "./src/commands/review"
|
|
55
55
|
import { vcs, help as vcsHelp } from "./src/commands/vcs"
|
|
56
56
|
import { VcsMigrationService } from "./src/services/VcsMigrationService"
|
|
57
|
+
import { act, help as actHelp } from "./src/commands/act"
|
|
57
58
|
import {
|
|
58
59
|
claimCommand,
|
|
59
60
|
claimHelp,
|
|
@@ -175,6 +176,26 @@ const VERSION = packageJson.version
|
|
|
175
176
|
|
|
176
177
|
// Define commands
|
|
177
178
|
const commands: Record<string, Command> = {
|
|
179
|
+
act: {
|
|
180
|
+
run: async (_args: string[], options: Record<string, any>) => {
|
|
181
|
+
if (options.help) return console.log(actHelp)
|
|
182
|
+
await runCommand(
|
|
183
|
+
act({
|
|
184
|
+
auto: options.auto,
|
|
185
|
+
draft: options.draft,
|
|
186
|
+
dryRun: options["dry-run"],
|
|
187
|
+
json: options.json,
|
|
188
|
+
epicId: options.epic,
|
|
189
|
+
taskId: options.task,
|
|
190
|
+
phaseId: options.phase,
|
|
191
|
+
inputAllowed: options.inputAllowed,
|
|
192
|
+
silent: options.silent,
|
|
193
|
+
verbose: options.verbose,
|
|
194
|
+
cwd: options.cwd,
|
|
195
|
+
}),
|
|
196
|
+
)
|
|
197
|
+
},
|
|
198
|
+
},
|
|
178
199
|
claim: {
|
|
179
200
|
run: async (args: string[], options: Record<string, any>) => {
|
|
180
201
|
if (options.help) return console.log(claimHelp)
|
|
@@ -753,6 +774,7 @@ agency v${VERSION}
|
|
|
753
774
|
Usage: agency <command> [options]
|
|
754
775
|
|
|
755
776
|
Commands:
|
|
777
|
+
act Interactively choose and act on work
|
|
756
778
|
init [path] Initialize an Agency workbase
|
|
757
779
|
workbase <subcommand> Manage registered workbases
|
|
758
780
|
integration <command> Inspect or sync managed integration files
|
package/package.json
CHANGED
package/src/cli-parser.test.ts
CHANGED
|
@@ -7,6 +7,39 @@ const expectUsageError = (args: string[], usage: string) => {
|
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
describe("strict CLI parsing", () => {
|
|
10
|
+
test("parses act selectors, dry-run, and JSON options", () => {
|
|
11
|
+
expect(
|
|
12
|
+
parseCli([
|
|
13
|
+
"act",
|
|
14
|
+
"--task",
|
|
15
|
+
"example",
|
|
16
|
+
"--phase",
|
|
17
|
+
"build",
|
|
18
|
+
"--dry-run",
|
|
19
|
+
"--auto",
|
|
20
|
+
"--draft",
|
|
21
|
+
]),
|
|
22
|
+
).toMatchObject({
|
|
23
|
+
commandName: "act",
|
|
24
|
+
args: [],
|
|
25
|
+
values: {
|
|
26
|
+
task: "example",
|
|
27
|
+
phase: "build",
|
|
28
|
+
"dry-run": true,
|
|
29
|
+
auto: true,
|
|
30
|
+
draft: true,
|
|
31
|
+
},
|
|
32
|
+
})
|
|
33
|
+
expect(parseCli(["act", "--json"]).values.json).toBe(true)
|
|
34
|
+
expectUsageError(["act", "task-id"], "agency act")
|
|
35
|
+
expectUsageError(["act", "--phase", "build"], "agency act")
|
|
36
|
+
expectUsageError(
|
|
37
|
+
["act", "--epic", "roadmap", "--task", "example"],
|
|
38
|
+
"agency act",
|
|
39
|
+
)
|
|
40
|
+
expectUsageError(["act", "--dry-run", "--json"], "agency act")
|
|
41
|
+
})
|
|
42
|
+
|
|
10
43
|
test("accepts review creation and refresh while enforcing one source", () => {
|
|
11
44
|
expect(
|
|
12
45
|
parseCli([
|
package/src/cli-parser.ts
CHANGED
|
@@ -146,6 +146,29 @@ const commands = {
|
|
|
146
146
|
options: ["json"],
|
|
147
147
|
},
|
|
148
148
|
},
|
|
149
|
+
act: {
|
|
150
|
+
usage:
|
|
151
|
+
"agency act [--epic <id> | --task <id> [--phase <id>]] [--dry-run | --json] [--auto] [--draft]",
|
|
152
|
+
options: {
|
|
153
|
+
...outputOptions,
|
|
154
|
+
...entitySelectorOptions,
|
|
155
|
+
"dry-run": { type: "boolean" },
|
|
156
|
+
auto: { type: "boolean" },
|
|
157
|
+
draft: { type: "boolean" },
|
|
158
|
+
},
|
|
159
|
+
command: {
|
|
160
|
+
usage:
|
|
161
|
+
"agency act [--epic <id> | --task <id> [--phase <id>]] [--dry-run | --json] [--auto] [--draft]",
|
|
162
|
+
minArgs: 0,
|
|
163
|
+
maxArgs: 0,
|
|
164
|
+
options: ["epic", "task", "phase", "dry-run", "json", "auto", "draft"],
|
|
165
|
+
conflicts: [
|
|
166
|
+
["dry-run", "json"],
|
|
167
|
+
["epic", "task"],
|
|
168
|
+
["epic", "phase"],
|
|
169
|
+
],
|
|
170
|
+
},
|
|
171
|
+
},
|
|
149
172
|
workbase: {
|
|
150
173
|
usage: "agency workbase <init|add|list|show|name|remove|prune|default>",
|
|
151
174
|
options: {
|
package/src/cli.test.ts
CHANGED
|
@@ -497,10 +497,14 @@ describe("CLI", () => {
|
|
|
497
497
|
expect(result.stderr).toContain("task new requires interactive input")
|
|
498
498
|
expect(result.stderr).toContain("agency task create")
|
|
499
499
|
}
|
|
500
|
+
const actResult = await runCli(["act", "--no-input"])
|
|
501
|
+
expect(actResult.exitCode).toBe(1)
|
|
502
|
+
expect(actResult.stderr).toContain("agency act requires interactive input")
|
|
500
503
|
})
|
|
501
504
|
|
|
502
505
|
test("routes command help and global options on either side of commands", async () => {
|
|
503
506
|
const commands = [
|
|
507
|
+
["act", "Usage: agency act"],
|
|
504
508
|
["init", "Usage: agency init"],
|
|
505
509
|
["workbase", "Usage: agency workbase"],
|
|
506
510
|
["integration", "Usage: agency integration"],
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
|
2
|
+
import { Effect } from "effect"
|
|
3
|
+
import { join } from "node:path"
|
|
4
|
+
import {
|
|
5
|
+
captureLogs,
|
|
6
|
+
cleanupTempDir,
|
|
7
|
+
createTempDir,
|
|
8
|
+
runTestEffect,
|
|
9
|
+
} from "../test-utils"
|
|
10
|
+
import type { Choice } from "../utils/chooser"
|
|
11
|
+
import { phase } from "./phase"
|
|
12
|
+
import { task } from "./task"
|
|
13
|
+
import { act, type ActInteraction } from "./act"
|
|
14
|
+
|
|
15
|
+
const scriptedInteraction = (
|
|
16
|
+
selections: readonly (string | null)[],
|
|
17
|
+
onSelect?: (prompt: string, choices: readonly Choice<unknown>[]) => void,
|
|
18
|
+
): ActInteraction => {
|
|
19
|
+
let index = 0
|
|
20
|
+
return {
|
|
21
|
+
select: (prompt, choices) => {
|
|
22
|
+
onSelect?.(prompt, choices)
|
|
23
|
+
const selection = selections[index++] ?? null
|
|
24
|
+
return Effect.succeed(
|
|
25
|
+
selection === null
|
|
26
|
+
? null
|
|
27
|
+
: (choices.find((choice) => choice.value === selection)?.value ??
|
|
28
|
+
null),
|
|
29
|
+
)
|
|
30
|
+
},
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("act command", () => {
|
|
35
|
+
let root: string
|
|
36
|
+
|
|
37
|
+
beforeEach(async () => {
|
|
38
|
+
root = await createTempDir()
|
|
39
|
+
await Bun.write(join(root, "agency.json"), '{"version":2}\n')
|
|
40
|
+
const initialized = Bun.spawnSync([
|
|
41
|
+
"git",
|
|
42
|
+
"init",
|
|
43
|
+
"--bare",
|
|
44
|
+
join(root, "repos/agency"),
|
|
45
|
+
])
|
|
46
|
+
if (initialized.exitCode !== 0) {
|
|
47
|
+
throw new Error(new TextDecoder().decode(initialized.stderr))
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
afterEach(async () => cleanupTempDir(root))
|
|
52
|
+
|
|
53
|
+
test("rejects non-interactive and empty invocations cleanly", async () => {
|
|
54
|
+
await expect(
|
|
55
|
+
runTestEffect(act({ cwd: root, inputAllowed: false })),
|
|
56
|
+
).rejects.toThrow("requires interactive input")
|
|
57
|
+
await expect(
|
|
58
|
+
runTestEffect(
|
|
59
|
+
act({ cwd: root, inputAllowed: true }, scriptedInteraction([])),
|
|
60
|
+
),
|
|
61
|
+
).rejects.toThrow("No active work items")
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test("returns an empty target list as JSON without interactive input", async () => {
|
|
65
|
+
const logs = await captureLogs(() =>
|
|
66
|
+
runTestEffect(act({ cwd: root, inputAllowed: false, json: true })),
|
|
67
|
+
)
|
|
68
|
+
expect(JSON.parse(logs[0]!)).toEqual({ targets: [] })
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test("cancellation exits without changing the selected item", async () => {
|
|
72
|
+
await createTask("example")
|
|
73
|
+
await runTestEffect(
|
|
74
|
+
act({ cwd: root, inputAllowed: true }, scriptedInteraction([null])),
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
expect(await readTaskStatus("example")).toBe("open")
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test("offers state-aware actions and dispatches drop through lifecycle semantics", async () => {
|
|
81
|
+
await createTask("example")
|
|
82
|
+
const offered: string[][] = []
|
|
83
|
+
const logs = await captureLogs(() =>
|
|
84
|
+
runTestEffect(
|
|
85
|
+
act(
|
|
86
|
+
{ cwd: root, inputAllowed: true },
|
|
87
|
+
scriptedInteraction(["task:example", "drop"], (_prompt, choices) => {
|
|
88
|
+
offered.push(choices.map((choice) => String(choice.value)))
|
|
89
|
+
}),
|
|
90
|
+
),
|
|
91
|
+
),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
expect(offered[1]).toEqual(["work", "pr", "drop"])
|
|
95
|
+
expect(logs).toEqual(["Marked task 'example' as dropped"])
|
|
96
|
+
expect(await readTaskStatus("example")).toBe("dropped")
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
test("accepts an explicit selector and prints dry-run command without mutation", async () => {
|
|
100
|
+
await createTask("example")
|
|
101
|
+
const prompts: string[] = []
|
|
102
|
+
const logs = await captureLogs(() =>
|
|
103
|
+
runTestEffect(
|
|
104
|
+
act(
|
|
105
|
+
{
|
|
106
|
+
cwd: root,
|
|
107
|
+
inputAllowed: true,
|
|
108
|
+
taskId: "example",
|
|
109
|
+
dryRun: true,
|
|
110
|
+
},
|
|
111
|
+
scriptedInteraction(["drop"], (prompt) => prompts.push(prompt)),
|
|
112
|
+
),
|
|
113
|
+
),
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
expect(prompts).toEqual(["Act on task example"])
|
|
117
|
+
expect(logs).toEqual(["agency task status example dropped"])
|
|
118
|
+
expect(await readTaskStatus("example")).toBe("open")
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test("returns structured actions and argv for agents", async () => {
|
|
122
|
+
await createTask("example")
|
|
123
|
+
const logs = await captureLogs(() =>
|
|
124
|
+
runTestEffect(
|
|
125
|
+
act({
|
|
126
|
+
cwd: root,
|
|
127
|
+
inputAllowed: false,
|
|
128
|
+
taskId: "example",
|
|
129
|
+
json: true,
|
|
130
|
+
auto: true,
|
|
131
|
+
draft: true,
|
|
132
|
+
}),
|
|
133
|
+
),
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
expect(JSON.parse(logs[0]!)).toMatchObject({
|
|
137
|
+
targets: [
|
|
138
|
+
{
|
|
139
|
+
kind: "task",
|
|
140
|
+
key: "example",
|
|
141
|
+
status: "open",
|
|
142
|
+
actions: [
|
|
143
|
+
{
|
|
144
|
+
id: "work",
|
|
145
|
+
command: ["agency", "work", "--task", "example", "--auto"],
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
id: "pr",
|
|
149
|
+
command: ["agency", "pr", "create", "example", "--draft"],
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
id: "drop",
|
|
153
|
+
command: ["agency", "task", "status", "example", "dropped"],
|
|
154
|
+
},
|
|
155
|
+
],
|
|
156
|
+
},
|
|
157
|
+
],
|
|
158
|
+
})
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
test("offers reopen and archive for terminal work", async () => {
|
|
162
|
+
await createTask("example")
|
|
163
|
+
await runTestEffect(
|
|
164
|
+
task({
|
|
165
|
+
subcommand: "status",
|
|
166
|
+
args: ["example", "dropped"],
|
|
167
|
+
cwd: root,
|
|
168
|
+
silent: true,
|
|
169
|
+
}),
|
|
170
|
+
)
|
|
171
|
+
let actions: string[] = []
|
|
172
|
+
await runTestEffect(
|
|
173
|
+
act(
|
|
174
|
+
{ cwd: root, inputAllowed: true },
|
|
175
|
+
scriptedInteraction(["task:example", null], (prompt, choices) => {
|
|
176
|
+
if (prompt.startsWith("Act on task")) {
|
|
177
|
+
actions = choices.map((choice) => String(choice.value))
|
|
178
|
+
}
|
|
179
|
+
}),
|
|
180
|
+
),
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
expect(actions).toEqual(["reopen", "archive"])
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
test("dispatches phase actions with the parent task identifier", async () => {
|
|
187
|
+
await runTestEffect(
|
|
188
|
+
task({
|
|
189
|
+
subcommand: "create",
|
|
190
|
+
args: ["multi"],
|
|
191
|
+
multiPhase: true,
|
|
192
|
+
cwd: root,
|
|
193
|
+
silent: true,
|
|
194
|
+
}),
|
|
195
|
+
)
|
|
196
|
+
await runTestEffect(
|
|
197
|
+
phase({
|
|
198
|
+
subcommand: "create",
|
|
199
|
+
args: ["multi", "build"],
|
|
200
|
+
repo: "agency",
|
|
201
|
+
branch: "task/multi-build",
|
|
202
|
+
base: "main",
|
|
203
|
+
cwd: root,
|
|
204
|
+
silent: true,
|
|
205
|
+
}),
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
let actions: string[] = []
|
|
209
|
+
await runTestEffect(
|
|
210
|
+
act(
|
|
211
|
+
{ cwd: root, inputAllowed: true, silent: true },
|
|
212
|
+
scriptedInteraction(
|
|
213
|
+
["phase:multi/build", "drop"],
|
|
214
|
+
(prompt, choices) => {
|
|
215
|
+
if (prompt.startsWith("Act on phase")) {
|
|
216
|
+
actions = choices.map((choice) => String(choice.value))
|
|
217
|
+
}
|
|
218
|
+
},
|
|
219
|
+
),
|
|
220
|
+
),
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
expect(actions).toEqual(["work", "pr", "drop"])
|
|
224
|
+
const content = await Bun.file(
|
|
225
|
+
join(root, "tasks/multi/phases/build/PHASE.md"),
|
|
226
|
+
).text()
|
|
227
|
+
expect(content).toContain("status: dropped")
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
test("dispatches work and pull request actions through their command handlers", async () => {
|
|
231
|
+
await createTask("example")
|
|
232
|
+
const workCalls: unknown[] = []
|
|
233
|
+
await runTestEffect(
|
|
234
|
+
act(
|
|
235
|
+
{ cwd: root, inputAllowed: true, auto: true },
|
|
236
|
+
scriptedInteraction(["task:example", "work"]),
|
|
237
|
+
((options) => {
|
|
238
|
+
workCalls.push(options)
|
|
239
|
+
return Effect.void
|
|
240
|
+
}) as Parameters<typeof act>[2],
|
|
241
|
+
),
|
|
242
|
+
)
|
|
243
|
+
expect(workCalls).toEqual([
|
|
244
|
+
expect.objectContaining({
|
|
245
|
+
taskId: "example",
|
|
246
|
+
auto: true,
|
|
247
|
+
cwd: root,
|
|
248
|
+
}),
|
|
249
|
+
])
|
|
250
|
+
|
|
251
|
+
let prActionSeen = false
|
|
252
|
+
await expect(
|
|
253
|
+
runTestEffect(
|
|
254
|
+
act(
|
|
255
|
+
{ cwd: root, inputAllowed: true },
|
|
256
|
+
{
|
|
257
|
+
select: (prompt, choices) => {
|
|
258
|
+
if (prompt.startsWith("Act on task")) {
|
|
259
|
+
prActionSeen = choices.some((choice) => choice.value === "pr")
|
|
260
|
+
return Effect.fail(new Error("stop before external PR command"))
|
|
261
|
+
}
|
|
262
|
+
return Effect.succeed(
|
|
263
|
+
choices.find((choice) => choice.value === "task:example")
|
|
264
|
+
?.value ?? null,
|
|
265
|
+
)
|
|
266
|
+
},
|
|
267
|
+
},
|
|
268
|
+
),
|
|
269
|
+
),
|
|
270
|
+
).rejects.toThrow("stop before external PR command")
|
|
271
|
+
expect(prActionSeen).toBe(true)
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
test("rejects a stale selection before dispatch", async () => {
|
|
275
|
+
await createTask("example")
|
|
276
|
+
let selection = 0
|
|
277
|
+
const interaction: ActInteraction = {
|
|
278
|
+
select: (_prompt, choices) => {
|
|
279
|
+
selection++
|
|
280
|
+
if (selection === 1) {
|
|
281
|
+
return Effect.succeed(
|
|
282
|
+
choices.find((choice) => choice.value === "task:example")?.value ??
|
|
283
|
+
null,
|
|
284
|
+
)
|
|
285
|
+
}
|
|
286
|
+
return Effect.promise(async () => {
|
|
287
|
+
await Bun.write(
|
|
288
|
+
join(root, "tasks/example/TASK.md"),
|
|
289
|
+
(
|
|
290
|
+
await Bun.file(join(root, "tasks/example/TASK.md")).text()
|
|
291
|
+
).replace("# Example", "# Example\n\nChanged after selection"),
|
|
292
|
+
)
|
|
293
|
+
return (
|
|
294
|
+
choices.find((choice) => choice.value === "drop")?.value ?? null
|
|
295
|
+
)
|
|
296
|
+
})
|
|
297
|
+
},
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
await expect(
|
|
301
|
+
runTestEffect(act({ cwd: root, inputAllowed: true }, interaction)),
|
|
302
|
+
).rejects.toThrow("Selected work item changed")
|
|
303
|
+
expect(await readTaskStatus("example")).toBe("open")
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
const createTask = (id: string) =>
|
|
307
|
+
runTestEffect(
|
|
308
|
+
task({
|
|
309
|
+
subcommand: "create",
|
|
310
|
+
args: [id],
|
|
311
|
+
repo: "agency",
|
|
312
|
+
branch: `task/${id}`,
|
|
313
|
+
base: "main",
|
|
314
|
+
cwd: root,
|
|
315
|
+
silent: true,
|
|
316
|
+
}),
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
const readTaskStatus = async (id: string) => {
|
|
320
|
+
const content = await Bun.file(join(root, `tasks/${id}/TASK.md`)).text()
|
|
321
|
+
return content.match(/^status: (.+)$/m)?.[1]
|
|
322
|
+
}
|
|
323
|
+
})
|
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
import { Effect } from "effect"
|
|
2
|
+
import type { GraphNode } from "../graph-schema"
|
|
3
|
+
import { isTerminalStatus } from "../readiness"
|
|
4
|
+
import { GraphService } from "../services/GraphService"
|
|
5
|
+
import { WorkbaseService } from "../services/WorkbaseService"
|
|
6
|
+
import type { BaseCommandOptions } from "../utils/command"
|
|
7
|
+
import { choose, type Choice } from "../utils/chooser"
|
|
8
|
+
import { createLoggers } from "../utils/effect"
|
|
9
|
+
import { macchiato } from "../utils/theme"
|
|
10
|
+
import { archive as archiveCommand } from "./archive"
|
|
11
|
+
import { phase as phaseCommand } from "./phase"
|
|
12
|
+
import { prCreate as createPullRequest } from "./pr"
|
|
13
|
+
import { task as taskCommand } from "./task"
|
|
14
|
+
import { work as startWork, type StartWork } from "./work"
|
|
15
|
+
|
|
16
|
+
type EntityNode = Extract<
|
|
17
|
+
GraphNode,
|
|
18
|
+
{ readonly kind: "epic" | "task" | "phase" }
|
|
19
|
+
>
|
|
20
|
+
|
|
21
|
+
type ActAction = "work" | "pr" | "reopen" | "drop" | "archive"
|
|
22
|
+
|
|
23
|
+
export interface ActInteraction {
|
|
24
|
+
readonly select: <T>(
|
|
25
|
+
prompt: string,
|
|
26
|
+
choices: readonly Choice<T>[],
|
|
27
|
+
command?: readonly string[],
|
|
28
|
+
) => Effect.Effect<T | null, Error>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface ActOptions extends BaseCommandOptions {
|
|
32
|
+
readonly auto?: boolean
|
|
33
|
+
readonly draft?: boolean
|
|
34
|
+
readonly dryRun?: boolean
|
|
35
|
+
readonly json?: boolean
|
|
36
|
+
readonly epicId?: string
|
|
37
|
+
readonly taskId?: string
|
|
38
|
+
readonly phaseId?: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const defaultInteraction: ActInteraction = {
|
|
42
|
+
select: (prompt, choices, command) => choose(prompt, choices, command),
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const entityKey = (node: EntityNode) => `${node.kind}:${node.key}`
|
|
46
|
+
|
|
47
|
+
const entityDescription = (node: EntityNode) =>
|
|
48
|
+
"description" in node.data && node.data.description
|
|
49
|
+
? ` - ${node.data.description}`
|
|
50
|
+
: ""
|
|
51
|
+
|
|
52
|
+
const entityChoices = (
|
|
53
|
+
nodes: readonly EntityNode[],
|
|
54
|
+
): readonly Choice<string>[] =>
|
|
55
|
+
nodes.map((node, index) => ({
|
|
56
|
+
key: String(index),
|
|
57
|
+
label: `[${node.status}] ${node.kind} ${node.key}${entityDescription(node)}`,
|
|
58
|
+
depth: node.kind === "phase" ? 1 : 0,
|
|
59
|
+
segments: [
|
|
60
|
+
{ text: `[${node.status}] `, color: macchiato.overlay1 },
|
|
61
|
+
{ text: `${node.kind} `, color: macchiato.sapphire },
|
|
62
|
+
{ text: node.key },
|
|
63
|
+
...(entityDescription(node)
|
|
64
|
+
? [{ text: entityDescription(node), color: macchiato.overlay0 }]
|
|
65
|
+
: []),
|
|
66
|
+
],
|
|
67
|
+
value: entityKey(node),
|
|
68
|
+
}))
|
|
69
|
+
|
|
70
|
+
const activeClaim = (node: EntityNode) =>
|
|
71
|
+
"claim" in node.data && node.data.claim?.state === "active"
|
|
72
|
+
|
|
73
|
+
const executionNode = (
|
|
74
|
+
node: EntityNode,
|
|
75
|
+
nodes: readonly GraphNode[],
|
|
76
|
+
): Extract<GraphNode, { readonly kind: "execution-unit" }> | undefined => {
|
|
77
|
+
if (node.kind === "epic") return undefined
|
|
78
|
+
if (node.kind === "task" && "phases" in node.data) return undefined
|
|
79
|
+
const phaseId =
|
|
80
|
+
node.kind === "phase"
|
|
81
|
+
? node.key.slice(node.key.indexOf("/") + 1)
|
|
82
|
+
: undefined
|
|
83
|
+
return nodes.find(
|
|
84
|
+
(
|
|
85
|
+
candidate,
|
|
86
|
+
): candidate is Extract<GraphNode, { readonly kind: "execution-unit" }> =>
|
|
87
|
+
candidate.kind === "execution-unit" &&
|
|
88
|
+
candidate.data.taskId ===
|
|
89
|
+
(node.kind === "task" ? node.key : node.key.split("/", 1)[0]) &&
|
|
90
|
+
(node.kind === "task" ||
|
|
91
|
+
("phaseId" in candidate.data && candidate.data.phaseId === phaseId)),
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const canWork = (node: EntityNode, nodes: readonly GraphNode[]) => {
|
|
96
|
+
if (node.kind === "epic" || (node.kind === "task" && "phases" in node.data)) {
|
|
97
|
+
return node.readiness.ready
|
|
98
|
+
}
|
|
99
|
+
const execution = executionNode(node, nodes)
|
|
100
|
+
return Boolean(
|
|
101
|
+
execution &&
|
|
102
|
+
!activeClaim(node) &&
|
|
103
|
+
(execution.readiness.ready ||
|
|
104
|
+
(execution.status === "working" &&
|
|
105
|
+
execution.readiness.blockers.every(
|
|
106
|
+
(blocker) => blocker.kind !== "validation",
|
|
107
|
+
))),
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const canCreatePr = (node: EntityNode, nodes: readonly GraphNode[]) => {
|
|
112
|
+
const execution = executionNode(node, nodes)
|
|
113
|
+
return Boolean(
|
|
114
|
+
execution &&
|
|
115
|
+
!execution.readiness.terminal &&
|
|
116
|
+
!("pr" in execution.data && execution.data.pr) &&
|
|
117
|
+
!execution.readiness.blockers.some(
|
|
118
|
+
(blocker) =>
|
|
119
|
+
blocker.kind === "dependency" || blocker.kind === "validation",
|
|
120
|
+
),
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const actionChoices = (
|
|
125
|
+
node: EntityNode,
|
|
126
|
+
nodes: readonly GraphNode[],
|
|
127
|
+
): readonly Choice<ActAction>[] => {
|
|
128
|
+
const choices: Choice<ActAction>[] = []
|
|
129
|
+
const execution = executionNode(node, nodes)
|
|
130
|
+
if (canWork(node, nodes)) {
|
|
131
|
+
choices.push({ key: "work", label: "Work on this item", value: "work" })
|
|
132
|
+
}
|
|
133
|
+
if (canCreatePr(node, nodes)) {
|
|
134
|
+
choices.push({ key: "pr", label: "Create pull request", value: "pr" })
|
|
135
|
+
}
|
|
136
|
+
if (execution && isTerminalStatus(node.status) && !activeClaim(node)) {
|
|
137
|
+
choices.push({ key: "reopen", label: "Reopen", value: "reopen" })
|
|
138
|
+
}
|
|
139
|
+
if (execution && !isTerminalStatus(node.status) && !activeClaim(node)) {
|
|
140
|
+
choices.push({ key: "drop", label: "Drop", value: "drop" })
|
|
141
|
+
}
|
|
142
|
+
if (node.readiness.terminal) {
|
|
143
|
+
choices.push({ key: "archive", label: "Archive", value: "archive" })
|
|
144
|
+
}
|
|
145
|
+
return choices
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const entityParts = (node: EntityNode) => {
|
|
149
|
+
if (node.kind !== "phase") return { taskId: node.key }
|
|
150
|
+
const separator = node.key.indexOf("/")
|
|
151
|
+
return {
|
|
152
|
+
taskId: node.key.slice(0, separator),
|
|
153
|
+
phaseId: node.key.slice(separator + 1),
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const actionCommand = (
|
|
158
|
+
node: EntityNode,
|
|
159
|
+
action: ActAction,
|
|
160
|
+
options: Pick<ActOptions, "auto" | "draft">,
|
|
161
|
+
): readonly string[] => {
|
|
162
|
+
const { taskId, phaseId } = entityParts(node)
|
|
163
|
+
switch (action) {
|
|
164
|
+
case "work":
|
|
165
|
+
return [
|
|
166
|
+
"agency",
|
|
167
|
+
"work",
|
|
168
|
+
...(node.kind === "epic"
|
|
169
|
+
? ["--epic", node.key]
|
|
170
|
+
: ["--task", taskId, ...(phaseId ? ["--phase", phaseId] : [])]),
|
|
171
|
+
...(options.auto ? ["--auto"] : []),
|
|
172
|
+
]
|
|
173
|
+
case "pr":
|
|
174
|
+
return [
|
|
175
|
+
"agency",
|
|
176
|
+
"pr",
|
|
177
|
+
"create",
|
|
178
|
+
taskId,
|
|
179
|
+
...(phaseId ? [phaseId] : []),
|
|
180
|
+
...(options.draft ? ["--draft"] : []),
|
|
181
|
+
]
|
|
182
|
+
case "reopen":
|
|
183
|
+
case "drop": {
|
|
184
|
+
const status = action === "reopen" ? "open" : "dropped"
|
|
185
|
+
return phaseId
|
|
186
|
+
? ["agency", "phase", "status", taskId, phaseId, status]
|
|
187
|
+
: ["agency", "task", "status", taskId, status]
|
|
188
|
+
}
|
|
189
|
+
case "archive":
|
|
190
|
+
return node.kind === "phase"
|
|
191
|
+
? ["agency", "archive", "phase", taskId, phaseId!]
|
|
192
|
+
: ["agency", "archive", node.kind, node.key]
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const shellCommand = (command: readonly string[]) =>
|
|
197
|
+
command
|
|
198
|
+
.map((argument) =>
|
|
199
|
+
/^[A-Za-z0-9_./:=+@%-]+$/.test(argument)
|
|
200
|
+
? argument
|
|
201
|
+
: `'${argument.replaceAll("'", `'\\''`)}'`,
|
|
202
|
+
)
|
|
203
|
+
.join(" ")
|
|
204
|
+
|
|
205
|
+
const targetOutput = (
|
|
206
|
+
node: EntityNode,
|
|
207
|
+
nodes: readonly GraphNode[],
|
|
208
|
+
options: Pick<ActOptions, "auto" | "draft">,
|
|
209
|
+
) => ({
|
|
210
|
+
kind: node.kind,
|
|
211
|
+
id: node.id,
|
|
212
|
+
key: node.key,
|
|
213
|
+
status: node.status,
|
|
214
|
+
readiness: node.readiness,
|
|
215
|
+
revision: node.data.sha256,
|
|
216
|
+
actions: actionChoices(node, nodes).map((choice) => ({
|
|
217
|
+
id: choice.value,
|
|
218
|
+
label: choice.label,
|
|
219
|
+
command: actionCommand(node, choice.value, options),
|
|
220
|
+
})),
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
const selectedEntityKey = (options: ActOptions) =>
|
|
224
|
+
options.epicId
|
|
225
|
+
? `epic:${options.epicId}`
|
|
226
|
+
: options.phaseId
|
|
227
|
+
? `phase:${options.taskId}/${options.phaseId}`
|
|
228
|
+
: options.taskId
|
|
229
|
+
? `task:${options.taskId}`
|
|
230
|
+
: undefined
|
|
231
|
+
|
|
232
|
+
const sameActions = (
|
|
233
|
+
left: readonly Choice<ActAction>[],
|
|
234
|
+
right: readonly Choice<ActAction>[],
|
|
235
|
+
) =>
|
|
236
|
+
left.map((choice) => choice.value).join("\0") ===
|
|
237
|
+
right.map((choice) => choice.value).join("\0")
|
|
238
|
+
|
|
239
|
+
export const act = (
|
|
240
|
+
options: ActOptions = {},
|
|
241
|
+
interaction: ActInteraction = defaultInteraction,
|
|
242
|
+
work: StartWork = startWork,
|
|
243
|
+
) =>
|
|
244
|
+
Effect.gen(function* () {
|
|
245
|
+
if (!options.json && options.inputAllowed === false) {
|
|
246
|
+
return yield* Effect.fail(
|
|
247
|
+
new Error(
|
|
248
|
+
"agency act requires interactive input; use --json to list actions for automation",
|
|
249
|
+
),
|
|
250
|
+
)
|
|
251
|
+
}
|
|
252
|
+
const cwd = options.cwd ?? process.cwd()
|
|
253
|
+
const workbase = yield* WorkbaseService
|
|
254
|
+
const graphs = yield* GraphService
|
|
255
|
+
const { log } = createLoggers(options)
|
|
256
|
+
const { config } = yield* workbase.loadConfig(cwd)
|
|
257
|
+
const graph = yield* graphs.get({ cwd })
|
|
258
|
+
const nodes = graph.nodes.filter(
|
|
259
|
+
(node): node is EntityNode =>
|
|
260
|
+
node.kind === "epic" || node.kind === "task" || node.kind === "phase",
|
|
261
|
+
)
|
|
262
|
+
if (nodes.length === 0) {
|
|
263
|
+
if (options.json) {
|
|
264
|
+
log(JSON.stringify({ targets: [] }, null, 2))
|
|
265
|
+
return
|
|
266
|
+
}
|
|
267
|
+
return yield* Effect.fail(
|
|
268
|
+
new Error("No active work items found in this workbase"),
|
|
269
|
+
)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const requestedKey = selectedEntityKey(options)
|
|
273
|
+
const matchingNodes = requestedKey
|
|
274
|
+
? nodes.filter((node) => entityKey(node) === requestedKey)
|
|
275
|
+
: nodes
|
|
276
|
+
if (requestedKey && matchingNodes.length === 0) {
|
|
277
|
+
return yield* Effect.fail(
|
|
278
|
+
new Error(`Selected work item '${requestedKey}' was not found`),
|
|
279
|
+
)
|
|
280
|
+
}
|
|
281
|
+
if (options.json) {
|
|
282
|
+
log(
|
|
283
|
+
JSON.stringify(
|
|
284
|
+
{
|
|
285
|
+
targets: matchingNodes.map((node) =>
|
|
286
|
+
targetOutput(node, graph.nodes, options),
|
|
287
|
+
),
|
|
288
|
+
},
|
|
289
|
+
null,
|
|
290
|
+
2,
|
|
291
|
+
),
|
|
292
|
+
)
|
|
293
|
+
return
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const selectedKey =
|
|
297
|
+
requestedKey ??
|
|
298
|
+
(yield* interaction.select(
|
|
299
|
+
"Act on",
|
|
300
|
+
entityChoices(nodes),
|
|
301
|
+
config.chooserCommand,
|
|
302
|
+
))
|
|
303
|
+
if (selectedKey === null) return
|
|
304
|
+
const selected = nodes.find((node) => entityKey(node) === selectedKey)
|
|
305
|
+
if (!selected) {
|
|
306
|
+
return yield* Effect.fail(
|
|
307
|
+
new Error("Selected work item is no longer available"),
|
|
308
|
+
)
|
|
309
|
+
}
|
|
310
|
+
const offeredActions = actionChoices(selected, graph.nodes)
|
|
311
|
+
if (offeredActions.length === 0) {
|
|
312
|
+
return yield* Effect.fail(
|
|
313
|
+
new Error(
|
|
314
|
+
`No actions are currently available for ${selected.kind} '${selected.key}'`,
|
|
315
|
+
),
|
|
316
|
+
)
|
|
317
|
+
}
|
|
318
|
+
const action = yield* interaction.select(
|
|
319
|
+
`Act on ${selected.kind} ${selected.key}`,
|
|
320
|
+
offeredActions,
|
|
321
|
+
config.chooserCommand,
|
|
322
|
+
)
|
|
323
|
+
if (action === null) return
|
|
324
|
+
|
|
325
|
+
const refreshed = yield* graphs.get({ cwd })
|
|
326
|
+
const current = refreshed.nodes.find(
|
|
327
|
+
(node): node is EntityNode =>
|
|
328
|
+
(node.kind === "epic" ||
|
|
329
|
+
node.kind === "task" ||
|
|
330
|
+
node.kind === "phase") &&
|
|
331
|
+
entityKey(node) === selectedKey,
|
|
332
|
+
)
|
|
333
|
+
if (!current) {
|
|
334
|
+
return yield* Effect.fail(
|
|
335
|
+
new Error(
|
|
336
|
+
"Selected work item changed or was removed; run agency act again",
|
|
337
|
+
),
|
|
338
|
+
)
|
|
339
|
+
}
|
|
340
|
+
if (
|
|
341
|
+
current.data.sha256 !== selected.data.sha256 ||
|
|
342
|
+
!sameActions(offeredActions, actionChoices(current, refreshed.nodes))
|
|
343
|
+
) {
|
|
344
|
+
return yield* Effect.fail(
|
|
345
|
+
new Error("Selected work item changed; run agency act again"),
|
|
346
|
+
)
|
|
347
|
+
}
|
|
348
|
+
const { taskId, phaseId } = entityParts(current)
|
|
349
|
+
if (options.dryRun) {
|
|
350
|
+
log(shellCommand(actionCommand(current, action, options)))
|
|
351
|
+
return
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
switch (action) {
|
|
355
|
+
case "work":
|
|
356
|
+
yield* work({
|
|
357
|
+
...(current.kind === "epic"
|
|
358
|
+
? { epicId: current.key }
|
|
359
|
+
: { taskId, ...(phaseId ? { phaseId } : {}) }),
|
|
360
|
+
auto: options.auto,
|
|
361
|
+
cwd,
|
|
362
|
+
inputAllowed: options.inputAllowed,
|
|
363
|
+
silent: options.silent,
|
|
364
|
+
verbose: options.verbose,
|
|
365
|
+
})
|
|
366
|
+
return
|
|
367
|
+
case "pr":
|
|
368
|
+
yield* createPullRequest({
|
|
369
|
+
taskId,
|
|
370
|
+
phaseId,
|
|
371
|
+
draft: options.draft,
|
|
372
|
+
cwd,
|
|
373
|
+
silent: options.silent,
|
|
374
|
+
verbose: options.verbose,
|
|
375
|
+
})
|
|
376
|
+
return
|
|
377
|
+
case "reopen":
|
|
378
|
+
if (phaseId) {
|
|
379
|
+
yield* phaseCommand({
|
|
380
|
+
subcommand: "status",
|
|
381
|
+
args: [taskId, phaseId, "open"],
|
|
382
|
+
cwd,
|
|
383
|
+
silent: options.silent,
|
|
384
|
+
verbose: options.verbose,
|
|
385
|
+
})
|
|
386
|
+
} else {
|
|
387
|
+
yield* taskCommand({
|
|
388
|
+
subcommand: "status",
|
|
389
|
+
args: [taskId, "open"],
|
|
390
|
+
cwd,
|
|
391
|
+
silent: options.silent,
|
|
392
|
+
verbose: options.verbose,
|
|
393
|
+
})
|
|
394
|
+
}
|
|
395
|
+
return
|
|
396
|
+
case "drop":
|
|
397
|
+
if (phaseId) {
|
|
398
|
+
yield* phaseCommand({
|
|
399
|
+
subcommand: "status",
|
|
400
|
+
args: [taskId, phaseId, "dropped"],
|
|
401
|
+
cwd,
|
|
402
|
+
silent: options.silent,
|
|
403
|
+
verbose: options.verbose,
|
|
404
|
+
})
|
|
405
|
+
} else {
|
|
406
|
+
yield* taskCommand({
|
|
407
|
+
subcommand: "status",
|
|
408
|
+
args: [taskId, "dropped"],
|
|
409
|
+
cwd,
|
|
410
|
+
silent: options.silent,
|
|
411
|
+
verbose: options.verbose,
|
|
412
|
+
})
|
|
413
|
+
}
|
|
414
|
+
return
|
|
415
|
+
case "archive":
|
|
416
|
+
yield* archiveCommand({
|
|
417
|
+
type: current.kind,
|
|
418
|
+
args: current.kind === "phase" ? [taskId, phaseId!] : [current.key],
|
|
419
|
+
cwd,
|
|
420
|
+
silent: options.silent,
|
|
421
|
+
verbose: options.verbose,
|
|
422
|
+
})
|
|
423
|
+
}
|
|
424
|
+
})
|
|
425
|
+
|
|
426
|
+
export const help = `
|
|
427
|
+
Usage: agency act [--epic <id> | --task <id> [--phase <id>]] [--dry-run | --json] [--auto] [--draft]
|
|
428
|
+
|
|
429
|
+
Interactively choose an active work item and a state-aware lifecycle action.
|
|
430
|
+
Selectors skip work-item selection. --dry-run prints the selected action's exact
|
|
431
|
+
Agency command without executing it. --json lists targets, available actions,
|
|
432
|
+
and command argv without prompting or executing.
|
|
433
|
+
|
|
434
|
+
Options:
|
|
435
|
+
--epic <id> Select an epic
|
|
436
|
+
--task <id> Select a task
|
|
437
|
+
--phase <id> Select a phase; requires --task
|
|
438
|
+
--dry-run Select an action and print its command without executing
|
|
439
|
+
--json List available actions and command argv as JSON
|
|
440
|
+
--auto Pass --auto when starting or continuing work
|
|
441
|
+
--draft Create a draft pull request
|
|
442
|
+
`
|
|
@@ -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,
|