@markjaquith/agency 2.34.0 → 2.35.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "2.34.0",
3
+ "version": "2.35.0",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -80,9 +80,6 @@
80
80
  "oxfmt": "^0.27.0",
81
81
  "typescript": "^7.0.2"
82
82
  },
83
- "peerDependencies": {
84
- "typescript": "^7.0.2"
85
- },
86
83
  "engines": {
87
84
  "bun": ">=1.0.0"
88
85
  }
package/src/cli.test.ts CHANGED
@@ -862,6 +862,19 @@ status: open
862
862
  await runGit(["-C", source, "remote", "add", "origin", daemon.remote])
863
863
 
864
864
  parseJson(await runCli(["init", root, "--json"], parent))
865
+ const agencyConfigPath = join(root, "agency.json")
866
+ const agencyConfig = JSON.parse(await Bun.file(agencyConfigPath).text())
867
+ await Bun.write(
868
+ agencyConfigPath,
869
+ `${JSON.stringify(
870
+ {
871
+ ...agencyConfig,
872
+ runners: { noop: { command: ["true"] } },
873
+ },
874
+ null,
875
+ 2,
876
+ )}\n`,
877
+ )
865
878
  parseJson(
866
879
  await runCli(["repo", "link", "agency", source, "--json"], root),
867
880
  )
@@ -1066,6 +1079,61 @@ status: open
1066
1079
  }
1067
1080
  }
1068
1081
  }
1082
+
1083
+ parseJson(
1084
+ await runCli(["task", "status", "example", "done", "--json"], root),
1085
+ )
1086
+ const blocked = await runCli(
1087
+ ["work", "--task", "example", "--runner", "noop"],
1088
+ root,
1089
+ )
1090
+ expect(blocked.exitCode).toBe(1)
1091
+ expect(blocked.stderr).toContain("Task status is done")
1092
+ expect(
1093
+ parseJson(await runCli(["task", "show", "example", "--json"], root))
1094
+ .data.status,
1095
+ ).toBe("done")
1096
+
1097
+ const resumedTask = await runCli(
1098
+ ["work", "--task", "example", "--runner", "noop", "--force"],
1099
+ root,
1100
+ )
1101
+ expect(resumedTask).toMatchObject({ exitCode: 0, stderr: "" })
1102
+ expect(resumedTask.stdout).toContain(
1103
+ "Reopened task/example from done as working",
1104
+ )
1105
+ expect(
1106
+ parseJson(await runCli(["task", "show", "example", "--json"], root))
1107
+ .data.status,
1108
+ ).toBe("working")
1109
+
1110
+ parseJson(
1111
+ await runCli(
1112
+ ["phase", "status", "pipeline", "build", "dropped", "--json"],
1113
+ root,
1114
+ ),
1115
+ )
1116
+ const resumedPhase = await runCli(
1117
+ [
1118
+ "work",
1119
+ "--task",
1120
+ "pipeline",
1121
+ "--phase",
1122
+ "build",
1123
+ "--runner",
1124
+ "noop",
1125
+ "--force",
1126
+ ],
1127
+ root,
1128
+ )
1129
+ expect(resumedPhase).toMatchObject({ exitCode: 0, stderr: "" })
1130
+ expect(resumedPhase.stdout).toContain(
1131
+ "Reopened phase/pipeline/build from dropped as working",
1132
+ )
1133
+ const graph = parseJson(await runCli(["graph", "--json"], root))
1134
+ expect(
1135
+ graph.nodes.find((node: any) => node.id === "task:pipeline").status,
1136
+ ).toBe("working")
1069
1137
  },
1070
1138
  30_000,
1071
1139
  )
@@ -69,10 +69,17 @@ interface HarnessOptions {
69
69
  readonly registeredWorkbases?: readonly string[]
70
70
  readonly existingDirectories?: readonly string[]
71
71
  readonly guardError?: Error
72
+ readonly launchError?: Error
72
73
  readonly workTargetIds?: readonly string[]
73
74
  readonly opencodeIntegrationState?: "managed" | "customized"
74
75
  readonly taskStatus?: "open" | "working" | "delegated" | "done" | "dropped"
75
76
  readonly phaseStatus?: "open" | "working" | "delegated" | "done" | "dropped"
77
+ readonly taskStatuses?: Readonly<
78
+ Record<string, "open" | "working" | "delegated" | "done" | "dropped">
79
+ >
80
+ readonly phaseStatuses?: Readonly<
81
+ Record<string, "open" | "working" | "delegated" | "done" | "dropped">
82
+ >
76
83
  }
