@haus-tech/haus-workflow 4.8.0 → 5.0.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.
Files changed (29) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/dist/cli.js +1883 -902
  3. package/docs/before-you-start.md +57 -0
  4. package/docs/glossary.md +56 -0
  5. package/library/catalog/manifest.json +97 -34
  6. package/library/global/agents/request-assessor.md +2 -2
  7. package/library/global/agents/request-scorer.md +14 -9
  8. package/library/global/agents/request-specifier-eskalerad.md +1 -1
  9. package/library/global/agents/request-specifier.md +1 -1
  10. package/library/global/skills/haus-workflow/references/new.md +18 -11
  11. package/library/global/skills/request/SKILL.md +731 -127
  12. package/library/global/skills/request/scripts/archive.mjs +1376 -0
  13. package/library/global/skills/request/scripts/check-lasta.mjs +64 -0
  14. package/library/global/skills/request/scripts/compare-scores.mjs +73 -15
  15. package/library/global/skills/request/scripts/contract-check.mjs +110 -49
  16. package/library/global/skills/request/scripts/lib.mjs +81 -0
  17. package/library/global/skills/request/scripts/precheck.mjs +30 -27
  18. package/library/global/skills/request/scripts/report.mjs +571 -0
  19. package/library/global/skills/request/scripts/resolve-anchors.mjs +243 -0
  20. package/library/global/skills/request-assess-impact/SKILL.md +10 -10
  21. package/library/global/skills/request-score/SKILL.md +19 -10
  22. package/library/global/skills/request-sync/SKILL.md +4 -4
  23. package/library/global/skills/request-write-spec/SKILL.md +6 -6
  24. package/library/global/skills/request-write-spec/references/specformat.md +2 -2
  25. package/package.json +5 -2
  26. package/library/global/agents/request-classifier.md +0 -55
  27. package/library/global/skills/request/scripts/compare-classifications.mjs +0 -94
  28. package/library/global/skills/request-classify/SKILL.md +0 -79
  29. package/library/global/skills/request-classify/references/klassningsinstruktion.md +0 -152
