@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.
- package/CHANGELOG.md +47 -0
- package/dist/cli.js +1883 -902
- package/docs/before-you-start.md +57 -0
- package/docs/glossary.md +56 -0
- package/library/catalog/manifest.json +97 -34
- package/library/global/agents/request-assessor.md +2 -2
- package/library/global/agents/request-scorer.md +14 -9
- package/library/global/agents/request-specifier-eskalerad.md +1 -1
- package/library/global/agents/request-specifier.md +1 -1
- package/library/global/skills/haus-workflow/references/new.md +18 -11
- package/library/global/skills/request/SKILL.md +731 -127
- package/library/global/skills/request/scripts/archive.mjs +1376 -0
- package/library/global/skills/request/scripts/check-lasta.mjs +64 -0
- package/library/global/skills/request/scripts/compare-scores.mjs +73 -15
- package/library/global/skills/request/scripts/contract-check.mjs +110 -49
- package/library/global/skills/request/scripts/lib.mjs +81 -0
- package/library/global/skills/request/scripts/precheck.mjs +30 -27
- package/library/global/skills/request/scripts/report.mjs +571 -0
- package/library/global/skills/request/scripts/resolve-anchors.mjs +243 -0
- package/library/global/skills/request-assess-impact/SKILL.md +10 -10
- package/library/global/skills/request-score/SKILL.md +19 -10
- package/library/global/skills/request-sync/SKILL.md +4 -4
- package/library/global/skills/request-write-spec/SKILL.md +6 -6
- package/library/global/skills/request-write-spec/references/specformat.md +2 -2
- package/package.json +5 -2
- package/library/global/agents/request-classifier.md +0 -55
- package/library/global/skills/request/scripts/compare-classifications.mjs +0 -94
- package/library/global/skills/request-classify/SKILL.md +0 -79
- 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.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
|
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
|
-
//
|
|
37
|
-
//
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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
|
-
|
|
47
|
-
if (
|
|
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
|
-
|
|
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
|
|
4
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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 =
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
|
254
|
-
//
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
270
|
-
|
|
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 (!
|
|
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 (
|
|
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 = '
|
|
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
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
|
|
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:
|
|
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.
|