@markjaquith/agency 3.2.0 → 3.2.1
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 +1 -1
- package/src/commands/context.test.ts +56 -0
- package/src/services/ContextService.ts +157 -200
package/package.json
CHANGED
|
@@ -282,6 +282,20 @@ status: dropped
|
|
|
282
282
|
})
|
|
283
283
|
|
|
284
284
|
test("reads each workbase document at most once per invocation", async () => {
|
|
285
|
+
const unrelatedArchive = join(root, "archive/tasks/unrelated/TASK.md")
|
|
286
|
+
await write(
|
|
287
|
+
root,
|
|
288
|
+
"archive/tasks/unrelated/TASK.md",
|
|
289
|
+
`---
|
|
290
|
+
ticketUrl: null
|
|
291
|
+
repo: agency
|
|
292
|
+
branch: unrelated
|
|
293
|
+
base: main
|
|
294
|
+
pr: null
|
|
295
|
+
status: done
|
|
296
|
+
---
|
|
297
|
+
`,
|
|
298
|
+
)
|
|
285
299
|
const reads = new Map<string, number>()
|
|
286
300
|
const originalFile = Bun.file
|
|
287
301
|
Bun.file = mock((path: string) => {
|
|
@@ -304,6 +318,48 @@ status: dropped
|
|
|
304
318
|
)
|
|
305
319
|
expect(documents.length).toBeGreaterThan(0)
|
|
306
320
|
expect(documents.every(([, count]) => count === 1)).toBe(true)
|
|
321
|
+
expect(reads.has(unrelatedArchive)).toBe(false)
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
test("limits repository inspection and overlaps independent Git observations", async () => {
|
|
325
|
+
const commands: string[][] = []
|
|
326
|
+
let activeGitCommands = 0
|
|
327
|
+
let maxActiveGitCommands = 0
|
|
328
|
+
const originalSpawn = Bun.spawn
|
|
329
|
+
;(Bun as { spawn: unknown }).spawn = mock(((
|
|
330
|
+
args: string[],
|
|
331
|
+
options: Parameters<typeof Bun.spawn>[1],
|
|
332
|
+
) => {
|
|
333
|
+
const command = [...args]
|
|
334
|
+
const child = originalSpawn(command, options)
|
|
335
|
+
if (command[0] !== "git") return child
|
|
336
|
+
commands.push(command)
|
|
337
|
+
activeGitCommands += 1
|
|
338
|
+
maxActiveGitCommands = Math.max(maxActiveGitCommands, activeGitCommands)
|
|
339
|
+
const exited = child.exited.finally(() => {
|
|
340
|
+
activeGitCommands -= 1
|
|
341
|
+
})
|
|
342
|
+
return new Proxy(child, {
|
|
343
|
+
get(target, property) {
|
|
344
|
+
return property === "exited"
|
|
345
|
+
? exited
|
|
346
|
+
: Reflect.get(target, property, target)
|
|
347
|
+
},
|
|
348
|
+
})
|
|
349
|
+
}) as unknown as typeof Bun.spawn)
|
|
350
|
+
try {
|
|
351
|
+
await readContext(root, "tasks/agent-contract/phases/context-command")
|
|
352
|
+
} finally {
|
|
353
|
+
;(Bun as { spawn: unknown }).spawn = originalSpawn
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
expect(
|
|
357
|
+
commands.some(
|
|
358
|
+
(command) =>
|
|
359
|
+
command.includes("config") && command.includes("--show-origin"),
|
|
360
|
+
),
|
|
361
|
+
).toBe(false)
|
|
362
|
+
expect(maxActiveGitCommands).toBeGreaterThanOrEqual(3)
|
|
307
363
|
})
|
|
308
364
|
|
|
309
365
|
test("resolves a bare task ID and returns root discovery context", async () => {
|
|
@@ -271,78 +271,57 @@ export class ContextService extends Effect.Service<ContextService>()(
|
|
|
271
271
|
|
|
272
272
|
if (relative(root, candidate) === "") {
|
|
273
273
|
const compact = !options.full
|
|
274
|
-
const
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
274
|
+
const validation = yield* provideContextServices(
|
|
275
|
+
workbase.validate(root, { includeDocuments: true }),
|
|
276
|
+
)
|
|
277
|
+
const projectDiscoveryDocument = <T>(
|
|
278
|
+
document: {
|
|
279
|
+
readonly id: string
|
|
280
|
+
readonly path: string
|
|
281
|
+
readonly content: string
|
|
282
|
+
readonly revision: string
|
|
283
|
+
readonly data: T
|
|
284
|
+
},
|
|
278
285
|
extra: Record<string, string> = {},
|
|
279
286
|
) =>
|
|
280
287
|
Effect.gen(function* () {
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
288
|
+
const body = compact
|
|
289
|
+
? {}
|
|
290
|
+
: {
|
|
291
|
+
body: (yield* parseFrontmatter(
|
|
292
|
+
document.content,
|
|
293
|
+
document.path,
|
|
294
|
+
)).body,
|
|
295
|
+
}
|
|
289
296
|
return {
|
|
290
297
|
...extra,
|
|
291
|
-
id,
|
|
292
|
-
path,
|
|
293
|
-
sha256:
|
|
294
|
-
data:
|
|
295
|
-
...
|
|
298
|
+
id: document.id,
|
|
299
|
+
path: document.path,
|
|
300
|
+
sha256: document.revision,
|
|
301
|
+
data: document.data,
|
|
302
|
+
...body,
|
|
296
303
|
}
|
|
297
304
|
})
|
|
298
|
-
|
|
299
|
-
const epics
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
for (const entry of (yield* fs.readDirectory(taskRoot))
|
|
319
|
-
.filter((item) => item.isDirectory)
|
|
320
|
-
.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
321
|
-
const document = yield* discover(
|
|
322
|
-
entry.name,
|
|
323
|
-
join(taskRoot, entry.name, "TASK.md"),
|
|
324
|
-
TaskFrontmatter,
|
|
325
|
-
)
|
|
326
|
-
if (document) tasks.push(document)
|
|
327
|
-
|
|
328
|
-
const phaseRoot = join(taskRoot, entry.name, "phases")
|
|
329
|
-
if (!(yield* fs.isDirectory(phaseRoot))) continue
|
|
330
|
-
for (const phaseEntry of (yield* fs.readDirectory(phaseRoot))
|
|
331
|
-
.filter((item) => item.isDirectory)
|
|
332
|
-
.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
333
|
-
const phase = yield* discover(
|
|
334
|
-
phaseEntry.name,
|
|
335
|
-
join(phaseRoot, phaseEntry.name, "PHASE.md"),
|
|
336
|
-
PhaseFrontmatter,
|
|
337
|
-
{ taskId: entry.name },
|
|
338
|
-
)
|
|
339
|
-
if (phase) phases.push(phase)
|
|
340
|
-
}
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
const validation = yield* provideContextServices(
|
|
345
|
-
workbase.validate(root),
|
|
305
|
+
const documents = validation.documents!
|
|
306
|
+
const epics = yield* Effect.all(
|
|
307
|
+
documents.epics.map((document) =>
|
|
308
|
+
projectDiscoveryDocument(document),
|
|
309
|
+
),
|
|
310
|
+
{ concurrency: "unbounded" },
|
|
311
|
+
)
|
|
312
|
+
const tasks = yield* Effect.all(
|
|
313
|
+
documents.tasks.map((document) =>
|
|
314
|
+
projectDiscoveryDocument(document),
|
|
315
|
+
),
|
|
316
|
+
{ concurrency: "unbounded" },
|
|
317
|
+
)
|
|
318
|
+
const phases = yield* Effect.all(
|
|
319
|
+
[...documents.phasesByTask].flatMap(([taskId, records]) =>
|
|
320
|
+
records.map((document) =>
|
|
321
|
+
projectDiscoveryDocument(document, { taskId }),
|
|
322
|
+
),
|
|
323
|
+
),
|
|
324
|
+
{ concurrency: "unbounded" },
|
|
346
325
|
)
|
|
347
326
|
return {
|
|
348
327
|
projection: compact ? "compact" : "complete",
|
|
@@ -556,104 +535,74 @@ export class ContextService extends Effect.Service<ContextService>()(
|
|
|
556
535
|
})
|
|
557
536
|
}
|
|
558
537
|
|
|
559
|
-
const
|
|
538
|
+
const validation = yield* provideContextServices(
|
|
539
|
+
workbase.validate(root, { includeDocuments: true }),
|
|
540
|
+
)
|
|
541
|
+
const validationDocuments = validation.documents!
|
|
542
|
+
const taskDocuments = new Map<string, Document<TaskData>>(
|
|
543
|
+
validationDocuments.tasks.map((document) => [
|
|
544
|
+
document.id,
|
|
545
|
+
{
|
|
546
|
+
id: document.id,
|
|
547
|
+
path: document.path,
|
|
548
|
+
sha256: document.revision,
|
|
549
|
+
data: document.data,
|
|
550
|
+
body: "",
|
|
551
|
+
},
|
|
552
|
+
]),
|
|
553
|
+
)
|
|
560
554
|
const phaseDocuments = new Map<string, Document<PhaseData>>()
|
|
561
|
-
for (const
|
|
562
|
-
join(root, "tasks"),
|
|
563
|
-
join(root, "archive", "tasks"),
|
|
564
|
-
]) {
|
|
565
|
-
if (!(yield* fs.isDirectory(taskRoot))) continue
|
|
566
|
-
const entries = (yield* fs.readDirectory(taskRoot))
|
|
567
|
-
.filter((entry) => entry.isDirectory)
|
|
568
|
-
.sort((a, b) => a.name.localeCompare(b.name))
|
|
569
|
-
const documents = yield* Effect.all(
|
|
570
|
-
entries.map((entry) =>
|
|
571
|
-
Effect.gen(function* () {
|
|
572
|
-
const path = join(taskRoot, entry.name, "TASK.md")
|
|
573
|
-
let taskDocument: Document<TaskData> | null = null
|
|
574
|
-
if (yield* fs.exists(path)) {
|
|
575
|
-
const content = yield* fs.readFile(path)
|
|
576
|
-
const parsed = yield* Effect.either(
|
|
577
|
-
parseFrontmatter(content, path),
|
|
578
|
-
)
|
|
579
|
-
if (Either.isRight(parsed)) {
|
|
580
|
-
const decoded = decode(TaskFrontmatter, parsed.right.data)
|
|
581
|
-
if (decoded.ok) {
|
|
582
|
-
taskDocument = {
|
|
583
|
-
id: entry.name,
|
|
584
|
-
path,
|
|
585
|
-
sha256: documentRevision(content),
|
|
586
|
-
data: decoded.value,
|
|
587
|
-
body: parsed.right.body,
|
|
588
|
-
}
|
|
589
|
-
}
|
|
590
|
-
}
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
const phasesPath = join(taskRoot, entry.name, "phases")
|
|
594
|
-
if (!(yield* fs.isDirectory(phasesPath))) {
|
|
595
|
-
return { taskDocument, phaseDocuments: [] }
|
|
596
|
-
}
|
|
597
|
-
const phaseEntries = (yield* fs.readDirectory(phasesPath))
|
|
598
|
-
.filter((item) => item.isDirectory)
|
|
599
|
-
.sort((a, b) => a.name.localeCompare(b.name))
|
|
600
|
-
const childDocuments = yield* Effect.all(
|
|
601
|
-
phaseEntries.map((phaseEntry) =>
|
|
602
|
-
Effect.gen(function* () {
|
|
603
|
-
const phasePath = join(
|
|
604
|
-
phasesPath,
|
|
605
|
-
phaseEntry.name,
|
|
606
|
-
"PHASE.md",
|
|
607
|
-
)
|
|
608
|
-
if (!(yield* fs.exists(phasePath))) return null
|
|
609
|
-
const content = yield* fs.readFile(phasePath)
|
|
610
|
-
const parsed = yield* Effect.either(
|
|
611
|
-
parseFrontmatter(content, phasePath),
|
|
612
|
-
)
|
|
613
|
-
if (Either.isLeft(parsed)) return null
|
|
614
|
-
const decoded = decode(
|
|
615
|
-
PhaseFrontmatter,
|
|
616
|
-
parsed.right.data,
|
|
617
|
-
)
|
|
618
|
-
if (!decoded.ok) return null
|
|
619
|
-
return [
|
|
620
|
-
`${entry.name}/${phaseEntry.name}`,
|
|
621
|
-
{
|
|
622
|
-
id: phaseEntry.name,
|
|
623
|
-
path: phasePath,
|
|
624
|
-
sha256: documentRevision(content),
|
|
625
|
-
data: decoded.value,
|
|
626
|
-
body: parsed.right.body,
|
|
627
|
-
},
|
|
628
|
-
] as const
|
|
629
|
-
}),
|
|
630
|
-
),
|
|
631
|
-
{ concurrency: "unbounded" },
|
|
632
|
-
)
|
|
633
|
-
return {
|
|
634
|
-
taskDocument,
|
|
635
|
-
phaseDocuments: childDocuments.filter(
|
|
636
|
-
(document): document is NonNullable<typeof document> =>
|
|
637
|
-
document !== null,
|
|
638
|
-
),
|
|
639
|
-
}
|
|
640
|
-
}),
|
|
641
|
-
),
|
|
642
|
-
{ concurrency: "unbounded" },
|
|
643
|
-
)
|
|
555
|
+
for (const [taskId, documents] of validationDocuments.phasesByTask) {
|
|
644
556
|
for (const document of documents) {
|
|
645
|
-
|
|
646
|
-
document.
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
557
|
+
phaseDocuments.set(`${taskId}/${document.id}`, {
|
|
558
|
+
id: document.id,
|
|
559
|
+
path: document.path,
|
|
560
|
+
sha256: document.revision,
|
|
561
|
+
data: document.data,
|
|
562
|
+
body: "",
|
|
563
|
+
})
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
if (task && !taskDocuments.has(task.id))
|
|
567
|
+
taskDocuments.set(task.id, task)
|
|
568
|
+
if (phase && target.taskId) {
|
|
569
|
+
phaseDocuments.set(`${target.taskId}/${phase.id}`, phase)
|
|
570
|
+
}
|
|
571
|
+
const relevantTaskIds = new Set([
|
|
572
|
+
...(target.taskId ? [target.taskId] : []),
|
|
573
|
+
...(epic?.data.tasks.map((child: Dependency) => child.id) ?? []),
|
|
574
|
+
])
|
|
575
|
+
for (const taskId of relevantTaskIds) {
|
|
576
|
+
let document = taskDocuments.get(taskId)
|
|
577
|
+
if (!document) {
|
|
578
|
+
const archived = yield* Effect.either(
|
|
579
|
+
readOptionalDocument(
|
|
580
|
+
taskId,
|
|
581
|
+
join(archivedTaskDirectory(root, taskId), "TASK.md"),
|
|
582
|
+
TaskFrontmatter,
|
|
583
|
+
),
|
|
584
|
+
)
|
|
585
|
+
document = Either.isRight(archived)
|
|
586
|
+
? (archived.right ?? undefined)
|
|
587
|
+
: undefined
|
|
588
|
+
if (document) taskDocuments.set(taskId, document)
|
|
589
|
+
}
|
|
590
|
+
if (!document || !("phases" in document.data)) continue
|
|
591
|
+
for (const child of document.data.phases) {
|
|
592
|
+
const key = `${taskId}/${child.id}`
|
|
593
|
+
if (phaseDocuments.has(key)) continue
|
|
594
|
+
const archived = yield* Effect.either(
|
|
595
|
+
readOptionalDocument(
|
|
596
|
+
child.id,
|
|
597
|
+
join(
|
|
598
|
+
archivedPhaseDirectory(root, taskId, child.id),
|
|
599
|
+
"PHASE.md",
|
|
600
|
+
),
|
|
601
|
+
PhaseFrontmatter,
|
|
602
|
+
),
|
|
603
|
+
)
|
|
604
|
+
if (Either.isRight(archived) && archived.right) {
|
|
605
|
+
phaseDocuments.set(key, archived.right)
|
|
657
606
|
}
|
|
658
607
|
}
|
|
659
608
|
}
|
|
@@ -720,9 +669,6 @@ export class ContextService extends Effect.Service<ContextService>()(
|
|
|
720
669
|
})
|
|
721
670
|
}
|
|
722
671
|
|
|
723
|
-
const validation = yield* provideContextServices(
|
|
724
|
-
workbase.validate(root),
|
|
725
|
-
)
|
|
726
672
|
const relevantPaths = new Set<string>(
|
|
727
673
|
[epic?.path, task?.path, phase?.path]
|
|
728
674
|
.filter((path): path is string => Boolean(path))
|
|
@@ -1017,9 +963,11 @@ export class ContextService extends Effect.Service<ContextService>()(
|
|
|
1017
963
|
const codePath = join(entityDirectory, "code")
|
|
1018
964
|
const inspectionWarnings: string[] = []
|
|
1019
965
|
const repositories = new Map(
|
|
1020
|
-
|
|
1021
|
-
(
|
|
1022
|
-
|
|
966
|
+
reviewData
|
|
967
|
+
? (yield* provideContextServices(
|
|
968
|
+
repositoryService.list(root),
|
|
969
|
+
)).map((repository) => [repository.alias, repository])
|
|
970
|
+
: [],
|
|
1023
971
|
)
|
|
1024
972
|
|
|
1025
973
|
const inspectCheckout = (
|
|
@@ -1027,12 +975,18 @@ export class ContextService extends Effect.Service<ContextService>()(
|
|
|
1027
975
|
checkoutPath: string,
|
|
1028
976
|
) =>
|
|
1029
977
|
Effect.gen(function* (): Generator<any, CheckoutInspection, any> {
|
|
1030
|
-
const materialized = yield*
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
978
|
+
const [materialized, listed, canonicalRoot] = yield* Effect.all(
|
|
979
|
+
[
|
|
980
|
+
fs.isDirectory(checkoutPath),
|
|
981
|
+
runGit(fs, repositoryPath, [
|
|
982
|
+
"worktree",
|
|
983
|
+
"list",
|
|
984
|
+
"--porcelain",
|
|
985
|
+
]),
|
|
986
|
+
fs.realPath(root),
|
|
987
|
+
],
|
|
988
|
+
{ concurrency: "unbounded" },
|
|
989
|
+
)
|
|
1036
990
|
if (listed === null) {
|
|
1037
991
|
inspectionWarnings.push(
|
|
1038
992
|
`Unable to inspect worktree registrations for ${repositoryPath}`,
|
|
@@ -1040,7 +994,7 @@ export class ContextService extends Effect.Service<ContextService>()(
|
|
|
1040
994
|
}
|
|
1041
995
|
const listedPaths = worktreePaths(listed)
|
|
1042
996
|
const canonicalCheckoutPath = join(
|
|
1043
|
-
|
|
997
|
+
canonicalRoot,
|
|
1044
998
|
relative(root, checkoutPath),
|
|
1045
999
|
)
|
|
1046
1000
|
let registered =
|
|
@@ -1056,21 +1010,25 @@ export class ContextService extends Effect.Service<ContextService>()(
|
|
|
1056
1010
|
dirty: null,
|
|
1057
1011
|
}
|
|
1058
1012
|
}
|
|
1059
|
-
const
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1013
|
+
const [
|
|
1014
|
+
checkoutCommit,
|
|
1015
|
+
checkoutBranch,
|
|
1016
|
+
checkoutStatus,
|
|
1017
|
+
resolvedCheckoutPath,
|
|
1018
|
+
] = yield* Effect.all(
|
|
1019
|
+
[
|
|
1020
|
+
runGit(fs, checkoutPath, ["rev-parse", "HEAD"]),
|
|
1021
|
+
runGit(fs, checkoutPath, [
|
|
1022
|
+
"symbolic-ref",
|
|
1023
|
+
"--quiet",
|
|
1024
|
+
"--short",
|
|
1025
|
+
"HEAD",
|
|
1026
|
+
]),
|
|
1027
|
+
runGitText(fs, checkoutPath, ["status", "--porcelain"]),
|
|
1028
|
+
fs.realPath(checkoutPath),
|
|
1029
|
+
],
|
|
1030
|
+
{ concurrency: "unbounded" },
|
|
1031
|
+
)
|
|
1074
1032
|
registered = registered || listedPaths.has(resolvedCheckoutPath)
|
|
1075
1033
|
if (checkoutCommit === null) {
|
|
1076
1034
|
inspectionWarnings.push(
|
|
@@ -1094,17 +1052,16 @@ export class ContextService extends Effect.Service<ContextService>()(
|
|
|
1094
1052
|
const repositoryPath =
|
|
1095
1053
|
repository?.path ?? join(root, "repos", executionData.repo)
|
|
1096
1054
|
const checkoutPath = join(codePath, executionData.repo)
|
|
1097
|
-
const branchCommit = yield*
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
checkoutPath,
|
|
1055
|
+
const [branchCommit, baseCommit, checkout] = yield* Effect.all(
|
|
1056
|
+
[
|
|
1057
|
+
backend.resolveRevision(
|
|
1058
|
+
repositoryPath,
|
|
1059
|
+
executionData.branch,
|
|
1060
|
+
),
|
|
1061
|
+
backend.resolveRevision(repositoryPath, executionData.base),
|
|
1062
|
+
inspectCheckout(repositoryPath, checkoutPath),
|
|
1063
|
+
],
|
|
1064
|
+
{ concurrency: "unbounded" },
|
|
1108
1065
|
)
|
|
1109
1066
|
if (branchCommit === null) {
|
|
1110
1067
|
inspectionWarnings.push(
|