@markjaquith/agency 2.62.0 → 2.64.0

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