@llm4ts/flow 2.16.0 → 2.18.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.
Files changed (50) hide show
  1. package/dist/Approval.d.ts +1 -1
  2. package/dist/Approval.d.ts.map +1 -1
  3. package/dist/Artifacts.d.ts +2 -2
  4. package/dist/Artifacts.d.ts.map +1 -1
  5. package/dist/BenchReport.d.ts +2 -2
  6. package/dist/BenchReport.d.ts.map +1 -1
  7. package/dist/CostLedger.d.ts +2 -2
  8. package/dist/CostLedger.d.ts.map +1 -1
  9. package/dist/CoverageLedger.d.ts +180 -0
  10. package/dist/CoverageLedger.d.ts.map +1 -0
  11. package/dist/CoverageLedger.js +415 -0
  12. package/dist/CoverageLedger.js.map +1 -0
  13. package/dist/EpicBrief.d.ts +239 -0
  14. package/dist/EpicBrief.d.ts.map +1 -0
  15. package/dist/EpicBrief.js +868 -0
  16. package/dist/EpicBrief.js.map +1 -0
  17. package/dist/Equiv.d.ts +3 -3
  18. package/dist/Equiv.d.ts.map +1 -1
  19. package/dist/Flow.d.ts +1 -1
  20. package/dist/Flow.d.ts.map +1 -1
  21. package/dist/FlowError.d.ts +23 -1
  22. package/dist/FlowError.d.ts.map +1 -1
  23. package/dist/FlowError.js +30 -1
  24. package/dist/FlowError.js.map +1 -1
  25. package/dist/Judgment.d.ts +2 -2
  26. package/dist/Judgment.d.ts.map +1 -1
  27. package/dist/Landing.d.ts +1 -1
  28. package/dist/Landing.d.ts.map +1 -1
  29. package/dist/Persistence.d.ts +2 -2
  30. package/dist/Persistence.d.ts.map +1 -1
  31. package/dist/PlanExecution.d.ts +1 -1
  32. package/dist/PlanExecution.d.ts.map +1 -1
  33. package/dist/ProgramJudge.d.ts +1 -1
  34. package/dist/ProgramJudge.d.ts.map +1 -1
  35. package/dist/Replay.d.ts +2 -2
  36. package/dist/Replay.d.ts.map +1 -1
  37. package/dist/Review.d.ts +3 -3
  38. package/dist/Review.d.ts.map +1 -1
  39. package/dist/ReviewCache.d.ts +1 -1
  40. package/dist/ReviewCache.d.ts.map +1 -1
  41. package/dist/Roster.d.ts +1 -1
  42. package/dist/Roster.d.ts.map +1 -1
  43. package/dist/RosterSeats.d.ts +1 -1
  44. package/dist/RosterSeats.d.ts.map +1 -1
  45. package/dist/Stories.d.ts +1 -1
  46. package/dist/Stories.d.ts.map +1 -1
  47. package/package.json +4 -2
  48. package/src/CoverageLedger.ts +549 -0
  49. package/src/EpicBrief.ts +1014 -0
  50. package/src/FlowError.ts +39 -1
