@markjaquith/agency 2.38.0 → 2.39.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/README.md CHANGED
@@ -358,10 +358,16 @@ agency validate
358
358
 
359
359
  ### Target Context
360
360
 
361
- `agency context [target] --json` returns the complete bootstrap context for an
362
- epic, task, or phase without modifying the workbase or fetching repositories.
363
- The target defaults to the current directory; entity directories, document
364
- paths, checkout descendants, and bare task IDs are accepted.
361
+ `agency context [target] --json` returns complete bootstrap context without
362
+ modifying the workbase or fetching repositories. At the workbase root it returns
363
+ a discovery catalog of all epics, tasks, and phases, including frontmatter,
364
+ paths, and document revisions. Elsewhere it returns context for an epic, task,
365
+ or phase. The target defaults to the current directory; entity directories,
366
+ document paths, checkout descendants, and bare task IDs are accepted.
367
+
368
+ Root discovery is compact by default and includes a hint to run `agency context
369
+ . --full --json` when document prose is needed. Entity context remains complete
370
+ by default. `--compact` explicitly requests compact entity context.
365
371
 
366
372
  The result includes workbase and target identity, ancestor frontmatter and prose
367
373
  with SHA-256 hashes, dependency and readiness state, aggregate status, writable
@@ -369,9 +375,10 @@ and reference authority, local checkout and resolved-commit state, recorded PR
369
375
  state, and validation warnings. Only `done` satisfies a dependency; `dropped` is
370
376
  terminal but remains a blocker.
371
377
 
372
- Complete output is the default. Pass `--compact` explicitly to omit document
373
- prose and low-level Git details while retaining identity, hashes, authority,
374
- paths, graph state, materialization state, and validation warnings.
378
+ Complete output is the default for entity targets. Pass `--compact` explicitly
379
+ to omit document prose and low-level Git details while retaining identity,
380
+ hashes, authority, paths, graph state, materialization state, and validation
381
+ warnings.
375
382
 
376
383
  ### Workbase Graph
377
384
 
