@haus-tech/haus-workflow 4.8.0 → 5.0.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.
Files changed (29) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/dist/cli.js +1883 -902
  3. package/docs/before-you-start.md +57 -0
  4. package/docs/glossary.md +56 -0
  5. package/library/catalog/manifest.json +97 -34
  6. package/library/global/agents/request-assessor.md +2 -2
  7. package/library/global/agents/request-scorer.md +14 -9
  8. package/library/global/agents/request-specifier-eskalerad.md +1 -1
  9. package/library/global/agents/request-specifier.md +1 -1
  10. package/library/global/skills/haus-workflow/references/new.md +18 -11
  11. package/library/global/skills/request/SKILL.md +731 -127
  12. package/library/global/skills/request/scripts/archive.mjs +1376 -0
  13. package/library/global/skills/request/scripts/check-lasta.mjs +64 -0
  14. package/library/global/skills/request/scripts/compare-scores.mjs +73 -15
  15. package/library/global/skills/request/scripts/contract-check.mjs +110 -49
  16. package/library/global/skills/request/scripts/lib.mjs +81 -0
  17. package/library/global/skills/request/scripts/precheck.mjs +30 -27
  18. package/library/global/skills/request/scripts/report.mjs +571 -0
  19. package/library/global/skills/request/scripts/resolve-anchors.mjs +243 -0
  20. package/library/global/skills/request-assess-impact/SKILL.md +10 -10
  21. package/library/global/skills/request-score/SKILL.md +19 -10
  22. package/library/global/skills/request-sync/SKILL.md +4 -4
  23. package/library/global/skills/request-write-spec/SKILL.md +6 -6
  24. package/library/global/skills/request-write-spec/references/specformat.md +2 -2
  25. package/package.json +5 -2
  26. package/library/global/agents/request-classifier.md +0 -55
  27. package/library/global/skills/request/scripts/compare-classifications.mjs +0 -94
  28. package/library/global/skills/request-classify/SKILL.md +0 -79
  29. 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,16 +2,26 @@
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
- * Skriver LIKA (exit 0) eller OLIKA + diffrader (exit 1) på stdout; varningar
10
- * går på stderr. Vid OLIKA väljer dispatchern ALDRIG en körning och räknar
11
- * ALDRIG medelvärde — posten eskaleras till granskaren med båda inringningarna.
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
14
+ * en deterministisk vinnare (högsta poängen, skyddar marginalen). Den
15
+ * skriver aldrig om en fil och räknar aldrig fram ett medelvärde.
16
+ *
17
+ * Skriver LIKA, LIKA_TOLERANS (exit 0) eller OLIKA + diffrader (exit 1) på
18
+ * stdout; varningar går på stderr. Vid OLIKA väljer dispatchern ALDRIG en
19
+ * körning och räknar ALDRIG medelvärde — posten eskaleras till granskaren med
20
+ * båda inringningarna.
12
21
  *
13
22
  * Exit 2 = jämförelsen kan inte göras alls (dispatcherns fel, aldrig LIKA) —
14
- * 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).
15
25
  *
16
26
  * Användning: node compare-scores.mjs <körning1.json> <körning2.json>
17
27
  */
