@markjaquith/agency 2.60.0 → 2.61.1
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/cli.test.ts +10 -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/PullRequestService.ts +11 -2
- package/src/services/SyncService.test.ts +90 -3
- package/src/services/SyncService.ts +27 -21
- 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/delivery-command.test.ts +18 -2
- package/src/workbase/delivery-command.ts +13 -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 +37 -1
|
@@ -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
|
|
@@ -104,8 +104,24 @@ describe("delivery commands", () => {
|
|
|
104
104
|
isDraft: false,
|
|
105
105
|
}
|
|
106
106
|
expect(
|
|
107
|
-
recordFromGitHubJson({
|
|
108
|
-
|
|
107
|
+
recordFromGitHubJson({
|
|
108
|
+
...base,
|
|
109
|
+
state: "OPEN",
|
|
110
|
+
headRefName: "feat/example",
|
|
111
|
+
baseRefName: "main",
|
|
112
|
+
headRepository: { nameWithOwner: "fork/agency" },
|
|
113
|
+
baseRepository: { nameWithOwner: "example/agency" },
|
|
114
|
+
mergeable: "MERGEABLE",
|
|
115
|
+
}),
|
|
116
|
+
).toMatchObject({
|
|
117
|
+
state: "open",
|
|
118
|
+
merged: false,
|
|
119
|
+
headRepository: "fork/agency",
|
|
120
|
+
headBranch: "feat/example",
|
|
121
|
+
baseRepository: "example/agency",
|
|
122
|
+
baseBranch: "main",
|
|
123
|
+
mergeable: true,
|
|
124
|
+
})
|
|
109
125
|
expect(
|
|
110
126
|
recordFromGitHubJson({
|
|
111
127
|
...base,
|
|
@@ -156,11 +156,24 @@ export const recordFromGitHubUrl = (url: string): PullRequestRecord => {
|
|
|
156
156
|
export const recordFromGitHubJson = (value: Record<string, unknown>) => {
|
|
157
157
|
const url = typeof value.url === "string" ? value.url : ""
|
|
158
158
|
const record = recordFromGitHubUrl(url)
|
|
159
|
+
const repositoryName = (repository: unknown) => {
|
|
160
|
+
if (!repository || typeof repository !== "object") return undefined
|
|
161
|
+
const nameWithOwner = (repository as Record<string, unknown>).nameWithOwner
|
|
162
|
+
return typeof nameWithOwner === "string" && nameWithOwner
|
|
163
|
+
? nameWithOwner
|
|
164
|
+
: undefined
|
|
165
|
+
}
|
|
159
166
|
const githubState = String(value.state ?? "OPEN").toLowerCase()
|
|
160
167
|
const merged = githubState === "merged" || value.mergedAt != null
|
|
161
168
|
const mergeable = String(value.mergeable ?? "UNKNOWN").toLowerCase()
|
|
162
169
|
return {
|
|
163
170
|
...record,
|
|
171
|
+
headRepository: repositoryName(value.headRepository),
|
|
172
|
+
headBranch:
|
|
173
|
+
typeof value.headRefName === "string" ? value.headRefName : undefined,
|
|
174
|
+
baseRepository: repositoryName(value.baseRepository) ?? record.repository,
|
|
175
|
+
baseBranch:
|
|
176
|
+
typeof value.baseRefName === "string" ? value.baseRefName : undefined,
|
|
164
177
|
state: merged ? "merged" : githubState === "closed" ? "closed" : "open",
|
|
165
178
|
draft: value.isDraft === true,
|
|
166
179
|
merged,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test"
|
|
2
2
|
import { Effect } from "effect"
|
|
3
|
-
import { parseFrontmatter } from "./frontmatter"
|
|
3
|
+
import { formatWorkDocumentBody, parseFrontmatter } from "./frontmatter"
|
|
4
4
|
|
|
5
5
|
describe("parseFrontmatter", () => {
|
|
6
6
|
test("parses YAML 1.2 frontmatter and body", async () => {
|
|
@@ -48,3 +48,20 @@ describe("parseFrontmatter", () => {
|
|
|
48
48
|
).rejects.toThrow("must begin with YAML frontmatter")
|
|
49
49
|
})
|
|
50
50
|
})
|
|
51
|
+
|
|
52
|
+
describe("formatWorkDocumentBody", () => {
|
|
53
|
+
test("generates explicit investigation sections", () => {
|
|
54
|
+
const body = formatWorkDocumentBody(
|
|
55
|
+
"Investigate Checkout",
|
|
56
|
+
"task",
|
|
57
|
+
"investigation",
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
expect(body).toContain("## Investigation Boundary")
|
|
61
|
+
expect(body).toContain("## Evidence")
|
|
62
|
+
expect(body).toContain("## Findings")
|
|
63
|
+
expect(body).toContain("## Recommendation")
|
|
64
|
+
expect(body).toContain("## Implementation Handoff")
|
|
65
|
+
expect(body).not.toContain("Describe the task outcome")
|
|
66
|
+
})
|
|
67
|
+
})
|
|
@@ -85,7 +85,35 @@ export const formatMarkdownDocument = (data: object, body: string) =>
|
|
|
85
85
|
export const formatWorkDocumentBody = (
|
|
86
86
|
title: string,
|
|
87
87
|
kind: "epic" | "task" | "phase",
|
|
88
|
-
|
|
88
|
+
purpose?: "investigation" | "implementation",
|
|
89
|
+
) =>
|
|
90
|
+
purpose === "investigation"
|
|
91
|
+
? `# ${title}
|
|
92
|
+
|
|
93
|
+
## Investigation Boundary
|
|
94
|
+
|
|
95
|
+
State the question, scope, and implementation excluded from this investigation.
|
|
96
|
+
|
|
97
|
+
## Evidence
|
|
98
|
+
|
|
99
|
+
Record inspected sources, commands, observations, and supporting links.
|
|
100
|
+
|
|
101
|
+
## Findings
|
|
102
|
+
|
|
103
|
+
Record findings, confidence, uncertainty, and relevant constraints.
|
|
104
|
+
|
|
105
|
+
## Recommendation
|
|
106
|
+
|
|
107
|
+
Recommend implementation, no change, or further investigation.
|
|
108
|
+
|
|
109
|
+
## Implementation Handoff
|
|
110
|
+
|
|
111
|
+
Record any distinct implementation task created from this investigation.
|
|
112
|
+
|
|
113
|
+
## Important Decisions
|
|
114
|
+
|
|
115
|
+
Record consequential decisions and their rationale.`
|
|
116
|
+
: `# ${title}
|
|
89
117
|
|
|
90
118
|
## Outcome
|
|
91
119
|
|
|
@@ -10,7 +10,7 @@ const agencyPlanPrompt = `You are in Agency Plan mode. Think, read, search, and
|
|
|
10
10
|
|
|
11
11
|
Start with \`agency context . --json\`. Use its document paths and revisions, then inspect the graph, related epics, tasks, phases, linked tickets, and repository declarations needed to understand the work. Use machine-readable Agency output when available instead of inferring structure from directory names.
|
|
12
12
|
|
|
13
|
-
When planning an epic, decompose it into independently deliverable tasks with explicit dependencies. Add phases only when one task genuinely requires multiple ordered delivery units. Reuse or update existing work instead of creating duplicate tasks or phases.
|
|
13
|
+
When planning an epic, decompose it into independently deliverable tasks with explicit dependencies. Add phases only when one task genuinely requires multiple ordered delivery units. Reuse or update existing work instead of creating duplicate tasks or phases, except when the user explicitly requests a new, separate, or follow-up item. Explicit-new intent overrides reuse of active and archived work even when the subject or suggested ID matches.
|
|
14
14
|
|
|
15
15
|
Use the Agency CLI for full workbase orchestration when the plan requires it, including creating or updating related epics, tasks, and phases; moving tasks; maintaining dependencies; and changing lifecycle state. Use \`--if-revision\` with the revision returned by context for mutations that support it, and run \`agency validate\` after changing workbase structure. Use available ticket tools to inspect or update a linked external ticket when the plan requires it.
|
|
16
16
|
|
|
@@ -64,6 +64,42 @@ describe("portable repository declarations", () => {
|
|
|
64
64
|
})
|
|
65
65
|
|
|
66
66
|
describe("body-of-work descriptions", () => {
|
|
67
|
+
test("decodes strict task purpose and handoff provenance", () => {
|
|
68
|
+
const handoff = {
|
|
69
|
+
source: { kind: "phase", taskId: "investigate", phaseId: "evidence" },
|
|
70
|
+
sourceRevision: "a".repeat(64),
|
|
71
|
+
}
|
|
72
|
+
const task = Schema.decodeUnknownSync(TaskFrontmatter, {
|
|
73
|
+
onExcessProperty: "error",
|
|
74
|
+
})({
|
|
75
|
+
ticketUrl: null,
|
|
76
|
+
purpose: "implementation",
|
|
77
|
+
handoff,
|
|
78
|
+
repo: "agency",
|
|
79
|
+
branch: "task/implement",
|
|
80
|
+
base: "main",
|
|
81
|
+
pr: null,
|
|
82
|
+
})
|
|
83
|
+
expect(task).toMatchObject({ purpose: "implementation", handoff })
|
|
84
|
+
|
|
85
|
+
for (const invalid of [
|
|
86
|
+
{ ...handoff, sourceRevision: "short" },
|
|
87
|
+
{ ...handoff, source: { kind: "phase", taskId: "investigate" } },
|
|
88
|
+
{ ...handoff, source: { kind: "task", taskId: "../unsafe" } },
|
|
89
|
+
]) {
|
|
90
|
+
expect(() =>
|
|
91
|
+
Schema.decodeUnknownSync(TaskFrontmatter, {
|
|
92
|
+
onExcessProperty: "error",
|
|
93
|
+
})({
|
|
94
|
+
ticketUrl: null,
|
|
95
|
+
purpose: "implementation",
|
|
96
|
+
handoff: invalid,
|
|
97
|
+
phases: [],
|
|
98
|
+
}),
|
|
99
|
+
).toThrow()
|
|
100
|
+
}
|
|
101
|
+
})
|
|
102
|
+
|
|
67
103
|
test("decodes review tasks strictly and rejects writable execution fields", () => {
|
|
68
104
|
const review = {
|
|
69
105
|
ticketUrl: null,
|
package/src/workbase/schemas.ts
CHANGED
|
@@ -46,7 +46,9 @@ const IsoTimestamp = NonEmptyString.pipe(
|
|
|
46
46
|
|
|
47
47
|
const GitCommit = Schema.String.pipe(Schema.pattern(/^[a-f0-9]{40}$/))
|
|
48
48
|
|
|
49
|
-
const DocumentRevision = Schema.String.pipe(
|
|
49
|
+
export const DocumentRevision = Schema.String.pipe(
|
|
50
|
+
Schema.pattern(/^[a-f0-9]{64}$/),
|
|
51
|
+
)
|
|
50
52
|
|
|
51
53
|
export const ClaimRecord = Schema.Struct({
|
|
52
54
|
claimant: NonEmptyString,
|
|
@@ -70,6 +72,10 @@ const GitHubPullRequestUrl = NonEmptyString.pipe(
|
|
|
70
72
|
export const PullRequestRecord = Schema.Struct({
|
|
71
73
|
provider: EntityId,
|
|
72
74
|
repository: NonEmptyString,
|
|
75
|
+
headRepository: Schema.optional(NonEmptyString),
|
|
76
|
+
headBranch: Schema.optional(NonEmptyString),
|
|
77
|
+
baseRepository: Schema.optional(NonEmptyString),
|
|
78
|
+
baseBranch: Schema.optional(NonEmptyString),
|
|
73
79
|
identifier: NonEmptyString,
|
|
74
80
|
url: Url,
|
|
75
81
|
state: Schema.Literal("open", "closed", "merged"),
|
|
@@ -155,6 +161,30 @@ const ExecutionUnit = {
|
|
|
155
161
|
completion: Schema.optional(CompletionRecord),
|
|
156
162
|
}
|
|
157
163
|
|
|
164
|
+
export const TaskPurpose = Schema.Literal("investigation", "implementation")
|
|
165
|
+
|
|
166
|
+
export const TaskHandoffSource = Schema.Union(
|
|
167
|
+
Schema.Struct({
|
|
168
|
+
kind: Schema.Literal("task"),
|
|
169
|
+
taskId: EntityId,
|
|
170
|
+
}),
|
|
171
|
+
Schema.Struct({
|
|
172
|
+
kind: Schema.Literal("phase"),
|
|
173
|
+
taskId: EntityId,
|
|
174
|
+
phaseId: EntityId,
|
|
175
|
+
}),
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
export const TaskHandoff = Schema.Struct({
|
|
179
|
+
source: TaskHandoffSource,
|
|
180
|
+
sourceRevision: DocumentRevision,
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
const TaskMetadata = {
|
|
184
|
+
purpose: Schema.optional(TaskPurpose),
|
|
185
|
+
handoff: Schema.optional(TaskHandoff),
|
|
186
|
+
}
|
|
187
|
+
|
|
158
188
|
export const EpicFrontmatter = Schema.Struct({
|
|
159
189
|
ticketUrl: Url,
|
|
160
190
|
description: Description,
|
|
@@ -166,6 +196,7 @@ const SinglePhaseTaskFrontmatter = Schema.Struct({
|
|
|
166
196
|
ticketUrl: Schema.NullOr(Url),
|
|
167
197
|
description: Description,
|
|
168
198
|
epic: Schema.optional(EntityId),
|
|
199
|
+
...TaskMetadata,
|
|
169
200
|
...ExecutionUnit,
|
|
170
201
|
})
|
|
171
202
|
|
|
@@ -173,6 +204,7 @@ const MultiPhaseTaskFrontmatter = Schema.Struct({
|
|
|
173
204
|
ticketUrl: Schema.NullOr(Url),
|
|
174
205
|
description: Description,
|
|
175
206
|
epic: Schema.optional(EntityId),
|
|
207
|
+
...TaskMetadata,
|
|
176
208
|
phases: Schema.Array(Dependency),
|
|
177
209
|
})
|
|
178
210
|
|
|
@@ -221,6 +253,7 @@ const ReviewTaskFrontmatter = Schema.Struct({
|
|
|
221
253
|
ticketUrl: Schema.NullOr(Url),
|
|
222
254
|
description: Description,
|
|
223
255
|
epic: Schema.optional(EntityId),
|
|
256
|
+
...TaskMetadata,
|
|
224
257
|
review: ReviewRecord,
|
|
225
258
|
status: Schema.optionalWith(WorkStatus, { default: () => "open" as const }),
|
|
226
259
|
claim: Schema.optional(ClaimRecord),
|
|
@@ -254,6 +287,9 @@ export type PullRequestRecord = Schema.Schema.Type<typeof PullRequestRecord>
|
|
|
254
287
|
export type ReviewSource = Schema.Schema.Type<typeof ReviewSource>
|
|
255
288
|
export type ReviewRecord = Schema.Schema.Type<typeof ReviewRecord>
|
|
256
289
|
export type CompletionRecord = Schema.Schema.Type<typeof CompletionRecord>
|
|
290
|
+
export type TaskPurpose = Schema.Schema.Type<typeof TaskPurpose>
|
|
291
|
+
export type TaskHandoffSource = Schema.Schema.Type<typeof TaskHandoffSource>
|
|
292
|
+
export type TaskHandoff = Schema.Schema.Type<typeof TaskHandoff>
|
|
257
293
|
export type EpicFrontmatter = Schema.Schema.Type<typeof EpicFrontmatter>
|
|
258
294
|
export type TaskFrontmatter = Schema.Schema.Type<typeof TaskFrontmatter>
|
|
259
295
|
export type PhaseFrontmatter = Schema.Schema.Type<typeof PhaseFrontmatter>
|