@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "2.61.3",
3
+ "version": "2.63.0",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -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,
@@ -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. Workbase context catalogs all epics, tasks, and phases.
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 target = inferTarget()
282
- if (!target) {
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 task = target.taskId
338
- ? yield* readDocument(
339
- target.taskId,
340
- join(root, "tasks", target.taskId, "TASK.md"),
341
- TaskFrontmatter,
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
- join(root, "epics", epicId, "EPIC.md"),
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 = join(root, "tasks")
378
- if (yield* fs.isDirectory(taskRoot)) {
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 (document.taskDocument) {
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.set(key, phaseDocument)
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
- ? descendantsReady &&
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 = phase?.data
781
- ? phase.data
782
- : task?.data && "repo" in task.data
783
- ? (task.data as ExecutionData)
784
- : null
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 ? task.data.review : null
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[] = reviewData
797
- ? [{ repo: reviewData.repo, ref: reviewData.commit }]
798
- : executionData
799
- ? (executionData.repos ?? [])
800
- : (epic?.data.repos ?? [])
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: executionData?.pr
1128
- ? normalizePullRequestRecord(executionData.pr)
1213
+ pr: pullRequestData?.pr
1214
+ ? normalizePullRequestRecord(pullRequestData.pr)
1129
1215
  : { url: null, state: "none" },
1130
1216
  validation: {
1131
1217
  valid: validation.valid,