@markjaquith/agency 2.59.0 → 2.61.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -326,6 +326,54 @@ describe("GraphMutationService", () => {
326
326
  )
327
327
  })
328
328
 
329
+ test("preserves historical handoff provenance across source and destination renames", async () => {
330
+ await runTestEffect(
331
+ Effect.gen(function* () {
332
+ const tasks = yield* TaskService
333
+ yield* tasks.create(
334
+ {
335
+ id: "investigation",
336
+ ticketUrl: null,
337
+ repo: "agency",
338
+ branch: "task/investigation",
339
+ base: "main",
340
+ purpose: "investigation",
341
+ },
342
+ root,
343
+ )
344
+ yield* tasks.handoff(
345
+ {
346
+ sourceTaskId: "investigation",
347
+ id: "implementation",
348
+ ticketUrl: null,
349
+ repo: "docs",
350
+ branch: "task/implementation",
351
+ base: "main",
352
+ },
353
+ root,
354
+ )
355
+ const before = yield* tasks.show("implementation", root)
356
+ const mutations = yield* GraphMutationService
357
+ yield* mutations.renameTask(
358
+ "investigation",
359
+ "investigation-renamed",
360
+ root,
361
+ )
362
+ yield* mutations.renameTask(
363
+ "implementation",
364
+ "implementation-renamed",
365
+ root,
366
+ )
367
+ const after = yield* tasks.show("implementation-renamed", root)
368
+ expect(after.data.handoff).toEqual(before.data.handoff)
369
+ expect(after.data.handoff?.source).toEqual({
370
+ kind: "task",
371
+ taskId: "investigation",
372
+ })
373
+ }),
374
+ )
375
+ })
376
+
329
377
  test("moves task membership in both directions", async () => {
330
378
  await runTestEffect(
331
379
  Effect.gen(function* () {
@@ -107,6 +107,21 @@ describe("IntegrationService", () => {
107
107
  ])
108
108
  })
109
109
 
110
+ test("generates the canonical Agency kickoff recipe with precedence", () => {
111
+ expect(managedWorkbaseAgents).toContain(
112
+ "takes precedence over generic Herdr defaults",
113
+ )
114
+ expect(managedWorkbaseAgents).toContain(
115
+ "agency work prepare <slug> --evidence",
116
+ )
117
+ expect(managedWorkbaseAgents).toContain("agency-kickoff-v1")
118
+ expect(managedWorkbaseAgents).toContain(
119
+ "call Herdr help, skill, or CLI discovery",
120
+ )
121
+ expect(managedWorkbaseAgents).toContain("exactly one final")
122
+ expect(managedWorkbaseAgents).toContain("leave the runner in the")
123
+ })
124
+
110
125
  test("generates a dynamic workbase plugin", () => {
111
126
  expect(managedWorkbaseOpencodePlugin).toContain(
112
127
  "process.env.AGENCY_WRITABLE_CHECKOUT",
@@ -405,6 +420,13 @@ describe("IntegrationService", () => {
405
420
  expect(body).toContain("authority.writable.checkoutPath")
406
421
  expect(body).toContain("Only `done` satisfies a dependency")
407
422
  expect(body).toContain("Require explicit user intent")
423
+ expect(body).toContain(
424
+ "explicit request for a new, separate, or follow-up item",
425
+ )
426
+ expect(body).toContain("agency task handoff")
427
+ expect(body).toContain(
428
+ "Creation and handoff do not imply worktree preparation",
429
+ )
408
430
  expect(body).toContain("changing repository")
409
431
  expect(body).toMatch(
410
432
  /archiving, restoring,\s+dropping, reopening, or completing work without a pull request/,
@@ -445,6 +467,28 @@ describe("IntegrationService", () => {
445
467
  expect(body).not.toContain(".opencode/command/agency.md")
446
468
  })
447
469
 
470
+ test("generates repository add and setup guidance", () => {
471
+ const body = managedBody(managedWorkbaseAgents)
472
+
473
+ expect(body).toContain("## Adding a Repository")
474
+ expect(body).toContain("agency repo add <alias> <remote> --json")
475
+ expect(body).toContain(
476
+ "`agency repo add` mutates immediately and does not accept `--apply`",
477
+ )
478
+ expect(body).toContain("agency repo setup --dry-run")
479
+ expect(body).toContain("agency repo setup --apply")
480
+ expect(body).toMatch(
481
+ /repositories that are already declared\s+but locally missing/,
482
+ )
483
+ expect(body).toContain("Do not edit\n`agency.json` or `repos/` manually")
484
+ expect(body).toMatch(
485
+ /agency repo verify <alias> --json\s+agency validate --json/,
486
+ )
487
+ expect(body).toMatch(
488
+ /run only these checks, in order, unless\s+`agency context` reports a relevant problem/,
489
+ )
490
+ })
491
+
448
492
  test("configures Agency agents with complete workbase access", () => {
449
493
  const config = JSON.parse(managedBody(managedWorkbaseOpencode))
450
494
 
@@ -480,6 +524,9 @@ describe("IntegrationService", () => {
480
524
  })
481
525
  expect(config.agent.agency.model).toBeUndefined()
482
526
  expect(config.agent.agency.permission).toBeUndefined()
527
+ expect(config.agent["agency-plan"].prompt).toContain(
528
+ "Explicit-new intent overrides reuse",
529
+ )
483
530
  expect(config.agent.agency.hidden).toBeUndefined()
484
531
  expect(config.agent.agency.steps).toBeUndefined()
485
532
  expect(config.agent.agency.prompt).toContain(
@@ -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
  {
@@ -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"] },