@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.
@@ -3,10 +3,10 @@
3
3
  * driftvakterna, dispatcher-rälsen för compare-scripten och hashningen bor här,
4
4
  * så en härdning aldrig kan landa i ena scriptet och missa det andra.
5
5
  */
6
- import { readFileSync } from 'node:fs'
6
+ import { readFileSync, existsSync } from 'node:fs'
7
7
  import { createHash } from 'node:crypto'
8
8
  import { homedir } from 'node:os'
9
- import { join, resolve } from 'node:path'
9
+ import { join, resolve, dirname, basename } from 'node:path'
10
10
 
11
11
  /**
12
12
  * Claude-konfigkatalogen. Installern hedrar CLAUDE_CONFIG_DIR (#278) — det ska
@@ -117,8 +117,51 @@ export function reportDiffs(diffs) {
117
117
  export const norm = (s) => s.replace(/[*`]/g, '').replace(/\s+/g, ' ').trim()
118
118
 
119
119
  /** Kanoniserar ett fakturerat modell-id: kontextsuffix som `[1m]` är samma
120
- * modell på en annan kontextnivå, aldrig en egen modell (ADR-0099). */
121
- export const canonicalModel = (id) => id.replace(/\[[^\]]*\]$/, '')
120
+ * modell på en annan kontextnivå, och ett datumsuffix `-YYYYMMDD` är samma
121
+ * modell med utgivningsdatum utskrivet, aldrig en egen modell (ADR-0099, #510). */
122
+ export const canonicalModel = (id) => id.replace(/\[[^\]]*\]$/, '').replace(/-\d{8}$/, '')
123
+
124
+ /** Agenternas svansrader (dispatch-kontraktet): plockas från slutet av
125
+ * svaret, aldrig mitt i. Kroppen är allt före den första svansraden. */
126
+ export function parseSvansrader(text) {
127
+ const lines = String(text).replace(/\r\n/g, '\n').split('\n')
128
+ const svans = { modell: null, lasta: null, avvikelse: null }
129
+ const nyckel = { MODELL: 'modell', LÄSTA: 'lasta', AVVIKELSE: 'avvikelse' }
130
+ let i = lines.length - 1
131
+ while (i >= 0) {
132
+ const t = lines[i].trim()
133
+ if (t === '') {
134
+ i--
135
+ continue
136
+ }
137
+ const m = /^(MODELL|LÄSTA|AVVIKELSE):\s*(.*)$/u.exec(t)
138
+ if (!m) break
139
+ if (svans[nyckel[m[1]]] === null) svans[nyckel[m[1]]] = m[2].trim()
140
+ i--
141
+ }
142
+ return {
143
+ body: lines
144
+ .slice(0, i + 1)
145
+ .join('\n')
146
+ .trim(),
147
+ ...svans,
148
+ }
149
+ }
150
+
151
+ /** Första JSON-objektet i en svarskropp: i ett ```json-staket om det finns,
152
+ * annars från första { till sista }. null när inget går att tolka. */
153
+ export function extraheraJson(body) {
154
+ const fence = /```(?:json)?\s*\n([\s\S]*?)\n```/u.exec(body)
155
+ const k = fence ? fence[1] : String(body)
156
+ const start = k.indexOf('{')
157
+ const slut = k.lastIndexOf('}')
158
+ if (start === -1 || slut < start) return null
159
+ try {
160
+ return JSON.parse(k.slice(start, slut + 1))
161
+ } catch {
162
+ return null
163
+ }
164
+ }
122
165
 
