@markjaquith/agency 2.60.0 → 2.61.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.
@@ -116,6 +116,234 @@ describe("task and phase services", () => {
116
116
  expect(epic.data.tasks).toEqual([])
117
117
  })
118
118
 
119
+ test("creates revision-bound task and phase investigation handoffs", async () => {
120
+ const source = await runTestEffect(
121
+ TaskService.pipe(
122
+ Effect.flatMap((service) =>
123
+ service.create(
124
+ {
125
+ id: "investigate",
126
+ ticketUrl: null,
127
+ multiPhase: true,
128
+ purpose: "investigation",
129
+ },
130
+ root,
131
+ ),
132
+ ),
133
+ ),
134
+ )
135
+ const phase = await runTestEffect(
136
+ PhaseService.pipe(
137
+ Effect.flatMap((service) =>
138
+ service.create(
139
+ {
140
+ taskId: "investigate",
141
+ id: "evidence",
142
+ repo: "agency",
143
+ branch: "task/investigate-evidence",
144
+ base: "main",
145
+ },
146
+ root,
147
+ ),
148
+ ),
149
+ ),
150
+ )
151
+ const currentSource = await runTestEffect(
152
+ TaskService.pipe(
153
+ Effect.flatMap((service) => service.show("investigate", root)),
154
+ ),
155
+ )
156
+ const sourceContent = await Bun.file(source.path).text()
157
+ const phaseContent = await Bun.file(phase.path).text()
158
+
159
+ const fromTask = await runTestEffect(
160
+ TaskService.pipe(
161
+ Effect.flatMap((service) =>
162
+ service.handoff(
163
+ {
164
+ sourceTaskId: "investigate",
165
+ id: "implement-task",
166
+ ticketUrl: null,
167
+ repo: "agency",
168
+ branch: "task/implement-task",
169
+ base: "main",
170
+ },
171
+ root,
172
+ ),
173
+ ),
174
+ ),
175
+ )
176
+ const fromPhase = await runTestEffect(
177
+ TaskService.pipe(
178
+ Effect.flatMap((service) =>
179
+ service.handoff(
180
+ {
181
+ sourceTaskId: "investigate",
182
+ sourcePhaseId: "evidence",
183
+ id: "implement-phase",
184
+ ticketUrl: null,
185
+ repo: "effect",
186
+ branch: "task/implement-phase",
187
+ base: "main",
188
+ },
189
+ root,
190
+ ),
191
+ ),
192
+ ),
193
+ )
194
+
195
+ expect(fromTask.source).toEqual({
196
+ selector: "task/investigate",
197
+ documentPath: source.path,
198
+ revision: currentSource.revision,
199
+ })
200
+ expect(fromPhase.source).toEqual({
201
+ selector: "phase/investigate/evidence",
202
+ documentPath: phase.path,
203
+ revision: phase.revision,
204
+ })
205
+ expect(fromPhase.validation.valid).toBe(true)
206
+ expect(fromPhase.worktreePrepare.command).toEqual([
207
+ "agency",
208
+ "worktree",
209
+ "prepare",
210
+ "implement-phase",
211
+ ])
212
+ expect(await Bun.file(source.path).text()).toBe(sourceContent)
213
+ expect(await Bun.file(phase.path).text()).toBe(phaseContent)
214
+ expect(
215
+ await Bun.file(join(root, "tasks/implement-phase/code")).exists(),
216
+ ).toBe(false)
217
+
218
+ const created = await runTestEffect(
219
+ TaskService.pipe(
220
+ Effect.flatMap((service) => service.show("implement-phase", root)),
221
+ ),
222
+ )
223
+ expect(created.data).toMatchObject({
224
+ purpose: "implementation",
225
+ handoff: {
226
+ source: {
227
+ kind: "phase",
228
+ taskId: "investigate",
229
+ phaseId: "evidence",
230
+ },
231
+ sourceRevision: phase.revision,
232
+ },
233
+ status: "open",
234
+ })
235
+ await Bun.write(
236
+ phase.path,
237
+ phaseContent.replace(
238
+ "Describe the phase outcome.",
239
+ "Recorded evidence remains stale-safe.",
240
+ ),
241
+ )
242
+ const staleSource = await runTestEffect(
243
+ TaskService.pipe(
244
+ Effect.flatMap((service) => service.show("implement-phase", root)),
245
+ ),
246
+ )
247
+ expect(staleSource.data.handoff?.sourceRevision).toBe(phase.revision)
248
+
249
+ await expect(
250
+ runTestEffect(
251
+ TaskService.pipe(
252
+ Effect.flatMap((service) =>
253
+ service.handoff(
254
+ {
255
+ sourceTaskId: "investigate",
256
+ id: "implement-task",
257
+ ticketUrl: null,
258
+ repo: "agency",
259
+ branch: "task/duplicate",
260
+ base: "main",
261
+ },
262
+ root,
263
+ ),
264
+ ),
265
+ ),
266
+ ),
267
+ ).rejects.toThrow("already exists")
268
+
269
+ await mkdir(join(root, "archive/tasks/reserved"), { recursive: true })
270
+ await expect(
271
+ runTestEffect(
272
+ TaskService.pipe(
273
+ Effect.flatMap((service) =>
274
+ service.handoff(
275
+ {
276
+ sourceTaskId: "investigate",
277
+ id: "reserved",
278
+ ticketUrl: null,
279
+ repo: "agency",
280
+ branch: "task/reserved",
281
+ base: "main",
282
+ },
283
+ root,
284
+ ),
285
+ ),
286
+ ),
287
+ ),
288
+ ).rejects.toThrow("explicit creation requires a different ID")
289
+ })
290
+
291
+ test("rolls back handoff creation when a source revision is stale", async () => {
292
+ const source = await runTestEffect(
293
+ TaskService.pipe(
294
+ Effect.flatMap((service) =>
295
+ service.create(
296
+ {
297
+ id: "stale-investigation",
298
+ ticketUrl: null,
299
+ repo: "agency",
300
+ branch: "task/stale-investigation",
301
+ base: "main",
302
+ purpose: "investigation",
303
+ },
304
+ root,
305
+ ),
306
+ ),
307
+ ),
308
+ )
309
+ const staleRevision = source.revision
310
+ await Bun.write(
311
+ source.path,
312
+ `${source.content}\nChanged after inspection.\n`,
313
+ )
314
+
315
+ await expect(
316
+ runTestEffect(
317
+ TaskService.pipe(
318
+ Effect.flatMap((service) =>
319
+ service.create(
320
+ {
321
+ id: "stale-implementation",
322
+ ticketUrl: null,
323
+ repo: "effect",
324
+ branch: "task/stale-implementation",
325
+ base: "main",
326
+ purpose: "implementation",
327
+ handoff: {
328
+ source: {
329
+ kind: "task",
330
+ taskId: "stale-investigation",
331
+ },
332
+ sourceRevision: staleRevision,
333
+ },
334
+ preconditions: [{ path: source.path, revision: staleRevision }],
335
+ },
336
+ root,
337
+ ),
338
+ ),
339
+ ),
340
+ ),
341
+ ).rejects.toThrow("Revision conflict")
342
+ expect(
343
+ await Bun.file(join(root, "tasks/stale-implementation/TASK.md")).exists(),
344
+ ).toBe(false)
345
+ })
346
+
119
347
  test("creates and sequences phases on a multi-phase task", async () => {
120
348
  await runTestEffect(
121
349
  TaskService.pipe(
@@ -192,6 +420,11 @@ describe("task and phase services", () => {
192
420
  repos: [{ repo: "effect", ref: "main" }],
193
421
  branch: "task/single",
194
422
  base: "main",
423
+ purpose: "implementation",
424
+ handoff: {
425
+ source: { kind: "task", taskId: "investigation" },
426
+ sourceRevision: "a".repeat(64),
427
+ },
195
428
  },
196
429
  root,
197
430
  ),
@@ -256,6 +489,11 @@ describe("task and phase services", () => {
256
489
  expect(task.data).toEqual({
257
490
  ticketUrl: "https://example.com/task",
258
491
  description: "Deliver the complete task.",
492
+ purpose: "implementation",
493
+ handoff: {
494
+ source: { kind: "task", taskId: "investigation" },
495
+ sourceRevision: "a".repeat(64),
496
+ },
259
497
  phases: [
260
498
  { id: "implementation" },
261
499
  { id: "extra", dependsOn: ["implementation"] },
@@ -1,15 +1,19 @@
1
1
  import { Schema, TreeFormatter } from "@effect/schema"
2
2
  import { Data, Effect, Either } from "effect"
3
+ import { readdir } from "node:fs/promises"
3
4
  import { join } from "node:path"
4
5
  import { FileSystemService } from "./FileSystemService"
5
- import { WorkbaseService } from "./WorkbaseService"
6
+ import { WorkbaseService, type ValidationReport } from "./WorkbaseService"
6
7
  import { VersionControlService } from "./VersionControlService"
7
8
  import { EpicService, type EpicRecord } from "./EpicService"
8
9
  import {
9
10
  EntityId,
11
+ PhaseFrontmatter,
10
12
  TaskFrontmatter,
11
13
  type RepositoryReference,
12
14
  type ReviewRecord,
15
+ type TaskHandoff,
16
+ type TaskPurpose,
13
17
  type TaskFrontmatter as TaskData,
14
18
  WorkStatus,
15
19
  } from "../workbase/schemas"
@@ -17,6 +21,7 @@ import {
17
21
  formatMarkdownDocument,
18
22
  formatWorkDocumentBody,
19
23
  parseFrontmatter,
24
+ parseFrontmatterSync,
20
25
  } from "../workbase/frontmatter"
21
26
  import { canTransitionStatus } from "../readiness"
22
27
  import { documentRevision } from "../workbase/document-revision"
@@ -27,6 +32,7 @@ import {
27
32
  } from "../workbase/completion"
28
33
  import {
29
34
  documentWriteStep,
35
+ pathMustNotExistStep,
30
36
  runLifecycleTransaction,
31
37
  type TransactionStep,
32
38
  } from "./LifecycleTransaction"
@@ -54,6 +60,27 @@ export interface CreateTaskInput {
54
60
  readonly branch?: string
55
61
  readonly base?: string
56
62
  readonly review?: ReviewRecord
63
+ readonly purpose?: TaskPurpose
64
+ readonly handoff?: TaskHandoff
65
+ readonly preconditions?: readonly {
66
+ readonly path: string
67
+ readonly revision: string
68
+ }[]
69
+ readonly transactionSteps?: readonly TransactionStep[]
70
+ readonly postWriteSteps?: readonly TransactionStep[]
71
+ }
72
+
73
+ export interface HandoffTaskInput {
74
+ readonly sourceTaskId: string
75
+ readonly sourcePhaseId?: string
76
+ readonly id: string
77
+ readonly ticketUrl: string | null
78
+ readonly description?: string
79
+ readonly epic?: string
80
+ readonly repo: string
81
+ readonly repos?: readonly RepositoryReference[]
82
+ readonly branch: string
83
+ readonly base: string
57
84
  }
58
85
 
59
86
  const decodeTask = (input: unknown) => {
@@ -82,6 +109,72 @@ const decodeStatus = (status: string) => {
82
109
  : Effect.succeed(result.right)
83
110
  }
84
111
 
112
+ const decodePhase = (input: unknown) => {
113
+ const result = Schema.decodeUnknownEither(PhaseFrontmatter, {
114
+ errors: "all",
115
+ onExcessProperty: "error",
116
+ })(input)
117
+ return Either.isLeft(result)
118
+ ? Effect.fail(
119
+ new TaskError({ message: TreeFormatter.formatErrorSync(result.left) }),
120
+ )
121
+ : Effect.succeed(result.right)
122
+ }
123
+
124
+ const branchAvailableStep = (
125
+ root: string,
126
+ repo: string,
127
+ branch: string,
128
+ destinationId: string,
129
+ ): TransactionStep => ({
130
+ label: `verify branch ${repo}:${branch} is available`,
131
+ preflight: async () => {
132
+ const taskEntries = await readdir(join(root, "tasks"), {
133
+ withFileTypes: true,
134
+ }).catch(() => [])
135
+ for (const taskEntry of taskEntries) {
136
+ if (!taskEntry.isDirectory() || taskEntry.name === destinationId) continue
137
+ const taskPath = join(root, "tasks", taskEntry.name, "TASK.md")
138
+ const taskContent = await Bun.file(taskPath).text()
139
+ const taskData = Schema.decodeUnknownSync(TaskFrontmatter, {
140
+ errors: "all",
141
+ onExcessProperty: "error",
142
+ })(parseFrontmatterSync(taskContent, taskPath).data)
143
+ if (
144
+ "repo" in taskData &&
145
+ taskData.repo === repo &&
146
+ taskData.branch === branch
147
+ ) {
148
+ throw new Error(
149
+ `Writable branch '${branch}' for repository '${repo}' is already owned by task '${taskEntry.name}'`,
150
+ )
151
+ }
152
+ if (!("phases" in taskData)) continue
153
+ for (const phase of taskData.phases) {
154
+ const phasePath = join(
155
+ root,
156
+ "tasks",
157
+ taskEntry.name,
158
+ "phases",
159
+ phase.id,
160
+ "PHASE.md",
161
+ )
162
+ const phaseContent = await Bun.file(phasePath).text()
163
+ const phaseData = Schema.decodeUnknownSync(PhaseFrontmatter, {
164
+ errors: "all",
165
+ onExcessProperty: "error",
166
+ })(parseFrontmatterSync(phaseContent, phasePath).data)
167
+ if (phaseData.repo === repo && phaseData.branch === branch) {
168
+ throw new Error(
169
+ `Writable branch '${branch}' for repository '${repo}' is already owned by phase '${taskEntry.name}/${phase.id}'`,
170
+ )
171
+ }
172
+ }
173
+ }
174
+ },
175
+ apply: async () => {},
176
+ })
177
+
85
178
  const reviewPinRef = (taskId: string) =>
86
179
  `refs/agency/reviews/${Buffer.from(taskId).toString("hex")}`
87
180
 
@@ -151,7 +244,22 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
151
244
  }
152
245
  if (yield* fs.exists(archivedTaskDirectory(root, id))) {
153
246
  return yield* new TaskError({
154
- message: `Task '${id}' is archived; restore it before reusing this ID`,
247
+ message: `Task '${id}' is archived; explicit creation requires a different ID`,
248
+ })
249
+ }
250
+ const taskMetadata = {
251
+ ...(input.purpose ? { purpose: input.purpose } : {}),
252
+ ...(input.handoff ? { handoff: input.handoff } : {}),
253
+ }
254
+ if (input.handoff && input.purpose !== "implementation") {
255
+ return yield* new TaskError({
256
+ message:
257
+ "Task handoff provenance requires purpose 'implementation'",
258
+ })
259
+ }
260
+ if (input.purpose === "implementation" && !input.handoff) {
261
+ return yield* new TaskError({
262
+ message: "Implementation-purpose tasks require handoff provenance",
155
263
  })
156
264
  }
157
265
 
@@ -175,6 +283,7 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
175
283
  ? { description: input.description }
176
284
  : {}),
177
285
  ...(input.epic ? { epic: input.epic } : {}),
286
+ ...taskMetadata,
178
287
  review: input.review,
179
288
  })
180
289
  } else if (input.multiPhase) {
@@ -184,6 +293,7 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
184
293
  ? { description: input.description }
185
294
  : {}),
186
295
  ...(input.epic ? { epic: input.epic } : {}),
296
+ ...taskMetadata,
187
297
  phases: [],
188
298
  })
189
299
  } else {
@@ -198,6 +308,7 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
198
308
  ? { description: input.description }
199
309
  : {}),
200
310
  ...(input.epic ? { epic: input.epic } : {}),
311
+ ...taskMetadata,
201
312
  repo: input.repo,
202
313
  ...(input.repos?.length ? { repos: input.repos } : {}),
203
314
  branch: input.branch,
@@ -245,7 +356,7 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
245
356
  .join(" ")
246
357
  const content = formatMarkdownDocument(
247
358
  data,
248
- formatWorkDocumentBody(title, "task"),
359
+ formatWorkDocumentBody(title, "task", input.purpose),
249
360
  )
250
361
  const writes: {
251
362
  path: string
@@ -273,14 +384,29 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
273
384
  }
274
385
  yield* runLifecycleTransaction({
275
386
  root,
276
- preconditions: parentEpic
277
- ? [{ path: parentEpic.path, revision: parentEpic.revision }]
278
- : [],
387
+ preconditions: [
388
+ ...(input.preconditions ?? []),
389
+ ...(parentEpic
390
+ ? [{ path: parentEpic.path, revision: parentEpic.revision }]
391
+ : []),
392
+ ],
279
393
  steps: [
394
+ ...(input.transactionSteps ?? []),
395
+ pathMustNotExistStep(
396
+ root,
397
+ directory,
398
+ `Task '${id}' already exists`,
399
+ ),
400
+ pathMustNotExistStep(
401
+ root,
402
+ archivedTaskDirectory(root, id),
403
+ `Task '${id}' is archived; explicit creation requires a different ID`,
404
+ ),
280
405
  ...(input.review
281
406
  ? [reviewPinStep(root, id, input.review, reviewEnvironment)]
282
407
  : []),
283
408
  documentWriteStep(root, writes),
409
+ ...(input.postWriteSteps ?? []),
284
410
  ],
285
411
  })
286
412
 
@@ -293,6 +419,139 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
293
419
  } satisfies TaskRecord
294
420
  }),
