@haus-tech/haus-workflow 5.0.1 → 5.1.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/CHANGELOG.md +7 -0
- package/library/catalog/manifest.json +67 -61
- package/library/global/agents/request-assessor.md +10 -2
- package/library/global/agents/request-specifier-eskalerad.md +8 -0
- package/library/global/agents/request-specifier.md +8 -3
- package/library/global/skills/request/SKILL.md +575 -217
- package/library/global/skills/request/scripts/archive.mjs +736 -59
- package/library/global/skills/request/scripts/compare-scores.mjs +119 -13
- package/library/global/skills/request/scripts/contract-check.mjs +268 -40
- package/library/global/skills/request/scripts/kontroll.mjs +622 -0
- package/library/global/skills/request/scripts/korning.mjs +1502 -0
- package/library/global/skills/request/scripts/lib.mjs +477 -5
- package/library/global/skills/request/scripts/precheck.mjs +132 -5
- package/library/global/skills/request/scripts/report.mjs +85 -14
- package/library/global/skills/request/scripts/svep.mjs +640 -0
- package/library/global/skills/request-assess-impact/SKILL.md +45 -4
- package/library/global/skills/request-score/SKILL.md +51 -11
- package/library/global/skills/request-score/references/poangskala.md +3 -0
- package/library/global/skills/request-write-spec/SKILL.md +9 -4
- package/library/global/skills/request-write-spec/references/specformat.md +48 -0
- package/package.json +1 -1
|
@@ -16,7 +16,21 @@
|
|
|
16
16
|
import { readFileSync, existsSync } from 'node:fs'
|
|
17
17
|
import { join } from 'node:path'
|
|
18
18
|
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
markerStrippedSha256,
|
|
21
|
+
refDriftWarning,
|
|
22
|
+
headingRe,
|
|
23
|
+
sectionLines,
|
|
24
|
+
isHeadingLine,
|
|
25
|
+
SPEC_HEADINGS,
|
|
26
|
+
headingLanguage,
|
|
27
|
+
kandidatIn,
|
|
28
|
+
avvikelseEtikett,
|
|
29
|
+
AVVIKELSE_ETIKETTER,
|
|
30
|
+
utfallsvalAlternativ,
|
|
31
|
+
prisavgorandeVal,
|
|
32
|
+
prisavgorandeForm,
|
|
33
|
+
} from './lib.mjs'
|
|
20
34
|
|
|
21
35
|
const dir = process.argv[2]
|
|
22
36
|
const requireImpact = process.argv.includes('--impact')
|
|
@@ -27,11 +41,19 @@ if (!dir) {
|
|
|
27
41
|
|
|
28
42
|
const problems = []
|
|
29
43
|
const warnings = []
|
|
44
|
+
// Rader orkestratorn läser ur stdout i stället för ur filerna (ADR-0105,
|
|
45
|
+
// ADR-0106, #505): `PRISAVGORANDE: <text>` när granskarblocket flaggar ett
|
|
46
|
+
// prisavgörande öppet val; `KANDIDAT: <utfall>` per block som bär markören,
|
|
47
|
+
// alltid, oavsett exitkod; `AVVIKELSE: <etikett>` när --impact hittar en
|
|
48
|
+
// giltig etikett.
|
|
49
|
+
const kandidater = []
|
|
50
|
+
let avvikelse = null
|
|
51
|
+
let prisavgorande = null
|
|
30
52
|
|
|
31
53
|
// Kontraktsdriftvakt: rubrik- och vokabulärreglerna nedan speglar specformatet.
|
|
32
54
|
// Pinnen är markör-strippad (stabil över haus-releaser, som stämplar om filer
|
|
33
55
|
// med paketversionen) och sökvägen hedrar CLAUDE_CONFIG_DIR — se lib.mjs.
|
|
34
|
-
const SPECFORMAT_SHA = '
|
|
56
|
+
const SPECFORMAT_SHA = '2e26f9270ced172cd9903f53384a4f7ad0f972084045040c263bf01dc7a6e422'
|
|
35
57
|
{
|
|
36
58
|
const w = refDriftWarning('request-write-spec/references/specformat.md', SPECFORMAT_SHA)
|
|
37
59
|
if (w) warnings.push(w)
|
|
@@ -53,6 +75,55 @@ const read = (name, required) => {
|
|
|
53
75
|
return readFileSync(p, 'utf8')
|
|
54
76
|
}
|
|
55
77
|
|
|
78
|
+
/** Rubrikradens text utan fetstil- eller #-markering. */
|
|
79
|
+
const headingText = (line) =>
|
|
80
|
+
line
|
|
81
|
+
.trim()
|
|
82
|
+
.replace(/^\*\*(.+)\*\*$/, '$1')
|
|
83
|
+
.replace(/^#{1,6}\s*/, '')
|
|
84
|
+
.trim()
|
|
85
|
+
|
|
86
|
+
/** En specrubrik enligt tabellen, på något av språken. */
|
|
87
|
+
const isSpecHeading = (line) => SPEC_HEADINGS.some((row) => headingRe(row.key).test(line.trim()))
|
|
88
|
+
|
|
89
|
+
/** Vilka språkbärande rubriker blocket har, per språk. Literalerna kommer ur
|
|
90
|
+
* SPEC_HEADINGS, aldrig härifrån; Problem är neutral och räknas inte. */
|
|
91
|
+
const headingsByLanguage = (block) => {
|
|
92
|
+
const sv = []
|
|
93
|
+
const en = []
|
|
94
|
+
for (const line of block.split('\n')) {
|
|
95
|
+
if (!isHeadingLine(line)) continue
|
|
96
|
+
const text = headingText(line)
|
|
97
|
+
for (const row of SPEC_HEADINGS) {
|
|
98
|
+
if (row.sv === row.en) continue
|
|
99
|
+
if (text === row.sv) sv.push(row.sv)
|
|
100
|
+
else if (text === row.en) en.push(row.en)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return { sv, en }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Kandidatformen (ADR-0105): titel, markör direkt under titeln, Problem.
|
|
108
|
+
* Titel = första rubrikrad som varken är en specrubrik eller en markör;
|
|
109
|
+
* saknas titel ska markören vara blockets första rubrikrad. Lösning och När
|
|
110
|
+
* det är klart är då fel form, antingen spec eller kandidat. Ingår inte,
|
|
111
|
+
* Kräver och Villkor tillåts, och ingen 2–5-punktskontroll görs.
|
|
112
|
+
*/
|
|
113
|
+
const checkKandidatForm = (block, kandidat, label) => {
|
|
114
|
+
const lines = block.split('\n')
|
|
115
|
+
const headings = lines.map((l, i) => (isHeadingLine(l) ? i : -1)).filter((i) => i !== -1)
|
|
116
|
+
const title = headings.find((i) => !isSpecHeading(lines[i]) && kandidatIn(lines[i]) == null)
|
|
117
|
+
const expected = title == null ? headings[0] : headings.find((i) => i > title)
|
|
118
|
+
if (expected !== kandidat.line)
|
|
119
|
+
problems.push(
|
|
120
|
+
`${label}: kandidatmarkören ska stå som första rubrikrad${title == null ? '' : ' efter titeln'}`,
|
|
121
|
+
)
|
|
122
|
+
for (const heading of ['Lösning', 'När det är klart'])
|
|
123
|
+
if (headingRe(heading).test(block))
|
|
124
|
+
problems.push(`${label}: kandidat med ${heading}: antingen spec eller kandidat`)
|
|
125
|
+
}
|
|
126
|
+
|
|
56
127
|
// ---- spec.md ---------------------------------------------------------------
|
|
57
128
|
const spec = read('spec.md', true)
|
|
58
129
|
if (spec != null) {
|
|
@@ -72,10 +143,29 @@ if (spec != null) {
|
|
|
72
143
|
if (blocks.length === 0) problems.push('spec.md: tom')
|
|
73
144
|
blocks.forEach((block, i) => {
|
|
74
145
|
const label = blocks.length > 1 ? `spec.md block ${i + 1}` : 'spec.md'
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
|
|
146
|
+
// Rubrikspråk (ADR-0114): specen skrivs på önskemålets språk och rubrikerna
|
|
147
|
+
// på samma språk som brödtexten. Brödtexten kontrolleras inte, men rubriker
|
|
148
|
+
// ur båda språken i samma block fälls, kunden ser rubrikerna. Vilka som
|
|
149
|
+
// hittades per språk namnges så rättningen blir riktad.
|
|
150
|
+
if (headingLanguage(block) === 'blandad') {
|
|
151
|
+
const { sv, en } = headingsByLanguage(block)
|
|
152
|
+
problems.push(
|
|
153
|
+
`${label}: rubriker på två språk (sv: ${sv.join(', ')}, en: ${en.join(', ')}), välj ett`,
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
// Obligatoriskt per specformatet: Problem, alltid. Lösning och När det är
|
|
157
|
+
// klart bara i spec-formen; kandidatformen (ADR-0105) ersätter dem med
|
|
158
|
+
// markören och routar posten till sitt stopputfall utan analys och poäng.
|
|
159
|
+
if (!headingRe('Problem').test(block)) problems.push(`${label}: rubriken "Problem" saknas`)
|
|
160
|
+
const kandidat = kandidatIn(block)
|
|
161
|
+
if (kandidat) {
|
|
162
|
+
kandidater.push(kandidat.utfall)
|
|
163
|
+
checkKandidatForm(block, kandidat, label)
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
for (const heading of ['Lösning', 'När det är klart'])
|
|
78
167
|
if (!headingRe(heading).test(block)) problems.push(`${label}: rubriken "${heading}" saknas`)
|
|
168
|
+
// Ingår inte är valfri i formatet ("ibland ingen alls"), saknad blir varning.
|
|
79
169
|
if (!headingRe('Ingår inte').test(block))
|
|
80
170
|
warnings.push(`${label}: "Ingår inte" saknas — ofta rätt att ringa in vad som inte ingår`)
|
|
81
171
|
// 2–5 numrerade punkter under När det är klart, avgränsat vid nästa
|
|
@@ -114,10 +204,22 @@ if (gb != null) {
|
|
|
114
204
|
'Antaganden',
|
|
115
205
|
'Vägval jag gjort',
|
|
116
206
|
'Öppna utfallsval',
|
|
207
|
+
'Prisavgörande öppet val',
|
|
117
208
|
'Flaggor',
|
|
118
209
|
'Struket ur underlaget',
|
|
119
210
|
])
|
|
120
211
|
if (!gb.includes(`**${row}:**`)) problems.push(`granskarblock.md: raden "**${row}:**" saknas`)
|
|
212
|
+
// Prisavgörande öppet val (#505, ADR-0118): specifierns eget stoppsignal när
|
|
213
|
+
// ett öppet val avgör priset. Skrivs på stdout för orkestratorn, före
|
|
214
|
+
// KANDIDAT-raderna. En form som varken är nej eller ett ja med text (bart
|
|
215
|
+
// ja/yes, kolon utan text, fritext utan etikett) är en BRIST: den ser ut
|
|
216
|
+
// som ett svar men går inte att läsa, och ska aldrig tyst behandlas som
|
|
217
|
+
// nej (granskningsfynd, #505).
|
|
218
|
+
if (prisavgorandeForm(gb) === 'ja-utan-text')
|
|
219
|
+
problems.push(
|
|
220
|
+
'granskarblock.md: raden "**Prisavgörande öppet val:**" ska vara "nej" eller "ja: <valet i en mening>"',
|
|
221
|
+
)
|
|
222
|
+
prisavgorande = prisavgorandeVal(gb)
|
|
121
223
|
}
|
|
122
224
|
|
|
123
225
|
// ---- impact.md (bedöm-steget) ---------------------------------------------
|
|
@@ -143,6 +245,26 @@ if (requireImpact) {
|
|
|
143
245
|
problems.push('impact.md: "## Till granskaren" saknas')
|
|
144
246
|
for (const row of ['Ej verifierat', 'Avvikelse mot specen', 'Obesvarade utfallsval'])
|
|
145
247
|
if (!impact.includes(`**${row}:**`)) problems.push(`impact.md: raden "**${row}:**" saknas`)
|
|
248
|
+
// Avvikelseetikett (ADR-0106): raden måste börja med en av de tre
|
|
249
|
+
// etiketterna, annars har scorern ingen väg att agera på den. Form, aldrig
|
|
250
|
+
// innehåll: etikettvärdet påverkar inte exitkoden, grinden för motsägelse
|
|
251
|
+
// ligger i orkestratorn och contract-check. En gammal analys utan etikett
|
|
252
|
+
// fälls också, kostnaden är en assessor-dispatch per pågående post.
|
|
253
|
+
if (impact.includes('**Avvikelse mot specen:**')) {
|
|
254
|
+
avvikelse = avvikelseEtikett(impact)
|
|
255
|
+
if (avvikelse == null)
|
|
256
|
+
problems.push(
|
|
257
|
+
`impact.md: raden "**Avvikelse mot specen:**" ska börja med en etikett: ${AVVIKELSE_ETIKETTER.join(', ')}`,
|
|
258
|
+
)
|
|
259
|
+
// #507 (ADR-0117): motsägelse betyder att inget listat alternativ
|
|
260
|
+
// levererar specens Problem. Bär analysen ändå alternativ är etiketten
|
|
261
|
+
// fel satt, och posten dispatchas om i stället för att stoppas tyst.
|
|
262
|
+
const alternativ = utfallsvalAlternativ(impact)
|
|
263
|
+
if (avvikelse === 'motsägelse' && alternativ !== null && alternativ > 0)
|
|
264
|
+
problems.push(
|
|
265
|
+
`impact.md: motsägelse med ${alternativ} listade alternativ under "Obesvarade utfallsval": välj precisering och låt fyrstegsregeln avgöra, eller stryk alternativen med prefixet "struket:" och skälet`,
|
|
266
|
+
)
|
|
267
|
+
}
|
|
146
268
|
// Analysen får inte läcka poängsättning eller tid (ADR-0098: kontrollen
|
|
147
269
|
// gällde tidigare klassord; klassaxeln är borttagen och regeln riktas nu
|
|
148
270
|
// mot poängen, som är det analysens läsare inte får primas mot).
|
|
@@ -210,6 +332,11 @@ if (state != null) {
|
|
|
210
332
|
}
|
|
211
333
|
}
|
|
212
334
|
|
|
335
|
+
// Kontraktsraderna först, före varningar och verdikt: orkestratorn läser dem
|
|
336
|
+
// innan den tolkar exitkoden.
|
|
337
|
+
if (prisavgorande != null) console.log(`PRISAVGORANDE: ${prisavgorande}`)
|
|
338
|
+
for (const k of kandidater) console.log(`KANDIDAT: ${k}`)
|
|
339
|
+
if (avvikelse != null) console.log(`AVVIKELSE: ${avvikelse}`)
|
|
213
340
|
for (const w of warnings) console.log(`VARNING: ${w}`)
|
|
214
341
|
if (problems.length === 0) {
|
|
215
342
|
console.log('OK: formkontrollen passerad')
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
import fs from 'node:fs'
|
|
23
23
|
import path from 'node:path'
|
|
24
24
|
import { fileURLToPath } from 'node:url'
|
|
25
|
-
import { sectionLines } from './lib.mjs'
|
|
25
|
+
import { canonicalModel, sectionLines } from './lib.mjs'
|
|
26
26
|
|
|
27
27
|
const SPECFALT = ['Problem', 'Lösning', 'När det är klart', 'Ingår inte']
|
|
28
28
|
|
|
@@ -90,13 +90,23 @@ const läsDok = (dir, namn) => {
|
|
|
90
90
|
const läsKorning = (dir, namn) => {
|
|
91
91
|
const state = läsJson(path.join(dir, 'state.json'))
|
|
92
92
|
const spec = läsSpec(dir)
|
|
93
|
+
const delar = kataloger(dir)
|
|
94
|
+
.filter((d) => /^del-\d+$/.test(d))
|
|
95
|
+
.sort()
|
|
96
|
+
.map((d) => läsKorning(path.join(dir, d), d))
|
|
97
|
+
// En bunt (align-beslut F) har sina specar i delarna och ingen egen i
|
|
98
|
+
// roten. Roten är då hel av det den var; "ofullständig" skulle påstå att
|
|
99
|
+
// arkivet är trasigt. En rot utan spec OCH utan delar saknar specen på
|
|
100
|
+
// riktigt och behåller märkningen.
|
|
101
|
+
const bunt = spec === null && delar.length > 0
|
|
93
102
|
return {
|
|
94
103
|
namn,
|
|
95
104
|
sokvag: dir,
|
|
96
105
|
// Ofullständig betyder att rapporten inte kan visa körningen som en hel
|
|
97
106
|
// körning. Den visas ändå, märkt: tyst utelämnande är den enda utgången
|
|
98
107
|
// som skulle ljuga för läsaren.
|
|
99
|
-
ofullstandig: state === null || spec === null,
|
|
108
|
+
ofullstandig: state === null || (spec === null && !bunt),
|
|
109
|
+
bunt,
|
|
100
110
|
state,
|
|
101
111
|
spec,
|
|
102
112
|
impact: läsDok(dir, 'impact.md'),
|
|
@@ -108,10 +118,7 @@ const läsKorning = (dir, namn) => {
|
|
|
108
118
|
// headless-svaret rått, och en haus-ägd nyckel i den gör den till ett
|
|
109
119
|
// blandat format som inte längre går att diffa mot CLI:ns utdata.
|
|
110
120
|
klocka: läsJson(path.join(dir, 'matning.json')),
|
|
111
|
-
delar
|
|
112
|
-
.filter((d) => /^del-\d+$/.test(d))
|
|
113
|
-
.sort()
|
|
114
|
-
.map((d) => läsKorning(path.join(dir, d), d)),
|
|
121
|
+
delar,
|
|
115
122
|
}
|
|
116
123
|
}
|
|
117
124
|
|
|
@@ -216,18 +223,55 @@ const KOSTNADSFALT = [
|
|
|
216
223
|
['Dispatcher', 'matning', (m) => tal(m.subagent_stats?.spawned)],
|
|
217
224
|
]
|
|
218
225
|
|
|
219
|
-
|
|
220
|
-
|
|
226
|
+
/** Kanoniska modellnycklar i en körnings korning.json, sorterade. Flera
|
|
227
|
+
* fakturerade nycklar (t.ex. med `[1m]`-suffix eller ett datumstämplat
|
|
228
|
+
* snapshot-namn) kan kanonisera till samma sträng; unionen dedupar dem. */
|
|
229
|
+
const modellNycklar = (m) =>
|
|
230
|
+
[...new Set(Object.keys(m?.modelUsage ?? {}).map(canonicalModel))].sort()
|
|
231
|
+
|
|
232
|
+
/** Kostnad per kanonisk nyckel: dubbletter efter kanonisering summeras,
|
|
233
|
+
* annars skulle en snapshot-omdöpt nyckel tyst dölja den äldre kostnaden. */
|
|
234
|
+
const kostnadPerNyckel = (m) => {
|
|
235
|
+
const ut = {}
|
|
236
|
+
for (const [nyckel, v] of Object.entries(m?.modelUsage ?? {})) {
|
|
237
|
+
const k = canonicalModel(nyckel)
|
|
238
|
+
ut[k] = (ut[k] ?? 0) + (v?.costUSD ?? 0)
|
|
239
|
+
}
|
|
240
|
+
return ut
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const kostnadsrader = (k, nycklar = modellNycklar(k.matning)) =>
|
|
244
|
+
KOSTNADSFALT.flatMap(([namn, källa, hämta]) => {
|
|
221
245
|
// En del är ingen egen process: mätningen kommer ur headless-svaret för
|
|
222
246
|
// hela körningen. "omätt" skulle påstå att mätningen misslyckades, vilket
|
|
223
247
|
// är en annan sak än att den inte finns på den här nivån.
|
|
224
|
-
if (k.rot)
|
|
248
|
+
if (k.rot) {
|
|
249
|
+
const rad = [namn, `<em>ingår i ${esc(k.rot)}</em>`]
|
|
250
|
+
if (namn !== 'USD') return [rad]
|
|
251
|
+
// Sektionens nyckelunion gäller även delkolumnen: den har ingen egen
|
|
252
|
+
// modelUsage, så per-nyckelraderna får samma "ingår i"-text som
|
|
253
|
+
// totalen, aldrig streck (vilket skulle påstå att nyckeln är omätt här
|
|
254
|
+
// i stället för bokförd hos föräldern) eller för få celler (vilket
|
|
255
|
+
// skulle förskjuta tabellen mot syskonkolumnerna).
|
|
256
|
+
return [rad, ...nycklar.map((n) => [`USD ${n}`, `<em>ingår i ${esc(k.rot)}</em>`])]
|
|
257
|
+
}
|
|
225
258
|
// Två oberoende källor: en saknad fil säger omätt för SINA rader och
|
|
226
259
|
// ljuger inte om de andra. Radlistan har ändå alltid samma längd och
|
|
227
260
|
// samma fältnamn, mätt eller omätt.
|
|
228
261
|
const dok = k[källa]
|
|
229
262
|
const värde = dok ? hämta(dok) : null
|
|
230
|
-
|
|
263
|
+
const rad = [namn, värde === null ? '<em>omätt</em>' : esc(värde)]
|
|
264
|
+
if (namn !== 'USD') return [rad]
|
|
265
|
+
// #494: orkestratorns (hjälpmodellens) kostnad låg förr inbakad i
|
|
266
|
+
// kandidatens siffra under den enda USD-raden. En rad per fakturerad,
|
|
267
|
+
// kanoniserad nyckel läggs till efter totalen, som sektionens
|
|
268
|
+
// nyckelunion (`nycklar`) så att alla körningar i sektionen får samma
|
|
269
|
+
// radlista, mätt eller inte.
|
|
270
|
+
const per = kostnadPerNyckel(dok)
|
|
271
|
+
return [
|
|
272
|
+
rad,
|
|
273
|
+
...nycklar.map((n) => [`USD ${n}`, n in per ? esc(usd(per[n])) : '<em>omätt</em>']),
|
|
274
|
+
]
|
|
231
275
|
})
|
|
232
276
|
|
|
233
277
|
const provenienrader = (k) => {
|
|
@@ -271,6 +315,28 @@ const varde = (v) => {
|
|
|
271
315
|
return esc(v)
|
|
272
316
|
}
|
|
273
317
|
|
|
318
|
+
/** Poängens utfall. Prisfilen är det gällande dokumentet när den finns.
|
|
319
|
+
* Saknas den kan körningen ändå ha ett utfall: en kandidat (KONCEPT/DIAGNOS)
|
|
320
|
+
* eller en motsägelse (TILLBAKA_TILL_SPEC) stoppas innan scorern körs och
|
|
321
|
+
* får aldrig någon prisfil, bara `state.stopp`. Utan den här raden såg en
|
|
322
|
+
* sådan körning ut som en tom kolumn, och läsaren kunde inte skilja
|
|
323
|
+
* "stoppades tidigt" från "aldrig kört". Äldre arkiv utan `stopp` ger streck
|
|
324
|
+
* som förut. */
|
|
325
|
+
const poangUtfall = (k) => {
|
|
326
|
+
if (k.poang?.outcome != null) return k.poang.outcome
|
|
327
|
+
const stopp = k.state?.stopp
|
|
328
|
+
if (!stopp || typeof stopp !== 'object' || stopp.utfall == null) return null
|
|
329
|
+
return stopp.kalla ? `${stopp.utfall} (stopp ur ${stopp.kalla})` : `${stopp.utfall} (stopp)`
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Statuscellen. Ofullständig går först: en bunt utan state.json är
|
|
333
|
+
* fortfarande oläsbar som körning, och det får inte döljas bakom buntetiketten. */
|
|
334
|
+
const statuscell = (k) => {
|
|
335
|
+
if (k.ofullstandig) return '<strong>ofullständig</strong>'
|
|
336
|
+
if (k.bunt) return esc(`bunt (${k.delar.length} delar)`)
|
|
337
|
+
return esc(k.state?.status ?? '–')
|
|
338
|
+
}
|
|
339
|
+
|
|
274
340
|
/** Kuverten är slutna och nycklarna deras, inte rapportens: klassningen bär sitt
|
|
275
341
|
* utfall i `classification.utfall`, poängen sitt tal i `score` och sin
|
|
276
342
|
* inringning i `bracket`. Formerna är pinnade i contract-check.mjs. */
|
|
@@ -278,10 +344,10 @@ const utfallsrader = (k, harKlass = true) => [
|
|
|
278
344
|
...(harKlass ? [['Klass', esc(k.klass?.classification?.utfall ?? '–')]] : []),
|
|
279
345
|
['Poäng', esc(k.poang?.score ?? '–')],
|
|
280
346
|
['Inringning', esc(inringning(k.poang?.bracket))],
|
|
281
|
-
['Utfall poäng', esc(k
|
|
347
|
+
['Utfall poäng', esc(poangUtfall(k) ?? '–')],
|
|
282
348
|
['Delning', k.delar.length ? esc(`${k.delar.length} delar`) : 'delade sig inte'],
|
|
283
349
|
['Dubbelkörning', esc(k.state?.runs?.at(-1)?.double_run ?? '–')],
|
|
284
|
-
['Status',
|
|
350
|
+
['Status', statuscell(k)],
|
|
285
351
|
]
|
|
286
352
|
|
|
287
353
|
const specrader = (k) => SPECFALT.map((f) => [f, `<pre>${esc(k.spec?.[f] ?? '–')}</pre>`])
|
|
@@ -333,7 +399,7 @@ const klassrader = (k) => {
|
|
|
333
399
|
const poangrader = (k) => {
|
|
334
400
|
const p = k.poang
|
|
335
401
|
return [
|
|
336
|
-
['Utfall', varde(
|
|
402
|
+
['Utfall', varde(poangUtfall(k))],
|
|
337
403
|
['Poäng', varde(p?.score)],
|
|
338
404
|
['Typ', varde(p?.type)],
|
|
339
405
|
['Inringning', esc(inringning(p?.bracket))],
|
|
@@ -347,6 +413,7 @@ const poangrader = (k) => {
|
|
|
347
413
|
['Villkor', varde(p?.conditions)],
|
|
348
414
|
['Beroenden', varde(p?.dependencies)],
|
|
349
415
|
['Noter', varde(p?.notes)],
|
|
416
|
+
['Täckningspost', varde(p?.coverage_post)],
|
|
350
417
|
['Alternativ', varde(p?.alternatives)],
|
|
351
418
|
['Kundmotivering', varde(p?.customer_rationale)],
|
|
352
419
|
]
|
|
@@ -421,6 +488,10 @@ const postSektion = (post) => {
|
|
|
421
488
|
// äldre arkiv. Kalibreringshistoriken kastas inte, men en post utan axeln
|
|
422
489
|
// ska inte visa en tom sektion för den.
|
|
423
490
|
const harKlass = korningar.some((k) => k.klass != null)
|
|
491
|
+
// #494: nyckelunionen beräknas en gång för hela sektionen, inte per
|
|
492
|
+
// körning, annars kan två körningar i samma sektion få olika radlängd på
|
|
493
|
+
// Kostnad och tabellen skulle förskjutas.
|
|
494
|
+
const kostnadsnycklar = [...new Set(korningar.flatMap((k) => modellNycklar(k.matning)))].sort()
|
|
424
495
|
const block = [
|
|
425
496
|
['Utfall', (k) => utfallsrader(k, harKlass)],
|
|
426
497
|
...(harKlass ? [['Klassning', klassrader]] : []),
|
|
@@ -428,7 +499,7 @@ const postSektion = (post) => {
|
|
|
428
499
|
['Spec', specrader],
|
|
429
500
|
['Analys', analysrader],
|
|
430
501
|
['Granskarblock', granskarrader],
|
|
431
|
-
['Kostnad', kostnadsrader],
|
|
502
|
+
['Kostnad', (k) => kostnadsrader(k, kostnadsnycklar)],
|
|
432
503
|
['Proveniens', provenienrader],
|
|
433
504
|
]
|
|
434
505
|
return `
|