@haus-tech/haus-workflow 5.0.0 → 5.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/dist/cli.js +913 -751
- package/library/catalog/manifest.json +67 -61
- package/library/global/agents/request-assessor.md +10 -2
- package/library/global/agents/request-specifier-eskalerad.md +8 -0
- package/library/global/agents/request-specifier.md +8 -3
- package/library/global/skills/request/SKILL.md +590 -227
- package/library/global/skills/request/scripts/archive.mjs +736 -59
- package/library/global/skills/request/scripts/compare-scores.mjs +119 -13
- package/library/global/skills/request/scripts/contract-check.mjs +268 -40
- package/library/global/skills/request/scripts/kontroll.mjs +622 -0
- package/library/global/skills/request/scripts/korning.mjs +1502 -0
- package/library/global/skills/request/scripts/lib.mjs +477 -5
- package/library/global/skills/request/scripts/precheck.mjs +132 -5
- package/library/global/skills/request/scripts/report.mjs +85 -14
- package/library/global/skills/request/scripts/svep.mjs +640 -0
- package/library/global/skills/request-assess-impact/SKILL.md +45 -4
- package/library/global/skills/request-score/SKILL.md +51 -11
- package/library/global/skills/request-score/references/poangskala.md +3 -0
- package/library/global/skills/request-sync/SKILL.md +3 -3
- package/library/global/skills/request-write-spec/SKILL.md +9 -4
- package/library/global/skills/request-write-spec/references/specformat.md +48 -0
- package/package.json +1 -1
|
@@ -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))
|