@llm4ts/flow 0.13.4 → 0.14.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llm4ts/flow",
3
- "version": "0.13.4",
3
+ "version": "0.14.0",
4
4
  "description": "Effect-native LLM workflow, persistence, review, and repository automation",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -19,6 +19,7 @@
19
19
  "./BasecampTool": "./dist/BasecampTool.js",
20
20
  "./Bench": "./dist/Bench.js",
21
21
  "./BenchReport": "./dist/BenchReport.js",
22
+ "./BoardSync": "./dist/BoardSync.js",
22
23
  "./CapabilityGuard": "./dist/CapabilityGuard.js",
23
24
  "./Chat": "./dist/Chat.js",
24
25
  "./Classified": "./dist/Classified.js",
@@ -27,6 +28,7 @@
27
28
  "./CostTracker": "./dist/CostTracker.js",
28
29
  "./Equiv": "./dist/Equiv.js",
29
30
  "./EquivReport": "./dist/EquivReport.js",
31
+ "./EstimatedUsage": "./dist/EstimatedUsage.js",
30
32
  "./Flow": "./dist/Flow.js",
31
33
  "./FlowContext": "./dist/FlowContext.js",
32
34
  "./FlowError": "./dist/FlowError.js",
@@ -39,6 +41,7 @@
39
41
  "./Mermaid": "./dist/Mermaid.js",
40
42
  "./Pack": "./dist/Pack.js",
41
43
  "./Package": "./dist/Package.js",
44
+ "./PageSpec": "./dist/PageSpec.js",
42
45
  "./Patterns": "./dist/Patterns.js",
43
46
  "./Persistence": "./dist/Persistence.js",
44
47
  "./Plan": "./dist/Plan.js",
@@ -83,7 +86,7 @@
83
86
  "typescript"
84
87
  ],
85
88
  "dependencies": {
86
- "@llm4ts/core": "0.13.4"
89
+ "@llm4ts/core": "0.14.0"
87
90
  },
