@markjaquith/agency 2.71.16 → 2.71.18

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.71.16",
3
+ "version": "2.71.18",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -75,8 +75,10 @@
75
75
  "benchmark:context": "bun scripts/benchmark-context.ts",
76
76
  "benchmark:claim": "bun scripts/benchmark-claim.ts",
77
77
  "benchmark:finish": "bun scripts/benchmark-finish.ts",
78
+ "benchmark:epic": "bun scripts/benchmark-epic.ts",
78
79
  "benchmark:phase": "bun scripts/benchmark-phase.ts",
79
80
  "benchmark:push": "bun scripts/benchmark-push.ts",
81
+ "benchmark:archive": "bun scripts/benchmark-archive.ts",
80
82
  "benchmark:sync": "bun scripts/benchmark-sync.ts",
81
83
  "benchmark:task": "bun scripts/benchmark-task.ts",
82
84
  "benchmark:validate": "bun scripts/benchmark-validate.ts",
@@ -111,6 +111,35 @@ describe("epic command", () => {
111
111
  })
112
112
  })
113
113
 
114
+ test("reads each epic document once when listing JSON", async () => {
115
+ await runTestEffect(
116
+ epic({
117
+ subcommand: "create",
118
+ args: ["example"],
119
+ ticketUrl: "https://example.com/epic",
120
+ repos: ["agency:main"],
121
+ cwd: root,
122
+ silent: true,
123
+ }),
124
+ )
125
+ const original = Bun.file
126
+ let reads = 0
127
+ Bun.file = ((path: Parameters<typeof Bun.file>[0], ...args: never[]) => {
128
+ if (String(path).endsWith("/epics/example/EPIC.md")) reads += 1
129
+ return original(path as unknown as string, ...args)
130
+ }) as typeof Bun.file
131
+ try {
132
+ await captureLogs(() =>
133
+ runTestEffect(
134
+ epic({ subcommand: "list", args: [], cwd: root, json: true }),
135
+ ),
136
+ )
137
+ } finally {
138
+ Bun.file = original
139
+ }
140
+ expect(reads).toBe(2)
141
+ })
142
+
114
143
  test("renders a readable operational table", async () => {
115
144
  await runTestEffect(
116
145
  epic({
@@ -1,4 +1,5 @@
1
1
  import { Effect } from "effect"
2
+ import { join } from "node:path"
2
3
  import type { BaseCommandOptions } from "../utils/command"
3
4
  import { EpicService } from "../services/EpicService"
4
5
  import { createLoggers } from "../utils/effect"
@@ -71,7 +72,6 @@ export const epic = (options: EpicOptions, work: StartWork = startWork) =>
71
72
  }
72
73
 
73
74
  case "list": {
74
- const records = yield* epics.list(cwd)
75
75
  const { epicRows } = yield* getWorkViews({
76
76
  cwd,
77
77
  statuses: options.statuses,
@@ -80,14 +80,15 @@ export const epic = (options: EpicOptions, work: StartWork = startWork) =>
80
80
  blocked: options.blocked,
81
81
  pr: options.pr,
82
82
  })
83
- const ordered = epicRows.flatMap((row) => {
84
- const record = records.find((item) => item.id === row.key)
85
- return record ? [record] : []
86
- })
87
83
  if (options.json) {
88
84
  log(
89
85
  JSON.stringify(
90
- ordered.map(({ content: _, ...record }) => record),
86
+ epicRows.map((row) => ({
87
+ id: row.id,
88
+ path: join(cwd, "epics", row.id, "EPIC.md"),
89
+ revision: row.revision,
90
+ data: row.data,
91
+ })),
91
92
  null,
92
93
  2,
93
94
  ),
package/src/readiness.ts CHANGED
@@ -24,23 +24,27 @@ export const canTransitionStatus = (from: WorkStatus, to: WorkStatus) =>
24
24
  export const aggregateProgress = (statuses: readonly WorkStatus[]) => {
25
25
  const counts = {
26
26
  total: statuses.length,
27
- open: statuses.filter((status) => status === "open").length,
28
- working: statuses.filter((status) => status === "working").length,
29
- delegated: statuses.filter((status) => status === "delegated").length,
30
- done: statuses.filter((status) => status === "done").length,
31
- dropped: statuses.filter((status) => status === "dropped").length,
32
- terminal: statuses.filter(isTerminalStatus).length,
27
+ open: 0,
28
+ working: 0,
29
+ delegated: 0,
30
+ done: 0,
31
+ dropped: 0,
32
+ terminal: 0,
33
+ }
34
+ for (const status of statuses) {
35
+ counts[status] += 1
36
+ if (isTerminalStatus(status)) counts.terminal += 1
33
37
  }
34
38
  const status: WorkStatus =
35
- statuses.length === 0
39
+ counts.total === 0
36
40
  ? "open"
37
- : statuses.every((value) => value === "done")
41
+ : counts.done === counts.total
38
42
  ? "done"
39
- : statuses.every(isTerminalStatus)
43
+ : counts.terminal === counts.total
40
44
  ? "dropped"
41
- : statuses.includes("working")
45
+ : counts.working > 0
42
46
  ? "working"
43
- : statuses.includes("delegated")
47
+ : counts.delegated > 0
44
48
  ? "delegated"
45
49
  : "open"
46
50
  return { status, ...counts }
@@ -121,6 +121,35 @@ describe("ArchiveService bulk task archive", () => {
121
121
  )
122
122
  })
123
123
 
124
+ test("reuses loaded records while preflighting task worktrees", async () => {
125
+ for (const id of ["first", "second"]) {
126
+ await createTask(id)
127
+ await dropTask(id)
128
+ }
129
+ let taskReads = 0
130
+ const file = Bun.file(join(root, "tasks/first/TASK.md"))
131
+ const prototype = Object.getPrototypeOf(file) as {
132
+ text: () => Promise<string>
133
+ name: string
134
+ }
135
+ const originalText = prototype.text
136
+ prototype.text = function () {
137
+ if (this.name.endsWith("/TASK.md")) taskReads += 1
138
+ return originalText.call(this)
139
+ }
140
+
141
+ try {
142
+ const result = await archiveTasks(true)
143
+ expect(result.tasks.map((task) => task.disposition)).toEqual([
144
+ "planned",
145
+ "planned",
146
+ ])
147
+ expect(taskReads).toBe(10)
148
+ } finally {
149
+ prototype.text = originalText
150
+ }
151
+ })
152
+
124
153
  test("uses aggregate phase status and archives only when every phase is terminal", async () => {
125
154
  await runTestEffect(
126
155
  TaskService.pipe(
@@ -887,7 +887,10 @@ export class ArchiveService extends Effect.Service<ArchiveService>()(
887
887
  })
888
888
  continue
889
889
  }
890
+ }
890
891
 
892
+ for (const context of contexts.values()) {
893
+ if (skipped.has(context.task.id)) continue
891
894
  const removed: string[] = []
892
895
  let preflightFailure: unknown
893
896
  for (const unit of context.executionUnits) {
@@ -895,6 +898,10 @@ export class ArchiveService extends Effect.Service<ArchiveService>()(
895
898
  .remove(unit.taskId, executionPhaseId(unit), root, {
896
899
  dryRun: true,
897
900
  persistResume: false,
901
+ task: context.task,
902
+ phase: unit.phaseId
903
+ ? context.phases.find((phase) => phase.id === unit.phaseId)
904
+ : undefined,
898
905
  })
899
906
  .pipe(Effect.either)
900
907
  if (Either.isLeft(result)) {
@@ -943,13 +950,13 @@ export class ArchiveService extends Effect.Service<ArchiveService>()(
943
950
  "dirty" in conflict &&
944
951
  conflict.dirty === true,
945
952
  )
946
- skipped.set(task.id, {
953
+ skipped.set(context.task.id, {
947
954
  code: dirty ? "dirty-worktree" : "checkout-preflight-failed",
948
955
  details: [message],
949
956
  })
950
957
  continue
951
958
  }
952
- removedByTask.set(task.id, [...new Set(removed)].sort())
959
+ removedByTask.set(context.task.id, [...new Set(removed)].sort())
953
960
  }
954
961
 
955
962
  const cohort = new Set(
@@ -1069,6 +1076,14 @@ export class ArchiveService extends Effect.Service<ArchiveService>()(
1069
1076
  snapshots,
1070
1077
  lockHeld: true,
1071
1078
  persistResume: false,
1079
+ task: contexts.get(unit.taskId)!.task,
1080
+ phase: unit.phaseId
1081
+ ? contexts
1082
+ .get(unit.taskId)!
1083
+ .phases.find(
1084
+ (phase) => phase.id === unit.phaseId,
1085
+ )
1086
+ : undefined,
1072
1087
  }),
1073
1088
  )
1074
1089
  }
@@ -298,22 +298,28 @@ export class GraphService extends Effect.Service<GraphService>()(
298
298
  }
299
299
  const taskDependencies = (taskId: string) =>
300
300
  taskDeclarations.get(taskId)?.dependsOn ?? []
301
+ const taskLeafStatusCache = new Map<string, WorkStatus[]>()
301
302
  const taskLeafStatuses = (taskId: string): WorkStatus[] => {
303
+ const cached = taskLeafStatusCache.get(taskId)
304
+ if (cached) return cached
302
305
  const task = tasks.get(taskId)
303
306
  if (!task) return []
304
- return "phases" in task.data
305
- ? task.data.phases.map(
306
- (item) =>
307
- phases.get(`${taskId}/${item.id}`)?.data.status ?? "open",
308
- )
309
- : [task.data.status]
307
+ const statuses =
308
+ "phases" in task.data
309
+ ? task.data.phases.map(
310
+ (item) =>
311
+ phases.get(`${taskId}/${item.id}`)?.data.status ?? "open",
312
+ )
313
+ : [task.data.status]
314
+ taskLeafStatusCache.set(taskId, statuses)
315
+ return statuses
310
316
  }
311
- const taskStatuses = new Map<string, WorkStatus>()
317
+ const taskStatusCache = new Map<string, WorkStatus>()
312
318
  const taskStatus = (taskId: string) => {
313
- const cached = taskStatuses.get(taskId)
319
+ const cached = taskStatusCache.get(taskId)
314
320
  if (cached) return cached
315
321
  const status = aggregateProgress(taskLeafStatuses(taskId)).status
316
- taskStatuses.set(taskId, status)
322
+ taskStatusCache.set(taskId, status)
317
323
  return status
318
324
  }
319
325
  const dependencyBlockers = (
@@ -284,6 +284,8 @@ interface RemoveOptions extends BaseCommandOptions {
284
284
  readonly lockHeld?: boolean
285
285
  readonly allowReferenceDrift?: boolean
286
286
  readonly persistResume?: boolean
287
+ readonly task?: TaskRecord
288
+ readonly phase?: PhaseRecord
287
289
  }
288
290
 
289
291
  interface LifecycleOptions extends BaseCommandOptions {
@@ -1575,14 +1577,14 @@ const removeJj = (
1575
1577
  const phases = yield* PhaseService
1576
1578
  const backend = yield* versionControl.forWorkbase(root)
1577
1579
  const inspection = yield* inspectExecution(taskId, phaseId, root)
1578
- const task = yield* tasks.show(taskId, root)
1580
+ const task = options.task ?? (yield* tasks.show(taskId, root))
1579
1581
  const execution =
1580
1582
  "phases" in task.data && phaseId
1581
- ? (yield* phases.show(taskId, phaseId, root)).data
1583
+ ? (options.phase ?? (yield* phases.show(taskId, phaseId, root))).data
1582
1584
  : task.data
1583
1585
  const phasePath =
1584
1586
  "phases" in task.data && phaseId
1585
- ? (yield* phases.show(taskId, phaseId, root)).path
1587
+ ? (options.phase ?? (yield* phases.show(taskId, phaseId, root))).path
1586
1588
  : null
1587
1589
  const resumePath = jjResumePath(task.path, phasePath)
1588
1590
  if (yield* fs.exists(resumePath)) {
@@ -2909,7 +2911,7 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
2909
2911
  conflicts: blockingConflicts,
2910
2912
  })
2911
2913
  }
2912
- const task = yield* tasks.show(taskId, root)
2914
+ const task = options.task ?? (yield* tasks.show(taskId, root))
2913
2915
 
2914
2916
  let execution:
2915
2917
  | {
@@ -2925,7 +2927,9 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
2925
2927
  message: `Task '${taskId}' has multiple phases; phase ID is required`,
2926
2928
  })
2927
2929
  }
2928
- const phase = yield* phases.show(taskId, phaseId, root)
2930
+ const phase =
2931
+ options.phase ??
2932
+ (yield* phases.show(taskId, phaseId, root))
2929
2933
  execution = phase.data
2930
2934
  codePath = join(dirname(phase.path), "code")
2931
2935
  } else {
package/src/work-view.ts CHANGED
@@ -34,6 +34,7 @@ export interface WorkViewRow {
34
34
  readonly pr: string
35
35
  readonly worktree: string
36
36
  readonly hasPr: boolean
37
+ readonly data: Readonly<Record<string, unknown>>
37
38
  }
38
39
 
39
40
  const allowedStatuses = new Set<WorkStatus>([
@@ -122,6 +123,9 @@ const rowFor = (
122
123
  hasPr: executions.some(
123
124
  (execution) => "pr" in execution.data && Boolean(execution.data.pr),
124
125
  ),
126
+ data: Object.fromEntries(
127
+ Object.entries(node.data).filter(([key]) => key !== "sha256"),
128
+ ),
125
129
  }
126
130
  }
127
131