123
166
  /** Ett "ordagrant" citat får hoppa med [...] och kortas med inledande/avslutande …
124
167
  * Varje kvarvarande segment måste vara exakt substräng av det normaliserade
@@ -175,13 +218,286 @@ export const SCORE_OUTCOMES = [
175
218
  'ESKALERA',
176
219
  ]
177
220
 
221
+ export const MODES = ['pricing', 'guard']
222
+
223
+ // Stegschemat (Task 4, requestmotor 2.1). Enumen är ASCII som hog/medel/lag;
224
+ // ett äldre arkiv med "större" läses fortfarande av report.mjs, men en färsk
225
+ // körning måste använda den ASCII-form compare-scores diffar på.
226
+ export const STEP_RELATIONS = ['storre', 'mindre', 'lika']
227
+
228
+ export const KONFIDENS = ['hog', 'medel', 'lag']
229
+
230
+ /**
231
+ * Skalans ankare i POÄNGORDNING, med poäng. Numren är fasta beteckningar satta
232
+ * när ankaret skrevs, inte platser i ordningen: A17 tillkom efteråt och ligger på
233
+ * 10 poäng mellan A12 och A13 (poangskala.md, Översikt). Varje script som
234
+ * behöver "angränsande", "innanför" eller "ankarets poäng" läser HÄR, aldrig ur
235
+ * ankarnumret (#506). Driftvakt: tests/request-ankartabell.test.js läser
236
+ * översiktstabellen och fäller varje avvikelse; sha-pinnen på poangskala.md i
237
+ * contract-check/compare-scores täcker resten av filen.
238
+ */
239
+ export const ANKARE = Object.freeze([
240
+ ['A1', 0.25],
241
+ ['A2', 0.75],
242
+ ['A3', 0.75],
243
+ ['A4', 1],
244
+ ['A5', 2],
245
+ ['A6', 2.25],
246
+ ['A7', 2.5],
247
+ ['A8', 3],
248
+ ['A9', 4],
249
+ ['A10', 4],
250
+ ['A11', 7],
251
+ ['A12', 9],
252
+ ['A17', 10],
253
+ ['A13', 12],
254
+ ['A14', 13],
255
+ ['A15', 16],
256
+ ['A16', 17],
257
+ ])
258
+ const ANKAR_INDEX = new Map(ANKARE.map(([id, poang], i) => [id, { i, poang }]))
259
+ /** Position i skalans ordning (0-baserat), null för okänt id eller icke-sträng. */
260
+ export const ankarPosition = (id) => ANKAR_INDEX.get(id)?.i ?? null
261
+ /** Ankarets poäng enligt översiktstabellen, null för okänt id. */
262
+ export const ankarPoang = (id) => ANKAR_INDEX.get(id)?.poang ?? null
263
+
264
+ /** Skalans översta ankare i POSITIONSORDNING (inte högsta numret: A17 ligger
265
+ * mellan A12 och A13). Inget-tak-utfallen måste ha jämfört mot detta (#503). */
266
+ export const toppAnkare = () => ANKARE[ANKARE.length - 1][0]
267
+
268
+ /** Uppdragstyperna ur översiktstabellen. `type` i score-kuvertet loggas för att
269
+ * mäta systematisk felplacering per typ (request-score/SKILL.md, Typen), vilket
270
+ * förutsätter ett slutet värdeförråd (#509). */
271
+ export const TYPER = [
272
+ 'Kundvänd funktion',
273
+ 'Underhåll',
274
+ 'Teknisk uppsättning',
275
+ 'Integration',
276
+ 'Internt verktyg',
277
+ ]
278
+
279
+ // Protokollmekanik som aldrig får nå kundtext. Klass/klassas är kundspråk
280
+ // (kunden ser Class-fältet) och KONCEPT/DIAGNOS är legitima kundutfall — de
281
+ // står medvetet INTE här. Poäng och skalans mekanik är interna. Gränserna är
282
+ // Unicode-medvetna (JS \b bryter vid å/ä/ö), och "protokoll" är snävat så att
283
+ // vardagssvenska som "protokollförs" går fri medan klassningsprotokollet fångas.
284
+ const B = (pat) => new RegExp(`(?<![\\p{L}\\d_])(?:${pat})`, 'iu')
285
+ export const CUSTOMER_BLACKLIST = [
286
+ B('poäng\\p{L}*'),
287
+ B('basklass\\p{L}*'),
288
+ B('bump\\p{L}*'),
289
+ B('ankar\\p{L}*'),
290
+ B('referensuppdrag\\p{L}*'),
291
+ B('inringning\\p{L}*'),
292
+ B('konfidens\\p{L}*'),
293
+ // Substantivformer av skalan är mekanik; verbet "skalar" (skalbarhet) är
294
+ // legitim kundsvenska och går fritt.
295
+ B('skal(?:a|an|ans|or|orna)(?!\\p{L})'),
296
+ B('protokoll(?:et|en|s)?(?!\\p{L})'),
297
+ /klassningsprotokoll/iu,
298
+ B('blast\\s*radius'),
299
+ B('touchpoint\\p{L}*'),
300
+ // u-flaggan är obligatorisk: utan den är \p{L} i lookaround teckenklassen
301
+ // "p{L" och "era B2B-kunder" blir ett falskt BROTT.
302
+ /(?<![\p{L}\d_])[SB][1-3](?![\p{L}\d_])/u,
303
+ ]
304
+
178
305
  /** Rubrikrad: fetstilsrad eller markdown-rubrik som utgör hela raden. Delad
179
306
  * mellan precheck (formkontroll) och report (fältvis jämförelse) så rapporten
180
307
  * läser specen med exakt samma regler som grinden. */
