@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 +1 -1
- package/src/commands/status.ts +1 -0
- package/src/services/ContextService.ts +83 -40
- package/src/services/GraphService.ts +60 -28
- package/src/services/WorkbaseService.ts +57 -27
- package/src/utils/chooser.test.ts +47 -0
- package/src/utils/chooser.ts +15 -1
- package/src/utils/interactive.test.tsx +272 -1
- package/src/utils/interactive.tsx +155 -29
- package/src/utils/theme.ts +17 -0
- package/src/work-view.ts +3 -0
- package/src/workbase/work-target.test.ts +27 -13
- package/src/workbase/work-target.ts +83 -28
package/package.json
CHANGED
package/src/commands/status.ts
CHANGED
|
@@ -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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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 =
|
|
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
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
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
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
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
|
|
package/src/utils/chooser.ts
CHANGED
|
@@ -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 {
|
|
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) {
|