@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,1014 @@
1
+ import * as Effect from "effect/Effect"
2
+ import * as Schema from "effect/Schema"
3
+ import type { JsonSchema } from "@llm4ts/core/Models"
4
+ import { makeOpenPointsCollector, OpenPoint } from "./Decisions.ts"
5
+ import { EpicBriefInvalid } from "./FlowError.ts"
6
+
7
+ /**
8
+ * The epic brief: what `epic-design` writes and `epic-stories` plans from.
9
+ * The file is the state. It says what an epic delivers and, for every
10
+ * scenario of every legacy program it considered, whether that behaviour is
11
+ * carried over, dropped, already provided by the target, or deferred, each
12
+ * with its evidence in the extract pack (`PROGRAM › scenario title`). A human
13
+ * edits it, answers its open points, writes feedback, and approves it by
14
+ * setting `Status: approved`.
15
+ */
16
+
17
+ export const BriefStatus = Schema.Literals(["draft", "approved"])
18
+ export type BriefStatus = typeof BriefStatus.Type
19
+
20
+ /** Legacy evidence: a scenario of a program, both named exactly as in the pack. */
21
+ export class Citation extends Schema.Class<Citation>("Citation")({
22
+ program: Schema.String,
23
+ scenario: Schema.String
24
+ }) {}
25
+
26
+ export class ScopeItem extends Schema.Class<ScopeItem>("ScopeItem")({
27
+ title: Schema.String,
28
+ citations: Schema.Array(Citation),
29
+ /** Why an item with no citation exists: behaviour with no legacy counterpart. */
30
+ newBehaviour: Schema.optionalKey(Schema.String)
31
+ }) {}
32
+
33
+ /** A scenario not carried over; `note` is the reason (dropped), the pointer (provided) or the note (deferred). */
34
+ export class Disposed extends Schema.Class<Disposed>("Disposed")({
35
+ program: Schema.String,
36
+ scenario: Schema.String,
37
+ note: Schema.String
38
+ }) {}
39
+
40
+ export class ConsideredProgram extends Schema.Class<ConsideredProgram>("ConsideredProgram")({
41
+ name: Schema.String,
42
+ reason: Schema.String
43
+ }) {}
44
+
45
+ export class EpicBrief extends Schema.Class<EpicBrief>("EpicBrief")({
46
+ epicId: Schema.String,
47
+ status: BriefStatus,
48
+ /** The epic as the user asked for it; becomes the story plan's `epic`. */
49
+ request: Schema.String,
50
+ legacy: Schema.String,
51
+ goal: Schema.String,
52
+ programs: Schema.Array(ConsideredProgram),
53
+ scope: Schema.Array(ScopeItem),
54
+ dropped: Schema.Array(Disposed),
55
+ provided: Schema.Array(Disposed),
56
+ deferred: Schema.Array(Disposed),
57
+ constraints: Schema.String,
58
+ openPoints: Schema.Array(OpenPoint),
59
+ feedback: Schema.String
60
+ }) {}
61
+
62
+ /** Open points with no answer, or an empty one. */
63
+ export const unanswered = (brief: EpicBrief): ReadonlyArray<OpenPoint> =>
64
+ brief.openPoints.filter((point) => (point.answer ?? "").trim().length === 0)
65
+
66
+ // ---- Rendering ----------------------------------------------------------------
67
+
68
+ const cite = " › "
69
+ const dash = " — "
70
+
71
+ const sectionTitles = {
72
+ goal: "Goal",
73
+ programs: "Legacy programs considered",
74
+ scope: "In scope",
75
+ dropped: "Dropped",
76
+ provided: "Provided by the target",
77
+ deferred: "Deferred",
78
+ constraints: "Constraints",
79
+ openPoints: "Open points",
80
+ feedback: "Feedback"
81
+ } as const
82
+ type SectionKey = keyof typeof sectionTitles
83
+
84
+ /** A scenario title holding the note separator is quoted, so the note still splits cleanly. */
85
+ const quoted = (scenario: string): string =>
86
+ scenario.includes(dash.trim()) ? `\`${scenario}\`` : scenario
87
+
88
+ const unquoted = (scenario: string): string =>
89
+ scenario.length > 1 && scenario.startsWith("`") && scenario.endsWith("`")
90
+ ? scenario.slice(1, -1)
91
+ : scenario
92
+
93
+ const renderDisposed = (entry: Disposed): string =>
94
+ `- ${entry.program}${cite}${quoted(entry.scenario)}${entry.note.length === 0 ? "" : `${dash}${entry.note}`}`
95
+
96
+ const section = (key: SectionKey, body: ReadonlyArray<string>): ReadonlyArray<string> => [
97
+ `## ${sectionTitles[key]}`,
98
+ "",
99
+ ...(body.length === 0 ? [] : [...body, ""])
100
+ ]
101
+
102
+ const prose = (text: string): ReadonlyArray<string> =>
103
+ text.trim().length === 0 ? [] : text.trim().split("\n")
104
+
105
+ export const renderEpicBrief = (brief: EpicBrief): string =>
106
+ [
107
+ `# Epic brief: ${brief.epicId}`,
108
+ "",
109
+ `Status: ${brief.status}`,
110
+ `Request: ${brief.request}`,
111
+ `Legacy: ${brief.legacy}`,
112
+ "",
113
+ ...section("goal", prose(brief.goal)),
114
+ ...section(
115
+ "programs",
116
+ brief.programs.map(
117
+ (program) =>
118
+ `- ${program.name}${program.reason.length === 0 ? "" : `${dash}${program.reason}`}`
119
+ )
120
+ ),
121
+ ...section(
122
+ "scope",
123
+ brief.scope.flatMap((item) => [
124
+ `- ${item.title}`,
125
+ ...item.citations.map((citation) => ` - ${citation.program}${cite}${citation.scenario}`),
126
+ ...(item.newBehaviour === undefined ? [] : [` - new: ${item.newBehaviour}`])
127
+ ])
128
+ ),
129
+ ...section("dropped", brief.dropped.map(renderDisposed)),
130
+ ...section("provided", brief.provided.map(renderDisposed)),
131
+ ...section("deferred", brief.deferred.map(renderDisposed)),
132
+ ...section("constraints", prose(brief.constraints)),
133
+ ...section(
134
+ "openPoints",
135
+ brief.openPoints.flatMap((point) => {
136
+ const answer = (point.answer ?? "").trim()
137
+ return [
138
+ `${point.number}. ${point.question}`,
139
+ ` answer:${answer.length === 0 ? "" : ` ${answer}`}`
140
+ ]
141
+ })
142
+ ),
143
+ ...section("feedback", prose(brief.feedback))
144
+ ]
145
+ .join("\n")
146
+ .replace(/\n+$/, "\n")
147
+
148
+ // ---- Parsing ------------------------------------------------------------------
149
+
150
+ const sectionOf = (title: string): SectionKey | undefined => {
151
+ for (const key of Object.keys(sectionTitles)) {
152
+ if (isSectionKey(key) && sectionTitles[key].toLowerCase() === title.toLowerCase()) return key
153
+ }
154
+ return undefined
155
+ }
156
+
157
+ const isSectionKey = (key: string): key is SectionKey => Object.hasOwn(sectionTitles, key)
158
+
159
+ const splitFirst = (text: string, separator: string): readonly [string, string | undefined] => {
160
+ const at = text.indexOf(separator)
161
+ return at < 0
162
+ ? [text.trim(), undefined]
163
+ : [text.slice(0, at).trim(), text.slice(at + separator.length).trim()]
164
+ }
165
+
166
+ /** `PROGRAM › scenario — note` (the note optional); undefined when there is no `›`. */
167
+ const parseDisposed = (text: string): Disposed | undefined => {
168
+ const [program, rest] = splitFirst(text, cite.trim())
169
+ if (rest === undefined || program.length === 0) return undefined
170
+ const close = rest.startsWith("`") ? rest.indexOf("`", 1) : -1
171
+ if (close > 0) {
172
+ const tail = rest.slice(close + 1).trim()
173
+ const note = tail.startsWith(dash.trim()) ? tail.slice(dash.trim().length).trim() : tail
174
+ return Disposed.make({ program, scenario: rest.slice(1, close), note })
175
+ }
176
+ const [scenario, note] = splitFirst(rest, dash)
177
+ return scenario.length === 0 ? undefined : Disposed.make({ program, scenario, note: note ?? "" })
178
+ }
179
+
180
+ const headerLine = /^(Status|Request|Legacy):\s*(.*)$/i
181
+
182
+ export const parseEpicBrief = Effect.fn("@llm4ts/flow/EpicBrief.parse")(function* (
183
+ markdown: string,
184
+ path?: string
185
+ ): Effect.fn.Return<EpicBrief, EpicBriefInvalid> {
186
+ const violations: Array<string> = []
187
+ const header: Record<string, string> = {}
188
+ let epicId: string | undefined
189
+ let status: BriefStatus | undefined
190
+ const proseLines: Record<"goal" | "constraints" | "feedback", Array<string>> = {
191
+ goal: [],
192
+ constraints: [],
193
+ feedback: []
194
+ }
195
+ const programs: Array<ConsideredProgram> = []
196
+ const scope: Array<{ title: string; citations: Array<Citation>; newBehaviour?: string }> = []
197
+ const disposed: Record<"dropped" | "provided" | "deferred", Array<Disposed>> = {
198
+ dropped: [],
199
+ provided: [],
200
+ deferred: []
201
+ }
202
+ const points = makeOpenPointsCollector()
203
+ let current: SectionKey | "header" | "unknown" = "header"
204
+ let fenced = false
205
+ let fenceLine = 0
206
+
207
+ const lines = markdown.split(/\r?\n/)
208
+ for (let index = 0; index < lines.length; index += 1) {
209
+ const number = index + 1
210
+ const raw = lines[index] ?? ""
211
+ const trimmed = raw.trim()
212
+ const isProse = current === "goal" || current === "constraints" || current === "feedback"
213
+ if (trimmed.startsWith("```")) {
214
+ fenced = !fenced
215
+ if (fenced) fenceLine = number
216
+ if (current === "goal" || current === "constraints" || current === "feedback") {
217
+ proseLines[current].push(raw)
218
+ }
219
+ continue
220
+ }
221
+ if (!fenced && trimmed.startsWith("## ")) {
222
+ const title = trimmed.slice(3).trim()
223
+ const key = sectionOf(title)
224
+ if (key === undefined) {
225
+ violations.push(`line ${number}: unknown section "${title}"`)
226
+ current = "unknown"
227
+ } else {
228
+ current = key
229
+ }
230
+ continue
231
+ }
232
+ if (current === "goal" || current === "constraints" || current === "feedback") {
233
+ proseLines[current].push(raw)
234
+ continue
235
+ }
236
+ if (isProse || fenced || trimmed.length === 0 || current === "unknown") {
237
+ continue
238
+ }
239
+ if (current === "header") {
240
+ const title = /^#\s+Epic brief:\s*(.+)$/i.exec(trimmed)
241
+ if (title?.[1] !== undefined) {
242
+ epicId = title[1].trim()
243
+ continue
244
+ }
245
+ const field = headerLine.exec(trimmed)
246
+ if (field?.[1] !== undefined) {
247
+ const name = field[1].toLowerCase()
248
+ const value = (field[2] ?? "").trim()
249
+ if (name === "status") {
250
+ if (value === "draft" || value === "approved") {
251
+ status = value
252
+ } else {
253
+ violations.push(`line ${number}: Status is \`draft\` or \`approved\`, got: ${value}`)
254
+ status = "draft"
255
+ }
256
+ } else {
257
+ header[name] = value
258
+ }
259
+ }
260
+ continue
261
+ }
262
+ if (current === "openPoints") {
263
+ const problem = points.add(number, trimmed)
264
+ if (problem !== undefined) violations.push(problem)
265
+ continue
266
+ }
267
+ const nested = /^\s+-\s+(.*)$/.exec(raw)
268
+ const top = /^-\s+(.*)$/.exec(raw)
269
+ if (current === "scope") {
270
+ if (top?.[1] !== undefined) {
271
+ scope.push({ title: top[1].trim(), citations: [] })
272
+ continue
273
+ }
274
+ const item = scope.at(-1)
275
+ if (nested?.[1] === undefined || item === undefined) {
276
+ violations.push(
277
+ `line ${number}: an in-scope entry is \`- <title>\` with indented \`- PROGRAM › scenario\` lines below it, got: ${trimmed}`
278
+ )
279
+ continue
280
+ }
281
+ const text = nested[1].trim()
282
+ const fresh = /^new:\s*(.*)$/i.exec(text)
283
+ if (fresh !== null) {
284
+ item.newBehaviour = (fresh[1] ?? "").trim()
285
+ continue
286
+ }
287
+ const [program, scenario] = splitFirst(text, cite.trim())
288
+ if (scenario === undefined || program.length === 0 || scenario.length === 0) {
289
+ violations.push(`line ${number}: a citation is \`PROGRAM › scenario title\`, got: ${text}`)
290
+ continue
291
+ }
292
+ item.citations.push(Citation.make({ program, scenario: unquoted(scenario) }))
293
+ continue
294
+ }
295
+ const bullet = (top ?? nested)?.[1]?.trim()
296
+ if (bullet === undefined) {
297
+ violations.push(`line ${number}: expected a \`- \` entry, got: ${trimmed}`)
298
+ continue
299
+ }
300
+ if (current === "programs") {
301
+ const [name, reason] = splitFirst(bullet, dash)
302
+ programs.push(ConsideredProgram.make({ name, reason: reason ?? "" }))
303
+ continue
304
+ }
305
+ const entry = parseDisposed(bullet)
306
+ if (entry === undefined) {
307
+ violations.push(
308
+ `line ${number}: an entry here is \`- PROGRAM › scenario title — note\`, got: ${bullet}`
309
+ )
310
+ continue
311
+ }
312
+ disposed[current].push(entry)
313
+ }
314
+
315
+ if (epicId === undefined) violations.push("missing the `# Epic brief: <id>` title")
316
+ if (status === undefined) violations.push("missing the `Status:` line")
317
+ if (fenced) {
318
+ // Everything after it was read as code: nothing below that line can be trusted.
319
+ violations.push(`line ${fenceLine}: the code fence opened here is never closed`)
320
+ }
321
+ if (header.request === undefined) violations.push("missing the `Request:` line")
322
+ if (header.legacy === undefined) violations.push("missing the `Legacy:` line")
323
+ if (violations.length > 0 || epicId === undefined || status === undefined) {
324
+ return yield* EpicBriefInvalid.make({
325
+ ...(path === undefined ? {} : { path }),
326
+ violations
327
+ })
328
+ }
329
+ const text = (key: "goal" | "constraints" | "feedback"): string =>
330
+ proseLines[key].join("\n").trim()
331
+ return EpicBrief.make({
332
+ epicId,
333
+ status,
334
+ request: header.request ?? "",
335
+ legacy: header.legacy ?? "",
336
+ goal: text("goal"),
337
+ programs,
338
+ scope: scope.map((item) =>
339
+ ScopeItem.make({
340
+ title: item.title,
341
+ citations: item.citations,
342
+ ...(item.newBehaviour === undefined ? {} : { newBehaviour: item.newBehaviour })
343
+ })
344
+ ),
345
+ dropped: disposed.dropped,
346
+ provided: disposed.provided,
347
+ deferred: disposed.deferred,
348
+ constraints: text("constraints"),
349
+ openPoints: points.points,
350
+ feedback: text("feedback")
351
+ })
352
+ })
353
+
354
+ // ---- Normalizing --------------------------------------------------------------
355
+
356
+ const oneLine = (text: string): string => text.replace(/\s*\r?\n\s*/g, " ").trim()
357
+
358
+ /** Prose the file format can hold: no `## ` line of its own, no fence left open. */
359
+ const safeProse = (text: string): string => {
360
+ let fenced = false
361
+ const lines = text.split(/\r?\n/).map((line) => {
362
+ if (line.trim().startsWith("```")) {
363
+ fenced = !fenced
364
+ return line
365
+ }
366
+ return !fenced && /^\s*##\s/.test(line) ? line.replace(/^(\s*)##\s/, "$1### ") : line
367
+ })
368
+ return (fenced ? [...lines, "```"] : lines).join("\n")
369
+ }
370
+
371
+ const safeDisposed = (entry: Disposed): Disposed =>
372
+ Disposed.make({
373
+ program: oneLine(entry.program),
374
+ scenario: oneLine(entry.scenario),
375
+ note: oneLine(entry.note)
376
+ })
377
+
378
+ /**
379
+ * The brief as the file can hold it. A model's proposal may carry a newline
380
+ * in a title, a `## ` line in the goal, an unclosed fence; written as they
381
+ * are, those would parse back as something else on the next run. Fields that
382
+ * live on one line are folded onto one, prose is made safe for the section
383
+ * grammar, and nothing else changes.
384
+ */
385
+ export const normalizeBrief = (brief: EpicBrief): EpicBrief =>
386
+ EpicBrief.make({
387
+ epicId: oneLine(brief.epicId),
388
+ status: brief.status,
389
+ request: oneLine(brief.request),
390
+ legacy: oneLine(brief.legacy),
391
+ goal: safeProse(brief.goal),
392
+ programs: brief.programs.map((program) =>
393
+ ConsideredProgram.make({ name: oneLine(program.name), reason: oneLine(program.reason) })
394
+ ),
395
+ scope: brief.scope.map((item) =>
396
+ ScopeItem.make({
397
+ title: oneLine(item.title).replace(/^-+\s*/, ""),
398
+ citations: item.citations.map((citation) =>
399
+ Citation.make({
400
+ program: oneLine(citation.program),
401
+ scenario: oneLine(citation.scenario)
402
+ })
403
+ ),
404
+ ...(item.newBehaviour === undefined ? {} : { newBehaviour: oneLine(item.newBehaviour) })
405
+ })
406
+ ),
407
+ dropped: brief.dropped.map(safeDisposed),
408
+ provided: brief.provided.map(safeDisposed),
409
+ deferred: brief.deferred.map(safeDisposed),
410
+ constraints: safeProse(brief.constraints),
411
+ openPoints: brief.openPoints.map((point) =>
412
+ OpenPoint.make({
413
+ number: point.number,
414
+ question: oneLine(point.question),
415
+ ...(point.answer === undefined ? {} : { answer: oneLine(point.answer) })
416
+ })
417
+ ),
418
+ feedback: safeProse(brief.feedback)
419
+ })
420
+
421
+ // ---- Checks -------------------------------------------------------------------
422
+
423
+ export interface PackProgram {
424
+ readonly name: string
425
+ /** The first paragraph of the program's spec. */
426
+ readonly summary: string
427
+ readonly scenarios: ReadonlyArray<string>
428
+ }
429
+
430
+ /** A disposition `modernize-refine` recorded in the legacy pack's decisions.md. */
431
+ export interface RefineDisposition {
432
+ readonly program: string
433
+ /** Absent: the disposition covers every scenario of the program. */
434
+ readonly scenario?: string
435
+ readonly disposition: "drop" | "provided" | "defer" | "wrap"
436
+ }
437
+
438
+ /** What the checks need from the extract pack, built by the flow from its files. */
439
+ export interface PackIndex {
440
+ readonly programs: ReadonlyArray<PackProgram>
441
+ readonly refine: ReadonlyArray<RefineDisposition>
442
+ }
443
+
444
+ export const BriefProblem = Schema.Union([
445
+ Schema.Struct({ kind: Schema.Literal("UnknownProgram"), program: Schema.String }),
446
+ Schema.Struct({
447
+ kind: Schema.Literal("UnknownScenario"),
448
+ program: Schema.String,
449
+ scenario: Schema.String,
450
+ closest: Schema.optionalKey(Schema.String)
451
+ }),
452
+ Schema.Struct({
453
+ kind: Schema.Literal("MissingPointer"),
454
+ program: Schema.String,
455
+ scenario: Schema.String,
456
+ pointer: Schema.String
457
+ }),
458
+ Schema.Struct({ kind: Schema.Literal("MissingReason"), where: Schema.String }),
459
+ Schema.Struct({
460
+ kind: Schema.Literal("DuplicateDisposition"),
461
+ program: Schema.String,
462
+ scenario: Schema.String,
463
+ lists: Schema.Array(Schema.String)
464
+ }),
465
+ Schema.Struct({
466
+ kind: Schema.Literal("Unaccounted"),
467
+ program: Schema.String,
468
+ scenario: Schema.String
469
+ }),
470
+ Schema.Struct({
471
+ kind: Schema.Literal("RefineConflict"),
472
+ program: Schema.String,
473
+ scenario: Schema.String,
474
+ disposition: Schema.String
475
+ }),
476
+ Schema.Struct({
477
+ kind: Schema.Literal("AlreadyOwned"),
478
+ program: Schema.String,
479
+ scenario: Schema.String,
480
+ /** The approved epic that already has the scenario in scope. */
481
+ epic: Schema.String
482
+ }),
483
+ Schema.Struct({
484
+ kind: Schema.Literal("ContradictsBrief"),
485
+ program: Schema.String,
486
+ scenario: Schema.String,
487
+ /** The approved epic that decided otherwise. */
488
+ epic: Schema.String,
489
+ /** This brief's disposition, in words. */
490
+ here: Schema.String,
491
+ /** The other epic's disposition, in words. */
492
+ disposition: Schema.String
493
+ }),
494
+ Schema.Struct({
495
+ kind: Schema.Literal("ApprovedWithOpenPoints"),
496
+ numbers: Schema.Array(Schema.Int)
497
+ })
498
+ ])
499
+ export type BriefProblem = typeof BriefProblem.Type
500
+
501
+ export const renderBriefProblem = (problem: BriefProblem): string => {
502
+ switch (problem.kind) {
503
+ case "UnknownProgram":
504
+ return `${problem.program} is not a program of the extract pack`
505
+ case "UnknownScenario":
506
+ return `${problem.program} has no scenario "${problem.scenario}"${
507
+ problem.closest === undefined ? "" : ` (did you mean "${problem.closest}"?)`
508
+ }`
509
+ case "MissingPointer":
510
+ return `${problem.program}${cite}${problem.scenario} is marked provided by "${problem.pointer}", which does not exist in the target`
511
+ case "MissingReason":
512
+ return `${problem.where} needs a reason`
513
+ case "DuplicateDisposition":
514
+ return `${problem.program}${cite}${problem.scenario} appears in several lists: ${problem.lists.join(", ")}`
515
+ case "Unaccounted":
516
+ return `${problem.program}${cite}${problem.scenario} has no disposition: put it in scope, or drop, mark provided or defer it`
517
+ case "RefineConflict":
518
+ return `${problem.program}${cite}${problem.scenario} is in scope, but modernize-refine marked it ${problem.disposition}; answer \`keep: <why>\` to keep it in scope, or move it out`
519
+ case "AlreadyOwned":
520
+ return `${problem.program}${cite}${problem.scenario} is in scope here and in epic ${problem.epic}; answer \`keep: <why>\` to share it, or move it out`
521
+ case "ContradictsBrief":
522
+ return `${problem.program}${cite}${problem.scenario} is ${problem.here} here, but epic ${problem.epic} has it ${problem.disposition}; answer \`keep: <why>\` to keep this brief's decision, or follow the other`
523
+ case "ApprovedWithOpenPoints":
524
+ return `the brief is approved with unanswered open points: ${problem.numbers.join(", ")}`
525
+ }
526
+ }
527
+
528
+ /** How a brief disposes of a scenario. */
529
+ export type BriefDisposition = "in-scope" | "dropped" | "provided" | "deferred"
530
+
531
+ export const dispositionWords: Readonly<Record<BriefDisposition, string>> = {
532
+ "in-scope": "in scope",
533
+ dropped: "dropped",
534
+ provided: "provided",
535
+ deferred: "deferred"
536
+ }
537
+
538
+ /** What an approved brief of another epic decided about a scenario (the coverage ledger). */
539
+ export interface InheritedDecision {
540
+ readonly program: string
541
+ readonly scenario: string
542
+ readonly epic: string
543
+ readonly disposition: BriefDisposition
544
+ readonly note: string
545
+ /** The epics this decision was kept against on purpose (`keep: <why>` in that brief). */
546
+ readonly keptAgainst?: ReadonlyArray<string>
547
+ }
548
+
549
+ /**
550
+ * The one rule for two briefs on one scenario, read from the side of the
551
+ * brief that has `here`. A deferred scenario is free on either side; two
552
+ * briefs that agree are fine; both in scope is ownership shared or disputed;
553
+ * anything else is a contradiction. The ledger and the per-brief check both
554
+ * use it, so they cannot disagree about what a conflict is.
555
+ */
556
+ export const crossProblem = (
557
+ program: string,
558
+ scenario: string,
559
+ here: BriefDisposition,
560
+ other: { readonly epic: string; readonly disposition: BriefDisposition }
561
+ ): BriefProblem | undefined => {
562
+ if (here === "deferred" || other.disposition === "deferred") return undefined
563
+ if (here === other.disposition) {
564
+ return here === "in-scope"
565
+ ? { kind: "AlreadyOwned", program, scenario, epic: other.epic }
566
+ : undefined
567
+ }
568
+ return {
569
+ kind: "ContradictsBrief",
570
+ program,
571
+ scenario,
572
+ epic: other.epic,
573
+ here: dispositionWords[here],
574
+ disposition: dispositionWords[other.disposition]
575
+ }
576
+ }
577
+
578
+ /**
579
+ * Whether the brief overrides a check on purpose: the check's own `[check]`
580
+ * question, answered `keep: <why>`. Any other answer, or an answer to
581
+ * another question, overrides nothing.
582
+ */
583
+ export const hasOverride = (brief: EpicBrief, problem: BriefProblem): boolean =>
584
+ brief.openPoints.some(
585
+ (point) =>
586
+ point.question === `${checkMark} ${renderBriefProblem(problem)}` &&
587
+ (point.answer ?? "").trim().toLowerCase().startsWith("keep")
588
+ )
589
+
590
+ /** The pointers of the provided entries: what the caller verifies before checking. */
591
+ export const providedPointers = (brief: EpicBrief): ReadonlyArray<string> => [
592
+ ...new Set(brief.provided.map((entry) => entry.note).filter((note) => note.length > 0))
593
+ ]
594
+
595
+ const loose = (text: string): string => text.trim().replace(/\s+/g, " ").toLowerCase()
596
+ const slot = (program: string, scenario: string): string => `${program}\u0000${scenario}`
597
+
598
+ export interface BriefCheckInputs {
599
+ readonly pack: PackIndex
600
+ /** The provided pointers that were found in the target (or its pack). */
601
+ readonly pointers: ReadonlySet<string>
602
+ /**
603
+ * What approved briefs of other epics decided (the coverage ledger). Their
604
+ * decisions are inherited: they count towards completeness, and deciding
605
+ * otherwise here is a problem. Absent: the brief is checked on its own.
606
+ */
607
+ readonly others?: ReadonlyArray<InheritedDecision>
608
+ /** This brief's epic, as the other briefs name it. Default: the brief's own id. */
609
+ readonly epicId?: string
610
+ }
611
+
612
+ /** Every problem of a brief against the pack, all at once; empty means acceptable. */
613
+ export const checkEpicBrief = (
614
+ brief: EpicBrief,
615
+ inputs: BriefCheckInputs
616
+ ): ReadonlyArray<BriefProblem> => {
617
+ const problems: Array<BriefProblem> = []
618
+ const programs = new Map(inputs.pack.programs.map((program) => [program.name, program]))
619
+ const lists = new Map<string, Array<string>>()
620
+ const place = (list: string, program: string, scenario: string): void => {
621
+ const known = programs.get(program)
622
+ if (known === undefined) {
623
+ if (!problems.some((p) => p.kind === "UnknownProgram" && p.program === program)) {
624
+ problems.push({ kind: "UnknownProgram", program })
625
+ }
626
+ return
627
+ }
628
+ if (!known.scenarios.includes(scenario)) {
629
+ const closest = known.scenarios.find((title) => loose(title) === loose(scenario))
630
+ problems.push({
631
+ kind: "UnknownScenario",
632
+ program,
633
+ scenario,
634
+ ...(closest === undefined ? {} : { closest })
635
+ })
636
+ return
637
+ }
638
+ const key = slot(program, scenario)
639
+ const seen = lists.get(key) ?? []
640
+ if (!seen.includes(list)) lists.set(key, [...seen, list])
641
+ }
642
+
643
+ for (const item of brief.scope) {
644
+ if (item.citations.length === 0 && (item.newBehaviour ?? "").trim().length === 0) {
645
+ problems.push({
646
+ kind: "MissingReason",
647
+ where: `in-scope item "${item.title}" cites nothing and`
648
+ })
649
+ }
650
+ for (const citation of item.citations) place("in scope", citation.program, citation.scenario)
651
+ }
652
+ for (const entry of brief.dropped) {
653
+ place("dropped", entry.program, entry.scenario)
654
+ if (entry.note.trim().length === 0) {
655
+ problems.push({
656
+ kind: "MissingReason",
657
+ where: `dropped ${entry.program}${cite}${entry.scenario}`
658
+ })
659
+ }
660
+ }
661
+ for (const entry of brief.provided) {
662
+ place("provided", entry.program, entry.scenario)
663
+ if (!inputs.pointers.has(entry.note)) {
664
+ problems.push({
665
+ kind: "MissingPointer",
666
+ program: entry.program,
667
+ scenario: entry.scenario,
668
+ pointer: entry.note
669
+ })
670
+ }
671
+ }
672
+ for (const entry of brief.deferred) {
673
+ place("deferred", entry.program, entry.scenario)
674
+ if (entry.note.trim().length === 0) {
675
+ problems.push({
676
+ kind: "MissingReason",
677
+ where: `deferred ${entry.program}${cite}${entry.scenario}`
678
+ })
679
+ }
680
+ }
681
+
682
+ for (const [key, where] of lists) {
683
+ if (where.length > 1) {
684
+ const [program = "", scenario = ""] = key.split("\u0000")
685
+ problems.push({ kind: "DuplicateDisposition", program, scenario, lists: where })
686
+ }
687
+ }
688
+ const inherited = new Map<string, Array<InheritedDecision>>()
689
+ for (const decision of inputs.others ?? []) {
690
+ const key = slot(decision.program, decision.scenario)
691
+ inherited.set(key, [...(inherited.get(key) ?? []), decision])
692
+ }
693
+ // Complete means every program the brief touches: the ones it lists as
694
+ // considered, and any it cites or disposes of without listing.
695
+ const touched = [
696
+ ...new Set([
697
+ ...brief.programs.map((program) => program.name),
698
+ ...[...lists.keys()].map((key) => key.split("\u0000")[0] ?? "")
699
+ ])
700
+ ]
701
+ for (const name of touched) {
702
+ const known = programs.get(name)
703
+ if (known === undefined) {
704
+ if (!problems.some((p) => p.kind === "UnknownProgram" && p.program === name)) {
705
+ problems.push({ kind: "UnknownProgram", program: name })
706
+ }
707
+ continue
708
+ }
709
+ for (const scenario of known.scenarios) {
710
+ if (!lists.has(slot(known.name, scenario)) && !inherited.has(slot(known.name, scenario))) {
711
+ problems.push({ kind: "Unaccounted", program: known.name, scenario })
712
+ }
713
+ }
714
+ }
715
+
716
+ // A scenario refine took out of the pack's scope can come back only on purpose.
717
+ for (const item of brief.scope) {
718
+ for (const citation of item.citations) {
719
+ const refined = inputs.pack.refine.find(
720
+ (entry) =>
721
+ entry.program === citation.program &&
722
+ (entry.scenario === undefined || entry.scenario === citation.scenario) &&
723
+ (entry.disposition === "drop" || entry.disposition === "provided")
724
+ )
725
+ if (refined !== undefined) {
726
+ const conflict: BriefProblem = {
727
+ kind: "RefineConflict",
728
+ program: citation.program,
729
+ scenario: citation.scenario,
730
+ disposition: refined.disposition
731
+ }
732
+ if (!hasOverride(brief, conflict)) problems.push(conflict)
733
+ }
734
+ }
735
+ }
736
+
737
+ // Approved briefs of other epics stand; `crossProblem` says when two briefs
738
+ // clash. A clash one side kept on purpose is settled: shared ownership when
739
+ // either keeps it, a contradiction when exactly one does.
740
+ const self = inputs.epicId ?? brief.epicId
741
+ const byWords = new Map<string, BriefDisposition>([
742
+ ["in scope", "in-scope"],
743
+ ["dropped", "dropped"],
744
+ ["provided", "provided"],
745
+ ["deferred", "deferred"]
746
+ ])
747
+ for (const [key, where] of lists) {
748
+ const [program = "", scenario = ""] = key.split("\u0000")
749
+ const here = byWords.get(where[0] ?? "")
750
+ if (here === undefined) continue
751
+ for (const other of inherited.get(key) ?? []) {
752
+ const problem = crossProblem(program, scenario, here, other)
753
+ if (problem === undefined) continue
754
+ const theyKept = (other.keptAgainst ?? []).includes(self)
755
+ const iKept = hasOverride(brief, problem)
756
+ const settled = problem.kind === "AlreadyOwned" ? theyKept || iKept : theyKept !== iKept
757
+ if (!settled) problems.push(problem)
758
+ }
759
+ }
760
+
761
+ const pending = unanswered(brief)
762
+ if (brief.status === "approved" && pending.length > 0) {
763
+ problems.push({ kind: "ApprovedWithOpenPoints", numbers: pending.map((point) => point.number) })
764
+ }
765
+ return problems
766
+ }
767
+
768
+ // ---- Proposals ----------------------------------------------------------------
769
+
770
+ /** The programs of the pack a request touches; the model's first typed reply. */
771
+ export class ProgramSelection extends Schema.Class<ProgramSelection>("ProgramSelection")({
772
+ programs: Schema.Array(ConsideredProgram)
773
+ }) {}
774
+
775
+ /** The model's typed proposal for a brief; checked before anything is written. */
776
+ export class EpicBriefProposal extends Schema.Class<EpicBriefProposal>("EpicBriefProposal")({
777
+ goal: Schema.String,
778
+ scope: Schema.Array(ScopeItem),
779
+ dropped: Schema.Array(Disposed),
780
+ provided: Schema.Array(Disposed),
781
+ deferred: Schema.Array(Disposed),
782
+ constraints: Schema.String,
783
+ /** Questions only a human can answer. */
784
+ openPoints: Schema.Array(Schema.String)
785
+ }) {}
786
+
787
+ const text: JsonSchema = { type: "string" }
788
+ const disposedList = (note: string): JsonSchema => ({
789
+ type: "array",
790
+ items: {
791
+ type: "object",
792
+ properties: {
793
+ program: text,
794
+ scenario: text,
795
+ note: { type: "string", description: note }
796
+ },
797
+ required: ["program", "scenario", "note"]
798
+ }
799
+ })
800
+
801
+ export const programSelectionJsonSchema: JsonSchema = {
802
+ type: "object",
803
+ properties: {
804
+ programs: {
805
+ type: "array",
806
+ items: {
807
+ type: "object",
808
+ properties: { name: text, reason: text },
809
+ required: ["name", "reason"]
810
+ }
811
+ }
812
+ },
813
+ required: ["programs"]
814
+ }
815
+
816
+ export const epicBriefProposalJsonSchema: JsonSchema = {
817
+ type: "object",
818
+ properties: {
819
+ goal: text,
820
+ scope: {
821
+ type: "array",
822
+ items: {
823
+ type: "object",
824
+ properties: {
825
+ title: text,
826
+ citations: {
827
+ type: "array",
828
+ items: {
829
+ type: "object",
830
+ properties: { program: text, scenario: text },
831
+ required: ["program", "scenario"]
832
+ }
833
+ },
834
+ newBehaviour: {
835
+ type: "string",
836
+ description: "only for an item with no citation: why it has no legacy counterpart"
837
+ }
838
+ },
839
+ required: ["title", "citations"]
840
+ }
841
+ },
842
+ dropped: disposedList("why the behaviour is not carried over"),
843
+ provided: disposedList("the target path that already does it"),
844
+ deferred: disposedList("why it waits and for what"),
845
+ constraints: text,
846
+ openPoints: { type: "array", items: text }
847
+ },
848
+ required: ["goal", "scope", "dropped", "provided", "deferred", "constraints", "openPoints"]
849
+ }
850
+
851
+ export interface AssembleOptions {
852
+ readonly epicId: string
853
+ readonly request: string
854
+ readonly legacy: string
855
+ readonly programs: ReadonlyArray<ConsideredProgram>
856
+ readonly proposal: EpicBriefProposal
857
+ /** The brief this one revises: its unanswered points the proposal did not restate are kept. */
858
+ readonly previous?: EpicBrief
859
+ /** Problems the fix round could not clear: raised as `[check]` open points. */
860
+ readonly problems?: ReadonlyArray<BriefProblem>
861
+ }
862
+
863
+ export const checkMark = "[check]"
864
+
865
+ /**
866
+ * The next draft: never approved, feedback consumed, open points renumbered.
867
+ * Answered points are folded in by the revision and go, except answered
868
+ * `[check]` points: those are the human overriding a check (a scenario
869
+ * refine dropped, kept on purpose) and stay as the record of it.
870
+ */
871
+ export const assembleBrief = (options: AssembleOptions): EpicBrief => {
872
+ const previous = options.previous
873
+ const isAnswered = (point: OpenPoint): boolean => (point.answer ?? "").trim().length > 0
874
+ const carried: ReadonlyArray<{ readonly question: string; readonly answer?: string }> =
875
+ previous === undefined
876
+ ? []
877
+ : [
878
+ ...unanswered(previous)
879
+ .filter((point) => !point.question.startsWith(checkMark))
880
+ .map((point) => ({ question: point.question })),
881
+ ...previous.openPoints
882
+ .filter((point) => point.question.startsWith(checkMark) && isAnswered(point))
883
+ .map((point) => ({ question: point.question, answer: (point.answer ?? "").trim() }))
884
+ ]
885
+ const all: ReadonlyArray<{ readonly question: string; readonly answer?: string }> = [
886
+ ...options.proposal.openPoints.map((question) => ({ question })),
887
+ ...carried,
888
+ ...(options.problems ?? []).map((problem) => ({
889
+ question: `${checkMark} ${renderBriefProblem(problem)}`
890
+ }))
891
+ ]
892
+ const unique = all.filter(
893
+ (entry, index) => all.findIndex((other) => other.question === entry.question) === index
894
+ )
895
+ return EpicBrief.make({
896
+ epicId: options.epicId,
897
+ status: "draft",
898
+ request: options.request,
899
+ legacy: options.legacy,
900
+ goal: options.proposal.goal,
901
+ programs: options.programs,
902
+ scope: options.proposal.scope,
903
+ dropped: options.proposal.dropped,
904
+ provided: options.proposal.provided,
905
+ deferred: options.proposal.deferred,
906
+ constraints: options.proposal.constraints,
907
+ openPoints: unique.map((entry, index) =>
908
+ OpenPoint.make({
909
+ number: index + 1,
910
+ question: entry.question,
911
+ ...(entry.answer === undefined ? {} : { answer: entry.answer })
912
+ })
913
+ ),
914
+ feedback: ""
915
+ })
916
+ }
917
+
918
+ // ---- The loop -----------------------------------------------------------------
919
+
920
+ export type LoopAction = "propose" | "revise" | "halt" | "await-approval" | "validate"
921
+
922
+ /** What one run does, decided from the file alone. */
923
+ export const loopAction = (brief: EpicBrief | undefined): LoopAction => {
924
+ if (brief === undefined) return "propose"
925
+ if (brief.status === "approved") return "validate"
926
+ // An answered `[check]` point is an override on file, not something to fold in.
927
+ const answered = brief.openPoints.some(
928
+ (point) => (point.answer ?? "").trim().length > 0 && !point.question.startsWith(checkMark)
929
+ )
930
+ if (answered || brief.feedback.trim().length > 0) return "revise"
931
+ return unanswered(brief).length > 0 ? "halt" : "await-approval"
932
+ }
933
+
934
+ const notes = (brief: EpicBrief): ReadonlyMap<string, readonly [string, string]> => {
935
+ const found = new Map<string, readonly [string, string]>()
936
+ const add = (kind: string, entries: ReadonlyArray<Disposed>): void => {
937
+ for (const entry of entries) {
938
+ found.set(`${entry.program}${cite}${entry.scenario}`, [kind, entry.note])
939
+ }
940
+ }
941
+ add("reason", brief.dropped)
942
+ add("pointer", brief.provided)
943
+ add("note", brief.deferred)
944
+ return found
945
+ }
946
+
947
+ const dispositions = (brief: EpicBrief): ReadonlyMap<string, string> => {
948
+ const found = new Map<string, string>()
949
+ const add = (list: string, program: string, scenario: string): void => {
950
+ found.set(`${program}${cite}${scenario}`, list)
951
+ }
952
+ for (const item of brief.scope) {
953
+ for (const citation of item.citations) add("in scope", citation.program, citation.scenario)
954
+ }
955
+ for (const entry of brief.dropped) add("dropped", entry.program, entry.scenario)
956
+ for (const entry of brief.provided) add("provided", entry.program, entry.scenario)
957
+ for (const entry of brief.deferred) add("deferred", entry.program, entry.scenario)
958
+ return found
959
+ }
960
+
961
+ /** What a revision changed, in lines a person reads; empty when nothing did. */
962
+ export const diffBriefs = (
963
+ previous: EpicBrief | undefined,
964
+ next: EpicBrief
965
+ ): ReadonlyArray<string> => {
966
+ if (previous === undefined) {
967
+ return [
968
+ `programs considered: ${next.programs.map((program) => program.name).join(", ") || "none"}`,
969
+ `in scope: ${next.scope.length} item(s); dropped: ${next.dropped.length}; provided by the target: ${next.provided.length}; deferred: ${next.deferred.length}`,
970
+ `open points: ${next.openPoints.length}`
971
+ ]
972
+ }
973
+ const changes: Array<string> = []
974
+ const before = new Set(previous.programs.map((program) => program.name))
975
+ const after = new Set(next.programs.map((program) => program.name))
976
+ for (const name of after) if (!before.has(name)) changes.push(`program added: ${name}`)
977
+ for (const name of before) if (!after.has(name)) changes.push(`program removed: ${name}`)
978
+ const was = dispositions(previous)
979
+ const is = dispositions(next)
980
+ for (const [scenario, list] of is) {
981
+ const old = was.get(scenario)
982
+ if (old === undefined) changes.push(`${scenario}: added to ${list}`)
983
+ else if (old !== list) changes.push(`${scenario}: ${old} → ${list}`)
984
+ }
985
+ for (const [scenario, list] of was) {
986
+ if (!is.has(scenario)) changes.push(`${scenario}: removed (was ${list})`)
987
+ }
988
+ // A rationale is what an audit reads: a changed one is never silent.
989
+ const noted = notes(previous)
990
+ for (const [scenario, [kind, note]] of notes(next)) {
991
+ const old = noted.get(scenario)
992
+ if (old !== undefined && old[0] === kind && old[1] !== note) {
993
+ changes.push(`${scenario}: ${kind} changed`)
994
+ }
995
+ }
996
+ const titled = new Set(previous.scope.map((item) => item.title))
997
+ const titles = new Set(next.scope.map((item) => item.title))
998
+ for (const title of titles) if (!titled.has(title)) changes.push(`item added: ${title}`)
999
+ for (const title of titled) if (!titles.has(title)) changes.push(`item removed: ${title}`)
1000
+ const asking = new Set(next.openPoints.map((point) => point.question))
1001
+ const asked = new Set(previous.openPoints.map((point) => point.question))
1002
+ for (const question of asking)
1003
+ if (!asked.has(question)) changes.push(`open point raised: ${question}`)
1004
+ for (const point of previous.openPoints) {
1005
+ if (asking.has(point.question)) continue
1006
+ const answer = (point.answer ?? "").trim()
1007
+ changes.push(
1008
+ `open point closed: ${point.question}${answer.length === 0 ? "" : ` (answer: ${answer})`}`
1009
+ )
1010
+ }
1011
+ if (previous.goal !== next.goal) changes.push("goal rewritten")
1012
+ if (previous.constraints !== next.constraints) changes.push("constraints rewritten")
1013
+ return changes
1014
+ }