181
308
  export const isHeadingLine = (line) =>
182
309
  /^\*\*[^*]+\*\*\s*$/.test(line.trim()) || /^#{1,6}\s+\S/.test(line.trim())
183
310
 
184
- export const headingRe = (h) => new RegExp(`^(\\*\\*${h}\\*\\*|#{1,6}\\s*${h})\\s*$`, 'm')
311
+ /**
312
+ * Specformatets rubriker, EN tabell (ADR-0114). Nyckeln är den svenska
313
+ * literalen som scripten alltid anropat med; `en` är den engelska literal
314
+ * specformatet tillåter när önskemålet är på engelska. IPC 14001: alla tio
315
+ * specutkast fälldes för att rubrikmatchningen bara kände svenska medan
316
+ * skillen sa "svenska om önskemålet är på svenska" och önskemålet var på
317
+ * engelska. Literalerna bor här och ingen annanstans: precheck, report och
318
+ * sectionLines går alla genom `headingRe(key)`.
319
+ */
320
+ export const SPEC_HEADINGS = [
321
+ { key: 'Problem', sv: 'Problem', en: 'Problem' },
322
+ { key: 'Lösning', sv: 'Lösning', en: 'Solution' },
323
+ { key: 'När det är klart', sv: 'När det är klart', en: 'Done when' },
324
+ { key: 'Ingår inte', sv: 'Ingår inte', en: 'Not included' },
325
+ { key: 'Utseende', sv: 'Utseende', en: 'Appearance' },
326
+ { key: 'Kräver', sv: 'Kräver', en: 'Requires' },
327
+ { key: 'Villkor', sv: 'Villkor', en: 'Conditions' },
328
+ ]
329
+
330
+ /** Kandidatformens markörrad (ADR-0105): står direkt under titeln i stället
331
+ * för Lösning och När det är klart, och routar posten till sitt stopputfall
332
+ * utan analys eller poäng. */
333
+ export const KANDIDAT_MARKORER = [
334
+ { utfall: 'KONCEPT', sv: 'Koncept-kandidat', en: 'Concept candidate' },
335
+ { utfall: 'DIAGNOS', sv: 'Diagnos-kandidat', en: 'Diagnosis candidate' },
336
+ ]
337
+
338
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
339
+
340
+ /** Regex som matchar en hel rubrikrad för NÅGON av literalerna. */
341
+ const literalsRe = (literals, flags = 'm') => {
342
+ const alt = [...new Set(literals)].map(escapeRe).join('|')
343
+ // `#{1,6}\\s+`, aldrig `\\s*`: isHeadingLine kräver mellanslag efter #, och de
344
+ // två måste se samma rader som rubriker, annars läcker `##Villkor` in i
345
+ // föregående sektion i sectionLines (granskningsfynd 2026-09-02).
346
+ return new RegExp(`^(\\*\\*(?:${alt})\\*\\*|#{1,6}\\s+(?:${alt}))\\s*$`, flags)
347
+ }
348
+
349
+ /** Rubrikregex för en nyckel: en specrubrik matchar båda språkens literal,
350
+ * en okänd nyckel (impact-rubrikerna) matchas som literal precis som förut. */
351
+ export const headingRe = (h) => {
352
+ const row = SPEC_HEADINGS.find((r) => r.key === h)
353
+ return literalsRe(row ? [row.sv, row.en] : [h])
354
+ }
355
+
356
+ /**
357
+ * Rubrikernas språk i ett specblock: 'sv', 'en', 'blandad' (minst en rubrik
358
+ * ur varje språk) eller null (ingen språkbärande rubrik alls). Problem är
359
+ * samma ord på båda språk och räknas därför inte. Blandning fälls av precheck:
360
+ * kunden ser rubrikerna, och opus-varvet i IPC 14001 lämnade engelsk brödtext
361
+ * under svenska rubriker efter omdispatchen.
362
+ */
363
+ export const headingLanguage = (block) => {
364
+ const found = new Set()
365
+ for (const line of block.split('\n')) {
366
+ const t = line.trim()
367
+ for (const row of SPEC_HEADINGS) {
368
+ if (row.sv === row.en) continue
369
+ if (literalsRe([row.sv], '').test(t)) found.add('sv')
370
+ else if (literalsRe([row.en], '').test(t)) found.add('en')
371
+ }
372
+ }
373
+ if (found.size === 0) return null
374
+ return found.size === 2 ? 'blandad' : [...found][0]
375
+ }
376
+
377
+ /** Kandidatmarkören i ett block: { utfall, line } eller null. Bara en hel
378
+ * rubrikrad räknas, aldrig ett omnämnande i löptext. */
379
+ export const kandidatIn = (block) => {
380
+ const lines = block.split('\n')
381
+ for (let i = 0; i < lines.length; i++) {
382
+ const t = lines[i].trim()
383
+ for (const m of KANDIDAT_MARKORER)
384
+ if (literalsRe([m.sv, m.en], '').test(t)) return { utfall: m.utfall, line: i }
385
+ }
386
+ return null
387
+ }
388
+
389
+ export const AVVIKELSE_ETIKETTER = ['ingen', 'precisering', 'motsägelse']
390
+
391
+ /**
392
+ * Etiketten på analysens rad `**Avvikelse mot specen:**` (ADR-0106): ordet
393
+ * direkt efter kolonet, skiftlägesokänsligt, följt av radslut eller ett
394
+ * skiljetecken/mellanslag. Fritext utan etikett ger null; det är precis den
395
+ * rad ("bör tillbaka till specen") som scorern aldrig fick en väg att agera på.
396
+ */
397
+ export const avvikelseEtikett = (text) => {
398
+ const m = /^\s*\*\*Avvikelse mot specen:\*\*\s*(\p{L}+)(?=$|[\s.,;:!?])/imu.exec(text)
399
+ if (!m) return null
400
+ const ord = m[1].toLowerCase()
401
+ return AVVIKELSE_ETIKETTER.includes(ord) ? ord : null
402
+ }
403
+
404
+ export const UTFALLSVAL_RAD = { sv: 'Obesvarade utfallsval', en: 'Unanswered outcome choices' }
405
+ export const STRUKET_PREFIX = ['struket:', 'struck:']
406
+
407
+ export const PRISAVGORANDE_RAD = {
408
+ sv: 'Prisavgörande öppet val',
409
+ en: 'Price-decisive open choice',
410
+ }
411
+
412
+ /** Texten efter `ja:`/`yes:` på raden Prisavgörande öppet val, annars null.
413
+ * Raden är specifierns eget stoppsignal (#505, ADR-0118): ett val som
414
+ * avgör priset och som den inte kan ta utan att gissa kundens avsikt. */
415
+ export const prisavgorandeVal = (text) => {
416
+ const re = new RegExp(
417
+ `^\\*\\*(?:${[PRISAVGORANDE_RAD.sv, PRISAVGORANDE_RAD.en].map(escapeRe).join('|')}):\\*\\*\\s*(ja|yes)\\s*:\\s*(.+)$`,
418
+ 'imu',
419
+ )
420
+ const m = re.exec(String(text))
421
+ return m ? m[2].trim() : null
422
+ }
423
+
424
+ /** Formen på raden Prisavgörande öppet val: 'nej' | 'ja' | 'ja-utan-text' |
425
+ * null (raden saknas). 'ja-utan-text' täcker ett bart ja/yes, en kolon utan
426
+ * text efter, och fritext som inte är nej/ja/no/yes. Allt sådant är ett
427
+ * ja utan ett läsbart val, och precheck ska fälla det i stället för att
428
+ * tyst likställa det med nej (granskningsfynd, #505). */
429
+ export const prisavgorandeForm = (text) => {
430
+ const radRe = new RegExp(
431
+ `^\\*\\*(?:${[PRISAVGORANDE_RAD.sv, PRISAVGORANDE_RAD.en].map(escapeRe).join('|')}):\\*\\*\\s*(.*)$`,
432
+ 'imu',
433
+ )
434
+ const m = radRe.exec(String(text))
435
+ if (!m) return null
436
+ const rest = m[1].trim()
437
+ if (/^(?:nej|no)(?=$|[\s.,;:!?])/i.test(rest)) return 'nej'
438
+ if (/^(?:ja|yes)\s*:\s*(\S.*)$/i.test(rest)) return 'ja'
439
+ return 'ja-utan-text'
440
+ }
441
+
442
+ const INGA_ORD = ['inga', 'none', '-', 'tom']
443
+
444
+ /**
445
+ * Antalet listade, ostrukna alternativ under raden Obesvarade utfallsval
446
+ * (#507). Raden är en fetstilsrad, inte en rubrik: sektionen är allt till
447
+ * nästa fetstilsrad eller rubrik. En listpunkt ("- " eller "1. ") på kolumn 0
448
+ * är ett alternativ; en indenterad rad är en underrad till föregående
449
+ * alternativ (t.ex. en struket-motivering) och räknas inte för sig. En punkt
450
+ * som börjar med struket:/struck: räknas inte. null när raden saknas (då
451
+ * äger precheck felet).
452
+ */
453
+ export const utfallsvalAlternativ = (text) => {
454
+ const lines = String(text).replace(/\r\n/g, '\n').split('\n')
455
+ const radRe = new RegExp(
456
+ `^\\*\\*(?:${[UTFALLSVAL_RAD.sv, UTFALLSVAL_RAD.en].map(escapeRe).join('|')}):\\*\\*\\s*(.*)$`,
457
+ 'u',
458
+ )
459
+ const start = lines.findIndex((l) => radRe.test(l.trim()))
460
+ if (start === -1) return null
461
+ const efterKolon = radRe.exec(lines[start].trim())[1].trim().toLowerCase()
462
+ if (INGA_ORD.includes(efterKolon.replace(/[.]$/, ''))) return 0
463
+ let antal = 0
464
+ for (const l of lines.slice(start + 1)) {
465
+ const t = l.trim()
466
+ if (isHeadingLine(t) || /^\*\*[^*]+:\*\*/.test(t)) break
467
+ // Ett alternativ är ett listobjekt på kolumn 0 av originalraden (#507
468
+ // uppföljning): en indenterad rad under ett objekt är en underrad
469
+ // (delar av samma alternativ, t.ex. en struket-motivering), inte ett
470
+ // eget alternativ. Kravet ligger på `l`, inte på den trimmade `t`.
471
+ const m = /^(?:[-*+]|\d+[.)])\s+(.*)$/.exec(l)
472
+ if (!m) continue
473
+ // Prefixtestet körs mot normaliserad text: markdown-emfas (**fetstil**,
474
+ // _kursiv_, `kod`) runt "struket:" får inte dölja markören för
475
+ // ostruken-räkningen.
476
+ const punkt = m[1].replace(/[*_`]/g, '').trim().toLowerCase()
477
+ if (!STRUKET_PREFIX.some((p) => punkt.startsWith(p))) antal++
478
+ }
479
+ return antal
480
+ }
481
+
482
+ /**
483
+ * Närmaste katalog, från `dir` och uppåt, som innehåller `file`; sökvägen
484
+ * till filen eller null. Stannar vid en katalog med ett namn i `stopNames`
485
+ * (sökningen går aldrig ut ur experimentytan), vid filsystemets rot, och
486
+ * efter `maxLevels` steg. Används av proveniens: korning.json ligger på
487
+ * körningsnivå medan stämplingen ibland görs per del-N.
488
+ */
489
+ export const findUpward = (dir, file, stopNames = ['experiments'], maxLevels = 3) => {
490
+ let cur = resolve(dir)
491
+ for (let level = 0; level <= maxLevels; level++) {
492
+ if (stopNames.includes(basename(cur))) return null
493
+ const p = join(cur, file)
494
+ if (existsSync(p)) return p
495
+ const parent = dirname(cur)
496
+ if (parent === cur) return null
497
+ cur = parent
498
+ }
499
+ return null
500
+ }
185
501
 
186
502
  /** Raderna i sektionen under rubriken `heading`, fram till nästa rubrikrad. */
187
503
  export const sectionLines = (block, heading) => {
@@ -192,3 +508,159 @@ export const sectionLines = (block, heading) => {
192
508
  const end = rest.findIndex((l) => isHeadingLine(l))
193
509
  return rest.slice(0, end === -1 ? rest.length : end)
194
510
  }
511
+
512
+ /** Specifierns svar: specblock, vart och ett följt av sitt eget "## Till
513
+ * granskaren" (ADR-0118). Titel = rubrikrad som varken är specrubrik (något
514
+ * språk), kandidatmarkör eller granskarrubrik; varje titel startar en ny
515
+ * spec, ett granskarblock hör till specen den direkt följer. Gamla formatet
516
+ * (ett enda block sist efter flera specar) paras med alla specar och märks
517
+ * `delat`, så att `granskaBuntBlock` i korning.mjs kan fälla det som BRIST.
518
+ * Sonnet-orkestratorn gjorde detta i prosa; nu är det kod som precheck
519
+ * prövar resultatet av. */
520
+ export function delaSpecifierSvar(body) {
521
+ const lines = String(body).replace(/\r\n/g, '\n').split('\n')
522
+ const isGranskare = (l) => /^##\s+Till granskaren\s*$/u.test(l.trim())
523
+ const isSpecHeading = (l) => SPEC_HEADINGS.some((row) => headingRe(row.key).test(l.trim()))
524
+ const isTitle = (l) =>
525
+ isHeadingLine(l) && !isSpecHeading(l) && kandidatIn(l) === null && !isGranskare(l)
526
+ // En fristående fetstilsrad ("**...**", hela raden, inget efter): true om
527
+ // raden utgör en HEL fetstilsrad, med den inre texten (utan "**") som
528
+ // andra värdet. Skiljer en specTITEL ("**Chefer raderar...**", ingen
529
+ // avslutande kolon) från en granskarblocks ETIKETTRAD ("**Flaggor:**",
530
+ // avslutande kolon, värdet på nästa rad eller inget alls).
531
+ const fetstilsrad = (l) => {
532
+ const m = /^\*\*([^*]+)\*\*\s*$/u.exec(l.trim())
533
+ return m ? m[1].trim() : null
534
+ }
535
+ // En rad som avslutar ett ÖPPET granskarblock: (1) en rubrik av nivå 1
536
+ // eller 2 (## eller #) som inte själv är granskarrubriken, eller (2) en
537
+ // fristående fetstilsrad vars text INTE slutar på kolon - det är en ny
538
+ // spectitel (specformatet skriver specrubriker som fetstil, ADR-0114/
539
+ // specformat.md). En fristående fetstilsrad SOM slutar på kolon
540
+ // ("**Antaganden:**", "**Flaggor:**") är en granskarblocks etikettrad och
541
+ // hör till blockets EGET innehåll, liksom en djupare underrubrik
542
+ // (### Flaggor). Granskningsfynd 1: `isTitle` (all fetstil räknas som
543
+ // rubrik) körd INUTI blocket klippte av det vid varje etikettrad.
544
+ // Granskningsfynd 2 (regression i fixen för fynd 1): att bara tillåta
545
+ // #/##-rubriker missade att en bunts specTITLAR i praktiken är fetstil,
546
+ // inte markdown-rubriker - två bunt-specar med varsitt block kollapsade
547
+ // till en enda spec.
548
+ // Granskningsfynd 3 (M8, final-review): en fristående kandidatmarkör utan
549
+ // kolon ("**Koncept-kandidat**") är varken en granskarrubrik eller en
550
+ // specTITEL - den hör till blockets EGET innehåll, precis som `isTitle`
551
+ // redan undantar den från att räknas som en ny spec.
552
+ const avslutarBlock = (l) => {
553
+ if (isGranskare(l)) return false
554
+ if (kandidatIn(l) !== null) return false
555
+ if (/^#{1,2}\s+\S/.test(l.trim())) return true
556
+ const inre = fetstilsrad(l)
557
+ return inre !== null && !inre.endsWith(':')
558
+ }
559
+
560
+ // Pass 1: hitta varje granskarblocks utsträckning (start till nästa
561
+ // blockavslutande rubrik eller granskarrubrik, annars slutet).
562
+ const granskareStarts = []
563
+ lines.forEach((l, i) => {
564
+ if (isGranskare(l)) granskareStarts.push(i)
565
+ })
566
+ const granskareRanges = granskareStarts.map((start) => {
567
+ let end = lines.length
568
+ for (let i = start + 1; i < lines.length; i++) {
569
+ if (avslutarBlock(lines[i]) || isGranskare(lines[i])) {
570
+ end = i
571
+ break
572
+ }
573
+ }
574
+ return { start, end, text: lines.slice(start, end).join('\n').trim() }
575
+ })
576
+ const iGranskareIntervall = (i) => granskareRanges.some((r) => i >= r.start && i < r.end)
577
+
578
+ // Pass 2: dela resten (utanför granskarblocken) i specar via titelrader.
579
+ const specar = []
580
+ let cur = null
581
+ let curStart = -1
582
+ const stang = () => {
583
+ if (cur) specar.push({ text: cur.join('\n').trim(), start: curStart })
584
+ cur = null
585
+ }
586
+ for (let i = 0; i < lines.length; i++) {
587
+ const l = lines[i]
588
+ if (iGranskareIntervall(i)) continue
589
+ if (l.trim() === '---') continue
590
+ if (isTitle(l)) {
591
+ stang()
592
+ cur = [l]
593
+ curStart = i
594
+ continue
595
+ }
596
+ if (cur === null) {
597
+ if (l.trim() === '') continue
598
+ cur = [l]
599
+ curStart = i
600
+ } else cur.push(l)
601
+ }
602
+ stang()
603
+
604
+ const medSpec = specar.filter((s) => s.text !== '')
605
+
606
+ // Para varje spec med granskarblocket som ligger direkt efter den (före
607
+ // nästa specs start).
608
+ const perSpec = medSpec.map((s, i) => {
609
+ const nasta = i + 1 < medSpec.length ? medSpec[i + 1].start : Infinity
610
+ const block = granskareRanges.find((r) => r.start > s.start && r.start < nasta)
611
+ return { spec: s.text, granskarblock: block ? block.text : null }
612
+ })
613
+
614
+ // Gamla formatet: fler än en spec, exakt ETT granskarblock i hela svaret,
615
+ // och det hör (strukturellt) bara till sista specen. Ett svar där varje
616
+ // spec redan bär sitt eget block ska INTE märkas delat.
617
+ const delat =
618
+ perSpec.length > 1 &&
619
+ granskareRanges.length === 1 &&
620
+ perSpec.filter((s) => s.granskarblock !== null).length === 1 &&
621
+ perSpec[perSpec.length - 1].granskarblock !== null
622
+
623
+ if (delat) {
624
+ const blockText = perSpec[perSpec.length - 1].granskarblock
625
+ return {
626
+ specar: perSpec.map((s) => ({ spec: s.spec, granskarblock: blockText })),
627
+ delat: true,
628
+ }
629
+ }
630
+ return { specar: perSpec, delat: false }
631
+ }
632
+
633
+ /** DEPS-raden ur dispatch-kontraktet: `installerade` när node_modules/ finns
634
+ * under repo-roten, annars `saknas`. */
635
+ export const avgorDeps = (repoRot) =>
636
+ existsSync(join(repoRot, 'node_modules')) ? 'installerade' : 'saknas'
637
+
638
+ /** En fakturerad nyckel är begärd när den ÄR modellvärdet eller ett fullt
639
+ * id i samma familj (`opus` täcker `claude-opus-5`). Nyckeln kanoniseras av
640
+ * anroparen. */
641
+ export const matcharModell = (nyckel, modell) =>
642
+ nyckel === modell || nyckel === `claude-${modell}` || nyckel.startsWith(`claude-${modell}-`)
643
+
644
+ /** Schemaläggaren: högst `taket` tickets i flykt, nästa ticket startar när
645
+ * en är helt klar. `korTicket(ticket, index)` returnerar ett löfte som löses
646
+ * när ticketens samtliga modeller är klara. Ett fel i en ticket stoppar
647
+ * aldrig svepet (Batch: "Kör vidare vid fel"); det landar som `{ fel }` i
648
+ * resultatet. Resultaten kommer i ticketordning. */
649
+ export async function schemalagg(tickets, taket, korTicket) {
650
+ if (!Number.isInteger(taket) || taket < 1)
651
+ throw new Error(`taket måste vara ett heltal >= 1 (fick ${taket})`)
652
+ const resultat = new Array(tickets.length)
653
+ let nasta = 0
654
+ const arbetare = async () => {
655
+ while (nasta < tickets.length) {
656
+ const i = nasta++
657
+ try {
658
+ resultat[i] = await korTicket(tickets[i], i)
659
+ } catch (e) {
660
+ resultat[i] = { fel: e?.message ?? String(e) }
661
+ }
662
+ }
663
+ }
664
+ await Promise.all(Array.from({ length: Math.min(taket, tickets.length) }, arbetare))
665
+ return resultat
666
+ }