@llm4ts/flow 2.15.0 → 2.17.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 (45) 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/EpicBrief.d.ts +185 -0
  10. package/dist/EpicBrief.d.ts.map +1 -0
  11. package/dist/EpicBrief.js +781 -0
  12. package/dist/EpicBrief.js.map +1 -0
  13. package/dist/Equiv.d.ts +3 -3
  14. package/dist/Equiv.d.ts.map +1 -1
  15. package/dist/Flow.d.ts +1 -1
  16. package/dist/Flow.d.ts.map +1 -1
  17. package/dist/FlowError.d.ts +23 -1
  18. package/dist/FlowError.d.ts.map +1 -1
  19. package/dist/FlowError.js +30 -1
  20. package/dist/FlowError.js.map +1 -1
  21. package/dist/Judgment.d.ts +2 -2
  22. package/dist/Judgment.d.ts.map +1 -1
  23. package/dist/Landing.d.ts +1 -1
  24. package/dist/Landing.d.ts.map +1 -1
  25. package/dist/Persistence.d.ts +2 -2
  26. package/dist/Persistence.d.ts.map +1 -1
  27. package/dist/PlanExecution.d.ts +1 -1
  28. package/dist/PlanExecution.d.ts.map +1 -1
  29. package/dist/ProgramJudge.d.ts +1 -1
  30. package/dist/ProgramJudge.d.ts.map +1 -1
  31. package/dist/Replay.d.ts +2 -2
  32. package/dist/Replay.d.ts.map +1 -1
  33. package/dist/Review.d.ts +3 -3
  34. package/dist/Review.d.ts.map +1 -1
  35. package/dist/ReviewCache.d.ts +1 -1
  36. package/dist/ReviewCache.d.ts.map +1 -1
  37. package/dist/Roster.d.ts +1 -1
  38. package/dist/Roster.d.ts.map +1 -1
  39. package/dist/RosterSeats.d.ts +1 -1
  40. package/dist/RosterSeats.d.ts.map +1 -1
  41. package/dist/Stories.d.ts +1 -1
  42. package/dist/Stories.d.ts.map +1 -1
  43. package/package.json +3 -2
  44. package/src/EpicBrief.ts +900 -0
  45. package/src/FlowError.ts +39 -1