@@ -33,18 +43,66 @@ if (a.outcome === undefined || b.outcome === undefined) {
33
43
  const show = (v) => JSON.stringify(v ?? null)
34
44
  const diffs = []
35
45
  if (a.outcome !== b.outcome) diffs.push(`outcome: ${show(a.outcome)} ↔ ${show(b.outcome)}`)
36
- // confidence är ett enum (hog/medel/lag), inte prosa — samma poäng med olika
37
- // säkerhet är information granskaren ska se, precis som klassningens konfidenssida.
38
- if (a.confidence !== b.confidence)
39
- diffs.push(`confidence: ${show(a.confidence)} ↔ ${show(b.confidence)}`)
46
+ // Toleransregel (beslut 2026-08-28, ADR-0078): en poängskillnad inom 0,5 med
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.
51
+ const TOLERANS = 0.5
52
+
53
+ /**
54
+ * Ankarnummer på skalan A1..A17. Golv null (under-A1-fallet) läses som 0, alltså
55
+ * steget under A1. Ett ankare som inte går att tolka ger null och behandlas som
56
+ * icke-angränsande, så en oläslig inringning aldrig kan tystas som brus.
57
+ */
58
+ const ankarNr = (v) => {
59
+ if (v === null || v === undefined) return 0
60
+ const m = /^A(\d+)$/.exec(String(v))
61
+ return m ? Number(m[1]) : null
62
+ }
63
+ /**
64
+ * Inringningarna får toleransbehandling bara när de faktiskt pekar på samma del
65
+ * av skalan: överlappande eller angränsande band. Två körningar som landar på
66
+ * samma poäng men ringar in A5/A6 respektive A11/A12 är inte prisbrus, det är
67
+ * precis den drift dubbelkörningen finns för att visa, även när summan råkar bli
68
+ * densamma.
69
+ */
70
+ const nastanSammaBand = (fa, ta, fb, tb) => {
71
+ const [l1, h1, l2, h2] = [ankarNr(fa), ankarNr(ta), ankarNr(fb), ankarNr(tb)]
72
+ if ([l1, h1, l2, h2].some((n) => n === null)) return false
73
+ return l1 <= h2 + 1 && l2 <= h1 + 1
74
+ }
75
+
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
40
83
  if (a.outcome === 'POANG' && b.outcome === 'POANG') {
41
- if (a.score !== b.score) diffs.push(`score: ${show(a.score)} ↔ ${show(b.score)}`)
84
+ const delta = Math.abs(a.score - b.score)
85
+ const inomTolerans = Number.isFinite(delta) && delta <= TOLERANS
86
+ if (konfDiff) (inomTolerans ? tolerans : diffs).push(konfDiff)
87
+ if (a.score !== b.score)
88
+ (inomTolerans ? tolerans : diffs).push(`score: ${show(a.score)} ↔ ${show(b.score)}`)
42
89
  const fa = a.bracket?.floor?.anchor ?? null
43
90
  const fb = b.bracket?.floor?.anchor ?? null
44
91
  const ta = a.bracket?.ceiling?.anchor ?? null
45
92
  const tb = b.bracket?.ceiling?.anchor ?? null
46
- if (fa !== fb) diffs.push(`bracket.floor.anchor: ${show(fa)} ↔ ${show(fb)}`)
47
- if (ta !== tb) diffs.push(`bracket.ceiling.anchor: ${show(ta)} ↔ ${show(tb)}`)
93
+ const ankarDiffs = []
94
+ if (fa !== fb) ankarDiffs.push(`bracket.floor.anchor: ${show(fa)} ↔ ${show(fb)}`)
95
+ if (ta !== tb) ankarDiffs.push(`bracket.ceiling.anchor: ${show(ta)} ↔ ${show(tb)}`)
96
+ ;(inomTolerans && nastanSammaBand(fa, ta, fb, tb) ? tolerans : diffs).push(...ankarDiffs)
97
+ } else if (konfDiff) {
98
+ diffs.push(konfDiff)
48
99
  }
49
100
 
50
- reportDiffs(diffs)
101
+ if (diffs.length === 0 && tolerans.length > 0) {
102
+ const winner = b.score > a.score ? 2 : 1
103
+ console.log(`LIKA_TOLERANS: högsta gäller ${Math.max(a.score, b.score)} (körning ${winner})`)
104
+ for (const t of tolerans) console.log(`- ${t}`)
105
+ process.exit(0)
106
+ }
107
+ // Hårda diffar visas tillsammans med toleransraderna: granskaren ska se helheten.
108
+ reportDiffs([...diffs, ...tolerans])
@@ -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 } 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
  }
@@ -47,9 +77,6 @@ for (const [rel, expected] of Object.entries(EXPECTED_REFS)) {
47
77
  const w = refDriftWarning(rel, expected)
48
78
  if (w) warnings.push(w)
49
79
  }
