@haus-tech/haus-workflow 4.8.0 → 4.9.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.
@@ -6,9 +6,17 @@
6
6
  * prosa) och inringningens golv- och takankare. Prosa (why, drivers,
7
7
  * rationale) jämförs ALDRIG.
8
8
  *
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.
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
13
+ * en deterministisk vinnare (högsta poängen, skyddar marginalen). Den
14
+ * skriver aldrig om en fil och räknar aldrig fram ett medelvärde.
15
+ *
16
+ * Skriver LIKA, LIKA_TOLERANS (exit 0) eller OLIKA + diffrader (exit 1) på
17
+ * stdout; varningar går på stderr. Vid OLIKA väljer dispatchern ALDRIG en
18
+ * körning och räknar ALDRIG medelvärde — posten eskaleras till granskaren med
19
+ * båda inringningarna.
12
20
  *
13
21
  * Exit 2 = jämförelsen kan inte göras alls (dispatcherns fel, aldrig LIKA) —
14
22
  * rälsen bor i lib.mjs och delas med compare-classifications.
@@ -37,14 +45,56 @@ if (a.outcome !== b.outcome) diffs.push(`outcome: ${show(a.outcome)} ↔ ${show(
37
45
  // säkerhet är information granskaren ska se, precis som klassningens konfidenssida.
38
46
  if (a.confidence !== b.confidence)
39
47
  diffs.push(`confidence: ${show(a.confidence)} ↔ ${show(b.confidence)}`)
48
+ // 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.
52
+ const TOLERANS = 0.5
53
+
54
+ /**
55
+ * Ankarnummer på skalan A1..A17. Golv null (under-A1-fallet) läses som 0, alltså
56
+ * steget under A1. Ett ankare som inte går att tolka ger null och behandlas som
57
+ * icke-angränsande, så en oläslig inringning aldrig kan tystas som brus.
58
+ */
59
+ const ankarNr = (v) => {
60
+ if (v === null || v === undefined) return 0
61
+ const m = /^A(\d+)$/.exec(String(v))
62
+ return m ? Number(m[1]) : null
63
+ }
64
+ /**
65
+ * Inringningarna får toleransbehandling bara när de faktiskt pekar på samma del
66
+ * av skalan: överlappande eller angränsande band. Två körningar som landar på
67
+ * samma poäng men ringar in A5/A6 respektive A11/A12 är inte prisbrus, det är
68
+ * precis den drift dubbelkörningen finns för att visa, även när summan råkar bli
69
+ * densamma.
70
+ */
71
+ const nastanSammaBand = (fa, ta, fb, tb) => {
72
+ const [l1, h1, l2, h2] = [ankarNr(fa), ankarNr(ta), ankarNr(fb), ankarNr(tb)]
73
+ if ([l1, h1, l2, h2].some((n) => n === null)) return false
74
+ return l1 <= h2 + 1 && l2 <= h1 + 1
75
+ }
76
+
77
+ const tolerans = []
40
78
  if (a.outcome === 'POANG' && b.outcome === 'POANG') {
41
- if (a.score !== b.score) diffs.push(`score: ${show(a.score)} ↔ ${show(b.score)}`)
79
+ const delta = Math.abs(a.score - b.score)
80
+ const inomTolerans = Number.isFinite(delta) && delta <= TOLERANS
81
+ if (a.score !== b.score)
82
+ (inomTolerans ? tolerans : diffs).push(`score: ${show(a.score)} ↔ ${show(b.score)}`)
42
83
  const fa = a.bracket?.floor?.anchor ?? null
43
84
  const fb = b.bracket?.floor?.anchor ?? null
44
85
  const ta = a.bracket?.ceiling?.anchor ?? null
45
86
  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)}`)
87
+ const ankarDiffs = []
88
+ if (fa !== fb) ankarDiffs.push(`bracket.floor.anchor: ${show(fa)} ↔ ${show(fb)}`)
89
+ if (ta !== tb) ankarDiffs.push(`bracket.ceiling.anchor: ${show(ta)} ↔ ${show(tb)}`)
90
+ ;(inomTolerans && nastanSammaBand(fa, ta, fb, tb) ? tolerans : diffs).push(...ankarDiffs)
48
91
  }
49
92
 
50
- reportDiffs(diffs)
93
+ if (diffs.length === 0 && tolerans.length > 0) {
94
+ const winner = b.score > a.score ? 2 : 1
95
+ console.log(`LIKA_TOLERANS: högsta gäller ${Math.max(a.score, b.score)} (körning ${winner})`)
96
+ for (const t of tolerans) console.log(`- ${t}`)
97
+ process.exit(0)
98
+ }
99
+ // Hårda diffar visas tillsammans med toleransraderna: granskaren ska se helheten.
100
+ reportDiffs([...diffs, ...tolerans])
@@ -13,7 +13,7 @@
13
13
  */
