@llm4ts/flow 2.1.0 → 2.2.1

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 (57) 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/Decisions.d.ts +187 -0
  10. package/dist/Decisions.d.ts.map +1 -0
  11. package/dist/Decisions.js +677 -0
  12. package/dist/Decisions.js.map +1 -0
  13. package/dist/Domains.d.ts +125 -0
  14. package/dist/Domains.d.ts.map +1 -0
  15. package/dist/Domains.js +528 -0
  16. package/dist/Domains.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 +25 -1
  22. package/dist/FlowError.d.ts.map +1 -1
  23. package/dist/FlowError.js +33 -1
  24. package/dist/FlowError.js.map +1 -1
  25. package/dist/Pack.d.ts +16 -0
  26. package/dist/Pack.d.ts.map +1 -1
  27. package/dist/Pack.js +53 -1
  28. package/dist/Pack.js.map +1 -1
  29. package/dist/PageSpec.d.ts +21 -7
  30. package/dist/PageSpec.d.ts.map +1 -1
  31. package/dist/PageSpec.js +83 -14
  32. package/dist/PageSpec.js.map +1 -1
  33. package/dist/Persistence.d.ts +2 -2
  34. package/dist/Persistence.d.ts.map +1 -1
  35. package/dist/PlanExecution.d.ts +1 -1
  36. package/dist/PlanExecution.d.ts.map +1 -1
  37. package/dist/ProgramJudge.d.ts +1 -1
  38. package/dist/ProgramJudge.d.ts.map +1 -1
  39. package/dist/Replay.d.ts +2 -2
  40. package/dist/Replay.d.ts.map +1 -1
  41. package/dist/Review.d.ts +2 -2
  42. package/dist/Review.d.ts.map +1 -1
  43. package/dist/ReviewCache.d.ts +1 -1
  44. package/dist/ReviewCache.d.ts.map +1 -1
  45. package/dist/SpecChecks.d.ts +8 -0
  46. package/dist/SpecChecks.d.ts.map +1 -1
  47. package/dist/SpecChecks.js +7 -1
  48. package/dist/SpecChecks.js.map +1 -1
  49. package/dist/Stories.d.ts +1 -1
  50. package/dist/Stories.d.ts.map +1 -1
  51. package/package.json +4 -2
  52. package/src/Decisions.ts +844 -0
  53. package/src/Domains.ts +650 -0
  54. package/src/FlowError.ts +41 -1
  55. package/src/Pack.ts +79 -1
  56. package/src/PageSpec.ts +132 -8
  57. package/src/SpecChecks.ts +15 -1
