@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 +3 -1
- package/src/commands/epic.test.ts +29 -0
- package/src/commands/epic.ts +7 -6
- package/src/readiness.ts +15 -11
- package/src/services/ArchiveBulkService.test.ts +29 -0
- package/src/services/ArchiveService.ts +17 -2
- package/src/services/GraphService.ts +15 -9
- package/src/services/WorktreeService.ts +9 -5
- package/src/work-view.ts +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@markjaquith/agency",
|
|
3
|
-
"version": "2.71.
|
|
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({
|
package/src/commands/epic.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
28
|
-
working:
|
|
29
|
-
delegated:
|
|
30
|
-
done:
|
|
31
|
-
dropped:
|
|
32
|
-
terminal:
|
|
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
|
-
|
|
39
|
+
counts.total === 0
|
|
36
40
|
? "open"
|
|
37
|
-
:
|
|
41
|
+
: counts.done === counts.total
|
|
38
42
|
? "done"
|
|
39
|
-
:
|
|
43
|
+
: counts.terminal === counts.total
|
|
40
44
|
? "dropped"
|
|
41
|
-
:
|
|
45
|
+
: counts.working > 0
|
|
42
46
|
? "working"
|
|
43
|
-
:
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
|
317
|
+
const taskStatusCache = new Map<string, WorkStatus>()
|
|
312
318
|
const taskStatus = (taskId: string) => {
|
|
313
|
-
const cached =
|
|
319
|
+
const cached = taskStatusCache.get(taskId)
|
|
314
320
|
if (cached) return cached
|
|
315
321
|
const status = aggregateProgress(taskLeafStatuses(taskId)).status
|
|
316
|
-
|
|
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 =
|
|
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
|
|