@haus-tech/haus-workflow 5.6.1 → 5.7.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.
@@ -50,6 +50,8 @@ import {
50
50
  CLASS_UTFALL,
51
51
  SCORE_OUTCOMES,
52
52
  CUSTOMER_BLACKLIST,
53
+ CUSTOMER_BLACKLIST_TVETYDIG,
54
+ TVA_SIGNAL_RE,
53
55
  MODES,
54
56
  STEP_RELATIONS,
55
57
  KONFIDENS,
@@ -239,7 +241,12 @@ const ANCHOR_RE = /(?<![\p{L}\d_])A(?:1[0-7]|[1-9])(?![\p{L}\d_])/u
239
241
  //
240
242
  // "poängen är att" behöver ingen lookahead: "är att" följs alltid av en
241
243
  // sats, aldrig av en poängangivelse.
242
- const IDIOM_ALLOW = [/hela\s+poängen\s+(?:med|i)\b(?![^.!?]*\d)/giu, /poängen\s+är\s+att\b/giu]
244
+ const IDIOM_ALLOW = [
245
+ /hela\s+poängen\s+(?:med|i)\b(?![^.!?]*\d)/giu,
246
+ /poängen\s+är\s+att\b/giu,
247
+ /the\s+whole\s+point\s+of\b(?![^.!?]*\d)/giu,
248
+ /the\s+point\s+is\s+that\b/giu,
249
+ ]
243
250
  const maskIdioms = (text) =>
244
251
  IDIOM_ALLOW.reduce((t, re) => t.replace(re, (m) => '�'.repeat(m.length)), text)
245
252
  const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
@@ -261,6 +268,15 @@ const checkCustomerText = (text, field) => {
261
268
  const m = masked.match(re)
262
269
  if (m && !iUnderlaget(m[0])) problems.push(`${field}: protokollterm "${m[0]}" i kundtext`)
263
270
  }
271
+ // Tvetydig tier: ordet ensamt är legitim engelska. Radformen är identisk med
272
+ // den entydiga tiern, så kontroll.mjs KUNDTEXT_RE väljer kundtextfixen för
273
+ // båda.
274
+ if (TVA_SIGNAL_RE.test(masked)) {
275
+ for (const re of CUSTOMER_BLACKLIST_TVETYDIG) {
276
+ const m = masked.match(re)
277
+ if (m && !iUnderlaget(m[0])) problems.push(`${field}: protokollterm "${m[0]}" i kundtext`)
278
+ }
279
+ }
264
280
  // Ankarvarningen körs mot ORIGINALET, aldrig mot den maskade strängen.
265
281
  // Maskningen bevarar längden men inte innehållet, och varningscitatet ska
266
282
  // visa vad som faktiskt stod i texten.
@@ -310,12 +310,14 @@ export function hamtaFalt(doc, falt) {
310
310
 
311
311
  export function omskrivningsPrompt(text, term) {
312
312
  return [
313
- 'Skriv om följande kundtext så att ordet "' +
313
+ 'Rewrite the following customer-facing text so that the word "' +
314
314
  term +
315
- '" (och böjningar av det) inte förekommer.',
316
- 'Behåll innebörden och längden. Använd inga ord som beskriver hur ett pris räknats fram',
317
- '(poäng, ankare, skala, referensuppdrag, inringning, konfidens, klass, protokoll).',
318
- 'Svara med enbart den omskrivna texten, utan citattecken och utan kommentar.',
315
+ '" (and inflections of it) does not appear.',
316
+ 'Keep the meaning and the length. Do not translate the text: answer in the same',
317
+ 'language it is written in. Use no words that describe how a price was derived',
318
+ '(score, anchor, scale, reference assignment, bracketing, confidence, base class,',
319
+ 'protocol; poäng, ankare, skala, referensuppdrag, inringning, konfidens, klass, protokoll).',
320
+ 'Answer with the rewritten text only, no quotation marks and no commentary.',
319
321
  '',
320
322
  text,
321
323
  ].join('\n')
@@ -57,6 +57,7 @@ import {
57
57
  claudeDir,
58
58
  canonicalModel,
59
59
  SPEC_HEADINGS,
60
+ IMPACT_HEADINGS,
60
61
  SCORE_OUTCOMES,
61
62
  } from './lib.mjs'
62
63
 
@@ -228,6 +229,15 @@ export function assessorPrompt({ repoRot, deps, sprak, spec, granskarblock, mani
228
229
  `REPO-ROT: ${repoRot}`,
229
230
  `DEPS: ${deps}`,
230
231
  sprakRad,
232
+ // Analysen är intern och skrivs alltid på engelska: assessorskillen
233
+ // föreskriver IMPACT_HEADINGS:s engelska literaler, och skelettet är
234
+ // därför den engelska kolumnen. Det är oberoende av SPRÅK, som bara styr
235
+ // analysens kundvända delar, inte denna interna struktur. Skelettet finns
236
+ // av samma skäl som specifierns (ADR-0114): modellen ska aldrig gissa
237
+ // literalen, och en gissning kostar en omdispatch. Arkivets svenska
238
+ // analyser läses fortfarande, headingRe matchar båda kolumnerna.
239
+ 'RUBRIKER:',
240
+ ...IMPACT_HEADINGS.map((row) => `**${row.en}**`),
231
241
  'KÄLLOR:',
232
242
  manifest.trim(),
233
243
  '',
@@ -858,10 +868,13 @@ export const granskaBuntBlock = ({ specar, delat }) => {
858
868
  const varningar = []
859
869
  if (delat && specar.length > 1)
860
870
  brister.push(
861
- '- granskarblock delat över bunten, ett per spec krävs: skriv "## Till granskaren" direkt efter varje spec',
871
+ '- granskarblock delat över bunten, ett per spec krävs: skriv rubriken "For the reviewer" (svenska: "Till granskaren") direkt efter varje spec',
862
872
  )
863
873
  specar.forEach((s, i) => {
864
- if (s.granskarblock === null) brister.push(`- spec ${i + 1} saknar Till granskaren-block`)
874
+ if (s.granskarblock === null)
875
+ brister.push(
876
+ `- spec ${i + 1} saknar granskarblock ("For the reviewer", svenska: "Till granskaren")`,
877
+ )
865
878
  })
866
879
  for (let i = 0; i < specar.length; i++)
867
880
  for (let j = i + 1; j < specar.length; j++)
@@ -940,7 +953,7 @@ function skrivSpec(del, spec, granskarblock, meta, ctx) {
940
953
  writeFileSync(path.join(del.dir, 'spec.md'), spec.trim() + '\n')
941
954
  if (granskarblock)
942
955
  writeFileSync(path.join(del.dir, 'granskarblock.md'), granskarblock.trim() + '\n')
943
- else logg(`VARNING ${path.basename(del.dir)}: svaret bar inget Till granskaren-block`)
956
+ else logg(`VARNING ${path.basename(del.dir)}: svaret bar inget granskarblock (For the reviewer)`)
944
957
  addRun(
945
958
  del.dir,
946
959
  {
@@ -266,7 +266,7 @@ export const ankarPoang = (id) => ANKAR_INDEX.get(id)?.poang ?? null
266
266
  export const toppAnkare = () => ANKARE[ANKARE.length - 1][0]
267
267
 
268
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
269
+ * mäta systematisk felplacering per typ (request-score/SKILL.md, The type), vilket
270
270
  * förutsätter ett slutet värdeförråd (#509). */
271
271
  export const TYPER = [
272
272
  'Kundvänd funktion',
@@ -285,7 +285,6 @@ const B = (pat) => new RegExp(`(?<![\\p{L}\\d_])(?:${pat})`, 'iu')
285
285
  export const CUSTOMER_BLACKLIST = [
286
286
  B('poäng\\p{L}*'),
287
287
  B('basklass\\p{L}*'),
288
- B('bump\\p{L}*'),
289
288
  B('ankar\\p{L}*'),
290
289
  B('referensuppdrag\\p{L}*'),
291
290
  B('inringning\\p{L}*'),
@@ -300,8 +299,46 @@ export const CUSTOMER_BLACKLIST = [
300
299
  // u-flaggan är obligatorisk: utan den är \p{L} i lookaround teckenklassen
301
300
  // "p{L" och "era B2B-kunder" blir ett falskt BROTT.
302
301
  /(?<![\p{L}\d_])[SB][1-3](?![\p{L}\d_])/u,
302
+ // Engelska motsvarigheter (requestmotor engelska artefakter, spec §3):
303
+ // entydiga termer som fäller ensamma, precis som de svenska ovan.
304
+ B('reference\\s+assignment\\p{L}*'),
305
+ B('base\\s+class\\p{L}*'),
306
+ B('bracketing'),
307
+ ]
308
+
309
+ /**
310
+ * Tvetydig tier (spec §3): ord som är protokollmekanik hos oss och vanlig
311
+ * engelska hos kunden ("scale the component", "a confidence interval"). De
312
+ * fäller bara tillsammans med en andra signal i samma fält — ett poängformat
313
+ * tal eller ett ankar-id. Ett falskt utslag kostar en omdispatch, ett missat
314
+ * läckage når kunden, och basfrekvensen är hög: därför två signaler, samma
315
+ * logik som tvåordsgränsen i språkgrinden (ADR-0122). "bump" hör hemma här
316
+ * (inte i den entydiga tiern ovan): ordet är vanlig engelska ("bump the
317
+ * version") lika ofta som det är protokollmekanik. Mönstret är däremot
318
+ * `bump\p{L}*`, inte `bumps?`: kundens text kan stå på svenska även när
319
+ * motorns egen prosa är engelsk, och som `bumps?` hade bumpa, bumpade och
320
+ * bumpning varit ofångbara vid varje signalnivå (ADR-0129).
321
+ */
322
+ export const CUSTOMER_BLACKLIST_TVETYDIG = [
323
+ B('scores?(?![\\p{L}\\d_])'),
324
+ B('scoring(?![\\p{L}\\d_])'),
325
+ B('anchors?(?![\\p{L}\\d_])'),
326
+ B('scales?(?![\\p{L}\\d_])'),
327
+ B('confidence(?![\\p{L}\\d_])'),
328
+ B('bump\\p{L}*'),
303
329
  ]
304
330
 
331
+ /** Den andra signalen: ett ankar-id (A1-A17, alltid VERSAL A — en gemen "a"
332
+ * som i "a4 paper" räknas inte, lookbehind ger ingen skiftlägesfrihet här),
333
+ * ett poängformat tal i 0,25-steg (2.5, 2,75, 2.50 …), eller valfritt tal
334
+ * följt av "point(s)"/"poäng". Decimalgrenen är klämd mellan två speglade
335
+ * vakter, `(?<!\d\.)` och `(?!\.\d)`, så inget segment av ett punktat
336
+ * versionsnummer kan bli den andra signalen: varken sista ledet ("2.5.1")
337
+ * eller ett mellanled ("release 10.50.25", "v1.2.75"). Ett ensamt "2,75"
338
+ * eller "2.5" är oberört. */
339
+ export const TVA_SIGNAL_RE =
340
+ /(?<![\p{L}\d_])(?:A(?:1[0-7]|[1-9])|(?<!\d\.)\d+[.,](?:25|5|50|75)(?!\.\d)|\d+\s*(?:[Pp]oints?|[Pp]oäng))(?![\p{L}\d_])/u
341
+
305
342
  /** Rubrikrad: fetstilsrad eller markdown-rubrik som utgör hela raden. Delad
306
343
  * mellan precheck (formkontroll) och report (fältvis jämförelse) så rapporten
307
344
  * läser specen med exakt samma regler som grinden. */
@@ -327,6 +364,23 @@ export const SPEC_HEADINGS = [
327
364
  { key: 'Villkor', sv: 'Villkor', en: 'Conditions' },
328
365
  ]
329
366
 
367
+ /** Påverkansanalysens rubriker (ADR-0114:s mekanism, nu även för impact.md).
368
+ * Analysen är intern och skrivs på engelska; den svenska literalen står kvar
369
+ * så att arkivet och pågående poster fortfarande läses. */
370
+ export const IMPACT_HEADINGS = [
371
+ { key: 'Berörda repon och filer', sv: 'Berörda repon och filer', en: 'Affected repos and files' },
372
+ { key: 'Datalagring', sv: 'Datalagring', en: 'Data storage' },
373
+ { key: 'Integrationspunkter', sv: 'Integrationspunkter', en: 'Integration points' },
374
+ { key: 'Flöden', sv: 'Flöden', en: 'Flows' },
375
+ { key: 'Gränssnitt', sv: 'Gränssnitt', en: 'Interfaces' },
376
+ { key: 'Verifiering', sv: 'Verifiering', en: 'Verification' },
377
+ { key: 'Delbarhet', sv: 'Delbarhet', en: 'Splittability' },
378
+ { key: 'Konsekvens av fel', sv: 'Konsekvens av fel', en: 'Consequence of failure' },
379
+ { key: 'Vägval', sv: 'Vägval', en: 'Decisions taken' },
380
+ { key: 'Antaganden', sv: 'Antaganden', en: 'Assumptions' },
381
+ { key: 'Beroenden', sv: 'Beroenden', en: 'Dependencies' },
382
+ ]
383
+
330
384
  /** Kandidatformens markörrad (ADR-0105): står direkt under titeln i stället
331
385
  * för Lösning och När det är klart, och routar posten till sitt stopputfall
332
386
  * utan analys eller poäng. */
@@ -335,6 +389,23 @@ export const KANDIDAT_MARKORER = [
335
389
  { utfall: 'DIAGNOS', sv: 'Diagnos-kandidat', en: 'Diagnosis candidate' },
336
390
  ]
337
391
 
392
+ /** Tidsord förbjuds i både spec och analys (precheck: BRISTER). EN regex, så
393
+ * reglerna aldrig glider isär, och den bor här i stället för i precheck.mjs så
394
+ * att driftvakten kan läsa samma regex som grinden: en skill som själv primar
395
+ * modellen med ett ord grinden fäller (uppgift 13: "estimated") fälls i
396
+ * testet, inte i nästa skarpa körning. Båda språken, eftersom interna
397
+ * dokument nu skrivs på engelska. Tidsord matchas utan inledande gräns så
398
+ * sammansättningar (arbetstimmar, halvtimme) fälls. */
399
+ export const TIDSORD_RE =
400
+ /[\p{L}]*tim(?:me|mar|marna|men)[\p{L}]*|[\p{L}]*estim(?:at|er)[\p{L}]*|(?<![\p{L}\d_])hours?(?![\p{L}\d_])|(?<![\p{L}\d_])man-?days?(?![\p{L}\d_])/iu
401
+
402
+ /** Poängord i analysen (precheck: VARNING, aldrig brist — "poängen med
403
+ * ändringen" och ett ordagrant citerat kod-enum är legitima). Unicode-medvetna
404
+ * gränser: JS \b bryter vid å/ä/ö. Delad med driftvakten av samma skäl som
405
+ * TIDSORD_RE. */
406
+ export const POANGORD_RE =
407
+ /(?<![\p{L}\d_])(?:poäng(?:en|er|sätt\p{L}*|sättning\p{L}*)?|scor(?:e|es|ing))(?![\p{L}\d_])/iu
408
+
338
409
  const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
339
410
 
340
411
  /** Regex som matchar en hel rubrikrad för NÅGON av literalerna. */
@@ -346,10 +417,10 @@ const literalsRe = (literals, flags = 'm') => {
346
417
  return new RegExp(`^(\\*\\*(?:${alt})\\*\\*|#{1,6}\\s+(?:${alt}))\\s*$`, flags)
347
418
  }
348
419
 
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. */
420
+ /** Rubrikregex för en nyckel: en spec- eller impact-rubrik matchar båda
421
+ * språkens literal, en okänd nyckel matchas som literal precis som förut. */
351
422
  export const headingRe = (h) => {
352
- const row = SPEC_HEADINGS.find((r) => r.key === h)
423
+ const row = SPEC_HEADINGS.find((r) => r.key === h) ?? IMPACT_HEADINGS.find((r) => r.key === h)
353
424
  return literalsRe(row ? [row.sv, row.en] : [h])
354
425
  }
355
426
 
@@ -374,6 +445,23 @@ export const headingLanguage = (block) => {
374
445
  return found.size === 2 ? 'blandad' : [...found][0]
375
446
  }
376
447
 
448
+ /** Som headingLanguage, fast över IMPACT_HEADINGS. Egen funktion, aldrig en
449
+ * flagga på headingLanguage: specens och analysens tabeller ska inte kunna
450
+ * fälla varandra när ett dokument citerar den andras rubrik. */
451
+ export const impactHeadingLanguage = (block) => {
452
+ const found = new Set()
453
+ for (const line of block.split('\n')) {
454
+ const t = line.trim()
455
+ for (const row of IMPACT_HEADINGS) {
456
+ if (row.sv === row.en) continue
457
+ if (literalsRe([row.sv], '').test(t)) found.add('sv')
458
+ else if (literalsRe([row.en], '').test(t)) found.add('en')
459
+ }
460
+ }
461
+ if (found.size === 0) return null
462
+ return found.size === 2 ? 'blandad' : [...found][0]
463
+ }
464
+
377
465
  /** Kandidatmarkören i ett block: { utfall, line } eller null. Bara en hel
378
466
  * rubrikrad räknas, aldrig ett omnämnande i löptext. */
379
467
  export const kandidatIn = (block) => {
@@ -386,28 +474,88 @@ export const kandidatIn = (block) => {
386
474
  return null
387
475
  }
388
476
 
477
+ export const TILL_GRANSKAREN = { sv: 'Till granskaren', en: 'For the reviewer' }
478
+
479
+ /** Granskarblockets etikettrader, `{ key, sv, en }` per rad — nyckeln är den
480
+ * svenska literalen som anroparna alltid använt. Tabellen är den enda platsen
481
+ * för literalerna; UTFALLSVAL_RAD och PRISAVGORANDE_RAD härleds ur den så att
482
+ * de befintliga konsumenterna i lib.mjs behåller sin form. Frusen: raderna
483
+ * läses av precheck, korning och kontroll, och en muterad rad hade tyst
484
+ * ändrat vad grindarna kräver. */
485
+ export const GRANSKARE_ROWS = Object.freeze(
486
+ [
487
+ { key: 'Antaganden', sv: 'Antaganden', en: 'Assumptions' },
488
+ { key: 'Vägval jag gjort', sv: 'Vägval jag gjort', en: 'Decisions I made' },
489
+ { key: 'Öppna utfallsval', sv: 'Öppna utfallsval', en: 'Open outcome choices' },
490
+ {
491
+ key: 'Prisavgörande öppet val',
492
+ sv: 'Prisavgörande öppet val',
493
+ en: 'Price-decisive open choice',
494
+ },
495
+ { key: 'Flaggor', sv: 'Flaggor', en: 'Flags' },
496
+ { key: 'Struket ur underlaget', sv: 'Struket ur underlaget', en: 'Struck from the source' },
497
+ { key: 'Ej verifierat', sv: 'Ej verifierat', en: 'Not verified' },
498
+ { key: 'Avvikelse mot specen', sv: 'Avvikelse mot specen', en: 'Deviation from the spec' },
499
+ {
500
+ key: 'Obesvarade utfallsval',
501
+ sv: 'Obesvarade utfallsval',
502
+ en: 'Unanswered outcome choices',
503
+ },
504
+ ].map((row) => Object.freeze(row)),
505
+ )
506
+
507
+ export const granskareRow = (key) => {
508
+ const row = GRANSKARE_ROWS.find((r) => r.key === key)
509
+ if (!row) throw new Error(`okänd granskarblocksrad: ${key}`)
510
+ return row
511
+ }
512
+
513
+ /** Regex för en granskarblocksrad `**Etikett:**` i BÅDA språken. Fetstilen och
514
+ * kolonet är formkravet: "Flaggor: inga" utan fetstil är inte raden. */
515
+ export const granskareRadRe = (key) => {
516
+ const row = granskareRow(key)
517
+ const alt = [...new Set([row.sv, row.en])].map(escapeRe).join('|')
518
+ return new RegExp(`^\\s*\\*\\*(?:${alt}):\\*\\*`, 'imu')
519
+ }
520
+
521
+ /** Regex för rubriken `## Till granskaren` i båda språken. */
522
+ export const tillGranskarenRe = () => {
523
+ const alt = [TILL_GRANSKAREN.sv, TILL_GRANSKAREN.en].map(escapeRe).join('|')
524
+ return new RegExp(`^#{1,6}\\s+(?:${alt})\\s*$`, 'imu')
525
+ }
526
+
389
527
  export const AVVIKELSE_ETIKETTER = ['ingen', 'precisering', 'motsägelse']
390
528
 
529
+ /** Engelsk literal -> svensk nyckel. Nyckeln är kanonisk i hela motorn:
530
+ * contract-check, kontroll, korning och archive jämför mot den svenska. */
531
+ export const AVVIKELSE_ALIAS = {
532
+ ingen: 'ingen',
533
+ precisering: 'precisering',
534
+ motsägelse: 'motsägelse',
535
+ none: 'ingen',
536
+ refinement: 'precisering',
537
+ contradiction: 'motsägelse',
538
+ }
539
+
391
540
  /**
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å.
541
+ * Etiketten på analysens rad `**Avvikelse mot specen:**` / `**Deviation from
542
+ * the spec:**` (ADR-0106): ordet direkt efter kolonet, skiftlägesokänsligt,
543
+ * följt av radslut eller ett skiljetecken. Fritext utan etikett ger null.
544
+ * RADETIKETTEN är tvåspråkig, inte bara värdet: missas den går en engelsk
545
+ * motsägelse tyst förbi precheck:s includes-grind till poängsättning.
396
546
  */
397
547
  export const avvikelseEtikett = (text) => {
398
- const m = /^\s*\*\*Avvikelse mot specen:\*\*\s*(\p{L}+)(?=$|[\s.,;:!?])/imu.exec(text)
548
+ const rad = granskareRadRe('Avvikelse mot specen').source
549
+ const m = new RegExp(`${rad}\\s*(\\p{L}+)(?=$|[\\s.,;:!?])`, 'imu').exec(String(text))
399
550
  if (!m) return null
400
551
  const ord = m[1].toLowerCase()
401
- return AVVIKELSE_ETIKETTER.includes(ord) ? ord : null
552
+ return Object.hasOwn(AVVIKELSE_ALIAS, ord) ? AVVIKELSE_ALIAS[ord] : null
402
553
  }
403
554
 
404
- export const UTFALLSVAL_RAD = { sv: 'Obesvarade utfallsval', en: 'Unanswered outcome choices' }
555
+ export const UTFALLSVAL_RAD = granskareRow('Obesvarade utfallsval')
405
556
  export const STRUKET_PREFIX = ['struket:', 'struck:']
406
557
 
407
- export const PRISAVGORANDE_RAD = {
408
- sv: 'Prisavgörande öppet val',
409
- en: 'Price-decisive open choice',
410
- }
558
+ export const PRISAVGORANDE_RAD = granskareRow('Prisavgörande öppet val')
411
559
 
412
560
  /** Texten efter `ja:`/`yes:` på raden Prisavgörande öppet val, annars null.
413
561
  * Raden är specifierns eget stoppsignal (#505, ADR-0118): ett val som
@@ -583,7 +731,7 @@ export const sectionLines = (block, heading) => {
583
731
  * prövar resultatet av. */
584
732
  export function delaSpecifierSvar(body) {
585
733
  const lines = String(body).replace(/\r\n/g, '\n').split('\n')
586
- const isGranskare = (l) => /^##\s+Till granskaren\s*$/u.test(l.trim())
734
+ const isGranskare = (l) => tillGranskarenRe().test(l.trim())
587
735
  const isSpecHeading = (l) => SPEC_HEADINGS.some((row) => headingRe(row.key).test(l.trim()))
588
736
  const isTitle = (l) =>
589
737
  isHeadingLine(l) && !isSpecHeading(l) && kandidatIn(l) === null && !isGranskare(l)
@@ -728,3 +876,127 @@ export async function schemalagg(tickets, taket, korTicket) {
728
876
  await Promise.all(Array.from({ length: Math.min(taket, tickets.length) }, arbetare))
729
877
  return resultat
730
878
  }
879
+
880
+ /**
881
+ * Literaler som ALDRIG översätts när motorns prosa skrivs om till engelska
882
+ * (spec §4). De är identifierare, inte språk: script jämför dem literalt, och
883
+ * ett byte bryter arkivets uppspelbarhet eller Asanas uppslag. Registret har
884
+ * två uppgifter — det säger vad omskrivningen ska citera ordagrant, och
885
+ * språkvakten (tests/request-sprakkontrakt.test.js) stryker dem ur texten
886
+ * innan den mäter språk. En literal som bara finns på ett av ställena hade
887
+ * gjort vakten fel, därför bor listan här.
888
+ *
889
+ * `rubriker` härleds ur de tvåspråkiga tabellernas svenska kolumn i stället
890
+ * för att kopieras för hand: SPEC_HEADINGS, IMPACT_HEADINGS, GRANSKARE_ROWS,
891
+ * TILL_GRANSKAREN, KANDIDAT_MARKORER, AVVIKELSE_ETIKETTER och STRUKET_PREFIX:s
892
+ * svenska entré. Tabellerna är sanningen; en handkopierad lista hade kunnat
893
+ * glida isär från dem tyst.
894
+ */
895
+ const RUBRIK_LITERALER = [
896
+ ...SPEC_HEADINGS.map((r) => r.sv),
897
+ ...IMPACT_HEADINGS.map((r) => r.sv),
898
+ ...GRANSKARE_ROWS.map((r) => r.sv),
899
+ TILL_GRANSKAREN.sv,
900
+ ...KANDIDAT_MARKORER.map((m) => m.sv),
901
+ ...AVVIKELSE_ETIKETTER,
902
+ STRUKET_PREFIX[0],
903
+ ]
904
+
905
+ export const FRYSTA_LITERALER = Object.freeze({
906
+ promptnycklar: Object.freeze([
907
+ 'SPRÅK:',
908
+ 'KÄLLOR:',
909
+ 'REPO-ROT:',
910
+ 'DEPS:',
911
+ 'MODE:',
912
+ 'DELPOST:',
913
+ 'ESKALERAD:',
914
+ 'REQUEST_ID:',
915
+ 'RUBRIKER:',
916
+ 'LÄSTA:',
917
+ 'MODELL:',
918
+ 'AVVIKELSE:',
919
+ 'RÄTTNINGAR:',
920
+ 'ÖNSKEMÅL:',
921
+ 'SPEC:',
922
+ 'PÅVERKANSANALYS:',
923
+ 'SPEC (godkänd):',
924
+ 'GRANSKARBLOCK',
925
+ 'GRANSKARBLOCK (internt underlag, följer aldrig med i analysens kundtext):',
926
+ 'FULLPLAN:',
927
+ 'TIDIGARE_POANG:',
928
+ ]),
929
+ promptvarden: Object.freeze([
930
+ 'installerade',
931
+ 'saknas',
932
+ 'ja',
933
+ 'nej',
934
+ 'inga',
935
+ 'svenska',
936
+ 'engelska',
937
+ 'ja, del N av M',
938
+ ]),
939
+ jsonEnum: Object.freeze([
940
+ ...SCORE_OUTCOMES,
941
+ ...STEP_RELATIONS,
942
+ ...KONFIDENS,
943
+ ...MODES,
944
+ ...TYPER,
945
+ 'grund',
946
+ 'yta',
947
+ 'omfattning',
948
+ 'ankare',
949
+ 'kalla',
950
+ 'delstrang',
951
+ 'citat',
952
+ 'pastaende',
953
+ ]),
954
+ asana: Object.freeze([
955
+ 'Analys',
956
+ 'Påverkansanalys',
957
+ 'Poängsättning',
958
+ '## Ursprunglig förfrågan',
959
+ '## Specifikation',
960
+ '## Lösning och omfattning',
961
+ 'Beställd',
962
+ 'Ej beställd',
963
+ 'Uppskjuten',
964
+ 'Mottaget',
965
+ 'Under bedömning',
966
+ 'Inkommet',
967
+ 'Poängsättning redo för granskning',
968
+ // Fullständiga fältnamn och statusvärden ur request-sync/SKILL.md.
969
+ // Redan engelska fältnamn (t.ex. "Original request") räknas också som
970
+ // identifierare — Asana slår upp dem literalt.
971
+ 'Original request',
972
+ 'Original request title',
973
+ 'Source',
974
+ 'Customer',
975
+ 'Requested by',
976
+ 'Request type',
977
+ 'Funding',
978
+ 'Score',
979
+ 'Has conditions',
980
+ 'Decision',
981
+ 'Decision reason',
982
+ 'Spec iterations',
983
+ 'Score iterations',
984
+ 'Offer status',
985
+ 'Request status',
986
+ 'Kundstatus',
987
+ 'Class rationale',
988
+ 'Kunden skrev själv',
989
+ 'Mejl',
990
+ 'Möte',
991
+ 'Haus initierade',
992
+ 'Bug',
993
+ 'Improvement',
994
+ 'Wear',
995
+ '📥 Inkommet',
996
+ '🔍 Spec redo för granskning',
997
+ '✅ Spec ok',
998
+ '⚖️ Poängsättning redo för granskning',
999
+ '📤 Klar – till kund',
1000
+ ]),
1001
+ rubriker: Object.freeze([...new Set(RUBRIK_LITERALER)]),
1002
+ })