295
421
 
422
+ handoff: (input: HandoffTaskInput, startPath: string = process.cwd()) =>
423
+ Effect.gen(function* () {
424
+ const fs = yield* FileSystemService
425
+ const workbase = yield* WorkbaseService
426
+ const service = yield* TaskService
427
+ const root = yield* workbase.discover(startPath)
428
+ const sourceTaskId = yield* decodeId(input.sourceTaskId)
429
+ const sourceTask = yield* service.show(sourceTaskId, root)
430
+ const validation = yield* workbase.validate(root)
431
+ if (!validation.valid) {
432
+ return yield* new TaskError({
433
+ message: "Cannot create a handoff in an invalid workbase",
434
+ })
435
+ }
436
+ if (sourceTask.data.purpose !== "investigation") {
437
+ return yield* new TaskError({
438
+ message: `Task '${sourceTaskId}' is not an investigation task`,
439
+ })
440
+ }
441
+
442
+ const preconditions = [
443
+ { path: sourceTask.path, revision: sourceTask.revision },
444
+ ]
445
+ let source: TaskHandoff["source"]
446
+ let committedValidation: ValidationReport | undefined
447
+ let sourcePath = sourceTask.path
448
+ let sourceRevision = sourceTask.revision
449
+ if (input.sourcePhaseId) {
450
+ const phaseId = yield* decodeId(input.sourcePhaseId)
451
+ if (
452
+ !("phases" in sourceTask.data) ||
453
+ !sourceTask.data.phases.some((phase) => phase.id === phaseId)
454
+ ) {
455
+ return yield* new TaskError({
456
+ message: `Phase '${sourceTaskId}/${phaseId}' does not exist`,
457
+ })
458
+ }
459
+ sourcePath = join(
460
+ root,
461
+ "tasks",
462
+ sourceTaskId,
463
+ "phases",
464
+ phaseId,
465
+ "PHASE.md",
466
+ )
467
+ const sourceContent = yield* fs.readFile(sourcePath)
468
+ const parsed = yield* parseFrontmatter(sourceContent, sourcePath)
469
+ yield* decodePhase(parsed.data)
470
+ sourceRevision = documentRevision(sourceContent)
471
+ preconditions.push({ path: sourcePath, revision: sourceRevision })
472
+ source = { kind: "phase", taskId: sourceTaskId, phaseId }
473
+ } else {
474
+ source = { kind: "task", taskId: sourceTaskId }
475
+ }
476
+
477
+ const record = yield* service.create(
478
+ {
479
+ id: input.id,
480
+ ticketUrl: input.ticketUrl,
481
+ description: input.description,
482
+ epic: input.epic,
483
+ repo: input.repo,
484
+ repos: input.repos,
485
+ branch: input.branch,
486
+ base: input.base,
487
+ purpose: "implementation",
488
+ handoff: { source, sourceRevision },
489
+ preconditions,
490
+ transactionSteps: [
491
+ branchAvailableStep(root, input.repo, input.branch, input.id),
492
+ ],
493
+ postWriteSteps: [
494
+ {
495
+ label: "validate resulting workbase",
496
+ apply: async () => {
497
+ const report = await Effect.runPromise(
498
+ workbase
499
+ .validate(root)
500
+ .pipe(
501
+ Effect.provideService(FileSystemService, fs),
502
+ Effect.provideService(WorkbaseService, workbase),
503
+ ),
504
+ )
505
+ if (!report.valid) {
506
+ throw new Error(
507
+ `Handoff would create an invalid workbase: ${report.issues.map((issue) => `${issue.path}: ${issue.message}`).join("; ")}`,
508
+ )
509
+ }
510
+ committedValidation = report
511
+ },
512
+ },
513
+ ],
514
+ },
515
+ root,
516
+ )
517
+ if (!("repo" in record.data)) {
518
+ return yield* new TaskError({
519
+ message: `Task '${record.id}' is not an implementation execution unit`,
520
+ })
521
+ }
522
+ if (!committedValidation) {
523
+ return yield* new TaskError({
524
+ message: "Handoff validation did not produce a result",
525
+ })
526
+ }
527
+ const taskDirectory = join(root, "tasks", record.id)
528
+ const sourceSelector =
529
+ source.kind === "phase"
530
+ ? `phase/${source.taskId}/${source.phaseId}`
531
+ : `task/${source.taskId}`
532
+ return {
533
+ task: {
534
+ id: record.id,
535
+ selector: `task/${record.id}`,
536
+ directory: taskDirectory,
537
+ documentPath: record.path,
538
+ revision: record.revision,
539
+ branch: record.data.branch,
540
+ base: record.data.base,
541
+ },
542
+ source: {
543
+ selector: sourceSelector,
544
+ documentPath: sourcePath,
545
+ revision: sourceRevision,
546
+ },
547
+ validation: committedValidation,
548
+ worktreePrepare: {
549
+ target: `task/${record.id}`,
550
+ command: ["agency", "worktree", "prepare", record.id],
551
+ },
552
+ }
553
+ }),
554
+
296
555
  list: (startPath: string = process.cwd()) =>
