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