@haus-tech/haus-workflow 5.0.1 → 5.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.
@@ -16,7 +16,22 @@
16
16
  import { readFileSync, existsSync } from 'node:fs'
17
17
  import { join } from 'node:path'
18
18
 
19
- import { markerStrippedSha256, refDriftWarning, headingRe, sectionLines } from './lib.mjs'
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
+ utfallsvalProsa,
32
+ prisavgorandeVal,
33
+ prisavgorandeForm,
34
+ } from './lib.mjs'
20
35
 
21
36
  const dir = process.argv[2]
22
37
  const requireImpact = process.argv.includes('--impact')
@@ -27,11 +42,19 @@ if (!dir) {
27
42
 
28
43
  const problems = []
29
44
  const warnings = []
45
+ // Rader orkestratorn läser ur stdout i stället för ur filerna (ADR-0105,
46
+ // ADR-0106, #505): `PRISAVGORANDE: <text>` när granskarblocket flaggar ett
47
+ // prisavgörande öppet val; `KANDIDAT: <utfall>` per block som bär markören,
48
+ // alltid, oavsett exitkod; `AVVIKELSE: <etikett>` när --impact hittar en
49
+ // giltig etikett.
50
+ const kandidater = []
51
+ let avvikelse = null
52
+ let prisavgorande = null
30
53
 
31
54
  // Kontraktsdriftvakt: rubrik- och vokabulärreglerna nedan speglar specformatet.
32
55
  // Pinnen är markör-strippad (stabil över haus-releaser, som stämplar om filer
33
56
  // med paketversionen) och sökvägen hedrar CLAUDE_CONFIG_DIR — se lib.mjs.
34
- const SPECFORMAT_SHA = 'e6134fd8a3c1cc9e3056c849d8834310e39d269846f2b6bec2871a83d488fc01'
57
+ const SPECFORMAT_SHA = '2e26f9270ced172cd9903f53384a4f7ad0f972084045040c263bf01dc7a6e422'
35
58
  {
36
59
  const w = refDriftWarning('request-write-spec/references/specformat.md', SPECFORMAT_SHA)
37
60
  if (w) warnings.push(w)
@@ -53,6 +76,55 @@ const read = (name, required) => {
53
76
  return readFileSync(p, 'utf8')
54
77
  }
55
78
 
79
+ /** Rubrikradens text utan fetstil- eller #-markering. */
80
+ const headingText = (line) =>
81
+ line
82
+ .trim()
83
+ .replace(/^\*\*(.+)\*\*$/, '$1')
84
+ .replace(/^#{1,6}\s*/, '')
85
+ .trim()
86
+
87
+ /** En specrubrik enligt tabellen, på något av språken. */
88
+ const isSpecHeading = (line) => SPEC_HEADINGS.some((row) => headingRe(row.key).test(line.trim()))
89
+
90
+ /** Vilka språkbärande rubriker blocket har, per språk. Literalerna kommer ur
91
+ * SPEC_HEADINGS, aldrig härifrån; Problem är neutral och räknas inte. */
92
+ const headingsByLanguage = (block) => {
93
+ const sv = []
94
+ const en = []
95
+ for (const line of block.split('\n')) {
96
+ if (!isHeadingLine(line)) continue
97
+ const text = headingText(line)
98
+ for (const row of SPEC_HEADINGS) {
99
+ if (row.sv === row.en) continue
100
+ if (text === row.sv) sv.push(row.sv)
101
+ else if (text === row.en) en.push(row.en)
102
+ }
103
+ }
104
+ return { sv, en }
105
+ }
106
+
107
+ /**
108
+ * Kandidatformen (ADR-0105): titel, markör direkt under titeln, Problem.
109
+ * Titel = första rubrikrad som varken är en specrubrik eller en markör;
110
+ * saknas titel ska markören vara blockets första rubrikrad. Lösning och När
111
+ * det är klart är då fel form, antingen spec eller kandidat. Ingår inte,
112
+ * Kräver och Villkor tillåts, och ingen 2–5-punktskontroll görs.
113
+ */
114
+ const checkKandidatForm = (block, kandidat, label) => {
115
+ const lines = block.split('\n')
116
+ const headings = lines.map((l, i) => (isHeadingLine(l) ? i : -1)).filter((i) => i !== -1)
117
+ const title = headings.find((i) => !isSpecHeading(lines[i]) && kandidatIn(lines[i]) == null)
118
+ const expected = title == null ? headings[0] : headings.find((i) => i > title)
119
+ if (expected !== kandidat.line)
120
+ problems.push(
121
+ `${label}: kandidatmarkören ska stå som första rubrikrad${title == null ? '' : ' efter titeln'}`,
122
+ )
123
+ for (const heading of ['Lösning', 'När det är klart'])
124
+ if (headingRe(heading).test(block))
125
+ problems.push(`${label}: kandidat med ${heading}: antingen spec eller kandidat`)
126
+ }
127
+
56
128
  // ---- spec.md ---------------------------------------------------------------
57
129
  const spec = read('spec.md', true)
58
130
  if (spec != null) {
@@ -72,10 +144,29 @@ if (spec != null) {
72
144
  if (blocks.length === 0) problems.push('spec.md: tom')
73
145
  blocks.forEach((block, i) => {
74
146
  const label = blocks.length > 1 ? `spec.md block ${i + 1}` : 'spec.md'
75
- // Obligatoriskt per specformatet: Problem, Lösning, När det är klart.
76
- // Ingår inte är valfri i formatet ("ibland ingen alls") — saknad blir varning.
77
- for (const heading of ['Problem', 'Lösning', 'När det är klart'])
147
+ // Rubrikspråk (ADR-0114): specen skrivs på önskemålets språk och rubrikerna
148
+ // på samma språk som brödtexten. Brödtexten kontrolleras inte, men rubriker
149
+ // ur båda språken i samma block fälls, kunden ser rubrikerna. Vilka som
150
+ // hittades per språk namnges så rättningen blir riktad.
151
+ if (headingLanguage(block) === 'blandad') {
152
+ const { sv, en } = headingsByLanguage(block)
153
+ problems.push(
154
+ `${label}: rubriker på två språk (sv: ${sv.join(', ')}, en: ${en.join(', ')}), välj ett`,
155
+ )
156
+ }
157
+ // Obligatoriskt per specformatet: Problem, alltid. Lösning och När det är
158
+ // klart bara i spec-formen; kandidatformen (ADR-0105) ersätter dem med
159
+ // markören och routar posten till sitt stopputfall utan analys och poäng.
160
+ if (!headingRe('Problem').test(block)) problems.push(`${label}: rubriken "Problem" saknas`)
161
+ const kandidat = kandidatIn(block)
162
+ if (kandidat) {
163
+ kandidater.push(kandidat.utfall)
164
+ checkKandidatForm(block, kandidat, label)
165
+ return
166
+ }
167
+ for (const heading of ['Lösning', 'När det är klart'])
78
168
  if (!headingRe(heading).test(block)) problems.push(`${label}: rubriken "${heading}" saknas`)
169
+ // Ingår inte är valfri i formatet ("ibland ingen alls"), saknad blir varning.
79
170
  if (!headingRe('Ingår inte').test(block))
80
171
  warnings.push(`${label}: "Ingår inte" saknas — ofta rätt att ringa in vad som inte ingår`)
81
172
  // 2–5 numrerade punkter under När det är klart, avgränsat vid nästa
@@ -114,10 +205,22 @@ if (gb != null) {
114
205
  'Antaganden',
115
206
  'Vägval jag gjort',
116
207
  'Öppna utfallsval',
208
+ 'Prisavgörande öppet val',
117
209
  'Flaggor',
118
210
  'Struket ur underlaget',
119
211
  ])
120
212
  if (!gb.includes(`**${row}:**`)) problems.push(`granskarblock.md: raden "**${row}:**" saknas`)
213
+ // Prisavgörande öppet val (#505, ADR-0118): specifierns eget stoppsignal när
214
+ // ett öppet val avgör priset. Skrivs på stdout för orkestratorn, före
215
+ // KANDIDAT-raderna. En form som varken är nej eller ett ja med text (bart
216
+ // ja/yes, kolon utan text, fritext utan etikett) är en BRIST: den ser ut
217
+ // som ett svar men går inte att läsa, och ska aldrig tyst behandlas som
218
+ // nej (granskningsfynd, #505).
219
+ if (prisavgorandeForm(gb) === 'ja-utan-text')
220
+ problems.push(
221
+ 'granskarblock.md: raden "**Prisavgörande öppet val:**" ska vara "nej" eller "ja: <valet i en mening>"',
222
+ )
223
+ prisavgorande = prisavgorandeVal(gb)
121
224
  }
122
225
 
123
226
  // ---- impact.md (bedöm-steget) ---------------------------------------------
@@ -143,6 +246,41 @@ if (requireImpact) {
143
246
  problems.push('impact.md: "## Till granskaren" saknas')
144
247
  for (const row of ['Ej verifierat', 'Avvikelse mot specen', 'Obesvarade utfallsval'])
145
248
  if (!impact.includes(`**${row}:**`)) problems.push(`impact.md: raden "**${row}:**" saknas`)
249
+ // Avvikelseetikett (ADR-0106): raden måste börja med en av de tre
250
+ // etiketterna, annars har scorern ingen väg att agera på den. Form, aldrig
251
+ // innehåll: etikettvärdet påverkar inte exitkoden, grinden för motsägelse
252
+ // ligger i orkestratorn och contract-check. En gammal analys utan etikett
253
+ // fälls också, kostnaden är en assessor-dispatch per pågående post.
254
+ if (impact.includes('**Avvikelse mot specen:**')) {
255
+ avvikelse = avvikelseEtikett(impact)
256
+ if (avvikelse == null)
257
+ problems.push(
258
+ `impact.md: raden "**Avvikelse mot specen:**" ska börja med en etikett: ${AVVIKELSE_ETIKETTER.join(', ')}`,
259
+ )
260
+ // #507 (ADR-0117): motsägelse betyder att inget listat alternativ
261
+ // levererar specens Problem. Bär analysen ändå alternativ är etiketten
262
+ // fel satt, och posten dispatchas om i stället för att stoppas tyst.
263
+ const alternativ = utfallsvalAlternativ(impact)
264
+ if (avvikelse === 'motsägelse' && alternativ !== null && alternativ > 0)
265
+ problems.push(
266
+ `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`,
267
+ )
268
+ // #520: alternativ i löptext ("(a) … (b) …") räknas inte som listade
269
+ // och passerade grinden ovan som om raden vore "inga". Formen krävs:
270
+ // vid motsägelse är prosa en BRIST (grinden kan annars inte avgöra om
271
+ // etiketten är rätt), vid precisering en VARNING (scorerns
272
+ // fyrstegsregel läser listan, prosa läser den inte). Aldrig en
273
+ // tolkning av vad "(a)" betyder.
274
+ const formkrav =
275
+ 'skriv en listpunkt per alternativ på kolumn 0 ("- …"), stryk med prefixet "struket:" och skälet, eller skriv "inga"'
276
+ if (
277
+ (avvikelse === 'motsägelse' || avvikelse === 'precisering') &&
278
+ utfallsvalProsa(impact) === true
279
+ )
280
+ (avvikelse === 'motsägelse' ? problems : warnings).push(
281
+ `impact.md: ${avvikelse} med alternativ i löptext under "Obesvarade utfallsval": ${formkrav}`,
282
+ )
283
+ }
146
284
  // Analysen får inte läcka poängsättning eller tid (ADR-0098: kontrollen
147
285
  // gällde tidigare klassord; klassaxeln är borttagen och regeln riktas nu
148
286
  // mot poängen, som är det analysens läsare inte får primas mot).
@@ -210,6 +348,11 @@ if (state != null) {
210
348
  }
211
349
  }
212
350
 
351
+ // Kontraktsraderna först, före varningar och verdikt: orkestratorn läser dem
352
+ // innan den tolkar exitkoden.
353
+ if (prisavgorande != null) console.log(`PRISAVGORANDE: ${prisavgorande}`)
354
+ for (const k of kandidater) console.log(`KANDIDAT: ${k}`)
355
+ if (avvikelse != null) console.log(`AVVIKELSE: ${avvikelse}`)
213
356
  for (const w of warnings) console.log(`VARNING: ${w}`)
214
357
  if (problems.length === 0) {
215
358
  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: kataloger(dir)
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
 
@@ -212,22 +219,94 @@ const KOSTNADSFALT = [
212
219
  // sa 5,5 minuter om en sonnetkörning som tog omkring 34. Ett `wall_ms` som
213
220
  // saknas eller inte är ett tal är omätt, aldrig noll: en nolla skulle läsas
214
221
  // som en ögonblicklig körning.
215
- ['Väggtid', 'klocka', (m) => (Number.isFinite(m.wall_ms) ? minuter(m.wall_ms) : null)],
222
+ // #526: väggtiden mättes under samtidig last. Lasten står i korning.json:s
223
+ // steg (`samtidiga_processer`, maxvärdet över stegen: det är den värsta
224
+ // trängseln körningen köade bakom); saknas fältet (äldre körningar, eller
225
+ // en räknare som föll i varje steg) är samtidigheten okänd, inte noll.
226
+ [
227
+ 'Väggtid',
228
+ 'klocka',
229
+ (m, k) =>
230
+ Number.isFinite(m.wall_ms) ? `${minuter(m.wall_ms)} (${samtidighet(k.matning)})` : null,
231
+ ],
216
232
  ['Dispatcher', 'matning', (m) => tal(m.subagent_stats?.spawned)],
233
+ // #523: nekade verktygsanrop, summan av `permission_denials` över stegen.
234
+ // Ett steg utan fältet (null, eller äldre korning.json) räknas inte; bär
235
+ // inget steg fältet är summan omätt, aldrig noll.
236
+ ['Nekade anrop', 'matning', (m) => nekadeAnrop(m)],
217
237
  ]
218
238
 
219
- const kostnadsrader = (k) =>
220
- KOSTNADSFALT.map(([namn, källa, hämta]) => {
239
+ /** Summan av nekade verktygsanrop över stegen, null när inget steg bär
240
+ * fältet. */
241
+ const nekadeAnrop = (matning) => {
242
+ const tal = (matning?.steg ?? [])
243
+ .map((s) => s?.permission_denials)
244
+ .filter((n) => Number.isFinite(n))
245
+ return tal.length === 0 ? null : String(tal.reduce((a, b) => a + b, 0))
246
+ }
247
+
248
+ /** Kanoniska modellnycklar i en körnings korning.json, sorterade. Flera
249
+ * fakturerade nycklar (t.ex. med `[1m]`-suffix eller ett datumstämplat
250
+ * snapshot-namn) kan kanonisera till samma sträng; unionen dedupar dem. */
251
+ const modellNycklar = (m) =>
252
+ [...new Set(Object.keys(m?.modelUsage ?? {}).map(canonicalModel))].sort()
253
+
254
+ /** Kostnad per kanonisk nyckel: dubbletter efter kanonisering summeras,
255
+ * annars skulle en snapshot-omdöpt nyckel tyst dölja den äldre kostnaden. */
256
+ const kostnadPerNyckel = (m) => {
257
+ const ut = {}
258
+ for (const [nyckel, v] of Object.entries(m?.modelUsage ?? {})) {
259
+ const k = canonicalModel(nyckel)
260
+ ut[k] = (ut[k] ?? 0) + (v?.costUSD ?? 0)
261
+ }
262
+ return ut
263
+ }
264
+
265
+ /** Maxantalet samtidiga agentprocesser över stegen, som text. Steg med null
266
+ * (räknaren föll) räknas inte; utan ett enda tal är samtidigheten okänd. */
267
+ const samtidighet = (matning) => {
268
+ const tal = (matning?.steg ?? [])
269
+ .map((s) => s?.samtidiga_processer)
270
+ .filter((n) => Number.isFinite(n))
271
+ if (tal.length === 0) return 'okänd samtidighet'
272
+ const max = Math.max(...tal)
273
+ return `${max} ${max === 1 ? 'samtidig' : 'samtidiga'}`
274
+ }
275
+
276
+ const kostnadsrader = (k, nycklar = modellNycklar(k.matning)) =>
277
+ KOSTNADSFALT.flatMap(([namn, källa, hämta]) => {
221
278
  // En del är ingen egen process: mätningen kommer ur headless-svaret för
222
279
  // hela körningen. "omätt" skulle påstå att mätningen misslyckades, vilket
223
280
  // är en annan sak än att den inte finns på den här nivån.
224
- if (k.rot) return [namn, `<em>ingår i ${esc(k.rot)}</em>`]
281
+ if (k.rot) {
282
+ const rad = [namn, `<em>ingår i ${esc(k.rot)}</em>`]
283
+ if (namn !== 'USD') return [rad]
284
+ // Sektionens nyckelunion gäller även delkolumnen: den har ingen egen
285
+ // modelUsage, så per-nyckelraderna får samma "ingår i"-text som
286
+ // totalen, aldrig streck (vilket skulle påstå att nyckeln är omätt här
287
+ // i stället för bokförd hos föräldern) eller för få celler (vilket
288
+ // skulle förskjuta tabellen mot syskonkolumnerna).
289
+ return [rad, ...nycklar.map((n) => [`USD ${n}`, `<em>ingår i ${esc(k.rot)}</em>`])]
290
+ }
225
291
  // Två oberoende källor: en saknad fil säger omätt för SINA rader och
226
292
  // ljuger inte om de andra. Radlistan har ändå alltid samma längd och
227
293
  // samma fältnamn, mätt eller omätt.
228
294
  const dok = k[källa]
229
- const värde = dok ? hämta(dok) : null
230
- return [namn, värde === null ? '<em>omätt</em>' : esc(värde)]
295
+ // Hela körningen följer med som andra argument: Väggtid läser klockan ur
296
+ // matning.json men samtidigheten ur korning.json:s steg.
297
+ const värde = dok ? hämta(dok, k) : null
298
+ const rad = [namn, värde === null ? '<em>omätt</em>' : esc(värde)]
299
+ if (namn !== 'USD') return [rad]
300
+ // #494: orkestratorns (hjälpmodellens) kostnad låg förr inbakad i
301
+ // kandidatens siffra under den enda USD-raden. En rad per fakturerad,
302
+ // kanoniserad nyckel läggs till efter totalen, som sektionens
303
+ // nyckelunion (`nycklar`) så att alla körningar i sektionen får samma
304
+ // radlista, mätt eller inte.
305
+ const per = kostnadPerNyckel(dok)
306
+ return [
307
+ rad,
308
+ ...nycklar.map((n) => [`USD ${n}`, n in per ? esc(usd(per[n])) : '<em>omätt</em>']),
309
+ ]
231
310
  })
232
311
 
233
312
  const provenienrader = (k) => {
@@ -271,6 +350,28 @@ const varde = (v) => {
271
350
  return esc(v)
272
351
  }
273
352
 
353
+ /** Poängens utfall. Prisfilen är det gällande dokumentet när den finns.
354
+ * Saknas den kan körningen ändå ha ett utfall: en kandidat (KONCEPT/DIAGNOS)
355
+ * eller en motsägelse (TILLBAKA_TILL_SPEC) stoppas innan scorern körs och
356
+ * får aldrig någon prisfil, bara `state.stopp`. Utan den här raden såg en
357
+ * sådan körning ut som en tom kolumn, och läsaren kunde inte skilja
358
+ * "stoppades tidigt" från "aldrig kört". Äldre arkiv utan `stopp` ger streck
359
+ * som förut. */
360
+ const poangUtfall = (k) => {
361
+ if (k.poang?.outcome != null) return k.poang.outcome
362
+ const stopp = k.state?.stopp
363
+ if (!stopp || typeof stopp !== 'object' || stopp.utfall == null) return null
364
+ return stopp.kalla ? `${stopp.utfall} (stopp ur ${stopp.kalla})` : `${stopp.utfall} (stopp)`
365
+ }
366
+
367
+ /** Statuscellen. Ofullständig går först: en bunt utan state.json är
368
+ * fortfarande oläsbar som körning, och det får inte döljas bakom buntetiketten. */
369
+ const statuscell = (k) => {
370
+ if (k.ofullstandig) return '<strong>ofullständig</strong>'
371
+ if (k.bunt) return esc(`bunt (${k.delar.length} delar)`)
372
+ return esc(k.state?.status ?? '–')
373
+ }
374
+
274
375
  /** Kuverten är slutna och nycklarna deras, inte rapportens: klassningen bär sitt
275
376
  * utfall i `classification.utfall`, poängen sitt tal i `score` och sin
276
377
  * inringning i `bracket`. Formerna är pinnade i contract-check.mjs. */
@@ -278,10 +379,10 @@ const utfallsrader = (k, harKlass = true) => [
278
379
  ...(harKlass ? [['Klass', esc(k.klass?.classification?.utfall ?? '–')]] : []),
279
380
  ['Poäng', esc(k.poang?.score ?? '–')],
280
381
  ['Inringning', esc(inringning(k.poang?.bracket))],
281
- ['Utfall poäng', esc(k.poang?.outcome ?? '–')],
382
+ ['Utfall poäng', esc(poangUtfall(k) ?? '–')],
282
383
  ['Delning', k.delar.length ? esc(`${k.delar.length} delar`) : 'delade sig inte'],
283
384
  ['Dubbelkörning', esc(k.state?.runs?.at(-1)?.double_run ?? '–')],
284
- ['Status', k.ofullstandig ? '<strong>ofullständig</strong>' : esc(k.state?.status ?? '–')],
385
+ ['Status', statuscell(k)],
285
386
  ]
286
387
 
287
388
  const specrader = (k) => SPECFALT.map((f) => [f, `<pre>${esc(k.spec?.[f] ?? '–')}</pre>`])
@@ -333,7 +434,7 @@ const klassrader = (k) => {
333
434
  const poangrader = (k) => {
334
435
  const p = k.poang
335
436
  return [
336
- ['Utfall', varde(p?.outcome)],
437
+ ['Utfall', varde(poangUtfall(k))],
337
438
  ['Poäng', varde(p?.score)],
338
439
  ['Typ', varde(p?.type)],
339
440
  ['Inringning', esc(inringning(p?.bracket))],
@@ -347,6 +448,7 @@ const poangrader = (k) => {
347
448
  ['Villkor', varde(p?.conditions)],
348
449
  ['Beroenden', varde(p?.dependencies)],
349
450
  ['Noter', varde(p?.notes)],
451
+ ['Täckningspost', varde(p?.coverage_post)],
350
452
  ['Alternativ', varde(p?.alternatives)],
351
453
  ['Kundmotivering', varde(p?.customer_rationale)],
352
454
  ]
@@ -421,6 +523,10 @@ const postSektion = (post) => {
421
523
  // äldre arkiv. Kalibreringshistoriken kastas inte, men en post utan axeln
422
524
  // ska inte visa en tom sektion för den.
423
525
  const harKlass = korningar.some((k) => k.klass != null)
526
+ // #494: nyckelunionen beräknas en gång för hela sektionen, inte per
527
+ // körning, annars kan två körningar i samma sektion få olika radlängd på
528
+ // Kostnad och tabellen skulle förskjutas.
529
+ const kostnadsnycklar = [...new Set(korningar.flatMap((k) => modellNycklar(k.matning)))].sort()
424
530
  const block = [
425
531
  ['Utfall', (k) => utfallsrader(k, harKlass)],
426
532
  ...(harKlass ? [['Klassning', klassrader]] : []),
@@ -428,7 +534,7 @@ const postSektion = (post) => {
428
534
  ['Spec', specrader],
429
535
  ['Analys', analysrader],
430
536
  ['Granskarblock', granskarrader],
431
- ['Kostnad', kostnadsrader],
537
+ ['Kostnad', (k) => kostnadsrader(k, kostnadsnycklar)],
432
538
  ['Proveniens', provenienrader],
433
539
  ]
434
540
  return `