@haus-tech/haus-workflow 4.5.0 → 4.7.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 +28 -0
- package/dist/cli.js +871 -429
- package/library/catalog/manifest.json +6 -15
- package/library/global/agents/request-assessor.md +44 -0
- package/library/global/agents/request-classifier.md +55 -0
- package/library/global/agents/request-scorer.md +54 -0
- package/library/global/agents/request-specifier-eskalerad.md +43 -0
- package/library/global/agents/request-specifier.md +44 -0
- package/library/global/skills/request/SKILL.md +446 -0
- package/library/global/skills/request/scripts/compare-classifications.mjs +94 -0
- package/library/global/skills/request/scripts/compare-scores.mjs +50 -0
- package/library/global/skills/request/scripts/contract-check.mjs +334 -0
- package/library/global/skills/request/scripts/lib.mjs +113 -0
- package/library/global/skills/request/scripts/precheck.mjs +217 -0
- package/library/global/skills/request/scripts/ref-hash.mjs +22 -0
- package/package.json +2 -1
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
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:
|
|
5
|
+
* parafraserade "ordagranna" citat, läckta protokolltermer i kundtext, brutna
|
|
6
|
+
* enum- och upplösningsregler. Bedömer aldrig innehållets rimlighet — bara
|
|
7
|
+
* kontraktet. Vid BROTT: dispatcha agenten om EN gång med felraderna, sedan
|
|
8
|
+
* stopp. VARNING är heuristik (t.ex. ankar-id som krockar med pappersformat) —
|
|
9
|
+
* visas, stoppar inte.
|
|
10
|
+
*
|
|
11
|
+
* Användning: node contract-check.mjs <run.json> <spec.md> [impact.md]
|
|
12
|
+
* Exit 0 = ok, 1 = kontraktsbrott (listade), 2 = användningsfel.
|
|
13
|
+
*/
|
|
14
|
+
import { readFileSync } from 'node:fs'
|
|
15
|
+
|
|
16
|
+
import { refDriftWarning } from './lib.mjs'
|
|
17
|
+
|
|
18
|
+
const argv = process.argv.slice(2)
|
|
19
|
+
let expectId = null
|
|
20
|
+
const idFlag = argv.indexOf('--expect-id')
|
|
21
|
+
if (idFlag !== -1) {
|
|
22
|
+
expectId = argv[idFlag + 1]
|
|
23
|
+
argv.splice(idFlag, 2)
|
|
24
|
+
}
|
|
25
|
+
const [jsonPath, specPath, impactPath] = argv
|
|
26
|
+
if (!jsonPath || !specPath) {
|
|
27
|
+
console.error(
|
|
28
|
+
'användning: node contract-check.mjs <run.json> <spec.md> [impact.md] [--expect-id <id>]',
|
|
29
|
+
)
|
|
30
|
+
process.exit(2)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const problems = []
|
|
34
|
+
const warnings = []
|
|
35
|
+
|
|
36
|
+
// Kontraktsdriftvakt: enums, upplösning och svartlista nedan SPEGLAR reglerna
|
|
37
|
+
// i referensfilerna. Pinsen är markör-strippade (stabila över haus-releaser,
|
|
38
|
+
// som stämplar om filer med paketversionen) och sökvägarna hedrar
|
|
39
|
+
// CLAUDE_CONFIG_DIR — se lib.mjs.
|
|
40
|
+
const EXPECTED_REFS = {
|
|
41
|
+
'request-classify/references/klassningsinstruktion.md':
|
|
42
|
+
'811f0bff62c749255e86da7ea8db0ea868ebc9df14262f9889101c110d9d14ff',
|
|
43
|
+
'request-score/references/poangskala.md':
|
|
44
|
+
'cb5a6c221b5d39c445ca31a427fd3f96c648388ac5441c22ea2ea5f72e1f8e31',
|
|
45
|
+
}
|
|
46
|
+
for (const [rel, expected] of Object.entries(EXPECTED_REFS)) {
|
|
47
|
+
const w = refDriftWarning(rel, expected)
|
|
48
|
+
if (w) warnings.push(w)
|
|
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
|
+
let doc
|
|
54
|
+
try {
|
|
55
|
+
doc = JSON.parse(readFileSync(jsonPath, 'utf8'))
|
|
56
|
+
} catch (e) {
|
|
57
|
+
console.log(`BROTT: ${jsonPath} är inte giltig JSON (${e.message})`)
|
|
58
|
+
process.exit(1)
|
|
59
|
+
}
|
|
60
|
+
// Oläsbart underlag är dispatcherns fel (exit 2), aldrig ett kontraktsbrott —
|
|
61
|
+
// exit 1 skulle trigga en meningslös och dyr omdispatch av agenten.
|
|
62
|
+
let source
|
|
63
|
+
try {
|
|
64
|
+
source = norm(
|
|
65
|
+
readFileSync(specPath, 'utf8') + '\n' + (impactPath ? readFileSync(impactPath, 'utf8') : ''),
|
|
66
|
+
)
|
|
67
|
+
} catch (e) {
|
|
68
|
+
console.error(`kunde inte läsa underlaget: ${e.message}`)
|
|
69
|
+
process.exit(2)
|
|
70
|
+
}
|
|
71
|
+
|
|
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
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Alla strängvärden ur ett godtyckligt värde (drivers kan vara objekt). */
|
|
85
|
+
const collectStrings = (v) => {
|
|
86
|
+
if (typeof v === 'string') return [v]
|
|
87
|
+
if (Array.isArray(v)) return v.flatMap(collectStrings)
|
|
88
|
+
if (v && typeof v === 'object') return Object.values(v).flatMap(collectStrings)
|
|
89
|
+
return []
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Protokollmekanik som aldrig får nå kundtext. Klass/klassas är kundspråk
|
|
93
|
+
// (kunden ser Class-fältet) och KONCEPT/DIAGNOS är legitima kundutfall — de
|
|
94
|
+
// står medvetet INTE här. Poäng och skalans mekanik är interna. Gränserna är
|
|
95
|
+
// Unicode-medvetna (JS \b bryter vid å/ä/ö), och "protokoll" är snävat så att
|
|
96
|
+
// vardagssvenska som "protokollförs" går fri medan klassningsprotokollet fångas.
|
|
97
|
+
const B = (pat) => new RegExp(`(?<![\\p{L}\\d_])(?:${pat})`, 'iu')
|
|
98
|
+
const CUSTOMER_BLACKLIST = [
|
|
99
|
+
B('poäng\\p{L}*'),
|
|
100
|
+
B('basklass\\p{L}*'),
|
|
101
|
+
B('bump\\p{L}*'),
|
|
102
|
+
B('ankar\\p{L}*'),
|
|
103
|
+
B('referensuppdrag\\p{L}*'),
|
|
104
|
+
B('inringning\\p{L}*'),
|
|
105
|
+
B('konfidens\\p{L}*'),
|
|
106
|
+
// Substantivformer av skalan är mekanik; verbet "skalar" (skalbarhet) är
|
|
107
|
+
// legitim kundsvenska och går fritt.
|
|
108
|
+
B('skal(?:a|an|ans|or|orna)(?!\\p{L})'),
|
|
109
|
+
B('protokoll(?:et|en|s)?(?!\\p{L})'),
|
|
110
|
+
/klassningsprotokoll/iu,
|
|
111
|
+
B('blast\\s*radius'),
|
|
112
|
+
B('touchpoint\\p{L}*'),
|
|
113
|
+
// u-flaggan är obligatorisk: utan den är \p{L} i lookaround teckenklassen
|
|
114
|
+
// "p{L" och "era B2B-kunder" blir ett falskt BROTT.
|
|
115
|
+
/(?<![\p{L}\d_])[SB][1-3](?![\p{L}\d_])/u,
|
|
116
|
+
]
|
|
117
|
+
// Ankar-id (A1–A17) kolliderar strukturellt med pappersformat (A3/A4/A5) och
|
|
118
|
+
// går inte att regexa isär — heuristik, alltså varning i stället för brott.
|
|
119
|
+
const ANCHOR_RE = /(?<![\p{L}\d_])A(?:1[0-7]|[1-9])(?![\p{L}\d_])/u
|
|
120
|
+
const checkCustomerText = (text, field) => {
|
|
121
|
+
if (typeof text !== 'string') return
|
|
122
|
+
for (const re of CUSTOMER_BLACKLIST) {
|
|
123
|
+
const m = text.match(re)
|
|
124
|
+
if (m) problems.push(`${field}: protokollterm "${m[0]}" i kundtext`)
|
|
125
|
+
}
|
|
126
|
+
const a = text.match(ANCHOR_RE)
|
|
127
|
+
if (a) warnings.push(`${field}: "${a[0]}" — ankar-id eller pappersformat? Kontrollera`)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Poängupplösning: 0.25-steg under 3, heltal från 3, alltid > 0.
|
|
131
|
+
* Multiplar av 0.25 är exakta i binär flyttal, så *4-testet är exakt. */
|
|
132
|
+
const checkScoreValue = (score, field) => {
|
|
133
|
+
if (typeof score !== 'number') {
|
|
134
|
+
problems.push(`${field}: saknas eller är inte ett tal`)
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
if (score <= 0) problems.push(`${field}: ${score} — poäng måste vara större än 0`)
|
|
138
|
+
else if (score < 3 ? Math.round(score * 4) !== score * 4 : !Number.isInteger(score))
|
|
139
|
+
problems.push(`${field}: ${score} bryter upplösningen (0.25-steg under 3, heltal från 3)`)
|
|
140
|
+
}
|
|
141
|
+
const checkBracket = (b, field) => {
|
|
142
|
+
// Under-A1-fallet är kontraktsenligt golvlöst: "Får du inget golv: posten är
|
|
143
|
+
// mindre än A1 och får A1:s poäng" — då är floor null och taket A1.
|
|
144
|
+
const floorless = b?.floor == null && b?.ceiling?.anchor === 'A1'
|
|
145
|
+
if (
|
|
146
|
+
!(floorless || b?.floor?.anchor) ||
|
|
147
|
+
!b?.ceiling?.anchor ||
|
|
148
|
+
!Array.isArray(b?.between) ||
|
|
149
|
+
!Array.isArray(b?.steps)
|
|
150
|
+
) {
|
|
151
|
+
problems.push(
|
|
152
|
+
`${field}: ofullständig inringning (floor/ceiling/between/steps krävs; undantag: floor null när taket är A1) — en poäng utan inringning är inte ett giltigt utfall`,
|
|
153
|
+
)
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
if (!floorless && b.between.length !== 2)
|
|
157
|
+
problems.push(
|
|
158
|
+
`${field}.between: ${b.between.length} element (kräver exakt 2 angränsande ankare)`,
|
|
159
|
+
)
|
|
160
|
+
if (b.steps.length === 0)
|
|
161
|
+
problems.push(`${field}.steps: tom — inringningen ska redovisa ankarjämförelserna`)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const MODES = ['pricing', 'guard']
|
|
165
|
+
const BASKLASS = ['TINY', 'SMALL', 'MEDIUM', 'LARGE', 'OVER']
|
|
166
|
+
|
|
167
|
+
// Kuverten är slutna: agenterna får aldrig lägga till egna nycklar ("ordagrant
|
|
168
|
+
// och orört"). Okänd toppnyckel är ett brott, inte en utökning.
|
|
169
|
+
const ENVELOPE_KEYS = {
|
|
170
|
+
classify: ['request_id', 'mode', 'classification', 'customer', 'guard_diff'],
|
|
171
|
+
score: [
|
|
172
|
+
'request_id',
|
|
173
|
+
'mode',
|
|
174
|
+
'outcome',
|
|
175
|
+
'score',
|
|
176
|
+
'type',
|
|
177
|
+
'bracket',
|
|
178
|
+
'drivers',
|
|
179
|
+
'confidence',
|
|
180
|
+
'conditions',
|
|
181
|
+
'assumptions_stated',
|
|
182
|
+
'alternatives',
|
|
183
|
+
'dependencies',
|
|
184
|
+
'notes',
|
|
185
|
+
'customer_rationale',
|
|
186
|
+
'guard_diff',
|
|
187
|
+
],
|
|
188
|
+
}
|
|
189
|
+
const checkEnvelopeKeys = (kind) => {
|
|
190
|
+
for (const key of Object.keys(doc))
|
|
191
|
+
if (!ENVELOPE_KEYS[kind].includes(key))
|
|
192
|
+
problems.push(`okänd toppnyckel "${key}" — kuvertet är slutet, egna nycklar är förbjudna`)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
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
|
+
]
|
|
209
|
+
// Trasig form är brottet, aldrig en krasch — utan '- '-rader har omdispatchen
|
|
210
|
+
// inget att citera och stacktracen ser ut som ett scriptfel.
|
|
211
|
+
const c = doc.classification && typeof doc.classification === 'object' ? doc.classification : null
|
|
212
|
+
if (c === null) {
|
|
213
|
+
console.log('BROTT (1):')
|
|
214
|
+
console.log('- classification: null eller inte ett objekt — kuvertet är trasigt')
|
|
215
|
+
process.exit(1)
|
|
216
|
+
}
|
|
217
|
+
checkEnvelopeKeys('classify')
|
|
218
|
+
if (!MODES.includes(doc.mode)) problems.push(`mode: ogiltigt värde "${doc.mode}"`)
|
|
219
|
+
if (!UTFALL.includes(c.utfall))
|
|
220
|
+
problems.push(`classification.utfall: ogiltigt värde "${c.utfall}"`)
|
|
221
|
+
if (c.basklass != null && !BASKLASS.includes(c.basklass))
|
|
222
|
+
problems.push(`classification.basklass: ogiltigt värde "${c.basklass}"`)
|
|
223
|
+
// En klassning utan frågerader har ingen belägglogik alls — tom lista är
|
|
224
|
+
// samma brott som tomt belägg. Undantag: INPUT_SAKNAS stoppar i Steg 0,
|
|
225
|
+
// FÖRE frågorna — då är kravet i stället en icke-tom saknas-lista.
|
|
226
|
+
if (c.utfall === 'INPUT_SAKNAS') {
|
|
227
|
+
if (!Array.isArray(c.saknas) || c.saknas.length === 0)
|
|
228
|
+
problems.push(
|
|
229
|
+
'classification.saknas: tom trots INPUT_SAKNAS — utfallet ska lista vad som saknas',
|
|
230
|
+
)
|
|
231
|
+
} else if (!Array.isArray(c.fragor) || c.fragor.length === 0) {
|
|
232
|
+
problems.push(
|
|
233
|
+
'classification.fragor: saknas eller är tom — varje körning ska redovisa sina frågerader',
|
|
234
|
+
)
|
|
235
|
+
}
|
|
236
|
+
const fragorRows = Array.isArray(c.fragor) ? c.fragor : []
|
|
237
|
+
for (const f0 of fragorRows) {
|
|
238
|
+
if (f0 == null || typeof f0 !== 'object') {
|
|
239
|
+
problems.push('fragor: en rad är null eller inte ett objekt')
|
|
240
|
+
continue
|
|
241
|
+
}
|
|
242
|
+
const f = f0
|
|
243
|
+
// S1–S3/B1–B3 svarar JA/NEJ; basklassraden svarar med en klass
|
|
244
|
+
// (instruktionens JSON, "ordagrant och orört" — tvinga aldrig om den).
|
|
245
|
+
if (/^[SB][1-3]$/i.test(f.id ?? '')) {
|
|
246
|
+
if (!['JA', 'NEJ'].includes(f.svar))
|
|
247
|
+
problems.push(`fråga ${f.id}: svar "${f.svar}" (kräver JA/NEJ)`)
|
|
248
|
+
} else if (!BASKLASS.includes(f.svar) && !['JA', 'NEJ'].includes(f.svar)) {
|
|
249
|
+
problems.push(`fråga ${f.id}: svar "${f.svar}" (kräver basklassvärde eller JA/NEJ)`)
|
|
250
|
+
}
|
|
251
|
+
if (typeof f.konfidens !== 'number' || f.konfidens < 0 || f.konfidens > 1)
|
|
252
|
+
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))
|
|
258
|
+
problems.push(
|
|
259
|
+
`fråga ${f.id}: belägg är inte ordagrant citat ur underlaget: "${f.belagg.slice(0, 70)}..."`,
|
|
260
|
+
)
|
|
261
|
+
}
|
|
262
|
+
checkCustomerText(doc.customer?.rationale, 'customer.rationale')
|
|
263
|
+
for (const [i, cond] of (doc.customer?.conditions ?? []).entries())
|
|
264
|
+
checkCustomerText(cond, `customer.conditions[${i}]`)
|
|
265
|
+
for (const [i, dep] of (doc.customer?.dependencies ?? []).entries())
|
|
266
|
+
checkCustomerText(dep, `customer.dependencies[${i}]`)
|
|
267
|
+
} else if (doc.outcome !== undefined) {
|
|
268
|
+
// ---- score ---------------------------------------------------------------
|
|
269
|
+
const OUTCOMES = [
|
|
270
|
+
'POANG',
|
|
271
|
+
'KONCEPT',
|
|
272
|
+
'DIAGNOS',
|
|
273
|
+
'TILLBAKA_TILL_SPEC',
|
|
274
|
+
'DELA_UPP',
|
|
275
|
+
'OVER_SKALAN',
|
|
276
|
+
'INPUT_SAKNAS',
|
|
277
|
+
]
|
|
278
|
+
checkEnvelopeKeys('score')
|
|
279
|
+
if (!MODES.includes(doc.mode)) problems.push(`mode: ogiltigt värde "${doc.mode}"`)
|
|
280
|
+
if (!OUTCOMES.includes(doc.outcome)) problems.push(`outcome: ogiltigt värde "${doc.outcome}"`)
|
|
281
|
+
if (!['hog', 'medel', 'lag'].includes(doc.confidence))
|
|
282
|
+
problems.push(`confidence: ogiltigt värde "${doc.confidence}"`)
|
|
283
|
+
if (doc.outcome === 'POANG') {
|
|
284
|
+
checkScoreValue(doc.score, 'score')
|
|
285
|
+
checkBracket(doc.bracket, 'bracket')
|
|
286
|
+
if (!Array.isArray(doc.drivers) || doc.drivers.length < 2 || doc.drivers.length > 3)
|
|
287
|
+
problems.push(
|
|
288
|
+
`drivers: ${Array.isArray(doc.drivers) ? doc.drivers.length : 'saknas'} (skillen kräver två eller tre faktorer)`,
|
|
289
|
+
)
|
|
290
|
+
} else if (doc.score != null) {
|
|
291
|
+
problems.push(`score: ska vara null när outcome är ${doc.outcome}`)
|
|
292
|
+
}
|
|
293
|
+
// Citat i drivers (även i objektform) ska vara ordagranna ur underlaget.
|
|
294
|
+
// Både raka och typografiska citattecken räknas. Ett icke-array-värde är
|
|
295
|
+
// redan fällt av POANG-kontrollen ovan — här skyddar vi bara iterationen.
|
|
296
|
+
for (const [i, d] of (Array.isArray(doc.drivers) ? doc.drivers : []).entries())
|
|
297
|
+
for (const str of collectStrings(d))
|
|
298
|
+
for (const m of str.matchAll(/["“”„«»]([^"“”„«»]{15,})["“”„«»]/g))
|
|
299
|
+
if (!quoteOk(m[1]))
|
|
300
|
+
problems.push(
|
|
301
|
+
`drivers[${i}]: citat är inte ordagrant ur underlaget: "${m[1].slice(0, 70)}..."`,
|
|
302
|
+
)
|
|
303
|
+
checkCustomerText(doc.customer_rationale, 'customer_rationale')
|
|
304
|
+
for (const [i, cond] of (doc.conditions ?? []).entries())
|
|
305
|
+
checkCustomerText(cond, `conditions[${i}]`)
|
|
306
|
+
for (const [i, a] of (doc.assumptions_stated ?? []).entries())
|
|
307
|
+
checkCustomerText(a, `assumptions_stated[${i}]`)
|
|
308
|
+
// Alternativa lösningar bär samma kontrakt som huvudpoängen: kundspråk i
|
|
309
|
+
// label, giltig upplösning, och "ett alternativ utan inringning är lika
|
|
310
|
+
// ogiltigt som en post utan".
|
|
311
|
+
for (const [i, alt] of (doc.alternatives ?? []).entries()) {
|
|
312
|
+
checkCustomerText(alt?.label, `alternatives[${i}].label`)
|
|
313
|
+
checkCustomerText(alt?.adds, `alternatives[${i}].adds`)
|
|
314
|
+
if (alt?.score != null) {
|
|
315
|
+
checkScoreValue(alt.score, `alternatives[${i}].score`)
|
|
316
|
+
checkBracket(alt?.bracket, `alternatives[${i}].bracket`)
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
} else {
|
|
320
|
+
problems.push('okänd dokumenttyp: varken classification (classify) eller outcome (score)')
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Kuvertets request_id ska höra till katalogen det arkiveras i.
|
|
324
|
+
if (expectId != null && doc.request_id !== expectId)
|
|
325
|
+
problems.push(`request_id: "${doc.request_id}" matchar inte förväntat "${expectId}"`)
|
|
326
|
+
|
|
327
|
+
for (const w of warnings) console.log(`VARNING: ${w}`)
|
|
328
|
+
if (problems.length === 0) {
|
|
329
|
+
console.log('OK: kontraktet håller')
|
|
330
|
+
process.exit(0)
|
|
331
|
+
}
|
|
332
|
+
console.log(`BROTT (${problems.length}):`)
|
|
333
|
+
for (const p of problems) console.log(`- ${p}`)
|
|
334
|
+
process.exit(1)
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Delade byggstenar för requestmotorns kontrollscript. En kopia av varje regel:
|
|
3
|
+
* driftvakterna, dispatcher-rälsen för compare-scripten och hashningen bor här,
|
|
4
|
+
* så en härdning aldrig kan landa i ena scriptet och missa det andra.
|
|
5
|
+
*/
|
|
6
|
+
import { readFileSync } from 'node:fs'
|
|
7
|
+
import { createHash } from 'node:crypto'
|
|
8
|
+
import { homedir } from 'node:os'
|
|
9
|
+
import { join, resolve } from 'node:path'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Claude-konfigkatalogen. Installern hedrar CLAUDE_CONFIG_DIR (#278) — det ska
|
|
13
|
+
* scripten också göra, annars läser driftvakten fel eller kvarlämnat träd på
|
|
14
|
+
* relokerade maskiner.
|
|
15
|
+
*/
|
|
16
|
+
export function claudeDir() {
|
|
17
|
+
return process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Tar bort haus ägarmarkör ur filinnehåll före hashning. Installern stämplar
|
|
22
|
+
* varje fil med paketversionen i markören, så en hash ÖVER markören byter värde
|
|
23
|
+
* vid varje release utan att innehållet ändrats — driftvakten skulle bli
|
|
24
|
+
* permanent brus och guardens skevhetsvägran skulle ogiltigförklara alla
|
|
25
|
+
* arkiverade körningar vid varje haus-uppgradering. Strippningen är idempotent
|
|
26
|
+
* och en no-op på ostämplade filer.
|
|
27
|
+
*/
|
|
28
|
+
export function stripHausMarker(content) {
|
|
29
|
+
return content
|
|
30
|
+
.split('\n')
|
|
31
|
+
.filter(
|
|
32
|
+
(line, i) =>
|
|
33
|
+
!(
|
|
34
|
+
line.startsWith('<!-- HAUS-MANAGED') ||
|
|
35
|
+
((i === 0 || i === 1) && line.startsWith('// HAUS-MANAGED')) ||
|
|
36
|
+
/^haus_managed:\s/.test(line)
|
|
37
|
+
),
|
|
38
|
+
)
|
|
39
|
+
.join('\n')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** sha256 (hex) över markör-strippat filinnehåll. */
|
|
43
|
+
export function markerStrippedSha256(path) {
|
|
44
|
+
return createHash('sha256')
|
|
45
|
+
.update(stripHausMarker(readFileSync(path, 'utf8')))
|
|
46
|
+
.digest('hex')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Kontraktsdriftvakt: jämför en referensfils markör-strippade hash mot den pin
|
|
51
|
+
* scriptet byggdes mot. Returnerar en varningssträng eller null — anroparen
|
|
52
|
+
* väljer kanal (precheck/contract-check samlar i sin rapport, compare-scripten
|
|
53
|
+
* skriver till stderr så verdiktraden på stdout förblir ren).
|
|
54
|
+
*/
|
|
55
|
+
export function refDriftWarning(relSkillPath, expectedSha) {
|
|
56
|
+
const p = join(claudeDir(), 'skills', relSkillPath)
|
|
57
|
+
try {
|
|
58
|
+
if (markerStrippedSha256(p) !== expectedSha)
|
|
59
|
+
return `referensfilen ${p} har ändrats sedan scriptet skrevs — se över att kontrollerna fortfarande speglar den`
|
|
60
|
+
} catch {
|
|
61
|
+
return `referensfilen ${p} gick inte att läsa för driftkontroll`
|
|
62
|
+
}
|
|
63
|
+
return null
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Dispatcher-räls för compare-scripten: argv, samma-fil-vägran, läs+parse,
|
|
68
|
+
* byteidentisk-vägran och mode-kontroll. Exit 2 = jämförelsen kan inte göras
|
|
69
|
+
* (dispatcherns fel, aldrig LIKA). Returnerar { a, b } (hela dokumenten).
|
|
70
|
+
*/
|
|
71
|
+
export function loadRunPair(usage) {
|
|
72
|
+
const [aPath, bPath] = process.argv.slice(2)
|
|
73
|
+
if (!aPath || !bPath) {
|
|
74
|
+
console.error(usage)
|
|
75
|
+
process.exit(2)
|
|
76
|
+
}
|
|
77
|
+
if (resolve(aPath) === resolve(bPath)) {
|
|
78
|
+
console.error('samma fil två gånger — driftvakten kräver två oberoende körningar')
|
|
79
|
+
process.exit(2)
|
|
80
|
+
}
|
|
81
|
+
let rawA, rawB, a, b
|
|
82
|
+
try {
|
|
83
|
+
rawA = readFileSync(aPath, 'utf8')
|
|
84
|
+
rawB = readFileSync(bPath, 'utf8')
|
|
85
|
+
a = JSON.parse(rawA)
|
|
86
|
+
b = JSON.parse(rawB)
|
|
87
|
+
} catch (e) {
|
|
88
|
+
console.error(`kunde inte läsa körningarna: ${e.message}`)
|
|
89
|
+
process.exit(2)
|
|
90
|
+
}
|
|
91
|
+
if (rawA === rawB) {
|
|
92
|
+
console.error(
|
|
93
|
+
'filerna är byteidentiska — två oberoende körningar är aldrig det; fel fil skickad?',
|
|
94
|
+
)
|
|
95
|
+
process.exit(2)
|
|
96
|
+
}
|
|
97
|
+
if (a.mode !== 'pricing' || b.mode !== 'pricing') {
|
|
98
|
+
console.error(`båda körningarna måste vara mode pricing (fick "${a.mode}" och "${b.mode}")`)
|
|
99
|
+
process.exit(2)
|
|
100
|
+
}
|
|
101
|
+
return { a, b }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Skriver LIKA (exit 0) eller OLIKA + diffrader (exit 1) på stdout. */
|
|
105
|
+
export function reportDiffs(diffs) {
|
|
106
|
+
if (diffs.length === 0) {
|
|
107
|
+
console.log('LIKA')
|
|
108
|
+
process.exit(0)
|
|
109
|
+
}
|
|
110
|
+
console.log(`OLIKA (${diffs.length}):`)
|
|
111
|
+
for (const d of diffs) console.log(`- ${d}`)
|
|
112
|
+
process.exit(1)
|
|
113
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Mekanisk förhandskontroll av en requestpost i arkivet, före dyra
|
|
4
|
+
* bedömningskörningar. Kontrollerar ENDAST form, aldrig innehåll — en post som
|
|
5
|
+
* passerar kan fortfarande stoppas av protokollen (TILLBAKA_TILL_SPEC m.fl.),
|
|
6
|
+
* och det är deras jobb, inte det här scriptets. Returnerar därför ett eget
|
|
7
|
+
* status (ok/fel-lista), aldrig ett protokollutfall.
|
|
8
|
+
*
|
|
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.
|
|
11
|
+
*
|
|
12
|
+
* Användning: node precheck.mjs <arkivkatalog> [--impact]
|
|
13
|
+
* --impact kräv och kontrollera även impact.md (bedöm-steget)
|
|
14
|
+
* Exit 0 = ok, 1 = brister (listade på stdout), 2 = användningsfel.
|
|
15
|
+
*/
|
|
16
|
+
import { readFileSync, existsSync } from 'node:fs'
|
|
17
|
+
import { join } from 'node:path'
|
|
18
|
+
|
|
19
|
+
import { markerStrippedSha256, refDriftWarning } from './lib.mjs'
|
|
20
|
+
|
|
21
|
+
const dir = process.argv[2]
|
|
22
|
+
const requireImpact = process.argv.includes('--impact')
|
|
23
|
+
if (!dir) {
|
|
24
|
+
console.error('användning: node precheck.mjs <arkivkatalog> [--impact]')
|
|
25
|
+
process.exit(2)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const problems = []
|
|
29
|
+
const warnings = []
|
|
30
|
+
|
|
31
|
+
// Kontraktsdriftvakt: rubrik- och vokabulärreglerna nedan speglar specformatet.
|
|
32
|
+
// Pinnen är markör-strippad (stabil över haus-releaser, som stämplar om filer
|
|
33
|
+
// med paketversionen) och sökvägen hedrar CLAUDE_CONFIG_DIR — se lib.mjs.
|
|
34
|
+
const SPECFORMAT_SHA = '53bbb92ad6337557901e827c97a0995ff92150f88e0ede2810a28f36b51aa17c'
|
|
35
|
+
{
|
|
36
|
+
const w = refDriftWarning('request-write-spec/references/specformat.md', SPECFORMAT_SHA)
|
|
37
|
+
if (w) warnings.push(w)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Tidsord förbjuds i både spec och analys — EN regex, så reglerna aldrig glider isär.
|
|
41
|
+
const TIDSORD_RE = /[\p{L}]*tim(?:me|mar|marna|men)[\p{L}]*|[\p{L}]*estim(?:at|er)[\p{L}]*/iu
|
|
42
|
+
|
|
43
|
+
// JS \b bryter vid å/ä/ö — svenska ord kräver Unicode-medvetna gränser
|
|
44
|
+
// ("kräver" får aldrig matcha förbudet "kr").
|
|
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
|
+
|
|
60
|
+
const read = (name, required) => {
|
|
61
|
+
const p = join(dir, name)
|
|
62
|
+
if (!existsSync(p)) {
|
|
63
|
+
if (required) problems.push(`${name}: saknas`)
|
|
64
|
+
return null
|
|
65
|
+
}
|
|
66
|
+
return readFileSync(p, 'utf8')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ---- spec.md ---------------------------------------------------------------
|
|
70
|
+
const spec = read('spec.md', true)
|
|
71
|
+
if (spec != null) {
|
|
72
|
+
// En katalog = en spec. Specformatet föreskriver ingen separator mellan
|
|
73
|
+
// specar, så bunt-detektionen räknar den obligatoriska Problem-rubriken över
|
|
74
|
+
// hela filen — fler än en betyder att en bunt inte delats i egna kataloger,
|
|
75
|
+
// och då klassas två beslut som en post (fel pris åt något håll).
|
|
76
|
+
const problemCount = (spec.match(new RegExp(headingRe('Problem').source, 'gm')) ?? []).length
|
|
77
|
+
if (problemCount > 1)
|
|
78
|
+
problems.push(
|
|
79
|
+
`spec.md: ${problemCount} "Problem"-rubriker i en katalog — en spec per katalog, dela bunten först`,
|
|
80
|
+
)
|
|
81
|
+
const blocks = spec
|
|
82
|
+
.split(/\n---\n/)
|
|
83
|
+
.map((b) => b.trim())
|
|
84
|
+
.filter(Boolean)
|
|
85
|
+
if (blocks.length === 0) problems.push('spec.md: tom')
|
|
86
|
+
blocks.forEach((block, i) => {
|
|
87
|
+
const label = blocks.length > 1 ? `spec.md block ${i + 1}` : 'spec.md'
|
|
88
|
+
// Obligatoriskt per specformatet: Problem, Lösning, När det är klart.
|
|
89
|
+
// Ingår inte är valfri i formatet ("ibland ingen alls") — saknad blir varning.
|
|
90
|
+
for (const heading of ['Problem', 'Lösning', 'När det är klart'])
|
|
91
|
+
if (!headingRe(heading).test(block)) problems.push(`${label}: rubriken "${heading}" saknas`)
|
|
92
|
+
if (!headingRe('Ingår inte').test(block))
|
|
93
|
+
warnings.push(`${label}: "Ingår inte" saknas — ofta rätt att ringa in vad som inte ingår`)
|
|
94
|
+
// 2–5 numrerade punkter under När det är klart, avgränsat vid nästa
|
|
95
|
+
// rubrikrad (inte vid första ** — fetstil inuti en punkt är tillåten).
|
|
96
|
+
const sect = sectionLines(block, 'När det är klart')
|
|
97
|
+
if (sect != null) {
|
|
98
|
+
const items = sect.filter((l) => /^\d+\.\s/.test(l.trim()))
|
|
99
|
+
if (items.length < 2 || items.length > 5)
|
|
100
|
+
problems.push(`${label}: "När det är klart" har ${items.length} punkter (kräver 2–5)`)
|
|
101
|
+
for (const it of items)
|
|
102
|
+
if (W('eller').test(it))
|
|
103
|
+
problems.push(`${label}: "eller" i utfallspunkt: ${it.trim().slice(0, 60)}`)
|
|
104
|
+
}
|
|
105
|
+
})
|
|
106
|
+
// Förbjuden vokabulär i hela specen (tid, pengar, hedge-ord). Tidsord matchas
|
|
107
|
+
// utan inledande gräns så sammansättningar (arbetstimmar, halvtimme) fälls.
|
|
108
|
+
// "kr" kräver siffra före sig — annars fälls identifierare som "KR-2".
|
|
109
|
+
const banned = ['förslagsvis', 'alternativt', 'beroende på', 'en lämplig', 'kronor', 'SEK']
|
|
110
|
+
for (const word of banned) {
|
|
111
|
+
const m = spec.match(W(word))
|
|
112
|
+
if (m) problems.push(`spec.md: förbjudet ord "${m[0]}"`)
|
|
113
|
+
}
|
|
114
|
+
// Bindestreck friar identifierare ("punkt 3: KR-2" är inget belopp).
|
|
115
|
+
const kr = spec.match(/\d[\s.,:]*kr(?![\p{L}\d_-])/iu)
|
|
116
|
+
if (kr) problems.push(`spec.md: belopp "${kr[0].trim()}"`)
|
|
117
|
+
const time = spec.match(TIDSORD_RE)
|
|
118
|
+
if (time) problems.push(`spec.md: förbjudet tidsord "${time[0]}"`)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ---- granskarblock.md ------------------------------------------------------
|
|
122
|
+
const gb = read('granskarblock.md', true)
|
|
123
|
+
if (gb != null) {
|
|
124
|
+
if (!gb.includes('## Till granskaren'))
|
|
125
|
+
problems.push('granskarblock.md: rubriken "## Till granskaren" saknas')
|
|
126
|
+
for (const row of [
|
|
127
|
+
'Antaganden',
|
|
128
|
+
'Vägval jag gjort',
|
|
129
|
+
'Öppna utfallsval',
|
|
130
|
+
'Flaggor',
|
|
131
|
+
'Struket ur underlaget',
|
|
132
|
+
])
|
|
133
|
+
if (!gb.includes(`**${row}:**`)) problems.push(`granskarblock.md: raden "**${row}:**" saknas`)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---- impact.md (bedöm-steget) ---------------------------------------------
|
|
137
|
+
if (requireImpact) {
|
|
138
|
+
const impact = read('impact.md', true)
|
|
139
|
+
if (impact != null) {
|
|
140
|
+
const headings = [
|
|
141
|
+
'Berörda repon och filer',
|
|
142
|
+
'Datalagring',
|
|
143
|
+
'Integrationspunkter',
|
|
144
|
+
'Flöden',
|
|
145
|
+
'Gränssnitt',
|
|
146
|
+
'Verifiering',
|
|
147
|
+
'Delbarhet',
|
|
148
|
+
'Konsekvens av fel',
|
|
149
|
+
'Vägval',
|
|
150
|
+
'Antaganden',
|
|
151
|
+
'Beroenden',
|
|
152
|
+
]
|
|
153
|
+
for (const h of headings)
|
|
154
|
+
if (!headingRe(h).test(impact)) problems.push(`impact.md: rubriken "${h}" saknas`)
|
|
155
|
+
if (!impact.includes('## Till granskaren'))
|
|
156
|
+
problems.push('impact.md: "## Till granskaren" saknas')
|
|
157
|
+
for (const row of ['Ej verifierat', 'Avvikelse mot specen', 'Obesvarade utfallsval'])
|
|
158
|
+
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}*)?'))
|
|
164
|
+
if (m)
|
|
165
|
+
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`,
|
|
172
|
+
)
|
|
173
|
+
const t = impact.match(TIDSORD_RE)
|
|
174
|
+
if (t) problems.push(`impact.md: förbjudet tidsord "${t[0]}"`)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ---- state.json ------------------------------------------------------------
|
|
179
|
+
const state = read('state.json', true)
|
|
180
|
+
if (state != null) {
|
|
181
|
+
try {
|
|
182
|
+
const s = JSON.parse(state)
|
|
183
|
+
for (const key of ['request_id', 'slug', 'title', 'status', 'runs'])
|
|
184
|
+
if (!(key in s)) problems.push(`state.json: fältet "${key}" saknas`)
|
|
185
|
+
// Underlagsproveniens: analysen måste höra till DEN spec som ligger i
|
|
186
|
+
// katalogen nu. En respec efter underkännande lämnar annars en gammal
|
|
187
|
+
// analys som prissätts mot ny spec — helt osynligt utan den här kontrollen.
|
|
188
|
+
if (requireImpact && spec != null && Array.isArray(s.runs)) {
|
|
189
|
+
const impactRun = [...s.runs].reverse().find((r) => r.file === 'impact.md')
|
|
190
|
+
const recorded = impactRun?.underlag_sha256?.['spec.md']
|
|
191
|
+
if (recorded == null) {
|
|
192
|
+
// Tystnad här är exakt skevheten grinden finns för — varna alltid när
|
|
193
|
+
// analysen inte kan knytas till specen, oavsett varför.
|
|
194
|
+
warnings.push(
|
|
195
|
+
impactRun
|
|
196
|
+
? 'state.json: analysens runs-rad saknar underlag_sha256 — kan inte verifiera att analysen hör till nuvarande spec'
|
|
197
|
+
: 'state.json: impact.md finns men ingen runs-rad för den — kan inte verifiera att analysen hör till nuvarande spec',
|
|
198
|
+
)
|
|
199
|
+
} else if (recorded !== markerStrippedSha256(join(dir, 'spec.md'))) {
|
|
200
|
+
problems.push(
|
|
201
|
+
'impact.md: analysen gjordes mot en annan version av spec.md — kör om analysen innan posten bedöms',
|
|
202
|
+
)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
} catch {
|
|
206
|
+
problems.push('state.json: ogiltig JSON')
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
for (const w of warnings) console.log(`VARNING: ${w}`)
|
|
211
|
+
if (problems.length === 0) {
|
|
212
|
+
console.log('OK: formkontrollen passerad')
|
|
213
|
+
process.exit(0)
|
|
214
|
+
}
|
|
215
|
+
console.log(`BRISTER (${problems.length}):`)
|
|
216
|
+
for (const p of problems) console.log(`- ${p}`)
|
|
217
|
+
process.exit(1)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skriver den markör-strippade sha256:an för en fil — den enda hash som är
|
|
3
|
+
* stabil över haus-releaser (installern stämplar om filer med paketversionen).
|
|
4
|
+
* Används av /request-wrappern för state.json:s reference_sha256 och
|
|
5
|
+
* underlag_sha256, och är samma hashdefinition som driftvakterna i lib.mjs.
|
|
6
|
+
*
|
|
7
|
+
* Användning: node ref-hash.mjs <fil>
|
|
8
|
+
* Exit 0 + hex-hash på stdout; 2 = användningsfel/oläsbar fil.
|
|
9
|
+
*/
|
|
10
|
+
import { markerStrippedSha256 } from './lib.mjs'
|
|
11
|
+
|
|
12
|
+
const [file] = process.argv.slice(2)
|
|
13
|
+
if (!file) {
|
|
14
|
+
console.error('användning: node ref-hash.mjs <fil>')
|
|
15
|
+
process.exit(2)
|
|
16
|
+
}
|
|
17
|
+
try {
|
|
18
|
+
console.log(markerStrippedSha256(file))
|
|
19
|
+
} catch (e) {
|
|
20
|
+
console.error(`kunde inte läsa ${file}: ${e.message}`)
|
|
21
|
+
process.exit(2)
|
|
22
|
+
}
|