@markjaquith/agency 2.37.1 → 2.38.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "2.37.1",
3
+ "version": "2.38.1",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -26,6 +26,7 @@ export const status = (options: StatusOptions = {}) =>
26
26
  const executionRows = report.valid
27
27
  ? (yield* getWorkViews({
28
28
  cwd,
29
+ validation: report,
29
30
  statuses: options.statuses,
30
31
  repositories: options.repositories,
31
32
  ready: options.ready,
@@ -264,47 +264,90 @@ export class ContextService extends Effect.Service<ContextService>()(
264
264
  const entries = (yield* fs.readDirectory(taskRoot))
265
265
  .filter((entry) => entry.isDirectory)
266
266
  .sort((a, b) => a.name.localeCompare(b.name))
267
- for (const entry of entries) {
268
- const path = join(taskRoot, entry.name, "TASK.md")
269
- if (!(yield* fs.exists(path))) continue
270
- const content = yield* fs.readFile(path)
271
- const parsed = yield* Effect.either(
272
- parseFrontmatter(content, path),
273
- )
274
- if (Either.isLeft(parsed)) continue
275
- const decoded = decode(TaskFrontmatter, parsed.right.data)
276
- if (!decoded.ok) continue
277
- taskDocuments.set(entry.name, {
278
- id: entry.name,
279
- path,
280
- sha256: documentRevision(content),
281
- data: decoded.value,
282
- body: parsed.right.body,
283
- })
284
- const phasesPath = join(taskRoot, entry.name, "phases")
285
- if (!(yield* fs.isDirectory(phasesPath))) continue
286
- for (const phaseEntry of (yield* fs.readDirectory(phasesPath))
287
- .filter((item) => item.isDirectory)
288
- .sort((a, b) => a.name.localeCompare(b.name))) {
289
- const phasePath = join(phasesPath, phaseEntry.name, "PHASE.md")
290
- if (!(yield* fs.exists(phasePath))) continue
291
- const phaseContent = yield* fs.readFile(phasePath)
292
- const phaseParsed = yield* Effect.either(
293
- parseFrontmatter(phaseContent, phasePath),
294
- )
295
- if (Either.isLeft(phaseParsed)) continue
296
- const phaseDecoded = decode(
297
- PhaseFrontmatter,
298
- phaseParsed.right.data,
267
+ const documents = yield* Effect.all(
268
+ entries.map((entry) =>
269
+ Effect.gen(function* () {
270
+ const path = join(taskRoot, entry.name, "TASK.md")
271
+ let taskDocument: Document<TaskData> | null = null
272
+ if (yield* fs.exists(path)) {
273
+ const content = yield* fs.readFile(path)
274
+ const parsed = yield* Effect.either(
275
+ parseFrontmatter(content, path),
276
+ )
277
+ if (Either.isRight(parsed)) {
278
+ const decoded = decode(TaskFrontmatter, parsed.right.data)
279
+ if (decoded.ok) {
280
+ taskDocument = {
281
+ id: entry.name,
282
+ path,
283
+ sha256: documentRevision(content),
284
+ data: decoded.value,
285
+ body: parsed.right.body,
286
+ }
287
+ }
288
+ }
289
+ }
290
+
291
+ const phasesPath = join(taskRoot, entry.name, "phases")
292
+ if (!(yield* fs.isDirectory(phasesPath))) {
293
+ return { taskDocument, phaseDocuments: [] }
294
+ }
295
+ const phaseEntries = (yield* fs.readDirectory(phasesPath))
296
+ .filter((item) => item.isDirectory)
297
+ .sort((a, b) => a.name.localeCompare(b.name))
298
+ const childDocuments = yield* Effect.all(
299
+ phaseEntries.map((phaseEntry) =>
300
+ Effect.gen(function* () {
301
+ const phasePath = join(
302
+ phasesPath,
303
+ phaseEntry.name,
304
+ "PHASE.md",
305
+ )
306
+ if (!(yield* fs.exists(phasePath))) return null
307
+ const content = yield* fs.readFile(phasePath)
308
+ const parsed = yield* Effect.either(
309
+ parseFrontmatter(content, phasePath),
310
+ )
311
+ if (Either.isLeft(parsed)) return null
312
+ const decoded = decode(
313
+ PhaseFrontmatter,
314
+ parsed.right.data,
315
+ )
316
+ if (!decoded.ok) return null
317
+ return [
318
+ `${entry.name}/${phaseEntry.name}`,
319
+ {
320
+ id: phaseEntry.name,
321
+ path: phasePath,
322
+ sha256: documentRevision(content),
323
+ data: decoded.value,
324
+ body: parsed.right.body,
325
+ },
326
+ ] as const
327
+ }),
328
+ ),
329
+ { concurrency: "unbounded" },
330
+ )
331
+ return {
332
+ taskDocument,
333
+ phaseDocuments: childDocuments.filter(
334
+ (document): document is NonNullable<typeof document> =>
335
+ document !== null,
336
+ ),
337
+ }
338
+ }),
339
+ ),
340
+ { concurrency: "unbounded" },
341
+ )
342
+ for (const document of documents) {
343
+ if (document.taskDocument) {
344
+ taskDocuments.set(
345
+ document.taskDocument.id,
346
+ document.taskDocument,
299
347
  )
300
- if (!phaseDecoded.ok) continue
301
- phaseDocuments.set(`${entry.name}/${phaseEntry.name}`, {
302
- id: phaseEntry.name,
303
- path: phasePath,
304
- sha256: documentRevision(phaseContent),
305
- data: phaseDecoded.value,
306
- body: phaseParsed.right.body,
307
- })
348
+ }
349
+ for (const [key, phaseDocument] of document.phaseDocuments) {
350
+ phaseDocuments.set(key, phaseDocument)
308
351
  }
309
352
  }
310
353
  }
@@ -33,7 +33,7 @@ import {
33
33
  type WorkStatus,
34
34
  } from "../workbase/schemas"
35
35
  import { FileSystemService } from "./FileSystemService"
36
- import { WorkbaseService } from "./WorkbaseService"
36
+ import { WorkbaseService, type ValidationReport } from "./WorkbaseService"
37
37
 
38
38
  class GraphError extends Data.TaggedError("GraphError")<{
39
39
  readonly message: string
@@ -58,6 +58,7 @@ type ExecutionData = PhaseData | Extract<TaskData, { readonly repo: string }>
58
58
 
59
59
  export interface GraphOptions {
60
60
  readonly cwd?: string
61
+ readonly validation?: ValidationReport
61
62
  readonly ready?: boolean
62
63
  readonly blocked?: boolean
63
64
  readonly statuses?: readonly WorkStatus[]
@@ -166,34 +167,64 @@ export class GraphService extends Effect.Service<GraphService>()(
166
167
  } satisfies Document<Schema.Schema.Type<S>>
167
168
  })
168
169
 
169
- for (const id of yield* directories(join(root, "epics"))) {
170
- const path = join(root, "epics", id, "EPIC.md")
171
- if (yield* fs.exists(path)) {
172
- epics.set(id, yield* readDocument(id, path, EpicFrontmatter))
173
- }
170
+ const epicIds = yield* directories(join(root, "epics"))
171
+ const epicDocuments = yield* Effect.all(
172
+ epicIds.map((id) =>
173
+ Effect.gen(function* () {
174
+ const path = join(root, "epics", id, "EPIC.md")
175
+ return (yield* fs.exists(path))
176
+ ? yield* readDocument(id, path, EpicFrontmatter)
177
+ : null
178
+ }),
179
+ ),
180
+ { concurrency: "unbounded" },
181
+ )
182
+ for (const document of epicDocuments) {
183
+ if (document) epics.set(document.id, document)
174
184
  }
175
- for (const id of yield* directories(join(root, "tasks"))) {
176
- const path = join(root, "tasks", id, "TASK.md")
177
- if (yield* fs.exists(path)) {
178
- tasks.set(id, yield* readDocument(id, path, TaskFrontmatter))
179
- }
180
- for (const phaseId of yield* directories(
181
- join(root, "tasks", id, "phases"),
182
- )) {
183
- const phasePath = join(
184
- root,
185
- "tasks",
186
- id,
187
- "phases",
188
- phaseId,
189
- "PHASE.md",
190
- )
191
- if (yield* fs.exists(phasePath)) {
192
- phases.set(
193
- `${id}/${phaseId}`,
194
- yield* readDocument(phaseId, phasePath, PhaseFrontmatter),
185
+
186
+ const taskIds = yield* directories(join(root, "tasks"))
187
+ const taskDocuments = yield* Effect.all(
188
+ taskIds.map((id) =>
189
+ Effect.gen(function* () {
190
+ const path = join(root, "tasks", id, "TASK.md")
191
+ const task = (yield* fs.exists(path))
192
+ ? yield* readDocument(id, path, TaskFrontmatter)
193
+ : null
194
+ const phaseIds = yield* directories(
195
+ join(root, "tasks", id, "phases"),
195
196
  )
196
- }
197
+ const taskPhases = yield* Effect.all(
198
+ phaseIds.map((phaseId) =>
199
+ Effect.gen(function* () {
200
+ const phasePath = join(
201
+ root,
202
+ "tasks",
203
+ id,
204
+ "phases",
205
+ phaseId,
206
+ "PHASE.md",
207
+ )
208
+ return (yield* fs.exists(phasePath))
209
+ ? yield* readDocument(
210
+ phaseId,
211
+ phasePath,
212
+ PhaseFrontmatter,
213
+ )
214
+ : null
215
+ }),
216
+ ),
217
+ { concurrency: "unbounded" },
218
+ )
219
+ return { id, task, phases: taskPhases }
220
+ }),
221
+ ),
222
+ { concurrency: "unbounded" },
223
+ )
224
+ for (const documents of taskDocuments) {
225
+ if (documents.task) tasks.set(documents.id, documents.task)
226
+ for (const phase of documents.phases) {
227
+ if (phase) phases.set(`${documents.id}/${phase.id}`, phase)
197
228
  }
198
229
  }
199
230
 
@@ -262,7 +293,8 @@ export class GraphService extends Effect.Service<GraphService>()(
262
293
  ]
263
294
  })
264
295
 
265
- const validation = yield* workbase.validate(root)
296
+ const validation =
297
+ options.validation ?? (yield* workbase.validate(root))
266
298
  const validationBlockers = (
267
299
  paths: readonly string[],
268
300
  ): GraphBlocker[] =>
@@ -45,7 +45,7 @@ interface ValidationIssue {
45
45
  readonly message: string
46
46
  }
47
47
 
48
- interface ValidationReport {
48
+ export interface ValidationReport {
49
49
  readonly root: string
50
50
  readonly issues: readonly ValidationIssue[]
51
51
  readonly epicCount: number
@@ -634,34 +634,64 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
634
634
  return decoded.value
635
635
  })
636
636
 
637
- for (const id of yield* readDirectories(join(root, "epics"))) {
638
- const path = join(root, "epics", id, "EPIC.md")
639
- const data = yield* readDocument(path, EpicFrontmatter)
640
- if (data) {
641
- epics.set(id, { id, path, data })
642
- }
637
+ const epicIds = yield* readDirectories(join(root, "epics"))
638
+ const epicDocuments = yield* Effect.all(
639
+ epicIds.map((id) =>
640
+ Effect.gen(function* () {
641
+ const path = join(root, "epics", id, "EPIC.md")
642
+ const data = yield* readDocument(path, EpicFrontmatter)
643
+ return data ? { id, path, data } : null
644
+ }),
645
+ ),
646
+ { concurrency: "unbounded" },
647
+ )
648
+ for (const document of epicDocuments) {
649
+ if (document) epics.set(document.id, document)
643
650
  }
644
651
 
645
- for (const id of yield* readDirectories(join(root, "tasks"))) {
646
- const taskPath = join(root, "tasks", id)
647
- const path = join(taskPath, "TASK.md")
648
- const data = yield* readDocument(path, TaskFrontmatter)
649
- if (data) {
650
- tasks.set(id, { id, path, data })
651
- }
652
-
653
- for (const phaseId of yield* readDirectories(
654
- join(taskPath, "phases"),
655
- )) {
656
- const phasePath = join(taskPath, "phases", phaseId, "PHASE.md")
657
- const phase = yield* readDocument(phasePath, PhaseFrontmatter)
658
- if (phase) {
659
- phases.set(`${id}/${phaseId}`, {
660
- id: phaseId,
661
- path: phasePath,
662
- data: phase,
663
- })
664
- }
652
+ const taskIds = yield* readDirectories(join(root, "tasks"))
653
+ const taskDocuments = yield* Effect.all(
654
+ taskIds.map((id) =>
655
+ Effect.gen(function* () {
656
+ const taskPath = join(root, "tasks", id)
657
+ const path = join(taskPath, "TASK.md")
658
+ const data = yield* readDocument(path, TaskFrontmatter)
659
+ const phaseIds = yield* readDirectories(
660
+ join(taskPath, "phases"),
661
+ )
662
+ const taskPhases = yield* Effect.all(
663
+ phaseIds.map((phaseId) =>
664
+ Effect.gen(function* () {
665
+ const phasePath = join(
666
+ taskPath,
667
+ "phases",
668
+ phaseId,
669
+ "PHASE.md",
670
+ )
671
+ const phase = yield* readDocument(
672
+ phasePath,
673
+ PhaseFrontmatter,
674
+ )
675
+ return phase
676
+ ? { id: phaseId, path: phasePath, data: phase }
677
+ : null
678
+ }),
679
+ ),
680
+ { concurrency: "unbounded" },
681
+ )
682
+ return {
683
+ id,
684
+ task: data ? { id, path, data } : null,
685
+ phases: taskPhases,
686
+ }
687
+ }),
688
+ ),
689
+ { concurrency: "unbounded" },
690
+ )
691
+ for (const documents of taskDocuments) {
692
+ if (documents.task) tasks.set(documents.id, documents.task)
693
+ for (const phase of documents.phases) {
694
+ if (phase) phases.set(`${documents.id}/${phase.id}`, phase)
665
695
  }
666
696
  }
667
697
 
@@ -77,6 +77,53 @@ describe("chooser", () => {
77
77
  })
78
78
  })
79
79
 
80
+ test("passes hierarchy depth only to the native renderer", async () => {
81
+ let offered: readonly {
82
+ readonly key: string
83
+ readonly label: string
84
+ readonly depth?: number
85
+ readonly segments?: readonly {
86
+ readonly text: string
87
+ readonly color?: string
88
+ }[]
89
+ }[] = []
90
+ const io = createIO({
91
+ select: async (_prompt, choices) => {
92
+ offered = choices
93
+ return choices[0]!.key
94
+ },
95
+ })
96
+
97
+ await Effect.runPromise(
98
+ choose(
99
+ "Pick one",
100
+ [
101
+ {
102
+ key: "parent",
103
+ label: "Parent",
104
+ depth: 0,
105
+ segments: [{ text: "P", color: "#c6a0f6" }],
106
+ value: 1,
107
+ },
108
+ { key: "child", label: "Child", depth: 1, value: 2 },
109
+ ],
110
+ undefined,
111
+ io,
112
+ ),
113
+ )
114
+
115
+ expect(offered).toEqual([
116
+ {
117
+ key: "parent",
118
+ label: "Parent",
119
+ depth: 0,
120
+ segments: [{ text: "P", color: "#c6a0f6" }],
121
+ },
122
+ { key: "child", label: "Child", depth: 1 },
123
+ ])
124
+ expect(io.inputs).toEqual([])
125
+ })
126
+
80
127
  test("preserves colors for configured external choosers", async () => {
81
128
  const io = createIO({ color: true })
82
129
 
@@ -4,9 +4,16 @@ export interface Choice<T> {
4
4
  readonly key: string
5
5
  readonly label: string
6
6
  readonly plainLabel?: string
7
+ readonly depth?: number
8
+ readonly segments?: readonly ChoiceSegment[]
7
9
  readonly value: T
8
10
  }
9
11
 
12
+ export interface ChoiceSegment {
13
+ readonly text: string
14
+ readonly color?: string
15
+ }
16
+
10
17
  export type ChooserErrorReason =
11
18
  | "invalid-choices"
12
19
  | "input-unavailable"
@@ -36,7 +43,12 @@ export interface ChooserIO {
36
43
  readonly color: boolean
37
44
  readonly select: (
38
45
  prompt: string,
39
- choices: readonly { readonly key: string; readonly label: string }[],
46
+ choices: readonly {
47
+ readonly key: string
48
+ readonly label: string
49
+ readonly depth?: number
50
+ readonly segments?: readonly ChoiceSegment[]
51
+ }[],
40
52
  ) => Promise<string | null>
41
53
  readonly run: (
42
54
  command: readonly string[],
@@ -175,6 +187,8 @@ const nativeChoice = async <T>(
175
187
  choices.map((choice) => ({
176
188
  key: choice.key,
177
189
  label: displayLabel(choice, false),
190
+ ...(choice.depth === undefined ? {} : { depth: choice.depth }),
191
+ ...(choice.segments === undefined ? {} : { segments: choice.segments }),
178
192
  })),
179
193
  )
180
194
  } catch (cause) {