88
91
  "peerDependencies": {
89
92
  "effect": "4.0.0-beta.102"
@@ -421,6 +421,103 @@ export const relationAddArgs = (
421
421
  ...json
422
422
  ]
423
423
 
424
+ // ---------------------------------------------------------------------------
425
+ // Work item links
426
+ // ---------------------------------------------------------------------------
427
+
428
+ // Azure DevOps models "this is part of that" and "this waits for that" as
429
+ // first-class links, where a GitHub issue has only prose. A consumer that
430
+ // writes `Parent: #12` into a description is describing a relationship the
431
+ // board can hold properly — and only the prose version shows up in the
432
+ // backlog tree, the dependency view, or a query.
433
+ export const WorkItemLinkKind = Schema.Literals([
434
+ "Parent",
435
+ "Child",
436
+ "Related",
437
+ "Predecessor",
438
+ "Successor"
439
+ ])
440
+ export type WorkItemLinkKind = typeof WorkItemLinkKind.Type
441
+
442
+ export const workItemLinkKinds: ReadonlyArray<WorkItemLinkKind> = [
443
+ "Parent",
444
+ "Child",
445
+ "Related",
446
+ "Predecessor",
447
+ "Successor"
448
+ ]
449
+
450
+ export class WorkItemLink extends Schema.Class<WorkItemLink>("WorkItemLink")({
451
+ kind: WorkItemLinkKind,
452
+ id: Schema.Int
453
+ }) {}
454
+
455
+ // Forward is the one that points away from the "primary" end: a parent's
456
+ // link to its child is Hierarchy-Forward, so a child's link to its parent
457
+ // is the Reverse. Dependency reads the same way — an item's Predecessor is
458
+ // the one it waits for, which is what "blocked by" means.
459
+ const linkReferenceNames: Readonly<Record<WorkItemLinkKind, string>> = {
460
+ Parent: "System.LinkTypes.Hierarchy-Reverse",
461
+ Child: "System.LinkTypes.Hierarchy-Forward",
462
+ Related: "System.LinkTypes.Related",
463
+ Predecessor: "System.LinkTypes.Dependency-Reverse",
464
+ Successor: "System.LinkTypes.Dependency-Forward"
465
+ }
466
+
467
+ export const linkReferenceName = (kind: WorkItemLinkKind): string => linkReferenceNames[kind]
468
+
469
+ export const linkKindOfReference = (reference: string): WorkItemLinkKind | undefined =>
470
+ workItemLinkKinds.find(
471
+ (kind) => linkReferenceNames[kind].toLowerCase() === reference.trim().toLowerCase()
472
+ )
473
+
474
+ // A relation's url addresses the work item through the REST API, whatever
475
+ // the organization's host: the id is its last segment.
476
+ export const workItemIdOfUrl = (url: string): number | undefined => {
477
+ const match = /\/workItems\/(\d+)$/i.exec(url.trim())
478
+ const digits = match?.[1]
479
+ return digits === undefined ? undefined : Number(digits)
480
+ }
481
+
482
+ export const parseWorkItemLinks = (
483
+ payload: string
484
+ ): Effect.Effect<ReadonlyArray<WorkItemLink>, ProcessError> =>
485
+ Schema.decodeUnknownEffect(Schema.fromJsonString(AdoRelations))(payload).pipe(
486
+ Effect.map((parsed) =>
487
+ (parsed.relations ?? []).flatMap((relation) => {
488
+ const kind = linkKindOfReference(relation.rel ?? "")
489
+ const id = workItemIdOfUrl(relation.url ?? "")
490
+ // Artifact links and hyperlinks share the relations array; they are
491
+ // skipped rather than half-decoded, as developmentLinks skips these.
492
+ return kind === undefined || id === undefined ? [] : [WorkItemLink.make({ kind, id })]
493
+ })
494
+ ),
495
+ Effect.mapError(decodeFailure("az boards work-item show --expand relations"))
496
+ )
497
+
498
+ // Work item links take --target-id, where an artifact link takes the
499
+ // --target-url of a vstfs: URI. The CLI resolves --relation-type against
500
+ // the organization's own link types by name.
501
+ export const workItemLinkArgs = (
502
+ config: AdoConfig,
503
+ id: number,
504
+ kind: WorkItemLinkKind,
505
+ targetId: number
506
+ ): ReadonlyArray<string> => [
507
+ "boards",
508
+ "work-item",
509
+ "relation",
510
+ "add",
511
+ "--id",
512
+ String(id),
513
+ "--relation-type",
514
+ kind.toLowerCase(),
515
+ "--target-id",
516
+ String(targetId),
517
+ ...org(config),
518
+ ...json
519
+ ]
520
+
424
521
  export const repositoryShowArgs = (
425
522
  config: AdoConfig,
426
523
  repository: string
@@ -731,6 +828,14 @@ export interface AzureDevOpsToolShape {
731
828
  // commits linked to it. Empty when nothing has been linked yet.
732
829
  readonly developmentLinks: (id: number) => Effect.Effect<ReadonlyArray<GitArtifact>, FlowError>
733
830
  readonly linkArtifact: (id: number, artifact: GitArtifact) => Effect.Effect<void, FlowError>
831
+ // The work item's own links — hierarchy and dependency — as opposed to
832
+ // the git objects developmentLinks returns from the same call.
833
+ readonly workItemLinks: (id: number) => Effect.Effect<ReadonlyArray<WorkItemLink>, FlowError>
834
+ readonly linkWorkItem: (
835
+ id: number,
836
+ kind: WorkItemLinkKind,
837
+ targetId: number
838
+ ) => Effect.Effect<void, FlowError>
734
839
  // Resolves a repository's GUIDs, which every artifact link needs and no
735
840
  // caller can know from a repository name alone.
736
841
  readonly repository: (name?: string) => Effect.Effect<GitRepository, FlowError>
@@ -863,6 +968,16 @@ export const makeAzureDevOpsTool = (
863
968
  ),
864
969
  linkArtifact: (id, artifact) =>
865
970
  write("ado linkArtifact", run(relationAddArgs(config, id, artifact)).pipe(Effect.asVoid)),
971
+ workItemLinks: (id) =>
972
+ read(
973
+ "ado workItemLinks",
974
+ run(workItemShowArgs(config, id, "relations")).pipe(Effect.flatMap(parseWorkItemLinks))
975
+ ),
976
+ linkWorkItem: (id, kind, targetId) =>
977
+ write(
978
+ "ado linkWorkItem",
979
+ run(workItemLinkArgs(config, id, kind, targetId)).pipe(Effect.asVoid)
980
+ ),
866
981
  repository: (name = config.repository) =>
867
982
  read(
868
983
  "ado repository",
@@ -0,0 +1,358 @@
1
+ import * as Effect from "effect/Effect"
2
+ import * as Ref from "effect/Ref"
3
+ import * as Schema from "effect/Schema"
4
+ import { quoteWiql, type AzureDevOpsToolShape } from "./AzureDevOpsTool.ts"
5
+ import { FlowAborted, type FlowError } from "./FlowError.ts"
6
+ import { loadVersioned, saveVersioned, type PlainFileStoreShape } from "./Persistence.ts"
7
+
8
+ // The progress board of the conversion scenario: a port with the work-item
9
+ // lifecycle (plan → start → complete/fail/skip) and two adapters — a local
10
+ // file board (the default, fully offline: board.json + rendered board.md)
11
+ // and an Azure DevOps adapter (the stretch goal) mapping the same lifecycle
12
+ // onto work items. Flows depend only on the port; which board the client
13
+ // sees is wiring, not policy.
14
+
15
+ export const BoardVersion = 1
16
+
17
+ export const BoardStatus = Schema.Literals(["planned", "active", "done", "failed", "skipped"])
18
+ export type BoardStatus = typeof BoardStatus.Type
19
+
20
+ export class BoardItem extends Schema.Class<BoardItem>("BoardItem")({
21
+ id: Schema.String,
22
+ title: Schema.String,
23
+ wave: Schema.optionalKey(Schema.String),
24
+ status: BoardStatus,
25
+ branch: Schema.optionalKey(Schema.String),
26
+ reportPath: Schema.optionalKey(Schema.String),
27
+ /** Free-form note: a failure reason, a skip rationale, a triage disposition. */
28
+ detail: Schema.optionalKey(Schema.String),
29
+ /** ESTIMATES, never measurements — see EstimatedUsage (ADR 0012). */
30
+ estimatedTokens: Schema.optionalKey(Schema.Int),
31
+ estimatedCostUsd: Schema.optionalKey(Schema.Number)
32
+ }) {}
33
+
34
+ export class Board extends Schema.Class<Board>("Board")({
35
+ title: Schema.String,
36
+ items: Schema.Array(BoardItem)
37
+ }) {}
38
+
39
+ export interface BoardItemResult {
40
+ readonly branch?: string
41
+ readonly reportPath?: string
42
+ readonly detail?: string
43
+ readonly estimatedTokens?: number
44
+ readonly estimatedCostUsd?: number
45
+ }
46
+
47
+ export interface BoardSyncShape {
48
+ /** Publish the full planned work list. Idempotent: known ids keep their state. */
49
+ readonly plan: (items: ReadonlyArray<BoardItem>) => Effect.Effect<void, FlowError>
50
+ readonly start: (id: string) => Effect.Effect<void, FlowError>
51
+ readonly complete: (id: string, result: BoardItemResult) => Effect.Effect<void, FlowError>
52
+ readonly fail: (id: string, reason: string) => Effect.Effect<void, FlowError>
53
+ readonly skip: (id: string, reason: string) => Effect.Effect<void, FlowError>
54
+ readonly snapshot: Effect.Effect<Board, FlowError>
55
+ }
56
+
57
+ const sectionOrder: ReadonlyArray<readonly [BoardStatus, string]> = [
58
+ ["active", "Active"],
59
+ ["planned", "Planned"],
60
+ ["done", "Done"],
61
+ ["failed", "Failed"],
62
+ ["skipped", "Skipped"]
63
+ ]
64
+
65
+ const itemLine = (item: BoardItem): string => {
66
+ const parts: Array<string> = [`- **${item.id}** — ${item.title}`]
67
+ if (item.wave !== undefined) {
68
+ parts.push(`(wave: ${item.wave})`)
69
+ }
70
+ if (item.branch !== undefined) {
71
+ parts.push(`branch \`${item.branch}\``)
72
+ }
73
+ if (item.reportPath !== undefined) {
74
+ parts.push(`[report](${item.reportPath})`)
75
+ }
76
+ if (item.estimatedTokens !== undefined || item.estimatedCostUsd !== undefined) {
77
+ const tokens = item.estimatedTokens === undefined ? "" : `~${item.estimatedTokens} tokens`
78
+ const cost = item.estimatedCostUsd === undefined ? "" : `~$${item.estimatedCostUsd.toFixed(2)}`
79
+ parts.push(`(${[tokens, cost].filter((part) => part.length > 0).join(", ")} — estimated)`)
80
+ }
81
+ if (item.detail !== undefined) {
82
+ parts.push(`— ${item.detail}`)
83
+ }
84
+ return parts.join(" ")
85
+ }
86
+
87
+ /** The markdown board — every figure on it labelled estimated. */
88
+ export const renderBoard = (board: Board): string => {
89
+ const lines: Array<string> = [`# Board: ${board.title}`, ""]
90
+ const counts = sectionOrder
91
+ .map(([status]) => `${status}: ${board.items.filter((item) => item.status === status).length}`)
92
+ .join(" · ")
93
+ lines.push(counts, "")
94
+ lines.push(
95
+ "All token and cost figures are ESTIMATES (character-count heuristics), not",
96
+ "measurements.",
97
+ ""
98
+ )
99
+ for (const [status, heading] of sectionOrder) {
100
+ const items = board.items.filter((item) => item.status === status)
101
+ if (items.length === 0) {
102
+ continue
103
+ }
104
+ lines.push(`## ${heading}`, "")
105
+ for (const item of items) {
106
+ lines.push(itemLine(item))
107
+ }
108
+ lines.push("")
109
+ }
110
+ return lines.join("\n").trimEnd() + "\n"
111
+ }
112
+
113
+ const join = (root: string, path: string): string =>
114
+ `${root.replace(/[\\/]+$/, "")}/${path.replace(/^[\\/]+/, "")}`
115
+
116
+ const applyResult = (item: BoardItem, status: BoardStatus, result: BoardItemResult): BoardItem =>
117
+ BoardItem.make({
118
+ ...item,
119
+ status,
120
+ ...(result.branch === undefined ? {} : { branch: result.branch }),
121
+ ...(result.reportPath === undefined ? {} : { reportPath: result.reportPath }),
122
+ ...(result.detail === undefined ? {} : { detail: result.detail }),
123
+ ...(result.estimatedTokens === undefined
124
+ ? {}
125
+ : { estimatedTokens: Math.round(result.estimatedTokens) }),
126
+ ...(result.estimatedCostUsd === undefined ? {} : { estimatedCostUsd: result.estimatedCostUsd })
127
+ })
128
+
129
+ /**
130
+ * The default adapter: `board.json` (versioned) plus a rendered `board.md`
131
+ * under `directory`. The JSON is the state, the markdown is the demo surface;
132
+ * both are rewritten on every mutation so a crash never leaves them apart.
133
+ */
134
+ export const makeLocalBoardSync = (
135
+ files: PlainFileStoreShape,
136
+ directory: string,
137
+ title: string
138
+ ): BoardSyncShape => {
139
+ const jsonPath = join(directory, "board.json")
140
+ const markdownPath = join(directory, "board.md")
141
+
142
+ const load: Effect.Effect<Board, FlowError> = loadVersioned(
143
+ files,
144
+ jsonPath,
145
+ BoardVersion,
146
+ Board
147
+ ).pipe(Effect.map((board) => board ?? Board.make({ title, items: [] })))
148
+
149
+ const save = (board: Board): Effect.Effect<void, FlowError> =>
150
+ saveVersioned(files, jsonPath, BoardVersion, Board, board).pipe(
151
+ Effect.andThen(files.writeAtomic(markdownPath, renderBoard(board)))
152
+ )
153
+
154
+ const update = (
155
+ id: string,
156
+ transform: (item: BoardItem) => BoardItem
157
+ ): Effect.Effect<void, FlowError> =>
158
+ Effect.gen(function* () {
159
+ const board = yield* load
160
+ if (!board.items.some((item) => item.id === id)) {
161
+ return yield* FlowAborted.make({ message: `board has no item '${id}' — plan it first` })
162
+ }
163
+ yield* save(
164
+ Board.make({
165
+ ...board,
166
+ items: board.items.map((item) => (item.id === id ? transform(item) : item))
167
+ })
168
+ )
169
+ })
170
+
171
+ return {
172
+ plan: (items) =>
173
+ Effect.gen(function* () {
174
+ const board = yield* load
175
+ const known = new Map(board.items.map((item) => [item.id, item] as const))
176
+ // Known ids keep their lived state — re-planning must never demote a
177
+ // converted page back to "planned".
178
+ const merged = [...board.items, ...items.filter((item) => !known.has(item.id))]
179
+ yield* save(Board.make({ title: board.title, items: merged }))
180
+ }),
181
+ start: (id) => update(id, (item) => BoardItem.make({ ...item, status: "active" })),
182
+ complete: (id, result) => update(id, (item) => applyResult(item, "done", result)),
183
+ fail: (id, reason) =>
184
+ update(id, (item) => BoardItem.make({ ...item, status: "failed", detail: reason })),
185
+ skip: (id, reason) =>
186
+ update(id, (item) => BoardItem.make({ ...item, status: "skipped", detail: reason })),
187
+ snapshot: load
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Fan one lifecycle out to several boards (local file + ADO, typically).
193
+ * Mutations hit every board in order; `snapshot` reads the FIRST — the local
194
+ * board is the source of truth, the others are mirrors.
195
+ */
196
+ export const composeBoardSync = (boards: ReadonlyArray<BoardSyncShape>): BoardSyncShape => {
197
+ const first = boards[0]
198
+ const each = (
199
+ operation: (board: BoardSyncShape) => Effect.Effect<void, FlowError>
200
+ ): Effect.Effect<void, FlowError> => Effect.forEach(boards, operation, { discard: true })
201
+ return {
202
+ plan: (items) => each((board) => board.plan(items)),
203
+ start: (id) => each((board) => board.start(id)),
204
+ complete: (id, result) => each((board) => board.complete(id, result)),
205
+ fail: (id, reason) => each((board) => board.fail(id, reason)),
206
+ skip: (id, reason) => each((board) => board.skip(id, reason)),
207
+ snapshot:
208
+ first === undefined
209
+ ? Effect.succeed(Board.make({ title: "empty", items: [] }))
210
+ : first.snapshot
211
+ }
212
+ }
213
+
214
+ export interface AdoBoardOptions {
215
+ /** Work item type created for each page. Default "Task". */
216
+ readonly workItemType?: string
217
+ /** Tag identifying this board's items — the idempotency key. Default "llm4ts-convert". */
218
+ readonly tag?: string
219
+ /** Lifecycle → System.State mapping. Defaults: New / Active / Closed. */
220
+ readonly states?: {
221
+ readonly planned?: string
222
+ readonly active?: string
223
+ readonly done?: string
224
+ }
225
+ }
226
+
227
+ const markerQuery = (tag: string, id: string): string =>
228
+ "SELECT [System.Id] FROM WorkItems WHERE " +
229
+ `[System.Tags] CONTAINS ${quoteWiql(tag)} AND ` +
230
+ `[System.Title] CONTAINS ${quoteWiql(`[${id}]`)}`
231
+
232
+ /**
233
+ * The stretch adapter (ADR 0012): the same lifecycle mapped onto Azure DevOps
234
+ * work items through the az-CLI AzureDevOpsTool (ADR 0011). Idempotent by
235
+ * title marker — `[<id>]` plus the board tag — so a re-run finds its items
236
+ * instead of duplicating them. Failure and skip are tags plus a comment,
237
+ * never invented states.
238
+ */
239
+ export const makeAdoBoardSync = Effect.fn("@llm4ts/flow/BoardSync.makeAdo")(function* (
240
+ ado: AzureDevOpsToolShape,
241
+ boardTitle: string,
242
+ options: AdoBoardOptions = {}
243
+ ): Effect.fn.Return<BoardSyncShape> {
244
+ const workItemType = options.workItemType ?? "Task"
245
+ const tag = options.tag ?? "llm4ts-convert"
246
+ const states = {
247
+ planned: options.states?.planned ?? "New",
248
+ active: options.states?.active ?? "Active",
249
+ done: options.states?.done ?? "Closed"
250
+ }
251
+ const ids = yield* Ref.make<ReadonlyMap<string, number>>(new Map())
252
+
253
+ const lookup = (id: string): Effect.Effect<number, FlowError> =>
254
+ Effect.gen(function* () {
255
+ const cached = (yield* Ref.get(ids)).get(id)
256
+ if (cached !== undefined) {
257
+ return cached
258
+ }
259
+ const found = yield* ado.wiqlIds(markerQuery(tag, id))
260
+ const first = found[0]
261
+ if (first === undefined) {
262
+ return yield* FlowAborted.make({
263
+ message: `no ADO work item tagged '${tag}' with '[${id}]' in its title — plan it first`
264
+ })
265
+ }
266
+ yield* Ref.update(ids, (current) => new Map(current).set(id, first))
267
+ return first
268
+ })
269
+
270
+ return {
271
+ plan: (items) =>
272
+ Effect.gen(function* () {
273
+ for (const item of items) {
274
+ const existing = yield* ado.wiqlIds(markerQuery(tag, item.id))
275
+ const first = existing[0]
276
+ if (first !== undefined) {
277
+ yield* Ref.update(ids, (current) => new Map(current).set(item.id, first))
278
+ continue
279
+ }
280
+ const created = yield* ado.createWorkItem(
281
+ workItemType,
282
+ `[${item.id}] ${item.title}`,
283
+ item.detail ?? item.title,
284
+ [tag]
285
+ )
286
+ yield* Ref.update(ids, (current) => new Map(current).set(item.id, created.id))
287
+ if (states.planned !== "New") {
288
+ yield* ado.setState(created.id, states.planned)
289
+ }
290
+ }
291
+ }),
292
+ start: (id) =>
293
+ Effect.gen(function* () {
294
+ const workItem = yield* lookup(id)
295
+ yield* ado.setState(workItem, states.active)
296
+ }),
297
+ complete: (id, result) =>
298
+ Effect.gen(function* () {
299
+ const workItem = yield* lookup(id)
300
+ const parts = [
301
+ result.branch === undefined ? undefined : `Branch: ${result.branch}`,
302
+ result.reportPath === undefined ? undefined : `Report: ${result.reportPath}`,
303
+ result.estimatedTokens === undefined
304
+ ? undefined
305
+ : `~${Math.round(result.estimatedTokens)} tokens (estimated)`,
306
+ result.estimatedCostUsd === undefined
307
+ ? undefined
308
+ : `~$${result.estimatedCostUsd.toFixed(2)} (estimated)`,
309
+ result.detail
310
+ ].filter((part): part is string => part !== undefined)
311
+ if (parts.length > 0) {
312
+ yield* ado.writeComment(workItem, parts.join("\n"))
313
+ }
314
+ yield* ado.setState(workItem, states.done)
315
+ }),
316
+ fail: (id, reason) =>
317
+ Effect.gen(function* () {
318
+ const workItem = yield* lookup(id)
319
+ yield* ado.editTags(workItem, [`${tag}-failed`], [])
320
+ yield* ado.writeComment(workItem, `FAILED: ${reason}`)
321
+ }),
322
+ skip: (id, reason) =>
323
+ Effect.gen(function* () {
324
+ const workItem = yield* lookup(id)
325
+ yield* ado.editTags(workItem, [`${tag}-skipped`], [])
326
+ yield* ado.writeComment(workItem, `SKIPPED: ${reason}`)
327
+ }),
328
+ snapshot: Effect.gen(function* () {
329
+ const found = yield* ado.wiqlIds(
330
+ `SELECT [System.Id] FROM WorkItems WHERE [System.Tags] CONTAINS ${quoteWiql(tag)}`
331
+ )
332
+ const items: Array<BoardItem> = []
333
+ for (const workItemId of found) {
334
+ const workItem = yield* ado.readWorkItem(workItemId)
335
+ const match = /^\[([^\]]+)\] (.*)$/.exec(workItem.title)
336
+ const failed = workItem.tags.includes(`${tag}-failed`)
337
+ const skipped = workItem.tags.includes(`${tag}-skipped`)
338
+ const status: BoardStatus = failed
339
+ ? "failed"
340
+ : skipped
341
+ ? "skipped"
342
+ : workItem.state === states.done
343
+ ? "done"
344
+ : workItem.state === states.active
345
+ ? "active"
346
+ : "planned"
347
+ items.push(
348
+ BoardItem.make({
349
+ id: match?.[1] ?? String(workItemId),
350
+ title: match?.[2] ?? workItem.title,
351
+ status
352
+ })
353
+ )
354
+ }
355
+ return Board.make({ title: boardTitle, items })
356
+ })
357
+ }
358
+ })