@llm4ts/flow 2.0.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.
- package/dist/Approval.d.ts +1 -1
- package/dist/Approval.d.ts.map +1 -1
- package/dist/Artifacts.d.ts +2 -2
- package/dist/Artifacts.d.ts.map +1 -1
- package/dist/BenchReport.d.ts +2 -2
- package/dist/BenchReport.d.ts.map +1 -1
- package/dist/BoardSync.d.ts +12 -5
- package/dist/BoardSync.d.ts.map +1 -1
- package/dist/BoardSync.js +27 -6
- package/dist/BoardSync.js.map +1 -1
- package/dist/CostLedger.d.ts +2 -2
- package/dist/CostLedger.d.ts.map +1 -1
- package/dist/Decisions.d.ts +187 -0
- package/dist/Decisions.d.ts.map +1 -0
- package/dist/Decisions.js +677 -0
- package/dist/Decisions.js.map +1 -0
- package/dist/Domains.d.ts +125 -0
- package/dist/Domains.d.ts.map +1 -0
- package/dist/Domains.js +528 -0
- package/dist/Domains.js.map +1 -0
- package/dist/Equiv.d.ts +3 -3
- package/dist/Equiv.d.ts.map +1 -1
- package/dist/Flow.d.ts +1 -1
- package/dist/Flow.d.ts.map +1 -1
- package/dist/FlowError.d.ts +25 -1
- package/dist/FlowError.d.ts.map +1 -1
- package/dist/FlowError.js +33 -1
- package/dist/FlowError.js.map +1 -1
- package/dist/Pack.d.ts +16 -0
- package/dist/Pack.d.ts.map +1 -1
- package/dist/Pack.js +53 -1
- package/dist/Pack.js.map +1 -1
- package/dist/PageSpec.d.ts +21 -7
- package/dist/PageSpec.d.ts.map +1 -1
- package/dist/PageSpec.js +83 -14
- package/dist/PageSpec.js.map +1 -1
- package/dist/Persistence.d.ts +2 -2
- package/dist/Persistence.d.ts.map +1 -1
- package/dist/PlanExecution.d.ts +1 -1
- package/dist/PlanExecution.d.ts.map +1 -1
- package/dist/ProgramJudge.d.ts +1 -1
- package/dist/ProgramJudge.d.ts.map +1 -1
- package/dist/Replay.d.ts +2 -2
- package/dist/Replay.d.ts.map +1 -1
- package/dist/Review.d.ts +2 -2
- package/dist/Review.d.ts.map +1 -1
- package/dist/ReviewCache.d.ts +1 -1
- package/dist/ReviewCache.d.ts.map +1 -1
- package/dist/SpecChecks.d.ts +8 -0
- package/dist/SpecChecks.d.ts.map +1 -1
- package/dist/SpecChecks.js +7 -1
- package/dist/SpecChecks.js.map +1 -1
- package/dist/Stories.d.ts +5 -4
- package/dist/Stories.d.ts.map +1 -1
- package/dist/Stories.js +20 -10
- package/dist/Stories.js.map +1 -1
- package/dist/StoryPlan.d.ts +2 -1
- package/dist/StoryPlan.d.ts.map +1 -1
- package/dist/StoryPlan.js +1 -1
- package/dist/StoryPlan.js.map +1 -1
- package/package.json +4 -2
- package/src/BoardSync.ts +35 -6
- package/src/Decisions.ts +844 -0
- package/src/Domains.ts +650 -0
- package/src/FlowError.ts +41 -1
- package/src/Pack.ts +79 -1
- package/src/PageSpec.ts +132 -8
- package/src/SpecChecks.ts +15 -1
- package/src/Stories.ts +20 -10
- package/src/StoryPlan.ts +3 -2
package/src/Decisions.ts
ADDED
|
@@ -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
|
+
}
|