@haus-tech/haus-workflow 4.9.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.
- package/CHANGELOG.md +27 -0
- package/dist/cli.js +847 -499
- package/library/catalog/manifest.json +97 -34
- package/library/global/agents/request-assessor.md +2 -2
- package/library/global/agents/request-scorer.md +13 -8
- package/library/global/agents/request-specifier-eskalerad.md +1 -1
- package/library/global/agents/request-specifier.md +1 -1
- package/library/global/skills/request/SKILL.md +675 -363
- package/library/global/skills/request/scripts/archive.mjs +1376 -0
- package/library/global/skills/request/scripts/check-lasta.mjs +64 -0
- package/library/global/skills/request/scripts/compare-scores.mjs +23 -15
- package/library/global/skills/request/scripts/contract-check.mjs +108 -36
- package/library/global/skills/request/scripts/lib.mjs +66 -0
- package/library/global/skills/request/scripts/precheck.mjs +30 -27
- package/library/global/skills/request/scripts/report.mjs +571 -0
- package/library/global/skills/request/scripts/resolve-anchors.mjs +243 -0
- package/library/global/skills/request-assess-impact/SKILL.md +10 -10
- package/library/global/skills/request-score/SKILL.md +19 -10
- package/library/global/skills/request-sync/SKILL.md +4 -4
- package/library/global/skills/request-write-spec/SKILL.md +6 -6
- package/library/global/skills/request-write-spec/references/specformat.md +2 -2
- package/package.json +1 -1
- package/library/global/agents/request-classifier.md +0 -55
- package/library/global/skills/request/scripts/compare-classifications.mjs +0 -94
- package/library/global/skills/request/scripts/quote-repair.mjs +0 -166
- package/library/global/skills/request-classify/SKILL.md +0 -79
- 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
|
+
}
|