@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,64 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Deterministisk kontroll av scorerns LÄSTA-svansrad (ADR-0099). Läs-scopet
4
+ * är en promptregel, inte en fysisk spärr — raden gör den granskbar, och det
5
+ * här scriptet gör granskningen mekanisk i stället för en instruktion som är
6
+ * lätt att missa (granskningens fynd på PR #470).
7
+ *
8
+ * Tillåten mängd: EXAKT underlagens två sökvägar (spec.md, impact.md — båda
9
+ * OBLIGATORISKA: scorern måste ha läst sitt underlag), plus valfria filer
10
+ * under skillens egen katalog (`skills/request-score/` under Claude-
11
+ * konfigkatalogen, referensfilen). Allt annat — körningskatalogens övriga
12
+ * filer, repot, vad som helst — är ett kontraktsbrott: behandla exit 1 som
13
+ * BROTT i bedöm steg 5 (omdispatch EN gång, sedan stopp).
14
+ *
15
+ * Användning: node check-lasta.mjs "<LÄSTA-radens innehåll>" <spec.md> <impact.md>
16
+ * Innehållet är det som står EFTER "LÄSTA: ": kommaseparerade absoluta
17
+ * sökvägar, eller literalen "inga".
18
+ * Exit 0 = ok, 1 = kontraktsbrott (BROTT-rader på stdout), 2 = användningsfel.
19
+ */
20
+ import path from 'node:path'
21
+
22
+ import { claudeDir } from './lib.mjs'
23
+
24
+ const [lasta, specPath, impactPath] = process.argv.slice(2)
25
+ if (lasta == null || !specPath || !impactPath) {
26
+ console.error('användning: node check-lasta.mjs "<LÄSTA-innehåll>" <spec.md> <impact.md>')
27
+ process.exit(2)
28
+ }
29
+
30
+ const problems = []
31
+ const spec = path.resolve(specPath)
32
+ const impact = path.resolve(impactPath)
33
+ // Skillkatalogen med avslutande separator, så prefixtestet aldrig släpper
34
+ // igenom en granne som `request-score-annat/`.
35
+ const skillDir = path.resolve(claudeDir(), 'skills', 'request-score') + path.sep
36
+
37
+ const lista =
38
+ lasta.trim() === 'inga'
39
+ ? []
40
+ : lasta
41
+ .split(',')
42
+ .map((s) => s.trim())
43
+ .filter(Boolean)
44
+
45
+ const lastaResolved = lista.map((p) => path.resolve(p))
46
+
47
+ for (const kravd of [spec, impact])
48
+ if (!lastaResolved.includes(kravd))
49
+ problems.push(`obligatoriskt underlag olästt: ${kravd} saknas på LÄSTA-raden`)
50
+
51
+ for (const p of lastaResolved)
52
+ if (p !== spec && p !== impact && !p.startsWith(skillDir))
53
+ problems.push(
54
+ `otillåten sökväg på LÄSTA-raden: ${p} — tillåtet är exakt underlagens två ` +
55
+ 'filer plus skillens egen katalog',
56
+ )
57
+
58
+ if (problems.length === 0) {
59
+ console.log('OK: LÄSTA-raden håller sig inom läs-scopet')
60
+ process.exit(0)
61
+ }
62
+ console.log(`BROTT (${problems.length}):`)
63
+ for (const p of problems) console.log(`- ${p}`)
64
+ process.exit(1)
@@ -2,14 +2,15 @@
2
2
  * Deterministisk dubbelkörningsdiff för poängsättningar (driftvakten på
3
3
  * MEDIUM+). Poängen har större driftyta än klassens enum och multipliceras
4
4
  * direkt till pris — request-score:s eget acceptanstest är att två oberoende
5
- * körningar ger samma poäng. Jämförs: outcome, score, confidence (enum, inte
6
- * prosa) och inringningens golv- och takankare. Prosa (why, drivers,
7
- * rationale) jämförs ALDRIG.
5
+ * körningar ger samma poäng. Jämförelsefälten är outcome, score och
6
+ * inringningens golv- och takankare. Prosa (why, drivers, rationale) jämförs
7
+ * ALDRIG. Confidence (enum, inte prosa) visas alltid i utskriften, men fäller
8
+ * bara ett par utanför toleransen, se nedan.
8
9
  *
9
- * Toleransregel: en poängskillnad ≤ 0,5 med samma outcome och confidence är
10
- * prisbrus, inte oenighet. En ankarskillnad tolereras dessutom bara när banden
11
- * överlappar eller angränsar; icke-angränsande band är hård diff även vid samma
12
- * poäng. LIKA_TOLERANS hör till LIKA-familjen (exit 0) med
10
+ * Toleransregel: en poängskillnad ≤ 0,5 med samma outcome är prisbrus, inte
11
+ * oenighet, oavsett confidence. En ankarskillnad tolereras dessutom bara när
12
+ * banden överlappar eller angränsar; icke-angränsande band är hård diff även
13
+ * vid samma poäng. LIKA_TOLERANS hör till LIKA-familjen (exit 0) med
13
14
  * en deterministisk vinnare (högsta poängen, skyddar marginalen). Den
14
15
  * skriver aldrig om en fil och räknar aldrig fram ett medelvärde.
15
16
  *
@@ -19,7 +20,8 @@
19
20
  * båda inringningarna.
20
21
  *
21
22
  * Exit 2 = jämförelsen kan inte göras alls (dispatcherns fel, aldrig LIKA) —
22
- * rälsen bor i lib.mjs och delas med compare-classifications.
23
+ * rälsen bor i lib.mjs (delades tidigare med compare-classifications,
24
+ * borttagen med klassaxeln i ADR-0098).
23
25
  *
24
26
  * Användning: node compare-scores.mjs <körning1.json> <körning2.json>
25
27
  */
@@ -41,14 +43,11 @@ if (a.outcome === undefined || b.outcome === undefined) {
41
43
  const show = (v) => JSON.stringify(v ?? null)
42
44
  const diffs = []
43
45
  if (a.outcome !== b.outcome) diffs.push(`outcome: ${show(a.outcome)} ↔ ${show(b.outcome)}`)
44
- // confidence är ett enum (hog/medel/lag), inte prosa — samma poäng med olika
45
- // säkerhet är information granskaren ska se, precis som klassningens konfidenssida.
46
- if (a.confidence !== b.confidence)
47
- diffs.push(`confidence: ${show(a.confidence)} ↔ ${show(b.confidence)}`)
48
46
  // Toleransregel (beslut 2026-08-28, ADR-0078): en poängskillnad inom 0,5 med
49
- // samma outcome och confidence är prisbrus, inte oenighet. Båda pilotfallens
50
- // eskaleringar gällde Δ = 0,25 och kostade ett helt nytt varv styck. Högsta
51
- // poängen gäller (skyddar marginalen); ett medelvärde räknas fortfarande aldrig.
47
+ // samma outcome är prisbrus, inte oenighet, oavsett confidence. Båda
48
+ // pilotfallens eskaleringar gällde Δ = 0,25 och kostade ett helt nytt varv
49
+ // styck. Högsta poängen gäller (skyddar marginalen); ett medelvärde räknas
50
+ // fortfarande aldrig.
52
51
  const TOLERANS = 0.5
53
52
 
54
53
  /**
@@ -75,9 +74,16 @@ const nastanSammaBand = (fa, ta, fb, tb) => {
75
74
  }
76
75
 
77
76
  const tolerans = []
77
+ // Konfidensordet (hog/medel/lag) är en självrapporterad etikett. Den visas
78
+ // alltid i utskriften, men fäller aldrig ensam ett par vars poäng ligger inom
79
+ // toleransen: identisk poäng med medel↔hog routade en skarp post till ESKALERA
80
+ // på enbart ordet (ADR-0092). Utanför toleransen är den hård diff som förut.
81
+ const konfDiff =
82
+ a.confidence !== b.confidence ? `confidence: ${show(a.confidence)} ↔ ${show(b.confidence)}` : null
78
83
  if (a.outcome === 'POANG' && b.outcome === 'POANG') {
79
84
  const delta = Math.abs(a.score - b.score)
80
85
  const inomTolerans = Number.isFinite(delta) && delta <= TOLERANS
86
+ if (konfDiff) (inomTolerans ? tolerans : diffs).push(konfDiff)
81
87
  if (a.score !== b.score)
82
88
  (inomTolerans ? tolerans : diffs).push(`score: ${show(a.score)} ↔ ${show(b.score)}`)
83
89
  const fa = a.bracket?.floor?.anchor ?? null
@@ -88,6 +94,8 @@ if (a.outcome === 'POANG' && b.outcome === 'POANG') {
88
94
  if (fa !== fb) ankarDiffs.push(`bracket.floor.anchor: ${show(fa)} ↔ ${show(fb)}`)
89
95
  if (ta !== tb) ankarDiffs.push(`bracket.ceiling.anchor: ${show(ta)} ↔ ${show(tb)}`)
90
96
  ;(inomTolerans && nastanSammaBand(fa, ta, fb, tb) ? tolerans : diffs).push(...ankarDiffs)
97
+ } else if (konfDiff) {
98
+ diffs.push(konfDiff)
91
99
  }
92
100
 
93
101
  if (diffs.length === 0 && tolerans.length > 0) {
@@ -1,7 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Mekanisk efterhandskontroll av en classify- eller score-JSON mot sitt
4
- * underlag. Fångar det starka modeller ändå slarvar med i lång kontext:
3
+ * Mekanisk efterhandskontroll av en score-JSON mot sitt underlag.
4
+ * Classify-grenen finns kvar ENBART för äldre arkiv (före ADR-0098, då
5
+ * klassaxeln togs bort): inga nya classify-svar produceras, men arkiverade
6
+ * ska fortsätta gå att kontrollera. Fångar det starka modeller ändå slarvar
7
+ * med i lång kontext:
5
8
  * parafraserade "ordagranna" citat, läckta protokolltermer i kundtext, brutna
6
9
  * enum- och upplösningsregler. Bedömer aldrig innehållets rimlighet — bara
7
10
  * kontraktet. Vid BROTT: dispatcha agenten om EN gång med felraderna, sedan
@@ -13,15 +16,41 @@
13
16
  */
14
17
  import { readFileSync } from 'node:fs'
15
18
 
16
- import { refDriftWarning, norm, quoteOk } from './lib.mjs'
19
+ import { refDriftWarning, norm, quoteOk, CLASS_UTFALL, SCORE_OUTCOMES } from './lib.mjs'
17
20
 
18
21
  const argv = process.argv.slice(2)
19
22
  let expectId = null
20
23
  const idFlag = argv.indexOf('--expect-id')
21
24
  if (idFlag !== -1) {
22
- expectId = argv[idFlag + 1]
25
+ // Ett `--expect-id` utan ett efterföljande värde (sista argumentet, eller
26
+ // direkt följt av en ny flagga) fick tidigare `expectId = undefined`, och
27
+ // `undefined != null` är `false` i JS, så kontrollen mot `request_id` längre
28
+ // ner hoppades då tyst över helt, exakt som om flaggan aldrig angetts.
29
+ // `--expect-id` är den ENDA spärren mot att arkivera en körning mot fel
30
+ // post (avgörande i `utvärdera`, se SKILL.md), så ett värdelöst anrop får
31
+ // aldrig tolkas som "ingen kontroll begärd". Det är nästan säkert ett
32
+ // dispatcherfel som ska stanna hårt (exit 2), inte ett tyst OK.
33
+ const v = argv[idFlag + 1]
34
+ if (v === undefined || v.startsWith('--')) {
35
+ console.error('--expect-id kräver ett värde')
36
+ process.exit(2)
37
+ }
38
+ expectId = v
23
39
  argv.splice(idFlag, 2)
24
40
  }
41
+ // En felstavad flaggform (t.ex. `--expectid`, bindestrecket i "expect-id"
42
+ // tappat) matchar aldrig `--expect-id` ovan och blir kvar i `argv` som ett
43
+ // obehandlat argument. Utan den här kontrollen landade den tyst som
44
+ // `impactPath` (eller ströks av destructuringen om positionen inte räckte
45
+ // till) och kontrollen mot `request_id` kördes aldrig, samma
46
+ // fail-öppet-brist som det värdelösa fallet ovan, bara via ett annat
47
+ // missförstått flaggnamn. Varje kvarvarande `--`-token är alltså ett
48
+ // användningsfel, aldrig en gissning om vad anroparen menade.
49
+ const okandFlagga = argv.find((a) => a.startsWith('--'))
50
+ if (okandFlagga !== undefined) {
51
+ console.error(`okänd flagga: ${okandFlagga} (giltig: --expect-id <id>)`)
52
+ process.exit(2)
53
+ }
25
54
  const [jsonPath, specPath, impactPath] = argv
26
55
  if (!jsonPath || !specPath) {
27
56
  console.error(
@@ -37,9 +66,10 @@ const warnings = []
37
66
  // i referensfilerna. Pinsen är markör-strippade (stabila över haus-releaser,
38
67
  // som stämplar om filer med paketversionen) och sökvägarna hedrar
39
68
  // CLAUDE_CONFIG_DIR — se lib.mjs.
69
+ // klassningsinstruktion.md togs bort med klassaxeln (ADR-0098): classify-
70
+ // grenen kontrollerar bara arkiverade filer och behöver ingen levande
71
+ // referensfil att drifta mot.
40
72
  const EXPECTED_REFS = {
41
- 'request-classify/references/klassningsinstruktion.md':
42
- '811f0bff62c749255e86da7ea8db0ea868ebc9df14262f9889101c110d9d14ff',
43
73
  'request-score/references/poangskala.md':
44
74
  'cb5a6c221b5d39c445ca31a427fd3f96c648388ac5441c22ea2ea5f72e1f8e31',
45
75
  }
@@ -66,8 +96,8 @@ try {
66
96
  process.exit(2)
67
97
  }
68
98
 
69
- // quoteOk kommer från lib.mjs (delad med quote-repair.mjs); bind källan lokalt
70
- // så befintliga anropsställen (`ok(f.belagg)` osv.) förblir oförändrade.
99
+ // quoteOk kommer från lib.mjs; bind källan lokalt så befintliga anropsställen
100
+ // (`ok(f.belagg)` osv.) förblir oförändrade.
71
101
  const ok = (q) => quoteOk(q, source)
72
102
 
73
103
  /** Alla strängvärden ur ett godtyckligt värde (drivers kan vara objekt). */
@@ -106,12 +136,48 @@ const CUSTOMER_BLACKLIST = [
106
136
  // Ankar-id (A1–A17) kolliderar strukturellt med pappersformat (A3/A4/A5) och
107
137
  // går inte att regexa isär — heuristik, alltså varning i stället för brott.
108
138
  const ANCHOR_RE = /(?<![\p{L}\d_])A(?:1[0-7]|[1-9])(?![\p{L}\d_])/u
139
+ // Vardagsidiom där "poängen" är vanlig svenska, inte skalans mekanik.
140
+ // Maskas bort FÖRE svartlistan så att "hela poängen med posten" går fri
141
+ // medan "poängen är 5" och "ger 5 poäng" fortfarande fälls.
142
+ //
143
+ // Undantaget är snävat till den STARKA idiom-markören, inte till talformen.
144
+ // Det observerade fallet som motiverade hela undantaget är "två fönster är
145
+ // hela poängen med posten", och "hela poängen med/i" är en konstruktion som
146
+ // bara förekommer idiomatiskt (syftet med något). Mekaniken uttrycks
147
+ // aldrig så: den säger "poängen är 5" eller "poängen med posten är 5",
148
+ // aldrig "hela poängen med posten är 5". Att kräva ordet "hela" gör
149
+ // undantaget smalast möjligt och stänger båda hålen i mekaniskt bruk på en
150
+ // gång, oavsett om talet skrivs som siffra ("är 5") eller ord ("handlar om
151
+ // fem enheter"). Ett tidigare försök jagade i stället en siffra i resten
152
+ // av meningen som ENDA spärr, utan att kräva "hela", och missade just
153
+ // ordformen (fälldes i en granskning). Siffer-lookaheaden ligger kvar som
154
+ // extra spärr på med/i-varianten, ren belt-and-braces mot specialfall som
155
+ // "hela poängen med de 5 posterna är 5" där "hela" ändå finns men en
156
+ // siffra i samma mening ändå bör tippa mot BROTT.
157
+ //
158
+ // Konsekvens, medveten falsk positiv: "poängen med det här är att slippa
159
+ // dubbelarbete" (idiom UTAN "hela") fälls nu som BROTT. Det kostar en
160
+ // omdispatch där agenten kan skriva "syftet med" i stället. Det är den
161
+ // säkra riktningen, eftersom BROTT ger en ofarlig omdispatch medan motsatt
162
+ // fel (falsk negativ) släpper protokollmekanik hela vägen ut i kundtext.
163
+ // Rör INTE kravet på "hela" utan att förstå detta, det ser ut som det kan
164
+ // "lättas upp" men gör det inte utan att öppna hålet igen.
165
+ //
166
+ // "poängen är att" behöver ingen lookahead: "är att" följs alltid av en
167
+ // sats, aldrig av en poängangivelse.
168
+ const IDIOM_ALLOW = [/hela\s+poängen\s+(?:med|i)\b(?![^.!?]*\d)/giu, /poängen\s+är\s+att\b/giu]
169
+ const maskIdioms = (text) =>
170
+ IDIOM_ALLOW.reduce((t, re) => t.replace(re, (m) => '�'.repeat(m.length)), text)
109
171
  const checkCustomerText = (text, field) => {
110
172
  if (typeof text !== 'string') return
173
+ const masked = maskIdioms(text)
111
174
  for (const re of CUSTOMER_BLACKLIST) {
112
- const m = text.match(re)
175
+ const m = masked.match(re)
113
176
  if (m) problems.push(`${field}: protokollterm "${m[0]}" i kundtext`)
114
177
  }
178
+ // Ankarvarningen körs mot ORIGINALET, aldrig mot den maskade strängen.
179
+ // Maskningen bevarar längden men inte innehållet, och varningscitatet ska
180
+ // visa vad som faktiskt stod i texten.
115
181
  const a = text.match(ANCHOR_RE)
116
182
  if (a) warnings.push(`${field}: "${a[0]}" — ankar-id eller pappersformat? Kontrollera`)
117
183
  }
@@ -182,19 +248,10 @@ const checkEnvelopeKeys = (kind) => {
182
248
  }
183
249
 
184
250
  if (doc.classification !== undefined) {
185
- // ---- classify ------------------------------------------------------------
186
- const UTFALL = [
187
- 'TINY',
188
- 'SMALL',
189
- 'MEDIUM',
190
- 'LARGE',
191
- 'OFFERT',
192
- 'KONCEPT',
193
- 'DIAGNOS',
194
- 'TILLBAKA_TILL_SPEC',
195
- 'INPUT_SAKNAS',
196
- 'ESKALERA',
197
- ]
251
+ // ---- classify (endast äldre arkiv, före ADR-0098) -------------------------
252
+ // Enumen bor i lib.mjs (CLASS_UTFALL) — delad med archive.mjs, som validerar
253
+ // samma fält på en ARKIVERAD körning innan set-status priced litar på den.
254
+ const UTFALL = CLASS_UTFALL
198
255
  // Trasig form är brottet, aldrig en krasch — utan '- '-rader har omdispatchen
199
256
  // inget att citera och stacktracen ser ut som ett scriptfel.
200
257
  const c = doc.classification && typeof doc.classification === 'object' ? doc.classification : null
@@ -222,6 +279,9 @@ if (doc.classification !== undefined) {
222
279
  'classification.fragor: saknas eller är tom — varje körning ska redovisa sina frågerader',
223
280
  )
224
281
  }
282
+ // Fråge-id är protokollets, aldrig modellens. Ett påhittat id ("S1-stod")
283
+ // passerade tidigare som "övrig rad" och kunde inte jämföras i dubbelkörningen.
284
+ const PROTOKOLL_IDS = ['S1', 'S2', 'S3', 'BASKLASS', 'B1', 'B2', 'B3']
225
285
  const fragorRows = Array.isArray(c.fragor) ? c.fragor : []
226
286
  for (const f0 of fragorRows) {
227
287
  if (f0 == null || typeof f0 !== 'object') {
@@ -229,6 +289,10 @@ if (doc.classification !== undefined) {
229
289
  continue
230
290
  }
231
291
  const f = f0
292
+ if (!PROTOKOLL_IDS.includes(f.id))
293
+ problems.push(
294
+ `fråga ${f.id}: okänt fråge-id (protokollet har exakt: ${PROTOKOLL_IDS.join(', ')})`,
295
+ )
232
296
  // S1–S3/B1–B3 svarar JA/NEJ; basklassraden svarar med en klass
233
297
  // (instruktionens JSON, "ordagrant och orört" — tvinga aldrig om den).
234
298
  if (/^[SB][1-3]$/i.test(f.id ?? '')) {
@@ -239,11 +303,18 @@ if (doc.classification !== undefined) {
239
303
  }
240
304
  if (typeof f.konfidens !== 'number' || f.konfidens < 0 || f.konfidens > 1)
241
305
  problems.push(`fråga ${f.id}: konfidens ${f.konfidens} utanför 0–1`)
242
- // Belägg är obligatoriskt per instruktionen — saknat belägg är brottet,
243
- // inte låg kvalitet.
244
- if (!f.belagg || norm(f.belagg).length === 0)
245
- problems.push(`fråga ${f.id}: belägg saknas (obligatoriskt, ordagrant citat)`)
246
- else if (!ok(f.belagg))
306
+ // Belägg är obligatoriskt. Saknas belagg men ankaret finns har dispatchern
307
+ // hoppat över resolve-steget: det är dispatcherns fel (exit 2), aldrig
308
+ // agentens brott, en omdispatch vore meningslös.
309
+ if (!f.belagg || norm(f.belagg).length === 0) {
310
+ if (f.belagg_ankare) {
311
+ console.error(`fråga ${f.id}: belagg_ankare olöst — kör resolve-anchors.mjs först`)
312
+ process.exit(2)
313
+ }
314
+ problems.push(
315
+ `fråga ${f.id}: belägg saknas (obligatoriskt: belagg_ankare i svaret, belagg efter resolve)`,
316
+ )
317
+ } else if (!ok(f.belagg))
247
318
  problems.push(
248
319
  `fråga ${f.id}: belägg är inte ordagrant citat ur underlaget: "${f.belagg.slice(0, 70)}..."`,
249
320
  )
@@ -255,15 +326,8 @@ if (doc.classification !== undefined) {
255
326
  checkCustomerText(dep, `customer.dependencies[${i}]`)
256
327
  } else if (doc.outcome !== undefined) {
257
328
  // ---- score ---------------------------------------------------------------
258
- const OUTCOMES = [
259
- 'POANG',
260
- 'KONCEPT',
261
- 'DIAGNOS',
262
- 'TILLBAKA_TILL_SPEC',
263
- 'DELA_UPP',
264
- 'OVER_SKALAN',
265
- 'INPUT_SAKNAS',
266
- ]
329
+ // Enumen bor i lib.mjs (SCORE_OUTCOMES) — samma delning som UTFALL ovan.
330
+ const OUTCOMES = SCORE_OUTCOMES
267
331
  checkEnvelopeKeys('score')
268
332
  if (!MODES.includes(doc.mode)) problems.push(`mode: ogiltigt värde "${doc.mode}"`)
269
333
  if (!OUTCOMES.includes(doc.outcome)) problems.push(`outcome: ogiltigt värde "${doc.outcome}"`)
@@ -279,6 +343,14 @@ if (doc.classification !== undefined) {
279
343
  } else if (doc.score != null) {
280
344
  problems.push(`score: ska vara null när outcome är ${doc.outcome}`)
281
345
  }
346
+ // Ett ankare utan sitt lösta citat betyder att dispatchern hoppat över
347
+ // resolve-anchors.mjs: dispatcherns fel (exit 2), aldrig agentens brott.
348
+ for (const [i, d] of (Array.isArray(doc.drivers) ? doc.drivers : []).entries())
349
+ if (d && typeof d === 'object' && d.ankare && !d.citat) {
350
+ console.error(`drivers[${i}]: ankare olöst — kör resolve-anchors.mjs först`)
351
+ process.exit(2)
352
+ }
353
+
282
354
  // Citat i drivers (även i objektform) ska vara ordagranna ur underlaget.
283
355
  // Både raka och typografiska citattecken räknas. Ett icke-array-värde är
284
356
  // redan fällt av POANG-kontrollen ovan — här skyddar vi bara iterationen.
@@ -116,6 +116,10 @@ export function reportDiffs(diffs) {
116
116
  * källans fetstil- eller `kod`-markeringar är fortfarande ordagrant. */
117
117
  export const norm = (s) => s.replace(/[*`]/g, '').replace(/\s+/g, ' ').trim()
118
118
 
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(/\[[^\]]*\]$/, '')
122
+
119
123
  /** Ett "ordagrant" citat får hoppa med [...] och kortas med inledande/avslutande …
120
124
  * Varje kvarvarande segment måste vara exakt substräng av det normaliserade
121
125
  * underlaget. Tomt efter filtrering = INTE ordagrant. */
@@ -126,3 +130,65 @@ export const quoteOk = (q, normalizedSource) => {
126
130
  .filter((seg) => seg.length > 0)
127
131
  return segments.length > 0 && segments.every((seg) => normalizedSource.includes(seg))
128
132
  }
133
+
134
+ /**
135
+ * Klassaxelns fullständiga utfallsenum (klassningsinstruktion.md, Steg 4) och
136
+ * poängaxelns fullständiga outcome-enum (request-score/SKILL.md). Delade
137
+ * mellan contract-check.mjs (validerar en FÄRSK körning mot protokollet) och
138
+ * archive.mjs (validerar en ARKIVERAD körning innan set-status priced litar
139
+ * på dess utfall) — en enda lista, aldrig två som kan glida isär.
140
+ *
141
+ * Detta är en ALLOW-lista (känt värde), inte bara en icke-tom-sträng-kontroll.
142
+ * En DENY-lista över stopputfall (se CLASS_STOP/SCORE_STOP i archive.mjs)
143
+ * släpper igenom en felstavning ("OFERT"), en tom sträng, eller fel skiftläge
144
+ * ("eskalera") eftersom inget av dem finns i deny-listan — grinden failar
145
+ * ÖPPET på precis den typ av trasig data en modell råkar producera. En
146
+ * allow-lista failar STÄNGT i stället: allt som inte är ett av de kända
147
+ * värdena är ett integritetsfel, aldrig ett grönt kort.
148
+ */
149
+ // CLASS_UTFALL finns kvar ENBART för äldre arkiv (före ADR-0098, då
150
+ // klassaxeln togs bort): archive.mjs och contract-check.mjs validerar
151
+ // fortfarande arkiverade classification-filer mot den.
152
+ export const CLASS_UTFALL = [
153
+ 'TINY',
154
+ 'SMALL',
155
+ 'MEDIUM',
156
+ 'LARGE',
157
+ 'OFFERT',
158
+ 'KONCEPT',
159
+ 'DIAGNOS',
160
+ 'TILLBAKA_TILL_SPEC',
161
+ 'INPUT_SAKNAS',
162
+ 'ESKALERA',
163
+ ]
164
+
165
+ export const SCORE_OUTCOMES = [
166
+ 'POANG',
167
+ 'KONCEPT',
168
+ 'DIAGNOS',
169
+ 'TILLBAKA_TILL_SPEC',
170
+ 'DELA_UPP',
171
+ 'OVER_SKALAN',
172
+ 'INPUT_SAKNAS',
173
+ // ADR-0098: flyttad hit från klassningens utfall när klassningen togs bort —
174
+ // utgången "en människa ska titta på det här" får aldrig saknas.
175
+ 'ESKALERA',
176
+ ]
177
+
178
+ /** Rubrikrad: fetstilsrad eller markdown-rubrik som utgör hela raden. Delad
179
+ * mellan precheck (formkontroll) och report (fältvis jämförelse) så rapporten
180
+ * läser specen med exakt samma regler som grinden. */
181
+ export const isHeadingLine = (line) =>
182
+ /^\*\*[^*]+\*\*\s*$/.test(line.trim()) || /^#{1,6}\s+\S/.test(line.trim())
183
+
184
+ export const headingRe = (h) => new RegExp(`^(\\*\\*${h}\\*\\*|#{1,6}\\s*${h})\\s*$`, 'm')
185
+
186
+ /** Raderna i sektionen under rubriken `heading`, fram till nästa rubrikrad. */
187
+ export const sectionLines = (block, heading) => {
188
+ const lines = block.split('\n')
189
+ const start = lines.findIndex((l) => headingRe(heading).test(l.trim()))
190
+ if (start === -1) return null
191
+ const rest = lines.slice(start + 1)
192
+ const end = rest.findIndex((l) => isHeadingLine(l))
193
+ return rest.slice(0, end === -1 ? rest.length : end)
194
+ }
@@ -7,7 +7,7 @@
7
7
  * status (ok/fel-lista), aldrig ett protokollutfall.
8
8
  *
9
9
  * BRISTER (exit 1) är hårda formfel. VARNING är heuristik som kräver mänsklig
10
- * blick (klassord i analys, saknad Ingår inte) — de skrivs ut men stoppar inget.
10
+ * blick (poängord i analys, saknad Ingår inte) — de skrivs ut men stoppar inget.
11
11
  *
12
12
  * Användning: node precheck.mjs <arkivkatalog> [--impact]
13
13
  * --impact kräv och kontrollera även impact.md (bedöm-steget)
@@ -16,7 +16,7 @@
16
16
  import { readFileSync, existsSync } from 'node:fs'
17
17
  import { join } from 'node:path'
18
18
 
19
- import { markerStrippedSha256, refDriftWarning } from './lib.mjs'
19
+ import { markerStrippedSha256, refDriftWarning, headingRe, sectionLines } from './lib.mjs'
20
20
 
21
21
  const dir = process.argv[2]
22
22
  const requireImpact = process.argv.includes('--impact')
@@ -31,7 +31,7 @@ const warnings = []
31
31
  // Kontraktsdriftvakt: rubrik- och vokabulärreglerna nedan speglar specformatet.
32
32
  // Pinnen är markör-strippad (stabil över haus-releaser, som stämplar om filer
33
33
  // med paketversionen) och sökvägen hedrar CLAUDE_CONFIG_DIR — se lib.mjs.
34
- const SPECFORMAT_SHA = '53bbb92ad6337557901e827c97a0995ff92150f88e0ede2810a28f36b51aa17c'
34
+ const SPECFORMAT_SHA = 'e6134fd8a3c1cc9e3056c849d8834310e39d269846f2b6bec2871a83d488fc01'
35
35
  {
36
36
  const w = refDriftWarning('request-write-spec/references/specformat.md', SPECFORMAT_SHA)
37
37
  if (w) warnings.push(w)
@@ -43,19 +43,6 @@ const TIDSORD_RE = /[\p{L}]*tim(?:me|mar|marna|men)[\p{L}]*|[\p{L}]*estim(?:at|e
43
43
  // JS \b bryter vid å/ä/ö — svenska ord kräver Unicode-medvetna gränser
44
44
  // ("kräver" får aldrig matcha förbudet "kr").
45
45
  const W = (pat) => new RegExp(`(?<![\\p{L}\\d_])(?:${pat})(?![\\p{L}\\d_])`, 'iu')
46
- // Rubrikrad: fetstilsrad eller markdown-rubrik som utgör hela raden.
47
- const isHeadingLine = (line) =>
48
- /^\*\*[^*]+\*\*\s*$/.test(line.trim()) || /^#{1,6}\s+\S/.test(line.trim())
49
- const headingRe = (h) => new RegExp(`^(\\*\\*${h}\\*\\*|#{1,6}\\s*${h})\\s*$`, 'm')
50
- /** Raderna i sektionen under rubriken `heading`, fram till nästa rubrikrad. */
51
- const sectionLines = (block, heading) => {
52
- const lines = block.split('\n')
53
- const start = lines.findIndex((l) => headingRe(heading).test(l.trim()))
54
- if (start === -1) return null
55
- const rest = lines.slice(start + 1)
56
- const end = rest.findIndex((l) => isHeadingLine(l))
57
- return rest.slice(0, end === -1 ? rest.length : end)
58
- }
59
46
 
60
47
  const read = (name, required) => {
61
48
  const p = join(dir, name)
@@ -156,19 +143,16 @@ if (requireImpact) {
156
143
  problems.push('impact.md: "## Till granskaren" saknas')
157
144
  for (const row of ['Ej verifierat', 'Avvikelse mot specen', 'Obesvarade utfallsval'])
158
145
  if (!impact.includes(`**${row}:**`)) problems.push(`impact.md: raden "**${row}:**" saknas`)
159
- // Analysen får inte läcka klass eller tid. Klassord är dock en heuristik —
160
- // "felklassen", "den klassen (av ändringar)" och ett ordagrant citerat
161
- // kod-enum (MEDIUM/LARGE i ett repo) är legitima — så träffar blir
162
- // varningar för mänsklig blick, aldrig hårda stopp. Tidsord förblir hårda.
163
- const m = impact.match(W('klass(?:er|en|ning\\p{L}*)?'))
146
+ // Analysen får inte läcka poängsättning eller tid (ADR-0098: kontrollen
147
+ // gällde tidigare klassord; klassaxeln är borttagen och regeln riktas nu
148
+ // mot poängen, som är det analysens läsare inte får primas mot).
149
+ // Poängord är dock en heuristik — "poängen med ändringen" och ett
150
+ // ordagrant citerat kod-enum är legitima — så träffar blir varningar för
151
+ // mänsklig blick, aldrig hårda stopp. Tidsord förblir hårda.
152
+ const m = impact.match(W('poäng(?:en|er|sätt\\p{L}*|sättning\\p{L}*)?'))
164
153
  if (m)
165
154
  warnings.push(
166
- `impact.md: klassord "${m[0]}" — kontrollera att analysen inte läcker klassning`,
167
- )
168
- const cls = impact.match(/(?<![\p{L}\d_])(TINY|SMALL|MEDIUM|LARGE|OFFERT)(?![\p{L}\d_])/u)
169
- if (cls)
170
- warnings.push(
171
- `impact.md: klassnamnet "${cls[0]}" — läcka, eller citat av ett kod-enum? Kontrollera`,
155
+ `impact.md: poängord "${m[0]}" — kontrollera att analysen inte läcker poängsättning`,
172
156
  )
173
157
  const t = impact.match(TIDSORD_RE)
174
158
  if (t) problems.push(`impact.md: förbjudet tidsord "${t[0]}"`)
@@ -182,6 +166,25 @@ if (state != null) {
182
166
  const s = JSON.parse(state)
183
167
  for (const key of ['request_id', 'slug', 'title', 'status', 'runs'])
184
168
  if (!(key in s)) problems.push(`state.json: fältet "${key}" saknas`)
169
+ // ADR-0099 (#461): två byteidentiska runs-rader för samma fil är
170
+ // signaturen ett OSTÄMPLAT omtag lämnar — samma dispatch bokförd två
171
+ // gånger, den förkastade utan avvisad-fil och utan felrader. Heuristik
172
+ // (en legitim dubblett är tänkbar), därför varning, aldrig brist.
173
+ if (Array.isArray(s.runs)) {
174
+ const sedda = new Set()
175
+ const flaggade = new Set()
176
+ for (const rad of s.runs) {
177
+ const nyckel = JSON.stringify(rad)
178
+ if (sedda.has(nyckel) && !flaggade.has(nyckel)) {
179
+ flaggade.add(nyckel)
180
+ warnings.push(
181
+ `state.json: identiska runs-rader för ${rad?.file ?? '?'} — ett omtag ska ` +
182
+ 'arkiveras som -avvisad med kontrollens felrader, aldrig bokföras som en dubblettrad',
183
+ )
184
+ }
185
+ sedda.add(nyckel)
186
+ }
187
+ }
185
188
  // Underlagsproveniens: analysen måste höra till DEN spec som ligger i
186
189
  // katalogen nu. En respec efter underkännande lämnar annars en gammal
187
190
  // analys som prissätts mot ny spec — helt osynligt utan den här kontrollen.