@haus-tech/haus-workflow 5.0.0 → 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.
@@ -16,7 +16,21 @@
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
+ 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 = 'e6134fd8a3c1cc9e3056c849d8834310e39d269846f2b6bec2871a83d488fc01'
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
- // 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'])
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: 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
 
@@ -216,18 +223,55 @@ const KOSTNADSFALT = [
216
223
  ['Dispatcher', 'matning', (m) => tal(m.subagent_stats?.spawned)],
217
224
  ]
218
225
 
219
- const kostnadsrader = (k) =>
220
- KOSTNADSFALT.map(([namn, källa, hämta]) => {
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) return [namn, `<em>ingår i ${esc(k.rot)}</em>`]
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
- return [namn, värde === null ? '<em>omätt</em>' : esc(värde)]
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.poang?.outcome ?? '–')],
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', k.ofullstandig ? '<strong>ofullständig</strong>' : esc(k.state?.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(p?.outcome)],
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 `