@markjaquith/agency 2.7.1 → 2.7.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "2.7.1",
3
+ "version": "2.7.2",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -193,7 +193,12 @@ describe("work command", () => {
193
193
  expect(harness.events).toEqual(["probe:opencode", "launch:opencode"])
194
194
  expect(harness.launches[0]).toEqual({
195
195
  cli: "opencode",
196
- args: ["opencode", "--continue"],
196
+ args: [
197
+ "opencode",
198
+ "--continue",
199
+ "--prompt",
200
+ "Work on the epic. Read /workbase/epics/delivery/EPIC.md.",
201
+ ],
197
202
  cwd: "/workbase/epics/delivery",
198
203
  })
199
204
  })
@@ -210,7 +215,7 @@ describe("work command", () => {
210
215
  })
211
216
 
212
217
  expect(harness.shownTasks).toEqual(["delivery"])
213
- expect(harness.launches[0]?.cwd).toBe("/workbase/tasks/delivery")
218
+ expect(harness.launches[0]?.cwd).toBe(singlePhaseWorkspace.writablePath)
214
219
  })
215
220
 
216
221
  test("treats a positional value as a task ID when it is not a directory", async () => {
@@ -223,7 +228,7 @@ describe("work command", () => {
223
228
  })
224
229
 
225
230
  expect(harness.shownTasks).toEqual(["delivery"])
226
- expect(harness.launches[0]?.cwd).toBe("/workbase/tasks/delivery")
231
+ expect(harness.launches[0]?.cwd).toBe(singlePhaseWorkspace.writablePath)
227
232
  })
228
233
 
229
234
  test("launches a multi-phase task agent without materializing", async () => {
@@ -238,7 +243,12 @@ describe("work command", () => {
238
243
  expect(harness.events).toEqual(["probe:opencode", "launch:opencode"])
239
244
  expect(harness.launches[0]).toEqual({
240
245
  cli: "opencode",
241
- args: ["opencode", "--continue"],
246
+ args: [
247
+ "opencode",
248
+ "--continue",
249
+ "--prompt",
250
+ "Work on the task. Read /workbase/tasks/delivery/TASK.md.",
251
+ ],
242
252
  cwd: "/workbase/tasks/delivery",
243
253
  })
244
254
  })
@@ -257,7 +267,16 @@ describe("work command", () => {
257
267
  "probe:opencode",
258
268
  "launch:opencode",
259
269
  ])
260
- expect(harness.launches[0]?.args).toEqual(["opencode", "--continue"])
270
+ expect(harness.launches[0]).toEqual({
271
+ cli: "opencode",
272
+ args: [
273
+ "opencode",
274
+ "--continue",
275
+ "--prompt",
276
+ "Start the task. Read /workbase/tasks/example/TASK.md and /workbase/tasks/example/phases/implementation/PHASE.md.",
277
+ ],
278
+ cwd: multiPhaseWorkspace.writablePath,
279
+ })
261
280
  expect(harness.statusUpdates).toEqual([
262
281
  "phase:example:implementation:working",
263
282
  ])
@@ -273,7 +292,7 @@ describe("work command", () => {
273
292
  })
274
293
 
275
294
  expect(harness.events[0]).toBe("materialize")
276
- expect(harness.launches[0]?.cwd).toBe("/workbase/tasks/example")
295
+ expect(harness.launches[0]?.cwd).toBe(singlePhaseWorkspace.writablePath)
277
296
  })
278
297
 
279
298
  test("selects a target with fzf when no directory is provided", async () => {
@@ -307,9 +326,7 @@ describe("work command", () => {
307
326
  "probe:opencode",
308
327
  "launch:opencode",
309
328
  ])
310
- expect(harness.launches[0]?.cwd).toBe(
311
- "/workbase/tasks/delivery/phases/build",
312
- )
329
+ expect(harness.launches[0]?.cwd).toBe(multiPhaseWorkspace.writablePath)
313
330
  })
314
331
 
