@haus-tech/haus-workflow 4.7.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.
@@ -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.7.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"