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