@haus-tech/haus-workflow 4.9.0 → 5.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/dist/cli.js +1381 -871
  3. package/library/catalog/manifest.json +97 -34
  4. package/library/global/agents/request-assessor.md +2 -2
  5. package/library/global/agents/request-scorer.md +13 -8
  6. package/library/global/agents/request-specifier-eskalerad.md +1 -1
  7. package/library/global/agents/request-specifier.md +1 -1
  8. package/library/global/skills/request/SKILL.md +691 -374
  9. package/library/global/skills/request/scripts/archive.mjs +1376 -0
  10. package/library/global/skills/request/scripts/check-lasta.mjs +64 -0
  11. package/library/global/skills/request/scripts/compare-scores.mjs +23 -15
  12. package/library/global/skills/request/scripts/contract-check.mjs +108 -36
  13. package/library/global/skills/request/scripts/lib.mjs +66 -0
  14. package/library/global/skills/request/scripts/precheck.mjs +30 -27
  15. package/library/global/skills/request/scripts/report.mjs +571 -0
  16. package/library/global/skills/request/scripts/resolve-anchors.mjs +243 -0
  17. package/library/global/skills/request-assess-impact/SKILL.md +10 -10
  18. package/library/global/skills/request-score/SKILL.md +19 -10
  19. package/library/global/skills/request-sync/SKILL.md +7 -7
  20. package/library/global/skills/request-write-spec/SKILL.md +6 -6
  21. package/library/global/skills/request-write-spec/references/specformat.md +2 -2
  22. package/package.json +1 -1
  23. package/library/global/agents/request-classifier.md +0 -55
  24. package/library/global/skills/request/scripts/compare-classifications.mjs +0 -94
  25. package/library/global/skills/request/scripts/quote-repair.mjs +0 -166
  26. package/library/global/skills/request-classify/SKILL.md +0 -79
  27. package/library/global/skills/request-classify/references/klassningsinstruktion.md +0 -152
