@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.
- package/CHANGELOG.md +19 -0
- package/library/catalog/manifest.json +67 -61
- package/library/global/agents/request-assessor.md +22 -6
- package/library/global/agents/request-scorer.md +3 -1
- 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 +700 -221
- package/library/global/skills/request/scripts/archive.mjs +920 -59
- package/library/global/skills/request/scripts/compare-scores.mjs +119 -13
- package/library/global/skills/request/scripts/contract-check.mjs +302 -40
- package/library/global/skills/request/scripts/kontroll.mjs +640 -0
- package/library/global/skills/request/scripts/korning.mjs +1635 -0
- package/library/global/skills/request/scripts/lib.mjs +541 -5
- package/library/global/skills/request/scripts/precheck.mjs +148 -5
- package/library/global/skills/request/scripts/report.mjs +122 -16
- package/library/global/skills/request/scripts/svep.mjs +737 -0
- package/library/global/skills/request-assess-impact/SKILL.md +58 -4
- package/library/global/skills/request-score/SKILL.md +57 -11
- package/library/global/skills/request-score/references/poangskala.md +3 -0
- package/library/global/skills/request-sync/SKILL.md +11 -12
- package/library/global/skills/request-write-spec/SKILL.md +13 -4
- package/library/global/skills/request-write-spec/references/specformat.md +48 -0
- package/package.json +1 -1
|
@@ -16,7 +16,22 @@
|
|
|
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
|
+
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 = '
|
|
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
|
-
//
|
|
76
|
-
//
|
|
77
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
220
|
-
|
|
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)
|
|
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
|
-
|
|
230
|
-
|
|
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
|
|
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',
|
|
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(
|
|
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 `
|