@@ -0,0 +1,900 @@
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("ApprovedWithOpenPoints"),
478
+ numbers: Schema.Array(Schema.Int)
479
+ })
480
+ ])
481
+ export type BriefProblem = typeof BriefProblem.Type
482
+
483
+ export const renderBriefProblem = (problem: BriefProblem): string => {
484
+ switch (problem.kind) {
485
+ case "UnknownProgram":
486
+ return `${problem.program} is not a program of the extract pack`
487
+ case "UnknownScenario":
488
+ return `${problem.program} has no scenario "${problem.scenario}"${
489
+ problem.closest === undefined ? "" : ` (did you mean "${problem.closest}"?)`
490
+ }`
491
+ case "MissingPointer":
492
+ return `${problem.program}${cite}${problem.scenario} is marked provided by "${problem.pointer}", which does not exist in the target`
493
+ case "MissingReason":
494
+ return `${problem.where} needs a reason`
495
+ case "DuplicateDisposition":
496
+ return `${problem.program}${cite}${problem.scenario} appears in several lists: ${problem.lists.join(", ")}`
497
+ case "Unaccounted":
498
+ return `${problem.program}${cite}${problem.scenario} has no disposition: put it in scope, or drop, mark provided or defer it`
499
+ case "RefineConflict":
500
+ 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`
501
+ case "ApprovedWithOpenPoints":
502
+ return `the brief is approved with unanswered open points: ${problem.numbers.join(", ")}`
503
+ }
504
+ }
505
+
506
+ /** The pointers of the provided entries: what the caller verifies before checking. */
507
+ export const providedPointers = (brief: EpicBrief): ReadonlyArray<string> => [
508
+ ...new Set(brief.provided.map((entry) => entry.note).filter((note) => note.length > 0))
509
+ ]
510
+
511
+ const loose = (text: string): string => text.trim().replace(/\s+/g, " ").toLowerCase()
512
+ const slot = (program: string, scenario: string): string => `${program}\u0000${scenario}`
513
+
514
+ export interface BriefCheckInputs {
515
+ readonly pack: PackIndex
516
+ /** The provided pointers that were found in the target (or its pack). */
517
+ readonly pointers: ReadonlySet<string>
518
+ }
519
+
520
+ /** Every problem of a brief against the pack, all at once; empty means acceptable. */
521
+ export const checkEpicBrief = (
522
+ brief: EpicBrief,
523
+ inputs: BriefCheckInputs
524
+ ): ReadonlyArray<BriefProblem> => {
525
+ const problems: Array<BriefProblem> = []
526
+ const programs = new Map(inputs.pack.programs.map((program) => [program.name, program]))
527
+ const lists = new Map<string, Array<string>>()
528
+ const place = (list: string, program: string, scenario: string): void => {
529
+ const known = programs.get(program)
530
+ if (known === undefined) {
531
+ if (!problems.some((p) => p.kind === "UnknownProgram" && p.program === program)) {
532
+ problems.push({ kind: "UnknownProgram", program })
533
+ }
534
+ return
535
+ }
536
+ if (!known.scenarios.includes(scenario)) {
537
+ const closest = known.scenarios.find((title) => loose(title) === loose(scenario))
538
+ problems.push({
539
+ kind: "UnknownScenario",
540
+ program,
541
+ scenario,
542
+ ...(closest === undefined ? {} : { closest })
543
+ })
544
+ return
545
+ }
546
+ const key = slot(program, scenario)
547
+ const seen = lists.get(key) ?? []
548
+ if (!seen.includes(list)) lists.set(key, [...seen, list])
549
+ }
550
+
551
+ for (const item of brief.scope) {
552
+ if (item.citations.length === 0 && (item.newBehaviour ?? "").trim().length === 0) {
553
+ problems.push({
554
+ kind: "MissingReason",
555
+ where: `in-scope item "${item.title}" cites nothing and`
556
+ })
557
+ }
558
+ for (const citation of item.citations) place("in scope", citation.program, citation.scenario)
559
+ }
560
+ for (const entry of brief.dropped) {
561
+ place("dropped", entry.program, entry.scenario)
562
+ if (entry.note.trim().length === 0) {
563
+ problems.push({
564
+ kind: "MissingReason",
565
+ where: `dropped ${entry.program}${cite}${entry.scenario}`
566
+ })
567
+ }
568
+ }
569
+ for (const entry of brief.provided) {
570
+ place("provided", entry.program, entry.scenario)
571
+ if (!inputs.pointers.has(entry.note)) {
572
+ problems.push({
573
+ kind: "MissingPointer",
574
+ program: entry.program,
575
+ scenario: entry.scenario,
576
+ pointer: entry.note
577
+ })
578
+ }
579
+ }
580
+ for (const entry of brief.deferred) {
581
+ place("deferred", entry.program, entry.scenario)
582
+ if (entry.note.trim().length === 0) {
583
+ problems.push({
584
+ kind: "MissingReason",
585
+ where: `deferred ${entry.program}${cite}${entry.scenario}`
586
+ })
587
+ }
588
+ }
589
+
590
+ for (const [key, where] of lists) {
591
+ if (where.length > 1) {
592
+ const [program = "", scenario = ""] = key.split("\u0000")
593
+ problems.push({ kind: "DuplicateDisposition", program, scenario, lists: where })
594
+ }
595
+ }
596
+ // Complete means every program the brief touches: the ones it lists as
597
+ // considered, and any it cites or disposes of without listing.
598
+ const touched = [
599
+ ...new Set([
600
+ ...brief.programs.map((program) => program.name),
601
+ ...[...lists.keys()].map((key) => key.split("\u0000")[0] ?? "")
602
+ ])
603
+ ]
604
+ for (const name of touched) {
605
+ const known = programs.get(name)
606
+ if (known === undefined) {
607
+ if (!problems.some((p) => p.kind === "UnknownProgram" && p.program === name)) {
608
+ problems.push({ kind: "UnknownProgram", program: name })
609
+ }
610
+ continue
611
+ }
612
+ for (const scenario of known.scenarios) {
613
+ if (!lists.has(slot(known.name, scenario))) {
614
+ problems.push({ kind: "Unaccounted", program: known.name, scenario })
615
+ }
616
+ }
617
+ }
618
+
619
+ // A scenario refine took out of the pack's scope can come back only on purpose.
620
+ // The override is explicit: the check's own question, answered `keep: <why>`.
621
+ const justified = (conflict: BriefProblem): boolean =>
622
+ brief.openPoints.some(
623
+ (point) =>
624
+ point.question === `${checkMark} ${renderBriefProblem(conflict)}` &&
625
+ (point.answer ?? "").trim().toLowerCase().startsWith("keep")
626
+ )
627
+ for (const item of brief.scope) {
628
+ for (const citation of item.citations) {
629
+ const refined = inputs.pack.refine.find(
630
+ (entry) =>
631
+ entry.program === citation.program &&
632
+ (entry.scenario === undefined || entry.scenario === citation.scenario) &&
633
+ (entry.disposition === "drop" || entry.disposition === "provided")
634
+ )
635
+ if (refined !== undefined) {
636
+ const conflict: BriefProblem = {
637
+ kind: "RefineConflict",
638
+ program: citation.program,
639
+ scenario: citation.scenario,
640
+ disposition: refined.disposition
641
+ }
642
+ if (!justified(conflict)) problems.push(conflict)
643
+ }
644
+ }
645
+ }
646
+
647
+ const pending = unanswered(brief)
648
+ if (brief.status === "approved" && pending.length > 0) {
649
+ problems.push({ kind: "ApprovedWithOpenPoints", numbers: pending.map((point) => point.number) })
650
+ }
651
+ return problems
652
+ }
653
+
654
+ // ---- Proposals ----------------------------------------------------------------
655
+
656
+ /** The programs of the pack a request touches; the model's first typed reply. */
657
+ export class ProgramSelection extends Schema.Class<ProgramSelection>("ProgramSelection")({
658
+ programs: Schema.Array(ConsideredProgram)
659
+ }) {}
660
+
661
+ /** The model's typed proposal for a brief; checked before anything is written. */
662
+ export class EpicBriefProposal extends Schema.Class<EpicBriefProposal>("EpicBriefProposal")({
663
+ goal: Schema.String,
664
+ scope: Schema.Array(ScopeItem),
665
+ dropped: Schema.Array(Disposed),
666
+ provided: Schema.Array(Disposed),
667
+ deferred: Schema.Array(Disposed),
668
+ constraints: Schema.String,
669
+ /** Questions only a human can answer. */
670
+ openPoints: Schema.Array(Schema.String)
671
+ }) {}
672
+
673
+ const text: JsonSchema = { type: "string" }
674
+ const disposedList = (note: string): JsonSchema => ({
675
+ type: "array",
676
+ items: {
677
+ type: "object",
678
+ properties: {
679
+ program: text,
680
+ scenario: text,
681
+ note: { type: "string", description: note }
682
+ },
683
+ required: ["program", "scenario", "note"]
684
+ }
685
+ })
686
+
687
+ export const programSelectionJsonSchema: JsonSchema = {
688
+ type: "object",
689
+ properties: {
690
+ programs: {
691
+ type: "array",
692
+ items: {
693
+ type: "object",
694
+ properties: { name: text, reason: text },
695
+ required: ["name", "reason"]
696
+ }
697
+ }
698
+ },
699
+ required: ["programs"]
700
+ }
701
+
702
+ export const epicBriefProposalJsonSchema: JsonSchema = {
703
+ type: "object",
704
+ properties: {
705
+ goal: text,
706
+ scope: {
707
+ type: "array",
708
+ items: {
709
+ type: "object",
710
+ properties: {
711
+ title: text,
712
+ citations: {
713
+ type: "array",
714
+ items: {
715
+ type: "object",
716
+ properties: { program: text, scenario: text },
717
+ required: ["program", "scenario"]
718
+ }
719
+ },
720
+ newBehaviour: {
721
+ type: "string",
722
+ description: "only for an item with no citation: why it has no legacy counterpart"
723
+ }
724
+ },
725
+ required: ["title", "citations"]
726
+ }
727
+ },
728
+ dropped: disposedList("why the behaviour is not carried over"),
729
+ provided: disposedList("the target path that already does it"),
730
+ deferred: disposedList("why it waits and for what"),
731
+ constraints: text,
732
+ openPoints: { type: "array", items: text }
733
+ },
734
+ required: ["goal", "scope", "dropped", "provided", "deferred", "constraints", "openPoints"]
735
+ }
736
+
737
+ export interface AssembleOptions {
738
+ readonly epicId: string
739
+ readonly request: string
740
+ readonly legacy: string
741
+ readonly programs: ReadonlyArray<ConsideredProgram>
742
+ readonly proposal: EpicBriefProposal
743
+ /** The brief this one revises: its unanswered points the proposal did not restate are kept. */
744
+ readonly previous?: EpicBrief
745
+ /** Problems the fix round could not clear: raised as `[check]` open points. */
746
+ readonly problems?: ReadonlyArray<BriefProblem>
747
+ }
748
+
749
+ export const checkMark = "[check]"
750
+
751
+ /**
752
+ * The next draft: never approved, feedback consumed, open points renumbered.
753
+ * Answered points are folded in by the revision and go, except answered
754
+ * `[check]` points: those are the human overriding a check (a scenario
755
+ * refine dropped, kept on purpose) and stay as the record of it.
756
+ */
757
+ export const assembleBrief = (options: AssembleOptions): EpicBrief => {
758
+ const previous = options.previous
759
+ const isAnswered = (point: OpenPoint): boolean => (point.answer ?? "").trim().length > 0
760
+ const carried: ReadonlyArray<{ readonly question: string; readonly answer?: string }> =
761
+ previous === undefined
762
+ ? []
763
+ : [
764
+ ...unanswered(previous)
765
+ .filter((point) => !point.question.startsWith(checkMark))
766
+ .map((point) => ({ question: point.question })),
767
+ ...previous.openPoints
768
+ .filter((point) => point.question.startsWith(checkMark) && isAnswered(point))
769
+ .map((point) => ({ question: point.question, answer: (point.answer ?? "").trim() }))
770
+ ]
771
+ const all: ReadonlyArray<{ readonly question: string; readonly answer?: string }> = [
772
+ ...options.proposal.openPoints.map((question) => ({ question })),
773
+ ...carried,
774
+ ...(options.problems ?? []).map((problem) => ({
775
+ question: `${checkMark} ${renderBriefProblem(problem)}`
776
+ }))
777
+ ]
778
+ const unique = all.filter(
779
+ (entry, index) => all.findIndex((other) => other.question === entry.question) === index
780
+ )
781
+ return EpicBrief.make({
782
+ epicId: options.epicId,
783
+ status: "draft",
784
+ request: options.request,
785
+ legacy: options.legacy,
786
+ goal: options.proposal.goal,
787
+ programs: options.programs,
788
+ scope: options.proposal.scope,
789
+ dropped: options.proposal.dropped,
790
+ provided: options.proposal.provided,
791
+ deferred: options.proposal.deferred,
792
+ constraints: options.proposal.constraints,
793
+ openPoints: unique.map((entry, index) =>
794
+ OpenPoint.make({
795
+ number: index + 1,
796
+ question: entry.question,
797
+ ...(entry.answer === undefined ? {} : { answer: entry.answer })
798
+ })
799
+ ),
800
+ feedback: ""
801
+ })
802
+ }
803
+
804
+ // ---- The loop -----------------------------------------------------------------
805
+
806
+ export type LoopAction = "propose" | "revise" | "halt" | "await-approval" | "validate"
807
+
808
+ /** What one run does, decided from the file alone. */
809
+ export const loopAction = (brief: EpicBrief | undefined): LoopAction => {
810
+ if (brief === undefined) return "propose"
811
+ if (brief.status === "approved") return "validate"
812
+ // An answered `[check]` point is an override on file, not something to fold in.
813
+ const answered = brief.openPoints.some(
814
+ (point) => (point.answer ?? "").trim().length > 0 && !point.question.startsWith(checkMark)
815
+ )
816
+ if (answered || brief.feedback.trim().length > 0) return "revise"
817
+ return unanswered(brief).length > 0 ? "halt" : "await-approval"
818
+ }
819
+
820
+ const notes = (brief: EpicBrief): ReadonlyMap<string, readonly [string, string]> => {
821
+ const found = new Map<string, readonly [string, string]>()
822
+ const add = (kind: string, entries: ReadonlyArray<Disposed>): void => {
823
+ for (const entry of entries) {
824
+ found.set(`${entry.program}${cite}${entry.scenario}`, [kind, entry.note])
825
+ }
826
+ }
827
+ add("reason", brief.dropped)
828
+ add("pointer", brief.provided)
829
+ add("note", brief.deferred)
830
+ return found
831
+ }
832
+
833
+ const dispositions = (brief: EpicBrief): ReadonlyMap<string, string> => {
834
+ const found = new Map<string, string>()
835
+ const add = (list: string, program: string, scenario: string): void => {
836
+ found.set(`${program}${cite}${scenario}`, list)
837
+ }
838
+ for (const item of brief.scope) {
839
+ for (const citation of item.citations) add("in scope", citation.program, citation.scenario)
840
+ }
841
+ for (const entry of brief.dropped) add("dropped", entry.program, entry.scenario)
842
+ for (const entry of brief.provided) add("provided", entry.program, entry.scenario)
843
+ for (const entry of brief.deferred) add("deferred", entry.program, entry.scenario)
844
+ return found
845
+ }
846
+
847
+ /** What a revision changed, in lines a person reads; empty when nothing did. */
848
+ export const diffBriefs = (
849
+ previous: EpicBrief | undefined,
850
+ next: EpicBrief
851
+ ): ReadonlyArray<string> => {
852
+ if (previous === undefined) {
853
+ return [
854
+ `programs considered: ${next.programs.map((program) => program.name).join(", ") || "none"}`,
855
+ `in scope: ${next.scope.length} item(s); dropped: ${next.dropped.length}; provided by the target: ${next.provided.length}; deferred: ${next.deferred.length}`,
856
+ `open points: ${next.openPoints.length}`
857
+ ]
858
+ }
859
+ const changes: Array<string> = []
860
+ const before = new Set(previous.programs.map((program) => program.name))
861
+ const after = new Set(next.programs.map((program) => program.name))
862
+ for (const name of after) if (!before.has(name)) changes.push(`program added: ${name}`)
863
+ for (const name of before) if (!after.has(name)) changes.push(`program removed: ${name}`)
864
+ const was = dispositions(previous)
865
+ const is = dispositions(next)
866
+ for (const [scenario, list] of is) {
867
+ const old = was.get(scenario)
868
+ if (old === undefined) changes.push(`${scenario}: added to ${list}`)
869
+ else if (old !== list) changes.push(`${scenario}: ${old} → ${list}`)
870
+ }
871
+ for (const [scenario, list] of was) {
872
+ if (!is.has(scenario)) changes.push(`${scenario}: removed (was ${list})`)
873
+ }
874
+ // A rationale is what an audit reads: a changed one is never silent.
875
+ const noted = notes(previous)
876
+ for (const [scenario, [kind, note]] of notes(next)) {
877
+ const old = noted.get(scenario)
878
+ if (old !== undefined && old[0] === kind && old[1] !== note) {
879
+ changes.push(`${scenario}: ${kind} changed`)
880
+ }
881
+ }
882
+ const titled = new Set(previous.scope.map((item) => item.title))
883
+ const titles = new Set(next.scope.map((item) => item.title))
884
+ for (const title of titles) if (!titled.has(title)) changes.push(`item added: ${title}`)
885
+ for (const title of titled) if (!titles.has(title)) changes.push(`item removed: ${title}`)
886
+ const asking = new Set(next.openPoints.map((point) => point.question))
887
+ const asked = new Set(previous.openPoints.map((point) => point.question))
888
+ for (const question of asking)
889
+ if (!asked.has(question)) changes.push(`open point raised: ${question}`)
890
+ for (const point of previous.openPoints) {
891
+ if (asking.has(point.question)) continue
892
+ const answer = (point.answer ?? "").trim()
893
+ changes.push(
894
+ `open point closed: ${point.question}${answer.length === 0 ? "" : ` (answer: ${answer})`}`
895
+ )
896
+ }
897
+ if (previous.goal !== next.goal) changes.push("goal rewritten")
898
+ if (previous.constraints !== next.constraints) changes.push("constraints rewritten")
899
+ return changes
900
+ }