@llm4ts/flow 2.1.0 → 2.2.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 (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
package/src/Domains.ts ADDED
@@ -0,0 +1,650 @@
1
+ import * as Effect from "effect/Effect"
2
+ import * as Schema from "effect/Schema"
3
+ import type { JsonSchema } from "@llm4ts/core/Models"
4
+ import {
5
+ OpenPoint,
6
+ makeOpenPointsCollector,
7
+ parseApprovalMarker,
8
+ renderOpenPoints
9
+ } from "./Decisions.ts"
10
+ import { DecisionsInvalid } from "./FlowError.ts"
11
+ import { stableHash } from "./Plan.ts"
12
+ import type { SurveyGraph } from "./Survey.ts"
13
+
14
+ export { DecisionsInvalid } from "./FlowError.ts"
15
+
16
+ /**
17
+ * The grouping overlay of a modernization spec pack (ADR 0015): extraction is
18
+ * per program, delivery is per DOMAIN FEATURE — the pages that share a form
19
+ * target, the steps of one wizard, the shell of included fragments. Seeded
20
+ * deterministically from the survey graph by the pack's `## Consolidate`
21
+ * rules, named and adjusted by the model, approved by a human, and the input
22
+ * `plan.md` is regenerated from.
23
+ */
24
+
25
+ // ---- Deterministic clustering ------------------------------------------------
26
+
27
+ export interface ConsolidateRules {
28
+ /** Survey edge kinds that put two units in the same feature. `llm-*` matches by prefix. */
29
+ readonly cluster: ReadonlyArray<string>
30
+ /** Edge kinds whose target attaches as shared context (a fragment) and never clusters. */
31
+ readonly context: ReadonlyArray<string>
32
+ }
33
+
34
+ export interface Cluster {
35
+ readonly programs: ReadonlyArray<string>
36
+ /** Fragments the cluster's programs include, transitively. */
37
+ readonly context: ReadonlyArray<string>
38
+ /** A cluster of fragments themselves: the shell everything includes. */
39
+ readonly shell: boolean
40
+ }
41
+
42
+ const kindMatches = (kind: string, patterns: ReadonlyArray<string>): boolean =>
43
+ patterns.some((pattern) =>
44
+ pattern.endsWith("*") ? kind.startsWith(pattern.slice(0, -1)) : kind === pattern
45
+ )
46
+
47
+ class UnionFind {
48
+ private readonly parent = new Map<string, string>()
49
+
50
+ find(name: string): string {
51
+ const parent = this.parent.get(name)
52
+ if (parent === undefined || parent === name) {
53
+ this.parent.set(name, name)
54
+ return name
55
+ }
56
+ const root = this.find(parent)
57
+ this.parent.set(name, root)
58
+ return root
59
+ }
60
+
61
+ union(left: string, right: string): void {
62
+ const a = this.find(left)
63
+ const b = this.find(right)
64
+ if (a !== b) {
65
+ this.parent.set(a < b ? b : a, a < b ? a : b)
66
+ }
67
+ }
68
+ }
69
+
70
+ const byFirst = (left: Cluster, right: Cluster): number =>
71
+ (left.programs[0] ?? "").localeCompare(right.programs[0] ?? "")
72
+
73
+ /**
74
+ * Programs grouped by the pack's cluster edges: two units joined by an edge of
75
+ * a `cluster:` kind land in one feature, through any intermediate unit (a
76
+ * shared form target, a servlet). Programs that are the target of a
77
+ * `context:` edge are fragments: their own edges never merge anything, they
78
+ * attach to the clusters that include them, and they form shell clusters
79
+ * among themselves. Shell clusters first, then by first program name.
80
+ */
81
+ export const clusterPrograms = (
82
+ graph: SurveyGraph,
83
+ programs: ReadonlyArray<string>,
84
+ rules: ConsolidateRules
85
+ ): ReadonlyArray<Cluster> => {
86
+ const programSet = new Set(programs)
87
+ const contextEdges = graph.edges.filter((edge) => kindMatches(edge.kind, rules.context))
88
+ const fragments = new Set(
89
+ contextEdges.map((edge) => edge.to).filter((name) => programSet.has(name))
90
+ )
91
+ const pages = new UnionFind()
92
+ for (const program of programs) {
93
+ pages.find(program)
94
+ }
95
+ for (const edge of graph.edges) {
96
+ if (
97
+ kindMatches(edge.kind, rules.cluster) &&
98
+ !fragments.has(edge.from) &&
99
+ !fragments.has(edge.to)
100
+ ) {
101
+ pages.union(edge.from, edge.to)
102
+ }
103
+ }
104
+ const shell = new UnionFind()
105
+ for (const fragment of fragments) {
106
+ shell.find(fragment)
107
+ }
108
+ for (const edge of contextEdges) {
109
+ if (fragments.has(edge.from) && fragments.has(edge.to)) {
110
+ shell.union(edge.from, edge.to)
111
+ }
112
+ }
113
+ const includes = new Map<string, Array<string>>()
114
+ for (const edge of contextEdges) {
115
+ if (fragments.has(edge.to)) {
116
+ includes.set(edge.from, [...(includes.get(edge.from) ?? []), edge.to])
117
+ }
118
+ }
119
+ const contextOf = (members: ReadonlyArray<string>): ReadonlyArray<string> => {
120
+ const seen = new Set<string>()
121
+ const frontier = [...members]
122
+ while (frontier.length > 0) {
123
+ const current = frontier.pop()
124
+ if (current === undefined) {
125
+ break
126
+ }
127
+ for (const included of includes.get(current) ?? []) {
128
+ if (!seen.has(included)) {
129
+ seen.add(included)
130
+ frontier.push(included)
131
+ }
132
+ }
133
+ }
134
+ return [...seen].sort()
135
+ }
136
+ const groups = new Map<string, Array<string>>()
137
+ for (const program of programs) {
138
+ if (fragments.has(program)) {
139
+ continue
140
+ }
141
+ const root = pages.find(program)
142
+ groups.set(root, [...(groups.get(root) ?? []), program])
143
+ }
144
+ const shells = new Map<string, Array<string>>()
145
+ for (const fragment of fragments) {
146
+ const root = shell.find(fragment)
147
+ shells.set(root, [...(shells.get(root) ?? []), fragment])
148
+ }
149
+ const shellClusters: Array<Cluster> = [...shells.values()]
150
+ .map((members) => ({ programs: [...members].sort(), context: [], shell: true }))
151
+ .sort(byFirst)
152
+ const pageClusters: Array<Cluster> = [...groups.values()]
153
+ .map((members) => {
154
+ const sorted = [...members].sort()
155
+ return { programs: sorted, context: contextOf(sorted), shell: false }
156
+ })
157
+ .sort(byFirst)
158
+ return [...shellClusters, ...pageClusters]
159
+ }
160
+
161
+ // ---- The map ------------------------------------------------------------------
162
+
163
+ export class ScenarioRef extends Schema.Class<ScenarioRef>("ScenarioRef")({
164
+ program: Schema.String,
165
+ title: Schema.String
166
+ }) {}
167
+
168
+ export class FeatureScenario extends Schema.Class<FeatureScenario>("FeatureScenario")({
169
+ program: Schema.String,
170
+ title: Schema.String,
171
+ /** Scenarios of other programs this one absorbs as the same behaviour. */
172
+ mergedFrom: Schema.Array(ScenarioRef)
173
+ }) {}
174
+
175
+ export class DomainFeature extends Schema.Class<DomainFeature>("DomainFeature")({
176
+ /** kebab-case, the branch and contract name downstream. */
177
+ id: Schema.String,
178
+ name: Schema.String,
179
+ programs: Schema.Array(Schema.String),
180
+ context: Schema.Array(Schema.String),
181
+ scenarios: Schema.Array(FeatureScenario),
182
+ evidence: Schema.String
183
+ }) {}
184
+
185
+ export class Domains extends Schema.Class<Domains>("Domains")({
186
+ features: Schema.Array(DomainFeature),
187
+ /** Hash of the specs and decisions the map was built from — staleness check. */
188
+ inputsHash: Schema.String,
189
+ openPoints: Schema.Array(OpenPoint),
190
+ approved: Schema.Boolean
191
+ }) {
192
+ get unansweredOpenPoints(): ReadonlyArray<OpenPoint> {
193
+ return this.openPoints.filter((point) => point.answer === undefined)
194
+ }
195
+
196
+ featureOf(program: string): DomainFeature | undefined {
197
+ return this.features.find((feature) => feature.programs.includes(program))
198
+ }
199
+ }
200
+
201
+ const scenarioKey = (program: string, title: string): string => `${program} / ${title}`
202
+
203
+ /**
204
+ * Every surviving scenario of the pack must be assigned to exactly one
205
+ * feature, directly or as a `mergedFrom` source; a feature may not claim a
206
+ * scenario the pack does not have. Deterministic, run before any human looks.
207
+ */
208
+ export const checkExactlyOnce = (
209
+ domains: Domains,
210
+ surviving: ReadonlyMap<string, ReadonlySet<string>>
211
+ ): ReadonlyArray<string> => {
212
+ const violations: Array<string> = []
213
+ const assigned = new Map<string, Array<string>>()
214
+ const known = (program: string, title: string): boolean =>
215
+ surviving.get(program)?.has(title) ?? false
216
+ for (const feature of domains.features) {
217
+ for (const scenario of feature.scenarios) {
218
+ const refs = [{ program: scenario.program, title: scenario.title }, ...scenario.mergedFrom]
219
+ for (const ref of refs) {
220
+ const key = scenarioKey(ref.program, ref.title)
221
+ const owners = assigned.get(key) ?? []
222
+ owners.push(feature.id)
223
+ assigned.set(key, owners)
224
+ if (owners.length === 2) {
225
+ violations.push(`scenario '${key}' is assigned twice (${owners.join(", ")})`)
226
+ } else if (owners.length === 1 && !known(ref.program, ref.title)) {
227
+ violations.push(
228
+ `scenario '${key}' in feature ${feature.id} is not a surviving scenario of the pack`
229
+ )
230
+ }
231
+ }
232
+ }
233
+ }
234
+ for (const [program, titles] of surviving) {
235
+ for (const title of titles) {
236
+ if (!assigned.has(scenarioKey(program, title))) {
237
+ violations.push(`scenario '${scenarioKey(program, title)}' is assigned to no feature`)
238
+ }
239
+ }
240
+ }
241
+ return violations
242
+ }
243
+
244
+ // ---- Guide, render, parse ------------------------------------------------------
245
+
246
+ export const domainsGuide = [
247
+ "## How to read this map",
248
+ "",
249
+ "Each `## Feature:` groups the programs that deliver one domain feature, the",
250
+ "fragments they include as `context:`, and every surviving scenario it absorbs,",
251
+ "one per line as `- <program> / <scenario title>`. A scenario that describes",
252
+ "the same behaviour as another page's is listed once with `(merged from: …)`.",
253
+ "Every surviving scenario of the pack appears exactly once across the map.",
254
+ "The clusters were seeded from the survey graph by the pack's `## Consolidate`",
255
+ "rules; the model named them and proposed merges and folds, each with",
256
+ "`evidence:`. Edit freely: move a scenario line, rename a feature, split or",
257
+ "join sections. Answer `## Open points` with an indented `answer: …` line and",
258
+ "rerun; flip the marker at the end to approve. `inputs:` is the hash of the",
259
+ "specs and decisions this map was built from — the flow regroups when it",
260
+ "changes, or on request.",
261
+ ""
262
+ ].join("\n")
263
+
264
+ export const renderDomains = (domains: Domains): string => {
265
+ const blocks = domains.features.map((feature) =>
266
+ [
267
+ `## Feature: ${feature.name} (${feature.id})`,
268
+ "",
269
+ `programs: ${feature.programs.join(", ")}`,
270
+ `context: ${feature.context.join(", ")}`,
271
+ `evidence: ${feature.evidence}`,
272
+ "",
273
+ ...feature.scenarios.map((scenario) => {
274
+ const merged =
275
+ scenario.mergedFrom.length === 0
276
+ ? ""
277
+ : ` (merged from: ${scenario.mergedFrom
278
+ .map((ref) => scenarioKey(ref.program, ref.title))
279
+ .join("; ")})`
280
+ return `- ${scenarioKey(scenario.program, scenario.title)}${merged}`
281
+ }),
282
+ ""
283
+ ].join("\n")
284
+ )
285
+ return [
286
+ "# Domain features",
287
+ "",
288
+ domainsGuide,
289
+ `inputs: ${domains.inputsHash}`,
290
+ "",
291
+ ...blocks,
292
+ "## Open points",
293
+ "",
294
+ ...renderOpenPoints(domains.openPoints),
295
+ ...(domains.openPoints.length === 0 ? [] : [""]),
296
+ domains.approved ? "- [x] Approved" : "- [ ] Approved",
297
+ ""
298
+ ].join("\n")
299
+ }
300
+
301
+ const featureHeading = /^## Feature: (.+?) \(([a-z0-9][a-z0-9-]*)\)\s*$/
302
+ const scenarioLine = /^- (.+?) \/ (.+?)(?: \(merged from: (.+)\))?$/
303
+
304
+ interface FeatureDraft {
305
+ id: string
306
+ name: string
307
+ programs: Array<string>
308
+ context: Array<string>
309
+ scenarios: Array<FeatureScenario>
310
+ evidence: string
311
+ }
312
+
313
+ const list = (value: string): Array<string> =>
314
+ value
315
+ .split(",")
316
+ .map((item) => item.trim())
317
+ .filter((item) => item.length > 0)
318
+
319
+ export const parseDomains = Effect.fn("@llm4ts/flow/Domains.parse")(function* (
320
+ markdown: string,
321
+ path?: string
322
+ ): Effect.fn.Return<Domains, DecisionsInvalid> {
323
+ const features: Array<DomainFeature> = []
324
+ const violations: Array<string> = []
325
+ const points = makeOpenPointsCollector()
326
+ let inputsHash = ""
327
+ let approved = false
328
+ let draft: FeatureDraft | undefined
329
+ let section: "feature" | "open" | "other" = "other"
330
+ let fenced = false
331
+ const close = (): void => {
332
+ if (draft === undefined) {
333
+ return
334
+ }
335
+ if (draft.programs.length === 0) {
336
+ violations.push(`feature '${draft.id}' lists no programs`)
337
+ }
338
+ features.push(DomainFeature.make(draft))
339
+ draft = undefined
340
+ }
341
+ const lines = markdown.split(/\r?\n/)
342
+ for (let index = 0; index < lines.length; index += 1) {
343
+ const number = index + 1
344
+ const trimmed = (lines[index] ?? "").trim()
345
+ if (trimmed.startsWith("```")) {
346
+ fenced = !fenced
347
+ continue
348
+ }
349
+ if (fenced || trimmed.length === 0 || trimmed.startsWith("# ")) {
350
+ continue
351
+ }
352
+ const marker = parseApprovalMarker(trimmed)
353
+ if (marker !== undefined) {
354
+ approved = marker
355
+ continue
356
+ }
357
+ const heading = featureHeading.exec(trimmed)
358
+ if (heading !== null) {
359
+ close()
360
+ draft = {
361
+ id: heading[2] ?? "",
362
+ name: (heading[1] ?? "").trim(),
363
+ programs: [],
364
+ context: [],
365
+ scenarios: [],
366
+ evidence: ""
367
+ }
368
+ section = "feature"
369
+ continue
370
+ }
371
+ if (trimmed.startsWith("## ")) {
372
+ close()
373
+ section = trimmed === "## Open points" ? "open" : "other"
374
+ continue
375
+ }
376
+ if (section === "other") {
377
+ const inputs = /^inputs:\s*(\S+)\s*$/.exec(trimmed)
378
+ if (inputs?.[1] !== undefined) {
379
+ inputsHash = inputs[1]
380
+ }
381
+ continue
382
+ }
383
+ if (section === "open") {
384
+ const violation = points.add(number, trimmed)
385
+ if (violation !== undefined) {
386
+ violations.push(violation)
387
+ }
388
+ continue
389
+ }
390
+ if (draft === undefined) {
391
+ continue
392
+ }
393
+ const field = /^(programs|context|evidence):\s*(.*)$/.exec(trimmed)
394
+ if (field !== null) {
395
+ const value = field[2] ?? ""
396
+ if (field[1] === "programs") {
397
+ draft.programs = list(value)
398
+ } else if (field[1] === "context") {
399
+ draft.context = list(value)
400
+ } else {
401
+ draft.evidence = value.trim()
402
+ }
403
+ continue
404
+ }
405
+ if (trimmed.startsWith("- ")) {
406
+ const match = scenarioLine.exec(trimmed)
407
+ if (match === null) {
408
+ violations.push(
409
+ `line ${number}: scenario lines read \`- <program> / <title>\`, got: ${trimmed.slice(2)}`
410
+ )
411
+ continue
412
+ }
413
+ const mergedFrom = (match[3] ?? "")
414
+ .split(";")
415
+ .map((item) => item.trim())
416
+ .filter((item) => item.length > 0)
417
+ .flatMap((item) => {
418
+ const slash = item.indexOf(" / ")
419
+ return slash <= 0
420
+ ? []
421
+ : [ScenarioRef.make({ program: item.slice(0, slash), title: item.slice(slash + 3) })]
422
+ })
423
+ draft.scenarios.push(
424
+ FeatureScenario.make({
425
+ program: (match[1] ?? "").trim(),
426
+ title: (match[2] ?? "").trim(),
427
+ mergedFrom
428
+ })
429
+ )
430
+ }
431
+ }
432
+ close()
433
+ if (violations.length > 0) {
434
+ return yield* DecisionsInvalid.make({ ...(path === undefined ? {} : { path }), violations })
435
+ }
436
+ return Domains.make({ features, inputsHash, openPoints: points.points, approved })
437
+ })
438
+
439
+ // ---- Proposal (model) ----------------------------------------------------------
440
+
441
+ export class ProposedScenario extends Schema.Class<ProposedScenario>("ProposedScenario")({
442
+ program: Schema.String,
443
+ title: Schema.String,
444
+ mergedFrom: Schema.optionalKey(Schema.Array(ScenarioRef))
445
+ }) {}
446
+
447
+ export class ProposedFeature extends Schema.Class<ProposedFeature>("ProposedFeature")({
448
+ id: Schema.String,
449
+ name: Schema.String,
450
+ programs: Schema.Array(Schema.String),
451
+ scenarios: Schema.Array(ProposedScenario),
452
+ evidence: Schema.String
453
+ }) {}
454
+
455
+ export class DomainProposal extends Schema.Class<DomainProposal>("DomainProposal")({
456
+ features: Schema.Array(ProposedFeature),
457
+ openPoints: Schema.Array(Schema.String)
458
+ }) {}
459
+
460
+ const scenarioRefJsonSchema: JsonSchema = {
461
+ type: "object",
462
+ properties: { program: { type: "string" }, title: { type: "string" } },
463
+ required: ["program", "title"]
464
+ }
465
+
466
+ export const domainProposalJsonSchema: JsonSchema = {
467
+ type: "object",
468
+ properties: {
469
+ features: {
470
+ type: "array",
471
+ items: {
472
+ type: "object",
473
+ properties: {
474
+ id: { type: "string" },
475
+ name: { type: "string" },
476
+ programs: { type: "array", items: { type: "string" } },
477
+ scenarios: {
478
+ type: "array",
479
+ items: {
480
+ type: "object",
481
+ properties: {
482
+ program: { type: "string" },
483
+ title: { type: "string" },
484
+ mergedFrom: { type: "array", items: scenarioRefJsonSchema }
485
+ },
486
+ required: ["program", "title"]
487
+ }
488
+ },
489
+ evidence: { type: "string" }
490
+ },
491
+ required: ["id", "name", "programs", "scenarios", "evidence"]
492
+ }
493
+ },
494
+ openPoints: { type: "array", items: { type: "string" } }
495
+ },
496
+ required: ["features", "openPoints"]
497
+ }
498
+
499
+ /**
500
+ * The consolidation ask: the deterministic clusters and every surviving
501
+ * scenario, the rules the answer must keep (every scenario exactly once,
502
+ * clusters may be split, joined, or folded only with evidence), and the
503
+ * pack's stack-specific paragraph on what a feature is.
504
+ */
505
+ export const consolidatePrompt = (
506
+ clusters: ReadonlyArray<Cluster>,
507
+ scenarios: ReadonlyMap<string, ReadonlySet<string>>,
508
+ packParagraph?: string
509
+ ): string => {
510
+ const clusterLines = clusters.map(
511
+ (cluster, index) =>
512
+ `${index + 1}. ${cluster.shell ? "shell fragments" : "programs"}: ${cluster.programs.join(", ")}` +
513
+ (cluster.context.length === 0 ? "" : ` (includes: ${cluster.context.join(", ")})`)
514
+ )
515
+ const scenarioLines = [...scenarios.entries()].flatMap(([program, titles]) => [
516
+ `${program}:`,
517
+ ...[...titles].map((title) => ` - ${title}`)
518
+ ])
519
+ return [
520
+ "Name the domain features of this legacy estate and assign every surviving scenario to one.",
521
+ "",
522
+ "The clusters below were derived deterministically from the dependency graph (units that",
523
+ "share a form target, a wizard, or a servlet; fragments attach as included context). Each",
524
+ "cluster is a candidate feature. You may split a cluster, join two, or fold single-page",
525
+ "clusters into one feature (e.g. a 'Portal shell and static pages' feature), but ONLY with",
526
+ "evidence from the specs, stated in the feature's `evidence`.",
527
+ "",
528
+ "Rules:",
529
+ "- Every scenario listed below appears EXACTLY once across the features, either as its own",
530
+ " line or inside another scenario's `mergedFrom` when two pages describe the same behaviour.",
531
+ "- Feature names are business language; `id` is kebab-case and unique.",
532
+ "- Anything you could not decide from the specs goes in `openPoints` as a question for the",
533
+ " human, never as a guess.",
534
+ ...(packParagraph === undefined || packParagraph.trim().length === 0
535
+ ? []
536
+ : ["", packParagraph.trim()]),
537
+ "",
538
+ "Clusters:",
539
+ ...clusterLines,
540
+ "",
541
+ "Surviving scenarios per program:",
542
+ ...scenarioLines,
543
+ "",
544
+ 'Respond only with JSON: {"features":[{"id":"…","name":"…","programs":["…"],',
545
+ '"scenarios":[{"program":"…","title":"…","mergedFrom":[{"program":"…","title":"…"}]}],',
546
+ '"evidence":"…"}],"openPoints":["…"]}'
547
+ ].join("\n")
548
+ }
549
+
550
+ /** The map from a proposal: context comes from the clusters, open points are numbered. */
551
+ export const domainsFromProposal = (
552
+ proposal: DomainProposal,
553
+ clusters: ReadonlyArray<Cluster>,
554
+ inputsHash: string
555
+ ): Domains =>
556
+ Domains.make({
557
+ features: proposal.features.map((feature) => {
558
+ const context = new Set<string>()
559
+ for (const cluster of clusters) {
560
+ if (cluster.programs.some((program) => feature.programs.includes(program))) {
561
+ for (const fragment of cluster.context) {
562
+ context.add(fragment)
563
+ }
564
+ }
565
+ }
566
+ return DomainFeature.make({
567
+ id: feature.id,
568
+ name: feature.name,
569
+ programs: feature.programs,
570
+ context: [...context].sort(),
571
+ scenarios: feature.scenarios.map((scenario) =>
572
+ FeatureScenario.make({
573
+ program: scenario.program,
574
+ title: scenario.title,
575
+ mergedFrom: scenario.mergedFrom ?? []
576
+ })
577
+ ),
578
+ evidence: feature.evidence
579
+ })
580
+ }),
581
+ inputsHash,
582
+ openPoints: proposal.openPoints.map((question, index) =>
583
+ OpenPoint.make({ number: index + 1, question })
584
+ ),
585
+ approved: false
586
+ })
587
+
588
+ /** Stable over key order; changes with any spec or the decisions text. */
589
+ export const domainsInputsHash = (
590
+ specs: Readonly<Record<string, string>>,
591
+ decisionsText: string
592
+ ): string =>
593
+ stableHash(
594
+ [
595
+ ...Object.entries(specs)
596
+ .sort(([left], [right]) => left.localeCompare(right))
597
+ .map(([name, text]) => `${name}\n${text}`),
598
+ decisionsText
599
+ ].join("\n\n")
600
+ )
601
+
602
+ // ---- Navigation order ----------------------------------------------------------
603
+
604
+ /**
605
+ * The order a feature's pages convert in: pages no other page of the feature
606
+ * reaches over a cluster edge first (the list before its edit form, step 1
607
+ * before step 2), then breadth-first along those edges, ties and unreached
608
+ * pages in the feature's own order. Edges through a shared non-page unit (a
609
+ * form target both pages post to) say nothing about order and are ignored.
610
+ */
611
+ export const navigationOrder = (
612
+ feature: DomainFeature,
613
+ graph: SurveyGraph,
614
+ rules: ConsolidateRules
615
+ ): ReadonlyArray<string> => {
616
+ const pages = new Set(feature.programs)
617
+ const edges = graph.edges.filter(
618
+ (edge) =>
619
+ kindMatches(edge.kind, rules.cluster) &&
620
+ pages.has(edge.from) &&
621
+ pages.has(edge.to) &&
622
+ edge.from !== edge.to
623
+ )
624
+ const inbound = new Map(feature.programs.map((page) => [page, 0]))
625
+ for (const edge of edges) {
626
+ inbound.set(edge.to, (inbound.get(edge.to) ?? 0) + 1)
627
+ }
628
+ const ordered: Array<string> = []
629
+ const seen = new Set<string>()
630
+ const queue = feature.programs.filter((page) => (inbound.get(page) ?? 0) === 0)
631
+ while (queue.length > 0) {
632
+ const current = queue.shift()
633
+ if (current === undefined || seen.has(current)) {
634
+ continue
635
+ }
636
+ seen.add(current)
637
+ ordered.push(current)
638
+ for (const edge of edges) {
639
+ if (edge.from === current && !seen.has(edge.to)) {
640
+ queue.push(edge.to)
641
+ }
642
+ }
643
+ }
644
+ for (const page of feature.programs) {
645
+ if (!seen.has(page)) {
646
+ ordered.push(page)
647
+ }
648
+ }
649
+ return ordered
650
+ }
package/src/FlowError.ts CHANGED
@@ -186,6 +186,43 @@ export class StoryFailed extends Schema.TaggedError<StoryFailed>()("StoryFailed"
186
186
  }
187
187
  }