297
556
  Effect.gen(function* () {
298
557
  const fs = yield* FileSystemService
@@ -818,6 +818,36 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
818
818
  validateRepositories(task)
819
819
  validateBranchOwnership(task)
820
820
  validateCompletion(task)
821
+ if (task.data.handoff && task.data.purpose !== "implementation") {
822
+ issue(
823
+ task.path,
824
+ "Task handoff provenance requires purpose 'implementation'",
825
+ )
826
+ }
827
+ if (task.data.purpose === "implementation" && !task.data.handoff) {
828
+ issue(
829
+ task.path,
830
+ "Implementation-purpose tasks require handoff provenance",
831
+ )
832
+ }
833
+ if (
834
+ task.data.handoff?.source.kind === "task" &&
835
+ task.data.handoff.source.taskId === task.id
836
+ ) {
837
+ issue(
838
+ task.path,
839
+ "Task handoff provenance cannot reference itself",
840
+ )
841
+ }
842
+ if (
843
+ task.data.handoff?.source.kind === "phase" &&
844
+ task.data.handoff.source.taskId === task.id
845
+ ) {
846
+ issue(
847
+ task.path,
848
+ "Task handoff provenance cannot reference one of its own phases",
849
+ )
850
+ }
821
851
  if (task.data.epic) {
822
852
  const parent = epics.get(task.data.epic)
823
853
  if (!parent) {
@@ -62,6 +62,31 @@ agent from an active agent session; creating a pull request; archiving, restorin
62
62
  dropping, reopening, or completing work without a pull request; or using `--force`
63
63
  to override readiness.
64
64
 
65
+ ## Investigation Handoffs
66
+
67
+ An explicit request for a new, separate, or follow-up item overrides reuse of
68
+ every active or archived item, even when the subject or suggested ID matches.
69
+ Existing work may be inspected for duplicate scope, but do not mutate or select
70
+ it as the destination unless the user explicitly asks to reuse it.
71
+
72
+ Use this ordered flow for investigation-to-implementation work:
73
+
74
+ 1. Create investigation-only work with `agency task create <id> --purpose
75
+ investigation ...`.
76
+ 2. Record its boundary, evidence, findings, recommendation, and any no-change
77
+ outcome in the generated task sections.
78
+ 3. Create a distinct implementation task with `agency task handoff
79
+ <investigation-task> <new-task> ...`; add `--source-phase <phase>` when the
80
+ evidence belongs to one phase.
81
+ 4. Verify the returned new selector, source selector and revision, validation
82
+ result, and then confirm authority with `agency context <new-task> --json`.
83
+ 5. Prepare, open, or launch the new item only when separately requested.
84
+
85
+ Creation and handoff do not imply worktree preparation, status changes, agent
86
+ launch, or UI actions. Handoff provenance is one-way historical evidence, not a
87
+ dependency: source rename or content changes can make its selector unresolved or
88
+ revision stale, and Agency must not silently rewrite that evidence.
89
+
65
90
  ## Safety
66
91
 
67
92
  - Stop on validation errors, dependency blockers, an unexpected writable