@@ -0,0 +1,549 @@
1
+ import * as Schema from "effect/Schema"
2
+ import {
3
+ BriefStatus,
4
+ crossProblem,
5
+ dispositionWords,
6
+ hasOverride,
7
+ type BriefDisposition,
8
+ type EpicBrief,
9
+ type InheritedDecision,
10
+ type PackIndex
11
+ } from "./EpicBrief.ts"
12
+
13
+ /**
14
+ * The coverage ledger: what the epic briefs of a target repository have
15
+ * decided about a legacy extract pack, scenario by scenario. It is derived,
16
+ * never state: a pure function of the pack, the briefs and the epics'
17
+ * recorded progress. The report, the context the next brief inherits and the
18
+ * checks across briefs all read it. Only approved briefs bind; a draft's
19
+ * claims are shown as proposed.
20
+ */
21
+
22
+ const Disposition = Schema.Literals(["in-scope", "dropped", "provided", "deferred"])
23
+
24
+ /** One brief's word on one scenario. */
25
+ export const Claim = Schema.Struct({
26
+ epicId: Schema.String,
27
+ /** Whether the brief is approved; a draft's claim binds nothing. */
28
+ approved: Schema.Boolean,
29
+ disposition: Disposition,
30
+ /** The in-scope item's title, the reason, the pointer or the deferral note. */
31
+ note: Schema.String,
32
+ /** The epics this claim was kept against on purpose (`keep: <why>` in its brief). */
33
+ keptAgainst: Schema.Array(Schema.String),
34
+ /** Whether the claim still binds: false for a draft's, an overruled one, a deferral handed over. */
35
+ standing: Schema.Boolean
36
+ })
37
+ export type Claim = typeof Claim.Type
38
+
39
+ export const LedgerStatus = Schema.Literals([
40
+ "unclaimed",
41
+ "in-scope",
42
+ "dropped",
43
+ "provided",
44
+ "deferred",
45
+ "proposed",
46
+ "conflict"
47
+ ])
48
+ export type LedgerStatus = typeof LedgerStatus.Type
49
+
50
+ /** How far the owning epic is: brief approved, planned, stories merging, landed. */
51
+ export const DeliveryState = Schema.Literals(["approved", "planned", "in-progress", "landed"])
52
+ export type DeliveryState = typeof DeliveryState.Type
53
+
54
+ export const LedgerEntry = Schema.Struct({
55
+ program: Schema.String,
56
+ scenario: Schema.String,
57
+ status: LedgerStatus,
58
+ /** Every claim on the scenario, approved or not, in brief order. */
59
+ claims: Schema.Array(Claim),
60
+ /** The approved epics behind the status; both sides of a conflict. */
61
+ owners: Schema.Array(Schema.String),
62
+ /** In-scope entries only: the owning epic's delivery state. */
63
+ delivery: Schema.optionalKey(DeliveryState)
64
+ })
65
+ export type LedgerEntry = typeof LedgerEntry.Type
66
+
67
+ export const ProgramTotals = Schema.Struct({
68
+ program: Schema.String,
69
+ scenarios: Schema.Int,
70
+ inScope: Schema.Int,
71
+ dropped: Schema.Int,
72
+ provided: Schema.Int,
73
+ deferred: Schema.Int,
74
+ proposed: Schema.Int,
75
+ unclaimed: Schema.Int,
76
+ conflicts: Schema.Int
77
+ })
78
+ export type ProgramTotals = typeof ProgramTotals.Type
79
+
80
+ /** A claim on a program or scenario the pack no longer has. */
81
+ export const StaleClaim = Schema.Struct({
82
+ epicId: Schema.String,
83
+ program: Schema.String,
84
+ scenario: Schema.String,
85
+ disposition: Disposition
86
+ })
87
+ export type StaleClaim = typeof StaleClaim.Type
88
+
89
+ export const LedgerBriefInfo = Schema.Struct({
90
+ epicId: Schema.String,
91
+ status: BriefStatus,
92
+ delivery: Schema.optionalKey(DeliveryState)
93
+ })
94
+ export type LedgerBriefInfo = typeof LedgerBriefInfo.Type
95
+
96
+ export const CoverageLedger = Schema.Struct({
97
+ entries: Schema.Array(LedgerEntry),
98
+ programs: Schema.Array(ProgramTotals),
99
+ stale: Schema.Array(StaleClaim),
100
+ /** Briefs designed against another legacy repository than the pack in hand. */
101
+ skipped: Schema.Array(Schema.String),
102
+ briefs: Schema.Array(LedgerBriefInfo),
103
+ totals: Schema.Struct({
104
+ scenarios: Schema.Int,
105
+ /** Decided: in scope, dropped or provided by an approved brief. */
106
+ accounted: Schema.Int,
107
+ /** Dropped, provided, or in scope in an epic that landed. */
108
+ delivered: Schema.Int,
109
+ /** Unclaimed, deferred, only proposed, or in conflict. */
110
+ remaining: Schema.Int,
111
+ conflicts: Schema.Int
112
+ })
113
+ })
114
+ export type CoverageLedger = typeof CoverageLedger.Type
115
+
116
+ export interface LedgerBrief {
117
+ /** The epic's folder name. */
118
+ readonly epicId: string
119
+ readonly brief: EpicBrief
120
+ }
121
+
122
+ /** What `epic-stories` recorded for an epic. */
123
+ export interface EpicProgress {
124
+ readonly epicId: string
125
+ readonly planned: boolean
126
+ readonly stories: number
127
+ readonly merged: number
128
+ readonly landed: boolean
129
+ }
130
+
131
+ export interface LedgerInputs {
132
+ readonly pack: PackIndex
133
+ readonly briefs: ReadonlyArray<LedgerBrief>
134
+ readonly epics: ReadonlyArray<EpicProgress>
135
+ /** The legacy repository the pack came from; briefs naming another are skipped. */
136
+ readonly legacy?: string
137
+ }
138
+
139
+ interface Located {
140
+ readonly epicId: string
141
+ readonly approved: boolean
142
+ readonly disposition: BriefDisposition
143
+ readonly note: string
144
+ readonly program: string
145
+ readonly scenario: string
146
+ }
147
+
148
+ const claimsOf = (epicId: string, brief: EpicBrief): ReadonlyArray<Located> => {
149
+ const approved = brief.status === "approved"
150
+ const disposed = (
151
+ disposition: BriefDisposition,
152
+ entries: EpicBrief["dropped"]
153
+ ): ReadonlyArray<Located> =>
154
+ entries.map((entry) => ({
155
+ epicId,
156
+ approved,
157
+ disposition,
158
+ note: entry.note,
159
+ program: entry.program,
160
+ scenario: entry.scenario
161
+ }))
162
+ return [
163
+ ...brief.scope.flatMap((item) =>
164
+ item.citations.map(
165
+ (citation): Located => ({
166
+ epicId,
167
+ approved,
168
+ disposition: "in-scope",
169
+ note: item.title,
170
+ program: citation.program,
171
+ scenario: citation.scenario
172
+ })
173
+ )
174
+ ),
175
+ ...disposed("dropped", brief.dropped),
176
+ ...disposed("provided", brief.provided),
177
+ ...disposed("deferred", brief.deferred)
178
+ ]
179
+ }
180
+
181
+ const unique = (values: ReadonlyArray<string>): ReadonlyArray<string> => [...new Set(values)]
182
+
183
+ const trimmed = (path: string): string => path.replace(/[\\/]+$/, "")
184
+ const lastSegment = (path: string): string => trimmed(path).split(/[\\/]/).at(-1) ?? ""
185
+
186
+ /**
187
+ * Whether two `Legacy:` paths name the same repository. The same checkout is
188
+ * written with or without a trailing slash, and lives under another home on a
189
+ * colleague's machine or in CI: the folder's name is what stays.
190
+ */
191
+ export const sameLegacy = (left: string, right: string): boolean =>
192
+ trimmed(left) === trimmed(right) ||
193
+ (lastSegment(left).length > 0 && lastSegment(left) === lastSegment(right))
194
+
195
+ const deliveryOf = (progress: EpicProgress | undefined): DeliveryState =>
196
+ progress === undefined
197
+ ? "approved"
198
+ : progress.landed
199
+ ? "landed"
200
+ : progress.merged > 0
201
+ ? "in-progress"
202
+ : progress.planned
203
+ ? "planned"
204
+ : "approved"
205
+
206
+ interface Settled {
207
+ readonly status: LedgerStatus
208
+ readonly owners: ReadonlyArray<string>
209
+ readonly claims: ReadonlyArray<Claim>
210
+ }
211
+
212
+ export const buildLedger = (inputs: LedgerInputs): CoverageLedger => {
213
+ const legacy = inputs.legacy
214
+ const skipped = inputs.briefs
215
+ .filter((entry) => legacy !== undefined && !sameLegacy(entry.brief.legacy, legacy))
216
+ .map((entry) => entry.epicId)
217
+ const read = inputs.briefs.filter((entry) => !skipped.includes(entry.epicId))
218
+ const briefById = new Map(read.map((entry) => [entry.epicId, entry.brief]))
219
+ const progressById = new Map(inputs.epics.map((epic) => [epic.epicId, epic]))
220
+ const known = new Map(inputs.pack.programs.map((program) => [program.name, program.scenarios]))
221
+
222
+ const bySlot = new Map<string, Array<Located>>()
223
+ const stale: Array<StaleClaim> = []
224
+ for (const entry of read) {
225
+ for (const claim of claimsOf(entry.epicId, entry.brief)) {
226
+ if (!(known.get(claim.program) ?? []).includes(claim.scenario)) {
227
+ stale.push({
228
+ epicId: claim.epicId,
229
+ program: claim.program,
230
+ scenario: claim.scenario,
231
+ disposition: claim.disposition
232
+ })
233
+ continue
234
+ }
235
+ const key = `${claim.program}\u0000${claim.scenario}`
236
+ bySlot.set(key, [...(bySlot.get(key) ?? []), claim])
237
+ }
238
+ }
239
+
240
+ /**
241
+ * The status the approved claims give a scenario. Deferrals bind nobody.
242
+ * Every other pair of claims is put to `crossProblem`, the rule the
243
+ * per-brief check uses: a clash nobody kept on purpose is a conflict;
244
+ * shared ownership needs one `keep:`; a contradiction is settled for the
245
+ * one brief that kept its decision, and the other claim becomes history.
246
+ */
247
+ const settle = (program: string, scenario: string, located: ReadonlyArray<Located>): Settled => {
248
+ const binding = located.filter((claim) => claim.approved && claim.disposition !== "deferred")
249
+ const deferrers = located.filter((claim) => claim.approved && claim.disposition === "deferred")
250
+ const kept = (mine: Located, theirs: Located): boolean => {
251
+ const brief = briefById.get(mine.epicId)
252
+ const problem = crossProblem(program, scenario, mine.disposition, {
253
+ epic: theirs.epicId,
254
+ disposition: theirs.disposition
255
+ })
256
+ return brief !== undefined && problem !== undefined && hasOverride(brief, problem)
257
+ }
258
+ const keptAgainst = new Map<Located, Array<string>>()
259
+ const overruled = new Set<Located>()
260
+ let disputed = false
261
+ for (let i = 0; i < binding.length; i += 1) {
262
+ for (let j = i + 1; j < binding.length; j += 1) {
263
+ const left = binding[i]
264
+ const right = binding[j]
265
+ if (left === undefined || right === undefined) continue
266
+ if (left.epicId === right.epicId) {
267
+ // One brief disposing of a scenario twice disagrees with itself.
268
+ if (left.disposition !== right.disposition) disputed = true
269
+ continue
270
+ }
271
+ const clash = crossProblem(program, scenario, left.disposition, {
272
+ epic: right.epicId,
273
+ disposition: right.disposition
274
+ })
275
+ if (clash === undefined) continue
276
+ const leftKeeps = kept(left, right)
277
+ const rightKeeps = kept(right, left)
278
+ if (leftKeeps) keptAgainst.set(left, [...(keptAgainst.get(left) ?? []), right.epicId])
279
+ if (rightKeeps) keptAgainst.set(right, [...(keptAgainst.get(right) ?? []), left.epicId])
280
+ if (clash.kind === "AlreadyOwned") {
281
+ if (!leftKeeps && !rightKeeps) disputed = true
282
+ } else if (leftKeeps === rightKeeps) {
283
+ // Nobody kept it, or both insist: still a contradiction.
284
+ disputed = true
285
+ } else {
286
+ overruled.add(leftKeeps ? right : left)
287
+ }
288
+ }
289
+ }
290
+ const survivors = binding.filter((claim) => !overruled.has(claim))
291
+ const conflict = disputed || (binding.length > 0 && survivors.length === 0)
292
+ const status: LedgerStatus =
293
+ located.filter((claim) => claim.approved).length === 0
294
+ ? located.length === 0
295
+ ? "unclaimed"
296
+ : "proposed"
297
+ : conflict
298
+ ? "conflict"
299
+ : (survivors[0]?.disposition ?? "deferred")
300
+ const standing = (claim: Located): boolean =>
301
+ !claim.approved
302
+ ? false
303
+ : conflict
304
+ ? claim.disposition !== "deferred"
305
+ : claim.disposition === "deferred"
306
+ ? status === "deferred"
307
+ : !overruled.has(claim)
308
+ const owners =
309
+ status === "conflict"
310
+ ? unique(binding.map((claim) => claim.epicId))
311
+ : status === "deferred"
312
+ ? unique(deferrers.map((claim) => claim.epicId))
313
+ : unique(survivors.map((claim) => claim.epicId))
314
+ return {
315
+ status,
316
+ owners: located.filter((claim) => claim.approved).length === 0 ? [] : owners,
317
+ claims: located.map((claim) => ({
318
+ epicId: claim.epicId,
319
+ approved: claim.approved,
320
+ disposition: claim.disposition,
321
+ note: claim.note,
322
+ keptAgainst: keptAgainst.get(claim) ?? [],
323
+ standing: standing(claim)
324
+ }))
325
+ }
326
+ }
327
+
328
+ const entries: Array<LedgerEntry> = []
329
+ for (const program of inputs.pack.programs) {
330
+ for (const scenario of program.scenarios) {
331
+ const located = bySlot.get(`${program.name}\u0000${scenario}`) ?? []
332
+ const { status, owners, claims } = settle(program.name, scenario, located)
333
+ const [owner] = owners
334
+ entries.push({
335
+ program: program.name,
336
+ scenario,
337
+ status,
338
+ claims,
339
+ owners,
340
+ ...(status === "in-scope" && owner !== undefined
341
+ ? { delivery: deliveryOf(progressById.get(owner)) }
342
+ : {})
343
+ })
344
+ }
345
+ }
346
+
347
+ const count = (list: ReadonlyArray<LedgerEntry>, status: LedgerStatus): number =>
348
+ list.filter((entry) => entry.status === status).length
349
+ const programs = inputs.pack.programs.map((program): ProgramTotals => {
350
+ const own = entries.filter((entry) => entry.program === program.name)
351
+ return {
352
+ program: program.name,
353
+ scenarios: own.length,
354
+ inScope: count(own, "in-scope"),
355
+ dropped: count(own, "dropped"),
356
+ provided: count(own, "provided"),
357
+ deferred: count(own, "deferred"),
358
+ proposed: count(own, "proposed"),
359
+ unclaimed: count(own, "unclaimed"),
360
+ conflicts: count(own, "conflict")
361
+ }
362
+ })
363
+ const accounted =
364
+ count(entries, "in-scope") + count(entries, "dropped") + count(entries, "provided")
365
+ return {
366
+ entries,
367
+ programs,
368
+ stale,
369
+ skipped,
370
+ briefs: read.map((entry) => ({
371
+ epicId: entry.epicId,
372
+ status: entry.brief.status,
373
+ ...(entry.brief.status === "approved"
374
+ ? { delivery: deliveryOf(progressById.get(entry.epicId)) }
375
+ : {})
376
+ })),
377
+ totals: {
378
+ scenarios: entries.length,
379
+ accounted,
380
+ delivered:
381
+ count(entries, "dropped") +
382
+ count(entries, "provided") +
383
+ entries.filter((entry) => entry.status === "in-scope" && entry.delivery === "landed")
384
+ .length,
385
+ remaining: entries.length - accounted,
386
+ conflicts: count(entries, "conflict")
387
+ }
388
+ }
389
+ }
390
+
391
+ /**
392
+ * What approved briefs decided, scenario by scenario: the view another brief
393
+ * inherits. Only claims that still stand: an overruled decision, or a
394
+ * deferral another epic took up, binds nobody.
395
+ */
396
+ export const inheritedFrom = (ledger: CoverageLedger): ReadonlyArray<InheritedDecision> =>
397
+ ledger.entries.flatMap((entry) =>
398
+ entry.claims
399
+ .filter((claim) => claim.approved && claim.standing)
400
+ .map((claim) => ({
401
+ program: entry.program,
402
+ scenario: entry.scenario,
403
+ epic: claim.epicId,
404
+ disposition: claim.disposition,
405
+ note: claim.note,
406
+ ...(claim.keptAgainst.length === 0 ? {} : { keptAgainst: claim.keptAgainst })
407
+ }))
408
+ )
409
+
410
+ // ---- The report -----------------------------------------------------------------
411
+
412
+ const percent = (part: number, whole: number): string =>
413
+ // Floored: 299 of 300 is 99%, never a whole it has not reached.
414
+ `${whole === 0 ? 0 : Math.floor((part / whole) * 100)}%`
415
+
416
+ const plural = (count: number, word: string): string => `${count} ${word}${count === 1 ? "" : "s"}`
417
+
418
+ /** `142 scenarios · 96 accounted for (68%) · 58 delivered · 46 remaining · 2 conflicts`. */
419
+ export const ledgerHeadline = (ledger: CoverageLedger): string => {
420
+ const totals = ledger.totals
421
+ return [
422
+ plural(totals.scenarios, "scenario"),
423
+ `${totals.accounted} accounted for (${percent(totals.accounted, totals.scenarios)})`,
424
+ `${totals.delivered} delivered`,
425
+ `${totals.remaining} remaining`,
426
+ plural(totals.conflicts, "conflict")
427
+ ].join(" · ")
428
+ }
429
+
430
+ const deliveryWords: Readonly<Record<DeliveryState, string>> = {
431
+ approved: "approved",
432
+ planned: "planned",
433
+ "in-progress": "in progress",
434
+ landed: "landed"
435
+ }
436
+
437
+ const claimText = (claim: Claim): string =>
438
+ `${claim.epicId}: ${dispositionWords[claim.disposition]}${claim.note.length === 0 ? "" : ` (${claim.note})`}`
439
+
440
+ const titled = (title: string, lines: ReadonlyArray<string>): ReadonlyArray<string> =>
441
+ lines.length === 0 ? [] : [`## ${title}`, "", ...lines, ""]
442
+
443
+ export interface RenderLedgerOptions {
444
+ /** The legacy repository the pack was read from. */
445
+ readonly legacy: string
446
+ /** Brief files that did not parse, with the first thing wrong in each. */
447
+ readonly unreadable?: ReadonlyArray<{ readonly dir: string; readonly reason: string }>
448
+ }
449
+
450
+ /**
451
+ * The ledger as a page a person reads: the headline, a table per program,
452
+ * then only the lists that need someone: conflicts, scenarios still
453
+ * deferred, unclaimed ones, what drafts propose, stale citations.
454
+ */
455
+ export const renderLedger = (ledger: CoverageLedger, options: RenderLedgerOptions): string => {
456
+ const of = (status: LedgerStatus): ReadonlyArray<LedgerEntry> =>
457
+ ledger.entries.filter((entry) => entry.status === status)
458
+ const name = (entry: LedgerEntry): string => `${entry.program} › ${entry.scenario}`
459
+ const unclaimed = ledger.programs.flatMap((program) => {
460
+ const open = of("unclaimed").filter((entry) => entry.program === program.program)
461
+ return open.length === 0
462
+ ? []
463
+ : [`- ${program.program}: ${open.map((entry) => entry.scenario).join("; ")}`]
464
+ })
465
+ return [
466
+ "# Coverage ledger",
467
+ "",
468
+ ledgerHeadline(ledger),
469
+ "",
470
+ `Legacy: ${options.legacy}`,
471
+ "",
472
+ "Derived from the epic briefs of this repository and the legacy extract pack. Regenerate",
473
+ "it with `epic-design --coverage`; to change it, change a brief.",
474
+ "",
475
+ "## Briefs",
476
+ "",
477
+ ...(ledger.briefs.length === 0
478
+ ? ["no epic brief yet"]
479
+ : ledger.briefs.map((brief) => {
480
+ const delivery =
481
+ brief.delivery === undefined || brief.delivery === "approved"
482
+ ? ""
483
+ : `, ${deliveryWords[brief.delivery]}`
484
+ return `- ${brief.epicId} — ${brief.status}${delivery}`
485
+ })),
486
+ "",
487
+ "## Programs",
488
+ "",
489
+ "| Program | Scenarios | In scope | Dropped | Provided | Deferred | Proposed | Unclaimed | Conflicts | Accounted |",
490
+ "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |",
491
+ ...ledger.programs.map(
492
+ (program) =>
493
+ `| ${[
494
+ program.program,
495
+ program.scenarios,
496
+ program.inScope,
497
+ program.dropped,
498
+ program.provided,
499
+ program.deferred,
500
+ program.proposed,
501
+ program.unclaimed,
502
+ program.conflicts,
503
+ percent(program.inScope + program.dropped + program.provided, program.scenarios)
504
+ ].join(" | ")} |`
505
+ ),
506
+ "",
507
+ ...titled(
508
+ "Conflicts",
509
+ of("conflict").map(
510
+ (entry) =>
511
+ `- ${name(entry)} — ${entry.claims
512
+ .filter((claim) => claim.approved)
513
+ .map(claimText)
514
+ .join("; ")}`
515
+ )
516
+ ),
517
+ ...titled(
518
+ "Deferred, still waiting",
519
+ of("deferred").map((entry) => {
520
+ const claim = entry.claims.find((c) => c.approved && c.disposition === "deferred")
521
+ return `- ${name(entry)} — deferred by ${claim?.epicId ?? entry.owners.join(", ")}${
522
+ claim === undefined || claim.note.length === 0 ? "" : `: ${claim.note}`
523
+ }`
524
+ })
525
+ ),
526
+ ...titled("Unclaimed", unclaimed),
527
+ ...titled(
528
+ "Proposed by drafts",
529
+ of("proposed").map((entry) => `- ${name(entry)} — ${entry.claims.map(claimText).join("; ")}`)
530
+ ),
531
+ ...titled(
532
+ "Stale citations",
533
+ ledger.stale.map(
534
+ (claim) =>
535
+ `- ${claim.epicId} cites ${claim.program} › ${claim.scenario} (${dispositionWords[claim.disposition]}), which the pack no longer has`
536
+ )
537
+ ),
538
+ ...titled(
539
+ "Skipped briefs",
540
+ ledger.skipped.map((epicId) => `- ${epicId}`)
541
+ ),
542
+ ...titled(
543
+ "Briefs that could not be read",
544
+ (options.unreadable ?? []).map((brief) => `- ${brief.dir}: ${brief.reason}`)
545
+ )
546
+ ]
547
+ .join("\n")
548
+ .replace(/\n+$/, "\n")
549
+ }