@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.
@@ -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
@@ -104,8 +104,24 @@ describe("delivery commands", () => {
104
104
  isDraft: false,
105
105
  }
106
106
  expect(
107
- recordFromGitHubJson({ ...base, state: "OPEN", mergeable: "MERGEABLE" }),
108
- ).toMatchObject({ state: "open", merged: false, mergeable: true })
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
- ) => `# ${title}
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,
@@ -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(Schema.pattern(/^[a-f0-9]{64}$/))
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>