@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.
@@ -0,0 +1,1502 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Den inre processen i utvärdera-svepet, som script (plan requestmotor-korning,
4
+ * Task 3, ADR-0113). Fram till nu var den inre processen en sonnet-modell som
5
+ * läste 1300 rader SKILL.md och dispatchade tre agenter: 22 % av körningens
6
+ * kostnad för noll bedömningsbeslut, och protokollet bröts i precis de steg
7
+ * som bara var prosa. Här är samma flöde deterministisk kod: prompterna byggs
8
+ * ur filer på disk, varje agentsteg är en egen `claude -p --agent`-process med
9
+ * svaret RÅTT under `k<N>/steg/`, kontrollerna är scriptanrop och arkivet
10
+ * skrivs bara via `archive.mjs`. Ingen modelltur finns kvar i orkestreringen.
11
+ *
12
+ * Rollfördelningen (ADR-0092) håller: omdispatch sker ENBART på scriptbevisat
13
+ * brott, budgeten är EN omdispatch per agent och varv, och två körningar som
14
+ * skiljer sig (OLIKA) går till människan som `väntar på dom`, aldrig en
15
+ * tredje körning som röstar.
16
+ *
17
+ * Användning:
18
+ * node korning.mjs --katalog <abs k<N>> --exp <abs experimentkatalog> --repo-rot <abs>
19
+ * --request-id <gid eller slug> --modell <modell|default>
20
+ * [--claude <kommando>] [--delar-parallellt 2]
21
+ * [--steg-timeout 1800000]
22
+ *
23
+ * Läser `<exp>/experiment.json` (`sprak` OBLIGATORISK, ADR-0114: språket
24
+ * gissas aldrig), `<exp>/onskemal.md` och `<exp>/kallmanifest.md`. Skriver
25
+ * `steg/*.json`, spec/impact/prisfiler i körningskatalogen och `korning.json`;
26
+ * `experiment.json` och `state.json` rörs BARA via `archive.mjs`.
27
+ *
28
+ * stdout används inte (svep sparar stderr som `stderr.log`). Exit 0 = körningen
29
+ * gick igenom (status `klar` sätts av svep), 1 = minst en del blev ofärdig,
30
+ * 2 = användningsfel, 130 = avbruten med SIGTERM eller SIGINT.
31
+ *
32
+ * SIGTERM och SIGINT dödar varje pågående `claude`-process, skriver
33
+ * `korning.json` ur stegen så långt och lämnar körningen som `ofärdig`. Varje
34
+ * agentsteg har dessutom ett väggtidstak (`--steg-timeout`, 30 minuter): ett
35
+ * steg som hänger utan att svara kostar inga turer men blockerar körningen.
36
+ */
37
+ import {
38
+ existsSync,
39
+ mkdirSync,
40
+ readFileSync,
41
+ writeFileSync,
42
+ renameSync,
43
+ rmSync,
44
+ realpathSync,
45
+ } from 'node:fs'
46
+ import { spawn, spawnSync } from 'node:child_process'
47
+ import path from 'node:path'
48
+ import { fileURLToPath } from 'node:url'
49
+
50
+ import {
51
+ parseSvansrader,
52
+ delaSpecifierSvar,
53
+ matcharModell,
54
+ avgorDeps,
55
+ schemalagg,
56
+ markerStrippedSha256,
57
+ claudeDir,
58
+ canonicalModel,
59
+ SPEC_HEADINGS,
60
+ SCORE_OUTCOMES,
61
+ } from './lib.mjs'
62
+
63
+ const HAR = path.dirname(fileURLToPath(import.meta.url))
64
+ const ARCHIVE = path.join(HAR, 'archive.mjs')
65
+
66
+ /** Turtak per agent. Taken är generösa men ändliga: en agent som snurrar
67
+ * kostar pengar tills den dör, och en körning utan tak har inget slut. */
68
+ export const MAX_TURER = { specifier: 120, assessor: 200, scorer: 80 }
69
+
70
+ /** Modellen som skriver om kundtextfält i kontrollkedjan (kontroll.mjs
71
+ * --fixa-kundtext). Pinnad där, upprepad här bara som stegets modell_begard. */
72
+ const FIXMODELL = 'sonnet'
73
+ /** Ett hjälpanrop CLI:n själv gör (haiku, ett dussin output-tokens) är inte
74
+ * en obegärd modell. Taket skiljer hjälpanropet från en riktig körning. */
75
+ const HJALP_TAK = 200
76
+ const STANDARD_DELAR_PARALLELLT = 2
77
+ /** Väggtidstaket per agentsteg, 30 minuter. Turtaket ovan hindrar en agent som
78
+ * snurrar i turer, men inte en process som hänger utan att svara: den kostar
79
+ * ingenting i turer och allt i väggtid, och blockerar körningen för alltid.
80
+ * Taket är generöst, den längsta uppmätta assessorn låg under tio minuter. */
81
+ export const STANDARD_STEG_TIMEOUT = 1800000
82
+ /** Exitkoden efter ett signalavbrott, kutym för dödad av SIGINT. */
83
+ const SIGNAL_EXIT = 130
84
+ const VARDEFLAGGOR = [
85
+ 'katalog',
86
+ 'exp',
87
+ 'repo-rot',
88
+ 'request-id',
89
+ 'modell',
90
+ 'claude',
91
+ 'delar-parallellt',
92
+ 'steg-timeout',
93
+ ]
94
+
95
+ export const ANVANDNING = [
96
+ 'användning: korning.mjs --katalog <abs k<N>> --exp <abs experimentkatalog> --repo-rot <abs>',
97
+ ' --request-id <gid eller slug> --modell <modell|default>',
98
+ ` [--claude <kommando>] [--delar-parallellt ${STANDARD_DELAR_PARALLELLT}]`,
99
+ ` [--steg-timeout ${STANDARD_STEG_TIMEOUT}]`,
100
+ ].join('\n')
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // Rena hjälpare (testas in-process)
104
+ // ---------------------------------------------------------------------------
105
+
106
+ /**
107
+ * Verktygsurvalet per agent, en spegel av `tools:`-raden i
108
+ * `library/global/agents/request-*.md`. En headless `claude -p`-session nekar
109
+ * per default varje verktygsanrop som saknar en matchande allow-regel, och
110
+ * ingen kan svara på frågan: E2E:t (Task 7) förlorade 2,67 USD på att
111
+ * request-scorer nekades läsa sin egen `poangskala.md` och därför vägrade
112
+ * poängsätta, precis som dess kontrakt kräver. Listan sätts därför explicit i
113
+ * stället för att slå av behörighetslagret (`bypassPermissions` hade tillåtit
114
+ * allt, inklusive skrivningar in i arkivet som bara archive.mjs får göra).
115
+ * Assessorns "bara läsande git"-regel blir här mekanisk: fyra prefix, inte en
116
+ * mening i prosa som modellen kan tolka fel.
117
+ */
118
+ export const AGENT_VERKTYG = {
119
+ 'request-scorer': ['Read', 'Skill'],
120
+ 'request-specifier': ['Read', 'Grep', 'Glob', 'Skill'],
121
+ 'request-specifier-eskalerad': ['Read', 'Grep', 'Glob', 'Skill'],
122
+ 'request-assessor': [
123
+ 'Read',
124
+ 'Grep',
125
+ 'Glob',
126
+ 'Skill',
127
+ 'Bash(git ls-files:*)',
128
+ 'Bash(git check-ignore:*)',
129
+ 'Bash(git log:*)',
130
+ 'Bash(git grep:*)',
131
+ ],
132
+ }
133
+
134
+ /** Den pinnade headless-invokationen per agentsteg: agentdefinitionen ur
135
+ * ~/.claude/agents/ kör med sitt eget verktygsurval och sin egen effort,
136
+ * `--model` läggs på bara som override. `default` betyder "agentens egen
137
+ * modell" och får därför ingen flagga alls. `--add-dir` öppnar BARA
138
+ * skills-katalogen under Claude-konfigkatalogen: det är den agenten läser
139
+ * med Read (skillfiler och referensfiler ligger utanför repo-roten som är
140
+ * cwd), medan agentdefinitionen läses av CLI:n själv och aldrig via Read.
141
+ * Hela konfigkatalogen hade även öppnat inställningar, plugins och
142
+ * projektstate för läsning utan att något steg behöver det, och
143
+ * check-lasta släpper ändå bara igenom request-score-skillens egen katalog.
144
+ * `--allowedTools` ligger SIST: flaggan är variadisk och skulle annars svälja
145
+ * allt som kom efter den. Prompten går på stdin som förut. */
146
+ export const agentArgs = ({ agent, modell, maxTurer }) => [
147
+ '-p',
148
+ '--agent',
149
+ agent,
150
+ '--output-format',
151
+ 'json',
152
+ '--max-turns',
153
+ String(maxTurer),
154
+ '--add-dir',
155
+ path.join(claudeDir(), 'skills'),
156
+ ...(modell === 'default' ? [] : ['--model', modell]),
157
+ ...(AGENT_VERKTYG[agent] === undefined ? [] : ['--allowedTools', ...AGENT_VERKTYG[agent]]),
158
+ ]
159
+
160
+ /** RUBRIKER-blocket: de sju literalerna ur SPEC_HEADINGS på specens språk
161
+ * (ADR-0114). Tabellen bor i lib.mjs; här väljs bara kolumn. */
162
+ const rubrikrader = (sprak) => SPEC_HEADINGS.map((r) => `**${sprak === 'engelska' ? r.en : r.sv}**`)
163
+
164
+ /** Specifierns prompt, dispatch-kontraktets mall ordagrant. Ett okänt språk
165
+ * kastar: scriptet gissar aldrig rubrikspråk (IPC 14001 fällde tio utkast på
166
+ * just den gissningen). */
167
+ export function specifierPrompt({
168
+ onskemal,
169
+ manifest,
170
+ repoRot,
171
+ deps,
172
+ sprak,
173
+ eskalerad,
174
+ eskaleringsorsak = null,
175
+ }) {
176
+ if (sprak !== 'svenska' && sprak !== 'engelska')
177
+ throw new Error(`SPRÅK måste vara svenska eller engelska (fick "${sprak}")`)
178
+ return [
179
+ 'ÖNSKEMÅL:',
180
+ onskemal.trim(),
181
+ '',
182
+ 'KÄLLOR:',
183
+ manifest.trim(),
184
+ '',
185
+ `REPO-ROT: ${repoRot}`,
186
+ `DEPS: ${deps}`,
187
+ `SPRÅK: ${sprak}`,
188
+ 'RUBRIKER:',
189
+ ...rubrikrader(sprak),
190
+ `ESKALERAD: ${eskalerad ? (eskaleringsorsak ? `ja, ${eskaleringsorsak}` : 'ja') : 'nej'}`,
191
+ '',
192
+ ].join('\n')
193
+ }
194
+
195
+ /** Assessorns prompt. KÄLLOR ligger med (hävstång 5): analysen skrevs förr
196
+ * mot ett repo den fick leta i själv, medan manifestet låg färdigt bredvid. */
197
+ export function assessorPrompt({ repoRot, deps, spec, granskarblock, manifest }) {
198
+ return [
199
+ `REPO-ROT: ${repoRot}`,
200
+ `DEPS: ${deps}`,
201
+ 'KÄLLOR:',
202
+ manifest.trim(),
203
+ '',
204
+ 'SPEC (godkänd):',
205
+ spec.trim(),
206
+ '',
207
+ 'GRANSKARBLOCK (internt underlag, följer aldrig med i analysens kundtext):',
208
+ granskarblock.trim(),
209
+ '',
210
+ ].join('\n')
211
+ }
212
+
213
+ /** Scorerns prompt: underlaget per sökväg, aldrig inklistrat (ADR-0099). */
214
+ export function scorerPrompt({ requestId, delpost, specPath, impactPath }) {
215
+ return [
216
+ `REQUEST_ID: ${requestId}`,
217
+ 'MODE: pricing',
218
+ `DELPOST: ${delpost ? `ja, del ${delpost.part} av ${delpost.of}` : 'nej'}`,
219
+ '',
220
+ `SPEC: ${specPath}`,
221
+ `PÅVERKANSANALYS: ${impactPath}`,
222
+ '',
223
+ ].join('\n')
224
+ }
225
+
226
+ /** Omdispatchen: HELA ursprungsprompten oförändrad plus felraderna ordagrant.
227
+ * En ny subagent minns inget, enbart felrader utan underlag är obrukbart. */
228
+ export const omdispatchPrompt = (original, felrader, { endastSpec = null } = {}) =>
229
+ original +
230
+ '\n\nRÄTTNINGAR:\n' +
231
+ felrader.join('\n') +
232
+ (endastSpec ? '\nReturnera endast spec ' + endastSpec : '')
233
+
234
+ const raderMed = (stdout, prefix) =>
235
+ String(stdout)
236
+ .split('\n')
237
+ .filter((l) => l.startsWith(prefix))
238
+ .map((l) => l.slice(prefix.length).trim())
239
+
240
+ const bulletRader = (stdout) =>
241
+ String(stdout)
242
+ .split('\n')
243
+ .filter((l) => l.startsWith('- '))
244
+ .map((l) => l.trimEnd())
245
+
246
+ /** Felraderna efter den SISTA rubriken, aldrig varje "- "-rad i utskriften och
247
+ * aldrig efter den första rubriken. kontroll.mjs skriver först delscriptens
248
+ * hela stdout, som redan innehåller BÅDE deras egna `BROTT (n):`-rubriker och
249
+ * felraderna, och sedan sitt eget block med samma rader. Ett anrop mot
250
+ * check-lasta/contract-check ger alltså två rubriker; bara den sista är
251
+ * kontrollens egen sammanställning. Rökprovet 2026-09-02 gav varje brott
252
+ * dubbelt i runs-radens note och i omdispatchen, först för att raderna
253
+ * räknades utan rubrik alls, sedan för att slicen började vid ekot. */
254
+ const raderEfter = (stdout, rubrikRe) => {
255
+ const rader = String(stdout).split('\n')
256
+ const i = rader.findLastIndex((l) => rubrikRe.test(l))
257
+ return i === -1 ? [] : bulletRader(rader.slice(i + 1).join('\n'))
258
+ }
259
+
260
+ /** precheck.mjs stdout: KANDIDAT, AVVIKELSE, VARNING, sedan OK eller
261
+ * BRISTER (n) + "- "-rader. */
262
+ export function tolkaPrecheck(stdout, status) {
263
+ const kandidater = raderMed(stdout, 'KANDIDAT: ')
264
+ const avvikelser = raderMed(stdout, 'AVVIKELSE: ')
265
+ return {
266
+ ok: status === 0,
267
+ kandidat: kandidater[0] ?? null,
268
+ avvikelse: avvikelser[0] ?? null,
269
+ brister: raderEfter(stdout, /^BRISTER \(\d+\):/),
270
+ varningar: raderMed(stdout, 'VARNING: '),
271
+ prisavgorande: raderMed(stdout, 'PRISAVGORANDE: ')[0] ?? null,
272
+ }
273
+ }
274
+
275
+ /** kontroll.mjs stdout: MODELL, LÄSTA, ev. AVVIKELSE, REPARERAT (0..n),
276
+ * kedjans rader, sist OK eller BROTT (n) + "- "-rader. Exit 2 är
277
+ * dispatcherfel (ett delscript föll), aldrig ett modellfel. */
278
+ export function tolkaKontroll(stdout, status) {
279
+ // kontroll.mjs skriver `MODELL: -` när svansraden saknas. Strecket är dess
280
+ // platshållare för "saknas", aldrig ett värde: stämplas det vidare hamnar
281
+ // `--model -` i runs-raden i stället för "okänd".
282
+ const forsta = (prefix) => {
283
+ const v = raderMed(stdout, prefix)[0]
284
+ return v === undefined || v === '-' ? null : v
285
+ }
286
+ return {
287
+ ok: status === 0,
288
+ exit2: status === 2,
289
+ modell: forsta('MODELL: '),
290
+ lasta: forsta('LÄSTA: '),
291
+ avvikelse: forsta('AVVIKELSE: '),
292
+ reparerat: raderMed(stdout, 'REPARERAT: '),
293
+ brott: status === 0 ? [] : raderEfter(stdout, /^BROTT \(\d+\):/),
294
+ }
295
+ }
296
+
297
+ /** compare-scores.mjs första rad: LIKA | LIKA_TOLERANS: högsta gäller <p>
298
+ * (körning <1|2>) | OLIKA (n):. Exit 2 = jämförelsen kunde inte göras. */
299
+ export function tolkaCompare(stdout, status) {
300
+ const rader = bulletRader(stdout)
301
+ if (status === 2) return { verdikt: 'FEL', vinnare: 1, rader }
302
+ const forsta = String(stdout).split('\n')[0] ?? ''
303
+ if (forsta.startsWith('LIKA_TOLERANS')) {
304
+ const m = /\(körning (\d)\)/u.exec(forsta)
305
+ return { verdikt: 'LIKA_TOLERANS', vinnare: m ? Number(m[1]) : 1, rader }
306
+ }
307
+ if (forsta.startsWith('OLIKA')) return { verdikt: 'OLIKA', vinnare: 1, rader }
308
+ if (forsta.startsWith('LIKA')) return { verdikt: 'LIKA', vinnare: 1, rader }
309
+ return { verdikt: 'FEL', vinnare: 1, rader }
310
+ }
311
+
312
+ /** Modellverifiering per STEG mot faktureringen (ADR-0099). Steget kördes med
313
+ * en modell-override, alltså måste minst en fakturerad nyckel vara den
314
+ * begärda modellen; övriga nycklar får bara vara CLI:ns eget haiku-hjälpanrop
315
+ * (ett dussin output-tokens). Ett STORT haiku-anrop är inget hjälpanrop utan
316
+ * en riktig körning på fel modell. `default` kör agentens egen modell och
317
+ * prövas aldrig. */
318
+ export function verifieraSteg(modelUsage, modellBegard, { hjalpTak = HJALP_TAK } = {}) {
319
+ if (modellBegard === 'default') return { ok: true }
320
+ const poster = Object.entries(modelUsage ?? {}).map(([nyckel, v]) => [canonicalModel(nyckel), v])
321
+ const begarda = poster.filter(([k]) => matcharModell(k, modellBegard))
322
+ if (begarda.length === 0)
323
+ return {
324
+ ok: false,
325
+ orsak: `MODELL-avvikelse: ingen fakturerad nyckel är ${modellBegard} (${poster.map(([k]) => k).join(', ') || 'inga nycklar'})`,
326
+ }
327
+ const obegarda = poster
328
+ .filter(([k]) => !matcharModell(k, modellBegard))
329
+ .filter(([k, v]) => !(matcharModell(k, 'haiku') && (v?.outputTokens ?? 0) <= hjalpTak))
330
+ .map(([k]) => k)
331
+ if (obegarda.length > 0) return { ok: false, orsak: `MODELL-avvikelse: ${obegarda.join(', ')}` }
332
+ return { ok: true }
333
+ }
334
+
335
+ const USAGE_FALT = [
336
+ 'input_tokens',
337
+ 'output_tokens',
338
+ 'cache_read_input_tokens',
339
+ 'cache_creation_input_tokens',
340
+ ]
341
+ const MODELUSAGE_FALT = [
342
+ 'inputTokens',
343
+ 'outputTokens',
344
+ 'cacheReadInputTokens',
345
+ 'cacheCreationInputTokens',
346
+ 'costUSD',
347
+ ]
348
+
349
+ const summera = (falt, mal, kalla) =>
350
+ Object.fromEntries(falt.map((f) => [f, (mal?.[f] ?? 0) + (kalla?.[f] ?? 0)]))
351
+
352
+ /** Körningens summa över stegen, i de fältnamn report.mjs och
353
+ * `archive.mjs proveniens` läser. En körning som föll har en verklig
354
+ * kostnad, så summan skrivs även då; ett steg utan svar räknas som noll men
355
+ * listas, annars försvinner det ur räkningen av dispatchade steg. */
356
+ export function slaIhopKorning(steg) {
357
+ let total = 0
358
+ let usage = summera(USAGE_FALT, null, null)
359
+ let modelUsage = {}
360
+ let turer = 0
361
+ let apiMs = 0
362
+ const avvikelser = []
363
+ for (const s of steg) {
364
+ const svar = s.svar
365
+ if (!svar) continue
366
+ total += svar.total_cost_usd ?? 0
367
+ usage = summera(USAGE_FALT, usage, svar.usage)
368
+ for (const [nyckel, v] of Object.entries(svar.modelUsage ?? {}))
369
+ modelUsage = { ...modelUsage, [nyckel]: summera(MODELUSAGE_FALT, modelUsage[nyckel], v) }
370
+ turer += svar.num_turns ?? 0
371
+ apiMs += svar.duration_api_ms ?? 0
372
+ const v = verifieraSteg(svar.modelUsage, s.modell_begard)
373
+ if (!v.ok) avvikelser.push(`${s.fil}: ${v.orsak}`)
374
+ }
375
+ return {
376
+ korningsmotor: 'korning.mjs',
377
+ total_cost_usd: total,
378
+ usage,
379
+ modelUsage,
380
+ num_turns: turer,
381
+ duration_api_ms: apiMs,
382
+ subagent_stats: { spawned: steg.length },
383
+ modellverifiering: { ok: avvikelser.length === 0, avvikelser },
384
+ steg: steg.map((s) => ({
385
+ fil: s.fil,
386
+ agent: s.agent,
387
+ modell_begard: s.modell_begard,
388
+ total_cost_usd: s.svar?.total_cost_usd ?? 0,
389
+ usage: summera(USAGE_FALT, null, s.svar?.usage),
390
+ num_turns: s.svar?.num_turns ?? 0,
391
+ wall_ms: s.wall_ms ?? null,
392
+ is_error: s.svar ? s.svar.is_error === true : true,
393
+ })),
394
+ }
395
+ }
396
+
397
+ /** Körningens status ur delarnas: den allvarligaste vinner. `ofärdig` är
398
+ * avbruten data, `väntar på dom` kräver en människa, `stopp` är ett resultat.
399
+ * `null` betyder att svep får sätta `klar`. */
400
+ export function sammanvagdStatus(delStatusar) {
401
+ for (const s of ['ofärdig', 'väntar på dom', 'stopp']) if (delStatusar.includes(s)) return s
402
+ return null
403
+ }
404
+
405
+ const DOUBLE_RUN = { LIKA: 'bekräftad', LIKA_TOLERANS: 'tolerans', OLIKA: 'olika' }
406
+
407
+ /** Filnamnen för ett arkiveringsvarv: huvudfilen bär den gällande poängen,
408
+ * den andra körningen läggs som `-run2`. Vid tolerans är vinnaren den högsta
409
+ * poängen, inte körning 1. */
410
+ export const filnamnForVarv = ({ n, verdikt, vinnare }) => ({
411
+ huvud: `score-pricing-v${n}.json`,
412
+ run2: `score-pricing-v${n}-run2.json`,
413
+ huvudAr: vinnare,
414
+ doubleRun: DOUBLE_RUN[verdikt] ?? 'olika',
415
+ })
416
+
417
+ /** Ett stopputfall är allt utom POANG: posten får inget pris och går inte
418
+ * vidare (ADR-0098). */
419
+ export const arStopputfall = (outcome) => SCORE_OUTCOMES.includes(outcome) && outcome !== 'POANG'
420
+
421
+ /** Stoppet efter formkontrollen i speca (ADR-0105, ADR-0118): en kandidat
422
+ * routas till sitt utfall; ett prisavgörande öppet val går tillbaka till
423
+ * specen en gång och därefter till ESKALERA, aldrig en tredje specrunda. */
424
+ export const routaSpecStopp = ({ kandidat, prisavgorande, respecVarv }) => {
425
+ if (kandidat) return { utfall: kandidat, kalla: 'spec.md', orsak: null }
426
+ if (prisavgorande === null || prisavgorande === undefined) return null
427
+ if ((respecVarv ?? 0) >= 1)
428
+ return {
429
+ utfall: 'ESKALERA',
430
+ kalla: 'granskarblock.md',
431
+ orsak: `prisavgörande val kvarstår efter respec: ${prisavgorande}`,
432
+ }
433
+ return { utfall: 'TILLBAKA_TILL_SPEC', kalla: 'granskarblock.md', orsak: prisavgorande }
434
+ }
435
+
436
+ /** Brottraderna för EN scorerkörning. En kedja utan brottrader är ändå fallen
437
+ * i två fall, och båda måste bära en rad, annars räknas körningen som giltig
438
+ * och arkiveras som gällande pris. Utan ren JSON finns ingen `doc.outcome`
439
+ * att prissätta. Och en kedja som slutade skilt från 0 (`ok === false`) utan
440
+ * ett tolkbart BROTT-block har kraschat i sig själv: en grind som failar
441
+ * öppet när den själv går sönder är ingen grind. */
442
+ export function scorerBrott(brott, { doc, ok }) {
443
+ if (brott.length > 0) return brott
444
+ if (doc === null) return ['- kontrollkedjan lämnade ingen ren JSON']
445
+ if (ok === false) return ['- kontrollkedjan föll utan tolkbart BROTT-block']
446
+ return []
447
+ }
448
+
449
+ /** Dubbelkörningen räknas för varje prisbärande utfall: POANG >= 1 (ADR-0098)
450
+ * och OVER_SKALAN, som skickar posten till manuell offert på en enda körning
451
+ * om den inte prövas två gånger (ADR-0116, #503). DELA_UPP körs om per del. */
452
+ export const behoverDubbelkorning = (doc) =>
453
+ doc?.outcome === 'OVER_SKALAN' ||
454
+ (doc?.outcome === 'POANG' && typeof doc?.score === 'number' && doc.score >= 1)
455
+
456
+ // ---------------------------------------------------------------------------
457
+ // Mekaniken (verifieras i rökprovet och i E2E, aldrig mot ett fejkat claude)
458
+ // ---------------------------------------------------------------------------
459
+
460
+ /** All loggning går på stderr; stdout används inte alls, svep sparar stderr
461
+ * som `stderr.log` bredvid körningen. */
462
+ const logg = (rad) =>
463
+ process.stderr.write(`[korning] ${new Date().toISOString().slice(11, 19)} ${rad}\n`)
464
+
465
+ const arkiv = (args, ctx) =>
466
+ spawnSync(process.execPath, [ARCHIVE, ...args], { encoding: 'utf8', cwd: ctx.repoRot })
467
+ const script = (namn, args, ctx) =>
468
+ spawnSync(process.execPath, [path.join(HAR, namn), ...args], {
469
+ encoding: 'utf8',
470
+ cwd: ctx.repoRot,
471
+ })
472
+ const felText = (r) => (r.stderr || r.stdout || '').trim() || `exit ${r.status}`
473
+ const idag = () => new Date().toISOString().slice(0, 10)
474
+ const lasFil = (p) => {
475
+ try {
476
+ return readFileSync(p, 'utf8')
477
+ } catch {
478
+ return null
479
+ }
480
+ }
481
+
482
+ /** Argumenttolkning, fail-closed som svep.mjs och kontroll.mjs: alla saknade
483
+ * flaggor namnges på en gång, en okänd flagga eller ett värdelöst flaggvärde
484
+ * stoppar direkt i stället för att gissa. */
485
+ function tolkaArgv(argv) {
486
+ const enkla = {}
487
+ for (let i = 0; i < argv.length; i++) {
488
+ const a = argv[i]
489
+ if (!a.startsWith('--')) return { fel: `oväntat argument: ${a}` }
490
+ const namn = a.slice(2)
491
+ if (!VARDEFLAGGOR.includes(namn)) return { fel: `okänd flagga: --${namn}` }
492
+ const v = argv[i + 1]
493
+ if (v === undefined || v.startsWith('--')) return { fel: `--${namn} kräver ett värde` }
494
+ i++
495
+ enkla[namn] = v
496
+ }
497
+ const obligatoriska = ['katalog', 'exp', 'repo-rot', 'request-id', 'modell']
498
+ const saknas = obligatoriska.filter((n) => enkla[n] === undefined)
499
+ if (saknas.length > 0) return { fel: `saknar ${saknas.map((n) => `--${n}`).join(', ')}` }
500
+ const relativa = ['katalog', 'exp', 'repo-rot'].filter((n) => !path.isAbsolute(enkla[n]))
501
+ if (relativa.length > 0)
502
+ return { fel: `${relativa.map((n) => `--${n}`).join(', ')} måste vara absoluta sökvägar` }
503
+ const takText = enkla['delar-parallellt'] ?? String(STANDARD_DELAR_PARALLELLT)
504
+ if (!/^\d+$/.test(takText) || Number(takText) < 1)
505
+ return { fel: `--delar-parallellt måste vara ett heltal >= 1 (fick "${takText}")` }
506
+ const timeoutText = enkla['steg-timeout'] ?? String(STANDARD_STEG_TIMEOUT)
507
+ if (!/^\d+$/.test(timeoutText) || Number(timeoutText) < 1)
508
+ return { fel: `--steg-timeout måste vara ett heltal >= 1 (fick "${timeoutText}")` }
509
+ return {
510
+ katalog: enkla.katalog,
511
+ exp: enkla.exp,
512
+ repoRot: enkla['repo-rot'],
513
+ requestId: enkla['request-id'],
514
+ modell: enkla.modell,
515
+ claude: enkla.claude ?? 'claude',
516
+ delarParallellt: Number(takText),
517
+ stegTimeout: Number(timeoutText),
518
+ }
519
+ }
520
+
521
+ /** Underlaget på disk. Språket är OBLIGATORISKT (ADR-0114): utan det finns
522
+ * ingen rubrikuppsättning att be om, och en gissning fäller hela specvarvet. */
523
+ function lasUnderlag(val) {
524
+ let experiment
525
+ try {
526
+ experiment = JSON.parse(readFileSync(path.join(val.exp, 'experiment.json'), 'utf8'))
527
+ } catch (e) {
528
+ return { fel: `kunde inte läsa ${path.join(val.exp, 'experiment.json')}: ${e.message}` }
529
+ }
530
+ const sprak = experiment?.sprak
531
+ if (sprak !== 'svenska' && sprak !== 'engelska')
532
+ return {
533
+ fel: 'sprak saknas i experiment.json, sätt med archive.mjs experiment --satt sprak=svenska|engelska',
534
+ }
535
+ const onskemal = lasFil(path.join(val.exp, 'onskemal.md'))
536
+ const manifest = lasFil(path.join(val.exp, 'kallmanifest.md'))
537
+ const saknas = [
538
+ ...(onskemal === null ? ['onskemal.md'] : []),
539
+ ...(manifest === null ? ['kallmanifest.md'] : []),
540
+ ]
541
+ if (saknas.length > 0) return { fel: `${val.exp} saknar ${saknas.join(', ')}` }
542
+ if (!existsSync(path.join(val.katalog, 'state.json')))
543
+ return {
544
+ fel: `${val.katalog} saknar state.json. Körningskatalogen skapas av archive.mjs ny-korning`,
545
+ }
546
+ return {
547
+ onskemal,
548
+ manifest,
549
+ sprak,
550
+ eskalerad: experiment.eskalerad === true,
551
+ eskaleringsorsak:
552
+ typeof experiment.eskaleringsorsak === 'string' ? experiment.eskaleringsorsak : null,
553
+ deps: avgorDeps(val.repoRot),
554
+ }
555
+ }
556
+
557
+ /** Orsaken till att ett agentsteg räknas som fallet, eller null. Ordningen är
558
+ * från grövst till finast: en process som inte startade säger inget om
559
+ * modellen, en tom text säger inget om innehållet. `tidsgransMs` sätts när
560
+ * steget dödades av väggtidstaket och kommer före exitkoden: koden är då
561
+ * bara spåret efter vår egen SIGTERM och döljer den verkliga orsaken. */
562
+ export const stegOrsak = ({ spawnfel, tidsgransMs = null, exitCode, svar, text, verifiering }) => {
563
+ if (spawnfel) return `processen startade inte: ${spawnfel}`
564
+ if (tidsgransMs !== null) return `tidsgräns ${tidsgransMs} ms`
565
+ if (exitCode !== 0) return `exit ${exitCode === null ? 'okänd' : exitCode}`
566
+ if (svar === null) return 'svaret gick inte att tolka som JSON'
567
+ if (svar.is_error === true)
568
+ return `CLI:n rapporterade is_error (${String(svar.result ?? '').slice(0, 200)})`
569
+ if (text.trim() === '') return 'tomt svar'
570
+ if (!verifiering.ok) return verifiering.orsak
571
+ return null
572
+ }
573
+
574
+ /** Stegets råa stdout tolkad: filen skrivs (även vid fel; tom stdout skriver
575
+ * ingen fil), JSON parsas, modellen verifieras och orsaken avgörs. */
576
+ function tolkaSteg({ stdout, stegFil, modell, spawnfel, tidsgransMs, exitCode }) {
577
+ if (stdout.length > 0) writeFileSync(stegFil, stdout)
578
+ let svar = null
579
+ try {
580
+ svar = JSON.parse(stdout)
581
+ } catch {
582
+ svar = null
583
+ }
584
+ const text = typeof svar?.result === 'string' ? svar.result : ''
585
+ const verifiering = verifieraSteg(svar?.modelUsage, modell)
586
+ return {
587
+ svar,
588
+ text,
589
+ verifiering,
590
+ orsak: stegOrsak({ spawnfel, tidsgransMs, exitCode, svar, text, verifiering }),
591
+ }
592
+ }
593
+
594
+ /** Ett agentsteg: en egen `claude -p --agent`-process med prompten på stdin,
595
+ * svaret RÅTT i `steg/<fil>` och klockan runt den. Barnet läggs i `ctx.barn`
596
+ * så signalhanteraren kan döda det: en dödad korning.mjs får aldrig lämna
597
+ * betalda `claude`-processer efter sig. Väggtidstaket (`--steg-timeout`)
598
+ * dödar ett steg som hänger utan att svara. Anroparen avgör vad ett fallet
599
+ * steg betyder. */
600
+ function korAgent({ agent, modell, prompt, fil, maxTurer, ctx }) {
601
+ return new Promise((los) => {
602
+ const start = Date.now()
603
+ const stegFil = path.join(ctx.katalog, fil)
604
+ let stdout = ''
605
+ let stderr = ''
606
+ let klar = false
607
+ let tidsgransMs = null
608
+ let timer = null
609
+ const avsluta = ({ exitCode, spawnfel = null }) => {
610
+ if (klar) return
611
+ klar = true
612
+ if (timer !== null) clearTimeout(timer)
613
+ const wall_ms = Date.now() - start
614
+ const t = tolkaSteg({ stdout, stegFil, modell, spawnfel, tidsgransMs, exitCode })
615
+ ctx.samla({ fil, agent, modell_begard: modell, svar: t.svar, wall_ms })
616
+ if (t.orsak !== null && stderr.trim() !== '')
617
+ logg(`${fil} stderr: ${stderr.trim().slice(0, 200)}`)
618
+ logg(
619
+ `STEG ${fil} ${agent} ${modell} ${wall_ms}ms ${t.orsak === null ? 'ok' : `fallet: ${t.orsak}`}`,
620
+ )
621
+ los({ ...t, exitCode, wall_ms, ok: t.orsak === null })
622
+ }
623
+ let barn
624
+ try {
625
+ barn = spawn(ctx.claude, agentArgs({ agent, modell, maxTurer }), {
626
+ cwd: ctx.repoRot,
627
+ stdio: ['pipe', 'pipe', 'pipe'],
628
+ })
629
+ } catch (e) {
630
+ avsluta({ exitCode: null, spawnfel: e.message })
631
+ return
632
+ }
633
+ ctx.barn.add(barn)
634
+ timer = setTimeout(() => {
635
+ tidsgransMs = ctx.stegTimeout
636
+ logg(`STEG ${fil} dödas: tidsgräns ${ctx.stegTimeout} ms`)
637
+ barn.kill('SIGTERM')
638
+ }, ctx.stegTimeout)
639
+ barn.stdout.on('data', (d) => {
640
+ stdout += d
641
+ })
642
+ barn.stderr.on('data', (d) => {
643
+ stderr += d
644
+ })
645
+ barn.on('error', (e) => {
646
+ ctx.barn.delete(barn)
647
+ avsluta({ exitCode: null, spawnfel: e.message })
648
+ })
649
+ barn.on('close', (code) => {
650
+ ctx.barn.delete(barn)
651
+ avsluta({ exitCode: code })
652
+ })
653
+ barn.stdin.on('error', () => {})
654
+ barn.stdin.end(prompt)
655
+ })
656
+ }
657
+
658
+ /** Den fakturerade nyckeln för ETT steg, ur stegets eget modelUsage (ADR-0113; #510 a). addRun skickar den som --fakturerad. */
659
+ export function fakturerad(svar, modell) {
660
+ const nycklar = Object.keys(svar?.modelUsage ?? {}).map(canonicalModel)
661
+ if (modell === 'default')
662
+ return nycklar.find((k) => !matcharModell(k, 'haiku')) ?? nycklar[0] ?? null
663
+ return nycklar.find((k) => matcharModell(k, modell)) ?? null
664
+ }
665
+
666
+ /** `--ref <namn>=<hash>` över en referensfil i Claude-konfigkatalogen. En fil
667
+ * som inte går att läsa ger INGEN flagga: en påhittad hash ser lika giltig ut
668
+ * som en riktig. */
669
+ function refFlagga(relSokvag, namn) {
670
+ try {
671
+ return ['--ref', `${namn}=${markerStrippedSha256(path.join(claudeDir(), relSokvag))}`]
672
+ } catch (e) {
673
+ logg(`VARNING kunde inte hasha ${relSokvag}: ${e.message}. Runs-raden får ingen --ref`)
674
+ return []
675
+ }
676
+ }
677
+
678
+ const SPEC_REF = () =>
679
+ refFlagga('skills/request-write-spec/references/specformat.md', 'references/specformat.md')
680
+ const SCORE_REF = () => refFlagga('skills/request-score/references/poangskala.md', 'poangskala.md')
681
+
682
+ /** En runs-rad. Arkivet är append-only och äger sina egna vägransregler; en
683
+ * vägrad stämpling loggas men fäller aldrig körningen, kvittot på vad som
684
+ * hände ligger kvar i steg/. */
685
+ function addRun(dir, rad, ctx) {
686
+ const r = arkiv(
687
+ [
688
+ 'add-run',
689
+ dir,
690
+ '--file',
691
+ rad.fil,
692
+ '--skill',
693
+ rad.skill,
694
+ '--agent',
695
+ rad.agent,
696
+ '--model',
697
+ rad.model ?? 'okänd',
698
+ ...(rad.fakt ? ['--fakturerad', rad.fakt] : []),
699
+ ...(rad.ref ?? []),
700
+ ...(rad.note ? ['--note', rad.note] : []),
701
+ ...(rad.doubleRun ? ['--double-run', rad.doubleRun] : []),
702
+ ...(rad.supersededBy ? ['--superseded-by', rad.supersededBy] : []),
703
+ ],
704
+ ctx,
705
+ )
706
+ if (r.status !== 0) logg(`VARNING add-run ${rad.fil}: ${felText(r)}`)
707
+ return r.status === 0
708
+ }
709
+
710
+ /** Ett ledigt filnamn: en andra avvisad läggning samma dag får -2, aldrig
711
+ * samma namn som den första (arkivet skriver aldrig över ett kvitto). */
712
+ function ledigtNamn(dir, bas, ext) {
713
+ let namn = `${bas}${ext}`
714
+ for (let i = 2; existsSync(path.join(dir, namn)); i++) namn = `${bas}-${i}${ext}`
715
+ return namn
716
+ }
717
+
718
+ /** Ett förkastat textsteg bokförs alltid (ADR-0099): dokumentet döps om med
719
+ * `-avvisad-<datum>` och får en egen runs-rad med kontrollens felrader. */
720
+ function avvisaDokument(dir, bas, brister, rad, ctx) {
721
+ const namn = ledigtNamn(dir, `${bas}-avvisad-${idag()}`, '.md')
722
+ try {
723
+ renameSync(path.join(dir, `${bas}.md`), path.join(dir, namn))
724
+ } catch (e) {
725
+ logg(`VARNING kunde inte döpa om ${bas}.md: ${e.message}`)
726
+ return null
727
+ }
728
+ addRun(dir, { ...rad, fil: namn, note: brister.map((b) => b.replace(/^- /, '')).join('; ') }, ctx)
729
+ return namn
730
+ }
731
+
732
+ /** Delkatalogerna för en bunt: skapas av `archive.mjs ny-del`, aldrig av den
733
+ * inre processen själv (buntregeln i Experimentytan, ADR-0107 beslut 4).
734
+ * Del 1 ligger ALDRIG i körningsroten. */
735
+ function skapaDelar(antal, ctx) {
736
+ const delar = []
737
+ for (let i = 1; i <= antal; i++) {
738
+ const r = arkiv(
739
+ [
740
+ 'ny-del',
741
+ ctx.katalog,
742
+ '--typ',
743
+ 'bundle',
744
+ '--part',
745
+ String(i),
746
+ '--of',
747
+ String(antal),
748
+ '--request-id',
749
+ ctx.requestId,
750
+ ],
751
+ ctx,
752
+ )
753
+ if (r.status !== 0) return { fel: `ny-del ${i}/${antal}: ${felText(r)}` }
754
+ delar.push({ dir: r.stdout.trim(), delpost: { part: i, of: antal }, nr: i })
755
+ }
756
+ return { delar }
757
+ }
758
+
759
+ /** Granskarblocken i ett specifiersvar (ADR-0118): ett per spec. Ett delat
760
+ * block över en bunt och ett saknat block är brister (omdispatch); två delar
761
+ * med bytevis lika block är en varning, två små delar kan legitimt ha
762
+ * tom/inga/nej rakt igenom. */
763
+ export const granskaBuntBlock = ({ specar, delat }) => {
764
+ const brister = []
765
+ const varningar = []
766
+ if (delat && specar.length > 1)
767
+ brister.push(
768
+ '- granskarblock delat över bunten, ett per spec krävs: skriv "## Till granskaren" direkt efter varje spec',
769
+ )
770
+ specar.forEach((s, i) => {
771
+ if (s.granskarblock === null) brister.push(`- spec ${i + 1} saknar Till granskaren-block`)
772
+ })
773
+ for (let i = 0; i < specar.length; i++)
774
+ for (let j = i + 1; j < specar.length; j++)
775
+ if (specar[i].granskarblock !== null && specar[i].granskarblock === specar[j].granskarblock)
776
+ varningar.push(`del ${i + 1} och del ${j + 1} har identiska granskarblock`)
777
+ return { brister, varningar }
778
+ }
779
+
780
+ /** Specifiervarvet: ett agentsteg, uppdelning i specar, delkataloger vid bunt
781
+ * och en stämplad spec.md per del. Returnerar `{ fel }` när körningen inte
782
+ * kan fortsätta. */
783
+ async function korSpecifier(ctx) {
784
+ const agent = ctx.eskalerad ? 'request-specifier-eskalerad' : 'request-specifier'
785
+ const prompt = specifierPrompt({
786
+ onskemal: ctx.onskemal,
787
+ manifest: ctx.manifest,
788
+ repoRot: ctx.repoRot,
789
+ deps: ctx.deps,
790
+ sprak: ctx.sprak,
791
+ eskalerad: ctx.eskalerad,
792
+ eskaleringsorsak: ctx.eskaleringsorsak,
793
+ })
794
+ const res = await korAgent({
795
+ agent,
796
+ modell: ctx.modell,
797
+ prompt,
798
+ fil: 'steg/01-specifier.json',
799
+ maxTurer: MAX_TURER.specifier,
800
+ ctx,
801
+ })
802
+ if (!res.ok) return { fel: `specifier: ${res.orsak}` }
803
+ let { body, modell: svansModell } = parseSvansrader(res.text)
804
+ let uppdelat = delaSpecifierSvar(body)
805
+ if (uppdelat.specar.length === 0) return { fel: 'specifier: svaret innehåller ingen spec' }
806
+ let block = granskaBuntBlock(uppdelat)
807
+ for (const v of block.varningar) logg(`VARNING specifier: ${v}`)
808
+ let meta = { agent, svansModell, fakt: fakturerad(res.svar, ctx.modell) }
809
+ // Bristerna (delat block, saknat block) rättas med EN omdispatch av HELA
810
+ // specifierprompten: inget är skrivet ännu, så det finns inget att peka
811
+ // om per del (ADR-0118).
812
+ if (block.brister.length > 0) {
813
+ const omRes = await korAgent({
814
+ agent,
815
+ modell: ctx.modell,
816
+ prompt: omdispatchPrompt(prompt, block.brister),
817
+ fil: 'steg/01b-specifier-omdispatch.json',
818
+ maxTurer: MAX_TURER.specifier,
819
+ ctx,
820
+ })
821
+ if (!omRes.ok) return { fel: `specifier: ${omRes.orsak}` }
822
+ ;({ body, modell: svansModell } = parseSvansrader(omRes.text))
823
+ uppdelat = delaSpecifierSvar(body)
824
+ if (uppdelat.specar.length === 0)
825
+ return { fel: 'specifier: omdispatchen innehåller ingen spec' }
826
+ block = granskaBuntBlock(uppdelat)
827
+ for (const v of block.varningar) logg(`VARNING specifier: ${v}`)
828
+ if (block.brister.length > 0)
829
+ return { fel: `specifier: ${block.brister.map((b) => b.replace(/^- /, '')).join('; ')}` }
830
+ meta = { agent, svansModell, fakt: fakturerad(omRes.svar, ctx.modell) }
831
+ }
832
+ const { specar } = uppdelat
833
+ const uppdelning =
834
+ specar.length === 1
835
+ ? { delar: [{ dir: ctx.katalog, delpost: null, nr: 1 }] }
836
+ : skapaDelar(specar.length, ctx)
837
+ if (uppdelning.fel) return { fel: uppdelning.fel }
838
+ for (const [i, del] of uppdelning.delar.entries())
839
+ skrivSpec(del, specar[i].spec, specar[i].granskarblock, meta, ctx)
840
+ logg(`SPECIFIER ${specar.length} spec(ar), ${uppdelning.delar.length} del(ar)`)
841
+ return { delar: uppdelning.delar, prompt, agent, meta }
842
+ }
843
+
844
+ /** spec.md + granskarblock.md i en del, med runs-raden. Granskarblocket är
845
+ * internt underlag och stämplas inte: bara arkivkontraktets filer får rader. */
846
+ function skrivSpec(del, spec, granskarblock, meta, ctx) {
847
+ writeFileSync(path.join(del.dir, 'spec.md'), spec.trim() + '\n')
848
+ if (granskarblock)
849
+ writeFileSync(path.join(del.dir, 'granskarblock.md'), granskarblock.trim() + '\n')
850
+ else logg(`VARNING ${path.basename(del.dir)}: svaret bar inget Till granskaren-block`)
851
+ addRun(
852
+ del.dir,
853
+ {
854
+ fil: 'spec.md',
855
+ skill: 'request-write-spec',
856
+ agent: meta.agent,
857
+ model: meta.svansModell,
858
+ fakt: meta.fakt,
859
+ ref: SPEC_REF(),
860
+ },
861
+ ctx,
862
+ )
863
+ }
864
+
865
+ /** Delens namn i loggen och delraderna: sökvägen relativt körningskatalogen,
866
+ * eller katalognamnet när delen ÄR körningsroten. */
867
+ const delNamn = (ctx, dir) => path.relative(ctx.katalog, dir) || path.basename(dir)
868
+
869
+ /** Ett precheck-anrop: varningarna loggas, brister och kandidat tolkas, och en
870
+ * exitkod skild från 0 UTAN tolkbart BRISTER-block är ett fel, aldrig ett
871
+ * godkänt steg. En grind som failar öppet när den själv kraschar är ingen
872
+ * grind. */
873
+ function korPrecheck(del, ctx, { impact = false } = {}) {
874
+ const r = script('precheck.mjs', [del.dir, ...(impact ? ['--impact'] : [])], ctx)
875
+ const tolkad = tolkaPrecheck(r.stdout, r.status)
876
+ const etikett = `precheck${impact ? ' --impact' : ''}`
877
+ for (const v of tolkad.varningar) logg(`VARNING ${etikett} ${delNamn(ctx, del.dir)}: ${v}`)
878
+ if (tolkad.ok || tolkad.brister.length > 0) return { ...tolkad, fel: null }
879
+ const svans = (r.stderr || r.stdout || '').trim().split('\n').slice(-3).join(' | ').slice(0, 200)
880
+ return { ...tolkad, fel: `${etikett}: exit ${r.status}${svans ? ` (${svans})` : ''}` }
881
+ }
882
+
883
+ /** Formkontrollen per del (speca steg 6): brister rättas med EN omdispatch,
884
+ * en kandidat routas till sitt stopputfall. Brister prövas först: en
885
+ * kandidatmarkör i fel form är en brist, inte ett utfall. */
886
+ async function prechecka(del, spec, ctx) {
887
+ let r = korPrecheck(del, ctx)
888
+ if (r.fel) return { status: 'ofärdig', orsak: r.fel }
889
+ if (r.brister.length > 0) {
890
+ const om = await specOmdispatch(del, spec, r.brister, ctx)
891
+ if (om.fel) return { status: 'ofärdig', orsak: `spec: ${om.fel}` }
892
+ r = korPrecheck(del, ctx)
893
+ if (r.fel) return { status: 'ofärdig', orsak: r.fel }
894
+ if (r.brister.length > 0) {
895
+ // Raden stämplas med OMDISPATCHENS modell: det är den läggningen som
896
+ // avvisas nu, inte den första.
897
+ avvisaDokument(
898
+ del.dir,
899
+ 'spec',
900
+ r.brister,
901
+ {
902
+ skill: 'request-write-spec',
903
+ agent: spec.agent,
904
+ model: om.meta.svansModell,
905
+ fakt: om.meta.fakt,
906
+ ref: SPEC_REF(),
907
+ },
908
+ ctx,
909
+ )
910
+ return {
911
+ status: 'ofärdig',
912
+ orsak: `spec: ${r.brister.map((b) => b.replace(/^- /, '')).join('; ')}`,
913
+ }
914
+ }
915
+ }
916
+ const state = lasJson(path.join(del.dir, 'state.json'))
917
+ const stopp = routaSpecStopp({
918
+ kandidat: r.kandidat,
919
+ prisavgorande: r.prisavgorande,
920
+ respecVarv: state?.respec_varv ?? 0,
921
+ })
922
+ if (stopp) {
923
+ const s = arkiv(
924
+ [
925
+ 'stopp',
926
+ del.dir,
927
+ '--utfall',
928
+ stopp.utfall,
929
+ '--kalla',
930
+ stopp.kalla,
931
+ ...(stopp.utfall === 'ESKALERA' ? ['--orsak', stopp.orsak] : []),
932
+ ],
933
+ ctx,
934
+ )
935
+ if (s.status !== 0) return { status: 'ofärdig', orsak: `stopp: ${felText(s)}` }
936
+ logg(`STOPP ${delNamn(ctx, del.dir)} ${stopp.utfall}${stopp.orsak ? `: ${stopp.orsak}` : ''}`)
937
+ return {
938
+ status: 'stopp',
939
+ orsak: stopp.orsak ? `${stopp.utfall}: ${stopp.orsak}` : stopp.utfall,
940
+ }
941
+ }
942
+ return { status: null }
943
+ }
944
+
945
+ /** EN omdispatch av specifiern (ADR-0092): hela ursprungsprompten plus
946
+ * felraderna. Vid bunt begärs bara den fallna delens spec tillbaka. */
947
+ async function specOmdispatch(del, spec, brister, ctx) {
948
+ avvisaDokument(
949
+ del.dir,
950
+ 'spec',
951
+ brister,
952
+ {
953
+ skill: 'request-write-spec',
954
+ agent: spec.agent,
955
+ model: spec.meta.svansModell,
956
+ fakt: spec.meta.fakt,
957
+ ref: SPEC_REF(),
958
+ },
959
+ ctx,
960
+ )
961
+ const prompt = omdispatchPrompt(spec.prompt, brister, {
962
+ endastSpec: del.delpost ? del.delpost.part : null,
963
+ })
964
+ const fil = `steg/02-specifier-omdispatch-${del.delpost ? `del${del.delpost.part}` : 'rot'}.json`
965
+ const res = await korAgent({
966
+ agent: spec.agent,
967
+ modell: ctx.modell,
968
+ prompt,
969
+ fil,
970
+ maxTurer: MAX_TURER.specifier,
971
+ ctx,
972
+ })
973
+ if (!res.ok) return { fel: res.orsak }
974
+ const { body, modell: svansModell } = parseSvansrader(res.text)
975
+ const { specar } = delaSpecifierSvar(body)
976
+ if (specar.length === 0) return { fel: 'omdispatchen innehåller ingen spec' }
977
+ const meta = { agent: spec.agent, svansModell, fakt: fakturerad(res.svar, ctx.modell) }
978
+ skrivSpec(del, specar[0].spec, specar[0].granskarblock, meta, ctx)
979
+ return { ok: true, meta }
980
+ }
981
+
982
+ /** Analysen: ett agentsteg, impact.md med runs-rad, formkontroll med EN
983
+ * omdispatch, och motsägelse mot specen som stopputfall (ADR-0106). */
984
+ async function korAssessor(del, ctx) {
985
+ const spec = lasFil(path.join(del.dir, 'spec.md'))
986
+ if (spec === null) return { status: 'ofärdig', orsak: 'impact: spec.md saknas' }
987
+ const prompt = assessorPrompt({
988
+ repoRot: ctx.repoRot,
989
+ deps: ctx.deps,
990
+ spec,
991
+ granskarblock: lasFil(path.join(del.dir, 'granskarblock.md')) ?? 'inget granskarblock',
992
+ manifest: ctx.manifest,
993
+ })
994
+ const fil = `steg/1${del.nr}-assessor.json`
995
+ const res = await korAgent({
996
+ agent: 'request-assessor',
997
+ modell: ctx.modell,
998
+ prompt,
999
+ fil,
1000
+ maxTurer: MAX_TURER.assessor,
1001
+ ctx,
1002
+ })
1003
+ if (!res.ok) return { status: 'ofärdig', orsak: `impact: ${res.orsak}` }
1004
+ const meta = skrivImpact(del, res, ctx)
1005
+ let p = korPrecheck(del, ctx, { impact: true })
1006
+ if (p.fel) return { status: 'ofärdig', orsak: p.fel }
1007
+ if (p.brister.length > 0) {
1008
+ const om = await impactOmdispatch(del, prompt, p.brister, meta, ctx)
1009
+ if (om.fel) return { status: 'ofärdig', orsak: `impact: ${om.fel}` }
1010
+ p = korPrecheck(del, ctx, { impact: true })
1011
+ if (p.fel) return { status: 'ofärdig', orsak: p.fel }
1012
+ if (p.brister.length > 0) {
1013
+ avvisaDokument(del.dir, 'impact', p.brister, impactRad(om.svansModell, om.fakt), ctx)
1014
+ return {
1015
+ status: 'ofärdig',
1016
+ orsak: `impact: ${p.brister.map((b) => b.replace(/^- /, '')).join('; ')}`,
1017
+ }
1018
+ }
1019
+ }
1020
+ if (p.avvikelse === 'motsägelse') {
1021
+ const s = arkiv(
1022
+ ['stopp', del.dir, '--utfall', 'TILLBAKA_TILL_SPEC', '--kalla', 'impact.md'],
1023
+ ctx,
1024
+ )
1025
+ if (s.status !== 0) return { status: 'ofärdig', orsak: `stopp: ${felText(s)}` }
1026
+ return { status: 'stopp', orsak: 'TILLBAKA_TILL_SPEC' }
1027
+ }
1028
+ return { status: null }
1029
+ }
1030
+
1031
+ const impactRad = (svansModell, fakt) => ({
1032
+ skill: 'request-assess-impact',
1033
+ agent: 'request-assessor',
1034
+ model: svansModell,
1035
+ fakt,
1036
+ })
1037
+
1038
+ /** Analysens kropp (svansraderna strippade) som impact.md, med runs-rad.
1039
+ * Ingen --ref: analysen har ingen referensfil att pinna. */
1040
+ function skrivImpact(del, res, ctx) {
1041
+ const { body, modell: svansModell } = parseSvansrader(res.text)
1042
+ writeFileSync(path.join(del.dir, 'impact.md'), body.trim() + '\n')
1043
+ const fakt = fakturerad(res.svar, ctx.modell)
1044
+ addRun(del.dir, { ...impactRad(svansModell, fakt), fil: 'impact.md' }, ctx)
1045
+ return { svansModell, fakt }
1046
+ }
1047
+
1048
+ /** EN omdispatch av assessorn. Den avvisade analysen bokförs före omtaget. */
1049
+ async function impactOmdispatch(del, prompt, brister, meta, ctx) {
1050
+ avvisaDokument(del.dir, 'impact', brister, impactRad(meta.svansModell, meta.fakt), ctx)
1051
+ const fil = `steg/1${del.nr}-assessor-omdispatch.json`
1052
+ const res = await korAgent({
1053
+ agent: 'request-assessor',
1054
+ modell: ctx.modell,
1055
+ prompt: omdispatchPrompt(prompt, brister),
1056
+ fil,
1057
+ maxTurer: MAX_TURER.assessor,
1058
+ ctx,
1059
+ })
1060
+ if (!res.ok) return { fel: res.orsak }
1061
+ return skrivImpact(del, res, ctx)
1062
+ }
1063
+
1064
+ const lasJson = (p) => {
1065
+ try {
1066
+ return JSON.parse(readFileSync(p, 'utf8'))
1067
+ } catch {
1068
+ return null
1069
+ }
1070
+ }
1071
+ const brottText = (kr) => kr.brott.map((b) => b.replace(/^- /, '')).join('; ')
1072
+
1073
+ /** Kontrollkedjan för EN scorerkörning (kontroll.mjs): svansrader, JSON,
1074
+ * reparationer, check-lasta, resolve-anchors, contract-check och
1075
+ * fältomskrivning i en process. `--ut` nollas före varje anrop: en kvarlämnad
1076
+ * JSON från förra försöket skulle arkiveras som om den vore svaret. */
1077
+ function korKontroll(txt, ren, fixFil, del, ctx) {
1078
+ const fixPath = path.join(ctx.katalog, fixFil)
1079
+ // Både --ut och --fix-fil nollas: en kvarlämnad JSON från förra försöket
1080
+ // skulle arkiveras som om den vore svaret, och ett kvarlämnat fix-svar
1081
+ // räknas en gång till i korning.json vid en omkörning av samma katalog.
1082
+ rmSync(ren, { force: true })
1083
+ rmSync(fixPath, { force: true })
1084
+ const r = script(
1085
+ 'kontroll.mjs',
1086
+ [
1087
+ txt,
1088
+ path.join(del.dir, 'spec.md'),
1089
+ path.join(del.dir, 'impact.md'),
1090
+ '--expect-id',
1091
+ ctx.requestId,
1092
+ '--ut',
1093
+ ren,
1094
+ '--fixa-kundtext',
1095
+ '--claude',
1096
+ ctx.claude,
1097
+ '--fix-fil',
1098
+ fixPath,
1099
+ '--state',
1100
+ path.join(del.dir, 'state.json'),
1101
+ ],
1102
+ ctx,
1103
+ )
1104
+ const tolkad = tolkaKontroll(r.stdout, r.status)
1105
+ samlaFixsteg(fixFil, fixPath, ctx)
1106
+ if (tolkad.exit2) return { exit2: true, orsak: felText(r) }
1107
+ if (tolkad.avvikelse) logg(`AVVIKELSE scorer ${path.basename(del.dir)}: ${tolkad.avvikelse}`)
1108
+ return tolkad
1109
+ }
1110
+
1111
+ /** Kundtextfixens råsvar är egna claude-anrop: de kostar pengar och hör till
1112
+ * inget agentsteg, så de räknas som egna steg i korning.json. */
1113
+ function samlaFixsteg(fil, fixPath, ctx) {
1114
+ const rasvar = lasJson(fixPath)
1115
+ if (!Array.isArray(rasvar)) return
1116
+ for (const svar of rasvar)
1117
+ ctx.samla({ fil, agent: 'kundtextfix', modell_begard: FIXMODELL, svar, wall_ms: null })
1118
+ }
1119
+
1120
+ /** En scorerkörning från agentsvar till dom: kontrollkedjan, EN omdispatch vid
1121
+ * BROTT, kedjan igen. Kvarstående brott gör körningen avvisad (aldrig hela
1122
+ * delen: den andra körningen kan vara giltig). Exit 2 är dispatcherfel. */
1123
+ async function kontrolleraScorer(del, k, res, prompt, ctx) {
1124
+ const txt = path.join(del.dir, `tmp-scorer-run${k}.txt`)
1125
+ const ren = path.join(del.dir, `tmp-scorer-run${k}.json`)
1126
+ const bas = `steg/2${del.nr}-scorer-run${k}`
1127
+ const svarsrad = (kontroll, sista) => {
1128
+ const doc = lasJson(ren)
1129
+ const brott = scorerBrott(kontroll.brott, { doc, ok: kontroll.ok })
1130
+ return {
1131
+ giltig: brott.length === 0,
1132
+ brott,
1133
+ doc,
1134
+ text: sista?.text ?? '',
1135
+ txt,
1136
+ ren,
1137
+ modellSvans: kontroll.modell ?? parseSvansrader(sista?.text ?? '').modell,
1138
+ fakt: fakturerad(sista?.svar, ctx.modell),
1139
+ reparerat: kontroll.reparerat ?? [],
1140
+ }
1141
+ }
1142
+ if (!res.ok) {
1143
+ // Samma nollning som korKontroll gör före varje kedjeanrop: en kvarlämnad
1144
+ // ren JSON från ett tidigare försök i samma katalog skulle annars läsas
1145
+ // som det här stegets svar och arkiveras som en giltig poäng.
1146
+ rmSync(ren, { force: true })
1147
+ return svarsrad(
1148
+ { brott: [`- agentsteget föll: ${res.orsak}`], modell: null, reparerat: [], ok: false },
1149
+ res,
1150
+ )
1151
+ }
1152
+ writeFileSync(txt, res.text)
1153
+ let kontroll = korKontroll(txt, ren, `${bas}-kundtextfix.json`, del, ctx)
1154
+ if (kontroll.exit2) return { exit2: true, orsak: `kontroll run${k}: ${kontroll.orsak}` }
1155
+ let sista = res
1156
+ if (kontroll.brott.length > 0) {
1157
+ const fil = `${bas}-omdispatch.json`
1158
+ const om = await korAgent({
1159
+ agent: 'request-scorer',
1160
+ modell: ctx.modell,
1161
+ prompt: omdispatchPrompt(prompt, kontroll.brott),
1162
+ fil,
1163
+ maxTurer: MAX_TURER.scorer,
1164
+ ctx,
1165
+ })
1166
+ if (!om.ok)
1167
+ return svarsrad({ ...kontroll, brott: [...kontroll.brott, `- omdispatch: ${om.orsak}`] }, res)
1168
+ writeFileSync(txt, om.text)
1169
+ const andra = korKontroll(txt, ren, `${bas}-omdispatch-kundtextfix.json`, del, ctx)
1170
+ if (andra.exit2) return { exit2: true, orsak: `kontroll run${k} (omdispatch): ${andra.orsak}` }
1171
+ kontroll = { ...andra, reparerat: [...kontroll.reparerat, ...andra.reparerat] }
1172
+ sista = om
1173
+ }
1174
+ return svarsrad(kontroll, sista)
1175
+ }
1176
+
1177
+ /** Två scorerkörningar samtidigt (hävstång 2, #500): de är oberoende, och
1178
+ * dubbelkörningen krävs ändå för varje prisbärande poäng. */
1179
+ async function korScorers(del, ctx) {
1180
+ const prompt = scorerPrompt({
1181
+ requestId: ctx.requestId,
1182
+ delpost: del.delpost,
1183
+ specPath: path.join(del.dir, 'spec.md'),
1184
+ impactPath: path.join(del.dir, 'impact.md'),
1185
+ })
1186
+ const svar = await Promise.all(
1187
+ [1, 2].map((k) =>
1188
+ korAgent({
1189
+ agent: 'request-scorer',
1190
+ modell: ctx.modell,
1191
+ prompt,
1192
+ fil: `steg/2${del.nr}-scorer-run${k}.json`,
1193
+ maxTurer: MAX_TURER.scorer,
1194
+ ctx,
1195
+ }),
1196
+ ),
1197
+ )
1198
+ const korningar = []
1199
+ for (const [i, res] of svar.entries()) {
1200
+ const kr = await kontrolleraScorer(del, i + 1, res, prompt, ctx)
1201
+ if (kr.exit2) {
1202
+ stadaTmp([
1203
+ ...korningar,
1204
+ {
1205
+ txt: path.join(del.dir, `tmp-scorer-run${i + 1}.txt`),
1206
+ ren: path.join(del.dir, `tmp-scorer-run${i + 1}.json`),
1207
+ },
1208
+ ])
1209
+ return { status: 'ofärdig', orsak: `poäng: ${kr.orsak}` }
1210
+ }
1211
+ korningar.push(kr)
1212
+ }
1213
+ return { korningar }
1214
+ }
1215
+
1216
+ /** Kontrollkedjans arbetsyta, aldrig arkivinnehåll: tmp-filerna tas bort när
1217
+ * varvet är arkiverat eller avbrutet. */
1218
+ const stadaTmp = (korningar) => {
1219
+ for (const k of korningar) for (const p of [k?.txt, k?.ren]) if (p) rmSync(p, { force: true })
1220
+ }
1221
+
1222
+ /** Prisfilen: kontrollkedjans rena JSON när den finns, annars ett kvitto på
1223
+ * vad som kom tillbaka. En avvisad körning utan JSON får aldrig försvinna
1224
+ * spårlöst, den kostade lika mycket som en giltig. */
1225
+ function skrivPris(del, namn, kr) {
1226
+ const innehall = kr.doc ?? { avvisad: true, brott: kr.brott, text: kr.text }
1227
+ writeFileSync(path.join(del.dir, namn), JSON.stringify(innehall, null, 2) + '\n')
1228
+ }
1229
+
1230
+ const scorerNote = (kr, extra = []) =>
1231
+ [...extra, ...kr.reparerat.map((r) => `reparerat: ${r}`)].filter(Boolean).join('; ') || null
1232
+
1233
+ const scorerRad = (kr, fil, extra = {}) => ({
1234
+ fil,
1235
+ skill: 'request-score',
1236
+ agent: 'request-scorer',
1237
+ model: kr.modellSvans,
1238
+ fakt: kr.fakt,
1239
+ ref: SCORE_REF(),
1240
+ ...extra,
1241
+ })
1242
+
1243
+ /** Experimentresultat blir aldrig `priced` (Experimentytan); `assessed` är
1244
+ * den högsta status en körning kan nå här. */
1245
+ function sattAssessed(del, ctx) {
1246
+ const r = arkiv(['set-status', del.dir, 'assessed'], ctx)
1247
+ if (r.status !== 0) logg(`VARNING set-status assessed ${path.basename(del.dir)}: ${felText(r)}`)
1248
+ }
1249
+
1250
+ /** Körning 1 avvisad: ingen giltig poäng finns. Körning 2 arkiveras ändå som
1251
+ * spekulativ dubbelkörning, den är körd och betald. */
1252
+ function arkiveraAvvisad(del, n, k1, k2, ctx) {
1253
+ const huvud = `score-pricing-v${n}-avvisad.json`
1254
+ skrivPris(del, huvud, k1)
1255
+ addRun(del.dir, scorerRad(k1, huvud, { note: scorerNote(k1, [brottText(k1)]) }), ctx)
1256
+ const run2 = `score-pricing-v${n}-run2${k2.giltig ? '' : '-avvisad'}.json`
1257
+ skrivPris(del, run2, k2)
1258
+ const note = scorerNote(k2, [
1259
+ 'spekulativ dubbelkörning utan giltig körning 1',
1260
+ ...(k2.giltig ? [] : [brottText(k2)]),
1261
+ ])
1262
+ addRun(del.dir, scorerRad(k2, run2, { supersededBy: huvud, note }), ctx)
1263
+ return { status: 'ofärdig', orsak: `poäng: ${brottText(k1)}` }
1264
+ }
1265
+
1266
+ /** Körning 1 giltig men dubbelkörningen krävdes inte (stopputfall eller poäng
1267
+ * under 1, ADR-0098): körning 1 är gällande, körning 2 läggs som spekulativ. */
1268
+ function arkiveraEnkel(del, n, k1, k2, ctx) {
1269
+ const huvud = `score-pricing-v${n}.json`
1270
+ skrivPris(del, huvud, k1)
1271
+ addRun(del.dir, scorerRad(k1, huvud, { note: scorerNote(k1) }), ctx)
1272
+ const run2 = `score-pricing-v${n}-run2${k2.giltig ? '' : '-avvisad'}.json`
1273
+ skrivPris(del, run2, k2)
1274
+ const note = scorerNote(k2, [
1275
+ 'spekulativ dubbelkörning, ej krävd (ADR-0098/0116: poäng < 1 eller stopputfall utom OVER_SKALAN)',
1276
+ ...(k2.giltig ? [] : [brottText(k2)]),
1277
+ ])
1278
+ addRun(del.dir, scorerRad(k2, run2, { supersededBy: huvud, note }), ctx)
1279
+ sattAssessed(del, ctx)
1280
+ if (!arStopputfall(k1.doc.outcome)) return { status: null }
1281
+ // DELA_UPP kräver nya delkataloger, ny analys och ny poäng per del. Det
1282
+ // körs inte autonomt av den inre processen i v1 (ADR-0113), delen stannar
1283
+ // med utfallet synligt i stället för att låtsas vara färdig.
1284
+ const orsak =
1285
+ k1.doc.outcome === 'DELA_UPP'
1286
+ ? 'DELA_UPP: delning körs inte autonomt av korning.mjs'
1287
+ : k1.doc.outcome
1288
+ return { status: 'stopp', orsak }
1289
+ }
1290
+
1291
+ /** Båda giltiga och dubbelkörningen krävs: jämförelsen avgör vilken fil som
1292
+ * gäller. OLIKA går till människan (ADR-0092), aldrig till en tredje körning. */
1293
+ function arkiveraDubbel(del, n, k1, k2, ctx) {
1294
+ const c = script('compare-scores.mjs', [k1.ren, k2.ren], ctx)
1295
+ const jam = tolkaCompare(c.stdout, c.status)
1296
+ if (jam.verdikt === 'FEL') return { status: 'ofärdig', orsak: `compare-scores: ${felText(c)}` }
1297
+ const { huvud, run2, huvudAr, doubleRun } = filnamnForVarv({
1298
+ n,
1299
+ verdikt: jam.verdikt,
1300
+ vinnare: jam.vinnare,
1301
+ })
1302
+ const vinnaren = huvudAr === 2 ? k2 : k1
1303
+ const andra = huvudAr === 2 ? k1 : k2
1304
+ const diff = jam.rader.map((r) => r.replace(/^- /, ''))
1305
+ skrivPris(del, huvud, vinnaren)
1306
+ skrivPris(del, run2, andra)
1307
+ addRun(del.dir, scorerRad(vinnaren, huvud, { doubleRun, note: scorerNote(vinnaren) }), ctx)
1308
+ addRun(
1309
+ del.dir,
1310
+ scorerRad(andra, run2, {
1311
+ doubleRun,
1312
+ note: scorerNote(andra, jam.verdikt === 'OLIKA' ? diff : []),
1313
+ // En OLIKA har ingen gällande fil att peka på förrän domen är fälld.
1314
+ ...(jam.verdikt === 'OLIKA' ? {} : { supersededBy: huvud }),
1315
+ }),
1316
+ ctx,
1317
+ )
1318
+ sattAssessed(del, ctx)
1319
+ logg(`POÄNG ${path.basename(del.dir)} ${jam.verdikt} (${huvud})`)
1320
+ if (jam.verdikt === 'OLIKA')
1321
+ return { status: 'väntar på dom', orsak: `OLIKA: ${diff.join('; ')}` }
1322
+ return arStopputfall(vinnaren.doc.outcome)
1323
+ ? { status: 'stopp', orsak: vinnaren.doc.outcome }
1324
+ : { status: null }
1325
+ }
1326
+
1327
+ /** Arkiveringen av ett scorervarv. Tmp-filerna tas bort när de arkiverats:
1328
+ * de är kontrollkedjans arbetsyta, inte arkivinnehåll. */
1329
+ function arkiveraVarv(del, korningar, ctx) {
1330
+ const [k1, k2] = korningar
1331
+ const r = arkiv(['next-n', del.dir, '--typ', 'score'], ctx)
1332
+ if (r.status !== 0) return { status: 'ofärdig', orsak: `next-n: ${felText(r)}` }
1333
+ const n = Number(r.stdout.trim())
1334
+ try {
1335
+ if (!k1.giltig) return arkiveraAvvisad(del, n, k1, k2, ctx)
1336
+ if (!behoverDubbelkorning(k1.doc)) return arkiveraEnkel(del, n, k1, k2, ctx)
1337
+ return arkiveraDubbel(del, n, k1, k2, ctx)
1338
+ } finally {
1339
+ stadaTmp(korningar)
1340
+ }
1341
+ }
1342
+
1343
+ /** En del hela vägen: analys, två scorerkörningar, arkivering. */
1344
+ async function korDel(del, ctx) {
1345
+ const namn = delNamn(ctx, del.dir)
1346
+ const analys = await korAssessor(del, ctx)
1347
+ if (analys.status !== null) return { namn, ...analys }
1348
+ const scorers = await korScorers(del, ctx)
1349
+ if (scorers.status) return { namn, status: scorers.status, orsak: scorers.orsak }
1350
+ return { namn, ...arkiveraVarv(del, scorers.korningar, ctx) }
1351
+ }
1352
+
1353
+ /** Mätningen på disk. Skrivs ALLTID: kostnaden är verklig även när körningen
1354
+ * föll eller avbröts, och en körning utan korning.json är omätbar i
1355
+ * efterhand. */
1356
+ const skrivKorningsjson = (ctx, allaSteg) =>
1357
+ writeFileSync(
1358
+ path.join(ctx.katalog, 'korning.json'),
1359
+ JSON.stringify(slaIhopKorning(allaSteg), null, 2) + '\n',
1360
+ )
1361
+
1362
+ /** Avslutet: korning.json skrivs ALLTID (kostnaden är verklig även när
1363
+ * körningen föll), status vägs samman och sätts via archive.mjs, och sist
1364
+ * skrivs delraderna och bluf-tabellen på stderr. */
1365
+ function avsluta(ctx, allaSteg, rader, nyckel) {
1366
+ let statusfel = null
1367
+ skrivKorningsjson(ctx, allaSteg)
1368
+ const status = sammanvagdStatus(rader.map((r) => r.status ?? null))
1369
+ if (status !== null) {
1370
+ const orsak = rader
1371
+ .filter((r) => r.status === status && r.orsak)
1372
+ .map((r) => `${r.namn}: ${r.orsak}`)
1373
+ .join('; ')
1374
+ const r = arkiv(
1375
+ [
1376
+ 'experiment',
1377
+ ctx.exp,
1378
+ '--korning',
1379
+ nyckel,
1380
+ '--status',
1381
+ status,
1382
+ ...(orsak ? ['--orsak', orsak] : []),
1383
+ ],
1384
+ ctx,
1385
+ )
1386
+ if (r.status !== 0) {
1387
+ // Utan den här raden stannar exit på 0 vid stopp och väntar på dom, och
1388
+ // svep stämplar `klar` över en körning som behöver en människa.
1389
+ statusfel = `experiment --status ${status} misslyckades: ${felText(r)}`
1390
+ logg(`FEL ${statusfel}, körningen rapporteras som ofärdig i stället`)
1391
+ }
1392
+ }
1393
+ for (const r of rader)
1394
+ logg(`DEL ${r.namn} ${r.status ?? 'klar'}${r.orsak ? ` (${r.orsak})` : ''}`)
1395
+ const bluf = arkiv(['bluf', ctx.katalog], ctx)
1396
+ process.stderr.write(
1397
+ (bluf.status === 0 ? bluf.stdout : `bluf: ${felText(bluf)}`).trimEnd() + '\n',
1398
+ )
1399
+ return status === 'ofärdig' || statusfel !== null ? 1 : 0
1400
+ }
1401
+
1402
+ /** Signalavbrottet: döda barnen, skriv mätningen och lämna körningen som
1403
+ * `ofärdig` efter sig. Utan den här vägen överlever betalda `claude`-processer
1404
+ * en dödad korning.mjs, och korning.json skrivs aldrig, så både pengarna och
1405
+ * mätningen försvinner tyst. Kör aldrig återstoden av flödet: en avbruten
1406
+ * körning ÄR ofärdig. */
1407
+ function avbryt(ctx, allaSteg, nyckel, signal) {
1408
+ const antal = ctx.barn.size
1409
+ for (const barn of ctx.barn) barn.kill('SIGTERM')
1410
+ skrivKorningsjson(ctx, allaSteg)
1411
+ const r = arkiv(
1412
+ [
1413
+ 'experiment',
1414
+ ctx.exp,
1415
+ '--korning',
1416
+ nyckel,
1417
+ '--status',
1418
+ 'ofärdig',
1419
+ '--orsak',
1420
+ `avbruten (${signal})`,
1421
+ ],
1422
+ ctx,
1423
+ )
1424
+ if (r.status !== 0) logg(`VARNING experiment --status ofärdig: ${felText(r)}`)
1425
+ logg(`AVBRUTEN ${signal}: ${antal} barnprocess(er) dödade, korning.json skriven`)
1426
+ process.exit(SIGNAL_EXIT)
1427
+ }
1428
+
1429
+ async function main(argv) {
1430
+ const val = tolkaArgv(argv)
1431
+ if (val.fel) {
1432
+ console.error(val.fel)
1433
+ console.error(ANVANDNING)
1434
+ return 2
1435
+ }
1436
+ const underlag = lasUnderlag(val)
1437
+ if (underlag.fel) {
1438
+ console.error(underlag.fel)
1439
+ return 2
1440
+ }
1441
+ const allaSteg = []
1442
+ const ctx = { ...val, ...underlag, barn: new Set(), samla: (steg) => allaSteg.push(steg) }
1443
+ mkdirSync(path.join(ctx.katalog, 'steg'), { recursive: true })
1444
+ const nyckel = `${path.basename(path.dirname(ctx.katalog))}/${path.basename(ctx.katalog)}`
1445
+ for (const signal of ['SIGTERM', 'SIGINT'])
1446
+ process.on(signal, () => avbryt(ctx, allaSteg, nyckel, signal))
1447
+ logg(
1448
+ `START ${nyckel} ${ctx.requestId} ${ctx.modell} (${ctx.sprak}${ctx.eskalerad ? ', eskalerad' : ''})`,
1449
+ )
1450
+
1451
+ let rader
1452
+ try {
1453
+ rader = await korKorningen(ctx)
1454
+ } catch (e) {
1455
+ // korning.json skrivs ÄVEN när något kastar: kostnaden är redan dragen och
1456
+ // stegen ligger på disk. Ett kast som tar mätningen med sig gör körningen
1457
+ // omätbar i efterhand.
1458
+ logg(`FEL oväntat: ${e?.stack ?? e}`)
1459
+ rader = [
1460
+ {
1461
+ namn: path.basename(ctx.katalog),
1462
+ status: 'ofärdig',
1463
+ orsak: `oväntat fel: ${e?.message ?? e}`,
1464
+ },
1465
+ ]
1466
+ }
1467
+ return avsluta(ctx, allaSteg, rader, nyckel)
1468
+ }
1469
+
1470
+ /** Specifier, formkontroll per del och de parallella delarna. Returnerar en
1471
+ * rad per del; kastar bara vid oväntade fel (main skriver mätningen ändå). */
1472
+ async function korKorningen(ctx) {
1473
+ const spec = await korSpecifier(ctx)
1474
+ if (spec.fel) return [{ namn: path.basename(ctx.katalog), status: 'ofärdig', orsak: spec.fel }]
1475
+
1476
+ // Formkontrollen per del körs före den parallella fasen: en del som stoppas
1477
+ // eller faller här ska aldrig kosta en analys.
1478
+ const rader = []
1479
+ const kvar = []
1480
+ for (const del of spec.delar) {
1481
+ const namn = delNamn(ctx, del.dir)
1482
+ const r = await prechecka(del, spec, ctx)
1483
+ if (r.status === null) kvar.push(del)
1484
+ else rader.push({ namn, ...r })
1485
+ }
1486
+ const resultat = await schemalagg(kvar, ctx.delarParallellt, (del) => korDel(del, ctx))
1487
+ for (const [i, r] of resultat.entries())
1488
+ rader.push(r.fel ? { namn: delNamn(ctx, kvar[i].dir), status: 'ofärdig', orsak: r.fel } : r)
1489
+ return rader
1490
+ }
1491
+
1492
+ /** Huvudloopen körs bara när scriptet startas direkt; en import (testerna)
1493
+ * exekverar ingenting. */
1494
+ const arDirektKord = () => {
1495
+ try {
1496
+ return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))
1497
+ } catch {
1498
+ return false
1499
+ }
1500
+ }
1501
+
1502
+ if (arDirektKord()) main(process.argv.slice(2)).then((kod) => process.exit(kod))