188
188
 
189
+ /** A decisions or domains overlay that failed to parse or validate — every violation, not the first (ADR 0015). */
190
+ export class DecisionsInvalid extends Schema.TaggedError<DecisionsInvalid>()("DecisionsInvalid", {
191
+ path: Schema.optionalKey(Schema.String),
192
+ violations: Schema.Array(Schema.String)
193
+ }) {
194
+ get message(): string {
195
+ const where = this.path === undefined ? "" : ` in ${this.path}`
196
+ return `decisions invalid${where}:\n${this.violations.map((violation) => `- ${violation}`).join("\n")}`
197
+ }
198
+ }
199
+
200
+ /** A refinement halted on questions only a human can answer; answer them in the file and rerun. */
201
+ export class OpenPointsPending extends Schema.TaggedError<OpenPointsPending>()(
202
+ "OpenPointsPending",
203
+ {
204
+ path: Schema.String,
205
+ points: Schema.Array(Schema.String)
206
+ }
207
+ ) {
208
+ get message(): string {
209
+ return (
210
+ `${this.points.length} open point(s) in ${this.path} — answer them under '## Open points' and rerun:\n` +
211
+ this.points.map((point) => `- ${point}`).join("\n")
212
+ )
213
+ }
214
+ }
215
+
216
+ /** Two page specs of one domain feature disagree on an API operation — never merged silently (ADR 0012 addendum). */
217
+ export class ContractConflict extends Schema.TaggedError<ContractConflict>()("ContractConflict", {
218
+ feature: Schema.String,
219
+ conflicts: Schema.Array(Schema.String)
220
+ }) {
221
+ get message(): string {
222
+ return `feature '${this.feature}' has conflicting API contracts:\n${this.conflicts.map((c) => `- ${c}`).join("\n")}`
223
+ }
224
+ }
225
+
189
226
  export const FlowError = Schema.Union([
190
227
  PersistenceError,
191
228
  PlanParseError,
@@ -203,6 +240,9 @@ export const FlowError = Schema.Union([
203
240
  PerimeterViolation,
204
241
  MissingDependency,
205
242
  MergeConflict,
206
- StoryFailed
243
+ StoryFailed,
244
+ DecisionsInvalid,
245
+ OpenPointsPending,
246
+ ContractConflict
207
247
  ])
208
248
  export type FlowError = typeof FlowError.Type