@markjaquith/agency 2.49.0 → 2.50.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.
@@ -3,6 +3,8 @@ import {
3
3
  EpicFrontmatter,
4
4
  PhaseFrontmatter,
5
5
  PullRequestRecord,
6
+ ClaimRecord,
7
+ ReviewRecord,
6
8
  TaskFrontmatter,
7
9
  WorkStatus,
8
10
  } from "./workbase/schemas"
@@ -69,13 +71,24 @@ const DocumentHash = Schema.Struct({ sha256: Schema.String })
69
71
  export const GraphEpicData = Schema.extend(EpicFrontmatter, DocumentHash)
70
72
  export const GraphTaskData = Schema.extend(TaskFrontmatter, DocumentHash)
71
73
  export const GraphPhaseData = Schema.extend(PhaseFrontmatter, DocumentHash)
72
- export const GraphExecutionData = Schema.extend(
73
- PhaseFrontmatter,
74
+ export const GraphExecutionData = Schema.Union(
75
+ Schema.extend(
76
+ PhaseFrontmatter,
77
+ Schema.Struct({
78
+ taskId: Schema.String,
79
+ phaseId: Schema.optional(Schema.String),
80
+ ticketUrl: Schema.optional(Schema.NullOr(Schema.String)),
81
+ epic: Schema.optional(Schema.String),
82
+ }),
83
+ ),
74
84
  Schema.Struct({
75
85
  taskId: Schema.String,
76
- phaseId: Schema.optional(Schema.String),
77
- ticketUrl: Schema.optional(Schema.NullOr(Schema.String)),
86
+ ticketUrl: Schema.NullOr(Schema.String),
87
+ description: Schema.optional(Schema.String),
78
88
  epic: Schema.optional(Schema.String),
89
+ review: ReviewRecord,
90
+ status: WorkStatus,
91
+ claim: Schema.optional(ClaimRecord),
79
92
  }),
80
93
  )
81
94
 
@@ -98,15 +111,24 @@ export const GraphRepositoryGit = Schema.Struct({
98
111
  head: Schema.NullOr(Schema.String),
99
112
  branch: Schema.NullOr(Schema.String),
100
113
  })
101
- export const GraphExecutionGit = Schema.Struct({
102
- branch: Schema.String,
103
- base: Schema.String,
104
- branchCommit: Schema.NullOr(Schema.String),
105
- baseCommit: Schema.NullOr(Schema.String),
106
- checkoutCommit: Schema.NullOr(Schema.String),
107
- checkoutBranch: Schema.NullOr(Schema.String),
108
- dirty: Schema.NullOr(Schema.Boolean),
109
- })
114
+ export const GraphExecutionGit = Schema.Union(
115
+ Schema.Struct({
116
+ branch: Schema.String,
117
+ base: Schema.String,
118
+ branchCommit: Schema.NullOr(Schema.String),
119
+ baseCommit: Schema.NullOr(Schema.String),
120
+ checkoutCommit: Schema.NullOr(Schema.String),
121
+ checkoutBranch: Schema.NullOr(Schema.String),
122
+ dirty: Schema.NullOr(Schema.Boolean),
123
+ }),
124
+ Schema.Struct({
125
+ pinnedCommit: Schema.String,
126
+ sourceCommit: Schema.NullOr(Schema.String),
127
+ checkoutCommit: Schema.NullOr(Schema.String),
128
+ checkoutBranch: Schema.NullOr(Schema.String),
129
+ dirty: Schema.NullOr(Schema.Boolean),
130
+ }),
131
+ )
110
132
  export const GraphPr = Schema.Union(
111
133
  Schema.Struct({ url: Schema.Null, state: Schema.Literal("none") }),
112
134
  Schema.Struct({ url: Schema.String, state: Schema.Literal("unavailable") }),
package/src/protocol.ts CHANGED
@@ -100,6 +100,7 @@ const errorMetadata: Readonly<Record<string, ErrorMetadata>> = {
100
100
  ArchiveError: { code: "ARCHIVE_ERROR", retryable: false },
101
101
  WorktreeError: { code: "WORKTREE_ERROR", retryable: false },
102
102
  PullRequestError: { code: "PULL_REQUEST_ERROR", retryable: false },
103
+ ReviewError: { code: "REVIEW_ERROR", retryable: false },
103
104
  ContextError: {
104
105
  code: "CONTEXT_ERROR",
105
106
  retryable: false,
@@ -315,7 +315,7 @@ describe("ArchiveService", () => {
315
315
  ),
316
316
  ),
317
317
  )
318
- await Bun.write(join(workspace.writablePath, "dirty.txt"), "keep me\n")
318
+ await Bun.write(join(workspace.writablePath!, "dirty.txt"), "keep me\n")
319
319
 
320
320
  await expect(
321
321
  runTestEffect(
@@ -747,7 +747,7 @@ describe("ArchiveService", () => {
747
747
  ),
748
748
  ).rejects.toThrow("Another archive or restore operation")
749
749
  expect(
750
- await Bun.file(join(workspace.writablePath, "README.md")).exists(),
750
+ await Bun.file(join(workspace.writablePath!, "README.md")).exists(),
751
751
  ).toBe(true)
752
752
  expect(await Bun.file(join(root, "tasks/locked/TASK.md")).exists()).toBe(
753
753
  true,
@@ -367,6 +367,7 @@ const repositoriesFor = (record: ArchivedRecord) => {
367
367
  ...(record.data.repos ?? []).map((reference) => reference.repo),
368
368
  ]
369
369
  }
370
+ if ("review" in record.data) return [record.data.review.repo]
370
371
  return []
371
372
  }
372
373
 
@@ -108,7 +108,8 @@ interface ReconcileInput {
108
108
  const PR_URL = /^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/\d+\/?$/
109
109
 
110
110
  type SingleTaskData = Extract<TaskData, { readonly repo: string }>
111
- type ExecutionData = SingleTaskData | PhaseData
111
+ type ReviewTaskData = Extract<TaskData, { readonly review: unknown }>
112
+ type ExecutionData = SingleTaskData | ReviewTaskData | PhaseData
112
113
 
113
114
  const isTaggedClaimError = (
114
115
  error: unknown,
@@ -541,7 +542,7 @@ export class ClaimService extends Effect.Service<ClaimService>()(
541
542
  message: `Session '${input.sessionId}' does not own ${inspected.target.label}`,
542
543
  })
543
544
  }
544
- if (input.nonPrCompletion && data.pr !== null) {
545
+ if (input.nonPrCompletion && "pr" in data && data.pr !== null) {
545
546
  throw new ClaimError({
546
547
  target: inspected.target.label,
547
548
  message:
@@ -54,6 +54,7 @@ interface CheckoutInspection {
54
54
  readonly checkoutCommit: string | null
55
55
  readonly checkoutBranch: string | null
56
56
  readonly detached: boolean | null
57
+ readonly dirty: boolean | null
57
58
  }
58
59
 
59
60
  type ExecutionData = PhaseData & Partial<Pick<TaskData, "ticketUrl" | "epic">>
@@ -92,6 +93,18 @@ const runGit = (fs: FileSystemService, cwd: string, args: readonly string[]) =>
92
93
  Effect.catchAll(() => Effect.succeed(null)),
93
94
  )
94
95
 
96
+ const runGitText = (
97
+ fs: FileSystemService,
98
+ cwd: string,
99
+ args: readonly string[],
100
+ ) =>
101
+ fs.runCommand(["git", "-C", cwd, ...args], { captureOutput: true }).pipe(
102
+ Effect.map((result) =>
103
+ result.exitCode === 0 ? result.stdout.trim() : null,
104
+ ),
105
+ Effect.catchAll(() => Effect.succeed(null)),
106
+ )
107
+
95
108
  const worktreePaths = (output: string | null) =>
96
109
  new Set(
97
110
  (output ?? "")
@@ -765,9 +778,13 @@ export class ContextService extends Effect.Service<ContextService>()(
765
778
  : task?.data && "repo" in task.data
766
779
  ? (task.data as ExecutionData)
767
780
  : null
768
- const references: readonly RepositoryReference[] = executionData
769
- ? (executionData.repos ?? [])
770
- : (epic?.data.repos ?? [])
781
+ const reviewData =
782
+ task?.data && "review" in task.data ? task.data.review : null
783
+ const references: readonly RepositoryReference[] = reviewData
784
+ ? [{ repo: reviewData.repo, ref: reviewData.commit }]
785
+ : executionData
786
+ ? (executionData.repos ?? [])
787
+ : (epic?.data.repos ?? [])
771
788
  const entityDirectory = target.path.replace(
772
789
  /\/(?:EPIC|TASK|PHASE)\.md$/,
773
790
  "",
@@ -812,6 +829,7 @@ export class ContextService extends Effect.Service<ContextService>()(
812
829
  checkoutCommit: null,
813
830
  checkoutBranch: null,
814
831
  detached: null,
832
+ dirty: null,
815
833
  }
816
834
  }
817
835
  const checkoutCommit = yield* runGit(fs, checkoutPath, [
@@ -824,6 +842,10 @@ export class ContextService extends Effect.Service<ContextService>()(
824
842
  "--short",
825
843
  "HEAD",
826
844
  ])
845
+ const checkoutStatus = yield* runGitText(fs, checkoutPath, [
846
+ "status",
847
+ "--porcelain",
848
+ ])
827
849
  const resolvedCheckoutPath = yield* fs.realPath(checkoutPath)
828
850
  registered = registered || listedPaths.has(resolvedCheckoutPath)
829
851
  if (checkoutCommit === null) {
@@ -837,6 +859,8 @@ export class ContextService extends Effect.Service<ContextService>()(
837
859
  checkoutCommit,
838
860
  checkoutBranch,
839
861
  detached: checkoutBranch === null,
862
+ dirty:
863
+ checkoutStatus === null ? null : checkoutStatus.length > 0,
840
864
  }
841
865
  })
842
866
 
@@ -901,6 +925,17 @@ export class ContextService extends Effect.Service<ContextService>()(
901
925
  ...(yield* inspectCheckout(repositoryPath, checkoutPath)),
902
926
  })
903
927
  }
928
+ const reviewSourceCommit = reviewData
929
+ ? yield* runGit(fs, join(root, "repos", reviewData.repo), [
930
+ "ls-remote",
931
+ "origin",
932
+ reviewData.source.kind === "pull-request"
933
+ ? reviewData.source.fetchRef
934
+ : reviewData.source.ref
935
+ .replace(/^refs\/remotes\/origin\//, "")
936
+ .replace(/^origin\//, ""),
937
+ ])
938
+ : null
904
939
 
905
940
  const checkoutStates = [writable, ...referenceCheckouts].filter(
906
941
  (value): value is NonNullable<typeof value> => value !== null,
@@ -956,7 +991,11 @@ export class ContextService extends Effect.Service<ContextService>()(
956
991
  aggregate: aggregateProgress(aggregateStatuses),
957
992
  },
958
993
  authority: {
959
- mode: executionData ? "execution" : "orchestration",
994
+ mode: reviewData
995
+ ? "review"
996
+ : executionData
997
+ ? "execution"
998
+ : "orchestration",
960
999
  writable: writable
961
1000
  ? {
962
1001
  repo: writable.repo,
@@ -972,6 +1011,9 @@ export class ContextService extends Effect.Service<ContextService>()(
972
1011
  repositoryPath: reference.repositoryPath,
973
1012
  checkoutPath: reference.checkoutPath,
974
1013
  })),
1014
+ documents: {
1015
+ writable: reviewData && task ? [task.path] : [],
1016
+ },
975
1017
  },
976
1018
  workspace: options.compact
977
1019
  ? {
@@ -997,6 +1039,16 @@ export class ContextService extends Effect.Service<ContextService>()(
997
1039
  references: referenceCheckouts,
998
1040
  warnings: inspectionWarnings,
999
1041
  },
1042
+ review: reviewData
1043
+ ? {
1044
+ ...reviewData,
1045
+ sourceObservation: {
1046
+ available: reviewSourceCommit !== null,
1047
+ commit: reviewSourceCommit?.split(/\s+/)[0] ?? null,
1048
+ },
1049
+ checkout: referenceCheckouts[0] ?? null,
1050
+ }
1051
+ : null,
1000
1052
  pr: executionData?.pr
1001
1053
  ? normalizePullRequestRecord(executionData.pr)
1002
1054
  : { url: null, state: "none" },
@@ -282,6 +282,8 @@ export class DoctorService extends Effect.Service<DoctorService>()(
282
282
  }
283
283
 
284
284
  const refs = new Map<string, Set<string>>()
285
+ const reviewSources: { repo: string; task: string; ref: string }[] =
286
+ []
285
287
  const declareRef = (repo: string, ref: string) => {
286
288
  const values = refs.get(repo) ?? new Set<string>()
287
289
  values.add(ref)
@@ -293,7 +295,19 @@ export class DoctorService extends Effect.Service<DoctorService>()(
293
295
  declareRef(reference.repo, reference.ref)
294
296
  }
295
297
  for (const task of yield* tasks.list(root)) {
296
- if ("repo" in task.data) {
298
+ if ("review" in task.data) {
299
+ declareRef(task.data.review.repo, task.data.review.commit)
300
+ reviewSources.push({
301
+ repo: task.data.review.repo,
302
+ task: task.id,
303
+ ref:
304
+ task.data.review.source.kind === "pull-request"
305
+ ? task.data.review.source.fetchRef
306
+ : task.data.review.source.ref
307
+ .replace(/^refs\/remotes\/origin\//, "")
308
+ .replace(/^origin\//, ""),
309
+ })
310
+ } else if ("repo" in task.data) {
297
311
  declareRef(task.data.repo, task.data.base)
298
312
  for (const reference of task.data.repos ?? [])
299
313
  declareRef(reference.repo, reference.ref)
@@ -357,6 +371,36 @@ export class DoctorService extends Effect.Service<DoctorService>()(
357
371
  })
358
372
  if (!repositoryValid) continue
359
373
 
374
+ for (const source of reviewSources.filter(
375
+ (item) => item.repo === repository.alias,
376
+ )) {
377
+ const observed = yield* fs.runCommand(
378
+ [
379
+ "git",
380
+ "-C",
381
+ repository.path,
382
+ "ls-remote",
383
+ "origin",
384
+ source.ref,
385
+ ],
386
+ { captureOutput: true },
387
+ )
388
+ const available =
389
+ observed.exitCode === 0 && Boolean(observed.stdout.trim())
390
+ add({
391
+ id: `review.${source.task}.source`,
392
+ category: "repository",
393
+ level: "warning",
394
+ status: available ? "pass" : "fail",
395
+ message: available
396
+ ? `Review source '${source.ref}' is available for task '${source.task}'`
397
+ : `Review source '${source.ref}' is unavailable for task '${source.task}'; its pin remains usable`,
398
+ remediation: available
399
+ ? undefined
400
+ : "Use the pinned checkout or refresh after restoring the source.",
401
+ })
402
+ }
403
+
360
404
  for (const ref of [...(refs.get(repository.alias) ?? [])].sort()) {
361
405
  const local = yield* fs.runCommand(
362
406
  [
@@ -360,6 +360,11 @@ export class GraphMutationService extends Effect.Service<GraphMutationService>()
360
360
  "base",
361
361
  "pr",
362
362
  ].some((key) => updates[key as keyof TaskUpdates] !== undefined)
363
+ if ("review" in record.data && executionChange) {
364
+ return yield* new GraphMutationError({
365
+ message: `Review task '${id}' does not have writable execution metadata`,
366
+ })
367
+ }
363
368
  if ("phases" in record.data && executionChange) {
364
369
  return yield* new GraphMutationError({
365
370
  message: `Task '${id}' has multiple phases; update execution metadata on a phase instead`,
@@ -54,7 +54,10 @@ interface RepositoryRecord {
54
54
  readonly target: string | null
55
55
  }
56
56
 
57
- type ExecutionData = PhaseData | Extract<TaskData, { readonly repo: string }>
57
+ type ExecutionData =
58
+ | PhaseData
59
+ | Extract<TaskData, { readonly repo: string }>
60
+ | Extract<TaskData, { readonly review: unknown }>
58
61
 
59
62
  export interface GraphOptions {
60
63
  readonly cwd?: string
@@ -548,10 +551,18 @@ export class GraphService extends Effect.Service<GraphService>()(
548
551
  const executionRepositories = (
549
552
  data: TaskData | PhaseData,
550
553
  ): readonly string[] =>
551
- "repo" in data
552
- ? [data.repo, ...(data.repos ?? []).map((item) => item.repo)]
553
- : []
554
+ "review" in data
555
+ ? [data.review.repo]
556
+ : "repo" in data
557
+ ? [data.repo, ...(data.repos ?? []).map((item) => item.repo)]
558
+ : []
554
559
  const executionEdges = (id: string, data: TaskData | PhaseData) => {
560
+ if ("review" in data) {
561
+ edges.push(
562
+ edge("references", id, repositoryNodeId(data.review.repo)),
563
+ )
564
+ return
565
+ }
555
566
  if (!("repo" in data)) return
556
567
  edges.push(edge("writes", id, repositoryNodeId(data.repo)))
557
568
  for (const reference of data.repos ?? []) {
@@ -650,8 +661,9 @@ export class GraphService extends Effect.Service<GraphService>()(
650
661
  const executionDetails = (entityPath: string, data: ExecutionData) =>
651
662
  Effect.gen(function* () {
652
663
  const directory = entityPath.replace(/\/(?:TASK|PHASE)\.md$/, "")
653
- const checkoutPath = join(directory, "code", data.repo)
654
- const repositoryPath = join(root, "repos", data.repo)
664
+ const repo = "review" in data ? data.review.repo : data.repo
665
+ const checkoutPath = join(directory, "code", repo)
666
+ const repositoryPath = join(root, "repos", repo)
655
667
  const materialized = yield* fs.isDirectory(checkoutPath)
656
668
  const result: {
657
669
  workspace?: GraphExecutionWorkspace
@@ -666,58 +678,110 @@ export class GraphService extends Effect.Service<GraphService>()(
666
678
  }
667
679
  }
668
680
  if (include.has("git")) {
669
- result.git = {
670
- branch: data.branch,
671
- base: data.base,
672
- branchCommit: yield* run(fs, [
673
- "git",
674
- "-C",
675
- repositoryPath,
676
- "rev-parse",
677
- `${data.branch}^{commit}`,
678
- ]),
679
- baseCommit: yield* run(fs, [
680
- "git",
681
- "-C",
682
- repositoryPath,
683
- "rev-parse",
684
- `${data.base}^{commit}`,
685
- ]),
686
- checkoutCommit: materialized
687
- ? yield* run(fs, [
688
- "git",
689
- "-C",
690
- checkoutPath,
691
- "rev-parse",
692
- "HEAD",
693
- ])
694
- : null,
695
- checkoutBranch: materialized
696
- ? yield* run(fs, [
697
- "git",
698
- "-C",
699
- checkoutPath,
700
- "symbolic-ref",
701
- "--quiet",
702
- "--short",
703
- "HEAD",
704
- ])
705
- : null,
706
- dirty: materialized
707
- ? ((status) =>
708
- status === null ? null : status.length > 0)(
709
- yield* runText(fs, [
681
+ result.git =
682
+ "review" in data
683
+ ? {
684
+ pinnedCommit: data.review.commit,
685
+ sourceCommit: ((value) =>
686
+ value?.split(/\s+/)[0] ?? null)(
687
+ yield* run(fs, [
688
+ "git",
689
+ "-C",
690
+ repositoryPath,
691
+ "ls-remote",
692
+ "origin",
693
+ data.review.source.kind === "pull-request"
694
+ ? data.review.source.fetchRef
695
+ : data.review.source.ref
696
+ .replace(/^refs\/remotes\/origin\//, "")
697
+ .replace(/^origin\//, ""),
698
+ ]),
699
+ ),
700
+ checkoutCommit: materialized
701
+ ? yield* run(fs, [
702
+ "git",
703
+ "-C",
704
+ checkoutPath,
705
+ "rev-parse",
706
+ "HEAD",
707
+ ])
708
+ : null,
709
+ checkoutBranch: materialized
710
+ ? yield* run(fs, [
711
+ "git",
712
+ "-C",
713
+ checkoutPath,
714
+ "symbolic-ref",
715
+ "--quiet",
716
+ "--short",
717
+ "HEAD",
718
+ ])
719
+ : null,
720
+ dirty: materialized
721
+ ? ((status) =>
722
+ status === null ? null : status.length > 0)(
723
+ yield* runText(fs, [
724
+ "git",
725
+ "-C",
726
+ checkoutPath,
727
+ "status",
728
+ "--porcelain",
729
+ ]),
730
+ )
731
+ : null,
732
+ }
733
+ : {
734
+ branch: data.branch,
735
+ base: data.base,
736
+ branchCommit: yield* run(fs, [
710
737
  "git",
711
738
  "-C",
712
- checkoutPath,
713
- "status",
714
- "--porcelain",
739
+ repositoryPath,
740
+ "rev-parse",
741
+ `${data.branch}^{commit}`,
715
742
  ]),
716
- )
717
- : null,
718
- }
743
+ baseCommit: yield* run(fs, [
744
+ "git",
745
+ "-C",
746
+ repositoryPath,
747
+ "rev-parse",
748
+ `${data.base}^{commit}`,
749
+ ]),
750
+ checkoutCommit: materialized
751
+ ? yield* run(fs, [
752
+ "git",
753
+ "-C",
754
+ checkoutPath,
755
+ "rev-parse",
756
+ "HEAD",
757
+ ])
758
+ : null,
759
+ checkoutBranch: materialized
760
+ ? yield* run(fs, [
761
+ "git",
762
+ "-C",
763
+ checkoutPath,
764
+ "symbolic-ref",
765
+ "--quiet",
766
+ "--short",
767
+ "HEAD",
768
+ ])
769
+ : null,
770
+ dirty: materialized
771
+ ? ((status) =>
772
+ status === null ? null : status.length > 0)(
773
+ yield* runText(fs, [
774
+ "git",
775
+ "-C",
776
+ checkoutPath,
777
+ "status",
778
+ "--porcelain",
779
+ ]),
780
+ )
781
+ : null,
782
+ }
719
783
  }
720
- if (include.has("pr")) {
784
+ if (include.has("pr") && !("review" in data)) {
721
785
  if (!data.pr) {
722
786
  result.pr = { url: null, state: "none" }
723
787
  } else {
@@ -97,6 +97,11 @@ export class PhaseService extends Effect.Service<PhaseService>()(
97
97
  const taskId = yield* decodeId(input.taskId, "task")
98
98
  const id = yield* decodeId(input.id, "phase")
99
99
  const task = yield* tasks.show(taskId, root)
100
+ if ("review" in task.data) {
101
+ return yield* new PhaseError({
102
+ message: `Review task '${taskId}' cannot be converted to phases`,
103
+ })
104
+ }
100
105
  const isMultiPhase = "phases" in task.data
101
106
  let firstPhaseId: string | undefined
102
107
  if (!isMultiPhase) {
@@ -353,7 +353,7 @@ process.exit(${exitCode})
353
353
  test("blocks a dirty checkout before push or gh", async () => {
354
354
  await createTask()
355
355
  const workspace = await materialize()
356
- await Bun.write(join(workspace.writablePath, "dirty.txt"), "dirty\n")
356
+ await Bun.write(join(workspace.writablePath!, "dirty.txt"), "dirty\n")
357
357
  await writeFakeGh({ stdout: "https://github.com/example/agency/pull/45" })
358
358
 
359
359
  await expect(createPullRequest()).rejects.toThrow(
@@ -518,7 +518,7 @@ process.stdout.write(${JSON.stringify(JSON.stringify(providerRecord))})
518
518
  test("reports git status failure before push or gh", async () => {
519
519
  await createTask()
520
520
  const workspace = await materialize()
521
- await rm(join(workspace.writablePath, ".git"))
521
+ await rm(join(workspace.writablePath!, ".git"))
522
522
  await writeFakeGh({ stdout: "https://github.com/example/agency/pull/48" })
523
523
 
524
524
  await expect(createPullRequest()).rejects.toThrow(
@@ -50,6 +50,11 @@ export class PullRequestService extends Effect.Service<PullRequestService>()(
50
50
  const phases = yield* PhaseService
51
51
  const root = yield* workbase.discover(startPath)
52
52
  const task = yield* tasks.show(taskId, root)
53
+ if ("review" in task.data) {
54
+ return yield* new PullRequestError({
55
+ message: `Review task '${taskId}' cannot record a delivery pull request`,
56
+ })
57
+ }
53
58
  const target =
54
59
  "phases" in task.data
55
60
  ? phaseId
@@ -105,15 +110,20 @@ export class PullRequestService extends Effect.Service<PullRequestService>()(
105
110
  const worktrees = yield* WorktreeService
106
111
  const readiness = yield* ReadinessService
107
112
  const workbase = yield* WorkbaseService
108
- const task = yield* tasks.show(taskId, startPath)
113
+ const requestedTask = yield* tasks.show(taskId, startPath)
114
+ if ("review" in requestedTask.data) {
115
+ return yield* new PullRequestError({
116
+ message: `Review task '${taskId}' cannot create a delivery pull request`,
117
+ })
118
+ }
109
119
  const target =
110
- "phases" in task.data
120
+ "phases" in requestedTask.data
111
121
  ? phaseId
112
122
  ? yield* phases.show(taskId, phaseId, startPath)
113
123
  : yield* new PullRequestError({
114
124
  message: `Task '${taskId}' requires a phase ID`,
115
125
  })
116
- : task
126
+ : requestedTask
117
127
  if ("completion" in target.data && target.data.completion) {
118
128
  return yield* new PullRequestError({
119
129
  message:
@@ -134,11 +144,21 @@ export class PullRequestService extends Effect.Service<PullRequestService>()(
134
144
  options,
135
145
  )
136
146
  const workspaceTask = yield* tasks.show(taskId, workspace.root)
147
+ if ("review" in workspaceTask.data) {
148
+ return yield* new PullRequestError({
149
+ message: `Review task '${taskId}' cannot create a delivery pull request`,
150
+ })
151
+ }
137
152
  const execution =
138
153
  "phases" in workspaceTask.data
139
154
  ? (yield* phases.show(taskId, phaseId!, workspace.root)).data
140
155
  : workspaceTask.data
141
156
  const { config } = yield* workbase.loadConfig(workspace.root)
157
+ if (!workspace.writablePath) {
158
+ return yield* new PullRequestError({
159
+ message: `Task '${taskId}' has no writable checkout`,
160
+ })
161
+ }
142
162
  const remote = config.delivery?.remote ?? "origin"
143
163
 
144
164
  const status = yield* fs.runCommand(
@@ -77,7 +77,7 @@ const itemFor = (
77
77
  ? task.data.epic
78
78
  : undefined
79
79
  const dependentIds = new Set(node.dependents)
80
- if (node.data.phaseId) {
80
+ if ("phaseId" in node.data && node.data.phaseId) {
81
81
  const siblings = graph.nodes.filter(
82
82
  (candidate): candidate is ExecutionNode =>
83
83
  candidate.kind === "execution-unit" &&
@@ -93,10 +93,14 @@ const itemFor = (
93
93
  rank,
94
94
  key: node.key,
95
95
  taskId: node.data.taskId,
96
- ...(node.data.phaseId ? { phaseId: node.data.phaseId } : {}),
96
+ ...("phaseId" in node.data && node.data.phaseId
97
+ ? { phaseId: node.data.phaseId }
98
+ : {}),
97
99
  ...(node.data.description ? { description: node.data.description } : {}),
98
100
  parent: {
99
- ...(node.data.phaseId ? { taskId: node.data.taskId } : {}),
101
+ ...("phaseId" in node.data && node.data.phaseId
102
+ ? { taskId: node.data.taskId }
103
+ : {}),
100
104
  ...(epicId ? { epicId } : {}),
101
105
  },
102
106
  status: node.status,