50
- // Markdown-betoning (**fet**, *kursiv*, `kod`) strippas före jämförelse — ett
51
- // citat som återges utan källans markeringar är fortfarande ordagrant.
52
- const norm = (s) => s.replace(/[*`]/g, '').replace(/\s+/g, ' ').trim()
53
80
  let doc
54
81
  try {
55
82
  doc = JSON.parse(readFileSync(jsonPath, 'utf8'))
@@ -69,17 +96,9 @@ try {
69
96
  process.exit(2)
70
97
  }
71
98
 
72
- /** Ett "ordagrant" citat får hoppa med [...] och kortas med inledande/avslutande …
73
- * — varje kvarvarande segment måste vara exakt substräng av underlaget.
74
- * Ett citat som blir tomt efter filtreringen är INTE ordagrant (annars vore
75
- * "[...]" ett giltigt belägg för vad som helst). */
76
- const quoteOk = (q) => {
77
- const segments = q
78
- .split(/\s*\[(?:\.\.\.|…)\]\s*/)
79
- .map((seg) => norm(seg.replace(/^(\.\.\.|…)|(\.\.\.|…)$/g, '')))
80
- .filter((seg) => seg.length > 0)
81
- return segments.length > 0 && segments.every((seg) => source.includes(seg))
82
- }
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.
101
+ const ok = (q) => quoteOk(q, source)
83
102
 
84
103
  /** Alla strängvärden ur ett godtyckligt värde (drivers kan vara objekt). */
85
104
  const collectStrings = (v) => {
@@ -117,12 +136,48 @@ const CUSTOMER_BLACKLIST = [
117
136
  // Ankar-id (A1–A17) kolliderar strukturellt med pappersformat (A3/A4/A5) och
118
137
  // går inte att regexa isär — heuristik, alltså varning i stället för brott.
119
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)
120
171
  const checkCustomerText = (text, field) => {
121
172
  if (typeof text !== 'string') return
173
+ const masked = maskIdioms(text)
122
174
  for (const re of CUSTOMER_BLACKLIST) {
123
- const m = text.match(re)
175
+ const m = masked.match(re)
124
176
  if (m) problems.push(`${field}: protokollterm "${m[0]}" i kundtext`)
125
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.
126
181
  const a = text.match(ANCHOR_RE)
127
182
  if (a) warnings.push(`${field}: "${a[0]}" — ankar-id eller pappersformat? Kontrollera`)
128
183
  }
@@ -193,19 +248,10 @@ const checkEnvelopeKeys = (kind) => {
193
248
  }
194
249
 
195
250
  if (doc.classification !== undefined) {
196
- // ---- classify ------------------------------------------------------------
197
- const UTFALL = [
198
- 'TINY',
199
- 'SMALL',
200
- 'MEDIUM',
201
- 'LARGE',
202
- 'OFFERT',
203
- 'KONCEPT',
204
- 'DIAGNOS',
205
- 'TILLBAKA_TILL_SPEC',
206
- 'INPUT_SAKNAS',
207
- 'ESKALERA',
208
- ]
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
209
255
  // Trasig form är brottet, aldrig en krasch — utan '- '-rader har omdispatchen
210
256
  // inget att citera och stacktracen ser ut som ett scriptfel.
211
257
  const c = doc.classification && typeof doc.classification === 'object' ? doc.classification : null
@@ -233,6 +279,9 @@ if (doc.classification !== undefined) {
233
279
  'classification.fragor: saknas eller är tom — varje körning ska redovisa sina frågerader',
234
280
  )
235
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']
236
285
  const fragorRows = Array.isArray(c.fragor) ? c.fragor : []
237
286
  for (const f0 of fragorRows) {
238
287
  if (f0 == null || typeof f0 !== 'object') {
@@ -240,6 +289,10 @@ if (doc.classification !== undefined) {
240
289
  continue
241
290
  }
242
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
+ )
243
296
  // S1–S3/B1–B3 svarar JA/NEJ; basklassraden svarar med en klass
244
297
  // (instruktionens JSON, "ordagrant och orört" — tvinga aldrig om den).
245
298
  if (/^[SB][1-3]$/i.test(f.id ?? '')) {
@@ -250,11 +303,18 @@ if (doc.classification !== undefined) {
250
303
  }
251
304
  if (typeof f.konfidens !== 'number' || f.konfidens < 0 || f.konfidens > 1)
252
305
  problems.push(`fråga ${f.id}: konfidens ${f.konfidens} utanför 0–1`)
253
- // Belägg är obligatoriskt per instruktionen — saknat belägg är brottet,
254
- // inte låg kvalitet.
255
- if (!f.belagg || norm(f.belagg).length === 0)
256
- problems.push(`fråga ${f.id}: belägg saknas (obligatoriskt, ordagrant citat)`)
257
- else if (!quoteOk(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))
258
318
  problems.push(
259
319
  `fråga ${f.id}: belägg är inte ordagrant citat ur underlaget: "${f.belagg.slice(0, 70)}..."`,
260
320
  )
@@ -266,15 +326,8 @@ if (doc.classification !== undefined) {
266
326
  checkCustomerText(dep, `customer.dependencies[${i}]`)
267
327
  } else if (doc.outcome !== undefined) {
268
328
  // ---- score ---------------------------------------------------------------
269
- const OUTCOMES = [
270
- 'POANG',
271
- 'KONCEPT',
272
- 'DIAGNOS',
273
- 'TILLBAKA_TILL_SPEC',
274
- 'DELA_UPP',
275
- 'OVER_SKALAN',
276
- 'INPUT_SAKNAS',
277
- ]
329
+ // Enumen bor i lib.mjs (SCORE_OUTCOMES) — samma delning som UTFALL ovan.
330
+ const OUTCOMES = SCORE_OUTCOMES
278
331
  checkEnvelopeKeys('score')
279
332
  if (!MODES.includes(doc.mode)) problems.push(`mode: ogiltigt värde "${doc.mode}"`)
280
333
  if (!OUTCOMES.includes(doc.outcome)) problems.push(`outcome: ogiltigt värde "${doc.outcome}"`)
@@ -290,13 +343,21 @@ if (doc.classification !== undefined) {
290
343
  } else if (doc.score != null) {
291
344
  problems.push(`score: ska vara null när outcome är ${doc.outcome}`)
292
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
+
293
354
  // Citat i drivers (även i objektform) ska vara ordagranna ur underlaget.
294
355
  // Både raka och typografiska citattecken räknas. Ett icke-array-värde är
295
356
  // redan fällt av POANG-kontrollen ovan — här skyddar vi bara iterationen.
296
357
  for (const [i, d] of (Array.isArray(doc.drivers) ? doc.drivers : []).entries())
297
358
  for (const str of collectStrings(d))
298
359
  for (const m of str.matchAll(/["“”„«»]([^"“”„«»]{15,})["“”„«»]/g))
299
- if (!quoteOk(m[1]))
360
+ if (!ok(m[1]))
300
361
  problems.push(
301
362
  `drivers[${i}]: citat är inte ordagrant ur underlaget: "${m[1].slice(0, 70)}..."`,
302
363
  )
@@ -111,3 +111,84 @@ export function reportDiffs(diffs) {
111
111
  for (const d of diffs) console.log(`- ${d}`)
112
112
  process.exit(1)
113
113
  }
114
+
115
+ /** Markdown-betoning strippas före jämförelse: ett citat som återges utan
116
+ * källans fetstil- eller `kod`-markeringar är fortfarande ordagrant. */
117
+ export const norm = (s) => s.replace(/[*`]/g, '').replace(/\s+/g, ' ').trim()
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
+
123
+ /** Ett "ordagrant" citat får hoppa med [...] och kortas med inledande/avslutande …
124
+ * Varje kvarvarande segment måste vara exakt substräng av det normaliserade
125
+ * underlaget. Tomt efter filtrering = INTE ordagrant. */
126
+ export const quoteOk = (q, normalizedSource) => {
127
+ const segments = q
128
+ .split(/\s*\[(?:\.\.\.|…)\]\s*/)
129
+ .map((seg) => norm(seg.replace(/^(\.\.\.|…)|(\.\.\.|…)$/g, '')))
130
+ .filter((seg) => seg.length > 0)
131
+ return segments.length > 0 && segments.every((seg) => normalizedSource.includes(seg))
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.