@haus-tech/haus-workflow 5.0.1 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,17 +8,28 @@
8
8
  *
9
9
  * Del 1: add-run, next-n, sparr. Del 2 (Task 7): utslag (granskardomar),
10
10
  * set-status (statusövergångar, med spärr på priced) och kalibrering
11
- * (läs-enbart svepvy över kalibreringsnotiser).
11
+ * (läs-enbart svepvy över kalibreringsnotiser). Del 3 (plan requestmotor-14001,
12
+ * Task 6): stopp (stopputfall utan scorer, ur spec.md eller impact.md), respec
13
+ * (nollställning inför en ny spec) och bluf (tabellvy över en katalog och dess
14
+ * delar), samt effort ur agentdefinitionen i add-run och ett state.json-skelett
15
+ * från ny-korning. Del 4 (plan utvardera-parallellt-svep, Task 1): experiment,
16
+ * den enda skrivaren av experiment.json på experimentytan, låst och atomisk,
17
+ * så samtidiga svep-processer aldrig tappar varandras rader (#493).
12
18
  *
13
19
  * Användning:
14
20
  * node archive.mjs add-run <katalog> --file F --skill S --agent A --model M [flaggor]
15
21
  * node archive.mjs next-n <katalog> --typ classification [--typ score]
16
- * node archive.mjs ny-korning <modellkatalog>
22
+ * node archive.mjs ny-korning <modellkatalog> [--request-id G]
23
+ * node archive.mjs ny-del <körningskatalog> --typ bundle|split --part N --of M --request-id G
17
24
  * node archive.mjs proveniens <körningskatalog> --modell M --konto K --underlag F [--datum D]
18
25
  * node archive.mjs sparr <katalog>
19
26
  * node archive.mjs utslag <katalog> --typ viktning|kalibrering|sakandring --beslutad-av N [...]
20
27
  * node archive.mjs set-status <katalog> <status>
28
+ * node archive.mjs stopp <katalog> --utfall KONCEPT|DIAGNOS|TILLBAKA_TILL_SPEC|ESKALERA --kalla spec.md|impact.md|granskarblock.md [--orsak <text>]
29
+ * node archive.mjs respec <katalog>
30
+ * node archive.mjs bluf <katalog>
21
31
  * node archive.mjs kalibrering <arkivrot>
32
+ * node archive.mjs experiment <experimentkatalog> [--satt K=V]... [--variant M]... [--korning M/kN --status S [--orsak T]]
22
33
  * Exit 0 = ok, 1 = spärrad (sparr/set-status priced), 2 = användnings-/tillståndsfel.
23
34
  */
24
35
  import {
@@ -38,8 +49,13 @@ import path from 'node:path'
38
49
  import { createHash } from 'node:crypto'
39
50
  import {
40
51
  markerStrippedSha256,
52
+ stripHausMarker,
41
53
  claudeDir,
42
54
  canonicalModel,
55
+ kandidatIn,
56
+ avvikelseEtikett,
57
+ prisavgorandeVal,
58
+ findUpward,
43
59
  CLASS_UTFALL,
44
60
  SCORE_OUTCOMES,
45
61
  } from './lib.mjs'
@@ -48,7 +64,7 @@ const [cmd, katalog, ...rest] = process.argv.slice(2)
48
64
 
49
65
  if (!cmd || !katalog) {
50
66
  console.error(
51
- 'användning: archive.mjs <add-run|next-n|ny-korning|proveniens|sparr|utslag|set-status|kalibrering> <katalog> [flaggor]',
67
+ 'användning: archive.mjs <add-run|next-n|ny-korning|ny-del|proveniens|sparr|utslag|set-status|stopp|respec|bluf|kalibrering|experiment> <katalog> [flaggor]',
52
68
  )
53
69
  process.exit(2)
54
70
  }
@@ -81,6 +97,35 @@ const requireFlag = (name) => {
81
97
  }
82
98
  const has = (name) => rest.includes(`--${name}`)
83
99
 
100
+ /** Fail-closed flaggkontroll, samma princip som add-run och next-n tillämpar
101
+ * inline: varje token i `rest` måste höra till en KÄND flagga, annars är det
102
+ * ett fel (exit 2), aldrig ett tyst struntvärde. Värdeflaggor konsumerar
103
+ * nästa token, booleska flaggor bara sig själva. */
104
+ const avvisaOkandaFlaggor = (kommando, vardeflaggor, boolflaggor = []) => {
105
+ const konsumerat = new Set()
106
+ rest.forEach((v, i) => {
107
+ if (!v.startsWith('--')) return
108
+ const namn = v.slice(2)
109
+ if (vardeflaggor.includes(namn)) {
110
+ konsumerat.add(i)
111
+ konsumerat.add(i + 1)
112
+ } else if (boolflaggor.includes(namn)) {
113
+ konsumerat.add(i)
114
+ }
115
+ })
116
+ const ovantat = rest.filter((_, i) => !konsumerat.has(i))
117
+ if (ovantat.length > 0) {
118
+ const giltiga = [...vardeflaggor, ...boolflaggor].map((n) => `--${n}`)
119
+ console.error(
120
+ `${kommando}: okänd eller ej konsumerad flagga/argument (${ovantat.join(' ')}), ` +
121
+ (giltiga.length === 0
122
+ ? 'kommandot tar inga flaggor'
123
+ : `giltiga flaggor: ${giltiga.join(', ')}`),
124
+ )
125
+ process.exit(2)
126
+ }
127
+ }
128
+
84
129
  /** Sökvägen till en katalogs state.json. Parametern har `katalog` som
85
130
  * default så varje befintlig anropare är oförändrad; `proveniens` behöver
86
131
  * den för att kunna stämpla delkatalogerna med samma kod som föräldern, i
@@ -158,6 +203,16 @@ const readStateOrThrow = (d = katalog) => {
158
203
  }
159
204
  assertRowArrayField(parsed, 'runs', d)
160
205
  assertRowArrayField(parsed, 'granskarutslag', d)
206
+ // `stopp` är null, saknat (äldre arkiv) eller ett objekt { utfall, kalla,
207
+ // date }. Allt annat är trasigt: pricedBlockers och bluf läser fältet, och
208
+ // en sträng där hade tyst lästs som "inget stopp".
209
+ if (
210
+ parsed.stopp !== undefined &&
211
+ parsed.stopp !== null &&
212
+ (typeof parsed.stopp !== 'object' || Array.isArray(parsed.stopp))
213
+ ) {
214
+ throw new ArkivFel(`${statePath(d)} har ett stopp-fält som varken är null eller ett objekt`)
215
+ }
161
216
  return parsed
162
217
  }
163
218
 
@@ -180,29 +235,71 @@ const loadStateOrExit = () => {
180
235
  * skrivfel (t.ex. EACCES) i stället för att låta undantaget nå toppen som
181
236
  * en okontrollerad krasch med exit 1 — i det här kontraktet betyder exit 1
182
237
  * spärrad, och ett misslyckat skrivförsök är inte det. */
183
- const writeStateOrThrow = (s, d = katalog) => {
238
+ const writeStateOrThrow = (s, d = katalog) => writeJsonAtomiskOrThrow(s, d, 'state.json')
239
+
240
+ /** Samma rename-dans för vilken JSON-fil som helst i en katalog: state.json
241
+ * och experiment.json delar en enda skrivväg i stället för två kopior som
242
+ * kan glida isär (filnamnet är den enda skillnaden). */
243
+ const writeJsonAtomiskOrThrow = (s, d, fil) => {
244
+ const mal = path.join(d, fil)
184
245
  const rand = Math.random().toString(36).slice(2, 8)
185
- const tmp = path.join(d, `.state.json.tmp-${process.pid}-${rand}`)
246
+ const tmp = path.join(d, `.${fil}.tmp-${process.pid}-${rand}`)
186
247
  try {
187
248
  writeFileSync(tmp, JSON.stringify(s, null, 2) + '\n')
188
249
  // rename(2) byter inod, så temp-filens läge (satt av umask vid writeFileSync)
189
250
  // 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.
251
+ // befintliga filen (t.ex. 0600) med standardläget. Bevara det gamla
252
+ // läget om filen redan fanns; en ny fil har inget läge att bevara.
192
253
  try {
193
- chmodSync(tmp, statSync(statePath(d)).mode)
254
+ chmodSync(tmp, statSync(mal).mode)
194
255
  } catch {
195
- // ingen befintlig state.json (ny post) — inget läge att bevara.
256
+ // ingen befintlig fil (ny post), inget läge att bevara.
196
257
  }
197
- renameSync(tmp, statePath(d))
258
+ renameSync(tmp, mal)
198
259
  } catch (e) {
199
260
  try {
200
261
  unlinkSync(tmp)
201
262
  } catch {
202
263
  // temp-filen skapades aldrig eller är redan borta — inget att städa.
203
264
  }
204
- throw new ArkivFel(`kunde inte skriva ${statePath(d)}: ${e.message}`)
265
+ throw new ArkivFel(`kunde inte skriva ${mal}: ${e.message}`)
266
+ }
267
+ }
268
+
269
+ /** Läser experiment.json i katalogen d. Saknad fil är GILTIG (ny post, tom
270
+ * behållare), trasig JSON eller en icke-objekt-fil är ett arkivfel. De två
271
+ * fält kommandot skriver strukturerat valideras på samma sätt som runs i
272
+ * state.json: `varianter` måste vara en array av strängar och `korningar`
273
+ * ett objekt, annars sprids skräp tyst vid nästa skrivning. Alla andra
274
+ * fält (även äldre `baselinemodell`/`kandidatmodell`) lämnas orörda. */
275
+ const readExperimentOrThrow = (d) => {
276
+ const fil = path.join(d, 'experiment.json')
277
+ if (!existsSync(fil)) return { state: {}, fanns: false }
278
+ let parsed
279
+ try {
280
+ parsed = JSON.parse(readFileSync(fil, 'utf8'))
281
+ } catch (e) {
282
+ throw new ArkivFel(`${fil} är trasig JSON: ${e.message}`)
283
+ }
284
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
285
+ const fick = parsed === null ? 'null' : Array.isArray(parsed) ? 'array' : typeof parsed
286
+ throw new ArkivFel(`${fil} är inte ett objekt (fick ${fick})`)
287
+ }
288
+ if (
289
+ parsed.varianter !== undefined &&
290
+ (!Array.isArray(parsed.varianter) || parsed.varianter.some((v) => typeof v !== 'string'))
291
+ ) {
292
+ throw new ArkivFel(`${fil} har ett varianter-fält som inte är en array av strängar`)
293
+ }
294
+ if (
295
+ parsed.korningar !== undefined &&
296
+ (parsed.korningar === null ||
297
+ typeof parsed.korningar !== 'object' ||
298
+ Array.isArray(parsed.korningar))
299
+ ) {
300
+ throw new ArkivFel(`${fil} har ett korningar-fält som inte är ett objekt`)
205
301
  }
302
+ return { state: parsed, fanns: true }
206
303
  }
207
304
 
208
305
  /** Synkron sömn (blockerar tråden) för låsets pollning. Standardknepet för
@@ -461,6 +558,16 @@ const SCORE_STOP = [
461
558
  'ESKALERA',
462
559
  ]
463
560
 
561
+ /** `stopp`-kommandots utfall och källor. Kandidatmarkören i spec.md
562
+ * (KONCEPT/DIAGNOS, ADR-0105), motsägelse-etiketten i impact.md
563
+ * (TILLBAKA_TILL_SPEC, ADR-0106) och raden Prisavgörande öppet val i
564
+ * granskarblock.md (TILLBAKA_TILL_SPEC eller ESKALERA, ADR-0118) är de tre
565
+ * källor som kan bära ett stopp UTAN scorer. DELA_UPP, OVER_SKALAN och
566
+ * INPUT_SAKNAS är scorerns utfall och når arkivet via prisfilen, aldrig via
567
+ * stopp; ESKALERA når arkivet på båda vägarna. */
568
+ const STOPP_UTFALL = ['KONCEPT', 'DIAGNOS', 'TILLBAKA_TILL_SPEC', 'ESKALERA']
569
+ const STOPP_KALLOR = ['spec.md', 'impact.md', 'granskarblock.md']
570
+
464
571
  /** Tak för antal körningar per modell. Skyddet är mot en trasig loop, inte mot
465
572
  * Tims arbetssätt: 999 körningar av samma modell på samma post är alltid ett
466
573
  * fel, aldrig en avsikt. */
@@ -488,6 +595,13 @@ const PROVENIENS_REFERENSER = [
488
595
  * gett en tyst, obegriplig status i stället för ett fel. */
489
596
  const STATUSAR = ['spec_draft', 'spec_synced', 'assessed', 'priced', 'guard_run']
490
597
 
598
+ /** Körningsstatus i experiment.json, exakt de literaler SKILL.md:s
599
+ * återupptagningsregel och svep.mjs läser. */
600
+ const EXPERIMENT_STATUSAR = ['klar', 'ofärdig', 'stopp', 'väntar på dom']
601
+ /** En körningsnyckel är <modell>/k<N>, samma form som katalogen under
602
+ * experimentkatalogen. */
603
+ const KORNING_NYCKEL_RE = /^[^/]+\/k\d+$/
604
+
491
605
  /** Giltiga `--double-run`-värden (SKILL.md runs-kontraktet: "bekräftad" |
492
606
  * "olika" | "tolerans" | null). `add-run` skrev tidigare VILKEN STRÄNG SOM
493
607
  * HELST rakt in i fältet, samma klass av brist som `STATUSAR` ovan stänger
@@ -507,6 +621,53 @@ const GALLANDE_RE = (typ) => new RegExp(`^${typ}-pricing-v(\\d+)\\.json$`)
507
621
  const maxN = (runs, typ) =>
508
622
  Math.max(0, ...runs.map((r) => Number(GALLANDE_RE(typ).exec(r.file ?? '')?.[1] ?? 0)))
509
623
 
624
+ /** Första rubrikraden i ett specdokument, utan markdown-tecken; null om ingen. */
625
+ const forstaRubrik = (text) => {
626
+ const rad = String(text)
627
+ .split('\n')
628
+ .map((l) => l.trim())
629
+ .find((l) => /^#{1,6}\s+\S/.test(l) || /^\*\*[^*]+\*\*\s*$/.test(l))
630
+ return rad
631
+ ? rad
632
+ .replace(/^#{1,6}\s+/, '')
633
+ .replace(/^\*\*|\*\*$/g, '')
634
+ .trim()
635
+ : null
636
+ }
637
+
638
+ /** Kebab-slug: gemener, åäö till aao, allt annat än [a-z0-9] blir bindestreck. */
639
+ const slugAv = (titel) =>
640
+ titel
641
+ .toLowerCase()
642
+ .replace(/å/g, 'a')
643
+ .replace(/ä/g, 'a')
644
+ .replace(/ö/g, 'o')
645
+ .replace(/[^a-z0-9]+/g, '-')
646
+ .replace(/^-+|-+$/g, '')
647
+
648
+ /** Skelettet ny-korning och ny-del skriver, delat på ett ställe: samma form
649
+ * som SKILL.md:s state.json plus `experiment: true` (aldrig priced) och
650
+ * `stopp: null`. `bundle`/`split` överstyrs bara när anroparen ger dem, så
651
+ * ny-korning (ingen del) och ny-del (precis en av dem) delar exakt samma
652
+ * funktion i stället för två skrivvägar som kan glida isär. */
653
+ const skelett = (requestId, { bundle, split } = {}) => ({
654
+ experiment: true,
655
+ request_id: requestId,
656
+ slug: null,
657
+ former_slug: null,
658
+ title: null,
659
+ status: 'spec_draft',
660
+ bundle: bundle ?? { parent_slug: null, part: null, of: null },
661
+ split: split ?? { parent: null, part: null, of: null },
662
+ approval: null,
663
+ granskarutslag: [],
664
+ note: null,
665
+ stopp: null,
666
+ respec_varv: 0,
667
+ coverage_post: null,
668
+ runs: [],
669
+ })
670
+
510
671
  if (cmd === 'add-run') {
511
672
  // Samma fail-closed-princip som `next-n` redan tillämpar (se dess kommentar
512
673
  // ovan): varje token i `rest` måste höra till en KÄND flagga, annars är det
@@ -534,30 +695,17 @@ if (cmd === 'add-run') {
534
695
  'pairs-with',
535
696
  ]
536
697
  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
- }
698
+ avvisaOkandaFlaggor('add-run', ADD_RUN_VARDEFLAGGOR, ADD_RUN_BOOLFLAGGOR)
556
699
 
557
700
  const file = flag('file')
558
701
  const skill = flag('skill')
559
702
  const agent = flag('agent')
560
- const model = flag('model')
703
+ // --model förblir obligatorisk (plan requestmotor-14001, align: modellfallback
704
+ // struken; under utvärdera körs agenterna med override, så definitionens
705
+ // modell är per konstruktion inte den som kördes). Värdet kanoniseras:
706
+ // ett kontextsuffix som `[1m]` är samma modell (ADR-0099).
707
+ const modelRaw = flag('model')
708
+ const model = modelRaw === null ? null : canonicalModel(modelRaw)
561
709
  if (!file || !skill || !agent || !model) {
562
710
  console.error('add-run kräver --file, --skill, --agent, --model')
563
711
  process.exit(2)
@@ -580,7 +728,10 @@ if (cmd === 'add-run') {
580
728
  }
581
729
  referens[kv.slice(0, eq)] = kv.slice(eq + 1)
582
730
  }
583
- const effort = flag('effort')
731
+ // Effort ÄR agentdefinitionens (till skillnad från modellen, se ovan): en
732
+ // utelämnad --effort läses ur <claudeDir>/agents/<agent>.md. Explicit flagga
733
+ // vinner alltid.
734
+ const effort = flag('effort') ?? effortFranAgent(agent)
584
735
  const note = flag('note')
585
736
  const doubleRun = flag('double-run')
586
737
  // Samma vitlista som lässidan (`sparrSkal`, `DOUBLE_RUN_VALUES`) delar,
@@ -651,9 +802,37 @@ if (cmd === 'add-run') {
651
802
  // hoppa över den, arkivera aldrig en påhittad hash.
652
803
  }
653
804
  }
654
- state.runs = [...(state.runs ?? []), row]
655
- writeStateOrThrow(state)
656
- rowCount = state.runs.length
805
+ // #497: en ny spec.md/impact.md ersätter den förra läggningen av samma fil.
806
+ // Den föregående raden pekas ut, i stället för att två likadana rader
807
+ // står olänkade och läsaren måste gissa vilken som är på disk. Exakt
808
+ // filnamn med flit, inte SPEC_RE/IMPACT_RE: de mönstren matchar även
809
+ // t.ex. spec-avvisad-v1.md, som aldrig ska plockas in i supersede-kedjan.
810
+ const ersattbar = file === 'spec.md' || file === 'impact.md'
811
+ const runs = (state.runs ?? []).map((r) =>
812
+ ersattbar && r.file === file && (r.superseded_by ?? null) === null
813
+ ? { ...r, superseded_by: file }
814
+ : r,
815
+ )
816
+ // #495: titeln följer det dokument som faktiskt ligger på disk.
817
+ let titelFalt = {}
818
+ if (file === 'spec.md' && existsSync(path.join(katalog, 'spec.md'))) {
819
+ const rubrik = forstaRubrik(readFileSync(path.join(katalog, 'spec.md'), 'utf8'))
820
+ if (rubrik) titelFalt = { title: rubrik, slug: slugAv(rubrik) }
821
+ }
822
+ // Täckningspost (ADR-0119, #508): senaste pricing-filen som bär fältet
823
+ // vinner, precis som titeln följer spec.md på disk. En pricing-fil som
824
+ // (ännu) inte finns är inte add-runs jobb att fälla, den stämplas ofta
825
+ // innan agenten skrivit filen. Finns den redan är trasig JSON eller ett
826
+ // icke-objekt ett arkivfel som andra anropare i den här filen, fångas
827
+ // inte här: readPricingFileOrThrow kastar oskyddad.
828
+ if (PRICING_RE.test(file) && existsSync(path.join(katalog, file))) {
829
+ const data = readPricingFileOrThrow(file, katalog)
830
+ if (data.coverage_post !== null && typeof data.coverage_post === 'object')
831
+ titelFalt = { ...titelFalt, coverage_post: data.coverage_post }
832
+ }
833
+ const nyState = { ...state, ...titelFalt, runs: [...runs, row] }
834
+ writeStateOrThrow(nyState)
835
+ rowCount = nyState.runs.length
657
836
  } catch (e) {
658
837
  releaseLock(lockPath)
659
838
  if (e instanceof ArkivFel) {
@@ -957,6 +1136,8 @@ if (cmd === 'add-run') {
957
1136
  // katalog är HÄR modellkatalogen (experiments/<slug>/<modell>), inte en
958
1137
  // postkatalog. Den skapas om den saknas: första körningen av en modell ska
959
1138
  // inte kräva ett förberedande steg.
1139
+ avvisaOkandaFlaggor('ny-korning', ['request-id'])
1140
+ const requestId = flag('request-id')
960
1141
  try {
961
1142
  mkdirSync(katalog, { recursive: true })
962
1143
  } catch (e) {
@@ -971,16 +1152,182 @@ if (cmd === 'add-run') {
971
1152
  // två samtidiga processer skulle dela körningskatalog. Det är hela
972
1153
  // poängen med det här subkommandot.
973
1154
  mkdirSync(p)
974
- console.log(p)
975
- process.exit(0)
976
1155
  } catch (e) {
977
1156
  if (e.code === 'EEXIST') continue
978
1157
  console.error(`kunde inte skapa ${p}: ${e.message}`)
979
1158
  process.exit(2)
980
1159
  }
1160
+ // Skelettet skrivs av kommandot, inte av orkestratorn: IPC 14001 visade
1161
+ // körningar helt utan state.json på körningsnivå, så proveniens hade
1162
+ // ingenting att stämpla.
1163
+ try {
1164
+ writeStateOrThrow(skelett(requestId), p)
1165
+ } catch (e) {
1166
+ if (!(e instanceof ArkivFel)) throw e
1167
+ // Katalogen finns redan när skrivningen faller, och sökvägen får INTE
1168
+ // skrivas på stdout: anroparen skulle läsa den som en färdig körning.
1169
+ console.error(`${e.message}; katalogen ${p} är skapad men saknar state.json`)
1170
+ process.exit(2)
1171
+ }
1172
+ console.log(p)
1173
+ process.exit(0)
981
1174
  }
982
1175
  console.error(`fler än ${MAX_KORNINGAR} körningar i ${katalog}, något är fel`)
983
1176
  process.exit(2)
1177
+ } else if (cmd === 'ny-del') {
1178
+ // katalog är körningskatalogen (k<N>) eller en buntdel (DELA_UPP inuti en
1179
+ // del ger del-N/del-M). Skelettet skrivs av kommandot, aldrig av den inre
1180
+ // processen: samma skäl som ny-korning (ADR-0107 beslut 4).
1181
+ avvisaOkandaFlaggor('ny-del', ['typ', 'part', 'of', 'request-id'])
1182
+ const typ = requireFlag('typ')
1183
+ const part = Number(requireFlag('part'))
1184
+ const of = Number(requireFlag('of'))
1185
+ const requestId = requireFlag('request-id')
1186
+ if (!['bundle', 'split'].includes(typ)) {
1187
+ console.error(`--typ måste vara bundle eller split (fick "${typ}")`)
1188
+ process.exit(2)
1189
+ }
1190
+ if (!Number.isInteger(part) || !Number.isInteger(of) || part < 1 || of < 1 || part > of) {
1191
+ console.error(`--part och --of måste vara heltal >= 1 med part <= of (fick ${part}/${of})`)
1192
+ process.exit(2)
1193
+ }
1194
+ const p = path.join(katalog, `del-${part}`)
1195
+ try {
1196
+ mkdirSync(p)
1197
+ } catch (e) {
1198
+ console.error(e.code === 'EEXIST' ? `${p} finns redan` : `kunde inte skapa ${p}: ${e.message}`)
1199
+ process.exit(2)
1200
+ }
1201
+ const ref = { part, of }
1202
+ const st = skelett(
1203
+ requestId,
1204
+ typ === 'bundle'
1205
+ ? { bundle: { parent_slug: requestId, ...ref } }
1206
+ : { split: { parent: requestId, ...ref } },
1207
+ )
1208
+ try {
1209
+ writeStateOrThrow(st, p)
1210
+ } catch (e) {
1211
+ if (!(e instanceof ArkivFel)) throw e
1212
+ console.error(`${e.message}; katalogen ${p} är skapad men saknar state.json`)
1213
+ process.exit(2)
1214
+ }
1215
+ console.log(p)
1216
+ process.exit(0)
1217
+ } else if (cmd === 'experiment') {
1218
+ // katalog är HÄR experimentkatalogen (experiments/<slug>). experiment.json
1219
+ // är den enda filen på experimentytan som flera samtidiga processer
1220
+ // skriver (#493: sonnet/k4 försvann i en läs-modifiera-skriv utan lås), så
1221
+ // den enda skrivaren är det här kommandot, under samma lås som state.json-
1222
+ // kommandona. acquireLock låser i `katalog`, alltså i experimentkatalogen.
1223
+ avvisaOkandaFlaggor('experiment', ['satt', 'variant', 'korning', 'status', 'orsak'])
1224
+ const sattPar = flags('satt').map(tolkaSatt)
1225
+ const nyaVarianter = flags('variant')
1226
+ const korningsflaggor = flags('korning')
1227
+ const status = flag('status')
1228
+ const orsak = flag('orsak')
1229
+ if (korningsflaggor.length > 1) {
1230
+ console.error('experiment: högst en --korning per anrop')
1231
+ process.exit(2)
1232
+ }
1233
+ const korning = korningsflaggor[0] ?? null
1234
+ if (korning === null && (status !== null || orsak !== null)) {
1235
+ console.error('experiment: --status och --orsak kräver --korning')
1236
+ process.exit(2)
1237
+ }
1238
+ if (korning !== null) {
1239
+ if (!KORNING_NYCKEL_RE.test(korning)) {
1240
+ console.error(`experiment: --korning ska ha formen <modell>/k<N>, fick "${korning}"`)
1241
+ process.exit(2)
1242
+ }
1243
+ if (status === null) {
1244
+ console.error('experiment: --korning kräver --status')
1245
+ process.exit(2)
1246
+ }
1247
+ if (!EXPERIMENT_STATUSAR.includes(status)) {
1248
+ console.error(`okänd status: ${status} (giltiga: ${EXPERIMENT_STATUSAR.join(', ')})`)
1249
+ process.exit(2)
1250
+ }
1251
+ // Katalogen måste finnas: en statusrad för en körning som inte finns
1252
+ // är ett stavfel i anroparen, inte en körning.
1253
+ const korningsdir = path.join(katalog, korning)
1254
+ let finns = false
1255
+ try {
1256
+ finns = statSync(korningsdir).isDirectory()
1257
+ } catch {
1258
+ // saknas
1259
+ }
1260
+ if (!finns) {
1261
+ console.error(`experiment: körningskatalogen ${korningsdir} finns inte`)
1262
+ process.exit(2)
1263
+ }
1264
+ }
1265
+ // Katalogen skapas om den saknas, precis som ny-korning gör med sin: första
1266
+ // steget i ett svep ska inte kräva ett förberedande mkdir. Låset ligger i
1267
+ // katalogen, så den måste finnas innan acquireLock.
1268
+ try {
1269
+ mkdirSync(katalog, { recursive: true })
1270
+ } catch (e) {
1271
+ console.error(`kunde inte skapa experimentkatalogen ${katalog}: ${e.message}`)
1272
+ process.exit(2)
1273
+ }
1274
+ const lockPath = acquireLock()
1275
+ let andringar = 0
1276
+ let fannsFil = false
1277
+ try {
1278
+ const { state, fanns } = readExperimentOrThrow(katalog)
1279
+ fannsFil = fanns
1280
+ let nasta = { ...state }
1281
+ // --satt är append-only som proveniens: ett fält skrivs en gång, samma
1282
+ // värde igen är en no-op, ett annat värde är ett fel. Alla fält prövas
1283
+ // före skrivningen, så ett vägrat anrop lämnar ingenting efter sig.
1284
+ for (const [nyckel, varde] of sattPar) {
1285
+ if (nasta[nyckel] === undefined) {
1286
+ nasta = { ...nasta, [nyckel]: varde }
1287
+ andringar++
1288
+ } else if (nasta[nyckel] !== varde) {
1289
+ throw new ArkivFel(
1290
+ `experiment: fältet ${nyckel} har redan värdet ${JSON.stringify(nasta[nyckel])}, ` +
1291
+ `begärt ${JSON.stringify(varde)}; ett fält skrivs bara en gång`,
1292
+ )
1293
+ }
1294
+ }
1295
+ // UNION med bevarad ordning: befintliga först, invokationens nya efter.
1296
+ // En headless-process med en enmodellslista rör alltså aldrig de andra.
1297
+ const befintliga = nasta.varianter ?? []
1298
+ const tillagda = nyaVarianter.filter(
1299
+ (v, i, alla) => !befintliga.includes(v) && alla.indexOf(v) === i,
1300
+ )
1301
+ if (tillagda.length > 0) {
1302
+ nasta = { ...nasta, varianter: [...befintliga, ...tillagda] }
1303
+ andringar += tillagda.length
1304
+ }
1305
+ // Bara den egna raden skrivs; andra körningars rader kopieras orörda.
1306
+ if (korning !== null) {
1307
+ const rad = orsak === null ? { status } : { status, orsak }
1308
+ const befintlig = nasta.korningar?.[korning]
1309
+ if (JSON.stringify(befintlig) !== JSON.stringify(rad)) {
1310
+ nasta = { ...nasta, korningar: { ...(nasta.korningar ?? {}), [korning]: rad } }
1311
+ andringar++
1312
+ }
1313
+ }
1314
+ if (andringar > 0 || !fanns) writeJsonAtomiskOrThrow(nasta, katalog, 'experiment.json')
1315
+ } catch (e) {
1316
+ releaseLock(lockPath)
1317
+ if (e instanceof ArkivFel) {
1318
+ console.error(e.message)
1319
+ process.exit(2)
1320
+ }
1321
+ throw e
1322
+ }
1323
+ releaseLock(lockPath)
1324
+ // En nyskapad fil är en ändring även utan begärda fält, därför räknas
1325
+ // "oförändrad" bara när filen redan fanns.
1326
+ console.log(
1327
+ andringar === 0 && fannsFil
1328
+ ? 'OK: experiment oförändrad'
1329
+ : `OK: experiment (${andringar} ändringar)`,
1330
+ )
984
1331
  } else if (cmd === 'proveniens') {
985
1332
  // katalog är HÄR körningskatalogen (experiments/<slug>/<modell>/k<N>).
986
1333
  // Stämplingen stod tidigare bara som prosa i SKILL.md steg 4 och blev
@@ -1039,29 +1386,27 @@ if (cmd === 'add-run') {
1039
1386
  // nyckelmängden ur modelUsage (suffix som `[1m]` strippas) — det är den
1040
1387
  // startaren jämför mot varvets begärda modell. Saknas filen skrivs inget
1041
1388
  // 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}`)
1389
+ // Filen ligger på körningsnivå medan stämplingen ibland görs per del-N
1390
+ // (plan requestmotor-14001), så den söks uppåt från katalogen, aldrig
1391
+ // utanför experimentytan och högst tre nivåer.
1392
+ const korningFil = findUpward(katalog, 'korning.json')
1393
+ if (korningFil !== null) {
1394
+ try {
1395
+ const korning = JSON.parse(readFileSync(korningFil, 'utf8'))
1396
+ const nycklar = Object.keys(korning.modelUsage ?? {})
1397
+ if (nycklar.length > 0)
1398
+ stampel.fakturerade_modeller = [...new Set(nycklar.map(canonicalModel))].sort()
1399
+ } catch (e) {
1400
+ console.error(`kunde inte läsa ${korningFil} för fakturerade_modeller: ${e.message}`)
1050
1401
  process.exit(2)
1051
1402
  }
1052
1403
  }
1053
1404
 
1054
1405
  // Delarna är samma körning: samma modell, samma datum, samma hashar. De
1055
1406
  // 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()
1407
+ // låter delkolumnen ärva förälderns värden vid läsning. Rekursivt: ett
1408
+ // DELA_UPP inuti en buntdel ger del-N/del-M (align F).
1409
+ const delar = delKataloger(katalog)
1065
1410
 
1066
1411
  const lockPath = acquireLock()
1067
1412
  try {
@@ -1076,8 +1421,19 @@ if (cmd === 'add-run') {
1076
1421
  attSkriva.push([d, state])
1077
1422
  continue
1078
1423
  }
1079
- const skiljer = Object.keys(stampel).filter(
1080
- (k) => (befintlig === null ? undefined : befintlig[k]) !== stampel[k],
1424
+ // Djup jämförelse per fält: `fakturerade_modeller` är en array, och en
1425
+ // referensjämförelse hade fått varje omanrop att "skilja sig" i just
1426
+ // det fältet.
1427
+ // Unionen av nycklar: ett fält som fanns i den lagrade stämpeln men
1428
+ // saknas i den nya (korning.json hittas inte längre) är också drift.
1429
+ const nycklar = new Set([
1430
+ ...Object.keys(stampel),
1431
+ ...Object.keys(befintlig === null ? {} : befintlig),
1432
+ ])
1433
+ const skiljer = [...nycklar].filter(
1434
+ (k) =>
1435
+ JSON.stringify(befintlig === null ? undefined : befintlig[k]) !==
1436
+ JSON.stringify(stampel[k]),
1081
1437
  )
1082
1438
  if (skiljer.length > 0) {
1083
1439
  throw new ArkivFel(
@@ -1100,13 +1456,327 @@ if (cmd === 'add-run') {
1100
1456
  }
1101
1457
  throw e
1102
1458
  }
1459
+ } else if (cmd === 'stopp') {
1460
+ // Stopputfall UTAN scorer (ADR-0105/0106/0118): en kandidatspec routar direkt
1461
+ // till KONCEPT/DIAGNOS, en analys med motsägelse direkt till
1462
+ // TILLBAKA_TILL_SPEC, och ett prisavgörande öppet val i granskarblock.md
1463
+ // direkt till TILLBAKA_TILL_SPEC eller ESKALERA. Kommandot litar aldrig på
1464
+ // anroparens påstående: källan måste bära det.
1465
+ avvisaOkandaFlaggor('stopp', ['utfall', 'kalla', 'orsak'])
1466
+ const utfall = requireFlag('utfall')
1467
+ const kalla = requireFlag('kalla')
1468
+ const orsak = flag('orsak')
1469
+ if (!STOPP_UTFALL.includes(utfall)) {
1470
+ console.error(`okänt --utfall: ${utfall} (giltiga: ${STOPP_UTFALL.join(', ')})`)
1471
+ process.exit(2)
1472
+ }
1473
+ if (!STOPP_KALLOR.includes(kalla)) {
1474
+ console.error(`okänd --kalla: ${kalla} (giltiga: ${STOPP_KALLOR.join(', ')})`)
1475
+ process.exit(2)
1476
+ }
1477
+ const kallaPath = path.join(katalog, kalla)
1478
+ let text
1479
+ try {
1480
+ text = readFileSync(kallaPath, 'utf8')
1481
+ } catch (e) {
1482
+ console.error(`kunde inte läsa källan ${kallaPath}: ${e.message}`)
1483
+ process.exit(2)
1484
+ }
1485
+ if (kalla === 'spec.md') {
1486
+ const kandidat = kandidatIn(text)
1487
+ if (kandidat === null) {
1488
+ console.error(
1489
+ `${kallaPath} bär ingen kandidatmarkör (hel rubrikrad Koncept-kandidat/Diagnos-kandidat), ` +
1490
+ `kan inte stoppas som ${utfall}`,
1491
+ )
1492
+ process.exit(2)
1493
+ }
1494
+ if (kandidat.utfall !== utfall) {
1495
+ console.error(
1496
+ `${kallaPath} bär markören för ${kandidat.utfall} (rad ${kandidat.line + 1}), inte ${utfall}`,
1497
+ )
1498
+ process.exit(2)
1499
+ }
1500
+ } else if (kalla === 'granskarblock.md') {
1501
+ // ADR-0118: bara ett ja på raden Prisavgörande öppet val är ett stopp.
1502
+ if (prisavgorandeVal(text) === null) {
1503
+ console.error(
1504
+ `${kallaPath} bär inget prisavgörande öppet val (raden "Prisavgörande öppet val: ja: …"), ` +
1505
+ 'kan inte stoppas',
1506
+ )
1507
+ process.exit(2)
1508
+ }
1509
+ if (!['TILLBAKA_TILL_SPEC', 'ESKALERA'].includes(utfall)) {
1510
+ console.error(
1511
+ `ett prisavgörande val i ${kalla} stoppar som TILLBAKA_TILL_SPEC eller ESKALERA, inte ${utfall}`,
1512
+ )
1513
+ process.exit(2)
1514
+ }
1515
+ if (utfall === 'ESKALERA' && !orsak) {
1516
+ console.error(
1517
+ 'ESKALERA kräver --orsak: en människa ska se varför posten inte gick tillbaka till specen en gång till',
1518
+ )
1519
+ process.exit(2)
1520
+ }
1521
+ } else {
1522
+ const etikett = avvikelseEtikett(text)
1523
+ if (etikett !== 'motsägelse') {
1524
+ console.error(
1525
+ `${kallaPath} bär ${etikett === null ? 'ingen avvikelseetikett' : `etiketten "${etikett}"`} ` +
1526
+ 'på raden "Avvikelse mot specen:", bara "motsägelse" är ett stopp',
1527
+ )
1528
+ process.exit(2)
1529
+ }
1530
+ if (utfall !== 'TILLBAKA_TILL_SPEC') {
1531
+ console.error(`en motsägelse i ${kalla} stoppar som TILLBAKA_TILL_SPEC, inte ${utfall}`)
1532
+ process.exit(2)
1533
+ }
1534
+ }
1535
+
1536
+ const lockPath = acquireLock()
1537
+ let skrivet = false
1538
+ try {
1539
+ const state = readStateOrThrow()
1540
+ const befintlig = state.stopp ?? null
1541
+ if (befintlig !== null) {
1542
+ // Write-once på utfall+kalla; datumet och orsaken ingår inte, ett
1543
+ // identiskt omanrop en annan dag (eller med en annan orsakstext) är en
1544
+ // no-op.
1545
+ const begart = { utfall, kalla }
1546
+ const skiljer = Object.keys(begart).filter((k) => befintlig[k] !== begart[k])
1547
+ if (skiljer.length > 0) {
1548
+ throw new ArkivFel(
1549
+ `${statePath()} har redan ett stopp (${befintlig.utfall} ur ${befintlig.kalla}) som skiljer sig ` +
1550
+ `från ${utfall} ur ${kalla} (${skiljer.join(', ')}); arkivet är append-only, kör respec för att nolla`,
1551
+ )
1552
+ }
1553
+ } else {
1554
+ state.stopp = { utfall, kalla, date: today(), ...(orsak ? { orsak } : {}) }
1555
+ // impact.md: posten gick genom bedöm och är analyserad. spec.md och
1556
+ // granskarblock.md: kandidaten/det prisavgörande valet passerade aldrig
1557
+ // bedöms grind, posten stannar i spec_draft som förut.
1558
+ if (kalla === 'impact.md') state.status = 'assessed'
1559
+ writeStateOrThrow(state)
1560
+ skrivet = true
1561
+ }
1562
+ } catch (e) {
1563
+ releaseLock(lockPath)
1564
+ if (e instanceof ArkivFel) {
1565
+ console.error(e.message)
1566
+ process.exit(2)
1567
+ }
1568
+ throw e
1569
+ }
1570
+ releaseLock(lockPath)
1571
+ console.log(
1572
+ skrivet ? `OK: stopp ${utfall} ur ${kalla}` : `OK: stopp ${utfall} ur ${kalla} oförändrat`,
1573
+ )
1574
+ } else if (cmd === 'respec') {
1575
+ // Speca steg 5 som kommando: en ny spec över befintlig katalog invaliderar
1576
+ // analysen, godkännandet, domarna och stoppet. runs[] rörs aldrig, arkivet
1577
+ // är append-only; den gamla analysen döps om, raderas inte.
1578
+ avvisaOkandaFlaggor('respec', [])
1579
+ const lockPath = acquireLock()
1580
+ let antal = 0
1581
+ try {
1582
+ const state = readStateOrThrow()
1583
+ const impact = path.join(katalog, 'impact.md')
1584
+ if (existsSync(impact)) {
1585
+ const bas = path.join(katalog, `impact-superseded-${today()}`)
1586
+ let mal = `${bas}.md`
1587
+ for (let n = 2; existsSync(mal); n++) mal = `${bas}-${n}.md`
1588
+ try {
1589
+ renameSync(impact, mal)
1590
+ } catch (e) {
1591
+ throw new ArkivFel(`kunde inte döpa om ${impact} till ${mal}: ${e.message}`)
1592
+ }
1593
+ antal++
1594
+ }
1595
+ const nytt = { ...state }
1596
+ if (nytt.status !== 'spec_draft') {
1597
+ nytt.status = 'spec_draft'
1598
+ antal++
1599
+ }
1600
+ if (nytt.approval !== null) {
1601
+ nytt.approval = null
1602
+ antal++
1603
+ }
1604
+ if (!Array.isArray(nytt.granskarutslag) || nytt.granskarutslag.length > 0) {
1605
+ nytt.granskarutslag = []
1606
+ antal++
1607
+ }
1608
+ if (nytt.stopp !== null) {
1609
+ nytt.stopp = null
1610
+ antal++
1611
+ }
1612
+ // Monoton räknare (#505): varje respec-anrop är ett nytt varv oavsett om
1613
+ // övriga fält redan var normaliserade, till skillnad från fälten ovan
1614
+ // finns inget "redan rätt värde" att jämföra mot, så det här villkoret
1615
+ // är alltid sant.
1616
+ nytt.respec_varv = (state.respec_varv ?? 0) + 1
1617
+ antal++
1618
+ if (antal > 0) writeStateOrThrow(nytt)
1619
+ } catch (e) {
1620
+ releaseLock(lockPath)
1621
+ if (e instanceof ArkivFel) {
1622
+ console.error(e.message)
1623
+ process.exit(2)
1624
+ }
1625
+ throw e
1626
+ }
1627
+ releaseLock(lockPath)
1628
+ console.log(`OK: respec (${antal} ändringar)`)
1629
+ } else if (cmd === 'bluf') {
1630
+ // Läs-enbart tabellvy över katalogen och dess delar. Poängen tas ur
1631
+ // prisfilerna och stoppet ur state.json, aldrig ur `note` eller prosa: den
1632
+ // handskrivna BLUF:en i IPC 14001 felrapporterade.
1633
+ avvisaOkandaFlaggor('bluf', [])
1634
+ const SAKNAS = '–'
1635
+ const cell = (v) => String(v ?? SAKNAS).replace(/\|/g, '\\|')
1636
+ const radFor = (d, post) => {
1637
+ const state = readStateOrThrow(d)
1638
+ const runs = state.runs ?? []
1639
+ const direktaDelar = delKataloger(d).filter((p) => path.dirname(p) === d)
1640
+ let poang = SAKNAS
1641
+ let utfall = SAKNAS
1642
+ let konfidens = SAKNAS
1643
+ let anm = SAKNAS
1644
+ const nScore = maxN(runs, 'score')
1645
+ if (nScore > 0) {
1646
+ const fil = gallandeDokumentFil(state, 'score', nScore)
1647
+ const data = readPricingFileOrThrow(fil, d)
1648
+ poang = data.score ?? SAKNAS
1649
+ utfall = data.outcome ?? SAKNAS
1650
+ konfidens = data.confidence ?? SAKNAS
1651
+ const rad = [...runs].reverse().find((r) => r.file === fil)
1652
+ // En OLIKA som en dom redan lyft (liftingUtslag pekade ut gällande fil
1653
+ // ovan) väntar inte längre på någon; bara en olyft OLIKA får raden.
1654
+ if (rad?.double_run === 'olika' && !liftedByUtslag(state, 'score', nScore)) {
1655
+ anm = 'OLIKA, väntar på dom'
1656
+ const run2 = fil.replace(/\.json$/, '-run2.json')
1657
+ if (existsSync(path.join(d, run2))) {
1658
+ const b = readPricingFileOrThrow(run2, d)
1659
+ poang = `${poang} / ${b.score ?? SAKNAS}`
1660
+ }
1661
+ } else if (rad?.double_run === 'tolerans') {
1662
+ anm = 'tolerans'
1663
+ }
1664
+ } else if (!existsSync(path.join(d, 'spec.md')) && direktaDelar.length > 0) {
1665
+ poang = `bunt (${direktaDelar.length} delar)`
1666
+ utfall = poang
1667
+ } else if (state.stopp == null) {
1668
+ poang = 'ofärdig'
1669
+ }
1670
+ // Täckningspost (ADR-0119, #508): synlig i anm så granskaren ser att en
1671
+ // egen post väntar, utan att leta i pricing-filen. Beräknas separat och
1672
+ // läggs till sist (final-review M6) så att den varken tystas av
1673
+ // OLIKA/tolerans-texten ovan eller av ett stopp nedan.
1674
+ const taeckning = state.coverage_post?.yta ? `täckningspost: ${state.coverage_post.yta}` : null
1675
+ // Stopp trumfar OLIKA och tolerans: posten är stoppad oavsett vad
1676
+ // prisfilen säger, det tidigare anm-innehållet (OLIKA/tolerans) faller
1677
+ // bort. Täckningsposten är inte OLIKA/tolerans-text och överlever detta.
1678
+ if (state.stopp != null) {
1679
+ utfall = state.stopp.utfall
1680
+ anm =
1681
+ state.stopp.kalla === 'spec.md'
1682
+ ? 'kandidat (spec.md)'
1683
+ : state.stopp.kalla === 'granskarblock.md'
1684
+ ? 'prisavgörande val (granskarblock.md)'
1685
+ : 'motsägelse (impact.md)'
1686
+ }
1687
+ if (taeckning) anm = anm === SAKNAS ? taeckning : `${anm}; ${taeckning}`
1688
+ return `| ${[post, poang, utfall, konfidens, anm].map(cell).join(' | ')} |`
1689
+ }
1690
+ const rot = path.resolve(katalog)
1691
+ const rader = ['| post | poäng | utfall | konfidens | anm |', '| --- | --- | --- | --- | --- |']
1692
+ try {
1693
+ rader.push(radFor(rot, path.basename(rot)))
1694
+ for (const d of delKataloger(rot)) rader.push(radFor(d, path.relative(rot, d)))
1695
+ } catch (e) {
1696
+ if (e instanceof ArkivFel) {
1697
+ console.error(e.message)
1698
+ process.exit(2)
1699
+ }
1700
+ throw e
1701
+ }
1702
+ console.log(rader.join('\n'))
1103
1703
  } else {
1104
1704
  console.error(
1105
- 'okänt kommando (add-run, next-n, ny-korning, proveniens, sparr, utslag, set-status, kalibrering)',
1705
+ 'okänt kommando (add-run, next-n, ny-korning, ny-del, proveniens, sparr, utslag, set-status, stopp, respec, bluf, kalibrering, experiment)',
1106
1706
  )
1107
1707
  process.exit(2)
1108
1708
  }
1109
1709
 
1710
+ /** Tolkar ett --satt-par "nyckel=värde". Första "=" delar; `eskalerad` är
1711
+ * det enda booleska fältet och tar bara true/false, allt annat är en sträng.
1712
+ * `varianter` och `korningar` har egna flaggor med egen semantik (union,
1713
+ * egen rad) och får inte skrivas rakt av här. */
1714
+ function tolkaSatt(par) {
1715
+ const i = par.indexOf('=')
1716
+ if (i < 1) {
1717
+ console.error(`experiment: --satt ska ha formen nyckel=värde, fick "${par}"`)
1718
+ process.exit(2)
1719
+ }
1720
+ const nyckel = par.slice(0, i)
1721
+ const ra = par.slice(i + 1)
1722
+ if (nyckel === 'varianter' || nyckel === 'korningar') {
1723
+ console.error(`experiment: ${nyckel} skrivs med --variant respektive --korning, inte --satt`)
1724
+ process.exit(2)
1725
+ }
1726
+ if (nyckel === 'eskalerad') {
1727
+ if (ra !== 'true' && ra !== 'false') {
1728
+ console.error(`experiment: eskalerad tar bara true eller false, fick "${ra}"`)
1729
+ process.exit(2)
1730
+ }
1731
+ return [nyckel, ra === 'true']
1732
+ }
1733
+ return [nyckel, ra]
1734
+ }
1735
+
1736
+ /** Effort ur agentdefinitionens frontmatter: raden `effort: <värde>` mellan
1737
+ * de inledande `---`-raderna, markörrader (haus_managed:) tål den. Saknas
1738
+ * filen eller raden blir det null och en VARNING på stderr: IPC 14001 hade
1739
+ * effort null på alla rader utan att någon såg det. Läser aldrig brödtexten. */
1740
+ function effortFranAgent(agent) {
1741
+ const p = path.join(claudeDir(), 'agents', `${agent}.md`)
1742
+ let text
1743
+ try {
1744
+ text = stripHausMarker(readFileSync(p, 'utf8'))
1745
+ } catch (e) {
1746
+ console.error(`VARNING: kunde inte läsa ${p} för effort (${e.message}), effort blir null`)
1747
+ return null
1748
+ }
1749
+ const lines = text.split('\n')
1750
+ if (lines[0]?.trim() === '---') {
1751
+ for (let i = 1; i < lines.length && lines[i].trim() !== '---'; i++) {
1752
+ const m = /^effort:\s*(\S+)$/.exec(lines[i])
1753
+ if (m) return m[1]
1754
+ }
1755
+ }
1756
+ console.error(`VARNING: ${p} saknar en effort-rad i sin frontmatter, effort blir null`)
1757
+ return null
1758
+ }
1759
+
1760
+ /** Delkataloger under `dir`, REKURSIVT och i numerisk ordning: del-N som har
1761
+ * en state.json, djupet först (del-1, del-1/del-1, del-1/del-2, del-2, del-10).
1762
+ * En del utan state.json är inte en körning ännu och hoppas över, liksom
1763
+ * allt under den. Delad av proveniens och bluf så de två aldrig kan se
1764
+ * olika delar. */
1765
+ function delKataloger(dir) {
1766
+ let entries
1767
+ try {
1768
+ entries = readdirSync(dir, { withFileTypes: true })
1769
+ } catch (e) {
1770
+ throw new ArkivFel(`kunde inte lista ${dir}: ${e.message}`)
1771
+ }
1772
+ return entries
1773
+ .filter((e) => e.isDirectory() && /^del-\d+$/.test(e.name))
1774
+ .sort((a, b) => Number(a.name.slice(4)) - Number(b.name.slice(4)))
1775
+ .map((e) => path.join(dir, e.name))
1776
+ .filter((d) => existsSync(path.join(d, 'state.json')))
1777
+ .flatMap((d) => [d, ...delKataloger(d)])
1778
+ }
1779
+
1110
1780
  /** Det SENASTE viktningsutslaget (om något) som lyfter en axel vid ett givet
1111
1781
  * gällande n — namnger axeln, har ett `n` som är EXAKT det gällande n:et
1112
1782
  * (inte "minst lika högt"), och bär en `galler_fil`.
@@ -1250,8 +1920,8 @@ function gallandeDokumentFil(state, typ, n) {
1250
1920
  * grinden rapporterade att inget blockerar. Kastar ArkivFel; anroparen
1251
1921
  * (set-status) fångar det och avslutar med exit 2, skilt från den vanliga
1252
1922
  * spärr-vägran (exit 1). */
1253
- function readPricingFileOrThrow(fil) {
1254
- const p = path.join(katalog, fil)
1923
+ function readPricingFileOrThrow(fil, d = katalog) {
1924
+ const p = path.join(d, fil)
1255
1925
  let raw
1256
1926
  try {
1257
1927
  raw = readFileSync(p, 'utf8')
@@ -1345,6 +2015,13 @@ function pricedBlockers(state) {
1345
2015
  if (state.experiment === true) {
1346
2016
  skal.push('experimentvariant blir aldrig priced')
1347
2017
  }
2018
+ // Ett stopputfall skrivet av `stopp` (kandidat ur spec.md, motsägelse ur
2019
+ // impact.md) gäller tills `respec` nollar det. Bara HÄR, aldrig i
2020
+ // `sparrSkal`: sparr betyder "väntar på dom" och styr utvärderas
2021
+ // återupptagning; ett stopp där hade låst posten för alltid.
2022
+ if (state.stopp !== null && typeof state.stopp === 'object') {
2023
+ skal.push(`stopputfall ${state.stopp.utfall} ur ${state.stopp.kalla}`)
2024
+ }
1348
2025
  const runs = state.runs ?? []
1349
2026
  const nClass = maxN(runs, 'classification')
1350
2027
  if (nClass > 0) {