14
14
  import { readFileSync } from 'node:fs'
15
15
 
16
- import { refDriftWarning } from './lib.mjs'
16
+ import { refDriftWarning, norm, quoteOk } from './lib.mjs'
17
17
 
18
18
  const argv = process.argv.slice(2)
19
19
  let expectId = null
@@ -47,9 +47,6 @@ for (const [rel, expected] of Object.entries(EXPECTED_REFS)) {
47
47
  const w = refDriftWarning(rel, expected)
48
48
  if (w) warnings.push(w)
49
49
  }
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
50
  let doc
54
51
  try {
55
52
  doc = JSON.parse(readFileSync(jsonPath, 'utf8'))
@@ -69,17 +66,9 @@ try {
69
66
  process.exit(2)
70
67
  }
71
68
 
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
- }
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.
71
+ const ok = (q) => quoteOk(q, source)
83
72
 
84
73
  /** Alla strängvärden ur ett godtyckligt värde (drivers kan vara objekt). */
85
74
  const collectStrings = (v) => {
@@ -254,7 +243,7 @@ if (doc.classification !== undefined) {
254
243
  // inte låg kvalitet.
255
244
  if (!f.belagg || norm(f.belagg).length === 0)
256
245
  problems.push(`fråga ${f.id}: belägg saknas (obligatoriskt, ordagrant citat)`)
257
- else if (!quoteOk(f.belagg))
246
+ else if (!ok(f.belagg))
258
247
  problems.push(
259
248
  `fråga ${f.id}: belägg är inte ordagrant citat ur underlaget: "${f.belagg.slice(0, 70)}..."`,
260
249
  )
@@ -296,7 +285,7 @@ if (doc.classification !== undefined) {
296
285
  for (const [i, d] of (Array.isArray(doc.drivers) ? doc.drivers : []).entries())
297
286
  for (const str of collectStrings(d))
298
287
  for (const m of str.matchAll(/["“”„«»]([^"“”„«»]{15,})["“”„«»]/g))
299
- if (!quoteOk(m[1]))
288
+ if (!ok(m[1]))
300
289
  problems.push(
301
290
  `drivers[${i}]: citat är inte ordagrant ur underlaget: "${m[1].slice(0, 70)}..."`,
302
291
  )
@@ -111,3 +111,18 @@ 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
+ /** Ett "ordagrant" citat får hoppa med [...] och kortas med inledande/avslutande …
120
+ * Varje kvarvarande segment måste vara exakt substräng av det normaliserade
121
+ * underlaget. Tomt efter filtrering = INTE ordagrant. */
122
+ export const quoteOk = (q, normalizedSource) => {
123
+ const segments = q
124
+ .split(/\s*\[(?:\.\.\.|…)\]\s*/)
125
+ .map((seg) => norm(seg.replace(/^(\.\.\.|…)|(\.\.\.|…)$/g, '')))
126
+ .filter((seg) => seg.length > 0)
127
+ return segments.length > 0 && segments.every((seg) => normalizedSource.includes(seg))
128
+ }
@@ -0,0 +1,166 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Deterministisk citaträttning FÖRE omdispatch. Pilotsessionerna 2026-08-28
4
+ * visade att classifiern komponerar belägg i stället för att citera (4 av 4
5
+ * förstapass fälldes av contract-check), och varje fällning kostade en full
6
+ * omkörning på 6-9 min. Det här scriptet räddar det räddningsbara: ett citat
7
+ * som inte är exakt substräng ersätts med sin ENTYDIGT närmaste källmening.
8
+ * Ingen entydig träff = ingen rättning (hellre omdispatch än fel belägg), och
9
+ * skrivningen är allt eller inget så en halvrättad fil aldrig arkiveras.
10
+ * En kandidat med annan polaritet än citatet (se NEGATIONER) är aldrig giltig:
11
+ * rättningen får byta form på pekaren, aldrig vända påståendet.
12
+ *
13
+ * Rättar samma fält som contract-check kontrollerar: classification.fragor[].belagg
14
+ * samt citattecken-omgärdade segment (>= 15 tecken) i score-JSON:ens drivers.
15
+ * Bedömer aldrig innehåll; byter aldrig mening, bara formen på pekaren.
16
+ *
17
+ * Användning: node quote-repair.mjs <run.json> <spec.md> [impact.md]
18
+ * Stdout: "RÄTTAT (N):" + rader, "ORÄTTBART (N):" + rader, eller "INGET ATT RÄTTA".
19
+ * Exit 0 = inga orättbara citat kvar (rättade eller inga), 1 = orättbara kvar
20
+ * (filen orörd), 2 = användningsfel/oläsbar input (dispatcherfel).
21
+ */
22
+ import { readFileSync, writeFileSync } from 'node:fs'
23
+
24
+ import { norm, quoteOk } from './lib.mjs'
25
+
26
+ const [jsonPath, specPath, impactPath] = process.argv.slice(2)
27
+ if (!jsonPath || !specPath) {
28
+ console.error('användning: node quote-repair.mjs <run.json> <spec.md> [impact.md]')
29
+ process.exit(2)
30
+ }
31
+ let doc, sourceRaw
32
+ try {
33
+ doc = JSON.parse(readFileSync(jsonPath, 'utf8'))
34
+ sourceRaw =
35
+ readFileSync(specPath, 'utf8') + '\n' + (impactPath ? readFileSync(impactPath, 'utf8') : '')
36
+ } catch (e) {
37
+ console.error(`kunde inte läsa input: ${e.message}`)
38
+ process.exit(2)
39
+ }
40
+ const source = norm(sourceRaw)
41
+
42
+ // Kandidater: källans meningar (rad- och meningsgränser) plus par av
43
+ // angränsande meningar, så ett tvåmeningscitat kan få en exakt motsvarighet.
44
+ const sentences = sourceRaw
45
+ .split(/\n+|(?<=[.!?])\s+(?=[A-ZÅÄÖ0-9])/u)
46
+ .map((s) => s.trim())
47
+ .filter((s) => norm(s).length >= 15)
48
+ const candidates = [...sentences]
49
+ for (let i = 0; i + 1 < sentences.length; i++)
50
+ candidates.push(`${sentences[i]} ${sentences[i + 1]}`)
51
+
52
+ const tokens = (s) =>
53
+ new Set(
54
+ norm(s)
55
+ .toLowerCase()
56
+ .split(/[^\p{L}\p{N}]+/u)
57
+ .filter(Boolean),
58
+ )
59
+ const dice = (A, B) => {
60
+ let inter = 0
61
+ for (const t of A) if (B.has(t)) inter++
62
+ return (2 * inter) / (A.size + B.size || 1)
63
+ }
64
+
65
+ /**
66
+ * Polaritetsvakt. Dice över tokenmängder är blind för negation: "Ingen" är ett
67
+ * ord av nio, men vänder påståendet. Utan den här listan "rättas" ett belägg som
68
+ * säger att behörighetslogik ÄNDRAS till källmeningen som säger att den INTE
69
+ * ändras, och contract-check släpper igenom det, alltså tvärtemot vad
70
+ * citatgrinden finns för. Ett citat får bara ersättas av en kandidat med exakt
71
+ * samma uppsättning negationsord.
72
+ */
73
+ const NEGATIONER = new Set([
74
+ 'ingen',
75
+ 'inga',
76
+ 'inget',
77
+ 'inte',
78
+ 'icke',
79
+ 'aldrig',
80
+ 'utan',
81
+ 'ej',
82
+ 'saknas',
83
+ 'varken',
84
+ ])
85
+ const negationer = (s) => {
86
+ const found = []
87
+ for (const t of tokens(s)) if (NEGATIONER.has(t)) found.push(t)
88
+ return found.sort().join(' ')
89
+ }
90
+
91
+ // Entydighetskravet är själva säkerhetsregeln: bästa träffen måste vara både
92
+ // nära nog (>= 0.6) och klart ensam (marginal >= 0.15), annars rättas inget.
93
+ // Polaritetsvakten går FÖRE poängsättningen: en kandidat med annan uppsättning
94
+ // negationsord är aldrig en giltig ersättare, hur nära den än ligger i Dice.
95
+ // En par-kandidat som bara är bästa träffen med en angränsande mening bilagd
96
+ // (delsträngsöverlapp åt endera hållet) är samma källplats, inte en
97
+ // konkurrerande: annars slår varannan stark enmeningsträff ut sig själv på
98
+ // marginalkravet så snart en granne delar ett enda vanligt ord.
99
+ const findReplacement = (quote) => {
100
+ const q = tokens(quote)
101
+ const pol = negationer(quote)
102
+ const scored = candidates
103
+ .filter((c) => negationer(c) === pol)
104
+ .map((c) => ({ c, s: dice(q, tokens(c)) }))
105
+ .sort((a, b) => b.s - a.s)
106
+ const best = scored[0]
107
+ if (!best || best.s < 0.6) return null
108
+ const second =
109
+ scored.find((x) => x.c !== best.c && !x.c.includes(best.c) && !best.c.includes(x.c))?.s ?? 0
110
+ if (best.s - second >= 0.15 && quoteOk(best.c, source)) return best.c
111
+ return null
112
+ }
113
+
114
+ const trunc = (s) => (s.length > 60 ? `${s.slice(0, 60)}…` : s)
115
+ // Citattecken i en ersättning som ska splitsas IN mellan citattecken skulle
116
+ // stänga citatet för tidigt: strängen blir obalanserad och nästa läsning ser ett
117
+ // annat citat än det som godkändes. Sådana kandidater refuseras.
118
+ const CITATTECKEN = /["“”„«»]/
119
+ const repaired = []
120
+ const unfixable = []
121
+ const fixQuote = (val, label, forbidQuoteChars = false) => {
122
+ if (quoteOk(val, source)) return val
123
+ const r = findReplacement(val)
124
+ if (r && !(forbidQuoteChars && CITATTECKEN.test(r))) {
125
+ repaired.push(`- ${label}: "${trunc(val)}" → "${trunc(r)}"`)
126
+ return r
127
+ }
128
+ unfixable.push(`- ${label}: "${trunc(val)}"`)
129
+ return val
130
+ }
131
+
132
+ if (doc.classification && typeof doc.classification === 'object') {
133
+ for (const f of Array.isArray(doc.classification.fragor) ? doc.classification.fragor : [])
134
+ if (f && typeof f.belagg === 'string' && norm(f.belagg).length > 0)
135
+ f.belagg = fixQuote(f.belagg, `fråga ${f.id}`)
136
+ } else if (doc.outcome !== undefined) {
137
+ // Samma citat-regex som contract-check: raka och typografiska citattecken.
138
+ const fixInString = (str, label) =>
139
+ str.replace(
140
+ /(["“”„«»])([^"“”„«»]{15,})(["“”„«»])/g,
141
+ (_m, o, inner, c) => o + fixQuote(inner, label, true) + c,
142
+ )
143
+ const walk = (v, label) =>
144
+ typeof v === 'string'
145
+ ? fixInString(v, label)
146
+ : Array.isArray(v)
147
+ ? v.map((x) => walk(x, label))
148
+ : v && typeof v === 'object'
149
+ ? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, walk(x, label)]))
150
+ : v
151
+ if (Array.isArray(doc.drivers)) doc.drivers = doc.drivers.map((d, i) => walk(d, `drivers[${i}]`))
152
+ }
153
+
154
+ if (unfixable.length > 0) {
155
+ console.log(`ORÄTTBART (${unfixable.length}):`)
156
+ for (const u of unfixable) console.log(u)
157
+ process.exit(1)
158
+ }
159
+ if (repaired.length > 0) {
160
+ writeFileSync(jsonPath, JSON.stringify(doc, null, 2) + '\n')
161
+ console.log(`RÄTTAT (${repaired.length}):`)
162
+ for (const r of repaired) console.log(r)
163
+ } else {
164
+ console.log('INGET ATT RÄTTA')
165
+ }
166
+ process.exit(0)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haus-tech/haus-workflow",
3
- "version": "4.8.0",
3
+ "version": "4.9.0",
4
4
  "description": "Haus AI workflow CLI for Claude Code.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -17,7 +17,9 @@
17
17
  "library/global",
18
18
  "library/catalog",
19
19
  "README.md",
20
- "CHANGELOG.md"
20
+ "CHANGELOG.md",
21
+ "docs/before-you-start.md",
22
+ "docs/glossary.md"
21
23
  ],
22
24
  "scripts": {
23
25
  "build": "rm -rf dist && esbuild src/cli.ts --bundle --packages=external --platform=node --format=esm --outdir=dist --external:@inquirer/checkbox",
@@ -39,6 +41,7 @@
39
41
  "verify:full": "node scripts/verify.mjs --coverage",
40
42
  "check:contract-behavior": "node --import tsx scripts/contract-behavior-check.mjs",
41
43
  "check:mutation-table": "node scripts/check-mutation-table.mjs",
44
+ "check:docs-site": "node scripts/check-docs-site.mjs",
42
45
  "postinstall": "node ./scripts/postinstall.mjs || true",
43
46
  "prepare": "lefthook install || true",
44
47
  "security:audit": "yarn npm audit -A -R --severity high --environment production"