@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.
- package/README.md +96 -4
- package/cli-main.ts +7 -0
- package/fixtures/protocol/orchestration-recipes.json +19 -0
- package/index.ts +1 -0
- package/package.json +4 -1
- package/schemas/agency-graph-v1.schema.json +53 -0
- package/schemas/agency-kickoff-v1.schema.json +124 -0
- package/src/cli-parser.test.ts +50 -2
- package/src/cli-parser.ts +52 -6
- package/src/commands/task-phase.test.ts +49 -0
- package/src/commands/task.test.ts +77 -1
- package/src/commands/task.ts +123 -7
- package/src/commands/work.test.ts +62 -3
- package/src/commands/work.ts +106 -3
- 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 +47 -0
- package/src/services/LifecycleTransaction.ts +12 -0
- package/src/services/PhaseService.ts +2 -0
- package/src/services/TaskPhaseService.test.ts +238 -0
- package/src/services/TaskService.ts +265 -6
- package/src/services/WorkbaseService.ts +30 -0
- package/src/services/WorktreeService.ts +9 -6
- package/src/workbase/AGENTS.md +80 -4
- package/src/workbase/frontmatter.test.ts +18 -1
- package/src/workbase/frontmatter.ts +29 -1
- package/src/workbase/kickoff-contract.test.ts +145 -0
- package/src/workbase/kickoff-contract.ts +358 -0
- package/src/workbase/opencode-file.ts +1 -1
- package/src/workbase/schemas.test.ts +36 -0
- package/src/workbase/schemas.ts +33 -1
|
@@ -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"] },
|