77
84
 
78
85
  const createHarness = (options: HarnessOptions = {}) => {
@@ -92,6 +99,8 @@ const createHarness = (options: HarnessOptions = {}) => {
92
99
  const materializeOptions: Array<
93
100
  Parameters<WorktreeService["materialize"]>[3]
94
101
  > = []
102
+ const taskStatuses = { ...options.taskStatuses }
103
+ const phaseStatuses = { ...options.phaseStatuses }
95
104
  const worktrees = {
96
105
  materialize: (
97
106
  _taskId: string,
@@ -147,13 +156,19 @@ const createHarness = (options: HarnessOptions = {}) => {
147
156
  repo: "agency",
148
157
  branch: `task/${id}`,
149
158
  base: "main",
150
- status: options.taskStatus ?? "open",
159
+ status: taskStatuses[id] ?? options.taskStatus ?? "open",
151
160
  },
152
161
  })
153
162
  },
154
163
  list: () => Effect.succeed(options.taskRecords ?? []),
155
164
  setStatus: (id: string, status: string) => {
156
165
  statusUpdates.push(`task:${id}:${status}`)
166
+ taskStatuses[id] = status as
167
+ | "open"
168
+ | "working"
169
+ | "delegated"
170
+ | "done"
171
+ | "dropped"
157
172
  return Effect.void
158
173
  },
159
174
  }
@@ -167,12 +182,19 @@ const createHarness = (options: HarnessOptions = {}) => {
167
182
  repo: "agency",
168
183
  branch: `task/${id}`,
169
184
  base: "main",
170
- status: options.phaseStatus ?? "open",
185
+ status:
186
+ phaseStatuses[`${taskId}/${id}`] ?? options.phaseStatus ?? "open",
171
187
  },
172
188
  }),
173
189
  list: () => Effect.succeed(options.phaseRecords ?? []),
174
190
  setStatus: (taskId: string, id: string, status: string) => {
175
191
  statusUpdates.push(`phase:${taskId}:${id}:${status}`)
192
+ phaseStatuses[`${taskId}/${id}`] = status as
193
+ | "open"
194
+ | "working"
195
+ | "delegated"
196
+ | "done"
197
+ | "dropped"
176
198
  return Effect.void
177
199
  },
178
200
  }
@@ -241,6 +263,7 @@ const createHarness = (options: HarnessOptions = {}) => {
241
263
  events.push(`launch:${cli}`)
242
264
  launches.push({ cli, args, cwd })
243
265
  launchEnvironments.push(environment)
266
+ if (options.launchError) throw options.launchError
244
267
  }
245
268
  const defaultPick: PickWorkTarget = () => Effect.succeed(null)
246
269
  const defaultPickWorkbase: PickWorkbase = () => Effect.succeed(null)
@@ -284,6 +307,8 @@ const createHarness = (options: HarnessOptions = {}) => {
284
307
  launchEnvironments,
285
308
  materializeOptions,
286
309
  statusUpdates,
310
+ taskStatuses,
311
+ phaseStatuses,
287
312
  shownTasks,
288
313
  progressUpdates,
289
314
  guards,
@@ -328,6 +353,72 @@ describe("work command", () => {
328
353
  })
329
354
  })
330
355
 
