@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.
@@ -25,6 +25,18 @@ export interface TransactionStep {
25
25
  readonly manualRecovery?: string
26
26
  }
27
27
 
28
+ export const pathMustNotExistStep = (
29
+ root: string,
30
+ path: string,
31
+ message: string,
32
+ ): TransactionStep => ({
33
+ label: `verify ${relative(root, path)} is available`,
34
+ preflight: async () => {
35
+ if (await exists(path)) throw new Error(message)
36
+ },
37
+ apply: async () => {},
38
+ })
39
+
28
40
  interface DocumentWrite {
29
41
  readonly path: string
30
42
  readonly content: string
@@ -249,6 +249,8 @@ export class PhaseService extends Effect.Service<PhaseService>()(
249
249
  ? { description: task.data.description }
250
250
  : {}),
251
251
  ...(task.data.epic ? { epic: task.data.epic } : {}),
252
+ ...(task.data.purpose ? { purpose: task.data.purpose } : {}),
253
+ ...(task.data.handoff ? { handoff: task.data.handoff } : {}),
252
254
  phases: [
253
255
  { id: firstPhaseId! },
254
256
  {
@@ -239,7 +239,15 @@ export class PullRequestService extends Effect.Service<PullRequestService>()(
239
239
  })
240
240
  if (!url)
241
241
  throw new Error("GitHub CLI did not return a pull request URL")
242
- return { ...normalizePullRequestRecord(url), draft }
242
+ const normalized = normalizePullRequestRecord(url)
243
+ return {
244
+ ...normalized,
245
+ headRepository: repository,
246
+ headBranch: execution.branch,
247
+ baseRepository: normalized.repository,
248
+ baseBranch: execution.base,
249
+ draft,
250
+ }
243
251
  },
244
252
  catch: (cause) =>
245
253
  new PullRequestError({
@@ -249,7 +257,8 @@ export class PullRequestService extends Effect.Service<PullRequestService>()(
249
257
  if (
250
258
  config.delivery &&
251
259
  (record.provider !== config.delivery.provider ||
252
- record.repository.toLowerCase() !== repository.toLowerCase())
260
+ (record.headRepository ?? record.repository).toLowerCase() !==
261
+ repository.toLowerCase())
253
262
  ) {
254
263
  return yield* new PullRequestError({
255
264
  message:
@@ -66,12 +66,12 @@ case "$*" in
66
66
  esac
67
67
  if [ "$2" = "view" ]; then
68
68
  cat <<'JSON'
69
- {"number":42,"state":"MERGED","title":"Ship","isDraft":false,"headRefName":"feat/example","baseRefName":"main","url":"https://github.com/example/agency/pull/42","mergedAt":"2100-01-01T00:00:00Z","mergeCommit":{"oid":"abc"},"mergeable":"MERGEABLE"}
69
+ {"number":42,"state":"MERGED","title":"Ship","isDraft":false,"headRefName":"feat/example","baseRefName":"main","headRepository":{"nameWithOwner":"example/agency"},"baseRepository":{"nameWithOwner":"example/agency"},"url":"https://github.com/example/agency/pull/42","mergedAt":"2100-01-01T00:00:00Z","mergeCommit":{"oid":"abc"},"mergeable":"MERGEABLE"}
70
70
  JSON
71
71
  exit 0
72
72
  fi
73
73
  cat <<'JSON'
74
- [{"number":42,"state":"MERGED","title":"Ship","isDraft":false,"headRefName":"feat/example","baseRefName":"main","url":"https://github.com/example/agency/pull/42","mergedAt":"2100-01-01T00:00:00Z","mergeCommit":{"oid":"abc"},"mergeable":"MERGEABLE"}]
74
+ [{"number":42,"state":"MERGED","title":"Ship","isDraft":false,"headRefName":"feat/example","baseRefName":"main","headRepository":{"nameWithOwner":"example/agency"},"baseRepository":{"nameWithOwner":"example/agency"},"url":"https://github.com/example/agency/pull/42","mergedAt":"2100-01-01T00:00:00Z","mergeCommit":{"oid":"abc"},"mergeable":"MERGEABLE"}]
75
75
  JSON
76
76
  `,
77
77
  )
@@ -307,6 +307,10 @@ pr: null
307
307
  state: "merged",
308
308
  draft: false,
309
309
  merged: true,
310
+ headRepository: "example/agency",
311
+ headBranch: "feat/example",
312
+ baseRepository: "example/agency",
313
+ baseBranch: "main",
310
314
  mergeable: true,
311
315
  },
312
316
  claim: { state: "released", sessionId: "session-1" },
@@ -357,7 +361,7 @@ pr: null
357
361
  join(root, "bin", "gh"),
358
362
  `#!/bin/sh
359
363
  cat <<'JSON'
360
- {"number":42,"state":"OPEN","title":"Ship","isDraft":false,"headRefName":"feat/example","baseRefName":"main","url":"https://github.com/example/agency/pull/42","mergedAt":null,"mergeCommit":null,"mergeable":"CONFLICTING"}
364
+ {"number":42,"state":"OPEN","title":"Ship","isDraft":false,"headRefName":"feat/example","baseRefName":"main","headRepository":{"nameWithOwner":"example/agency"},"baseRepository":{"nameWithOwner":"example/agency"},"url":"https://github.com/example/agency/pull/42","mergedAt":null,"mergeCommit":null,"mergeable":"CONFLICTING"}
361
365
  JSON
362
366
  `,
363
367
  )
@@ -747,6 +751,89 @@ process.stdout.write(${JSON.stringify(JSON.stringify(record))})
747
751
  expect(task.data).toMatchObject({ status: "open" })
748
752
  })
749
753
 
754
+ test("reconciles a merged upstream PR whose head is the writable fork", async () => {
755
+ await runTestEffect(
756
+ TaskService.pipe(
757
+ Effect.flatMap((service) =>
758
+ service.create(
759
+ {
760
+ id: "forked",
761
+ ticketUrl: null,
762
+ repo: "agency",
763
+ branch: "task/polish-readme",
764
+ base: "main",
765
+ },
766
+ root,
767
+ ),
768
+ ),
769
+ ),
770
+ )
771
+ await runTestEffect(
772
+ WorktreeService.pipe(
773
+ Effect.flatMap((service) =>
774
+ service.materialize("forked", undefined, root),
775
+ ),
776
+ ),
777
+ )
778
+ await git(
779
+ ["remote", "set-url", "origin", "git@github.com:markjaquith/Pasted.git"],
780
+ join(root, "repos/agency"),
781
+ )
782
+ await runTestEffect(
783
+ PullRequestService.pipe(
784
+ Effect.flatMap((service) =>
785
+ service.setUrl(
786
+ "forked",
787
+ undefined,
788
+ "https://github.com/getpasted/pasted/pull/17",
789
+ root,
790
+ ),
791
+ ),
792
+ ),
793
+ )
794
+ await Bun.write(
795
+ join(root, "bin", "gh"),
796
+ `#!/bin/sh
797
+ cat <<'JSON'
798
+ {"number":17,"state":"MERGED","title":"Polish README","isDraft":false,"headRefName":"task/polish-readme","baseRefName":"main","headRepository":{"nameWithOwner":"markjaquith/Pasted"},"baseRepository":{"nameWithOwner":"getpasted/pasted"},"url":"https://github.com/getpasted/pasted/pull/17","mergedAt":"2026-08-11T00:00:00Z","mergeCommit":{"oid":"241d45da1115ef685c91a5e6882d118c16801550"},"mergeable":"UNKNOWN"}
799
+ JSON
800
+ `,
801
+ )
802
+ await chmod(join(root, "bin", "gh"), 0o755)
803
+
804
+ const applied = await runTestEffect(
805
+ SyncService.pipe(
806
+ Effect.flatMap((service) =>
807
+ service.reconcile({ cwd: root, apply: true }),
808
+ ),
809
+ ),
810
+ )
811
+ expect(
812
+ applied.unresolved.filter((notice) => notice.target === "task:forked"),
813
+ ).toEqual([])
814
+ expect(applied.changes.map((change) => change.kind)).toEqual([
815
+ "record-pr",
816
+ "mark-done",
817
+ ])
818
+ const task = await runTestEffect(
819
+ TaskService.pipe(
820
+ Effect.flatMap((service) => service.show("forked", root)),
821
+ ),
822
+ )
823
+ expect(task.data).toMatchObject({
824
+ status: "done",
825
+ pr: {
826
+ repository: "getpasted/pasted",
827
+ headRepository: "markjaquith/Pasted",
828
+ headBranch: "task/polish-readme",
829
+ baseRepository: "getpasted/pasted",
830
+ baseBranch: "main",
831
+ state: "merged",
832
+ merged: true,
833
+ },
834
+ })
835
+ })
836
+
750
837
  test("leaves non-PR completion unchanged when a matching PR is discoverable", async () => {
751
838
  await runTestEffect(
752
839
  TaskService.pipe(
@@ -349,7 +349,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
349
349
  "view",
350
350
  existing.url,
351
351
  "--json",
352
- "number,state,title,isDraft,headRefName,baseRefName,url,mergedAt,mergeCommit,mergeable",
352
+ "number,state,title,isDraft,headRefName,baseRefName,headRepository,baseRepository,url,mergedAt,mergeCommit,mergeable",
353
353
  ],
354
354
  {
355
355
  cwd: repositoryPath,
@@ -372,7 +372,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
372
372
  "--state",
373
373
  "all",
374
374
  "--json",
375
- "number,state,title,isDraft,headRefName,baseRefName,url,mergedAt,mergeCommit,mergeable",
375
+ "number,state,title,isDraft,headRefName,baseRefName,headRepository,baseRepository,url,mergedAt,mergeCommit,mergeable",
376
376
  ],
377
377
  {
378
378
  cwd: repositoryPath,
@@ -773,19 +773,6 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
773
773
  const query = prQueries.get(record.key)!
774
774
  const { remoteUrl, remoteRepository } = query
775
775
 
776
- if (
777
- existing &&
778
- remoteRepository.toLowerCase() !== existing.repository.toLowerCase()
779
- ) {
780
- prConflict = true
781
- unresolved.push({
782
- kind: "pr-repository-conflict",
783
- target: record.key,
784
- message: `Recorded PR repository does not match writable repository remote '${remoteName}'`,
785
- action: "Correct the configured remote or recorded PR",
786
- })
787
- }
788
-
789
776
  if (config.delivery && !remoteUrl) {
790
777
  warnings.push({
791
778
  kind: "delivery-remote-unavailable",
@@ -812,8 +799,9 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
812
799
  } else if (
813
800
  parsed.right &&
814
801
  (parsed.right.provider !== config.delivery.provider ||
815
- parsed.right.repository.toLowerCase() !==
816
- remoteRepository.toLowerCase())
802
+ (
803
+ parsed.right.headRepository ?? parsed.right.repository
804
+ ).toLowerCase() !== remoteRepository.toLowerCase())
817
805
  ) {
818
806
  if (parsed.right) {
819
807
  prConflict = true
@@ -850,14 +838,18 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
850
838
  current = recordFromGitHubJson(detail)
851
839
  pr = { ...detail, ...current }
852
840
  if (
853
- detail.headRefName !== data.branch ||
854
- detail.baseRefName !== data.base
841
+ current.headRepository?.toLowerCase() !==
842
+ remoteRepository.toLowerCase() ||
843
+ current.headBranch !== data.branch ||
844
+ current.baseRepository?.toLowerCase() !==
845
+ current.repository.toLowerCase() ||
846
+ current.baseBranch !== data.base
855
847
  ) {
856
848
  prConflict = true
857
849
  unresolved.push({
858
- kind: "pr-branch-conflict",
850
+ kind: "pr-repository-conflict",
859
851
  target: record.key,
860
- message: `Recorded PR branches do not match '${data.branch}' -> '${data.base}'`,
852
+ message: `Recorded PR head does not match '${remoteRepository}:${data.branch}' or base '${current.repository}:${data.base}'`,
861
853
  action: "Correct the declaration or recorded PR URL",
862
854
  })
863
855
  }
@@ -883,6 +875,20 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
883
875
  if (matches.length === 1) {
884
876
  current = recordFromGitHubJson(matches[0]!)
885
877
  pr = { ...matches[0], ...current }
878
+ if (
879
+ current.headRepository?.toLowerCase() !==
880
+ remoteRepository.toLowerCase() ||
881
+ current.baseRepository?.toLowerCase() !==
882
+ current.repository.toLowerCase()
883
+ ) {
884
+ prConflict = true
885
+ unresolved.push({
886
+ kind: "pr-repository-conflict",
887
+ target: record.key,
888
+ message: `Discovered PR repositories do not match writable repository '${remoteRepository}' and base '${current.repository}'`,
889
+ action: "Record the authoritative PR URL manually",
890
+ })
891
+ }
886
892
  } else if (matches.length > 1) {
887
893
  unresolved.push({
888
894
  kind: "multiple-prs",
@@ -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"] },