package/cli.ts CHANGED
@@ -601,6 +601,7 @@ const commands: Record<string, Command> = {
601
601
  ? join("tasks", options.task)
602
602
  : args[0],
603
603
  compact: options.compact,
604
+ full: options.full,
604
605
  json: options.json,
605
606
  silent: options.silent,
606
607
  verbose: options.verbose,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "2.38.0",
3
+ "version": "2.39.0",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -8,7 +8,7 @@ Commands that return Agency-owned data accept `--json` unless noted otherwise.
8
8
  ## Discovery And Health
9
9
 
10
10
  ```text
11
- agency context [target] [--json] [--compact]
11
+ agency context [target] [--json] [--compact | --full]
12
12
  agency graph [--json | --jsonl] [--ready | --blocked]
13
13
  [--status <status>...] [--repository <alias>...] [--kind <kind>...]
14
14
  [--include <bodies|workspace|git|pr>...]
@@ -21,11 +21,13 @@ agency integration status [--json]
21
21
  agency integration sync [--json]
22
22
  ```
23
23
 
24
- `context` defaults to cwd and accepts entity directories, document paths,
25
- checkout descendants, or a task ID. Complete output includes prose and Git
26
- details; `--compact` intentionally omits them. `graph` computes readiness before
27
- applying filters. `doctor` discovers required tools, integrations, repositories,
28
- refs, worktrees, permissions, drift, and optional runner capabilities. `sync` is
24
+ `context` defaults to cwd and accepts the workbase root, entity directories,
25
+ document paths, checkout descendants, or a task ID. Root context catalogs all
26
+ epics, tasks, and phases in compact form and hints that `--full` includes prose.
27
+ Entity context is complete by default; `--compact` intentionally omits prose and
28
+ low-level Git details. `graph` computes readiness before applying filters.
29
+ `doctor` discovers required tools, integrations, repositories, refs, worktrees,
30
+ permissions, drift, and optional runner capabilities. `sync` is
29
31
  observational unless `--apply` is explicit. Integration status includes safe
30
32
  remediation when customized OpenCode config prevents Agency from guaranteeing
31
33
  whole-workbase access.
@@ -8,9 +8,11 @@ names; they are not duplicated in frontmatter.
8
8
  `agency context . --json` returns a versioned success envelope whose result
9
9
  contains:
10
10
 
11
- - `projection`: `complete` or explicitly requested `compact`;
11
+ - `projection`: `compact` for root discovery by default, otherwise `complete`
12
+ unless compact entity context was explicitly requested;
12
13
  - `workbase`: root, config path, and config version;
13
- - `target`: resolved epic, task, or phase identity;
14
+ - `target`: resolved workbase, epic, task, or phase identity;
15
+ - `discovery`: at the workbase root, all valid epic, task, and phase documents;
14
16
  - `documents`: ancestor frontmatter, paths, SHA-256 revisions, and prose;
15
17
  - `graph`: parent, dependencies, dependents, readiness blockers, and progress;
16
18
  - `authority`: `orchestration` or `execution`, one writable checkout or none,
@@ -283,9 +285,11 @@ selection. Supply every required value or explicit entity selector. Global
283
285
  `--cwd` and `--workbase` selectors apply to `next` as they do to other discovery
284
286
  commands.
285
287
 
286
- Context defaults to the `complete` projection. `--compact` omits prose and
287
- low-level Git details but retains identity, document hashes, authority, paths,
288
- graph state, materialization, and validation warnings.
288
+ At a workbase root, context defaults to a compact discovery catalog of epics,
289
+ tasks, and phases, includes no writable authority, and provides a hint for
290
+ requesting `--full`. Entity context defaults to the `complete` projection.
291
+ `--compact` omits prose and low-level Git details but retains identity, document
292
+ hashes, authority, paths, graph state, materialization, and validation warnings.
289
293
 
290
294
  Graph projections are opt-in with repeatable
291
295
  `--include <bodies|workspace|git|pr>`. Filters such as `--ready`, `--blocked`,
@@ -403,6 +403,13 @@ describe("strict CLI parsing", () => {
403
403
  values: { json: true, compact: true },
404
404
  })
405
405
  expectUsageError(["status", "--compact"], "agency status")
406
+ expect(parseCli(["context", ".", "--json", "--full"])).toMatchObject({
407
+ commandName: "context",
408
+ args: ["."],
409
+ values: { json: true, full: true },
410
+ })
411
+ expectUsageError(["context", ".", "--compact", "--full"], "agency context")
412
+ expectUsageError(["status", "--full"], "agency status")
406
413
  })
407
414
 
408
415
  test("accepts work preparation options only for the prepare subcommand", () => {
package/src/cli-parser.ts CHANGED
@@ -891,17 +891,19 @@ const commands = {
891
891
  },
892
892
  },
893
893
  context: {
894
- usage: "agency context [target] [--json] [--compact]",
894
+ usage: "agency context [target] [--json] [--compact | --full]",
895
895
  options: {
896
896
  ...outputOptions,
897
897
  ...entitySelectorOptions,
898
898
  compact: { type: "boolean" },
899
+ full: { type: "boolean" },
899
900
  },
900
901
  command: {
901
- usage: "agency context [target] [--json] [--compact]",
902
+ usage: "agency context [target] [--json] [--compact | --full]",
902
903
  minArgs: 0,
903
904
  maxArgs: 1,
904
- options: ["json", "compact", "epic", "task", "phase"],
905
+ options: ["json", "compact", "full", "epic", "task", "phase"],
906
+ conflicts: [["compact", "full"]],
905
907
  },
906
908
  },
907
909
  graph: {
@@ -28,9 +28,10 @@ const readContext = async (
28
28
  root: string,
29
29
  target: string | undefined,
30
30
  compact = false,
31
+ full = false,
31
32
  ) => {
32
33
  const logs = await captureLogs(() =>
33
- runTestEffect(context({ cwd: root, target, compact, json: true })),
34
+ runTestEffect(context({ cwd: root, target, compact, full, json: true })),
34
35
  )
35
36
  expect(logs).toHaveLength(1)
36
37
  return JSON.parse(logs[0]!)
@@ -267,7 +268,7 @@ status: dropped
267
268
  })
268
269
  })
269
270
 
