@haus-tech/haus-workflow 5.0.0 → 5.1.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 +13 -0
- package/dist/cli.js +913 -751
- package/library/catalog/manifest.json +67 -61
- package/library/global/agents/request-assessor.md +10 -2
- package/library/global/agents/request-specifier-eskalerad.md +8 -0
- package/library/global/agents/request-specifier.md +8 -3
- package/library/global/skills/request/SKILL.md +590 -227
- package/library/global/skills/request/scripts/archive.mjs +736 -59
- package/library/global/skills/request/scripts/compare-scores.mjs +119 -13
- package/library/global/skills/request/scripts/contract-check.mjs +268 -40
- package/library/global/skills/request/scripts/kontroll.mjs +622 -0
- package/library/global/skills/request/scripts/korning.mjs +1502 -0
- package/library/global/skills/request/scripts/lib.mjs +477 -5
- package/library/global/skills/request/scripts/precheck.mjs +132 -5
- package/library/global/skills/request/scripts/report.mjs +85 -14
- package/library/global/skills/request/scripts/svep.mjs +640 -0
- package/library/global/skills/request-assess-impact/SKILL.md +45 -4
- package/library/global/skills/request-score/SKILL.md +51 -11
- package/library/global/skills/request-score/references/poangskala.md +3 -0
- package/library/global/skills/request-sync/SKILL.md +3 -3
- package/library/global/skills/request-write-spec/SKILL.md +9 -4
- package/library/global/skills/request-write-spec/references/specformat.md +48 -0
- package/package.json +1 -1
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
* driftvakterna, dispatcher-rälsen för compare-scripten och hashningen bor här,
|
|
4
4
|
* så en härdning aldrig kan landa i ena scriptet och missa det andra.
|
|
5
5
|
*/
|
|
6
|
-
import { readFileSync } from 'node:fs'
|
|
6
|
+
import { readFileSync, existsSync } from 'node:fs'
|
|
7
7
|
import { createHash } from 'node:crypto'
|
|
8
8
|
import { homedir } from 'node:os'
|
|
9
|
-
import { join, resolve } from 'node:path'
|
|
9
|
+
import { join, resolve, dirname, basename } from 'node:path'
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* Claude-konfigkatalogen. Installern hedrar CLAUDE_CONFIG_DIR (#278) — det ska
|
|
@@ -117,8 +117,51 @@ export function reportDiffs(diffs) {
|
|
|
117
117
|
export const norm = (s) => s.replace(/[*`]/g, '').replace(/\s+/g, ' ').trim()
|
|
118
118
|
|
|
119
119
|
/** Kanoniserar ett fakturerat modell-id: kontextsuffix som `[1m]` är samma
|
|
120
|
-
* modell på en annan kontextnivå,
|
|
121
|
-
|
|
120
|
+
* modell på en annan kontextnivå, och ett datumsuffix `-YYYYMMDD` är samma
|
|
121
|
+
* modell med utgivningsdatum utskrivet, aldrig en egen modell (ADR-0099, #510). */
|
|
122
|
+
export const canonicalModel = (id) => id.replace(/\[[^\]]*\]$/, '').replace(/-\d{8}$/, '')
|
|
123
|
+
|
|
124
|
+
/** Agenternas svansrader (dispatch-kontraktet): plockas från slutet av
|
|
125
|
+
* svaret, aldrig mitt i. Kroppen är allt före den första svansraden. */
|
|
126
|
+
export function parseSvansrader(text) {
|
|
127
|
+
const lines = String(text).replace(/\r\n/g, '\n').split('\n')
|
|
128
|
+
const svans = { modell: null, lasta: null, avvikelse: null }
|
|
129
|
+
const nyckel = { MODELL: 'modell', LÄSTA: 'lasta', AVVIKELSE: 'avvikelse' }
|
|
130
|
+
let i = lines.length - 1
|
|
131
|
+
while (i >= 0) {
|
|
132
|
+
const t = lines[i].trim()
|
|
133
|
+
if (t === '') {
|
|
134
|
+
i--
|
|
135
|
+
continue
|
|
136
|
+
}
|
|
137
|
+
const m = /^(MODELL|LÄSTA|AVVIKELSE):\s*(.*)$/u.exec(t)
|
|
138
|
+
if (!m) break
|
|
139
|
+
if (svans[nyckel[m[1]]] === null) svans[nyckel[m[1]]] = m[2].trim()
|
|
140
|
+
i--
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
body: lines
|
|
144
|
+
.slice(0, i + 1)
|
|
145
|
+
.join('\n')
|
|
146
|
+
.trim(),
|
|
147
|
+
...svans,
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Första JSON-objektet i en svarskropp: i ett ```json-staket om det finns,
|
|
152
|
+
* annars från första { till sista }. null när inget går att tolka. */
|
|
153
|
+
export function extraheraJson(body) {
|
|
154
|
+
const fence = /```(?:json)?\s*\n([\s\S]*?)\n```/u.exec(body)
|
|
155
|
+
const k = fence ? fence[1] : String(body)
|
|
156
|
+
const start = k.indexOf('{')
|
|
157
|
+
const slut = k.lastIndexOf('}')
|
|
158
|
+
if (start === -1 || slut < start) return null
|
|
159
|
+
try {
|
|
160
|
+
return JSON.parse(k.slice(start, slut + 1))
|
|
161
|
+
} catch {
|
|
162
|
+
return null
|
|
163
|
+
}
|
|
164
|
+
}
|
|
122
165
|
|
|
123
166
|
/** Ett "ordagrant" citat får hoppa med [...] och kortas med inledande/avslutande …
|
|
124
167
|
* Varje kvarvarande segment måste vara exakt substräng av det normaliserade
|
|
@@ -175,13 +218,286 @@ export const SCORE_OUTCOMES = [
|
|
|
175
218
|
'ESKALERA',
|
|
176
219
|
]
|
|
177
220
|
|
|
221
|
+
export const MODES = ['pricing', 'guard']
|
|
222
|
+
|
|
223
|
+
// Stegschemat (Task 4, requestmotor 2.1). Enumen är ASCII som hog/medel/lag;
|
|
224
|
+
// ett äldre arkiv med "större" läses fortfarande av report.mjs, men en färsk
|
|
225
|
+
// körning måste använda den ASCII-form compare-scores diffar på.
|
|
226
|
+
export const STEP_RELATIONS = ['storre', 'mindre', 'lika']
|
|
227
|
+
|
|
228
|
+
export const KONFIDENS = ['hog', 'medel', 'lag']
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Skalans ankare i POÄNGORDNING, med poäng. Numren är fasta beteckningar satta
|
|
232
|
+
* när ankaret skrevs, inte platser i ordningen: A17 tillkom efteråt och ligger på
|
|
233
|
+
* 10 poäng mellan A12 och A13 (poangskala.md, Översikt). Varje script som
|
|
234
|
+
* behöver "angränsande", "innanför" eller "ankarets poäng" läser HÄR, aldrig ur
|
|
235
|
+
* ankarnumret (#506). Driftvakt: tests/request-ankartabell.test.js läser
|
|
236
|
+
* översiktstabellen och fäller varje avvikelse; sha-pinnen på poangskala.md i
|
|
237
|
+
* contract-check/compare-scores täcker resten av filen.
|
|
238
|
+
*/
|
|
239
|
+
export const ANKARE = Object.freeze([
|
|
240
|
+
['A1', 0.25],
|
|
241
|
+
['A2', 0.75],
|
|
242
|
+
['A3', 0.75],
|
|
243
|
+
['A4', 1],
|
|
244
|
+
['A5', 2],
|
|
245
|
+
['A6', 2.25],
|
|
246
|
+
['A7', 2.5],
|
|
247
|
+
['A8', 3],
|
|
248
|
+
['A9', 4],
|
|
249
|
+
['A10', 4],
|
|
250
|
+
['A11', 7],
|
|
251
|
+
['A12', 9],
|
|
252
|
+
['A17', 10],
|
|
253
|
+
['A13', 12],
|
|
254
|
+
['A14', 13],
|
|
255
|
+
['A15', 16],
|
|
256
|
+
['A16', 17],
|
|
257
|
+
])
|
|
258
|
+
const ANKAR_INDEX = new Map(ANKARE.map(([id, poang], i) => [id, { i, poang }]))
|
|
259
|
+
/** Position i skalans ordning (0-baserat), null för okänt id eller icke-sträng. */
|
|
260
|
+
export const ankarPosition = (id) => ANKAR_INDEX.get(id)?.i ?? null
|
|
261
|
+
/** Ankarets poäng enligt översiktstabellen, null för okänt id. */
|
|
262
|
+
export const ankarPoang = (id) => ANKAR_INDEX.get(id)?.poang ?? null
|
|
263
|
+
|
|
264
|
+
/** Skalans översta ankare i POSITIONSORDNING (inte högsta numret: A17 ligger
|
|
265
|
+
* mellan A12 och A13). Inget-tak-utfallen måste ha jämfört mot detta (#503). */
|
|
266
|
+
export const toppAnkare = () => ANKARE[ANKARE.length - 1][0]
|
|
267
|
+
|
|
268
|
+
/** Uppdragstyperna ur översiktstabellen. `type` i score-kuvertet loggas för att
|
|
269
|
+
* mäta systematisk felplacering per typ (request-score/SKILL.md, Typen), vilket
|
|
270
|
+
* förutsätter ett slutet värdeförråd (#509). */
|
|
271
|
+
export const TYPER = [
|
|
272
|
+
'Kundvänd funktion',
|
|
273
|
+
'Underhåll',
|
|
274
|
+
'Teknisk uppsättning',
|
|
275
|
+
'Integration',
|
|
276
|
+
'Internt verktyg',
|
|
277
|
+
]
|
|
278
|
+
|
|
279
|
+
// Protokollmekanik som aldrig får nå kundtext. Klass/klassas är kundspråk
|
|
280
|
+
// (kunden ser Class-fältet) och KONCEPT/DIAGNOS är legitima kundutfall — de
|
|
281
|
+
// står medvetet INTE här. Poäng och skalans mekanik är interna. Gränserna är
|
|
282
|
+
// Unicode-medvetna (JS \b bryter vid å/ä/ö), och "protokoll" är snävat så att
|
|
283
|
+
// vardagssvenska som "protokollförs" går fri medan klassningsprotokollet fångas.
|
|
284
|
+
const B = (pat) => new RegExp(`(?<![\\p{L}\\d_])(?:${pat})`, 'iu')
|
|
285
|
+
export const CUSTOMER_BLACKLIST = [
|
|
286
|
+
B('poäng\\p{L}*'),
|
|
287
|
+
B('basklass\\p{L}*'),
|
|
288
|
+
B('bump\\p{L}*'),
|
|
289
|
+
B('ankar\\p{L}*'),
|
|
290
|
+
B('referensuppdrag\\p{L}*'),
|
|
291
|
+
B('inringning\\p{L}*'),
|
|
292
|
+
B('konfidens\\p{L}*'),
|
|
293
|
+
// Substantivformer av skalan är mekanik; verbet "skalar" (skalbarhet) är
|
|
294
|
+
// legitim kundsvenska och går fritt.
|
|
295
|
+
B('skal(?:a|an|ans|or|orna)(?!\\p{L})'),
|
|
296
|
+
B('protokoll(?:et|en|s)?(?!\\p{L})'),
|
|
297
|
+
/klassningsprotokoll/iu,
|
|
298
|
+
B('blast\\s*radius'),
|
|
299
|
+
B('touchpoint\\p{L}*'),
|
|
300
|
+
// u-flaggan är obligatorisk: utan den är \p{L} i lookaround teckenklassen
|
|
301
|
+
// "p{L" och "era B2B-kunder" blir ett falskt BROTT.
|
|
302
|
+
/(?<![\p{L}\d_])[SB][1-3](?![\p{L}\d_])/u,
|
|
303
|
+
]
|
|
304
|
+
|
|
178
305
|
/** Rubrikrad: fetstilsrad eller markdown-rubrik som utgör hela raden. Delad
|
|
179
306
|
* mellan precheck (formkontroll) och report (fältvis jämförelse) så rapporten
|
|
180
307
|
* läser specen med exakt samma regler som grinden. */
|
|
181
308
|
export const isHeadingLine = (line) =>
|
|
182
309
|
/^\*\*[^*]+\*\*\s*$/.test(line.trim()) || /^#{1,6}\s+\S/.test(line.trim())
|
|
183
310
|
|
|
184
|
-
|
|
311
|
+
/**
|
|
312
|
+
* Specformatets rubriker, EN tabell (ADR-0114). Nyckeln är den svenska
|
|
313
|
+
* literalen som scripten alltid anropat med; `en` är den engelska literal
|
|
314
|
+
* specformatet tillåter när önskemålet är på engelska. IPC 14001: alla tio
|
|
315
|
+
* specutkast fälldes för att rubrikmatchningen bara kände svenska medan
|
|
316
|
+
* skillen sa "svenska om önskemålet är på svenska" och önskemålet var på
|
|
317
|
+
* engelska. Literalerna bor här och ingen annanstans: precheck, report och
|
|
318
|
+
* sectionLines går alla genom `headingRe(key)`.
|
|
319
|
+
*/
|
|
320
|
+
export const SPEC_HEADINGS = [
|
|
321
|
+
{ key: 'Problem', sv: 'Problem', en: 'Problem' },
|
|
322
|
+
{ key: 'Lösning', sv: 'Lösning', en: 'Solution' },
|
|
323
|
+
{ key: 'När det är klart', sv: 'När det är klart', en: 'Done when' },
|
|
324
|
+
{ key: 'Ingår inte', sv: 'Ingår inte', en: 'Not included' },
|
|
325
|
+
{ key: 'Utseende', sv: 'Utseende', en: 'Appearance' },
|
|
326
|
+
{ key: 'Kräver', sv: 'Kräver', en: 'Requires' },
|
|
327
|
+
{ key: 'Villkor', sv: 'Villkor', en: 'Conditions' },
|
|
328
|
+
]
|
|
329
|
+
|
|
330
|
+
/** Kandidatformens markörrad (ADR-0105): står direkt under titeln i stället
|
|
331
|
+
* för Lösning och När det är klart, och routar posten till sitt stopputfall
|
|
332
|
+
* utan analys eller poäng. */
|
|
333
|
+
export const KANDIDAT_MARKORER = [
|
|
334
|
+
{ utfall: 'KONCEPT', sv: 'Koncept-kandidat', en: 'Concept candidate' },
|
|
335
|
+
{ utfall: 'DIAGNOS', sv: 'Diagnos-kandidat', en: 'Diagnosis candidate' },
|
|
336
|
+
]
|
|
337
|
+
|
|
338
|
+
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
339
|
+
|
|
340
|
+
/** Regex som matchar en hel rubrikrad för NÅGON av literalerna. */
|
|
341
|
+
const literalsRe = (literals, flags = 'm') => {
|
|
342
|
+
const alt = [...new Set(literals)].map(escapeRe).join('|')
|
|
343
|
+
// `#{1,6}\\s+`, aldrig `\\s*`: isHeadingLine kräver mellanslag efter #, och de
|
|
344
|
+
// två måste se samma rader som rubriker, annars läcker `##Villkor` in i
|
|
345
|
+
// föregående sektion i sectionLines (granskningsfynd 2026-09-02).
|
|
346
|
+
return new RegExp(`^(\\*\\*(?:${alt})\\*\\*|#{1,6}\\s+(?:${alt}))\\s*$`, flags)
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** Rubrikregex för en nyckel: en specrubrik matchar båda språkens literal,
|
|
350
|
+
* en okänd nyckel (impact-rubrikerna) matchas som literal precis som förut. */
|
|
351
|
+
export const headingRe = (h) => {
|
|
352
|
+
const row = SPEC_HEADINGS.find((r) => r.key === h)
|
|
353
|
+
return literalsRe(row ? [row.sv, row.en] : [h])
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Rubrikernas språk i ett specblock: 'sv', 'en', 'blandad' (minst en rubrik
|
|
358
|
+
* ur varje språk) eller null (ingen språkbärande rubrik alls). Problem är
|
|
359
|
+
* samma ord på båda språk och räknas därför inte. Blandning fälls av precheck:
|
|
360
|
+
* kunden ser rubrikerna, och opus-varvet i IPC 14001 lämnade engelsk brödtext
|
|
361
|
+
* under svenska rubriker efter omdispatchen.
|
|
362
|
+
*/
|
|
363
|
+
export const headingLanguage = (block) => {
|
|
364
|
+
const found = new Set()
|
|
365
|
+
for (const line of block.split('\n')) {
|
|
366
|
+
const t = line.trim()
|
|
367
|
+
for (const row of SPEC_HEADINGS) {
|
|
368
|
+
if (row.sv === row.en) continue
|
|
369
|
+
if (literalsRe([row.sv], '').test(t)) found.add('sv')
|
|
370
|
+
else if (literalsRe([row.en], '').test(t)) found.add('en')
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
if (found.size === 0) return null
|
|
374
|
+
return found.size === 2 ? 'blandad' : [...found][0]
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** Kandidatmarkören i ett block: { utfall, line } eller null. Bara en hel
|
|
378
|
+
* rubrikrad räknas, aldrig ett omnämnande i löptext. */
|
|
379
|
+
export const kandidatIn = (block) => {
|
|
380
|
+
const lines = block.split('\n')
|
|
381
|
+
for (let i = 0; i < lines.length; i++) {
|
|
382
|
+
const t = lines[i].trim()
|
|
383
|
+
for (const m of KANDIDAT_MARKORER)
|
|
384
|
+
if (literalsRe([m.sv, m.en], '').test(t)) return { utfall: m.utfall, line: i }
|
|
385
|
+
}
|
|
386
|
+
return null
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export const AVVIKELSE_ETIKETTER = ['ingen', 'precisering', 'motsägelse']
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Etiketten på analysens rad `**Avvikelse mot specen:**` (ADR-0106): ordet
|
|
393
|
+
* direkt efter kolonet, skiftlägesokänsligt, följt av radslut eller ett
|
|
394
|
+
* skiljetecken/mellanslag. Fritext utan etikett ger null; det är precis den
|
|
395
|
+
* rad ("bör tillbaka till specen") som scorern aldrig fick en väg att agera på.
|
|
396
|
+
*/
|
|
397
|
+
export const avvikelseEtikett = (text) => {
|
|
398
|
+
const m = /^\s*\*\*Avvikelse mot specen:\*\*\s*(\p{L}+)(?=$|[\s.,;:!?])/imu.exec(text)
|
|
399
|
+
if (!m) return null
|
|
400
|
+
const ord = m[1].toLowerCase()
|
|
401
|
+
return AVVIKELSE_ETIKETTER.includes(ord) ? ord : null
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
export const UTFALLSVAL_RAD = { sv: 'Obesvarade utfallsval', en: 'Unanswered outcome choices' }
|
|
405
|
+
export const STRUKET_PREFIX = ['struket:', 'struck:']
|
|
406
|
+
|
|
407
|
+
export const PRISAVGORANDE_RAD = {
|
|
408
|
+
sv: 'Prisavgörande öppet val',
|
|
409
|
+
en: 'Price-decisive open choice',
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** Texten efter `ja:`/`yes:` på raden Prisavgörande öppet val, annars null.
|
|
413
|
+
* Raden är specifierns eget stoppsignal (#505, ADR-0118): ett val som
|
|
414
|
+
* avgör priset och som den inte kan ta utan att gissa kundens avsikt. */
|
|
415
|
+
export const prisavgorandeVal = (text) => {
|
|
416
|
+
const re = new RegExp(
|
|
417
|
+
`^\\*\\*(?:${[PRISAVGORANDE_RAD.sv, PRISAVGORANDE_RAD.en].map(escapeRe).join('|')}):\\*\\*\\s*(ja|yes)\\s*:\\s*(.+)$`,
|
|
418
|
+
'imu',
|
|
419
|
+
)
|
|
420
|
+
const m = re.exec(String(text))
|
|
421
|
+
return m ? m[2].trim() : null
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** Formen på raden Prisavgörande öppet val: 'nej' | 'ja' | 'ja-utan-text' |
|
|
425
|
+
* null (raden saknas). 'ja-utan-text' täcker ett bart ja/yes, en kolon utan
|
|
426
|
+
* text efter, och fritext som inte är nej/ja/no/yes. Allt sådant är ett
|
|
427
|
+
* ja utan ett läsbart val, och precheck ska fälla det i stället för att
|
|
428
|
+
* tyst likställa det med nej (granskningsfynd, #505). */
|
|
429
|
+
export const prisavgorandeForm = (text) => {
|
|
430
|
+
const radRe = new RegExp(
|
|
431
|
+
`^\\*\\*(?:${[PRISAVGORANDE_RAD.sv, PRISAVGORANDE_RAD.en].map(escapeRe).join('|')}):\\*\\*\\s*(.*)$`,
|
|
432
|
+
'imu',
|
|
433
|
+
)
|
|
434
|
+
const m = radRe.exec(String(text))
|
|
435
|
+
if (!m) return null
|
|
436
|
+
const rest = m[1].trim()
|
|
437
|
+
if (/^(?:nej|no)(?=$|[\s.,;:!?])/i.test(rest)) return 'nej'
|
|
438
|
+
if (/^(?:ja|yes)\s*:\s*(\S.*)$/i.test(rest)) return 'ja'
|
|
439
|
+
return 'ja-utan-text'
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const INGA_ORD = ['inga', 'none', '-', 'tom']
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Antalet listade, ostrukna alternativ under raden Obesvarade utfallsval
|
|
446
|
+
* (#507). Raden är en fetstilsrad, inte en rubrik: sektionen är allt till
|
|
447
|
+
* nästa fetstilsrad eller rubrik. En listpunkt ("- " eller "1. ") på kolumn 0
|
|
448
|
+
* är ett alternativ; en indenterad rad är en underrad till föregående
|
|
449
|
+
* alternativ (t.ex. en struket-motivering) och räknas inte för sig. En punkt
|
|
450
|
+
* som börjar med struket:/struck: räknas inte. null när raden saknas (då
|
|
451
|
+
* äger precheck felet).
|
|
452
|
+
*/
|
|
453
|
+
export const utfallsvalAlternativ = (text) => {
|
|
454
|
+
const lines = String(text).replace(/\r\n/g, '\n').split('\n')
|
|
455
|
+
const radRe = new RegExp(
|
|
456
|
+
`^\\*\\*(?:${[UTFALLSVAL_RAD.sv, UTFALLSVAL_RAD.en].map(escapeRe).join('|')}):\\*\\*\\s*(.*)$`,
|
|
457
|
+
'u',
|
|
458
|
+
)
|
|
459
|
+
const start = lines.findIndex((l) => radRe.test(l.trim()))
|
|
460
|
+
if (start === -1) return null
|
|
461
|
+
const efterKolon = radRe.exec(lines[start].trim())[1].trim().toLowerCase()
|
|
462
|
+
if (INGA_ORD.includes(efterKolon.replace(/[.]$/, ''))) return 0
|
|
463
|
+
let antal = 0
|
|
464
|
+
for (const l of lines.slice(start + 1)) {
|
|
465
|
+
const t = l.trim()
|
|
466
|
+
if (isHeadingLine(t) || /^\*\*[^*]+:\*\*/.test(t)) break
|
|
467
|
+
// Ett alternativ är ett listobjekt på kolumn 0 av originalraden (#507
|
|
468
|
+
// uppföljning): en indenterad rad under ett objekt är en underrad
|
|
469
|
+
// (delar av samma alternativ, t.ex. en struket-motivering), inte ett
|
|
470
|
+
// eget alternativ. Kravet ligger på `l`, inte på den trimmade `t`.
|
|
471
|
+
const m = /^(?:[-*+]|\d+[.)])\s+(.*)$/.exec(l)
|
|
472
|
+
if (!m) continue
|
|
473
|
+
// Prefixtestet körs mot normaliserad text: markdown-emfas (**fetstil**,
|
|
474
|
+
// _kursiv_, `kod`) runt "struket:" får inte dölja markören för
|
|
475
|
+
// ostruken-räkningen.
|
|
476
|
+
const punkt = m[1].replace(/[*_`]/g, '').trim().toLowerCase()
|
|
477
|
+
if (!STRUKET_PREFIX.some((p) => punkt.startsWith(p))) antal++
|
|
478
|
+
}
|
|
479
|
+
return antal
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Närmaste katalog, från `dir` och uppåt, som innehåller `file`; sökvägen
|
|
484
|
+
* till filen eller null. Stannar vid en katalog med ett namn i `stopNames`
|
|
485
|
+
* (sökningen går aldrig ut ur experimentytan), vid filsystemets rot, och
|
|
486
|
+
* efter `maxLevels` steg. Används av proveniens: korning.json ligger på
|
|
487
|
+
* körningsnivå medan stämplingen ibland görs per del-N.
|
|
488
|
+
*/
|
|
489
|
+
export const findUpward = (dir, file, stopNames = ['experiments'], maxLevels = 3) => {
|
|
490
|
+
let cur = resolve(dir)
|
|
491
|
+
for (let level = 0; level <= maxLevels; level++) {
|
|
492
|
+
if (stopNames.includes(basename(cur))) return null
|
|
493
|
+
const p = join(cur, file)
|
|
494
|
+
if (existsSync(p)) return p
|
|
495
|
+
const parent = dirname(cur)
|
|
496
|
+
if (parent === cur) return null
|
|
497
|
+
cur = parent
|
|
498
|
+
}
|
|
499
|
+
return null
|
|
500
|
+
}
|
|
185
501
|
|
|
186
502
|
/** Raderna i sektionen under rubriken `heading`, fram till nästa rubrikrad. */
|
|
187
503
|
export const sectionLines = (block, heading) => {
|
|
@@ -192,3 +508,159 @@ export const sectionLines = (block, heading) => {
|
|
|
192
508
|
const end = rest.findIndex((l) => isHeadingLine(l))
|
|
193
509
|
return rest.slice(0, end === -1 ? rest.length : end)
|
|
194
510
|
}
|
|
511
|
+
|
|
512
|
+
/** Specifierns svar: specblock, vart och ett följt av sitt eget "## Till
|
|
513
|
+
* granskaren" (ADR-0118). Titel = rubrikrad som varken är specrubrik (något
|
|
514
|
+
* språk), kandidatmarkör eller granskarrubrik; varje titel startar en ny
|
|
515
|
+
* spec, ett granskarblock hör till specen den direkt följer. Gamla formatet
|
|
516
|
+
* (ett enda block sist efter flera specar) paras med alla specar och märks
|
|
517
|
+
* `delat`, så att `granskaBuntBlock` i korning.mjs kan fälla det som BRIST.
|
|
518
|
+
* Sonnet-orkestratorn gjorde detta i prosa; nu är det kod som precheck
|
|
519
|
+
* prövar resultatet av. */
|
|
520
|
+
export function delaSpecifierSvar(body) {
|
|
521
|
+
const lines = String(body).replace(/\r\n/g, '\n').split('\n')
|
|
522
|
+
const isGranskare = (l) => /^##\s+Till granskaren\s*$/u.test(l.trim())
|
|
523
|
+
const isSpecHeading = (l) => SPEC_HEADINGS.some((row) => headingRe(row.key).test(l.trim()))
|
|
524
|
+
const isTitle = (l) =>
|
|
525
|
+
isHeadingLine(l) && !isSpecHeading(l) && kandidatIn(l) === null && !isGranskare(l)
|
|
526
|
+
// En fristående fetstilsrad ("**...**", hela raden, inget efter): true om
|
|
527
|
+
// raden utgör en HEL fetstilsrad, med den inre texten (utan "**") som
|
|
528
|
+
// andra värdet. Skiljer en specTITEL ("**Chefer raderar...**", ingen
|
|
529
|
+
// avslutande kolon) från en granskarblocks ETIKETTRAD ("**Flaggor:**",
|
|
530
|
+
// avslutande kolon, värdet på nästa rad eller inget alls).
|
|
531
|
+
const fetstilsrad = (l) => {
|
|
532
|
+
const m = /^\*\*([^*]+)\*\*\s*$/u.exec(l.trim())
|
|
533
|
+
return m ? m[1].trim() : null
|
|
534
|
+
}
|
|
535
|
+
// En rad som avslutar ett ÖPPET granskarblock: (1) en rubrik av nivå 1
|
|
536
|
+
// eller 2 (## eller #) som inte själv är granskarrubriken, eller (2) en
|
|
537
|
+
// fristående fetstilsrad vars text INTE slutar på kolon - det är en ny
|
|
538
|
+
// spectitel (specformatet skriver specrubriker som fetstil, ADR-0114/
|
|
539
|
+
// specformat.md). En fristående fetstilsrad SOM slutar på kolon
|
|
540
|
+
// ("**Antaganden:**", "**Flaggor:**") är en granskarblocks etikettrad och
|
|
541
|
+
// hör till blockets EGET innehåll, liksom en djupare underrubrik
|
|
542
|
+
// (### Flaggor). Granskningsfynd 1: `isTitle` (all fetstil räknas som
|
|
543
|
+
// rubrik) körd INUTI blocket klippte av det vid varje etikettrad.
|
|
544
|
+
// Granskningsfynd 2 (regression i fixen för fynd 1): att bara tillåta
|
|
545
|
+
// #/##-rubriker missade att en bunts specTITLAR i praktiken är fetstil,
|
|
546
|
+
// inte markdown-rubriker - två bunt-specar med varsitt block kollapsade
|
|
547
|
+
// till en enda spec.
|
|
548
|
+
// Granskningsfynd 3 (M8, final-review): en fristående kandidatmarkör utan
|
|
549
|
+
// kolon ("**Koncept-kandidat**") är varken en granskarrubrik eller en
|
|
550
|
+
// specTITEL - den hör till blockets EGET innehåll, precis som `isTitle`
|
|
551
|
+
// redan undantar den från att räknas som en ny spec.
|
|
552
|
+
const avslutarBlock = (l) => {
|
|
553
|
+
if (isGranskare(l)) return false
|
|
554
|
+
if (kandidatIn(l) !== null) return false
|
|
555
|
+
if (/^#{1,2}\s+\S/.test(l.trim())) return true
|
|
556
|
+
const inre = fetstilsrad(l)
|
|
557
|
+
return inre !== null && !inre.endsWith(':')
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// Pass 1: hitta varje granskarblocks utsträckning (start till nästa
|
|
561
|
+
// blockavslutande rubrik eller granskarrubrik, annars slutet).
|
|
562
|
+
const granskareStarts = []
|
|
563
|
+
lines.forEach((l, i) => {
|
|
564
|
+
if (isGranskare(l)) granskareStarts.push(i)
|
|
565
|
+
})
|
|
566
|
+
const granskareRanges = granskareStarts.map((start) => {
|
|
567
|
+
let end = lines.length
|
|
568
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
569
|
+
if (avslutarBlock(lines[i]) || isGranskare(lines[i])) {
|
|
570
|
+
end = i
|
|
571
|
+
break
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
return { start, end, text: lines.slice(start, end).join('\n').trim() }
|
|
575
|
+
})
|
|
576
|
+
const iGranskareIntervall = (i) => granskareRanges.some((r) => i >= r.start && i < r.end)
|
|
577
|
+
|
|
578
|
+
// Pass 2: dela resten (utanför granskarblocken) i specar via titelrader.
|
|
579
|
+
const specar = []
|
|
580
|
+
let cur = null
|
|
581
|
+
let curStart = -1
|
|
582
|
+
const stang = () => {
|
|
583
|
+
if (cur) specar.push({ text: cur.join('\n').trim(), start: curStart })
|
|
584
|
+
cur = null
|
|
585
|
+
}
|
|
586
|
+
for (let i = 0; i < lines.length; i++) {
|
|
587
|
+
const l = lines[i]
|
|
588
|
+
if (iGranskareIntervall(i)) continue
|
|
589
|
+
if (l.trim() === '---') continue
|
|
590
|
+
if (isTitle(l)) {
|
|
591
|
+
stang()
|
|
592
|
+
cur = [l]
|
|
593
|
+
curStart = i
|
|
594
|
+
continue
|
|
595
|
+
}
|
|
596
|
+
if (cur === null) {
|
|
597
|
+
if (l.trim() === '') continue
|
|
598
|
+
cur = [l]
|
|
599
|
+
curStart = i
|
|
600
|
+
} else cur.push(l)
|
|
601
|
+
}
|
|
602
|
+
stang()
|
|
603
|
+
|
|
604
|
+
const medSpec = specar.filter((s) => s.text !== '')
|
|
605
|
+
|
|
606
|
+
// Para varje spec med granskarblocket som ligger direkt efter den (före
|
|
607
|
+
// nästa specs start).
|
|
608
|
+
const perSpec = medSpec.map((s, i) => {
|
|
609
|
+
const nasta = i + 1 < medSpec.length ? medSpec[i + 1].start : Infinity
|
|
610
|
+
const block = granskareRanges.find((r) => r.start > s.start && r.start < nasta)
|
|
611
|
+
return { spec: s.text, granskarblock: block ? block.text : null }
|
|
612
|
+
})
|
|
613
|
+
|
|
614
|
+
// Gamla formatet: fler än en spec, exakt ETT granskarblock i hela svaret,
|
|
615
|
+
// och det hör (strukturellt) bara till sista specen. Ett svar där varje
|
|
616
|
+
// spec redan bär sitt eget block ska INTE märkas delat.
|
|
617
|
+
const delat =
|
|
618
|
+
perSpec.length > 1 &&
|
|
619
|
+
granskareRanges.length === 1 &&
|
|
620
|
+
perSpec.filter((s) => s.granskarblock !== null).length === 1 &&
|
|
621
|
+
perSpec[perSpec.length - 1].granskarblock !== null
|
|
622
|
+
|
|
623
|
+
if (delat) {
|
|
624
|
+
const blockText = perSpec[perSpec.length - 1].granskarblock
|
|
625
|
+
return {
|
|
626
|
+
specar: perSpec.map((s) => ({ spec: s.spec, granskarblock: blockText })),
|
|
627
|
+
delat: true,
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
return { specar: perSpec, delat: false }
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/** DEPS-raden ur dispatch-kontraktet: `installerade` när node_modules/ finns
|
|
634
|
+
* under repo-roten, annars `saknas`. */
|
|
635
|
+
export const avgorDeps = (repoRot) =>
|
|
636
|
+
existsSync(join(repoRot, 'node_modules')) ? 'installerade' : 'saknas'
|
|
637
|
+
|
|
638
|
+
/** En fakturerad nyckel är begärd när den ÄR modellvärdet eller ett fullt
|
|
639
|
+
* id i samma familj (`opus` täcker `claude-opus-5`). Nyckeln kanoniseras av
|
|
640
|
+
* anroparen. */
|
|
641
|
+
export const matcharModell = (nyckel, modell) =>
|
|
642
|
+
nyckel === modell || nyckel === `claude-${modell}` || nyckel.startsWith(`claude-${modell}-`)
|
|
643
|
+
|
|
644
|
+
/** Schemaläggaren: högst `taket` tickets i flykt, nästa ticket startar när
|
|
645
|
+
* en är helt klar. `korTicket(ticket, index)` returnerar ett löfte som löses
|
|
646
|
+
* när ticketens samtliga modeller är klara. Ett fel i en ticket stoppar
|
|
647
|
+
* aldrig svepet (Batch: "Kör vidare vid fel"); det landar som `{ fel }` i
|
|
648
|
+
* resultatet. Resultaten kommer i ticketordning. */
|
|
649
|
+
export async function schemalagg(tickets, taket, korTicket) {
|
|
650
|
+
if (!Number.isInteger(taket) || taket < 1)
|
|
651
|
+
throw new Error(`taket måste vara ett heltal >= 1 (fick ${taket})`)
|
|
652
|
+
const resultat = new Array(tickets.length)
|
|
653
|
+
let nasta = 0
|
|
654
|
+
const arbetare = async () => {
|
|
655
|
+
while (nasta < tickets.length) {
|
|
656
|
+
const i = nasta++
|
|
657
|
+
try {
|
|
658
|
+
resultat[i] = await korTicket(tickets[i], i)
|
|
659
|
+
} catch (e) {
|
|
660
|
+
resultat[i] = { fel: e?.message ?? String(e) }
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
await Promise.all(Array.from({ length: Math.min(taket, tickets.length) }, arbetare))
|
|
665
|
+
return resultat
|
|
666
|
+
}
|