315
332
  test("selects a registered workbase when local discovery fails", async () => {
@@ -387,7 +404,7 @@ describe("work command", () => {
387
404
  expect(harness.events).toEqual([])
388
405
  })
389
406
 
390
- test("launches OpenCode in the task directory and continues its session", async () => {
407
+ test("launches OpenCode in the writable checkout with explicit context", async () => {
391
408
  const harness = createHarness()
392
409
 
393
410
  await harness.run({ taskId: "example", opencode: true })
@@ -400,8 +417,13 @@ describe("work command", () => {
400
417
  expect(harness.launches).toEqual([
401
418
  {
402
419
  cli: "opencode",
403
- args: ["opencode", "--continue"],
404
- cwd: "/workbase/tasks/example",
420
+ args: [
421
+ "opencode",
422
+ "--continue",
423
+ "--prompt",
424
+ "Start the task. Read /workbase/tasks/example/TASK.md.",
425
+ ],
426
+ cwd: singlePhaseWorkspace.writablePath,
405
427
  },
406
428
  ])
407
429
  expect(harness.statusUpdates).toEqual(["task:example:working"])
@@ -411,7 +433,7 @@ describe("work command", () => {
411
433
  ])
412
434
  })
413
435
 
414
- test("continues OpenCode for a multi-phase task", async () => {
436
+ test("continues OpenCode with explicit task and phase context", async () => {
415
437
  const harness = createHarness({ workspace: multiPhaseWorkspace })
416
438
 
417
439
  await harness.run({
@@ -420,7 +442,16 @@ describe("work command", () => {
420
442
  opencode: true,
421
443
  })
422
444
 
423
- expect(harness.launches[0]?.args).toEqual(["opencode", "--continue"])
445
+ expect(harness.launches[0]).toEqual({
446
+ cli: "opencode",
447
+ args: [
448
+ "opencode",
449
+ "--continue",
450
+ "--prompt",
451
+ "Start the task. Read /workbase/tasks/example/TASK.md and /workbase/tasks/example/phases/implementation/PHASE.md.",
452
+ ],
453
+ cwd: multiPhaseWorkspace.writablePath,
454
+ })
424
455
  })
425
456
 
426
457
  test("automatically falls back to Claude", async () => {
@@ -432,7 +463,7 @@ describe("work command", () => {
432
463
  expect(harness.launches[0]).toEqual({
433
464
  cli: "claude",
434
465
  args: ["claude", "Start the task. Read /workbase/tasks/example/TASK.md."],
435
- cwd: "/workbase/tasks/example",
466
+ cwd: singlePhaseWorkspace.writablePath,
436
467
  })
437
468
  })
438
469
 
@@ -456,7 +487,7 @@ describe("work command", () => {
456
487
  expect(harness.launches[0]).toEqual({
457
488
  cli: "claude",
458
489
  args: ["claude", "Start the task. Read /workbase/tasks/example/TASK.md."],
459
- cwd: "/workbase/tasks/example",
490
+ cwd: singlePhaseWorkspace.writablePath,
460
491
  })
461
492
  })
462
493
 
@@ -493,7 +524,7 @@ describe("work command", () => {
493
524
  verboseHarness.run({ taskId: "example", verbose: true }),
494
525
  )
495
526
  expect(verboseLogs).toEqual([
496
- "Launching command: opencode --continue (cwd: /workbase/tasks/example)",
527
+ "Launching command: opencode --continue --prompt 'Start the task. Read /workbase/tasks/example/TASK.md.' (cwd: /workbase/tasks/example/code/agency)",
497
528
  ])
498
529
  expect(verboseHarness.materializeOptions[0]?.verbose).toBe(true)
499
530
 
@@ -206,7 +206,7 @@ export const work = (
206
206
  prompt = workspace.phasePath
207
207
  ? `Start the task. Read ${workspace.taskPath} and ${workspace.phasePath}.`
208
208
  : `Start the task. Read ${workspace.taskPath}.`
209
- launchPath = dirname(target.path)
209
+ launchPath = workspace.writablePath
210
210
  }
211
211
 
212
212
  const requested = options.claude ? "claude" : "opencode"
@@ -227,7 +227,8 @@ export const work = (
227
227
  yield* tasks.setStatus(target.taskId, "working", root)
228
228
  }
229
229
 
230
- const args = cli === "opencode" ? ["--continue"] : [prompt]
230
+ const args =
231
+ cli === "opencode" ? ["--continue", "--prompt", prompt] : [prompt]
231
232
  verboseLog(
232
233
  `Launching command: ${formatCommand([cli, ...args])} (cwd: ${launchPath})`,
233
234
  )
@@ -314,6 +314,20 @@ process.exit(${exitCode})
314
314
  expect(await Bun.file(ghCallPath).exists()).toBe(false)
315
315
  })
316
316
 
317
+ test("blocks PR creation when workbase validation fails", async () => {
318
+ await createTask()
319
+ await materialize()
320
+ await mkdir(join(root, "tasks", "missing-document"), { recursive: true })
321
+ await writeFakeGh({ stdout: "https://github.com/example/agency/pull/45" })
322
+
323
+ await expect(createPullRequest()).rejects.toThrow(
324
+ "Required document is missing",
325
+ )
326
+
327
+ await expectRemoteBranch("task/example", false)
328
+ expect(await Bun.file(ghCallPath).exists()).toBe(false)
329
+ })
330
+
317
331
  test("reports push failure and does not invoke gh", async () => {
318
332
  await createTask()
319
333
  await materialize()
@@ -85,7 +85,7 @@ describe("WorktreeService", () => {
85
85
  expect(new TextDecoder().decode(branch.stdout).trim()).toBe("task/example")
86
86
  })
87
87
 
88
- test("does not fetch origins for existing worktrees", async () => {
88
+ test("does not fetch the origin for an existing writable worktree", async () => {
89
89
  await runTestEffect(
90
90
  TaskService.pipe(
91
91
  Effect.flatMap((service) =>
@@ -94,7 +94,6 @@ describe("WorktreeService", () => {
94
94
  id: "existing",
95
95
  ticketUrl: "https://example.com/task",
96
96
  repo: "agency",
97
- repos: [{ repo: "effect", ref: "main" }],
98
97
  branch: "task/existing",
99
98
  base: "main",
100
99
  },
@@ -114,6 +113,50 @@ describe("WorktreeService", () => {
114
113
  ["remote", "set-url", "origin", join(root, "missing")],
115
114
  join(root, "repos/agency"),
116
115
  )
116
+ await expect(
117
+ runTestEffect(
118
+ WorktreeService.pipe(
119
+ Effect.flatMap((service) =>
120
+ service.materialize("existing", undefined, root),
121
+ ),
122
+ ),
123
+ ),
124
+ ).resolves.toMatchObject({ repo: "agency" })
125
+ })
126
+
127
+ test("reuses an immutable reference checkout without fetching", async () => {
128
+ const commit = new TextDecoder()
129
+ .decode(
130
+ Bun.spawnSync(
131
+ ["git", "-C", join(root, "repos/effect"), "rev-parse", "main"],
132
+ { stdout: "pipe" },
133
+ ).stdout,
134
+ )
135
+ .trim()
136
+ await runTestEffect(
137
+ TaskService.pipe(
138
+ Effect.flatMap((service) =>
139
+ service.create(
140
+ {
141
+ id: "immutable-reference",
142
+ ticketUrl: "https://example.com/task",
143
+ repo: "agency",
144
+ repos: [{ repo: "effect", ref: commit }],
145
+ branch: "task/immutable-reference",
146
+ base: "main",
147
+ },
148
+ root,
149
+ ),
150
+ ),
151
+ ),
152
+ )
153
+ await runTestEffect(
154
+ WorktreeService.pipe(
155
+ Effect.flatMap((service) =>
156
+ service.materialize("immutable-reference", undefined, root),
157
+ ),
158
+ ),
159
+ )
117
160
  await git(
118
161
  ["remote", "set-url", "origin", join(root, "missing")],
119
162
  join(root, "repos/effect"),
@@ -123,11 +166,44 @@ describe("WorktreeService", () => {
123
166
  runTestEffect(
124
167
  WorktreeService.pipe(
125
168
  Effect.flatMap((service) =>
126
- service.materialize("existing", undefined, root),
169
+ service.materialize("immutable-reference", undefined, root),
127
170
  ),
128
171
  ),
129
172
  ),
130
- ).resolves.toMatchObject({ repo: "agency" })
173
+ ).resolves.toMatchObject({ repos: [{ repo: "effect", ref: commit }] })
174
+ })
175
+
176
+ test("stops before materializing when workbase validation fails", async () => {
177
+ await runTestEffect(
178
+ TaskService.pipe(
179
+ Effect.flatMap((service) =>
180
+ service.create(
181
+ {
182
+ id: "valid-target",
183
+ ticketUrl: "https://example.com/task",
184
+ repo: "agency",
185
+ branch: "task/valid-target",
186
+ base: "main",
187
+ },
188
+ root,
189
+ ),
190
+ ),
191
+ ),
192
+ )
193
+ await mkdir(join(root, "tasks", "missing-document"), { recursive: true })
194
+
195
+ await expect(
196
+ runTestEffect(
197
+ WorktreeService.pipe(
198
+ Effect.flatMap((service) =>
199
+ service.materialize("valid-target", undefined, root),
200
+ ),
201
+ ),
202
+ ),
203
+ ).rejects.toThrow("Required document is missing")
204
+ expect(
205
+ await Bun.file(join(root, "tasks", "valid-target", "code")).exists(),
206
+ ).toBe(false)
131
207
  })
132
208
 
133
209
  test("uses a configured worktree creation command", async () => {
@@ -330,7 +406,7 @@ describe("WorktreeService", () => {
330
406
  repo: "missing",
331
407
  base: "main",
332
408
  config: { version: 2 },
333
- expected: "Repository alias 'missing' does not exist",
409
+ expected: "Unknown repository alias 'missing'",
334
410
  },
335
411
  {
336
412
  id: "bad-base",
@@ -392,6 +468,7 @@ pr: null
392
468
  ),
393
469
  ),
394
470
  ).rejects.toThrow(fixture.expected)
471
+ await rm(taskDirectory, { recursive: true, force: true })
395
472
  }
396
473
  })
397
474
 
@@ -561,7 +638,7 @@ pr: null
561
638
  ).rejects.toThrow("is not registered to branch 'task/expected'")
562
639
  })
563
640
 
564
- test("rejects a reused reference checkout after its ref advances", async () => {
641
+ test("fetches a moving ref before checking a reused reference checkout", async () => {
565
642
  await runTestEffect(
566
643
  TaskService.pipe(
567
644
  Effect.flatMap((service) =>
@@ -570,7 +647,7 @@ pr: null
570
647
  id: "pinned-reference",
571
648
  ticketUrl: "https://example.com/task",
572
649
  repo: "agency",
573
- repos: [{ repo: "effect", ref: "main" }],
650
+ repos: [{ repo: "effect", ref: "origin/main" }],
574
651
  branch: "task/pinned-reference",
575
652
  base: "main",
576
653
  },
@@ -590,7 +667,6 @@ pr: null
590
667
  await Bun.write(join(source, "README.md"), "updated\n")
591
668
  await git(["add", "README.md"], source)
592
669
  await git(["-c", "commit.gpgsign=false", "commit", "-m", "update"], source)
593
- await git(["push", join(root, "repos/effect"), "main"], source)
594
670
 
595
671
  await expect(
596
672
  runTestEffect(
@@ -600,7 +676,7 @@ pr: null
600
676
  ),
601
677
  ),
602
678
  ),
603
- ).rejects.toThrow("does not match reference 'main'")
679
+ ).rejects.toThrow("does not match reference 'origin/main'")
604
680
  })
605
681
 
606
682
  test("rejects a reference checkout attached to a branch", async () => {
@@ -60,6 +60,11 @@ const formatCommand = (args: readonly string[]) =>
60
60
  )
61
61
  .join(" ")
62
62
 
63
+ const isCommitId = (ref: string) => /^[0-9a-f]{40,64}$/i.test(ref)
64
+
65
+ const originRef = (ref: string) =>
66
+ ref.replace(/^refs\/remotes\/origin\//, "").replace(/^origin\//, "")
67
+
63
68
  export class WorktreeService extends Effect.Service<WorktreeService>()(
64
69
  "WorktreeService",
65
70
  {
@@ -80,12 +85,10 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
80
85
  options.verbose === true && !options.silent && !options.json
81
86
  const { root, config } = yield* workbase.loadConfig(startPath)
82
87
  const report = yield* workbase.validate(root)
83
- const ownershipIssue = report.issues.find((issue) =>
84
- issue.message.startsWith("Writable branch "),
85
- )
86
- if (ownershipIssue) {
88
+ const validationIssue = report.issues[0]
89
+ if (validationIssue) {
87
90
  return yield* new WorktreeError({
88
- message: `${ownershipIssue.path}: ${ownershipIssue.message}`,
91
+ message: `${validationIssue.path}: ${validationIssue.message}`,
89
92
  })
90
93
  }
91
94
  const task = yield* tasks.show(taskId, root)
@@ -136,23 +139,32 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
136
139
  })
137
140
  }
138
141
 
139
- const fetchOrigin = Effect.gen(function* () {
140
- const remote = yield* fs.runCommand(
141
- ["git", "-C", repositoryPath, "remote", "get-url", "origin"],
142
- { captureOutput: true },
143
- )
144
- if (remote.exitCode !== 0) return
142
+ const fetchOrigin = (ref?: string) =>
143
+ Effect.gen(function* () {
144
+ const remote = yield* fs.runCommand(
145
+ ["git", "-C", repositoryPath, "remote", "get-url", "origin"],
146
+ { captureOutput: true },
147
+ )
148
+ if (remote.exitCode !== 0) return false
145
149
 
146
- const fetch = yield* fs.runCommand(
147
- ["git", "-C", repositoryPath, "fetch", "origin"],
148
- { captureOutput: true },
149
- )
150
- if (fetch.exitCode !== 0) {
151
- return yield* new WorktreeError({
152
- message: `Failed to fetch '${alias}': ${fetch.stderr}`,
153
- })
154
- }
155
- })
150
+ const fetch = yield* fs.runCommand(
151
+ [
152
+ "git",
153
+ "-C",
154
+ repositoryPath,
155
+ "fetch",
156
+ "origin",
157
+ ...(ref ? [ref] : []),
158
+ ],
159
+ { captureOutput: true },
160
+ )
161
+ if (fetch.exitCode !== 0) {
162
+ return yield* new WorktreeError({
163
+ message: `Failed to fetch '${alias}': ${fetch.stderr}`,
164
+ })
165
+ }
166
+ return true
167
+ })
156
168
 
157
169
  const listed = yield* fs.runCommand(
158
170
  [
@@ -210,7 +222,7 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
210
222
  message: `Worktree registry contains a missing checkout at ${checkoutPath}`,
211
223
  })
212
224
  }
213
- yield* fetchOrigin
225
+ yield* fetchOrigin()
214
226
 
215
227
  let args: string[]
216
228
  let env: Record<string, string> | undefined
@@ -297,6 +309,10 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
297
309
  })
298
310
  }
299
311
  } else {
312
+ const fetched = isCommitId(checkout.ref)
313
+ ? false
314
+ : yield* fetchOrigin(originRef(checkout.ref))
315
+ const resolvedRefName = fetched ? "FETCH_HEAD" : checkout.ref
300
316
  const resolvedRef = yield* fs.runCommand(
301
317
  [
302
318
  "git",
@@ -304,7 +320,7 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
304
320
  repositoryPath,
305
321
  "rev-parse",
306
322
  "--verify",
307
- `${checkout.ref}^{commit}`,
323
+ `${resolvedRefName}^{commit}`,
308
324
  ],
309
325
  { captureOutput: true },
310
326
  )
@@ -344,7 +360,6 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
344
360
  message: `Worktree registry contains a missing checkout at ${checkoutPath}`,
345
361
  })
346
362
  }
347
- yield* fetchOrigin
348
363
  const result = yield* fs.runCommand(
349
364
  [
350
365
  "git",