@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.
- package/README.md +50 -0
- package/cli-main.ts +2 -0
- package/fixtures/protocol/orchestration-recipes.json +19 -0
- package/package.json +1 -1
- package/schemas/agency-graph-v1.schema.json +53 -0
- package/src/cli-parser.test.ts +49 -1
- package/src/cli-parser.ts +30 -1
- package/src/commands/task-phase.test.ts +49 -0
- package/src/commands/task.ts +47 -1
- package/src/graph-schema.test.ts +8 -0
- package/src/graph-schema.ts +7 -0
- package/src/services/ArchiveService.test.ts +18 -1
- package/src/services/GraphMutationService.test.ts +48 -0
- package/src/services/IntegrationService.test.ts +10 -0
- package/src/services/LifecycleTransaction.ts +12 -0
- package/src/services/PhaseService.ts +2 -0
- package/src/services/TaskPhaseService.test.ts +238 -0
- package/src/services/TaskService.ts +265 -6
- package/src/services/WorkbaseService.ts +30 -0
- package/src/workbase/AGENTS.md +25 -0
- package/src/workbase/frontmatter.test.ts +18 -1
- package/src/workbase/frontmatter.ts +29 -1
- package/src/workbase/opencode-file.ts +1 -1
- package/src/workbase/schemas.test.ts +36 -0
- package/src/workbase/schemas.ts +33 -1
|
@@ -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;
|
|
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:
|
|
277
|
-
|
|
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) {
|
package/src/workbase/AGENTS.md
CHANGED
|
@@ -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
|