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