270
- test("resolves a bare task ID and rejects a root target", async () => {
271
+ test("resolves a bare task ID and returns root discovery context", async () => {
271
272
  const task = await readContext(root, "agent-contract")
272
273
  expect(task.target).toMatchObject({
273
274
  kind: "task",
@@ -275,9 +276,43 @@ status: dropped
275
276
  })
276
277
  expect(task.graph.parent).toEqual({ kind: "epic", id: "contract" })
277
278
 
278
- await expect(readContext(root, ".")).rejects.toThrow(
279
- "Cannot infer an Agency target",
280
- )
279
+ const workbase = await readContext(root, ".")
280
+ expect(workbase).toMatchObject({
281
+ projection: "compact",
282
+ workbase: { root, version: 2 },
283
+ target: { kind: "workbase", path: root },
284
+ hint: "Run agency context . --full --json to include document prose.",
285
+ authority: {
286
+ mode: "orchestration",
287
+ writable: null,
288
+ references: [],
289
+ },
290
+ validation: { valid: true, warnings: [] },
291
+ })
292
+ expect(
293
+ workbase.discovery.epics.map((item: { id: string }) => item.id),
294
+ ).toEqual(["contract"])
295
+ expect(
296
+ workbase.discovery.tasks.map((item: { id: string }) => item.id),
297
+ ).toEqual(["agent-contract", "foundations"])
298
+ expect(
299
+ workbase.discovery.phases.map(
300
+ (item: { taskId: string; id: string }) => `${item.taskId}/${item.id}`,
301
+ ),
302
+ ).toEqual(["agent-contract/context-command", "agent-contract/schema"])
303
+ expect(workbase.discovery.tasks[0].body).toBeUndefined()
304
+ expect(workbase.discovery.phases[0].data.status).toBe("open")
305
+ expect(workbase.discovery.phases[0].sha256).toMatch(/^[a-f0-9]{64}$/)
306
+ })
307
+
308
+ test("includes discovery prose only in full root context", async () => {
309
+ const workbase = await readContext(root, ".", false, true)
310
+ expect(workbase.projection).toBe("complete")
311
+ expect(workbase.hint).toBeNull()
312
+ expect(workbase.discovery.epics[0].body).toContain("# Contract")
313
+ expect(workbase.discovery.tasks[0].body).toContain("Task prose.")
314
+ expect(workbase.discovery.tasks[0].data.phases).toBeDefined()
315
+ expect(workbase.discovery.phases[0].taskId).toBe("agent-contract")
281
316
  })
282
317
 
283
318
  test("resolves bare task IDs from inside another target", async () => {
@@ -6,6 +6,7 @@ import { createLoggers } from "../utils/effect"
6
6
  interface ContextOptions extends BaseCommandOptions {
7
7
  readonly target?: string
8
8
  readonly compact?: boolean
9
+ readonly full?: boolean
9
10
  readonly json?: boolean
10
11
  }
11
12
 
@@ -20,12 +21,14 @@ export const context = (options: ContextOptions = {}) =>
20
21
  export const help = `
21
22
  Usage: agency context [target] [options]
22
23
 
23
- Return the complete, read-only context for an epic, task, or phase. The target
24
- defaults to the current directory and may be an entity path or task ID.
24
+ Return complete, read-only context for a workbase, epic, task, or phase. The
25
+ target defaults to the current directory and may be the workbase root, an entity
26
+ path, or a task ID. Workbase context catalogs all epics, tasks, and phases.
25
27
 
26
28
  Options:
27
29
  --json Output a versioned machine result
28
30
  --compact Omit prose bodies and low-level Git details
31
+ --full Include prose bodies in root workbase context
29
32
  --epic <id> Select an epic
30
33
  --task <id> Select a task
31
34
  --phase <id> Select a phase together with --task
@@ -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,
@@ -108,6 +108,7 @@ export class ContextService extends Effect.Service<ContextService>()(
108
108
  readonly target?: string
109
109
  readonly cwd?: string
110
110
  readonly compact?: boolean
111
+ readonly full?: boolean
111
112
  }) =>
112
113
  Effect.gen(function* () {
113
114
  const fs = yield* FileSystemService
@@ -121,6 +122,103 @@ export class ContextService extends Effect.Service<ContextService>()(
121
122
  candidateExists ? candidate : cwd,
122
123
  )
123
124
 
125
+ if (relative(root, candidate) === "") {
126
+ const compact = !options.full
127
+ const discover = <S extends Schema.Schema.AnyNoContext>(
128
+ id: string,
129
+ path: string,
130
+ schema: S,
131
+ extra: Record<string, string> = {},
132
+ ) =>
133
+ Effect.gen(function* () {
134
+ if (!(yield* fs.exists(path))) return null
135
+ const content = yield* fs.readFile(path)
136
+ const parsed = yield* Effect.either(
137
+ parseFrontmatter(content, path),
138
+ )
139
+ if (Either.isLeft(parsed)) return null
140
+ const decoded = decode(schema, parsed.right.data)
141
+ if (!decoded.ok) return null
142
+ return {
143
+ ...extra,
144
+ id,
145
+ path,
146
+ sha256: documentRevision(content),
147
+ data: decoded.value,
148
+ ...(compact ? {} : { body: parsed.right.body }),
149
+ }
150
+ })
151
+
152
+ const epics: unknown[] = []
153
+ const tasks: unknown[] = []
154
+ const phases: unknown[] = []
155
+ const epicRoot = join(root, "epics")
156
+ if (yield* fs.isDirectory(epicRoot)) {
157
+ for (const entry of (yield* fs.readDirectory(epicRoot))
158
+ .filter((item) => item.isDirectory)
159
+ .sort((a, b) => a.name.localeCompare(b.name))) {
160
+ const document = yield* discover(
161
+ entry.name,
162
+ join(epicRoot, entry.name, "EPIC.md"),
163
+ EpicFrontmatter,
164
+ )
165
+ if (document) epics.push(document)
166
+ }
167
+ }
168
+
169
+ const taskRoot = join(root, "tasks")
170
+ if (yield* fs.isDirectory(taskRoot)) {
171
+ for (const entry of (yield* fs.readDirectory(taskRoot))
172
+ .filter((item) => item.isDirectory)
173
+ .sort((a, b) => a.name.localeCompare(b.name))) {
174
+ const document = yield* discover(
175
+ entry.name,
176
+ join(taskRoot, entry.name, "TASK.md"),
177
+ TaskFrontmatter,
178
+ )
179
+ if (document) tasks.push(document)
180
+
181
+ const phaseRoot = join(taskRoot, entry.name, "phases")
182
+ if (!(yield* fs.isDirectory(phaseRoot))) continue
183
+ for (const phaseEntry of (yield* fs.readDirectory(phaseRoot))
184
+ .filter((item) => item.isDirectory)
185
+ .sort((a, b) => a.name.localeCompare(b.name))) {
186
+ const phase = yield* discover(
187
+ phaseEntry.name,
188
+ join(phaseRoot, phaseEntry.name, "PHASE.md"),
189
+ PhaseFrontmatter,
190
+ { taskId: entry.name },
191
+ )
192
+ if (phase) phases.push(phase)
193
+ }
194
+ }
195
+ }
196
+
197
+ const validation = yield* workbase.validate(root)
198
+ return {
199
+ projection: compact ? "compact" : "complete",
200
+ workbase: {
201
+ root,
202
+ configPath: join(root, "agency.json"),
203
+ version: config.version,
204
+ },
205
+ target: { kind: "workbase", path: root },
206
+ hint: compact
207
+ ? "Run agency context . --full --json to include document prose."
208
+ : null,
209
+ discovery: { epics, tasks, phases },
210
+ authority: {
211
+ mode: "orchestration",
212
+ writable: null,
213
+ references: [],
214
+ },
215
+ validation: {
216
+ valid: validation.valid,
217
+ warnings: validation.issues,
218
+ },
219
+ }
220
+ }
221
+
124
222
  const inferTarget = (): Target | null => {
125
223
  if (!candidateExists && !suppliedTarget.includes(sep)) {
126
224
  return {
@@ -264,47 +362,90 @@ export class ContextService extends Effect.Service<ContextService>()(
264
362
  const entries = (yield* fs.readDirectory(taskRoot))
265
363
  .filter((entry) => entry.isDirectory)
266
364
  .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,
365
+ const documents = yield* Effect.all(
366
+ entries.map((entry) =>
367
+ Effect.gen(function* () {
368
+ const path = join(taskRoot, entry.name, "TASK.md")
369
+ let taskDocument: Document<TaskData> | null = null
370
+ if (yield* fs.exists(path)) {
371
+ const content = yield* fs.readFile(path)
372
+ const parsed = yield* Effect.either(
373
+ parseFrontmatter(content, path),
374
+ )
375
+ if (Either.isRight(parsed)) {
376
+ const decoded = decode(TaskFrontmatter, parsed.right.data)
377
+ if (decoded.ok) {
378
+ taskDocument = {
379
+ id: entry.name,
380
+ path,
381
+ sha256: documentRevision(content),
382
+ data: decoded.value,
383
+ body: parsed.right.body,
384
+ }
385
+ }
386
+ }
387
+ }
388
+
389
+ const phasesPath = join(taskRoot, entry.name, "phases")
390
+ if (!(yield* fs.isDirectory(phasesPath))) {
391
+ return { taskDocument, phaseDocuments: [] }
392
+ }
393
+ const phaseEntries = (yield* fs.readDirectory(phasesPath))
394
+ .filter((item) => item.isDirectory)
395
+ .sort((a, b) => a.name.localeCompare(b.name))
396
+ const childDocuments = yield* Effect.all(
397
+ phaseEntries.map((phaseEntry) =>
398
+ Effect.gen(function* () {
399
+ const phasePath = join(
400
+ phasesPath,
401
+ phaseEntry.name,
402
+ "PHASE.md",
403
+ )
404
+ if (!(yield* fs.exists(phasePath))) return null
405
+ const content = yield* fs.readFile(phasePath)
406
+ const parsed = yield* Effect.either(
407
+ parseFrontmatter(content, phasePath),
408
+ )
409
+ if (Either.isLeft(parsed)) return null
410
+ const decoded = decode(
411
+ PhaseFrontmatter,
412
+ parsed.right.data,
413
+ )
414
+ if (!decoded.ok) return null
415
+ return [
416
+ `${entry.name}/${phaseEntry.name}`,
417
+ {
418
+ id: phaseEntry.name,
419
+ path: phasePath,
420
+ sha256: documentRevision(content),
421
+ data: decoded.value,
422
+ body: parsed.right.body,
423
+ },
424
+ ] as const
425
+ }),
426
+ ),
427
+ { concurrency: "unbounded" },
428
+ )
429
+ return {
430
+ taskDocument,
431
+ phaseDocuments: childDocuments.filter(
432
+ (document): document is NonNullable<typeof document> =>
433
+ document !== null,
434
+ ),
435
+ }
436
+ }),
437
+ ),
438
+ { concurrency: "unbounded" },
439
+ )
440
+ for (const document of documents) {
441
+ if (document.taskDocument) {
442
+ taskDocuments.set(
443
+ document.taskDocument.id,
444
+ document.taskDocument,
299
445
  )
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
- })
446
+ }
447
+ for (const [key, phaseDocument] of document.phaseDocuments) {
448
+ phaseDocuments.set(key, phaseDocument)
308
449
  }
309
450
  }
310
451
  }
@@ -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
 
package/src/work-view.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { Effect } from "effect"
2
2
  import type { GraphNode } from "./graph-schema"
3
3
  import { GraphService } from "./services/GraphService"
4
+ import type { ValidationReport } from "./services/WorkbaseService"
4
5
  import type { WorkStatus } from "./workbase/schemas"
5
6
 
6
7
  type WorkNode = Extract<
@@ -12,6 +13,7 @@ type ExecutionNode = Extract<WorkNode, { readonly kind: "execution-unit" }>
12
13
 
13
14
  export interface WorkViewOptions {
14
15
  readonly cwd?: string
16
+ readonly validation?: ValidationReport
15
17
  readonly statuses?: readonly string[]
16
18
  readonly repositories?: readonly string[]
17
19
  readonly ready?: boolean
@@ -170,6 +172,7 @@ export const getWorkViews = (options: WorkViewOptions = {}) =>
170
172
  )
171
173
  const graph = yield* graphService.get({
172
174
  cwd: options.cwd,
175
+ validation: options.validation,
173
176
  include: ["workspace"],
174
177
  })
175
178
  const workNodes = graph.nodes.filter(