356
+ test("reopens forced terminal tasks through open before launching as working", async () => {
357
+ for (const previousStatus of ["done", "dropped"] as const) {
358
+ const harness = createHarness({
359
+ taskStatuses: { example: previousStatus },
360
+ })
361
+ const output = await captureLogs(() =>
362
+ harness.run({ taskId: "example", opencode: true, force: true }),
363
+ )
364
+
365
+ expect(harness.statusUpdates).toEqual([
366
+ "task:example:open",
367
+ "task:example:working",
368
+ ])
369
+ expect(harness.taskStatuses.example).toBe("working")
370
+ expect(output).toEqual([
371
+ `Reopened task/example from ${previousStatus} as working`,
372
+ ])
373
+ }
374
+ })
375
+
376
+ test("reopens forced terminal phases through open before launching as working", async () => {
377
+ for (const previousStatus of ["done", "dropped"] as const) {
378
+ const harness = createHarness({
379
+ workspace: multiPhaseWorkspace,
380
+ multiPhaseTasks: ["example"],
381
+ phaseStatuses: { "example/implementation": previousStatus },
382
+ })
383
+ const output = await captureLogs(() =>
384
+ harness.run({
385
+ taskId: "example",
386
+ phaseId: "implementation",
387
+ opencode: true,
388
+ force: true,
389
+ }),
390
+ )
391
+
392
+ expect(harness.statusUpdates).toEqual([
393
+ "phase:example:implementation:open",
394
+ "phase:example:implementation:working",
395
+ ])
396
+ expect(harness.phaseStatuses["example/implementation"]).toBe("working")
397
+ expect(output).toEqual([
398
+ `Reopened phase/example/implementation from ${previousStatus} as working`,
399
+ ])
400
+ }
401
+ })
402
+
403
+ test("reports a forced reopen as one machine result", async () => {
404
+ const harness = createHarness({ taskStatuses: { example: "done" } })
405
+ const output = await captureLogs(() =>
406
+ harness.run({
407
+ taskId: "example",
408
+ opencode: true,
409
+ force: true,
410
+ json: true,
411
+ }),
412
+ )
413
+
414
+ expect(JSON.parse(output.join("\n"))).toEqual({
415
+ target: "task/example",
416
+ reopened: true,
417
+ previousStatus: "done",
418
+ status: "working",
419
+ })
420
+ })
421
+
331
422
  test("offers only launchable targets to the interactive chooser", async () => {
332
423
  const harness = createHarness({
333
424
  taskRecords: [
@@ -425,7 +516,7 @@ describe("work command", () => {
425
516
  opencode: true,
426
517
  })
427
518
 
428
- expect(harness.shownTasks).toEqual(["delivery"])
519
+ expect(harness.shownTasks).toEqual(["delivery", "delivery"])
429
520
  expect(harness.launches[0]?.cwd).toBe(taskDirectory)
430
521
  })
431
522
 
@@ -438,7 +529,7 @@ describe("work command", () => {
438
529
  opencode: true,
439
530
  })
440
531
 
441
- expect(harness.shownTasks).toEqual(["delivery"])
532
+ expect(harness.shownTasks).toEqual(["delivery", "delivery"])
442
533
  expect(harness.launches[0]?.cwd).toBe(taskDirectory)
443
534
  })
444
535
 
@@ -829,6 +920,22 @@ describe("work command", () => {
829
920
  expect(printed.environment.API_TOKEN).toBeUndefined()
830
921
  })
831
922
 
923
+ test("does not reopen a forced terminal target in print-only mode", async () => {
924
+ const harness = createHarness({ taskStatuses: { example: "done" } })
925
+
926
+ await captureLogs(() =>
927
+ harness.run({
928
+ taskId: "example",
929
+ opencode: true,
930
+ force: true,
931
+ printCommand: true,
932
+ }),
933
+ )
934
+
935
+ expect(harness.statusUpdates).toEqual([])
936
+ expect(harness.taskStatuses.example).toBe("done")
937
+ })
938
+
832
939
  test("prints the runtime OpenCode integration config", async () => {
833
940
  const harness = createHarness()
834
941
  const output = await captureLogs(() =>
@@ -933,6 +1040,35 @@ describe("work command", () => {
933
1040
  ])
934
1041
  })
935
1042
 
1043
+ test("does not reopen a terminal target when preparation fails", async () => {
1044
+ const harness = createHarness({
1045
+ taskStatuses: { example: "dropped" },
1046
+ materializeError: new Error("materialization failed"),
1047
+ })
1048
+
1049
+ await expect(
1050
+ harness.run({ taskId: "example", force: true }),
1051
+ ).rejects.toThrow("materialization failed")
1052
+ expect(harness.statusUpdates).toEqual([])
1053
+ expect(harness.taskStatuses.example).toBe("dropped")
1054
+ })
1055
+
1056
+ test("retains working when launch fails after a forced reopen", async () => {
1057
+ const harness = createHarness({
1058
+ taskStatuses: { example: "done" },
1059
+ launchError: new Error("launch failed"),
1060
+ })
1061
+
1062
+ await expect(
1063
+ harness.run({ taskId: "example", force: true, silent: true }),
1064
+ ).rejects.toThrow("launch failed")
1065
+ expect(harness.statusUpdates).toEqual([
1066
+ "task:example:open",
1067
+ "task:example:working",
1068
+ ])
1069
+ expect(harness.taskStatuses.example).toBe("working")
1070
+ })
1071
+
936
1072
  test("respects silent and verbose logging options", async () => {
937
1073
  const verboseHarness = createHarness()
938
1074
  const verboseLogs = await captureErrors(() =>
@@ -12,6 +12,7 @@ import { IntegrationService } from "../services/IntegrationService"
12
12
  import { createLoggers } from "../utils/effect"
13
13
  import { execvp } from "../utils/exec"
14
14
  import { createProgress, type Progress } from "../utils/progress"
15
+ import { isTerminalStatus } from "../readiness"
15
16
  import {
16
17
  buildWorkTargetChoices,
17
18
  pickWorkTarget,
@@ -379,9 +380,53 @@ export const work = (
379
380
  return
380
381
  }
381
382
  if (target.kind === "phase") {
383
+ const previousStatus = (yield* phases.show(
384
+ target.taskId,
385
+ target.phaseId,
386
+ root,
387
+ )).data.status
388
+ if (options.force && isTerminalStatus(previousStatus)) {
389
+ yield* phases.setStatus(target.taskId, target.phaseId, "open", root)
390
+ }
382
391
  yield* phases.setStatus(target.taskId, target.phaseId, "working", root)
392
+ if (options.force && isTerminalStatus(previousStatus)) {
393
+ const result = {
394
+ target: `phase/${target.taskId}/${target.phaseId}`,
395
+ reopened: true,
396
+ previousStatus,
397
+ status: "working",
398
+ }
399
+ log(
400
+ options.json
401
+ ? JSON.stringify(result)
402
+ : `Reopened ${result.target} from ${previousStatus} as working`,
403
+ )
404
+ }
383
405
  } else if (target.kind === "task" && !target.multiPhase) {
406
+ const task = yield* tasks.show(target.taskId, root)
407
+ if ("phases" in task.data) {
408
+ return yield* Effect.fail(
409
+ new Error(`Task '${target.taskId}' is not an execution unit`),
410
+ )
411
+ }
412
+ const previousStatus = task.data.status
413
+ if (options.force && isTerminalStatus(previousStatus)) {
414
+ yield* tasks.setStatus(target.taskId, "open", root)
415
+ }
384
416
  yield* tasks.setStatus(target.taskId, "working", root)
417
+ if (options.force && isTerminalStatus(previousStatus)) {
418
+ const result = {
419
+ target: `task/${target.taskId}`,
420
+ reopened: true,
421
+ previousStatus,
422
+ status: "working",
423
+ }
424
+ log(
425
+ options.json
426
+ ? JSON.stringify(result)
427
+ : `Reopened ${result.target} from ${previousStatus} as working`,
428
+ )
429
+ }
385
430
  }
386
431
  for (const [key, value] of Object.entries(environment)) {
387
432
  process.env[key] = value
@@ -484,7 +529,7 @@ Options:
484
529
  --print-command Print cwd, argv, and non-secret environment without launch
485
530
  --opencode Require the OpenCode preset
486
531
  --claude Require the Claude Code preset
487
- --force Override readiness and terminal-state guards
532
+ --force Override readiness; reopen terminal execution units
488
533
  --no-input Never open an interactive selector
489
534
 
490
535
  Without interactive input, provide an explicit workbase or cwd and an entity