@@ -0,0 +1,1376 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Arkivmekanikens normativa hem (ADR-0092): runs-stämpling, N-räkning och
4
+ * spärrstatus som deterministisk kod. SKILL.md beskriver flödet och anropar
5
+ * detta script; reglerna testas i tests/request-archive.test.js i stället
6
+ * för att drifta som prosa. Skriver aldrig om en befintlig runs-rad:
7
+ * arkivet är write-once, bara append.
8
+ *
9
+ * Del 1: add-run, next-n, sparr. Del 2 (Task 7): utslag (granskardomar),
10
+ * set-status (statusövergångar, med spärr på priced) och kalibrering
11
+ * (läs-enbart svepvy över kalibreringsnotiser).
12
+ *
13
+ * Användning:
14
+ * node archive.mjs add-run <katalog> --file F --skill S --agent A --model M [flaggor]
15
+ * node archive.mjs next-n <katalog> --typ classification [--typ score]
16
+ * node archive.mjs ny-korning <modellkatalog>
17
+ * node archive.mjs proveniens <körningskatalog> --modell M --konto K --underlag F [--datum D]
18
+ * node archive.mjs sparr <katalog>
19
+ * node archive.mjs utslag <katalog> --typ viktning|kalibrering|sakandring --beslutad-av N [...]
20
+ * node archive.mjs set-status <katalog> <status>
21
+ * node archive.mjs kalibrering <arkivrot>
22
+ * Exit 0 = ok, 1 = spärrad (sparr/set-status priced), 2 = användnings-/tillståndsfel.
23
+ */
24
+ import {
25
+ readFileSync,
26
+ writeFileSync,
27
+ openSync,
28
+ closeSync,
29
+ renameSync,
30
+ unlinkSync,
31
+ statSync,
32
+ chmodSync,
33
+ readdirSync,
34
+ existsSync,
35
+ mkdirSync,
36
+ } from 'node:fs'
37
+ import path from 'node:path'
38
+ import { createHash } from 'node:crypto'
39
+ import {
40
+ markerStrippedSha256,
41
+ claudeDir,
42
+ canonicalModel,
43
+ CLASS_UTFALL,
44
+ SCORE_OUTCOMES,
45
+ } from './lib.mjs'
46
+
47
+ const [cmd, katalog, ...rest] = process.argv.slice(2)
48
+
49
+ if (!cmd || !katalog) {
50
+ console.error(
51
+ 'användning: archive.mjs <add-run|next-n|ny-korning|proveniens|sparr|utslag|set-status|kalibrering> <katalog> [flaggor]',
52
+ )
53
+ process.exit(2)
54
+ }
55
+
56
+ /** Ett flaggvärde får aldrig tyst bli nästa flaggas namn — `--file --skill`
57
+ * ska vara ett fel, inte att `--file` råkar få värdet "--skill". */
58
+ const valueAt = (name, i) => {
59
+ const v = rest[i + 1]
60
+ if (v === undefined || v.startsWith('--')) {
61
+ console.error(`--${name} kräver ett värde`)
62
+ process.exit(2)
63
+ }
64
+ return v
65
+ }
66
+ const flag = (name) => {
67
+ const i = rest.indexOf(`--${name}`)
68
+ return i === -1 ? null : valueAt(name, i)
69
+ }
70
+ const flags = (name) => rest.flatMap((v, i) => (v === `--${name}` ? [valueAt(name, i)] : []))
71
+ /** En obligatorisk flagga som saknas är ett fel, aldrig en default: en
72
+ * proveniensstämpel med gissad modell eller gissat konto ser exakt lika
73
+ * giltig ut som en riktig och är omöjlig att upptäcka i efterhand. */
74
+ const requireFlag = (name) => {
75
+ const v = flag(name)
76
+ if (v === null) {
77
+ console.error(`--${name} krävs`)
78
+ process.exit(2)
79
+ }
80
+ return v
81
+ }
82
+ const has = (name) => rest.includes(`--${name}`)
83
+
84
+ /** Sökvägen till en katalogs state.json. Parametern har `katalog` som
85
+ * default så varje befintlig anropare är oförändrad; `proveniens` behöver
86
+ * den för att kunna stämpla delkatalogerna med samma kod som föräldern, i
87
+ * stället för en andra skrivväg som kan glida isär. */
88
+ const statePath = (d = katalog) => path.join(d, 'state.json')
89
+ const today = () => new Date().toISOString().slice(0, 10)
90
+
91
+ /** Trasig eller saknad state.json är alltid ett arkivfel (exit 2). En
92
+ * parsningsbar men icke-objekt state.json (array, tal, sträng, null) är
93
+ * LIKA trasig i acceptanskriteriets mening — annars skriver `sparr` OK rakt
94
+ * igenom en korrupt post, och `add-run` mot en `[]` sparar tillbaka en tom
95
+ * array (JSON.stringify slänger objektegenskaper på en array) och tappar
96
+ * raden tyst. Samma sak gäller varje SKRIVBAR listas fält specifikt: en
97
+ * behållare kan vara ett giltigt objekt medan fältet ändå är trasigt
98
+ * (`"abc"`, `{}`, `5`). `[...(state.X ?? []), row]` sprider VAD SOM HELST
99
+ * itererbart — en sträng sprids tecken för tecken till lika många
100
+ * skräprader (och write-once bryts TYST: en `utslag` mot `granskarutslag:
101
+ * "abc"` skriver om hela fältet till `['a','b','c',rad]` och rapporterar
102
+ * OK), och ett icke-itererbart värde (objekt, tal) kastar ett oupptäckt
103
+ * TypeError med exit 1 (kontraktets "spärrad", inte "trasigt arkiv"). Läs-
104
+ * sidan är lika illa träffad: `liftedByUtslag` gör `.some()` på fältet, och
105
+ * ett objekt/tal där kraschar OKONTROLLERAT (ingen `.some`-metod) med exit 1
106
+ * — `sparr` och `set-status priced` dör med stacktrace i stället för att
107
+ * vägra rent. Saknas fältet helt är det giltigt (ny post, tom lista); finns
108
+ * det men är inte en array, eller har ett element som inte är ett objekt,
109
+ * är det exit 2 — INNAN någon av de två (skriv- eller läs-)vägarna någonsin
110
+ * når fältet. */
111
+ class ArkivFel extends Error {}
112
+
113
+ /** Validerar att ETT skrivbart listfält (runs, granskarutslag) verkligen är
114
+ * en array av objekt — inte bara att behållaren (state) är ett objekt.
115
+ * Delad mellan de två listorna med flit: `runs` fick fyra fixrundor i Task 6
116
+ * för att komma fram till den här kontrollen, och `granskarutslag` är exakt
117
+ * samma sårbarhetsklass (samma spridningsmönster vid skrivning, samma
118
+ * `.some()`-krasch vid läsning) — samma fix, en gång, inte en andra kopia
119
+ * som kan glida isär från den första. */
120
+ const assertRowArrayField = (parsed, field, d = katalog) => {
121
+ if (parsed[field] === undefined) return
122
+ if (!Array.isArray(parsed[field])) {
123
+ throw new ArkivFel(
124
+ `${statePath(d)} har ett ${field}-fält som inte är en array (fick ${typeof parsed[field]})`,
125
+ )
126
+ }
127
+ // Behållarnivån räcker inte — varje ELEMENT måste vara ett objekt också.
128
+ // `null` i listan kraschar okontrollerat så fort någon läser en egenskap
129
+ // (TypeError, exit 1 — "spärrad" i det här kontraktet); ett tal eller en
130
+ // sträng kraschar inte direkt men sparas tillbaka som skräp och förblir
131
+ // osynligt för alltid.
132
+ const badIndex = parsed[field].findIndex(
133
+ (r) => r === null || typeof r !== 'object' || Array.isArray(r),
134
+ )
135
+ if (badIndex !== -1) {
136
+ throw new ArkivFel(
137
+ `${statePath(d)} har en ${field}-rad som inte är ett objekt (index ${badIndex})`,
138
+ )
139
+ }
140
+ }
141
+
142
+ const readStateOrThrow = (d = katalog) => {
143
+ let raw
144
+ try {
145
+ raw = readFileSync(statePath(d), 'utf8')
146
+ } catch (e) {
147
+ throw new ArkivFel(`kan inte läsa ${statePath(d)}: ${e.message}`)
148
+ }
149
+ let parsed
150
+ try {
151
+ parsed = JSON.parse(raw)
152
+ } catch (e) {
153
+ throw new ArkivFel(`${statePath(d)} är trasig JSON: ${e.message}`)
154
+ }
155
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
156
+ const fick = parsed === null ? 'null' : Array.isArray(parsed) ? 'array' : typeof parsed
157
+ throw new ArkivFel(`${statePath(d)} är inte ett state-objekt (fick ${fick})`)
158
+ }
159
+ assertRowArrayField(parsed, 'runs', d)
160
+ assertRowArrayField(parsed, 'granskarutslag', d)
161
+ return parsed
162
+ }
163
+
164
+ /** Läser state.json eller avslutar med exit 2. Används bara av de rena
165
+ * läskommandona (next-n, sparr) som aldrig håller ett lås. */
166
+ const loadStateOrExit = () => {
167
+ try {
168
+ return readStateOrThrow()
169
+ } catch (e) {
170
+ console.error(e.message)
171
+ process.exit(2)
172
+ }
173
+ }
174
+
175
+ /** Skriver state.json atomärt: till en temp-fil i samma katalog, sedan
176
+ * rename över målet. rename(2) byter ut hela filen i ett steg, så en
177
+ * läsare ser antingen hela den gamla eller hela den nya filen, ALDRIG en
178
+ * trunkerad fil (till skillnad från writeFileSync direkt mot state.json,
179
+ * som trunkerar målet innan den nya datan är skriven). Kastar ArkivFel på
180
+ * skrivfel (t.ex. EACCES) i stället för att låta undantaget nå toppen som
181
+ * en okontrollerad krasch med exit 1 — i det här kontraktet betyder exit 1
182
+ * spärrad, och ett misslyckat skrivförsök är inte det. */
183
+ const writeStateOrThrow = (s, d = katalog) => {
184
+ const rand = Math.random().toString(36).slice(2, 8)
185
+ const tmp = path.join(d, `.state.json.tmp-${process.pid}-${rand}`)
186
+ try {
187
+ writeFileSync(tmp, JSON.stringify(s, null, 2) + '\n')
188
+ // rename(2) byter inod, så temp-filens läge (satt av umask vid writeFileSync)
189
+ // skulle annars tyst ersätta en avsiktligt hårdare rättighet på den
190
+ // befintliga state.json (t.ex. 0600) med standardläget. Bevara det gamla
191
+ // läget om filen redan fanns; en ny post har inget läge att bevara.
192
+ try {
193
+ chmodSync(tmp, statSync(statePath(d)).mode)
194
+ } catch {
195
+ // ingen befintlig state.json (ny post) — inget läge att bevara.
196
+ }
197
+ renameSync(tmp, statePath(d))
198
+ } catch (e) {
199
+ try {
200
+ unlinkSync(tmp)
201
+ } catch {
202
+ // temp-filen skapades aldrig eller är redan borta — inget att städa.
203
+ }
204
+ throw new ArkivFel(`kunde inte skriva ${statePath(d)}: ${e.message}`)
205
+ }
206
+ }
207
+
208
+ /** Synkron sömn (blockerar tråden) för låsets pollning. Standardknepet för
209
+ * att sova utan async i ett rent CLI-script: Atomics.wait somnar tills
210
+ * timeouten går ut eftersom ingen annan agent notifierar den här cellen. */
211
+ const sleepMs = (ms) => {
212
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
213
+ }
214
+
215
+ /** `Number(x) || fallback` mappar ett uttryckligt `"0"` till fallback (0 är
216
+ * falsy) — fel för en tröskel där 0 är ett giltigt, meningsfullt värde
217
+ * ("ta över direkt"). Bara ett saknat/tomt/otolkbart värde faller tillbaka. */
218
+ const envMs = (name, fallback) => {
219
+ const v = process.env[name]
220
+ if (v === undefined || v === '') return fallback
221
+ const n = Number(v)
222
+ return Number.isFinite(n) ? n : fallback
223
+ }
224
+
225
+ const LOCK_POLL_MS = 20
226
+ // Fallback-horisont för när pid:et i låsfilen INTE går att pröva liv mot
227
+ // (oläsbart innehåll, t.ex. `touch .archive.lock`) — då är ålder den enda
228
+ // signal som finns. När pid:et ÄR läsbart avgör livskontrollen
229
+ // ({@link isAlive}) i stället, oavsett ålder: en add-run tar normalt
230
+ // millisekunder, men en riktig, levande innehavare ska aldrig bli bestulen
231
+ // bara för att den råkar ta lång tid. Överridebar för tester.
232
+ const LOCK_STALE_MS = envMs('ARCHIVE_LOCK_STALE_MS', 60 * 1000)
233
+ // Kort nådeperiod innan en väntande process säger något — vanlig konkurrens
234
+ // (två Bash-anrop i samma orkestratormeddelande) löses inom någon
235
+ // millisekund och ska inte skriva ett ord till stderr.
236
+ const LOCK_WARN_AFTER_MS = envMs('ARCHIVE_LOCK_WARN_AFTER_MS', 500)
237
+
238
+ /** Läser pid + tidsstämpel ur en låsfil. `null` om filen försvann mellan
239
+ * EEXIST och läsningen (ägaren släppte den just då — inget att invänta).
240
+ * Pid:et måste vara ett positivt heltal (samma guard som hydrate-lock.ts
241
+ * rad 132, `Number.isInteger(pid) && pid > 0`) — `process.kill` tolkar 0,
242
+ * negativa tal och andra icke-positiva värden som PROCESSGRUPPER, som
243
+ * nästan alltid "lyckas" oavsett vad som faktiskt körs. Utan den här
244
+ * guarden skulle en låsfil vars första rad råkar vara "0" eller "-1"
245
+ * alltid läsas som "levande" och hänga för alltid. Ett sådant pid
246
+ * behandlas som oläsbart och faller till mtime-vägen nedan. */
247
+ const readLockInfo = (lockPath) => {
248
+ let content
249
+ try {
250
+ content = readFileSync(lockPath, 'utf8')
251
+ } catch {
252
+ return null
253
+ }
254
+ const [pidLine, tsLine] = content.split('\n')
255
+ const parsedPid = Number.parseInt(pidLine, 10)
256
+ const pid = Number.isInteger(parsedPid) && parsedPid > 0 ? parsedPid : null
257
+ const ts = Date.parse(tsLine)
258
+ if (pid !== null && Number.isFinite(ts)) {
259
+ return { pid, ageMs: Date.now() - ts }
260
+ }
261
+ // Oläsbart eller ofullständigt innehåll (t.ex. `touch .archive.lock` som i
262
+ // granskningens repro, eller ett pid som inte är ett positivt heltal) —
263
+ // filens egen mtime är det enda ålderssignal som finns.
264
+ try {
265
+ return { pid: null, ageMs: Date.now() - statSync(lockPath).mtimeMs }
266
+ } catch {
267
+ return null
268
+ }
269
+ }
270
+
271
+ /** Samma livslängdsprobe som `hydrate-lock.ts`: en signal 0 mot ett dött pid
272
+ * ger ESRCH (död), mot ett levande pid stör den inte processen alls. EPERM
273
+ * betyder att processen finns men ägs av en annan användare — levande, och
274
+ * att läsa det som dött hade kunnat stjäla en pågående körning. */
275
+ const isAlive = (pid) => {
276
+ try {
277
+ process.kill(pid, 0)
278
+ return true
279
+ } catch (e) {
280
+ return e.code === 'EPERM'
281
+ }
282
+ }
283
+
284
+ /**
285
+ * Exklusivt filsystemslås runt add-runs läs-ändra-skriv. Utan detta tappar
286
+ * två samtidiga add-run-anrop en rad tyst och rapporterar båda OK: orkestratorn
287
+ * (en LLM i Claude Code) skickar rutinmässigt två Bash-anrop i samma
288
+ * meddelande, och SKILL.md steg 8 kräver att paret arkiveras atomärt.
289
+ * `openSync(path, 'wx')` är atomärt på OS-nivå (misslyckas med EEXIST om
290
+ * filen redan finns) — det är hela låsprimitivet.
291
+ *
292
+ * Ett kvarlämnat lås (processen dödad mitt i kritiska sektionen) får ALDRIG
293
+ * bricka posten permanent, och en LEVANDE innehavare får ALDRIG bli bestulen
294
+ * bara för att den tar lång tid — de två felen är varandras spegelbild och
295
+ * båda stängs av samma konvention som `hydrate-lock.ts` (ADR-0074): pid +
296
+ * tidsstämpel i låsfilen, en `process.kill(pid, 0)`-probe avgör liv när
297
+ * pid:et går att läsa, ålder är fallback-signalen när det inte gör det
298
+ * (t.ex. `touch .archive.lock`, som saknar ett pid att pröva) — MEN ålder
299
+ * gäller även på livsgrenen som ett tak: `process.kill(pid, 0)` svarar bara
300
+ * på om NÅGOT med det numret lever, inte om det är samma process som skrev
301
+ * låsfilen. macOS pid-utrymmet slår runt vid 99999 (en aktiv utvecklingsmaskin
302
+ * bränner igenom det på timmar till dagar), så ett lås från en krasch för
303
+ * länge sedan kan mycket väl namnge en helt orelaterad, levande process
304
+ * idag. En riktig add-run håller låset i millisekunder, så ett lås som är
305
+ * `LOCK_STALE_MS` gammalt OCH namnger en levande process är inte en
306
+ * långsam arkivering, det är ett återanvänt pid — och där, precis som för
307
+ * ett dött pid, är det debris. Utan taket blir en återanvänd-pid-krock en
308
+ * OBEGRÄNSAD väntan, vilket är ett strikt sämre utfall än referensen:
309
+ * `hydrate-lock.ts` returnerar bara `{acquired:false}` i sitt "okänt"-fall
310
+ * (en hoppad hydrering), den här funktionen har ingen "hoppa över"-väg att
311
+ * falla tillbaka på.
312
+ */
313
+ const acquireLock = () => {
314
+ const lockPath = path.join(katalog, '.archive.lock')
315
+ const start = Date.now()
316
+ let warned = false
317
+ const maybeWarn = (info) => {
318
+ if (warned || Date.now() - start <= LOCK_WARN_AFTER_MS) return
319
+ warned = true
320
+ console.error(
321
+ `väntar på ${lockPath} (innehas av pid ${info.pid ?? 'okänt'}, ` +
322
+ `${Math.round(info.ageMs / 1000)} s gammalt). Tas över automatiskt så snart ägaren ` +
323
+ `inte längre lever, eller under alla omständigheter efter ` +
324
+ `${Math.round(LOCK_STALE_MS / 1000)} s. Om ingen add-run faktiskt kör: radera filen ` +
325
+ `för hand och försök igen.`,
326
+ )
327
+ }
328
+
329
+ for (;;) {
330
+ try {
331
+ const fd = openSync(lockPath, 'wx')
332
+ writeFileSync(fd, `${process.pid}\n${new Date().toISOString()}\n`)
333
+ closeSync(fd)
334
+ return lockPath
335
+ } catch (e) {
336
+ if (e.code !== 'EEXIST') {
337
+ console.error(`kunde inte låsa ${lockPath}: ${e.message}`)
338
+ process.exit(2)
339
+ }
340
+ const info = readLockInfo(lockPath)
341
+ if (info === null) {
342
+ // TOCTOU: låsfilen försvann mellan EEXIST och läsningen. Ägaren kan
343
+ // ha släppt den (nästa varv i loopen tar den fria filen), ELLER en
344
+ // annan väntande process hann skapa ett NYTT, levande lås i exakt
345
+ // det fönstret — en unlink här skulle då radera det levande låset
346
+ // (granskningens deterministiska repro: fördröjning i just den
347
+ // punkten gav två processer i kritisk sektion samtidigt). Samma
348
+ // hantering som hydrate-lock.ts rad 181–188: ett nytt försök,
349
+ // ALDRIG en unlink.
350
+ continue
351
+ }
352
+ let debris
353
+ if (info.pid !== null) {
354
+ let alive
355
+ try {
356
+ alive = isAlive(info.pid)
357
+ } catch {
358
+ alive = true // okänt tolkas som levande, samma avvägning som hydrate-lock.ts
359
+ }
360
+ // Åldertaket gäller ÄVEN här: `alive` bara betyder att NÅGON process
361
+ // med det numret lever just nu, inte att det är samma process som
362
+ // skrev låsfilen. Ett pid-utrymme wrappar (macOS runt 99999), så ett
363
+ // lås som redan är LOCK_STALE_MS gammalt namnger med rimlig
364
+ // sannolikhet en återanvänd pid, inte en äkta, ovanligt långsam
365
+ // add-run — se motiveringen i funktionens docstring.
366
+ debris = !alive || info.ageMs > LOCK_STALE_MS
367
+ } else {
368
+ // Oläsbart innehåll — inget pid att pröva liv mot, bara mtime kvar.
369
+ debris = info.ageMs > LOCK_STALE_MS
370
+ }
371
+ if (!debris) {
372
+ maybeWarn(info)
373
+ sleepMs(LOCK_POLL_MS)
374
+ continue
375
+ }
376
+ try {
377
+ unlinkSync(lockPath)
378
+ } catch {
379
+ // redan borttagen av någon annan väntande process — varva om.
380
+ }
381
+ continue
382
+ }
383
+ }
384
+ }
385
+ const releaseLock = (lockPath) => {
386
+ try {
387
+ unlinkSync(lockPath)
388
+ } catch {
389
+ // redan borta — inget att göra.
390
+ }
391
+ }
392
+
393
+ /**
394
+ * Kända arkivfilnamn — allt annat är ett formfel i `--file` (t.ex. ett
395
+ * saknat "v" i "classification-pricing-1.json") som annars tyst arkiverar
396
+ * en rad som varken `next-n` eller `sparr` någonsin ser, eftersom ingendera
397
+ * matchar mönstret nedan.
398
+ *
399
+ * Alla tre mönstren matchar bara BASEN (spec, impact, eller typ + "-pricing-v"
400
+ * / "-guard-v" + siffror) och tillåter sedan en godtycklig kebab-suffixsvans
401
+ * — en uppräknad lista över exakta suffix visade sig ofullständig mot de
402
+ * skarpa arkiven på två sätt: en avvisad spec-leg (`spec-v1-avvisad.md`,
403
+ * ett precheck-BRIST-kvitto) matchade inte alls, och ett `run2`-ben kan i
404
+ * sig bli avvisat (kontraktsbrott på omkörningen) eller få en löpnummersvans
405
+ * vid upprepning (`-run2-avvisad-2`). Verifierat mot UNIONEN av `runs[].file`
406
+ * ur samtliga `state.json` i hemglass-workspace + haus-workflows egen
407
+ * dogfood-katalog (inklusive experiments/-trädets bunt-/del-underkataloger)
408
+ * — inte bara filer som råkar finnas kvar på disk, en avvisad läggning
409
+ * arkiveras ibland bara som kvitto i runs-raden. 19 distinkta namn, alla
410
+ * pinnade i tests/request-archive.test.js.
411
+ */
412
+ const SPEC_RE = /^spec(-[a-z0-9-]+)?\.md$/
413
+ const IMPACT_RE = /^impact(-[a-z0-9-]+)?\.md$/
414
+ const PRICING_RE = /^(classification|score)-(pricing|guard)-v\d+(-[a-z0-9-]+)?\.json$/
415
+ const FILE_PATTERNS = [SPEC_RE, IMPACT_RE, PRICING_RE]
416
+ const isKnownFile = (file) => FILE_PATTERNS.some((re) => re.test(file))
417
+
418
+ /**
419
+ * Underlagsprovenienens per filtyp — vilka dokument som faktiskt var indata
420
+ * till just den här filen, aldrig filen själv. En spec (avvisad eller ej)
421
+ * har inget underlag i repot (kundens råa önskemål räknas inte); en analys
422
+ * ser bara specen; pricing- och guard-filer ser bägge. Delar SPEC_RE/
423
+ * IMPACT_RE med `isKnownFile` ovan så de två aldrig kan glida isär — en
424
+ * avvisad spec-leg som vitlistan släpper igenom men underlagFor inte känner
425
+ * igen hade fallit till standardfallet och påstått spec.md+impact.md som
426
+ * sitt eget underlag, vilket är fel för en spec-leg. Verifierat mot riktiga
427
+ * arkiv i hemglass-workspace: spec.md-raden (och den avvisade
428
+ * spec-v1-avvisad.md-raden) bär tomt underlag_sha256, en
429
+ * impact(-superseded)-rad bär bara spec.md, en pricing-rad bär båda.
430
+ */
431
+ const underlagFor = (file) => {
432
+ if (SPEC_RE.test(file)) return []
433
+ if (IMPACT_RE.test(file)) return ['spec.md']
434
+ return ['spec.md', 'impact.md']
435
+ }
436
+
437
+ /** Axlarna på ETT ställe — `next-n`s giltiga `--typ`-värden och `sparr`s
438
+ * par (typ + svensk axelbeskrivning) fick tidigare stå dubbelt. */
439
+ const AXLAR = [
440
+ { typ: 'classification', axel: 'klassaxeln' },
441
+ { typ: 'score', axel: 'poängaxeln' },
442
+ ]
443
+ const TYPER = AXLAR.map((a) => a.typ)
444
+
445
+ /** Stopputfall per axel för `set-status priced`-grinden. OFFERT (klass) och
446
+ * POANG (poäng) är giltiga, prisbärande utfall och står medvetet UTANFÖR
447
+ * listorna — allt annat kräver människa eller kund innan posten kan bli
448
+ * priced. Källa: request-score/SKILL.md:s outcome-uppräkning. CLASS_STOP
449
+ * finns kvar enbart för äldre arkiv med en klassaxel (ADR-0098); dess
450
+ * källa var den borttagna request-classify/SKILL.md.
451
+ */
452
+ const CLASS_STOP = ['KONCEPT', 'DIAGNOS', 'TILLBAKA_TILL_SPEC', 'INPUT_SAKNAS', 'ESKALERA']
453
+ const SCORE_STOP = [
454
+ 'KONCEPT',
455
+ 'DIAGNOS',
456
+ 'TILLBAKA_TILL_SPEC',
457
+ 'DELA_UPP',
458
+ 'OVER_SKALAN',
459
+ 'INPUT_SAKNAS',
460
+ // ADR-0098: flyttad hit från CLASS_STOP när klassningen togs bort.
461
+ 'ESKALERA',
462
+ ]
463
+
464
+ /** Tak för antal körningar per modell. Skyddet är mot en trasig loop, inte mot
465
+ * Tims arbetssätt: 999 körningar av samma modell på samma post är alltid ett
466
+ * fel, aldrig en avsikt. */
467
+ const MAX_KORNINGAR = 999
468
+
469
+ /** Referensuppsättningen som `skill_sha256` täcker, relativt Claude-
470
+ * konfigkatalogen. Listan bor HÄR och inte i anroparens flaggor: fem
471
+ * sökvägar på kommandoraden är fem chanser att hasha över fel uppsättning,
472
+ * och en hash över fel uppsättning ser precis lika giltig ut som en riktig.
473
+ * request-classifier.md togs bort med klassaxeln (ADR-0098) — hashen byter
474
+ * därmed värde, vilket är korrekt: en körning före och efter borttagningen
475
+ * är inte samma kontrakt. */
476
+ const PROVENIENS_REFERENSER = [
477
+ 'agents/request-assessor.md',
478
+ 'agents/request-scorer.md',
479
+ 'agents/request-specifier-eskalerad.md',
480
+ 'agents/request-specifier.md',
481
+ 'skills/request/SKILL.md',
482
+ ]
483
+
484
+ /** Kända statusvärden (SKILL.md: "spec_draft | spec_synced | assessed |
485
+ * priced | guard_run"). `set-status` skrev tidigare VILKEN STRÄNG SOM HELST
486
+ * rakt in i state.json — inklusive en flaggformad sträng om anroparen råkat
487
+ * glömma statusvärdet (`set-status <katalog> --nagot-annat`), vilket hade
488
+ * gett en tyst, obegriplig status i stället för ett fel. */
489
+ const STATUSAR = ['spec_draft', 'spec_synced', 'assessed', 'priced', 'guard_run']
490
+
491
+ /** Giltiga `--double-run`-värden (SKILL.md runs-kontraktet: "bekräftad" |
492
+ * "olika" | "tolerans" | null). `add-run` skrev tidigare VILKEN STRÄNG SOM
493
+ * HELST rakt in i fältet, samma klass av brist som `STATUSAR` ovan stänger
494
+ * för statusvärdet, bara på det fält `sparr` faktiskt läser för att avgöra
495
+ * om en post är spärrad. Läs- och skrivsidan delar den här listan med
496
+ * flit: `sparr` (se `sparrSkal` nedan) behandlar ett värde UTANFÖR listan
497
+ * som blockerande snarare än som "okänt, alltså inget att bry sig om". De
498
+ * två måste stå still tillsammans, annars kan skrivsidan vitlista ett värde
499
+ * lässidan aldrig lär sig känna igen (eller tvärtom). */
500
+ const DOUBLE_RUN_VALUES = ['bekräftad', 'olika', 'tolerans']
501
+
502
+ /** Gällande fil per typ: högsta N bland suffixfria -pricing-vN-filnamn i runs.
503
+ * -run2 och -avvisad matchar mönstret aldrig, så de räknas aldrig med här,
504
+ * och guard-filer (…-guard-vN.json) har ett annat namnmönster och matchar
505
+ * inte heller — de har en egen N-sekvens, skild från pricing. */
506
+ const GALLANDE_RE = (typ) => new RegExp(`^${typ}-pricing-v(\\d+)\\.json$`)
507
+ const maxN = (runs, typ) =>
508
+ Math.max(0, ...runs.map((r) => Number(GALLANDE_RE(typ).exec(r.file ?? '')?.[1] ?? 0)))
509
+
510
+ if (cmd === 'add-run') {
511
+ // Samma fail-closed-princip som `next-n` redan tillämpar (se dess kommentar
512
+ // ovan): varje token i `rest` måste höra till en KÄND flagga, annars är det
513
+ // ett fel, aldrig ett tyst struntvärde. Utan den här kontrollen försvann ett
514
+ // felstavat flaggnamn (`--double_run`, JSON-fältets understreck i stället
515
+ // för flaggans bindestreck) rakt in i tomma intet: `flag('double-run')`
516
+ // hittar den aldrig, `doubleRun` blir `null`, och `--double_run olika`
517
+ // stämplade en rad som "inget att bry sig om" i stället för att fälla ett
518
+ // fel, granskningens repro. `--file`/`--skill`/`--agent`/`--model` tar
519
+ // ETT värde var, `--ref` kan upprepas (nyckel=värde), `--partial-rerun` tar
520
+ // aldrig ett värde (booleansk flagga, som `has()` redan förutsätter).
521
+ const ADD_RUN_VARDEFLAGGOR = [
522
+ 'file',
523
+ 'skill',
524
+ 'agent',
525
+ 'model',
526
+ // ADR-0099: den fakturerade modellen, när dispatchern känner den. Raden
527
+ // bär både vad agenten uppgav och vad faktureringen visar.
528
+ 'fakturerad',
529
+ 'ref',
530
+ 'effort',
531
+ 'note',
532
+ 'double-run',
533
+ 'superseded-by',
534
+ 'pairs-with',
535
+ ]
536
+ const ADD_RUN_BOOLFLAGGOR = ['partial-rerun']
537
+ const konsumerat = new Set()
538
+ rest.forEach((v, i) => {
539
+ if (!v.startsWith('--')) return
540
+ const namn = v.slice(2)
541
+ if (ADD_RUN_VARDEFLAGGOR.includes(namn)) {
542
+ konsumerat.add(i)
543
+ konsumerat.add(i + 1)
544
+ } else if (ADD_RUN_BOOLFLAGGOR.includes(namn)) {
545
+ konsumerat.add(i)
546
+ }
547
+ })
548
+ const ovantat = rest.filter((_, i) => !konsumerat.has(i))
549
+ if (ovantat.length > 0) {
550
+ console.error(
551
+ `add-run: okänd eller ej konsumerad flagga/argument (${ovantat.join(' ')}), ` +
552
+ `giltiga flaggor: ${[...ADD_RUN_VARDEFLAGGOR, ...ADD_RUN_BOOLFLAGGOR].map((n) => `--${n}`).join(', ')}`,
553
+ )
554
+ process.exit(2)
555
+ }
556
+
557
+ const file = flag('file')
558
+ const skill = flag('skill')
559
+ const agent = flag('agent')
560
+ const model = flag('model')
561
+ if (!file || !skill || !agent || !model) {
562
+ console.error('add-run kräver --file, --skill, --agent, --model')
563
+ process.exit(2)
564
+ }
565
+ if (!isKnownFile(file)) {
566
+ console.error(
567
+ `--file "${file}" matchar inget känt arkivmönster ` +
568
+ '(spec eller impact med valfri kebab-svans, t.ex. spec-v1-avvisad.md eller ' +
569
+ 'impact-superseded-<datum>.md; <typ>-pricing-vN eller <typ>-guard-vN med valfri ' +
570
+ 'suffixsvans, t.ex. -run2-avvisad)',
571
+ )
572
+ process.exit(2)
573
+ }
574
+ const referens = {}
575
+ for (const kv of flags('ref')) {
576
+ const eq = kv.indexOf('=')
577
+ if (eq === -1) {
578
+ console.error(`--ref kräver formen nyckel=värde, fick "${kv}"`)
579
+ process.exit(2)
580
+ }
581
+ referens[kv.slice(0, eq)] = kv.slice(eq + 1)
582
+ }
583
+ const effort = flag('effort')
584
+ const note = flag('note')
585
+ const doubleRun = flag('double-run')
586
+ // Samma vitlista som lässidan (`sparrSkal`, `DOUBLE_RUN_VALUES`) delar,
587
+ // annars kan skrivsidan sätta ett värde ("OLIKA" versalt, "oavgjord", vad
588
+ // som helst) som lässidan aldrig känner igen som blockerande. Granskningens
589
+ // repro: `--double-run OLIKA` (fel skiftläge) passerade `sparr` och
590
+ // `set-status priced` rakt igenom, eftersom `rad.double_run === 'olika'`
591
+ // (gemener, exakt sträng) aldrig matchade den lagrade versalen.
592
+ if (doubleRun !== null && !DOUBLE_RUN_VALUES.includes(doubleRun)) {
593
+ console.error(`okänt --double-run: "${doubleRun}" (giltiga: ${DOUBLE_RUN_VALUES.join(', ')})`)
594
+ process.exit(2)
595
+ }
596
+ const supersededBy = flag('superseded-by')
597
+ const partialRerun = has('partial-rerun')
598
+ const pairsWith = flag('pairs-with')
599
+
600
+ // Kritisk sektion: från låset förvärvas till det släpps får ingen annan
601
+ // add-run-process läsa eller skriva state.json för samma katalog.
602
+ const lockPath = acquireLock()
603
+ let rowCount
604
+ try {
605
+ const state = readStateOrThrow()
606
+ // Dubblettvakt: en PRICING-/GUARD-fil (klass- eller poängaxelns
607
+ // prisbärande dokument) får aldrig stämplas två gånger. Write-once är
608
+ // append-only på RADNIVÅ, men varje läsare (sparr, pricedBlockers,
609
+ // gallandeDokumentFil) tar den SENASTE raden för ett givet filnamn, så
610
+ // en andra add-run mot samma `--file` är i praktiken "senast vinner", och
611
+ // en vanlig omstämpling utan `--double-run` kan tyst desarmera en stående
612
+ // `double_run: "olika"`-spärr (granskningens repro). spec.md och
613
+ // impact.md är UNDANTAGNA med flit: en respec ERSÄTTER innehållet under
614
+ // samma filnamn (se SKILL.md, "Respec över befintlig katalog invaliderar
615
+ // analysen") och skriver en ny, legitim runs-rad för samma `--file` varje
616
+ // gång. De bär inget `double_run`/`partial_rerun`-tillstånd att desarmera,
617
+ // så en repeterad stämpling där är avsedd, inte en lucka.
618
+ if (PRICING_RE.test(file) && (state.runs ?? []).some((r) => r.file === file)) {
619
+ throw new ArkivFel(
620
+ `--file "${file}" är redan arkiverad (finns som runs-rad). En pricing-/guard-fil ` +
621
+ 'kan aldrig stämplas två gånger, det skulle tyst kunna desarmera en stående spärr ' +
622
+ '(en riktig omkörning får ett NYTT filnamn via next-n, t.ex. -run2 eller nästa vN)',
623
+ )
624
+ }
625
+ const row = {
626
+ file,
627
+ skill,
628
+ agent,
629
+ // ADR-0099: två fält — uppgiven (MODELL-svansraden, agentens självrapport)
630
+ // och fakturerad (faktureringslagret, när den är känd). Äldre arkiv bär
631
+ // en ren sträng här; läsare ska tåla båda formerna.
632
+ model: { uppgiven: model, fakturerad: flag('fakturerad') ?? null },
633
+ effort,
634
+ date: today(),
635
+ reference_sha256: referens,
636
+ underlag_sha256: {},
637
+ double_run: doubleRun,
638
+ superseded_by: supersededBy,
639
+ partial_rerun: partialRerun,
640
+ pairs_with: pairsWith,
641
+ }
642
+ if (note) row.note = note
643
+ for (const u of underlagFor(file)) {
644
+ const p = path.join(katalog, u)
645
+ try {
646
+ row.underlag_sha256[u] = markerStrippedSha256(p)
647
+ } catch (e) {
648
+ if (e.code !== 'ENOENT')
649
+ throw new ArkivFel(`kunde inte hasha underlaget ${p}: ${e.message}`)
650
+ // ENOENT: underlagsfilen finns inte ännu (t.ex. impact.md före assessorn) —
651
+ // hoppa över den, arkivera aldrig en påhittad hash.
652
+ }
653
+ }
654
+ state.runs = [...(state.runs ?? []), row]
655
+ writeStateOrThrow(state)
656
+ rowCount = state.runs.length
657
+ } catch (e) {
658
+ releaseLock(lockPath)
659
+ if (e instanceof ArkivFel) {
660
+ console.error(e.message)
661
+ process.exit(2)
662
+ }
663
+ throw e
664
+ }
665
+ releaseLock(lockPath)
666
+ console.log(`OK: runs-rad för ${file} (${rowCount} rader)`)
667
+ } else if (cmd === 'next-n') {
668
+ // `flags('typ')` bara SAMLAR IN varje värde som direkt följer ett `--typ`
669
+ // — den validerar aldrig att RESTEN av `rest` är just sådana par. Ett
670
+ // anrop skrivet som `--typ classification score` (en flagga, tänkt som en
671
+ // lista) ger alltså `typer = ['classification']` och tyst SLÄNGER
672
+ // `score` som ett obehandlat positionsargument, ingen varning. Det är
673
+ // granskningens repro: `--n` blev 1 för lågt eftersom scoreaxelns varv
674
+ // aldrig räknades med, och `add-run` skriver sedan över en redan
675
+ // arkiverad fil (ingen överskrivningsvakt där) — helt tyst, exit 0.
676
+ // Fail-closed-principen som resten av scriptet följer (se t.ex.
677
+ // `valueAt` ovan) kräver att den här formen vägras, inte tolkas löst:
678
+ // command-formen är upprepad `--typ <värde>`, ALDRIG en flagga med flera
679
+ // värden, och varje token i `rest` måste höra till ett sådant par.
680
+ const konsumerat = new Set()
681
+ rest.forEach((v, i) => {
682
+ if (v === '--typ') {
683
+ konsumerat.add(i)
684
+ konsumerat.add(i + 1)
685
+ }
686
+ })
687
+ const ovantat = rest.filter((_, i) => !konsumerat.has(i))
688
+ if (ovantat.length > 0) {
689
+ console.error(
690
+ `next-n: oväntat argument (${ovantat.join(' ')}) — upprepa --typ per axel, ` +
691
+ 't.ex. `--typ classification --typ score`, aldrig `--typ classification score`',
692
+ )
693
+ process.exit(2)
694
+ }
695
+ const typer = flags('typ')
696
+ if (typer.length === 0) {
697
+ console.error('next-n kräver minst en --typ classification|score')
698
+ process.exit(2)
699
+ }
700
+ const okanda = typer.filter((t) => !TYPER.includes(t))
701
+ if (okanda.length > 0) {
702
+ console.error(`okänd --typ: ${okanda.join(', ')} (giltiga: ${TYPER.join(', ')})`)
703
+ process.exit(2)
704
+ }
705
+ const runs = loadStateOrExit().runs ?? []
706
+ console.log(String(Math.max(...typer.map((t) => maxN(runs, t))) + 1))
707
+ } else if (cmd === 'sparr') {
708
+ const state = loadStateOrExit()
709
+ const skal = sparrSkal(state)
710
+ if (skal.length === 0) {
711
+ console.log('OK')
712
+ } else {
713
+ for (const s of skal) console.log(`SPÄRRAD: ${s}`)
714
+ process.exit(1)
715
+ }
716
+ } else if (cmd === 'utslag') {
717
+ const typ = flag('typ')
718
+ if (!['viktning', 'kalibrering', 'sakandring'].includes(typ)) {
719
+ console.error('utslag kräver --typ viktning|kalibrering|sakandring')
720
+ process.exit(2)
721
+ }
722
+ const beslutadAv = flag('beslutad-av')
723
+ if (!beslutadAv) {
724
+ console.error('utslag kräver --beslutad-av <namn>')
725
+ process.exit(2)
726
+ }
727
+ const row = { typ, date: today(), beslutad_av: beslutadAv }
728
+ if (typ === 'viktning') {
729
+ const beslut = flag('beslut')
730
+ const axel = flag('axel')
731
+ const gallerFil = flag('galler-fil')
732
+ // flag('n') === null (flaggan saknas helt) måste skiljas från ett
733
+ // ogiltigt värde — Number(null) är 0, ett GILTIGT heltal, så en bar
734
+ // `Number(flag('n'))` hade tyst gjort en SAKNAD --n till n=0 i stället
735
+ // för att vägra. Number(NaN) är aldrig ett heltal, så den vägen vägrar
736
+ // korrekt både "saknas" och "otolkbart".
737
+ const nRaw = flag('n')
738
+ const n = nRaw === null ? NaN : Number(nRaw)
739
+ if (!beslut || !axel || !gallerFil || !Number.isInteger(n) || n < 0) {
740
+ console.error('viktning kräver --beslut, --axel, --n (icke-negativt heltal), --galler-fil')
741
+ process.exit(2)
742
+ }
743
+ // --axel är inte fritext: fel värde (t.ex. "bogus") skriver ett utslag
744
+ // som failar STÄNGT (liftedByUtslag matchar aldrig okänt axelnamn mot
745
+ // TYPER), men VILSELEDER granskaren, som tror posten är upplåst medan
746
+ // sparr fortfarande spärrar. Samma vitlista som next-n redan använder.
747
+ if (!TYPER.includes(axel)) {
748
+ console.error(`okänd --axel: ${axel} (giltiga: ${TYPER.join(', ')})`)
749
+ process.exit(2)
750
+ }
751
+ // galler_fil är det prisbärande dokumentet ett viktningsutslag pekar på
752
+ // (se gallandeDokumentFil), fem kontroller, alla nödvändiga:
753
+ // (1) samma filnamnsmönster add-run redan vitlistar, så ett påhittat
754
+ // namn inte kan lyfta en spärr mot ett dokument som aldrig skulle
755
+ // godkänts som runs-rad;
756
+ // (2) filen måste faktiskt höra till den axel utslaget namnger, annars
757
+ // kan ett viktningsutslag för "classification" peka på en score-fil,
758
+ // och gallandeDokumentFil skulle läsa fel dokuments utfall/outcome;
759
+ // (3) filen måste vara en PRICING-fil (aldrig guard) för just den axeln,
760
+ // med ett läsbart versionsnummer. En guard-fil matchar (1) och (2)
761
+ // men hör till en annan mekanik och kan aldrig vara ett prisunderlag;
762
+ // (4) filnamnets versionsnummer får aldrig vara HÖGRE än --n. Fix-runda 1
763
+ // låste detta till EXAKT --n, men det stängde en legitim dom: efter
764
+ // en beställd omkörning (döm, "Beställ omkörning") har axeln bumpats
765
+ // till ett nytt varv (säg v3), granskaren ser DÅ tre körningar (de två
766
+ // ursprungliga plus omkörningen) och kan mycket väl döma att en av de
767
+ // ursprungliga (v1 eller v2) hade rätt hela tiden. Med exakt-matchning
768
+ // vägrade scriptet den domen (`--n 3 --galler-fil ...-v2.json`) medan
769
+ // `--n 2` inte lyfter något (2 är inte gällande n längre), och
770
+ // write-once gjorde det till en permanent död rad. `<=` löser det
771
+ // utan att öppna dörren åt fel håll: en dom kan peka BAKÅT på ett
772
+ // varv som redan fanns när den skrevs, aldrig FRAMÅT på ett som inte
773
+ // fanns än (det är samma "domen honoreras för fel varv"-brist som
774
+ // skrivtidskontrollen för --n själv stänger, se nedan, bara
775
+ // spegelvänd till filnamnssidan);
776
+ // (5) filen måste FAKTISKT vara arkiverad, alltså finnas som en `runs`-rad
777
+ // (kontrolleras nedan, inne i den kritiska sektionen där `state` finns)
778
+ // OCH finnas på disk nu. `runs`-kravet stänger en plantering (en JSON-
779
+ // fil som råkar matcha namnmönstret men aldrig gick via `add-run`);
780
+ // disk-kravet stänger att domen pekar på ett dokument ingenting kan
781
+ // läsa, vilket annars mötte priced-kontrollen som ett integritetsfel
782
+ // (ArkivFel) första gången någon försökte läsa den.
783
+ if (!isKnownFile(gallerFil)) {
784
+ console.error(`--galler-fil "${gallerFil}" matchar inget känt arkivmönster`)
785
+ process.exit(2)
786
+ }
787
+ if (!gallerFil.startsWith(`${axel}-`)) {
788
+ console.error(`--galler-fil "${gallerFil}" hör inte till axeln ${axel}`)
789
+ process.exit(2)
790
+ }
791
+ const versionMatch = new RegExp(`^${axel}-pricing-v(\\d+)(-[a-z0-9-]+)?\\.json$`).exec(
792
+ gallerFil,
793
+ )
794
+ if (!versionMatch) {
795
+ console.error(
796
+ `--galler-fil "${gallerFil}" är inte en pricing-fil för axeln ${axel} ` +
797
+ '(guard-filer kan aldrig vara prisunderlag för ett viktningsutslag)',
798
+ )
799
+ process.exit(2)
800
+ }
801
+ const gallerFilN = Number(versionMatch[1])
802
+ if (gallerFilN > n) {
803
+ console.error(
804
+ `--galler-fil "${gallerFil}" pekar på ett FRAMTIDA varv (v${gallerFilN}) för --n ${n}, ` +
805
+ 'en dom kan bara gälla ett varv som redan fanns när den skrevs',
806
+ )
807
+ process.exit(2)
808
+ }
809
+ if (!existsSync(path.join(katalog, gallerFil))) {
810
+ console.error(`--galler-fil "${gallerFil}" finns inte i ${katalog}`)
811
+ process.exit(2)
812
+ }
813
+ Object.assign(row, { beslut, axel, n, galler_fil: gallerFil })
814
+ } else if (typ === 'kalibrering') {
815
+ const fraga = flag('fraga')
816
+ const lasning1 = flag('lasning1')
817
+ const lasning2 = flag('lasning2')
818
+ if (!fraga || !lasning1 || !lasning2) {
819
+ console.error('kalibrering kräver --fraga, --lasning1, --lasning2')
820
+ process.exit(2)
821
+ }
822
+ Object.assign(row, { fraga, lasning1, lasning2 })
823
+ } else {
824
+ const beslut = flag('beslut')
825
+ if (!beslut) {
826
+ console.error('sakandring kräver --beslut')
827
+ process.exit(2)
828
+ }
829
+ row.beslut = beslut
830
+ }
831
+
832
+ // Samma lås som add-run — write-once gäller granskarutslag precis som runs.
833
+ const lockPath = acquireLock()
834
+ let rowCount
835
+ try {
836
+ const state = readStateOrThrow()
837
+ // n får aldrig peka på ett varv som inte finns ÄN. Kontrolleras här, inne
838
+ // i den kritiska sektionen och mot samma `state` som just lästs — annars
839
+ // kan --n namnge ett framtida varv (next-n skriver ut maxN+1, ett utslags
840
+ // --n betyder maxN, de två självklara talen skiljer med ett i just den
841
+ // förväxlingen) och en människa kan aldrig ha dömt om ett dokument som
842
+ // inte fanns när domen skrevs. Detta är den SUBSTANTIELLA halvan av
843
+ // fixen: den stänger dörren vid källan, innan en spekulativ rad någonsin
844
+ // kan hamna i granskarutslag. `liftingUtslag`s `===`-jämförelse (se dess
845
+ // kommentar) är den KOMPLETTERANDE halvan — djupförsvar mot en rad som
846
+ // ändå finns (äldre arkiv från före den här kontrollen, eller ett
847
+ // handredigerat state.json), inte en ersättning för den här kontrollen.
848
+ if (row.typ === 'viktning') {
849
+ const gallande = maxN(state.runs ?? [], row.axel)
850
+ if (row.n > gallande) {
851
+ throw new ArkivFel(
852
+ `--n ${row.n} är högre än gällande varv (${gallande}) för ${row.axel} — ` +
853
+ 'ett utslag kan bara handla om ett varv som redan finns',
854
+ )
855
+ }
856
+ // Disk-existensen (kontrollerad ovan, före låset) bevisar att FILEN
857
+ // finns, inte att den kom dit via en legitim `add-run`. Utan detta
858
+ // kunde en planterad JSON, aldrig arkiverad, ändå lyfta en spärr och
859
+ // bli priced-kontrollens gällande dokument.
860
+ if (!(state.runs ?? []).some((r) => r.file === row.galler_fil)) {
861
+ throw new ArkivFel(
862
+ `--galler-fil "${row.galler_fil}" finns inte bland arkiverade körningar ` +
863
+ `för ${row.axel} (state.json.runs). Ett utslag kan bara peka på en fil ` +
864
+ 'som faktiskt arkiverades via add-run',
865
+ )
866
+ }
867
+ }
868
+ state.granskarutslag = [...(state.granskarutslag ?? []), row]
869
+ writeStateOrThrow(state)
870
+ rowCount = state.granskarutslag.length
871
+ } catch (e) {
872
+ releaseLock(lockPath)
873
+ if (e instanceof ArkivFel) {
874
+ console.error(e.message)
875
+ process.exit(2)
876
+ }
877
+ throw e
878
+ }
879
+ releaseLock(lockPath)
880
+ console.log(`OK: ${typ}-utslag (${rowCount} rader)`)
881
+ } else if (cmd === 'set-status') {
882
+ const status = rest[0]
883
+ if (!status) {
884
+ console.error('set-status kräver ett statusvärde')
885
+ process.exit(2)
886
+ }
887
+ if (!STATUSAR.includes(status)) {
888
+ console.error(`okänt statusvärde: ${status} (giltiga: ${STATUSAR.join(', ')})`)
889
+ process.exit(2)
890
+ }
891
+ const lockPath = acquireLock()
892
+ let stopp = []
893
+ try {
894
+ const state = readStateOrThrow()
895
+ if (status === 'priced') {
896
+ // pricedBlockers kan kasta ArkivFel (trasigt/saknat gällande pricing-
897
+ // fil) — den vägen fångas av samma catch nedan och blir exit 2, ALDRIG
898
+ // tolkat som "inget blockerar".
899
+ stopp = pricedBlockers(state)
900
+ }
901
+ if (stopp.length === 0) {
902
+ state.status = status
903
+ writeStateOrThrow(state)
904
+ }
905
+ } catch (e) {
906
+ releaseLock(lockPath)
907
+ if (e instanceof ArkivFel) {
908
+ console.error(e.message)
909
+ process.exit(2)
910
+ }
911
+ throw e
912
+ }
913
+ releaseLock(lockPath)
914
+ if (stopp.length > 0) {
915
+ for (const s of stopp) console.log(`VÄGRAR priced: ${s}`)
916
+ process.exit(1)
917
+ }
918
+ console.log(`OK: status ${status}`)
919
+ } else if (cmd === 'kalibrering') {
920
+ // Läs-enbart svepvy: `katalog` är här arkivROTEN, inte en enskild post.
921
+ // Ingen lås, ingen skrivning — en trasig state.json någonstans i trädet
922
+ // ska aldrig stoppa hela listningen, bara hoppas över och rapporteras.
923
+ const root = katalog
924
+ const rader = []
925
+ const hoppade = []
926
+ const walk = (dir) => {
927
+ let entries
928
+ try {
929
+ entries = readdirSync(dir, { withFileTypes: true })
930
+ } catch (e) {
931
+ hoppade.push(`${dir}: ${e.message}`)
932
+ return
933
+ }
934
+ if (entries.some((e) => e.isFile() && e.name === 'state.json')) {
935
+ const p = path.join(dir, 'state.json')
936
+ try {
937
+ const parsed = JSON.parse(readFileSync(p, 'utf8'))
938
+ const utslagList = Array.isArray(parsed?.granskarutslag) ? parsed.granskarutslag : []
939
+ for (const u of utslagList) {
940
+ if (u && typeof u === 'object' && u.typ === 'kalibrering') {
941
+ const rel = path.relative(root, dir) || '.'
942
+ rader.push(`${rel} · ${u.fraga} · ${u.beslutad_av} · ${u.date}`)
943
+ }
944
+ }
945
+ } catch (e) {
946
+ hoppade.push(`${p}: ${e.message}`)
947
+ }
948
+ }
949
+ for (const entry of entries) {
950
+ if (entry.isDirectory()) walk(path.join(dir, entry.name))
951
+ }
952
+ }
953
+ walk(root)
954
+ for (const r of rader) console.log(r)
955
+ for (const h of hoppade) console.error(`hoppar över: ${h}`)
956
+ } else if (cmd === 'ny-korning') {
957
+ // katalog är HÄR modellkatalogen (experiments/<slug>/<modell>), inte en
958
+ // postkatalog. Den skapas om den saknas: första körningen av en modell ska
959
+ // inte kräva ett förberedande steg.
960
+ try {
961
+ mkdirSync(katalog, { recursive: true })
962
+ } catch (e) {
963
+ console.error(`kunde inte skapa modellkatalogen ${katalog}: ${e.message}`)
964
+ process.exit(2)
965
+ }
966
+ for (let n = 1; n <= MAX_KORNINGAR; n++) {
967
+ const p = path.join(katalog, `k${n}`)
968
+ try {
969
+ // UTAN recursive: mkdir är atomiskt och kastar EEXIST om namnet är taget.
970
+ // Med recursive:true skulle den tyst lyckas på en befintlig katalog, och
971
+ // två samtidiga processer skulle dela körningskatalog. Det är hela
972
+ // poängen med det här subkommandot.
973
+ mkdirSync(p)
974
+ console.log(p)
975
+ process.exit(0)
976
+ } catch (e) {
977
+ if (e.code === 'EEXIST') continue
978
+ console.error(`kunde inte skapa ${p}: ${e.message}`)
979
+ process.exit(2)
980
+ }
981
+ }
982
+ console.error(`fler än ${MAX_KORNINGAR} körningar i ${katalog}, något är fel`)
983
+ process.exit(2)
984
+ } else if (cmd === 'proveniens') {
985
+ // katalog är HÄR körningskatalogen (experiments/<slug>/<modell>/k<N>).
986
+ // Stämplingen stod tidigare bara som prosa i SKILL.md steg 4 och blev
987
+ // därför aldrig av: varje state.json på experimentytan saknade nyckeln och
988
+ // rapportens proveniensblock visade streck. Ett steg som ålägger startaren
989
+ // en skrivning måste anropa ett kommando.
990
+ const modell = requireFlag('modell')
991
+ const konto = requireFlag('konto')
992
+ const underlagFil = requireFlag('underlag')
993
+ const datum = flag('datum') ?? today()
994
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(datum)) {
995
+ console.error(`--datum måste vara YYYY-MM-DD (fick "${datum}")`)
996
+ process.exit(2)
997
+ }
998
+
999
+ /** Hashen räknas över hashar, inte över sammanslaget innehåll: sorterade
1000
+ * rader "<relativ sökväg>:<markör-strippad filhash>". Sorteringen gör
1001
+ * värdet oberoende av läsordning, och markör-strippningen är oundgänglig
1002
+ * av samma skäl som i runs[] — installern stämplar om filerna vid varje
1003
+ * haus-release, så en rå hel-fils-hash skulle byta värde utan att något
1004
+ * innehåll ändrats. */
1005
+ const referensHash = () => {
1006
+ const rader = []
1007
+ for (const rel of [...PROVENIENS_REFERENSER].sort()) {
1008
+ const p = path.join(claudeDir(), rel)
1009
+ try {
1010
+ rader.push(`${rel}:${markerStrippedSha256(p)}`)
1011
+ } catch (e) {
1012
+ // Aldrig en tyst överhoppad fil: hoppas en fil över betyder hashen
1013
+ // något annat än förra veckan, utan att skillnaden syns någonstans.
1014
+ console.error(`kunde inte läsa referensfilen ${p}: ${e.message}`)
1015
+ process.exit(2)
1016
+ }
1017
+ }
1018
+ return createHash('sha256').update(rader.join('\n')).digest('hex')
1019
+ }
1020
+
1021
+ let underlagHash
1022
+ try {
1023
+ underlagHash = markerStrippedSha256(underlagFil)
1024
+ } catch (e) {
1025
+ console.error(`kunde inte läsa underlaget ${underlagFil}: ${e.message}`)
1026
+ process.exit(2)
1027
+ }
1028
+
1029
+ const stampel = {
1030
+ modell,
1031
+ datum,
1032
+ konto,
1033
+ skill_sha256: referensHash(),
1034
+ underlag_sha256: underlagHash,
1035
+ }
1036
+
1037
+ // ADR-0099: faktureringslagret är primär verifieringskälla för modell-
1038
+ // override. Finns körningens korning.json antecknas den kanoniserade
1039
+ // nyckelmängden ur modelUsage (suffix som `[1m]` strippas) — det är den
1040
+ // startaren jämför mot varvets begärda modell. Saknas filen skrivs inget
1041
+ // fält: en påhittad tom lista ser ut som "inga modeller anropades".
1042
+ try {
1043
+ const korning = JSON.parse(readFileSync(path.join(katalog, 'korning.json'), 'utf8'))
1044
+ const nycklar = Object.keys(korning.modelUsage ?? {})
1045
+ if (nycklar.length > 0)
1046
+ stampel.fakturerade_modeller = [...new Set(nycklar.map(canonicalModel))].sort()
1047
+ } catch (e) {
1048
+ if (e.code !== 'ENOENT') {
1049
+ console.error(`kunde inte läsa korning.json för fakturerade_modeller: ${e.message}`)
1050
+ process.exit(2)
1051
+ }
1052
+ }
1053
+
1054
+ // Delarna är samma körning: samma modell, samma datum, samma hashar. De
1055
+ // stämplas var för sig i sin egen state.json, aldrig genom att rapporten
1056
+ // låter delkolumnen ärva förälderns värden vid läsning.
1057
+ const delar = readdirSync(katalog, { withFileTypes: true })
1058
+ .filter((e) => e.isDirectory() && /^del-\d+$/.test(e.name))
1059
+ .map((e) => path.join(katalog, e.name))
1060
+ // En delkatalog utan state.json är inte en körning ännu och hoppas över;
1061
+ // att fälla hela stämplingen på den vore att låta ett halvfärdigt DELA_UPP
1062
+ // blockera proveniensen för allt annat.
1063
+ .filter((d) => existsSync(path.join(d, 'state.json')))
1064
+ .sort()
1065
+
1066
+ const lockPath = acquireLock()
1067
+ try {
1068
+ // Två pass med flit: alla kataloger prövas mot en befintlig stämpel INNAN
1069
+ // någon skrivs, annars kan en avvikande del-2 lämna föräldern stämplad och
1070
+ // delarna halvstämplade, ett läge ingen kod städar upp.
1071
+ const attSkriva = []
1072
+ for (const d of [katalog, ...delar]) {
1073
+ const state = readStateOrThrow(d)
1074
+ const befintlig = state.proveniens
1075
+ if (befintlig === undefined) {
1076
+ attSkriva.push([d, state])
1077
+ continue
1078
+ }
1079
+ const skiljer = Object.keys(stampel).filter(
1080
+ (k) => (befintlig === null ? undefined : befintlig[k]) !== stampel[k],
1081
+ )
1082
+ if (skiljer.length > 0) {
1083
+ throw new ArkivFel(
1084
+ `${statePath(d)} har redan en proveniens som skiljer sig (${skiljer.join(', ')}) — arkivet är append-only, en omstämpling raderar just den skillnad stämpeln finns för att visa`,
1085
+ )
1086
+ }
1087
+ }
1088
+ for (const [d, state] of attSkriva) writeStateOrThrow({ ...state, proveniens: stampel }, d)
1089
+ releaseLock(lockPath)
1090
+ console.log(
1091
+ attSkriva.length === 0
1092
+ ? 'OK: proveniens oförändrad'
1093
+ : `OK: proveniens stämplad i ${attSkriva.length} katalog(er)`,
1094
+ )
1095
+ } catch (e) {
1096
+ releaseLock(lockPath)
1097
+ if (e instanceof ArkivFel) {
1098
+ console.error(e.message)
1099
+ process.exit(2)
1100
+ }
1101
+ throw e
1102
+ }
1103
+ } else {
1104
+ console.error(
1105
+ 'okänt kommando (add-run, next-n, ny-korning, proveniens, sparr, utslag, set-status, kalibrering)',
1106
+ )
1107
+ process.exit(2)
1108
+ }
1109
+
1110
+ /** Det SENASTE viktningsutslaget (om något) som lyfter en axel vid ett givet
1111
+ * gällande n — namnger axeln, har ett `n` som är EXAKT det gällande n:et
1112
+ * (inte "minst lika högt"), och bär en `galler_fil`.
1113
+ *
1114
+ * Jämförelsen är AVSIKTLIGT `===`, inte `>=`. Ett utslag gäller EXAKT det
1115
+ * varv det skrevs för — en människa kan bara döma om ett dokument hon har
1116
+ * läst, aldrig om ett framtida varv som inte finns än. Ett nytt varv är en
1117
+ * ny bedömning ingen har dömt om, och ska kräva ett NYTT utslag i stället
1118
+ * för att ärva ett gammalt. Med `>=` lyfte/omdirigerade en rad med ett för
1119
+ * högt `n` (skrivet innan det varvet ens fanns — utslags `--n` betyder
1120
+ * gällande maxN, medan `next-n` skriver ut maxN+1, så de två självklara
1121
+ * talen skiljer med ett och är lätta att förväxla) VARJE varv från och med
1122
+ * det att arkivet skrevs och tills gällande n hann i kapp — och en absurt
1123
+ * hög `--n` (t.ex. 1000, som `utslag` accepterar som ett vanligt heltal)
1124
+ * gjorde blocket PERMANENT lyft för alla varv upp till dess, eftersom
1125
+ * write-once gör raden omöjlig att dra tillbaka. `utslag`s skrivtidskontroll
1126
+ * (`n > maxN` vägras, se dess kommentar) stänger dörren vid källan för NYA
1127
+ * rader; den här `===`-jämförelsen är det KOMPLETTERANDE djupförsvaret på
1128
+ * lässidan — mot en rad som ändå finns (äldre arkiv från före
1129
+ * skrivtidskontrollen, eller ett handredigerat state.json), inte en ersättning
1130
+ * för skrivtidskontrollen. FÖRSÖK ALDRIG "förenkla" tillbaka till `>=` — det
1131
+ * återinför exakt den permanenta-block-buggen.
1132
+ *
1133
+ * "Senaste" (inte första) matchande rad: write-once gäller ORDNINGEN
1134
+ * raderna skrevs i, inte att bara den första domen om en axel räknas för
1135
+ * alltid — samma princip som `sparrSkal` redan använder för att hitta den
1136
+ * senaste runs-raden för en fil (`[...runs].reverse().find(...)`).
1137
+ *
1138
+ * Delad av `liftedByUtslag` (som bara bryr sig OM något lyfter) och
1139
+ * `gallandeDokumentFil` (som bryr sig om VILKEN fil den domen pekar på) —
1140
+ * ett enda ställe som avgör detta, så de två aldrig kan råka svara på olika
1141
+ * frågor om två utslag skulle peka åt olika håll. */
1142
+ function liftingUtslag(state, typ, n) {
1143
+ return (
1144
+ [...(state.granskarutslag ?? [])]
1145
+ .reverse()
1146
+ .find((u) => u.typ === 'viktning' && u.axel === typ && (u.n ?? 0) === n && u.galler_fil) ??
1147
+ null
1148
+ )
1149
+ }
1150
+
1151
+ /** Granskarutslag med typ, axel och gällande N som lyfter spärren när ett
1152
+ * senare riktat varv landat LIKA/LIKA_TOLERANS: en viktningsrad som namnger
1153
+ * just den axeln och exakt det gällande n:et (se `liftingUtslag`) räcker.
1154
+ * `utslag`-kommandot är den enda skrivvägen till granskarutslag, så utan en
1155
+ * sådan rad är arkivet tomt här och funktionen alltid falsk — det är
1156
+ * "stubben" tills utslag finns, inte ett hårdkodat `return false`. */
1157
+ function liftedByUtslag(state, typ, n) {
1158
+ return liftingUtslag(state, typ, n) !== null
1159
+ }
1160
+
1161
+ /** Sparr-spärrens skäl, utan I/O — delad av `sparr`-kommandot och
1162
+ * `pricedBlockers` (set-status priced måste vägra av exakt samma skäl).
1163
+ *
1164
+ * TRE skäl blockerar, och inget är de andras specialfall:
1165
+ * (1) `double_run: "olika"` på gällande fil, den ordinarie dubbelkörnings-
1166
+ * oenigheten (se `bedöm` steg 6 i SKILL.md);
1167
+ * (2) `partial_rerun: true` på gällande fil UTAN en matchande dom. En
1168
+ * beställd omkörning (`döm`, vägen "Beställ omkörning") skriver ALDRIG
1169
+ * ett jämförelseutslag av sig själv, det är en mätning som väntar på
1170
+ * granskarens dom, inte ett utslag i sig. Fix-runda 1-regression: den
1171
+ * gamla mekaniken tvingade dubbelkörning på det riktade varvet, så
1172
+ * raden bar alltid `double_run: "olika"/"bekräftad"/"tolerans"` från
1173
+ * compare-scriptet. Task 8 tog bort den tvångsdubblingen (döm beställer
1174
+ * EN körning) utan att ersätta spärren: en ensam körning har inget att
1175
+ * jämföra mot, så raden fick `double_run: null`, och (1) ensam missade
1176
+ * den helt: sparr gav OK och `set-status priced` gick igenom med tomt
1177
+ * `granskarutslag` (granskningens repro). `partial_rerun` är signalen
1178
+ * här, inte `double_run`s värde: vilket värde det än råkar bära (eller
1179
+ * sakna) väntar en partial_rerun-rad alltid på en explicit dom.
1180
+ * (3) `double_run` bär ett värde som INTE är `null`/`undefined` och INTE
1181
+ * finns i `DOUBLE_RUN_VALUES`. Ett okänt värde tolkas som blockerande,
1182
+ * aldrig som "inget att bry sig om". `add-run` vitlistar numera värdet
1183
+ * vid SKRIVNING (se dess kommentar), men den kontrollen skyddar bara
1184
+ * RADER SOM SKRIVS EFTER fixen. Skarpa arkiv från före den (eller ett
1185
+ * handredigerat state.json) kan bära ett värde ingen vitlista någonsin
1186
+ * godkände, konkret `"oavgjord"`, ett ord en tidigare orkestrator-
1187
+ * generation hittade på för "kunde inte avgöras" innan `sparr` ens
1188
+ * fanns som kod. Fail-OPEN här (bara jämföra mot exakt `"olika"` och
1189
+ * strunta i resten) läser en okänd sträng som en frisläppt post, precis
1190
+ * den läsordning granskningen visade. Fail-CLOSED, som `pricedBlockers`
1191
+ * redan tillämpar på pricing-filens `utfall`/`outcome`-enum, är den
1192
+ * enda säkra tolkningen av ett fält vars hela existens är att avgöra om
1193
+ * en människa redan sagt sitt: vet scriptet inte vad värdet betyder, kan
1194
+ * det aldrig veta att INGEN dom krävs. */
1195
+ function sparrSkal(state) {
1196
+ const runs = state.runs ?? []
1197
+ const skal = []
1198
+ for (const { typ, axel } of AXLAR) {
1199
+ const n = maxN(runs, typ)
1200
+ if (n === 0) continue
1201
+ const fil = `${typ}-pricing-v${n}.json`
1202
+ const rad = [...runs].reverse().find((r) => r.file === fil)
1203
+ if (!rad || liftedByUtslag(state, typ, n)) continue
1204
+ if (rad.double_run != null && !DOUBLE_RUN_VALUES.includes(rad.double_run)) {
1205
+ skal.push(
1206
+ `${axel} bär okänt double_run "${rad.double_run}" på ${fil}, ett värde som inte ` +
1207
+ 'går att tolka som avgjort räknas som blockerande',
1208
+ )
1209
+ } else if (rad.double_run === 'olika') {
1210
+ skal.push(`${axel} bär double_run "olika" på ${fil} utan dom`)
1211
+ } else if (rad.partial_rerun === true) {
1212
+ skal.push(`${axel} har en beställd omkörning (partial_rerun) på ${fil} som väntar på dom`)
1213
+ }
1214
+ }
1215
+ return skal
1216
+ }
1217
+
1218
+ /** Den GÄLLANDE dokumentfilen för en axel vid n — det som ska läsas för
1219
+ * stopputfallskontrollen och räknas som posten prisbärande underlag. Detta
1220
+ * är en MEDVETEN PRECISERING utöver briefen, som bara sa "läs JSON-filen ur
1221
+ * katalogen" utan att ta ställning till VILKEN fil när ett viktningsutslag
1222
+ * pekar om den gällande handlingen.
1223
+ *
1224
+ * Om ett viktningsutslag har lyft spärren för denna n, ÄR dess `galler_fil`
1225
+ * det gällande dokumentet — inte den vanliga `<typ>-pricing-v<n>.json`. Det
1226
+ * är hela poängen med fältet: granskningens repro visade att utan den här
1227
+ * halvan pekar domen på en fil ingenting läser. Konkret kedja som annars
1228
+ * prissätter en post en människa dömt ska eskaleras: klassfilen v2 säger
1229
+ * MEDIUM, dess run2 säger ESKALERA, granskaren dömer (via `utslag --typ
1230
+ * viktning --galler-fil classification-pricing-v2-run2.json`) att run2
1231
+ * gäller. Om priced-kontrollen då fortsätter läsa v2.json i stället för
1232
+ * run2.json ser den bara MEDIUM och släpper igenom en post som borde
1233
+ * eskaleras. Så länge gällande dokument alltid är galler_fil när ett lyft
1234
+ * finns, läser båda halvorna (stopputfall OCH prisunderlag) samma fil som
1235
+ * domen faktiskt pekar på. */
1236
+ function gallandeDokumentFil(state, typ, n) {
1237
+ return liftingUtslag(state, typ, n)?.galler_fil ?? `${typ}-pricing-v${n}.json`
1238
+ }
1239
+
1240
+ /** Läser en gällande pricing-fil (classification eller score) ur katalogen
1241
+ * och returnerar den PARSADE, validerade datan. Arkivet PÅSTÅR via sin
1242
+ * runs-rad (eller ett viktningsutslags `galler_fil`) att filen finns och är
1243
+ * gällande — saknas den, är den trasig JSON, eller är den parsningsbar men
1244
+ * inte ett objekt (array, tal, sträng, null), är det ett integritetsfel i
1245
+ * arkivet, ALDRIG en grön lampa för priced. Samma princip som
1246
+ * `readStateOrThrow` redan tillämpar på state.json (se dess kommentar) —
1247
+ * prisfilsläsaren hade inte ärvt den kontrollen, så en fil som PARSAR men
1248
+ * saknar utfall (`{}`, `null`, `[]`) gav `?.classification?.utfall` /
1249
+ * `?.outcome` lika med `undefined`, som inte finns i någon stopplista, och
1250
+ * grinden rapporterade att inget blockerar. Kastar ArkivFel; anroparen
1251
+ * (set-status) fångar det och avslutar med exit 2, skilt från den vanliga
1252
+ * spärr-vägran (exit 1). */
1253
+ function readPricingFileOrThrow(fil) {
1254
+ const p = path.join(katalog, fil)
1255
+ let raw
1256
+ try {
1257
+ raw = readFileSync(p, 'utf8')
1258
+ } catch (e) {
1259
+ throw new ArkivFel(`gällande fil ${p} saknas eller går inte att läsa: ${e.message}`)
1260
+ }
1261
+ let parsed
1262
+ try {
1263
+ parsed = JSON.parse(raw)
1264
+ } catch (e) {
1265
+ throw new ArkivFel(`${p} är trasig JSON: ${e.message}`)
1266
+ }
1267
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
1268
+ const fick = parsed === null ? 'null' : Array.isArray(parsed) ? 'array' : typeof parsed
1269
+ throw new ArkivFel(`${p} är inte ett objekt (fick ${fick})`)
1270
+ }
1271
+ return parsed
1272
+ }
1273
+
1274
+ /** Parfullständighet: ett pris är klass GÅNGER poäng, så `priced` kräver att
1275
+ * BÅDA axlarna faktiskt bildar ett par, inte bara att ingenting annat råkar
1276
+ * spärra. `sparrSkal` (ovan) blockerar en ENSKILD axel vars SENASTE rad bär
1277
+ * `double_run: "olika"` eller en oavgjord `partial_rerun` — den itererar
1278
+ * axlarna OBEROENDE av varandra och hoppar helt över en axel vars `maxN`
1279
+ * är 0, så den ser aldrig att den ANDRA axeln saknas eller pekar på ett
1280
+ * annat varv. Två granskningsrepron visade detta konkret: en katalog med
1281
+ * BARA klassaxeln arkiverad (poängaxeln aldrig körd, kanske avvisad av
1282
+ * contract-check och aldrig omkörd) gick till `priced` med exit 0 — och en
1283
+ * katalog med klass på v2 och poäng på v1 (två varv som aldrig arkiverades
1284
+ * TILLSAMMANS, ingen ATOMÄR arkivering skedde) gick också igenom. Ingen av
1285
+ * scenarierna rör en ENSKILD rad på fel sätt, så `sparrSkal` har inget att
1286
+ * hänga upp sig på.
1287
+ *
1288
+ * Legitim ojämnhet finns: en riktad omkörning (döm, "Beställ omkörning",
1289
+ * Task 8) bumpar AVSIKTLIGT bara den oeniga axelns N och lämnar den stående
1290
+ * axeln kvar på sitt gamla varv (se `bedöm` steg 8 och runs-kontraktet i
1291
+ * SKILL.md för `partial_rerun`/`pairs_with`). Det är INTE samma sak som de
1292
+ * två repronas trasiga tillstånd: raden som bär det högre N:et bär då
1293
+ * `partial_rerun: true` — själva avsikten att vara ojämn står skriven på
1294
+ * raden. Utan den flaggan, eller utan att någon rad alls finns för en axel,
1295
+ * finns ingen förklaring, och paret är helt enkelt inte komplett.
1296
+ * `sparrSkal` blockerar separat en OAVGJORD riktad omkörning (raden väntar
1297
+ * på granskarens dom) — den här kontrollen är inte en ersättning för den,
1298
+ * bara den KOMPLETTERANDE strukturella kontrollen att paret existerar och,
1299
+ * om det avviker, att avvikelsen är just en sådan förklarad omkörning. */
1300
+ function pairIncomplete(state) {
1301
+ const runs = state.runs ?? []
1302
+ const nClass = maxN(runs, 'classification')
1303
+ const nScore = maxN(runs, 'score')
1304
+ // ADR-0098: klassaxeln är borttagen — nya arkiv har bara poängaxeln och
1305
+ // prövas aldrig mot par-regeln. Regeln finns kvar ENBART för äldre arkiv
1306
+ // som bär en klassaxel: där är ett halvt eller omatchat par fortfarande
1307
+ // precis lika fel som när båda axlarna skrevs.
1308
+ if (nClass === 0) return null
1309
+ if (nScore === 0) {
1310
+ return 'paret är inte komplett — poängaxeln har ingen gällande fil'
1311
+ }
1312
+ if (nClass === nScore) return null
1313
+ // Den axel som ligger FÖRE (lägre N) är den stående axeln i en riktad
1314
+ // omkörning — det är den HÖGRE axelns senaste rad som bär förklaringen,
1315
+ // om någon finns.
1316
+ const [hogreTyp, hogreN] = nClass > nScore ? ['classification', nClass] : ['score', nScore]
1317
+ const fil = `${hogreTyp}-pricing-v${hogreN}.json`
1318
+ const rad = [...runs].reverse().find((r) => r.file === fil)
1319
+ if (rad?.partial_rerun === true) return null
1320
+ return (
1321
+ `paret är inte komplett — klassaxelns gällande varv (v${nClass}) och ` +
1322
+ `poängaxelns (v${nScore}) skiljer sig utan en förklarad enaxlig omkörning ` +
1323
+ `(${fil} saknar partial_rerun: true)`
1324
+ )
1325
+ }
1326
+
1327
+ /** Skäl som vägrar `set-status priced`: sparr-spärren, parfullständigheten,
1328
+ * experimentmarkören, och gällande klass-/poängfils utfall om det är ett
1329
+ * stopputfall. Kastar ArkivFel vidare (från readPricingFileOrThrow, eller
1330
+ * när utfallsfältet inte är ett KÄNT värde ur protokollets enum — se
1331
+ * CLASS_UTFALL/SCORE_OUTCOMES i lib.mjs) på ett trasigt arkiv — set-status
1332
+ * översätter det till exit 2, aldrig "inget blockerar".
1333
+ *
1334
+ * Enum-kontrollen är en ALLOW-lista, inte bara "är det en sträng". CLASS_STOP/
1335
+ * SCORE_STOP nedan är DENY-listor (stopputfall) — en deny-lista släpper
1336
+ * igenom allt den inte känner igen, så en felstavning ("OFERT"), en tom
1337
+ * sträng, eller fel skiftläge ("eskalera") hade passerat grinden OGRANSKAT
1338
+ * eftersom inget av dem finns i deny-listan. Allow-listan failar STÄNGT i
1339
+ * stället: allt utanför de kända värdena är ett integritetsfel, prövat
1340
+ * FÖRE deny-listan någonsin tillfrågas. */
1341
+ function pricedBlockers(state) {
1342
+ const skal = [...sparrSkal(state)]
1343
+ const parBrist = pairIncomplete(state)
1344
+ if (parBrist) skal.push(parBrist)
1345
+ if (state.experiment === true) {
1346
+ skal.push('experimentvariant blir aldrig priced')
1347
+ }
1348
+ const runs = state.runs ?? []
1349
+ const nClass = maxN(runs, 'classification')
1350
+ if (nClass > 0) {
1351
+ const fil = gallandeDokumentFil(state, 'classification', nClass)
1352
+ const data = readPricingFileOrThrow(fil)
1353
+ const utfall = data.classification?.utfall
1354
+ if (!CLASS_UTFALL.includes(utfall)) {
1355
+ throw new ArkivFel(
1356
+ `${path.join(katalog, fil)}: classification.utfall: ogiltigt värde "${utfall}" ` +
1357
+ `(giltiga: ${CLASS_UTFALL.join(', ')})`,
1358
+ )
1359
+ }
1360
+ if (CLASS_STOP.includes(utfall)) skal.push(`${fil} har stopputfallet ${utfall}`)
1361
+ }
1362
+ const nScore = maxN(runs, 'score')
1363
+ if (nScore > 0) {
1364
+ const fil = gallandeDokumentFil(state, 'score', nScore)
1365
+ const data = readPricingFileOrThrow(fil)
1366
+ const outcome = data.outcome
1367
+ if (!SCORE_OUTCOMES.includes(outcome)) {
1368
+ throw new ArkivFel(
1369
+ `${path.join(katalog, fil)}: outcome: ogiltigt värde "${outcome}" ` +
1370
+ `(giltiga: ${SCORE_OUTCOMES.join(', ')})`,
1371
+ )
1372
+ }
1373
+ if (SCORE_STOP.includes(outcome)) skal.push(`${fil} har stopputfallet ${outcome}`)
1374
+ }
1375
+ return skal
1376
+ }