@@ -0,0 +1,571 @@
1
+ /**
2
+ * Läser experimentytan och renderar en självständig HTML-rapport.
3
+ *
4
+ * Rapporten är ett LÄSVERKTYG. Den rangordnar aldrig modeller, rekommenderar
5
+ * aldrig en nivå och räknar aldrig fram ett facit: bedömningen av vad som är
6
+ * ett korrekt utfall är människans, gjord på erfarenhet av systemet. Scriptet
7
+ * visar spridningen och håller tyst om vem som hade rätt.
8
+ *
9
+ * Matrisen är med flit ojämn. En post kan ha opus och sonnet, en annan opus,
10
+ * sonnet och fable, eftersom en dyrare modell läggs till bara där de billigare
11
+ * inte räckte. Därför förutsätter rapporten aldrig samma kolumnuppsättning och
12
+ * räknar aldrig aggregat tvärs poster: ett medelvärde över olika
13
+ * modelluppsättningar jämför olika saker.
14
+ *
15
+ * Användning: node report.mjs <experimentyta> [--ut <fil>]
16
+ * Exit 0 = rapport skriven, 2 = användnings- eller lässfel.
17
+ *
18
+ * Varje radbyggare returnerar färdig HTML och äger sin egen escaping;
19
+ * `sektion` interpolerar cellerna oescapade. En ny sektion som glömmer detta
20
+ * öppnar för injektion, så regeln måste stå här, inte bara i författarnas huvud.
21
+ */
22
+ import fs from 'node:fs'
23
+ import path from 'node:path'
24
+ import { fileURLToPath } from 'node:url'
25
+ import { sectionLines } from './lib.mjs'
26
+
27
+ const SPECFALT = ['Problem', 'Lösning', 'När det är klart', 'Ingår inte']
28
+
29
+ /** Körningskataloger heter k<N>. Sorteringen är numerisk: k10 kommer efter k2,
30
+ * vilket strängordning inte ger. */
31
+ const KORNING_RE = /^k(\d+)$/
32
+
33
+ const läsJson = (p) => {
34
+ try {
35
+ return JSON.parse(fs.readFileSync(p, 'utf8'))
36
+ } catch {
37
+ return null
38
+ }
39
+ }
40
+
41
+ const kataloger = (p) =>
42
+ fs
43
+ .readdirSync(p, { withFileTypes: true })
44
+ .filter((e) => e.isDirectory())
45
+ .map((e) => e.name)
46
+
47
+ /** Gällande pris-JSON för en axel: högsta vN UTAN suffix. En `-run2`- eller
48
+ * `-avvisad`-fil är aldrig det gällande dokumentet, samma regel som
49
+ * `detaljer` följer.
50
+ *
51
+ * Axelnamnen är arkivets filnamn (`classification`, `score`), aldrig skillens
52
+ * svenska axelord: filerna på disk heter `classification-pricing-vN.json` och
53
+ * `score-pricing-vN.json`, pinnade av PRICING_RE i archive.mjs. */
54
+ const gallandePrisfil = (dir, axel) => {
55
+ const re = new RegExp(`^${axel}-pricing-v(\\d+)\\.json$`)
56
+ const träffar = fs
57
+ .readdirSync(dir)
58
+ .map((f) => [f, re.exec(f)])
59
+ .filter(([, m]) => m)
60
+ .sort((a, b) => Number(a[1][1]) - Number(b[1][1]))
61
+ return träffar.length ? läsJson(path.join(dir, träffar.at(-1)[0])) : null
62
+ }
63
+
64
+ const läsSpec = (dir) => {
65
+ let rå
66
+ try {
67
+ rå = fs.readFileSync(path.join(dir, 'spec.md'), 'utf8')
68
+ } catch {
69
+ return null
70
+ }
71
+ return Object.fromEntries(
72
+ SPECFALT.map((f) => {
73
+ const rader = sectionLines(rå, f)
74
+ return [f, rader === null ? null : rader.join('\n').trim()]
75
+ }),
76
+ )
77
+ }
78
+
79
+ /** Arkiverat dokument, eller null. Ett saknat dokument är inte samma sak som en
80
+ * ofullständig körning: analysen finns inte förrän bedömningsbenet körts, och
81
+ * en spec-only-körning är fortfarande en hel körning av det den var. */
82
+ const läsDok = (dir, namn) => {
83
+ try {
84
+ return fs.readFileSync(path.join(dir, namn), 'utf8')
85
+ } catch {
86
+ return null
87
+ }
88
+ }
89
+
90
+ const läsKorning = (dir, namn) => {
91
+ const state = läsJson(path.join(dir, 'state.json'))
92
+ const spec = läsSpec(dir)
93
+ return {
94
+ namn,
95
+ sokvag: dir,
96
+ // Ofullständig betyder att rapporten inte kan visa körningen som en hel
97
+ // körning. Den visas ändå, märkt: tyst utelämnande är den enda utgången
98
+ // som skulle ljuga för läsaren.
99
+ ofullstandig: state === null || spec === null,
100
+ state,
101
+ spec,
102
+ impact: läsDok(dir, 'impact.md'),
103
+ granskarblock: läsDok(dir, 'granskarblock.md'),
104
+ klass: gallandePrisfil(dir, 'classification'),
105
+ poang: gallandePrisfil(dir, 'score'),
106
+ matning: läsJson(path.join(dir, 'korning.json')),
107
+ // Startarens egen mätning. Egen fil med flit: `korning.json` är
108
+ // headless-svaret rått, och en haus-ägd nyckel i den gör den till ett
109
+ // blandat format som inte längre går att diffa mot CLI:ns utdata.
110
+ 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)),
115
+ }
116
+ }
117
+
118
+ export function läsExperimentyta(rot) {
119
+ if (!fs.existsSync(rot)) throw new Error(`experimentytan ${rot} finns inte`)
120
+ const alla = kataloger(rot)
121
+ .sort()
122
+ .map((slug) => {
123
+ const postDir = path.join(rot, slug)
124
+ const experiment = läsJson(path.join(postDir, 'experiment.json'))
125
+ const modeller = kataloger(postDir)
126
+ .sort()
127
+ .map((modell) => ({
128
+ namn: modell,
129
+ korningar: kataloger(path.join(postDir, modell))
130
+ .map((k) => [k, KORNING_RE.exec(k)])
131
+ .filter(([, m]) => m)
132
+ .sort((a, b) => Number(a[1][1]) - Number(b[1][1]))
133
+ .map(([k]) => läsKorning(path.join(postDir, modell, k), k)),
134
+ }))
135
+ .filter((m) => m.korningar.length > 0)
136
+ return { slug, experiment, modeller }
137
+ })
138
+ // En post utan k<N>-körningar är inte samma sak som en post som inte finns:
139
+ // ett äldre arkivlager lägger sina delar direkt under modellkatalogen, utan
140
+ // k<N>-nivå, och den posten ska synas som skippad, aldrig som frånvarande.
141
+ // `alla` är redan sorterad på slug (kataloger(rot).sort() ovan), så
142
+ // filtreringen behåller ordningen utan en extra sort.
143
+ const poster = alla.filter((p) => p.modeller.length > 0)
144
+ const utan_korningar = alla.filter((p) => p.modeller.length === 0).map((p) => p.slug)
145
+ return { rot, poster, utan_korningar }
146
+ }
147
+
148
+ const esc = (s) =>
149
+ String(s ?? '').replace(
150
+ /[&<>"']/g,
151
+ (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c],
152
+ )
153
+
154
+ const tal = (n) => (n == null ? '–' : new Intl.NumberFormat('sv-SE').format(n))
155
+ const usd = (n) => (n == null ? '–' : `$${n.toFixed(2)}`)
156
+ const minuter = (ms) => (ms == null ? '–' : `${(ms / 60000).toFixed(1)} min`)
157
+
158
+ /** Ett gid är siffror och inget annat. En slug-post (spec utan Asana-ticket)
159
+ * har inget gid och ska inte få ett påhittat: ett felaktigt gid i rapporten
160
+ * leder läsaren till fel ticket, vilket är värre än inget gid alls. */
161
+ const GID_RE = /^\d+$/
162
+
163
+ /** Postens identitet. Titeln och permalänken är experimentets, aldrig
164
+ * rapportens: Asanas permalänkar bär ett workspace-gid som inte finns någonstans
165
+ * på experimentytan, så en konstruerad URL vore en gissning som tyst slutar
166
+ * fungera. Datumet läses under båda namnen, äldre filer bär `date`. */
167
+ const identitet = (post) => {
168
+ const e = post.experiment ?? {}
169
+ const rid = String(e.request_id ?? '')
170
+ return {
171
+ titel: e.title || post.slug,
172
+ gid: GID_RE.test(rid) ? rid : GID_RE.test(post.slug) ? post.slug : null,
173
+ lank:
174
+ typeof e.permalink_url === 'string' && e.permalink_url.startsWith('https://')
175
+ ? e.permalink_url
176
+ : null,
177
+ datum: e.datum ?? e.date ?? null,
178
+ }
179
+ }
180
+
181
+ /** Identitetsraden under rubriken. Gid:t utelämnas när det redan ÄR rubriken
182
+ * (titellös post), men länken och datumet står kvar, annars tappas de för
183
+ * precis de poster som är svårast att känna igen. */
184
+ const identRad = (id) => {
185
+ const bitar = []
186
+ if (id.gid && id.gid !== id.titel)
187
+ bitar.push(id.lank ? `<a href="${esc(id.lank)}">${esc(id.gid)}</a>` : esc(id.gid))
188
+ else if (id.lank) bitar.push(`<a href="${esc(id.lank)}">Asana</a>`)
189
+ if (id.datum) bitar.push(esc(id.datum))
190
+ return bitar.length ? `<p class="ident">${bitar.join(' · ')}</p>` : ''
191
+ }
192
+
193
+ /** Kostnadsraderna. En körning utan korning.json är OMÄTT, inte noll: nollor
194
+ * skulle läsas som "gratis" och är den enda siffra rapporten kan ljuga med.
195
+ *
196
+ * Radlistan har ALLTID samma längd och samma fältnamn, mätt eller omätt. Varje
197
+ * sektion tar sina fältnamn från den första körningens lista, så en omätt
198
+ * körning med färre rader skulle förskjuta hela tabellen och ställa USD mot
199
+ * väggtid.
200
+ *
201
+ * Varje fält deklarerar sin egen källa: kostnaden och tokens kommer ur
202
+ * `korning.json` (headless-svaret), klocktiden ur `matning.json` (startarens
203
+ * mätning). */
204
+ const KOSTNADSFALT = [
205
+ ['USD', 'matning', (m) => usd(m.total_cost_usd)],
206
+ ['Tokens in', 'matning', (m) => tal(m.usage?.input_tokens)],
207
+ ['Tokens ut', 'matning', (m) => tal(m.usage?.output_tokens)],
208
+ ['Cache läst', 'matning', (m) => tal(m.usage?.cache_read_input_tokens)],
209
+ ['Cache skriven', 'matning', (m) => tal(m.usage?.cache_creation_input_tokens)],
210
+ // Klocktiden kommer ur startarens mätning, aldrig ur headless-svarets
211
+ // `duration_ms`: det fältet räknar inte in väntan på bakgrundssubagenter och
212
+ // sa 5,5 minuter om en sonnetkörning som tog omkring 34. Ett `wall_ms` som
213
+ // saknas eller inte är ett tal är omätt, aldrig noll: en nolla skulle läsas
214
+ // som en ögonblicklig körning.
215
+ ['Väggtid', 'klocka', (m) => (Number.isFinite(m.wall_ms) ? minuter(m.wall_ms) : null)],
216
+ ['Dispatcher', 'matning', (m) => tal(m.subagent_stats?.spawned)],
217
+ ]
218
+
219
+ const kostnadsrader = (k) =>
220
+ KOSTNADSFALT.map(([namn, källa, hämta]) => {
221
+ // En del är ingen egen process: mätningen kommer ur headless-svaret för
222
+ // hela körningen. "omätt" skulle påstå att mätningen misslyckades, vilket
223
+ // ä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>`]
225
+ // Två oberoende källor: en saknad fil säger omätt för SINA rader och
226
+ // ljuger inte om de andra. Radlistan har ändå alltid samma längd och
227
+ // samma fältnamn, mätt eller omätt.
228
+ 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)]
231
+ })
232
+
233
+ const provenienrader = (k) => {
234
+ const p = k.state?.proveniens ?? {}
235
+ return [
236
+ ['Modell', esc(p.modell ?? '–')],
237
+ ['Datum', esc(p.datum ?? '–')],
238
+ ['Konto', esc(p.konto ?? '–')],
239
+ ['SKILL.md', esc((p.skill_sha256 ?? '–').slice(0, 12))],
240
+ ['Underlag', esc((p.underlag_sha256 ?? '–').slice(0, 12))],
241
+ ]
242
+ }
243
+
244
+ /** Inringningen är ett objekt, inte en sträng: `esc()` rakt på den ger
245
+ * `[object Object]`. Golvlöst är kontraktsenligt, `floor` är null när taket är
246
+ * A1 och posten alltså är mindre än A1, och då är "under A1" det ärliga
247
+ * läsvärdet. Saknas inringningen helt får den samma streck som övriga saknade
248
+ * värden. */
249
+ const inringning = (b) => {
250
+ const tak = b?.ceiling?.anchor
251
+ if (!tak) return '–'
252
+ const golv = b?.floor?.anchor
253
+ return golv ? `${golv} till ${tak}` : `under ${tak}`
254
+ }
255
+
256
+ /** Renderar ett godtyckligt kuvertvärde. Kontraktet pinnar bara ytterhöljet
257
+ * (contract-check kräver floor/ceiling/between/steps och två till tre drivers);
258
+ * vad som ligger INUTI ett steg eller en driver är inte pinnat och skiljer sig
259
+ * mellan arkiv: en floor bär `score` och `why` i en körning och `poang` i en
260
+ * annan, en driver `faktor` i en och `ankare` i en annan. En renderare som
261
+ * plockar namngivna innernycklar tappar därför tyst innehåll i hälften av
262
+ * arkiven. Den här skriver ut de nycklar som faktiskt finns. */
263
+ const varde = (v) => {
264
+ if (v == null || v === '') return '–'
265
+ if (Array.isArray(v))
266
+ return v.length ? `<ul>${v.map((x) => `<li>${varde(x)}</li>`).join('')}</ul>` : '–'
267
+ if (typeof v === 'object') {
268
+ const rader = Object.entries(v).filter(([, x]) => x != null && x !== '')
269
+ return rader.length ? rader.map(([k, x]) => `<b>${esc(k)}:</b> ${varde(x)}`).join('<br>') : '–'
270
+ }
271
+ return esc(v)
272
+ }
273
+
274
+ /** Kuverten är slutna och nycklarna deras, inte rapportens: klassningen bär sitt
275
+ * utfall i `classification.utfall`, poängen sitt tal i `score` och sin
276
+ * inringning i `bracket`. Formerna är pinnade i contract-check.mjs. */
277
+ const utfallsrader = (k, harKlass = true) => [
278
+ ...(harKlass ? [['Klass', esc(k.klass?.classification?.utfall ?? '–')]] : []),
279
+ ['Poäng', esc(k.poang?.score ?? '–')],
280
+ ['Inringning', esc(inringning(k.poang?.bracket))],
281
+ ['Utfall poäng', esc(k.poang?.outcome ?? '–')],
282
+ ['Delning', k.delar.length ? esc(`${k.delar.length} delar`) : 'delade sig inte'],
283
+ ['Dubbelkörning', esc(k.state?.runs?.at(-1)?.double_run ?? '–')],
284
+ ['Status', k.ofullstandig ? '<strong>ofullständig</strong>' : esc(k.state?.status ?? '–')],
285
+ ]
286
+
287
+ const specrader = (k) => SPECFALT.map((f) => [f, `<pre>${esc(k.spec?.[f] ?? '–')}</pre>`])
288
+
289
+ /** Hopfälld som default: impact.md är flera sidor, och tre öppna analyser sida
290
+ * vid sida trycker ner utfallstabellen ur bild. Saknat dokument ger streck,
291
+ * aldrig en tom hopfällare som lovar innehåll som inte finns. */
292
+ const hopfalld = (text) =>
293
+ text == null ? '–' : `<details><summary>visa</summary><pre>${esc(text)}</pre></details>`
294
+
295
+ const analysrader = (k) => [['impact.md', hopfalld(k.impact)]]
296
+ const granskarrader = (k) => [['granskarblock.md', hopfalld(k.granskarblock)]]
297
+
298
+ /** Klassningens fältvisa rader. Frågeraderna är dynamiska: id-listan kommer ur
299
+ * kuvertet, inte ur en hårdkodad lista, eftersom protokollets frågor och
300
+ * stopputfallens delmängder skiljer sig mellan körningar. Belägget står under
301
+ * svaret därför att det är den enda delen som avslöjar om två lika svar vilar
302
+ * på samma läsning av underlaget. */
303
+ const klassrader = (k) => {
304
+ const c = k.klass?.classification
305
+ const kund = k.klass?.customer
306
+ // En handredigerad eller trunkerad envelope kan ha en null-post i fragor;
307
+ // contract-check.mjs stoppar det för välformade arkiv, men rapporten
308
+ // degraderar per cell, aldrig per sida, så en icke-objekt-rad filtreras bort
309
+ // i stället för att kasta och riva ner hela sidan.
310
+ const fragor = Array.isArray(c?.fragor) ? c.fragor.filter((f) => f && typeof f === 'object') : []
311
+ return [
312
+ ['Utfall', varde(c?.utfall)],
313
+ ['Basklass', varde(c?.basklass)],
314
+ ...fragor.map((f) => [
315
+ esc(f.id ?? '?'),
316
+ `${varde(f.svar)}${f.konfidens == null ? '' : ` (konfidens ${varde(f.konfidens)})`}` +
317
+ `<br><em>${esc(f.belagg ?? '–')}</em>`,
318
+ ]),
319
+ ['Bumpar träffade', varde(c?.bumpar_traffade)],
320
+ ['Genomförbarhetsvillkor', varde(c?.genomforbarhetsvillkor)],
321
+ ['Beroenden', varde(c?.beroenden)],
322
+ ['Saknas', varde(c?.saknas)],
323
+ ['Eskaleringsskäl', varde(c?.eskaleringsskal)],
324
+ ['Slutklassmotivering', varde(c?.slutklass_motivering)],
325
+ ['Kundmotivering', varde(kund?.rationale)],
326
+ ]
327
+ }
328
+
329
+ /** Poängens fältvisa rader. `inringning` behålls som sammanfattningsrad och
330
+ * golv/tak/steg visas dessutom hela: sammanfattningen svarar på VAR posten
331
+ * landade, de tre raderna på VARFÖR, och det är varför-delen som skiljer två
332
+ * modeller som råkade landa på samma tal. */
333
+ const poangrader = (k) => {
334
+ const p = k.poang
335
+ return [
336
+ ['Utfall', varde(p?.outcome)],
337
+ ['Poäng', varde(p?.score)],
338
+ ['Typ', varde(p?.type)],
339
+ ['Inringning', esc(inringning(p?.bracket))],
340
+ ['Golv', varde(p?.bracket?.floor)],
341
+ ['Tak', varde(p?.bracket?.ceiling)],
342
+ ['Mellan', varde(p?.bracket?.between)],
343
+ ['Steg', varde(p?.bracket?.steps)],
344
+ ['Drivers', varde(p?.drivers)],
345
+ ['Konfidens', varde(p?.confidence)],
346
+ ['Antaganden', varde(p?.assumptions_stated)],
347
+ ['Villkor', varde(p?.conditions)],
348
+ ['Beroenden', varde(p?.dependencies)],
349
+ ['Noter', varde(p?.notes)],
350
+ ['Alternativ', varde(p?.alternatives)],
351
+ ['Kundmotivering', varde(p?.customer_rationale)],
352
+ ]
353
+ }
354
+
355
+ /** En sektion är en rubrik plus rader, där varje rad har ett fältnamn och en
356
+ * cell per körning. Fältvis, inte dokumentvis: det som ska jämföras är samma
357
+ * fält, inte samma sidnummer. */
358
+ const sektion = (rubrik, rader, korningar) => `
359
+ <h3>${esc(rubrik)}</h3>
360
+ <div class="scroll">
361
+ <table>
362
+ <thead><tr><th>Fält</th>${korningar
363
+ .map((k) => `<th>${esc(k.rubrik)}</th>`)
364
+ .join('')}</tr></thead>
365
+ <tbody>
366
+ ${rader
367
+ .map(
368
+ (fältnamn, i) =>
369
+ `<tr><th>${esc(fältnamn)}</th>${korningar
370
+ .map((k) => `<td>${k.celler[i]}</td>`)
371
+ .join('')}</tr>`,
372
+ )
373
+ .join('\n')}
374
+ </tbody>
375
+ </table>
376
+ </div>`
377
+
378
+ /** Bygger en sektion ur en radfunktion per kolumn. Fältnamnen är UNIONEN över
379
+ * kolumnerna i första-förekomst-ordning, aldrig kolumn noll:s lista: två
380
+ * modeller som besvarat olika frågor är normalfallet vid stopputfall, och ett
381
+ * fält som bara en kolumn har måste synas, annars döljer rapporten just den
382
+ * skillnad läsaren letar efter. En kolumn utan fältet får streck. */
383
+ const byggSektion = (rubrik, kolumner, radfn) => {
384
+ const perKolumn = kolumner.map((k) => new Map(radfn(k)))
385
+ const namn = []
386
+ for (const m of perKolumn) for (const n of m.keys()) if (!namn.includes(n)) namn.push(n)
387
+ return sektion(
388
+ rubrik,
389
+ namn,
390
+ kolumner.map((k, i) => ({ ...k, celler: namn.map((n) => perKolumn[i].get(n) ?? '–') })),
391
+ )
392
+ }
393
+
394
+ /** Kolumnerna: varje körning följd av sina delar. Delarna är egna kolumner
395
+ * eftersom det är DÄR utfallen bor i en delad post; räknaren "N delar" döljer
396
+ * hela resultatet. `rot` är null för en riktig körning och namnet på
397
+ * körningen för en del, vilket är det kostnadsraderna behöver för att säga
398
+ * "ingår i k1" i stället för att påstå att mätningen misslyckades.
399
+ *
400
+ * Rekursivt trots att kontraktet stannar på en nivå (en del utan tak blir
401
+ * OVER_SKALAN och delas aldrig vidare): en oväntad nivå ska synas som en
402
+ * kolumn, aldrig tappas tyst. */
403
+ const plattaKolumner = (k, modell, prefix, rot) => {
404
+ const rubrik = `${prefix} / ${k.namn}`
405
+ return [
406
+ { ...k, modell, rubrik, rot },
407
+ ...k.delar.flatMap((d) => plattaKolumner(d, modell, rubrik, rot ?? k.namn)),
408
+ ]
409
+ }
410
+
411
+ const postSektion = (post) => {
412
+ // Kolumnerna är alla körningar av alla modeller för posten, plus varje
413
+ // körnings delar som egna kolumner direkt efter den. Ojämn matris är
414
+ // normalfallet, inte ett undantag som ska jämnas ut.
415
+ const korningar = post.modeller.flatMap((m) =>
416
+ m.korningar.flatMap((k) => plattaKolumner(k, m.namn, m.namn, null)),
417
+ )
418
+ const id = identitet(post)
419
+ // ADR-0098: klassaxeln är borttagen — Klassning-sektionen (och Klass-raden
420
+ // i Utfall) renderas bara när posten faktiskt bär klassningsfiler, dvs. för
421
+ // äldre arkiv. Kalibreringshistoriken kastas inte, men en post utan axeln
422
+ // ska inte visa en tom sektion för den.
423
+ const harKlass = korningar.some((k) => k.klass != null)
424
+ const block = [
425
+ ['Utfall', (k) => utfallsrader(k, harKlass)],
426
+ ...(harKlass ? [['Klassning', klassrader]] : []),
427
+ ['Poäng', poangrader],
428
+ ['Spec', specrader],
429
+ ['Analys', analysrader],
430
+ ['Granskarblock', granskarrader],
431
+ ['Kostnad', kostnadsrader],
432
+ ['Proveniens', provenienrader],
433
+ ]
434
+ return `
435
+ <section class="post" id="post-${esc(post.slug)}" hidden>
436
+ <h2>${esc(id.titel)}</h2>
437
+ ${identRad(id)}
438
+ ${block.map(([rubrik, fn]) => byggSektion(rubrik, korningar, fn)).join('\n')}
439
+ </section>`
440
+ }
441
+
442
+ /** Anledningen upprepas i båda hoppa-över-fallen nedan: en post utan
443
+ * k<N>-katalog är inte tom, den är skriven i ett äldre arkivlager som ligger
444
+ * utanför vad rapporten läser. Att kalla den tom skulle påstå att datan är
445
+ * borta, när den bara ligger på en nivå rapporten inte tolkar. */
446
+ const K_FORMAT_FORKLARING =
447
+ 'Rapporten läser körningar ur kataloger som heter k1, k2 och så vidare. Ett äldre arkiv, ' +
448
+ 'skrivet innan den namngivningen togs i bruk, har sina delar direkt under modellkatalogen, ' +
449
+ 'och de syns därför inte i den här rapporten.'
450
+
451
+ const postord = (n) => (n === 1 ? 'postkatalog' : 'postkataloger')
452
+
453
+ /** Katalognamnen kommer från filsystemet, inte från något som redan är
454
+ * HTML-säkert, så de escapas precis som varje annat arkivnamn i filen. */
455
+ const hoppadeLista = (namn) =>
456
+ `<details><summary>visa</summary><pre>${namn.map(esc).join('\n')}</pre></details>`
457
+
458
+ /** De tre sanna fallen. "Tom" är bara sant när det inte fanns några
459
+ * postkataloger alls: finns kataloger som skippades är ytan inte tom, den
460
+ * har lästa poster på fel nivå, och det är en annan sak att säga. */
461
+ const innehall = (modell) => {
462
+ const { poster, utan_korningar: skippade } = modell
463
+ if (poster.length === 0 && skippade.length === 0) {
464
+ return '<p class="tom">Experimentytan är tom.</p>'
465
+ }
466
+ if (poster.length === 0) {
467
+ return `<p class="tom">${skippade.length} ${postord(skippade.length)} hittades, men ingen har körningar under k&lt;N&gt;. ${K_FORMAT_FORKLARING}</p>
468
+ ${hoppadeLista(skippade)}`
469
+ }
470
+ const meny = poster
471
+ .map((p) => {
472
+ const namn = identitet(p).titel
473
+ const suffix = namn === p.slug ? '' : `, ${p.slug}`
474
+ return `<option value="post-${esc(p.slug)}">${esc(namn)}${esc(suffix)} (${p.modeller
475
+ .map((m) => `${esc(m.namn)}×${m.korningar.length}`)
476
+ .join(', ')})</option>`
477
+ })
478
+ .join('\n')
479
+ // Notisen står ovanför menyn, inte längst ner: en läsare som jämför
480
+ // modeller ska se att ytan är ofullständig INNAN hen börjar jämföra, inte
481
+ // upptäcka det av misstag efter att redan ha bildat sig en uppfattning.
482
+ const notis = skippade.length
483
+ ? `<p class="notis">${skippade.length} ${postord(skippade.length)} hoppades över: ingen körning under k&lt;N&gt; hittades. ${K_FORMAT_FORKLARING}</p>
484
+ ${hoppadeLista(skippade)}`
485
+ : ''
486
+ return `${notis}
487
+ <p><label>Post: <select id="val">${meny}</select></label></p>
488
+ ${poster.map(postSektion).join('\n')}
489
+ <script>
490
+ const val = document.getElementById('val')
491
+ const visa = () => {
492
+ for (const s of document.querySelectorAll('section.post')) s.hidden = s.id !== val.value
493
+ }
494
+ val.addEventListener('change', visa)
495
+ visa()
496
+ </script>`
497
+ }
498
+
499
+ export function renderaHtml(modell) {
500
+ return `<!doctype html>
501
+ <meta charset="utf-8">
502
+ <title>Modellrapport</title>
503
+ <style>
504
+ body { font: 15px/1.5 system-ui, sans-serif; margin: 2rem; max-width: 100%; }
505
+ select { font-size: 1rem; padding: .4rem; }
506
+ /* table-layout: fixed gör kolumnbredderna oberoende av innehållet: fältkolumnen
507
+ får sina 9rem, och datakolumnerna sin angivna bredd. Med auto-layout skulle
508
+ i stället den kolumn som råkar ha längst spec bli bredast, och fältkolumnen
509
+ hoppa i sidled mellan sektionerna.
510
+ min-width i stället för width på tabellen är det som låter .scroll göra sitt
511
+ jobb: får kolumnerna plats sträcks tabellen till full bredd och de delar
512
+ utrymmet, får de inte plats växer tabellen förbi behållaren och den scrollar
513
+ i sidled. Med width: 100% kunde tabellen aldrig bli bredare än behållaren,
514
+ så scrollen slog aldrig in och kolumnerna krympte i stället obegränsat: en
515
+ post delad i tre delar över fyra modeller ger sexton datakolumner, och de
516
+ blev då för smala för att läsa. */
517
+ table { border-collapse: collapse; margin: 1rem 0; width: max-content; min-width: 100%; table-layout: fixed; }
518
+ th, td { border: 1px solid #ccc; padding: .4rem .6rem; text-align: left; vertical-align: top; }
519
+ thead th { background: #f3f3f3; }
520
+ tbody th, thead th:first-child { width: 9rem; font-weight: 600; }
521
+ /* Golvbredden per datakolumn. Räcker utrymmet fördelas överskottet över dem,
522
+ annars är det här den bredd de behåller medan tabellen scrollar. */
523
+ tbody td, thead th:not(:first-child) { width: 14rem; }
524
+ /* Fast layout ger cellen ingen chans att växa, så allt som inte ryms måste
525
+ kunna brytas. Utan detta spiller en lång URL i en spec ut över grannkolumnen. */
526
+ td, th { overflow-wrap: anywhere; }
527
+ pre { white-space: pre-wrap; margin: 0; font: inherit; }
528
+ .tom { color: #666; }
529
+ .notis { color: #666; }
530
+ .ident { color: #666; margin: -.5rem 0 1rem; }
531
+ .scroll { overflow-x: auto; }
532
+ </style>
533
+ <h1>Modellrapport</h1>
534
+ <p>Underlag för manuell bedömning av lägsta acceptabla modellnivå. Rapporten tar
535
+ inte ställning, den visar spridningen mellan körningarna.</p>
536
+ ${innehall(modell)}
537
+ `
538
+ }
539
+
540
+ // ---- CLI -------------------------------------------------------------------
541
+ // `file://${argv[1]}` bryter mot import.meta.url när argv är /var/... och
542
+ // Node resolvar till /private/var/... (macOS-temp, och ~/.claude bakom länk).
543
+ const isCli = (() => {
544
+ if (!process.argv[1]) return false
545
+ try {
546
+ return fs.realpathSync(fileURLToPath(import.meta.url)) === fs.realpathSync(process.argv[1])
547
+ } catch {
548
+ return false
549
+ }
550
+ })()
551
+ const [rot, ...rest] = process.argv.slice(2)
552
+ if (isCli) {
553
+ if (!rot) {
554
+ console.error('användning: node report.mjs <experimentyta> [--ut <fil>]')
555
+ process.exit(2)
556
+ }
557
+ const iUt = rest.indexOf('--ut')
558
+ const utFil = iUt === -1 ? null : rest[iUt + 1]
559
+ if (iUt !== -1 && (!utFil || utFil.startsWith('--'))) {
560
+ console.error('--ut kräver ett värde')
561
+ process.exit(2)
562
+ }
563
+ try {
564
+ const html = renderaHtml(läsExperimentyta(rot))
565
+ if (utFil) fs.writeFileSync(utFil, html)
566
+ else process.stdout.write(html)
567
+ } catch (e) {
568
+ console.error(e.message)
569
+ process.exit(2)
570
+ }
571
+ }