@@ -0,0 +1,844 @@
1
+ import * as Effect from "effect/Effect"
2
+ import * as Schema from "effect/Schema"
3
+ import type { JsonSchema } from "@llm4ts/core/Models"
4
+ import { DecisionsInvalid } from "./FlowError.ts"
5
+
6
+ export { DecisionsInvalid, OpenPointsPending } from "./FlowError.ts"
7
+
8
+ /**
9
+ * The scope overlay of a modernization spec pack (ADR 0015): what the
10
+ * extracted pack should BECOME, kept apart from the judged, source-grounded
11
+ * record of what the legacy does. Keyed at program and Gherkin-scenario
12
+ * level only; `migrate` is the implicit default and is never written.
13
+ *
14
+ * The file is the state. Marks (`?`) ask the model to propose, deepen marks
15
+ * send the analyst back to the source with a focus, open points are the
16
+ * questions a proposal could not settle and the answers a human appended,
17
+ * and the approval marker is the gate seed checks.
18
+ */
19
+ export const Disposition = Schema.Literals(["drop", "provided", "defer", "wrap"])
20
+ export type Disposition = typeof Disposition.Type
21
+
22
+ const dispositionWords: ReadonlyArray<string> = ["drop", "provided", "defer", "wrap", "?"]
23
+
24
+ const decidedFields = {
25
+ reason: Schema.String,
26
+ /** `provided` only: the target path or capability that already covers it. */
27
+ pointer: Schema.optionalKey(Schema.String),
28
+ /** `defer` only: the wave or milestone the behaviour moves to. */
29
+ milestone: Schema.optionalKey(Schema.String),
30
+ decidedBy: Schema.optionalKey(Schema.String),
31
+ decidedAt: Schema.optionalKey(Schema.String)
32
+ }
33
+
34
+ export class ProgramDecision extends Schema.Class<ProgramDecision>("ProgramDecision")({
35
+ program: Schema.String,
36
+ disposition: Disposition,
37
+ ...decidedFields
38
+ }) {}
39
+
40
+ export class ScenarioDecision extends Schema.Class<ScenarioDecision>("ScenarioDecision")({
41
+ program: Schema.String,
42
+ scenario: Schema.String,
43
+ disposition: Disposition,
44
+ ...decidedFields
45
+ }) {}
46
+
47
+ /** A `?` line: "propose a disposition for me, here is my note". */
48
+ export class ProposalMark extends Schema.Class<ProposalMark>("ProposalMark")({
49
+ program: Schema.String,
50
+ scenario: Schema.optionalKey(Schema.String),
51
+ note: Schema.String
52
+ }) {}
53
+
54
+ /** A program to re-extract with a mandatory focus; `done` carries the commit once executed. */
55
+ export class DeepenMark extends Schema.Class<DeepenMark>("DeepenMark")({
56
+ program: Schema.String,
57
+ focus: Schema.String,
58
+ done: Schema.optionalKey(Schema.String)
59
+ }) {}
60
+
61
+ export class OpenPoint extends Schema.Class<OpenPoint>("OpenPoint")({
62
+ number: Schema.Int,
63
+ question: Schema.String,
64
+ answer: Schema.optionalKey(Schema.String)
65
+ }) {}
66
+
67
+ export class Decisions extends Schema.Class<Decisions>("Decisions")({
68
+ programs: Schema.Array(ProgramDecision),
69
+ scenarios: Schema.Array(ScenarioDecision),
70
+ marks: Schema.Array(ProposalMark),
71
+ deepen: Schema.Array(DeepenMark),
72
+ openPoints: Schema.Array(OpenPoint),
73
+ approved: Schema.Boolean
74
+ }) {
75
+ static readonly empty = (): Decisions =>
76
+ Decisions.make({
77
+ programs: [],
78
+ scenarios: [],
79
+ marks: [],
80
+ deepen: [],
81
+ openPoints: [],
82
+ approved: false
83
+ })
84
+
85
+ get isEmpty(): boolean {
86
+ return (
87
+ this.programs.length === 0 &&
88
+ this.scenarios.length === 0 &&
89
+ this.marks.length === 0 &&
90
+ this.deepen.length === 0 &&
91
+ this.openPoints.length === 0
92
+ )
93
+ }
94
+
95
+ get pendingDeepen(): ReadonlyArray<DeepenMark> {
96
+ return this.deepen.filter((mark) => mark.done === undefined)
97
+ }
98
+
99
+ get unansweredOpenPoints(): ReadonlyArray<OpenPoint> {
100
+ return this.openPoints.filter((point) => point.answer === undefined)
101
+ }
102
+
103
+ /** The program-level decision, if the whole program is disposed. */
104
+ programDecision(program: string): ProgramDecision | undefined {
105
+ return this.programs.find((entry) => entry.program === program)
106
+ }
107
+
108
+ /** The disposed scenario titles of one program (program-level decisions do not expand here). */
109
+ disposedScenarios(program: string): ReadonlySet<string> {
110
+ return new Set(
111
+ this.scenarios.filter((entry) => entry.program === program).map((entry) => entry.scenario)
112
+ )
113
+ }
114
+ }
115
+
116
+ // ---- Guide ------------------------------------------------------------------
117
+
118
+ export const decisionsGuide = [
119
+ "## How to mark",
120
+ "",
121
+ "Every program and scenario of the spec pack migrates unless it is listed",
122
+ "here. List only the exceptions, one per line, as `- <key>: <disposition> — <why>`.",
123
+ "A key is a program name (`accountOverview`) or `<program> / <scenario title>`",
124
+ "exactly as the title reads in the .feature file. Dispositions:",
125
+ "",
126
+ "- `drop` — deprecated, will not exist in the target. Needs a reason.",
127
+ "- `provided` — the target already has it or solves it differently. Needs the",
128
+ " target path or capability that provides it, then `;` and a note.",
129
+ "- `defer` — still to migrate, not in this delivery. Needs a reason; add",
130
+ " `; milestone: <wave or name>` to say when.",
131
+ "- `wrap` — programs only: stays on the legacy platform behind an API.",
132
+ "- `?` — ask the model to propose one; write your note after the dash.",
133
+ "",
134
+ "Append `(who, YYYY-MM-DD)` to sign a decision. Under `## Deepen`, list a",
135
+ "program with what the analyst must look for; the flow re-extracts it and",
136
+ "stamps `[done <commit>]`. Under `## Open points`, answer a question by adding",
137
+ "an indented `answer: …` line below it, then rerun. Flip the marker at the",
138
+ "end when everything above is what you want seeded.",
139
+ "",
140
+ "```markdown",
141
+ "- promoQ3: drop — expired 2011 campaign (riccardo, 2026-09-15)",
142
+ "- login: provided — src/auth/AuthProvider.tsx; the target owns login and session",
143
+ "- help: defer — content team rewrites the FAQ; milestone: wave-3",
144
+ "- accountOverview / Export movements as CSV: drop — reporting moves to the data platform",
145
+ "- oldTransfer: ? — looks dead, confirm nothing links here",
146
+ "```"
147
+ ].join("\n")
148
+
149
+ // ---- Parse ------------------------------------------------------------------
150
+
151
+ const approvedMarker = /^- \[([ xX])\] Approved\s*$/
152
+
153
+ /** `- [x] Approved` → true, `- [ ] Approved` → false, anything else → undefined. */
154
+ export const parseApprovalMarker = (trimmed: string): boolean | undefined => {
155
+ const marker = approvedMarker.exec(trimmed)
156
+ return marker === null ? undefined : marker[1] !== " "
157
+ }
158
+
159
+ /**
160
+ * Collects a `## Open points` section line by line: `1. <question>`, an
161
+ * indented `answer: …` below it, and continuation lines of either. Shared by
162
+ * every overlay that carries open points.
163
+ */
164
+ export const makeOpenPointsCollector = (): {
165
+ readonly points: Array<OpenPoint>
166
+ readonly add: (line: number, trimmed: string) => string | undefined
167
+ } => {
168
+ const points: Array<OpenPoint> = []
169
+ return {
170
+ points,
171
+ add: (line, trimmed) => {
172
+ const question = /^(\d+)\.\s+(.*)$/.exec(trimmed)
173
+ if (question !== null) {
174
+ points.push(
175
+ OpenPoint.make({
176
+ number: Number.parseInt(question[1] ?? "0", 10),
177
+ question: (question[2] ?? "").trim()
178
+ })
179
+ )
180
+ return undefined
181
+ }
182
+ const answer = /^answer:\s*(.*)$/.exec(trimmed)
183
+ const last = points.at(-1)
184
+ if (last === undefined) {
185
+ return `line ${line}: open points are numbered \`1. <question>\`, got: ${trimmed}`
186
+ }
187
+ const text = answer === null ? trimmed : (answer[1] ?? "").trim()
188
+ points[points.length - 1] =
189
+ answer !== null || last.answer !== undefined
190
+ ? OpenPoint.make({
191
+ number: last.number,
192
+ question: last.question,
193
+ answer: last.answer === undefined ? text : `${last.answer} ${text}`
194
+ })
195
+ : // A continuation line of the question itself.
196
+ OpenPoint.make({ number: last.number, question: `${last.question} ${text}` })
197
+ return undefined
198
+ }
199
+ }
200
+ }
201
+
202
+ export const renderOpenPoints = (points: ReadonlyArray<OpenPoint>): ReadonlyArray<string> =>
203
+ points.flatMap((point) => [
204
+ `${point.number}. ${point.question}`,
205
+ ...(point.answer === undefined ? [] : [` answer: ${point.answer}`])
206
+ ])
207
+ const sectionNames = ["Programs", "Scenarios", "Deepen", "Open points"] as const
208
+ type Section = (typeof sectionNames)[number] | "other"
209
+
210
+ const signature = /\s*\(([^()]+?),\s*(\d{4}-\d{2}-\d{2})\)\s*$/
211
+ const milestoneTail = /;\s*milestone:\s*([^;]+?)\s*$/
212
+
213
+ interface Decided {
214
+ readonly reason: string
215
+ readonly pointer?: string
216
+ readonly milestone?: string
217
+ readonly decidedBy?: string
218
+ readonly decidedAt?: string
219
+ }
220
+
221
+ const parseDecided = (
222
+ disposition: Disposition,
223
+ rest: string,
224
+ line: number,
225
+ key: string
226
+ ): Decided | string => {
227
+ let body = rest.trim()
228
+ const signed = signature.exec(body)
229
+ const decidedBy = signed?.[1]?.trim()
230
+ const decidedAt = signed?.[2]
231
+ if (signed !== null) {
232
+ body = body.slice(0, signed.index).trim()
233
+ }
234
+ const milestoned = milestoneTail.exec(body)
235
+ const milestone = milestoned?.[1]?.trim()
236
+ if (milestoned !== null) {
237
+ body = body.slice(0, milestoned.index).trim()
238
+ }
239
+ let pointer: string | undefined
240
+ if (disposition === "provided") {
241
+ const semicolon = body.indexOf(";")
242
+ const candidate = (semicolon < 0 ? body : body.slice(0, semicolon)).trim()
243
+ if (candidate.length === 0 || /\s/.test(candidate)) {
244
+ return `line ${line}: 'provided' needs a target pointer before ';' — \`- ${key}: provided — <path>; <note>\``
245
+ }
246
+ pointer = candidate
247
+ body = semicolon < 0 ? "" : body.slice(semicolon + 1).trim()
248
+ } else if (body.length === 0) {
249
+ return `line ${line}: '${disposition}' needs a reason after '—' — \`- ${key}: ${disposition} — <why>\``
250
+ }
251
+ return {
252
+ reason: body,
253
+ ...(pointer === undefined ? {} : { pointer }),
254
+ ...(milestone === undefined ? {} : { milestone }),
255
+ ...(decidedBy === undefined ? {} : { decidedBy }),
256
+ ...(decidedAt === undefined ? {} : { decidedAt })
257
+ }
258
+ }
259
+
260
+ const entryLine = /^- (.+?): (\S+)(?:\s+—\s*(.*))?$/
261
+
262
+ const isDisposition = (word: string): word is Disposition =>
263
+ word === "drop" || word === "provided" || word === "defer" || word === "wrap"
264
+
265
+ export const parseDecisions = Effect.fn("@llm4ts/flow/Decisions.parse")(function* (
266
+ markdown: string,
267
+ path?: string
268
+ ): Effect.fn.Return<Decisions, DecisionsInvalid> {
269
+ const programs: Array<ProgramDecision> = []
270
+ const scenarios: Array<ScenarioDecision> = []
271
+ const marks: Array<ProposalMark> = []
272
+ const deepen: Array<DeepenMark> = []
273
+ const points = makeOpenPointsCollector()
274
+ const violations: Array<string> = []
275
+ let approved = false
276
+ let section: Section = "other"
277
+ let fenced = false
278
+ const lines = markdown.split(/\r?\n/)
279
+ for (let index = 0; index < lines.length; index += 1) {
280
+ const number = index + 1
281
+ const raw = lines[index] ?? ""
282
+ const trimmed = raw.trim()
283
+ if (trimmed.startsWith("```")) {
284
+ fenced = !fenced
285
+ continue
286
+ }
287
+ if (fenced) {
288
+ continue
289
+ }
290
+ const marker = parseApprovalMarker(trimmed)
291
+ if (marker !== undefined) {
292
+ approved = marker
293
+ continue
294
+ }
295
+ if (trimmed.startsWith("## ")) {
296
+ const title = trimmed.slice(3).trim()
297
+ section = sectionNames.find((name) => name === title) ?? "other"
298
+ continue
299
+ }
300
+ if (trimmed.startsWith("# ") || trimmed.length === 0) {
301
+ continue
302
+ }
303
+ if (section === "Programs" || section === "Scenarios") {
304
+ const match = entryLine.exec(trimmed)
305
+ if (match === null) {
306
+ violations.push(
307
+ `line ${number}: expected \`- <key>: <disposition> — <why>\`, got: ${trimmed}`
308
+ )
309
+ continue
310
+ }
311
+ const key = match[1]?.trim() ?? ""
312
+ const word = match[2] ?? ""
313
+ const rest = match[3] ?? ""
314
+ const slash = key.indexOf(" / ")
315
+ const program = (section === "Scenarios" && slash > 0 ? key.slice(0, slash) : key).trim()
316
+ const scenario =
317
+ section === "Scenarios" && slash > 0 ? key.slice(slash + 3).trim() : undefined
318
+ if (section === "Scenarios" && scenario === undefined) {
319
+ violations.push(
320
+ `line ${number}: scenario keys read \`<program> / <scenario title>\`, got: ${key}`
321
+ )
322
+ continue
323
+ }
324
+ if (word === "?") {
325
+ marks.push(
326
+ ProposalMark.make({
327
+ program,
328
+ ...(scenario === undefined ? {} : { scenario }),
329
+ note: rest.trim()
330
+ })
331
+ )
332
+ continue
333
+ }
334
+ if (!isDisposition(word)) {
335
+ violations.push(
336
+ `line ${number}: unknown disposition '${word}' (${dispositionWords.join(" | ")})`
337
+ )
338
+ continue
339
+ }
340
+ const decided = parseDecided(word, rest, number, key)
341
+ if (typeof decided === "string") {
342
+ violations.push(decided)
343
+ continue
344
+ }
345
+ if (scenario === undefined) {
346
+ programs.push(ProgramDecision.make({ program, disposition: word, ...decided }))
347
+ } else if (word === "wrap") {
348
+ violations.push(`line ${number}: 'wrap' applies to programs, not scenarios`)
349
+ } else {
350
+ scenarios.push(ScenarioDecision.make({ program, scenario, disposition: word, ...decided }))
351
+ }
352
+ continue
353
+ }
354
+ if (section === "Deepen") {
355
+ const match = /^- ([^:]+?)(?::\s*(.*))?$/.exec(trimmed)
356
+ const program = match?.[1]?.trim() ?? trimmed.replace(/^- /, "").trim()
357
+ const body = match?.[2]?.trim() ?? ""
358
+ const done = /\s*\[done ([^\]]+)\]\s*$/.exec(body)
359
+ const focus = done === null ? body : body.slice(0, done.index).trim()
360
+ if (focus.length === 0) {
361
+ violations.push(
362
+ `line ${number}: deepen marks need a focus — \`- ${program}: <what to look for>\``
363
+ )
364
+ continue
365
+ }
366
+ deepen.push(
367
+ DeepenMark.make({
368
+ program,
369
+ focus,
370
+ ...(done?.[1] === undefined ? {} : { done: done[1].trim() })
371
+ })
372
+ )
373
+ continue
374
+ }
375
+ if (section === "Open points") {
376
+ const violation = points.add(number, trimmed)
377
+ if (violation !== undefined) {
378
+ violations.push(violation)
379
+ }
380
+ }
381
+ }
382
+ if (violations.length > 0) {
383
+ return yield* DecisionsInvalid.make({ ...(path === undefined ? {} : { path }), violations })
384
+ }
385
+ return Decisions.make({
386
+ programs,
387
+ scenarios,
388
+ marks,
389
+ deepen,
390
+ openPoints: points.points,
391
+ approved
392
+ })
393
+ })
394
+
395
+ // ---- Render -----------------------------------------------------------------
396
+
397
+ const renderDecided = (disposition: Disposition, entry: Decided): string => {
398
+ const body =
399
+ disposition === "provided" ? `${entry.pointer ?? ""}; ${entry.reason}`.trimEnd() : entry.reason
400
+ const milestone = entry.milestone === undefined ? "" : `; milestone: ${entry.milestone}`
401
+ const signed =
402
+ entry.decidedBy === undefined || entry.decidedAt === undefined
403
+ ? ""
404
+ : ` (${entry.decidedBy}, ${entry.decidedAt})`
405
+ return `${disposition} — ${body}${milestone}${signed}`
406
+ }
407
+
408
+ export const renderDecisions = (decisions: Decisions): string => {
409
+ const programLines = [
410
+ ...decisions.programs.map(
411
+ (entry) => `- ${entry.program}: ${renderDecided(entry.disposition, entry)}`
412
+ ),
413
+ ...decisions.marks
414
+ .filter((mark) => mark.scenario === undefined)
415
+ .map((mark) => `- ${mark.program}: ? — ${mark.note}`.trimEnd())
416
+ ]
417
+ const scenarioLines = [
418
+ ...decisions.scenarios.map(
419
+ (entry) =>
420
+ `- ${entry.program} / ${entry.scenario}: ${renderDecided(entry.disposition, entry)}`
421
+ ),
422
+ ...decisions.marks
423
+ .filter((mark) => mark.scenario !== undefined)
424
+ .map((mark) => `- ${mark.program} / ${mark.scenario}: ? — ${mark.note}`.trimEnd())
425
+ ]
426
+ const deepenLines = decisions.deepen.map(
427
+ (mark) =>
428
+ `- ${mark.program}: ${mark.focus}${mark.done === undefined ? "" : ` [done ${mark.done}]`}`
429
+ )
430
+ const pointLines = renderOpenPoints(decisions.openPoints)
431
+ const sectionOf = (title: string, lines: ReadonlyArray<string>): string =>
432
+ [`## ${title}`, "", ...(lines.length === 0 ? [] : [...lines, ""])].join("\n")
433
+ return [
434
+ "# Decisions",
435
+ "",
436
+ decisionsGuide,
437
+ "",
438
+ sectionOf("Programs", programLines),
439
+ sectionOf("Scenarios", scenarioLines),
440
+ sectionOf("Deepen", deepenLines),
441
+ sectionOf("Open points", pointLines),
442
+ decisions.approved ? "- [x] Approved" : "- [ ] Approved",
443
+ ""
444
+ ].join("\n")
445
+ }
446
+
447
+ // ---- Validate ---------------------------------------------------------------
448
+
449
+ export interface KnownPack {
450
+ readonly programs: ReadonlySet<string>
451
+ /** Scenario titles per program, from the pack's .feature files. */
452
+ readonly scenarios: ReadonlyMap<string, ReadonlySet<string>>
453
+ }
454
+
455
+ /**
456
+ * Every key must name a program of the pack and, for scenario keys, a title
457
+ * of that program's feature file. Order: programs, scenarios, marks, deepen —
458
+ * the order the sections are written in, so a violation list reads top-down.
459
+ */
460
+ export const validateDecisions = (
461
+ decisions: Decisions,
462
+ known: KnownPack
463
+ ): ReadonlyArray<string> => {
464
+ const violations: Array<string> = []
465
+ const checkProgram = (program: string): boolean => {
466
+ if (known.programs.has(program)) {
467
+ return true
468
+ }
469
+ violations.push(`program '${program}' is not in the spec pack`)
470
+ return false
471
+ }
472
+ const checkScenario = (program: string, title: string): void => {
473
+ if (!checkProgram(program)) {
474
+ return
475
+ }
476
+ const titles = known.scenarios.get(program) ?? new Set<string>()
477
+ if (!titles.has(title)) {
478
+ violations.push(
479
+ `scenario '${title}' does not exist in ${program} (known: ${[...titles].join(", ") || "none"})`
480
+ )
481
+ }
482
+ }
483
+ for (const entry of decisions.programs) {
484
+ checkProgram(entry.program)
485
+ }
486
+ for (const entry of decisions.scenarios) {
487
+ checkScenario(entry.program, entry.scenario)
488
+ }
489
+ for (const mark of decisions.marks) {
490
+ if (mark.scenario === undefined) {
491
+ checkProgram(mark.program)
492
+ } else {
493
+ checkScenario(mark.program, mark.scenario)
494
+ }
495
+ }
496
+ for (const mark of decisions.deepen) {
497
+ checkProgram(mark.program)
498
+ }
499
+ return violations
500
+ }
501
+
502
+ // ---- Waived coverage units --------------------------------------------------
503
+
504
+ export interface WaivedUnit {
505
+ readonly unit: string
506
+ readonly program: string
507
+ /** The decision that waives it, as `<key>: <disposition>`. */
508
+ readonly by: string
509
+ }
510
+
511
+ export interface WaiverInputs {
512
+ /** Traceability fragments per program: `<UNIT> — <refs>` lines. */
513
+ readonly fragments: ReadonlyMap<string, string>
514
+ /** Scenario titles per program, so a ref to a surviving scenario keeps the unit live. */
515
+ readonly scenarios: ReadonlyMap<string, ReadonlySet<string>>
516
+ }
517
+
518
+ const fragmentUnits = (fragment: string): ReadonlyArray<readonly [unit: string, refs: string]> =>
519
+ fragment.split(/\r?\n/).flatMap((line) => {
520
+ const separator = line.indexOf(" — ")
521
+ if (separator <= 0) {
522
+ return []
523
+ }
524
+ const unit = line.slice(0, separator).trim()
525
+ return unit.length === 0 || unit.startsWith("Patterns:")
526
+ ? []
527
+ : [[unit, line.slice(separator + 3)] as const]
528
+ })
529
+
530
+ /**
531
+ * Coverage units the decisions waive, derived — never addressed directly.
532
+ * A program-level decision waives every unit of that program's traceability
533
+ * fragment. A scenario-level decision waives a unit only when its refs name
534
+ * at least one disposed scenario and no surviving scenario of that program:
535
+ * a unit still reached by a migrating scenario stays live.
536
+ */
537
+ export const waivedUnits = (
538
+ decisions: Decisions,
539
+ inputs: WaiverInputs
540
+ ): ReadonlyArray<WaivedUnit> => {
541
+ const waived: Array<WaivedUnit> = []
542
+ for (const entry of decisions.programs) {
543
+ const fragment = inputs.fragments.get(entry.program)
544
+ if (fragment === undefined) {
545
+ continue
546
+ }
547
+ for (const [unit] of fragmentUnits(fragment)) {
548
+ waived.push({ unit, program: entry.program, by: `${entry.program}: ${entry.disposition}` })
549
+ }
550
+ }
551
+ const byProgram = new Map<string, Array<ScenarioDecision>>()
552
+ for (const entry of decisions.scenarios) {
553
+ if (decisions.programDecision(entry.program) !== undefined) {
554
+ continue
555
+ }
556
+ byProgram.set(entry.program, [...(byProgram.get(entry.program) ?? []), entry])
557
+ }
558
+ for (const [program, entries] of byProgram) {
559
+ const fragment = inputs.fragments.get(program)
560
+ if (fragment === undefined) {
561
+ continue
562
+ }
563
+ const disposed = new Set(entries.map((entry) => entry.scenario))
564
+ const surviving = [...(inputs.scenarios.get(program) ?? [])].filter(
565
+ (title) => !disposed.has(title)
566
+ )
567
+ for (const [unit, refs] of fragmentUnits(fragment)) {
568
+ const hit = entries.find((entry) => refs.includes(entry.scenario))
569
+ if (hit === undefined || surviving.some((title) => refs.includes(title))) {
570
+ continue
571
+ }
572
+ waived.push({
573
+ unit,
574
+ program,
575
+ by: `${program} / ${hit.scenario}: ${hit.disposition}`
576
+ })
577
+ }
578
+ }
579
+ return waived
580
+ }
581
+
582
+ // ---- Feature files ----------------------------------------------------------
583
+
584
+ const scenarioHeading = /^\s*Scenario(?: Outline)?:\s*(.+?)\s*$/
585
+ const blockHeading = /^\s*(?:Scenario(?: Outline)?|Rule|Background):/
586
+
587
+ /** The scenario titles of a feature file, in order. */
588
+ export const scenarioTitles = (feature: string): ReadonlyArray<string> =>
589
+ feature.split(/\r?\n/).flatMap((line) => {
590
+ const match = scenarioHeading.exec(line)
591
+ return match?.[1] === undefined ? [] : [match[1]]
592
+ })
593
+
594
+ /**
595
+ * The feature file without the scenarios in `disposed` — the projection seed
596
+ * hands the target, so a coder never sees a scenario it must not encode.
597
+ * Background, Rule headers, and every surviving block are kept verbatim.
598
+ */
599
+ export const filterFeature = (feature: string, disposed: ReadonlySet<string>): string => {
600
+ const kept: Array<string> = []
601
+ let skipping = false
602
+ for (const line of feature.split(/\r?\n/)) {
603
+ if (blockHeading.test(line)) {
604
+ const title = scenarioHeading.exec(line)?.[1]
605
+ skipping = title !== undefined && disposed.has(title)
606
+ }
607
+ if (!skipping) {
608
+ kept.push(line)
609
+ }
610
+ }
611
+ return kept.join("\n")
612
+ }
613
+
614
+ // ---- Proposal (model) ----------------------------------------------------------
615
+
616
+ /** One disposition the model proposes; `key` is a program or `<program> / <title>`. */
617
+ export class ProposedDecision extends Schema.Class<ProposedDecision>("ProposedDecision")({
618
+ key: Schema.String,
619
+ disposition: Disposition,
620
+ reason: Schema.String,
621
+ pointer: Schema.optionalKey(Schema.String)
622
+ }) {}
623
+
624
+ export class DecisionsProposal extends Schema.Class<DecisionsProposal>("DecisionsProposal")({
625
+ decisions: Schema.Array(ProposedDecision),
626
+ openPoints: Schema.Array(Schema.String)
627
+ }) {}
628
+
629
+ export const decisionsProposalJsonSchema: JsonSchema = {
630
+ type: "object",
631
+ properties: {
632
+ decisions: {
633
+ type: "array",
634
+ items: {
635
+ type: "object",
636
+ properties: {
637
+ key: { type: "string" },
638
+ disposition: { type: "string", enum: ["drop", "provided", "defer", "wrap"] },
639
+ reason: { type: "string" },
640
+ pointer: { type: "string" }
641
+ },
642
+ required: ["key", "disposition", "reason"]
643
+ }
644
+ },
645
+ openPoints: { type: "array", items: { type: "string" } }
646
+ },
647
+ required: ["decisions", "openPoints"]
648
+ }
649
+
650
+ export interface ProposeOptions {
651
+ /** Whether a read-only target workspace is mounted for `provided` evidence. */
652
+ readonly targetMounted: boolean
653
+ /** The pack's `prompts/refine-propose.md` paragraph. */
654
+ readonly packParagraph?: string
655
+ }
656
+
657
+ const markKey = (mark: ProposalMark): string =>
658
+ mark.scenario === undefined ? mark.program : `${mark.program} / ${mark.scenario}`
659
+
660
+ /**
661
+ * The prune proposal ask: every `?` mark with its note, the decisions a
662
+ * human already took (context, never to be overridden), the answered open
663
+ * points as guidance, and the marked programs' specs and features.
664
+ */
665
+ export const proposePrompt = (
666
+ decisions: Decisions,
667
+ specs: ReadonlyArray<{ readonly name: string; readonly spec: string; readonly feature: string }>,
668
+ options: ProposeOptions
669
+ ): string =>
670
+ [
671
+ "Propose dispositions for the marked programs and scenarios of this legacy spec pack.",
672
+ "",
673
+ "A disposition says what the extracted behaviour should become in the target:",
674
+ "- drop: deprecated, will not exist in the target — only with evidence from the specs",
675
+ " (dead route, expired campaign, developer harness, a technology with no target equivalent);",
676
+ options.targetMounted
677
+ ? "- provided: the target already has it or solves it differently — ONLY after reading the\n" +
678
+ " target workspace you are running in and naming, as `pointer`, the existing file that\n" +
679
+ " proves it (a path relative to the target root);"
680
+ : "- provided: NOT available in this run (no target workspace is mounted) — never propose it;",
681
+ "- wrap: a whole program that stays on the legacy platform behind an API.",
682
+ "Never propose defer: deferral is a delivery decision, not something the source evidences.",
683
+ "Also add consequential entries: a scenario or program that only makes sense with one you",
684
+ "drop (an orphaned route, a page nothing links to any more) gets its own entry.",
685
+ "Anything you cannot decide from the evidence goes in `openPoints` as a question, never a guess.",
686
+ ...(options.packParagraph === undefined || options.packParagraph.trim().length === 0
687
+ ? []
688
+ : ["", options.packParagraph.trim()]),
689
+ "",
690
+ "Marks to resolve:",
691
+ ...decisions.marks.map(
692
+ (mark) => `- ${markKey(mark)}${mark.note.length === 0 ? "" : ` — ${mark.note}`}`
693
+ ),
694
+ ...(decisions.programs.length + decisions.scenarios.length === 0
695
+ ? []
696
+ : [
697
+ "",
698
+ "Decisions already taken by a human (context only — do not repeat or contradict them):",
699
+ ...decisions.programs.map(
700
+ (entry) => `- ${entry.program}: ${entry.disposition} — ${entry.reason}`
701
+ ),
702
+ ...decisions.scenarios.map(
703
+ (entry) =>
704
+ `- ${entry.program} / ${entry.scenario}: ${entry.disposition} — ${entry.reason}`
705
+ )
706
+ ]),
707
+ ...(decisions.openPoints.some((point) => point.answer !== undefined)
708
+ ? [
709
+ "",
710
+ "Answers the human gave to earlier questions — treat them as instructions:",
711
+ ...decisions.openPoints
712
+ .filter((point) => point.answer !== undefined)
713
+ .map((point) => `- Q: ${point.question}\n A: ${point.answer ?? ""}`)
714
+ ]
715
+ : []),
716
+ "",
717
+ "Specs and features of the marked programs:",
718
+ ...specs.flatMap((entry) => ["", `===== ${entry.name} =====`, entry.spec, "", entry.feature]),
719
+ "",
720
+ 'Respond only with JSON: {"decisions":[{"key":"<program> or <program> / <scenario title>",',
721
+ '"disposition":"drop|provided|wrap","reason":"…","pointer":"<target path, provided only>"}],',
722
+ '"openPoints":["…"]}'
723
+ ].join("\n")
724
+
725
+ export interface ApplyProposalOptions {
726
+ /** Whether a `provided` pointer names something that exists in the target. */
727
+ readonly pointerExists: (pointer: string) => boolean
728
+ readonly known: KnownPack
729
+ /** The signature written on proposed entries; the human's approval covers them. */
730
+ readonly decidedBy?: string
731
+ readonly decidedAt: string
732
+ }
733
+
734
+ const splitKey = (key: string): { program: string; scenario?: string } => {
735
+ const slash = key.indexOf(" / ")
736
+ return slash > 0
737
+ ? { program: key.slice(0, slash).trim(), scenario: key.slice(slash + 3).trim() }
738
+ : { program: key.trim() }
739
+ }
740
+
741
+ /**
742
+ * Folds a proposal into the decisions. A proposed entry resolves the mark
743
+ * with the same key (the mark is removed) or lands as a consequential entry;
744
+ * it never overrides a decision a human already took. Anything the rules
745
+ * refuse — a `defer`, a `provided` without a real pointer, a `wrap` on a
746
+ * scenario, a key the pack does not have, a mark left unresolved — becomes
747
+ * an open point instead of a silent choice. Answered open points are
748
+ * consumed; the rest are renumbered after the survivors.
749
+ */
750
+ export const applyProposal = (
751
+ decisions: Decisions,
752
+ proposal: DecisionsProposal,
753
+ options: ApplyProposalOptions
754
+ ): Decisions => {
755
+ const programs = [...decisions.programs]
756
+ const scenarios = [...decisions.scenarios]
757
+ const questions: Array<string> = []
758
+ const resolved = new Set<string>()
759
+ const signature = {
760
+ ...(options.decidedBy === undefined ? {} : { decidedBy: options.decidedBy }),
761
+ decidedAt: options.decidedAt
762
+ }
763
+ const decided = (program: string, scenario?: string): boolean =>
764
+ scenario === undefined
765
+ ? programs.some((entry) => entry.program === program)
766
+ : scenarios.some((entry) => entry.program === program && entry.scenario === scenario)
767
+ for (const proposed of proposal.decisions) {
768
+ const { program, scenario } = splitKey(proposed.key)
769
+ const key = scenario === undefined ? program : `${program} / ${scenario}`
770
+ if (!options.known.programs.has(program)) {
771
+ questions.push(
772
+ `The proposal named '${key}', which is not a program of the pack — ignore or fix the key?`
773
+ )
774
+ continue
775
+ }
776
+ if (scenario !== undefined && !(options.known.scenarios.get(program)?.has(scenario) ?? false)) {
777
+ questions.push(
778
+ `The proposal named scenario '${key}', which ${program} does not have — ignore or fix the title?`
779
+ )
780
+ continue
781
+ }
782
+ if (decided(program, scenario)) {
783
+ continue
784
+ }
785
+ if (proposed.disposition === "defer") {
786
+ questions.push(
787
+ `The model would defer '${key}' (${proposed.reason}) — deferral is yours: mark it defer, drop, or leave it migrating`
788
+ )
789
+ continue
790
+ }
791
+ if (proposed.disposition === "wrap" && scenario !== undefined) {
792
+ questions.push(
793
+ `The model proposed wrap for scenario '${key}' — wrap applies to programs; drop it or leave it?`
794
+ )
795
+ continue
796
+ }
797
+ if (proposed.disposition === "provided") {
798
+ const pointer = proposed.pointer?.trim() ?? ""
799
+ if (pointer.length === 0 || !options.pointerExists(pointer)) {
800
+ questions.push(
801
+ `'${key}' was proposed as provided by '${pointer || "(no pointer)"}', which does not exist in the target — fix the pointer or choose drop`
802
+ )
803
+ continue
804
+ }
805
+ const entry = {
806
+ disposition: proposed.disposition,
807
+ reason: proposed.reason,
808
+ pointer,
809
+ ...signature
810
+ }
811
+ if (scenario === undefined) {
812
+ programs.push(ProgramDecision.make({ program, ...entry }))
813
+ } else {
814
+ scenarios.push(ScenarioDecision.make({ program, scenario, ...entry }))
815
+ }
816
+ } else {
817
+ const entry = { disposition: proposed.disposition, reason: proposed.reason, ...signature }
818
+ if (scenario === undefined) {
819
+ programs.push(ProgramDecision.make({ program, ...entry }))
820
+ } else {
821
+ scenarios.push(ScenarioDecision.make({ program, scenario, ...entry }))
822
+ }
823
+ }
824
+ resolved.add(key)
825
+ }
826
+ const marks = decisions.marks.filter((mark) => !resolved.has(markKey(mark)))
827
+ for (const mark of marks) {
828
+ questions.push(
829
+ `No disposition could be proposed for '${markKey(mark)}'${mark.note.length === 0 ? "" : ` (${mark.note})`} — decide it by hand or answer here`
830
+ )
831
+ }
832
+ const kept = decisions.openPoints.filter((point) => point.answer === undefined)
833
+ const openPoints = [...kept.map((point) => point.question), ...proposal.openPoints, ...questions]
834
+ .filter((question, index, all) => all.indexOf(question) === index)
835
+ .map((question, index) => OpenPoint.make({ number: index + 1, question }))
836
+ return Decisions.make({
837
+ programs,
838
+ scenarios,
839
+ marks: [],
840
+ deepen: decisions.deepen,
841
+ openPoints,
842
+ approved: false
843
+ })
844
+ }