@haus-tech/haus-workflow 5.12.1 → 6.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.
@@ -19,26 +19,38 @@
19
19
  * väntas göra och vilket /request-kommando som kör vidare, ur SKILL.md:s
20
20
  * egna grindar (regeltabellen står ovanför `nastaStegFor`). Del 6 (designa,
21
21
  * spec 2026-09-24): add-design, som verifierar, lägger undan, kopierar in
22
- * och bokför designdokumentet i state.design utan runs-rad.
22
+ * och bokför designdokumentet i state.design utan runs-rad. Del 7 (ADR-0156
23
+ * beslut 3 och 5): add-design-review, som kopierar in granskarens svar som
24
+ * design-review.md och bokför kvittot i state.design.review (utan runs-rad,
25
+ * kostnaden står i korning.json), och antaganden, läs-enbart lista över
26
+ * utkastets antaganden, granskningens utslag och spec conflicts.
23
27
  *
24
28
  * Användning:
25
29
  * node archive.mjs add-run <katalog> --file F --skill S --agent A --model M [flaggor]
26
30
  * node archive.mjs next-n <katalog> --typ classification [--typ score]
27
31
  * node archive.mjs ny-korning <modellkatalog> [--request-id G]
28
32
  * node archive.mjs ny-del <körningskatalog> --typ bundle|split --part N --of M --request-id G
33
+ * node archive.mjs ny-del <postkatalog> --typ split --part N --of M (produktionsdel, #599)
29
34
  * node archive.mjs proveniens <körningskatalog> --modell M --konto K --underlag F [--datum D]
30
35
  * node archive.mjs sparr <katalog>
31
36
  * node archive.mjs utslag <katalog> --typ viktning|kalibrering|sakandring --beslutad-av N [...]
32
37
  * node archive.mjs set-status <katalog> <status>
33
- * node archive.mjs stopp <katalog> --utfall KONCEPT|DIAGNOS|TILLBAKA_TILL_SPEC|ESKALERA --kalla spec.md|impact.md|granskarblock.md|design-avvisad-<datum>.md [--orsak <text>]
38
+ * node archive.mjs stopp <katalog> --utfall KONCEPT|DIAGNOS|TILLBAKA_TILL_SPEC|ESKALERA --kalla spec.md|impact.md|granskarblock.md|design-avvisad-<datum>.md|design-intervju-kravs-<datum>.md [--orsak <text>]
34
39
  * node archive.mjs respec <katalog>
35
40
  * node archive.mjs add-design <katalog> --file <absolut sökväg till designdokumentet>
41
+ * node archive.mjs add-design-review <katalog> --file <abs> --verdict fortsätt|stopp --model <m>
42
+ * node archive.mjs antaganden <katalog>
36
43
  * node archive.mjs bluf <katalog>
37
44
  * node archive.mjs nasta-steg <katalog>
38
45
  * node archive.mjs kalibrering <arkivrot>
39
46
  * node archive.mjs experiment <experimentkatalog> [--satt K=V]... [--variant M]... [--korning M/kN --status S [--orsak T] [--pid N]]
40
47
  * node archive.mjs set-split-gid <del-katalog> --gid <gid>
48
+ * node archive.mjs approve <katalog> --source asana --status "<Offer status>" | --source manual --by <namn>
49
+ * node archive.mjs synced <katalog> --offer-status "<Offer status>"
41
50
  * node archive.mjs paraply <katalog> --file <spec>
51
+ * node archive.mjs korning-start <katalog> --pid <N> [--katalog-korning <absolut sökväg>]
52
+ * node archive.mjs korning-slut <katalog>
53
+ * node archive.mjs rot [<katalog>]
42
54
  * Exit 0 = ok, 1 = spärrad (sparr/set-status priced), 2 = användnings-/tillståndsfel.
43
55
  */
44
56
  import {
@@ -57,6 +69,7 @@ import {
57
69
  copyFileSync,
58
70
  } from 'node:fs'
59
71
  import path from 'node:path'
72
+ import { spawnSync } from 'node:child_process'
60
73
  import { createHash } from 'node:crypto'
61
74
  import {
62
75
  markerStrippedSha256,
@@ -65,22 +78,31 @@ import {
65
78
  canonicalModel,
66
79
  kandidatIn,
67
80
  avvikelseEtikett,
81
+ avvikelseText,
68
82
  prisavgorandeVal,
69
83
  findUpward,
70
84
  CLASS_UTFALL,
71
85
  SCORE_OUTCOMES,
72
86
  designFrontmatter,
73
87
  specConflicts,
88
+ designMode,
89
+ designAssumptions,
90
+ sectionLines,
74
91
  katalogId,
92
+ designForm,
93
+ designSymbolkontroll,
94
+ repoRotForFil,
75
95
  specForm,
76
96
  SPEC_HEADINGS,
97
+ alfabetiskKarna,
98
+ arkivRot,
77
99
  } from './lib.mjs'
78
100
 
79
101
  const [cmd, katalog, ...rest] = process.argv.slice(2)
80
102
 
81
- if (!cmd || !katalog) {
103
+ if (!cmd || (!katalog && cmd !== 'rot')) {
82
104
  console.error(
83
- 'användning: archive.mjs <add-run|next-n|ny-korning|ny-del|proveniens|sparr|utslag|set-status|stopp|respec|add-design|set-split-gid|paraply|bluf|nasta-steg|kalibrering|experiment> <katalog> [flaggor]',
105
+ 'användning: archive.mjs <add-run|next-n|ny-korning|ny-del|proveniens|sparr|utslag|set-status|stopp|respec|add-design|add-design-review|antaganden|set-split-gid|approve|synced|paraply|korning-start|korning-slut|rot|bluf|nasta-steg|kalibrering|experiment> <katalog> [flaggor]',
84
106
  )
85
107
  process.exit(2)
86
108
  }
@@ -245,6 +267,20 @@ const readStateOrThrow = (d = katalog) => {
245
267
  ) {
246
268
  throw new ArkivFel(`${statePath(d)} har ett design-fält som varken är null eller ett objekt`)
247
269
  }
270
+ // ADR-0156 beslut 3: design.review är saknat, null eller ett objekt med en
271
+ // sträng `verdict`, skrivet av add-design-review. Allt annat är trasigt:
272
+ // nasta-steg läser verdict, och en sträng där hade tyst lästs som "ingen
273
+ // granskning".
274
+ const review = parsed.design?.review
275
+ if (
276
+ review !== undefined &&
277
+ review !== null &&
278
+ (typeof review !== 'object' || Array.isArray(review) || typeof review.verdict !== 'string')
279
+ ) {
280
+ throw new ArkivFel(
281
+ `${statePath(d)} har ett design.review som varken är null eller ett objekt med en sträng verdict`,
282
+ )
283
+ }
248
284
  // ADR-0144 beslut 6: split.gid är saknat, null eller ett Asana-gid som
249
285
  // sträng, skrivet av set-split-gid. Allt annat är trasigt: nasta-steg läser
250
286
  // fältet för att avgöra om delens deluppgift finns, och ett tal eller en
@@ -268,6 +304,23 @@ const readStateOrThrow = (d = katalog) => {
268
304
  ) {
269
305
  throw new ArkivFel(`${statePath(d)} har ett umbrella-fält som varken är null eller ett objekt`)
270
306
  }
307
+ // ADR-0155 beslut 3: `korning` är null, saknat eller ett objekt { pid,
308
+ // started, katalog } skrivet av korning-start. pid måste vara ett heltal:
309
+ // isAlive(pid) och nasta-steg läser det, och en sträng där hade tyst lästs
310
+ // som "ingen körning pågår". pid 0 är ingen process: kill(0, 0) prövar
311
+ // processgruppen och svarar alltid levande.
312
+ if (
313
+ parsed.korning !== undefined &&
314
+ parsed.korning !== null &&
315
+ (typeof parsed.korning !== 'object' ||
316
+ Array.isArray(parsed.korning) ||
317
+ !Number.isInteger(parsed.korning.pid) ||
318
+ parsed.korning.pid <= 0)
319
+ ) {
320
+ throw new ArkivFel(
321
+ `${statePath(d)} har ett korning-fält som varken är null eller ett objekt med ett heltals-pid > 0`,
322
+ )
323
+ }
271
324
  return parsed
272
325
  }
273
326
 
@@ -462,8 +515,8 @@ const isAlive = (pid) => {
462
515
  * (en hoppad hydrering), den här funktionen har ingen "hoppa över"-väg att
463
516
  * falla tillbaka på.
464
517
  */
465
- const acquireLock = () => {
466
- const lockPath = path.join(katalog, '.archive.lock')
518
+ const acquireLock = (d = katalog) => {
519
+ const lockPath = path.join(d, '.archive.lock')
467
520
  const start = Date.now()
468
521
  let warned = false
469
522
  const maybeWarn = (info) => {
@@ -542,6 +595,35 @@ const releaseLock = (lockPath) => {
542
595
  }
543
596
  }
544
597
 
598
+ /** #693: en ny runda på en delningsdel (respec, eller add-run på en rundas
599
+ * huvudfil) gör förälderns ⚖️-kvitto inaktuellt: föräldern stod på ⚖️ för
600
+ * delarnas förra priser. Roten är katalogen ovanför del-N/ och nollas under
601
+ * sitt eget lås, efter att delens lås släppts (ingen nästlad låsning).
602
+ * Delens skrivning är då redan gjord, så ett arkivfel på roten (t.ex.
603
+ * trasig state.json) är en VARNING och aldrig exit 2: en omkörning av
604
+ * add-run hade vägrats som dubblett. Arkivet är en cache; Asana är
605
+ * sanningen. De två skrivningarna är inte atomära: dör processen mellan
606
+ * dem står rotens kvitto kvar till nästa respec eller add-run på en del.
607
+ * Returnerar ett suffix till OK-raden. */
608
+ const nollaRotensKvitto = (delState, d = katalog) => {
609
+ if (delState?.split?.parent == null) return ''
610
+ const rotDir = path.dirname(d)
611
+ if (!existsSync(path.join(rotDir, 'state.json'))) return ''
612
+ const lockPath = acquireLock(rotDir)
613
+ try {
614
+ const rotState = readStateOrThrow(rotDir)
615
+ if (rotState.board === null || rotState.board === undefined) return ''
616
+ writeStateOrThrow({ ...rotState, board: null }, rotDir)
617
+ return ', förälderns brädkvitto nollat'
618
+ } catch (e) {
619
+ if (!(e instanceof ArkivFel)) throw e
620
+ console.error(`VARNING: förälderns brädkvitto kunde inte nollas: ${e.message}`)
621
+ return ', förälderns brädkvitto INTE nollat (se VARNING)'
622
+ } finally {
623
+ releaseLock(lockPath)
624
+ }
625
+ }
626
+
545
627
  /**
546
628
  * Kända arkivfilnamn — allt annat är ett formfel i `--file` (t.ex. ett
547
629
  * saknat "v" i "classification-pricing-1.json") som annars tyst arkiverar
@@ -564,6 +646,9 @@ const releaseLock = (lockPath) => {
564
646
  const SPEC_RE = /^spec(-[a-z0-9-]+)?\.md$/
565
647
  const IMPACT_RE = /^impact(-[a-z0-9-]+)?\.md$/
566
648
  const PRICING_RE = /^(classification|score)-(pricing|guard)-v\d+(-[a-z0-9-]+)?\.json$/
649
+ /** En rundas huvudfil: PRICING_RE utan suffixsvans. -run2 och -avvisad är
650
+ * aldrig huvudfil (bedöm steg 6: "-run2 files are NEVER counted"). */
651
+ const HUVUDFIL_RE = /^(classification|score)-(pricing|guard)-v\d+\.json$/
567
652
  const FILE_PATTERNS = [SPEC_RE, IMPACT_RE, PRICING_RE]
568
653
  const isKnownFile = (file) => FILE_PATTERNS.some((re) => re.test(file))
569
654
 
@@ -628,6 +713,12 @@ const SCORE_STOP = [
628
713
  const STOPP_UTFALL = ['KONCEPT', 'DIAGNOS', 'TILLBAKA_TILL_SPEC', 'ESKALERA']
629
714
  const STOPP_KALLOR = ['spec.md', 'impact.md', 'granskarblock.md']
630
715
  const DESIGN_AVVISAD_RE = /^design-avvisad-\d{4}-\d{2}-\d{2}(-\d+)?\.md$/
716
+ /** Designerns INTERVJU KRÄVS (P4-24): korning.mjs skriver svaret som kvittot
717
+ * design-intervju-kravs-<datum>.md (`-2` vid kollision) och stoppar posten
718
+ * som ESKALERA på det, så nästa batch inte betalar designern igen. Ett
719
+ * designa löser det: add-design nollar stoppet. */
720
+ const DESIGN_INTERVJU_RE = /^design-intervju-kravs-\d{4}-\d{2}-\d{2}(-\d+)?\.md$/
721
+ const arIntervjuStopp = (stopp) => DESIGN_INTERVJU_RE.test(stopp?.kalla ?? '')
631
722
 
632
723
  /** Ett designkonfliktstopp (slutgranskning I2): assessorn fann designen
633
724
  * obyggbar, motsägelseraden i impact.md börjar med `design conflict:` och
@@ -654,6 +745,8 @@ const MAX_KORNINGAR = 999
654
745
  * är inte samma kontrakt. */
655
746
  const PROVENIENS_REFERENSER = [
656
747
  'agents/request-assessor.md',
748
+ 'agents/request-design-reviewer.md',
749
+ 'agents/request-designer.md',
657
750
  'agents/request-scorer.md',
658
751
  'agents/request-specifier-eskalerad.md',
659
752
  'agents/request-specifier.md',
@@ -724,8 +817,8 @@ const slugAv = (titel) =>
724
817
  * `stopp: null`. `bundle`/`split` överstyrs bara när anroparen ger dem, så
725
818
  * ny-korning (ingen del) och ny-del (precis en av dem) delar exakt samma
726
819
  * funktion i stället för två skrivvägar som kan glida isär. */
727
- const skelett = (requestId, { bundle, split } = {}) => ({
728
- experiment: true,
820
+ const skelett = (requestId, { bundle, split, experiment = true } = {}) => ({
821
+ ...(experiment ? { experiment: true } : {}),
729
822
  request_id: requestId,
730
823
  slug: null,
731
824
  former_slug: null,
@@ -740,6 +833,7 @@ const skelett = (requestId, { bundle, split } = {}) => ({
740
833
  respec_varv: 0,
741
834
  coverage_post: null,
742
835
  design: null,
836
+ board: null,
743
837
  runs: [],
744
838
  })
745
839
 
@@ -768,6 +862,8 @@ if (cmd === 'add-run') {
768
862
  'double-run',
769
863
  'superseded-by',
770
864
  'pairs-with',
865
+ // ADR-0155 beslut 6: HEAD-sha som en analys lästes mot (korning.mjs).
866
+ 'repo-sha',
771
867
  ]
772
868
  const ADD_RUN_BOOLFLAGGOR = ['partial-rerun']
773
869
  avvisaOkandaFlaggor('add-run', ADD_RUN_VARDEFLAGGOR, ADD_RUN_BOOLFLAGGOR)
@@ -822,11 +918,17 @@ if (cmd === 'add-run') {
822
918
  const supersededBy = flag('superseded-by')
823
919
  const partialRerun = has('partial-rerun')
824
920
  const pairsWith = flag('pairs-with')
921
+ const repoSha = flag('repo-sha')
922
+ if (repoSha !== null && !/^[0-9a-f]{40}$/.test(repoSha)) {
923
+ console.error('--repo-sha kräver 40 hexsiffror')
924
+ process.exit(2)
925
+ }
825
926
 
826
927
  // Kritisk sektion: från låset förvärvas till det släpps får ingen annan
827
928
  // add-run-process läsa eller skriva state.json för samma katalog.
828
929
  const lockPath = acquireLock()
829
930
  let rowCount
931
+ let delStateForRot = null
830
932
  try {
831
933
  const state = readStateOrThrow()
832
934
  // Dubblettvakt: en PRICING-/GUARD-fil (klass- eller poängaxelns
@@ -866,6 +968,8 @@ if (cmd === 'add-run') {
866
968
  pairs_with: pairsWith,
867
969
  }
868
970
  if (note) row.note = note
971
+ // Frånvarande, inte null, när flaggan utelämnas (ADR-0155 beslut 6).
972
+ if (repoSha !== null) row.repo_sha = repoSha
869
973
  for (const u of underlagFor(file)) {
870
974
  const p = path.join(katalog, u)
871
975
  try {
@@ -894,20 +998,32 @@ if (cmd === 'add-run') {
894
998
  const rubrik = forstaRubrik(readFileSync(path.join(katalog, 'spec.md'), 'utf8'))
895
999
  if (rubrik) titelFalt = { title: rubrik, slug: slugAv(rubrik) }
896
1000
  }
897
- // Täckningspost (ADR-0119, #508): senaste pricing-filen som bär fältet
898
- // vinner, precis som titeln följer spec.md på disk. En pricing-fil som
899
- // (ännu) inte finns är inte add-runs jobb att fälla, den stämplas ofta
900
- // innan agenten skrivit filen. Finns den redan är trasig JSON eller ett
901
- // icke-objekt ett arkivfel som andra anropare i den här filen, fångas
902
- // inte här: readPricingFileOrThrow kastar oskyddad.
903
- if (PRICING_RE.test(file) && existsSync(path.join(katalog, file))) {
904
- const data = readPricingFileOrThrow(file, katalog)
905
- if (data.coverage_post !== null && typeof data.coverage_post === 'object')
906
- titelFalt = { ...titelFalt, coverage_post: data.coverage_post }
907
- }
1001
+ // Täckningspost (ADR-0159, ändrar ADR-0119 beslut 4; #763, #687): fältet
1002
+ // härleds ur den poängfil som GÄLLER efter den här raden, aldrig ur filen
1003
+ // som råkar stämplas. Bara en poängprisfil (huvudfil, -run2 eller
1004
+ // -avvisad) räknar om: en guardfil har en egen N-sekvens och synkas
1005
+ // aldrig (SKILL.md, arkivträdet), och spec.md/impact.md får inte väcka en
1006
+ // post som respec redan rensat. En -run2 eller -avvisad från en NYARE
1007
+ // runda än den gällande (huvudfilen är inte stämplad än) räknar inte om
1008
+ // heller: den rundan pågår och dess gällande fil finns inte än, så fältet
1009
+ // behåller sitt värde (null efter en respec) tills rundans huvudfil
1010
+ // stämplas. Se gallandeTackning för regeln.
1011
+ const poangfil = /^score-pricing-v(\d+)(-[a-z0-9-]+)?\.json$/.exec(file)
1012
+ if (poangfil) {
1013
+ const efter = { ...state, runs: [...runs, row] }
1014
+ if (Number(poangfil[1]) <= maxN(efter.runs, 'score'))
1015
+ titelFalt = { ...titelFalt, coverage_post: gallandeTackning(efter) }
1016
+ }
1017
+ // ADR-0153 beslut 3: brädkvittot betyder att den gällande prisrundan
1018
+ // ligger på brädan. En ny rundas huvudfil gör ett tidigare kvitto
1019
+ // inaktuellt, så nasta-steg säger "om inte gjort" igen. -run2, -avvisad,
1020
+ // spec.md och impact.md rör aldrig kvittot.
1021
+ if (HUVUDFIL_RE.test(file) && state.board !== null && state.board !== undefined)
1022
+ titelFalt = { ...titelFalt, board: null }
908
1023
  const nyState = { ...state, ...titelFalt, runs: [...runs, row] }
909
1024
  writeStateOrThrow(nyState)
910
1025
  rowCount = nyState.runs.length
1026
+ if (HUVUDFIL_RE.test(file)) delStateForRot = state
911
1027
  } catch (e) {
912
1028
  releaseLock(lockPath)
913
1029
  if (e instanceof ArkivFel) {
@@ -917,7 +1033,8 @@ if (cmd === 'add-run') {
917
1033
  throw e
918
1034
  }
919
1035
  releaseLock(lockPath)
920
- console.log(`OK: runs-rad för ${file} (${rowCount} rader)`)
1036
+ const rotSuffix = nollaRotensKvitto(delStateForRot)
1037
+ console.log(`OK: runs-rad för ${file} (${rowCount} rader)${rotSuffix}`)
921
1038
  } else if (cmd === 'next-n') {
922
1039
  // `flags('typ')` bara SAMLAR IN varje värde som direkt följer ett `--typ`
923
1040
  // — den validerar aldrig att RESTEN av `rest` är just sådana par. Ett
@@ -1120,6 +1237,13 @@ if (cmd === 'add-run') {
1120
1237
  }
1121
1238
  }
1122
1239
  state.granskarutslag = [...(state.granskarutslag ?? []), row]
1240
+ // ADR-0159: en dom på poängaxeln kan byta den gällande filen, så
1241
+ // täckningsposten räknas om ur den. En trasig gällande fil fäller domen
1242
+ // (exit 2) innan något skrivs.
1243
+ // En dom om ett äldre varv byter aldrig den gällande filen (liftingUtslag
1244
+ // kräver n === maxN) och räknar därför inte om.
1245
+ if (row.typ === 'viktning' && row.axel === 'score' && row.n === maxN(state.runs ?? [], 'score'))
1246
+ state.coverage_post = gallandeTackning(state)
1123
1247
  writeStateOrThrow(state)
1124
1248
  rowCount = state.granskarutslag.length
1125
1249
  } catch (e) {
@@ -1154,6 +1278,10 @@ if (cmd === 'add-run') {
1154
1278
  }
1155
1279
  if (stopp.length === 0) {
1156
1280
  state.status = status
1281
+ // ADR-0159: priced är steget före synken, så täckningsposten räknas om
1282
+ // här också. En huvudfil som stämplades innan den skrevs får då sin
1283
+ // post även när ingen senare stämpling kom.
1284
+ if (status === 'priced') state.coverage_post = gallandeTackning(state)
1157
1285
  writeStateOrThrow(state)
1158
1286
  }
1159
1287
  } catch (e) {
@@ -1253,19 +1381,96 @@ if (cmd === 'add-run') {
1253
1381
  // katalog är körningskatalogen (k<N>) eller en buntdel (DELA_UPP inuti en
1254
1382
  // del ger del-N/del-M). Skelettet skrivs av kommandot, aldrig av den inre
1255
1383
  // processen: samma skäl som ny-korning (ADR-0107 beslut 4).
1384
+ //
1385
+ // #599: under en produktionspost (state.json finns och saknar
1386
+ // experiment: true) skriver `--typ split` en produktionsdel i stället:
1387
+ // ingen experimentmarkör (set-status priced vägrar den annars), split.parent
1388
+ // är förälderns gid (katalognamnet när gid saknas), förälderns approval
1389
+ // ärvs (delen ärver den godkända specen, ADR-0144), och split valideras som
1390
+ // heltal vid skrivning, så ett handskrivet "of": "2" aldrig fäller
1391
+ // delningslage för hela trädet efteråt. --request-id är då valfri och måste
1392
+ // vara förälderns id.
1256
1393
  avvisaOkandaFlaggor('ny-del', ['typ', 'part', 'of', 'request-id'])
1257
1394
  const typ = requireFlag('typ')
1258
- const part = Number(requireFlag('part'))
1259
- const of = Number(requireFlag('of'))
1260
- const requestId = requireFlag('request-id')
1395
+ const partRa = requireFlag('part')
1396
+ const ofRa = requireFlag('of')
1397
+ const heltal = (v) => (/^[1-9]\d{0,2}$/.test(v) ? Number(v) : NaN)
1398
+ const part = heltal(partRa)
1399
+ const of = heltal(ofRa)
1261
1400
  if (!['bundle', 'split'].includes(typ)) {
1262
1401
  console.error(`--typ måste vara bundle eller split (fick "${typ}")`)
1263
1402
  process.exit(2)
1264
1403
  }
1265
- if (!Number.isInteger(part) || !Number.isInteger(of) || part < 1 || of < 1 || part > of) {
1266
- console.error(`--part och --of måste vara heltal >= 1 med part <= of (fick ${part}/${of})`)
1404
+ if (!Number.isInteger(part) || !Number.isInteger(of) || part > of) {
1405
+ console.error(
1406
+ `--part och --of måste vara heltal 1-999 med part <= of (fick "${partRa}"/"${ofRa}")`,
1407
+ )
1267
1408
  process.exit(2)
1268
1409
  }
1410
+ let foralder = null
1411
+ if (existsSync(path.join(katalog, 'state.json'))) {
1412
+ // Förälderns lås hålls från läsningen till delens state.json är skriven:
1413
+ // två samtidiga ny-del med olika --of kan annars båda passera
1414
+ // syskonkontrollen. Varje utgång nedan är process.exit, så låset släpps
1415
+ // i exit-hooken.
1416
+ const lockPath = acquireLock(katalog)
1417
+ process.on('exit', () => releaseLock(lockPath))
1418
+ try {
1419
+ foralder = readStateOrThrow(katalog)
1420
+ } catch (e) {
1421
+ if (!(e instanceof ArkivFel)) throw e
1422
+ console.error(e.message)
1423
+ process.exit(2)
1424
+ }
1425
+ }
1426
+ const produktion = foralder !== null && foralder.experiment !== true
1427
+ let requestId
1428
+ let arvtGodkannande = null
1429
+ if (produktion) {
1430
+ const vagra = (msg) => {
1431
+ console.error(`ny-del vägrar: ${msg}`)
1432
+ process.exit(2)
1433
+ }
1434
+ if (typ !== 'split')
1435
+ vagra('--typ bundle under en produktionspost (produktionsbuntar skrivs av speca)')
1436
+ if (foralder.split?.parent != null)
1437
+ vagra('föräldern är själv en delningsdel (en del delas aldrig vidare, den blir OVER_SKALAN)')
1438
+ if (foralder.approval === null || foralder.approval === undefined)
1439
+ vagra('föräldern saknar godkännande (delning följer bedöm, som kräver ett godkännande)')
1440
+ // Delningen följer scorerns DELA_UPP (bedöm-routningen, ADR-0144): samma
1441
+ // avläsning som nasta-steg gör, aldrig en handdelning av en annan post.
1442
+ let utfall
1443
+ try {
1444
+ utfall = gallandeScorerUtfall(foralder, katalog)?.outcome ?? null
1445
+ } catch (e) {
1446
+ if (!(e instanceof ArkivFel)) throw e
1447
+ vagra(e.message)
1448
+ }
1449
+ if (foralder.status !== 'assessed' || utfall !== 'DELA_UPP')
1450
+ vagra(
1451
+ `föräldern står inte på scorerns DELA_UPP (status ${foralder.status}, utfall ${utfall ?? 'inget'})`,
1452
+ )
1453
+ if (of < 2) vagra(`en delning har minst två delar (fick --of ${of})`)
1454
+ const foralderId = foralder.request_id ?? path.basename(path.resolve(katalog))
1455
+ const givet = flag('request-id')
1456
+ if (givet !== null && givet !== foralderId)
1457
+ vagra(`--request-id ${givet} är inte förälderns id ${foralderId}`)
1458
+ for (const syskon of delKataloger(katalog).filter((d) => path.dirname(d) === katalog)) {
1459
+ let st
1460
+ try {
1461
+ st = readStateOrThrow(syskon)
1462
+ } catch (e) {
1463
+ if (!(e instanceof ArkivFel)) throw e
1464
+ vagra(e.message)
1465
+ }
1466
+ if (st.split?.parent != null && st.split.of !== of)
1467
+ vagra(`${path.basename(syskon)} har split.of ${st.split.of}, inte ${of}`)
1468
+ }
1469
+ requestId = foralderId
1470
+ arvtGodkannande = foralder.approval
1471
+ } else {
1472
+ requestId = requireFlag('request-id')
1473
+ }
1269
1474
  const p = path.join(katalog, `del-${part}`)
1270
1475
  try {
1271
1476
  mkdirSync(p)
@@ -1274,12 +1479,17 @@ if (cmd === 'add-run') {
1274
1479
  process.exit(2)
1275
1480
  }
1276
1481
  const ref = { part, of }
1277
- const st = skelett(
1278
- requestId,
1279
- typ === 'bundle'
1280
- ? { bundle: { parent_slug: requestId, ...ref } }
1281
- : { split: { parent: requestId, ...ref } },
1282
- )
1482
+ const st = produktion
1483
+ ? {
1484
+ ...skelett(null, { split: { parent: requestId, ...ref }, experiment: false }),
1485
+ approval: arvtGodkannande,
1486
+ }
1487
+ : skelett(
1488
+ requestId,
1489
+ typ === 'bundle'
1490
+ ? { bundle: { parent_slug: requestId, ...ref } }
1491
+ : { split: { parent: requestId, ...ref } },
1492
+ )
1283
1493
  try {
1284
1494
  writeStateOrThrow(st, p)
1285
1495
  } catch (e) {
@@ -1555,15 +1765,16 @@ if (cmd === 'add-run') {
1555
1765
  avvisaOkandaFlaggor('stopp', ['utfall', 'kalla', 'orsak'])
1556
1766
  const utfall = requireFlag('utfall')
1557
1767
  const kalla = requireFlag('kalla')
1558
- const orsak = flag('orsak')
1768
+ let orsak = flag('orsak')
1559
1769
  if (!STOPP_UTFALL.includes(utfall)) {
1560
1770
  console.error(`okänt --utfall: ${utfall} (giltiga: ${STOPP_UTFALL.join(', ')})`)
1561
1771
  process.exit(2)
1562
1772
  }
1563
1773
  const arDesignKvitto = DESIGN_AVVISAD_RE.test(kalla)
1564
- if (!STOPP_KALLOR.includes(kalla) && !arDesignKvitto) {
1774
+ const arIntervjuKvitto = DESIGN_INTERVJU_RE.test(kalla)
1775
+ if (!STOPP_KALLOR.includes(kalla) && !arDesignKvitto && !arIntervjuKvitto) {
1565
1776
  console.error(
1566
- `okänd --kalla: ${kalla} (giltiga: ${STOPP_KALLOR.join(', ')}, design-avvisad-<datum>.md)`,
1777
+ `okänd --kalla: ${kalla} (giltiga: ${STOPP_KALLOR.join(', ')}, design-avvisad-<datum>.md, design-intervju-kravs-<datum>.md)`,
1567
1778
  )
1568
1779
  process.exit(2)
1569
1780
  }
@@ -1611,6 +1822,23 @@ if (cmd === 'add-run') {
1611
1822
  )
1612
1823
  process.exit(2)
1613
1824
  }
1825
+ } else if (arIntervjuKvitto) {
1826
+ // P4-24: kvittot är designerns svar, och bara en första rad
1827
+ // `INTERVJU KRÄVS: <skäl>` är ett stopp. Det går till en människa
1828
+ // (designa, intervjun), aldrig tillbaka till specen.
1829
+ const forsta = text.replace(/\r\n/g, '\n').trimStart().split('\n')[0].trim()
1830
+ if (!/^INTERVJU KRÄVS:\s*\S/u.test(forsta)) {
1831
+ console.error(`${kallaPath} börjar inte med "INTERVJU KRÄVS: <skäl>", kan inte stoppas`)
1832
+ process.exit(2)
1833
+ }
1834
+ if (utfall !== 'ESKALERA') {
1835
+ console.error(`ett intervjukrav i ${kalla} stoppar som ESKALERA, inte ${utfall}`)
1836
+ process.exit(2)
1837
+ }
1838
+ if (!orsak) {
1839
+ console.error('ESKALERA kräver --orsak: en människa ska se varför designern inte skrev något')
1840
+ process.exit(2)
1841
+ }
1614
1842
  } else if (kalla === 'granskarblock.md') {
1615
1843
  // ADR-0118: bara ett ja på raden Prisavgörande öppet val är ett stopp.
1616
1844
  if (prisavgorandeVal(text) === null) {
@@ -1645,6 +1873,26 @@ if (cmd === 'add-run') {
1645
1873
  console.error(`en motsägelse i ${kalla} stoppar som TILLBAKA_TILL_SPEC, inte ${utfall}`)
1646
1874
  process.exit(2)
1647
1875
  }
1876
+ // #595: designkonflikten (texten efter etiketten börjar med `design
1877
+ // conflict:`) avgör routningen till designa (arDesignkonflikt), så den
1878
+ // läses ur filen, aldrig bara ur flaggan. Utan --orsak blir filens text
1879
+ // orsaken; en --orsak som motsäger filen (en annan konflikttext, en
1880
+ // specorsak mot en designkonflikt, eller en designkonflikt filen inte
1881
+ // bär) vägras. En vanlig motsägelse behåller anroparens fria orsak.
1882
+ const filText = avvikelseText(text)
1883
+ const filKonflikt = filText !== null && /^design conflict:/i.test(filText) ? filText : null
1884
+ const givenKonflikt = orsak !== null && /^design conflict:/i.test(orsak.trim())
1885
+ const motsager =
1886
+ orsak !== null && (filKonflikt !== null ? orsak.trim() !== filKonflikt : givenKonflikt)
1887
+ if (motsager) {
1888
+ console.error(
1889
+ `--orsak motsäger impact.md: filen säger ${
1890
+ filKonflikt === null ? 'ingen designkonflikt' : `"${filKonflikt}"`
1891
+ }, flaggan "${orsak}"`,
1892
+ )
1893
+ process.exit(2)
1894
+ }
1895
+ if (filKonflikt !== null) orsak = filKonflikt
1648
1896
  }
1649
1897
 
1650
1898
  const lockPath = acquireLock()
@@ -1703,18 +1951,28 @@ if (cmd === 'add-run') {
1703
1951
  )
1704
1952
  } else if (cmd === 'respec') {
1705
1953
  // Speca steg 5 som kommando: en ny spec över befintlig katalog invaliderar
1706
- // analysen, designen, godkännandet, domarna och stoppet. runs[] rörs
1954
+ // analysen, designen, godkännandet, domarna, stoppet och brädkvittot
1955
+ // (ADR-0153 beslut 3: kvittot gällde en prisrunda som inte längre gäller). runs[] rörs
1707
1956
  // aldrig, arkivet är append-only; den gamla analysen och designen döps
1708
1957
  // om, raderas inte.
1709
1958
  avvisaOkandaFlaggor('respec', [])
1710
1959
  const lockPath = acquireLock()
1711
1960
  let antal = 0
1961
+ let delState = null
1712
1962
  try {
1713
1963
  const state = readStateOrThrow()
1964
+ // ADR-0155 beslut 3: en levande produktionskörning skriver mot samma
1965
+ // katalog; vägra innan något läggs undan. Ett dött pid rensas nedan.
1966
+ if (state.korning && isAlive(state.korning.pid))
1967
+ throw new ArkivFel(`respec vägrar: pågår (pid ${state.korning.pid})`)
1968
+ delState = state
1714
1969
  if (laggUndan(katalog, 'impact')) antal++
1715
1970
  // Designa (spec 2026-09-24): en ny spec gör den gamla designen inaktuell
1716
1971
  // av samma skäl som den gör analysen inaktuell. Läggs undan, raderas inte.
1717
1972
  if (laggUndan(katalog, 'design')) antal++
1973
+ // ADR-0156 beslut 3: granskningen gällde den gamla designen och följer
1974
+ // den, undanlagd; state.design.review nollas med state.design nedan.
1975
+ if (laggUndan(katalog, 'design-review')) antal++
1718
1976
  const nytt = { ...state }
1719
1977
  if (nytt.status !== 'spec_draft') {
1720
1978
  nytt.status = 'spec_draft'
@@ -1736,6 +1994,22 @@ if (cmd === 'add-run') {
1736
1994
  nytt.design = null
1737
1995
  antal++
1738
1996
  }
1997
+ if (nytt.board !== null && nytt.board !== undefined) {
1998
+ nytt.board = null
1999
+ antal++
2000
+ }
2001
+ // ADR-0159: täckningsposten hörde till en prisrunda som inte längre
2002
+ // gäller; nästa poängrunda räknar fram den på nytt.
2003
+ if (nytt.coverage_post !== null && nytt.coverage_post !== undefined) {
2004
+ nytt.coverage_post = null
2005
+ antal++
2006
+ }
2007
+ // Ett dött kvitto (levande vägrades ovan) hör till en körning som inte
2008
+ // finns; en ny spec nollar det.
2009
+ if (nytt.korning !== null && nytt.korning !== undefined) {
2010
+ nytt.korning = null
2011
+ antal++
2012
+ }
1739
2013
  // Monoton räknare (#505): varje respec-anrop är ett nytt varv oavsett om
1740
2014
  // övriga fält redan var normaliserade, till skillnad från fälten ovan
1741
2015
  // finns inget "redan rätt värde" att jämföra mot, så det här villkoret
@@ -1752,7 +2026,7 @@ if (cmd === 'add-run') {
1752
2026
  throw e
1753
2027
  }
1754
2028
  releaseLock(lockPath)
1755
- console.log(`OK: respec (${antal} ändringar)`)
2029
+ console.log(`OK: respec (${antal} ändringar${nollaRotensKvitto(delState)})`)
1756
2030
  } else if (cmd === 'add-design') {
1757
2031
  // Designa steg 5 (spec 2026-09-24): designdokumentet verifieras, kopieras
1758
2032
  // byteidentiskt in som design.md, bokförs i state.design och sätter status
@@ -1775,8 +2049,113 @@ if (cmd === 'add-run') {
1775
2049
  throw e
1776
2050
  }
1777
2051
  releaseLock(lockPath)
2052
+ // ADR-0156 beslut 4: intervjudokumentets saknade utkastsektioner och en
2053
+ // symbolkontroll som inte kunde köras vägrar inte; de syns på stderr.
2054
+ for (const v of kvitto.varningar) console.error(`VARNING: ${v}`)
1778
2055
  console.log(`OK: design ${kvitto.design.path} (${kvitto.design.sha256})`)
1779
2056
  if (kvitto.impactUndanlagd) console.log('OK: impact.md undanlagd (designkonfliktstoppet löst)')
2057
+ } else if (cmd === 'add-design-review') {
2058
+ // ADR-0156 beslut 3: designgranskarens svar kopieras byteidentiskt in som
2059
+ // design-review.md och bokförs i state.design.review. Ingen runs-rad:
2060
+ // granskningen är intern som designen, kostnaden står i korning.json. En
2061
+ // tidigare granskning läggs undan som design-review-superseded-<datum>.md.
2062
+ // --model lagras som det ges (korning.mjs skickar MODELL:-svansraden).
2063
+ avvisaOkandaFlaggor('add-design-review', ['file', 'verdict', 'model'])
2064
+ const fil = requireFlag('file')
2065
+ const verdict = requireFlag('verdict')
2066
+ const model = requireFlag('model')
2067
+ if (verdict !== 'fortsätt' && verdict !== 'stopp') {
2068
+ console.error(
2069
+ `add-design-review vägrar: --verdict måste vara fortsätt eller stopp, fick "${verdict}"`,
2070
+ )
2071
+ process.exit(2)
2072
+ }
2073
+ const lockPath = acquireLock()
2074
+ let sha256
2075
+ try {
2076
+ const state = readStateOrThrow()
2077
+ if (state.experiment === true) throw new ArkivFel('add-design-review vägrar: experimentkatalog')
2078
+ if (state.design === null || state.design === undefined)
2079
+ throw new ArkivFel('add-design-review vägrar: ingen design att granska')
2080
+ // Bytes läses före undanläggningen, så även katalogens egen
2081
+ // design-review.md går att peka ut utan att kopian faller på en flyttad fil.
2082
+ let bytes
2083
+ try {
2084
+ bytes = readFileSync(path.resolve(fil))
2085
+ } catch (e) {
2086
+ throw new ArkivFel(`add-design-review vägrar: kan inte läsa ${fil}: ${e.message}`)
2087
+ }
2088
+ laggUndan(katalog, 'design-review')
2089
+ const mal = path.join(katalog, 'design-review.md')
2090
+ try {
2091
+ writeFileSync(mal, bytes)
2092
+ } catch (e) {
2093
+ throw new ArkivFel(`kunde inte skriva ${mal}: ${e.message}`)
2094
+ }
2095
+ sha256 = markerStrippedSha256(mal)
2096
+ writeStateOrThrow({
2097
+ ...state,
2098
+ design: { ...state.design, review: { verdict, sha256, date: today(), model } },
2099
+ })
2100
+ } catch (e) {
2101
+ releaseLock(lockPath)
2102
+ if (e instanceof ArkivFel) {
2103
+ console.error(e.message)
2104
+ process.exit(2)
2105
+ }
2106
+ throw e
2107
+ }
2108
+ releaseLock(lockPath)
2109
+ console.log(`OK: design-review ${verdict} (${sha256})`)
2110
+ } else if (cmd === 'antaganden') {
2111
+ // ADR-0156 beslut 5: läs-enbart lista inför "synka" över varje antagande
2112
+ // utkastet gjorde själv (fråga, valt, ej valt, skäl), granskningens utslag
2113
+ // med antal fynd och spec conflicts, ur design.md och state.json, aldrig
2114
+ // handskriven. Inget lås, ingen skrivning.
2115
+ avvisaOkandaFlaggor('antaganden', [])
2116
+ const ut = []
2117
+ try {
2118
+ let text
2119
+ try {
2120
+ text = readFileSync(path.join(katalog, 'design.md'), 'utf8')
2121
+ } catch {
2122
+ throw new ArkivFel('antaganden vägrar: ingen design')
2123
+ }
2124
+ const state = readStateOrThrow()
2125
+ ut.push(`Designläge: ${designMode(text)}`)
2126
+ const antaganden = designAssumptions(text)
2127
+ if (antaganden === null) ut.push('Antaganden: avsnitt saknas')
2128
+ else if (!Array.isArray(antaganden)) ut.push(`Antaganden: felformade (${antaganden.fel})`)
2129
+ else if (antaganden.length === 0) ut.push('Antaganden: inga')
2130
+ else {
2131
+ ut.push(`Antaganden (${antaganden.length}):`)
2132
+ antaganden.forEach((a, i) => {
2133
+ ut.push(
2134
+ `${i + 1}. ${a.fraga}`,
2135
+ ` vald: ${a.vald}`,
2136
+ ` ej valda: ${a.ejValda.length > 0 ? a.ejValda.join('; ') : 'inga'}`,
2137
+ ` skäl: ${a.skal}`,
2138
+ )
2139
+ })
2140
+ }
2141
+ const review = state.design?.review ?? null
2142
+ ut.push(
2143
+ review === null
2144
+ ? 'Granskning: ingen'
2145
+ : `Granskning: ${review.verdict} (${antalFynd(path.join(katalog, 'design-review.md'))})`,
2146
+ )
2147
+ const konflikter = specConflicts(text)
2148
+ if (konflikter === null) ut.push('Spec conflicts: avsnitt saknas')
2149
+ else if (konflikter.length === 0) ut.push('Spec conflicts: none')
2150
+ else ut.push(`Spec conflicts: ${konflikter.length}`, ...konflikter.map((k) => ` ${k}`))
2151
+ } catch (e) {
2152
+ if (e instanceof ArkivFel) {
2153
+ console.error(e.message)
2154
+ process.exit(2)
2155
+ }
2156
+ throw e
2157
+ }
2158
+ console.log(ut.join('\n'))
1780
2159
  } else if (cmd === 'set-split-gid') {
1781
2160
  // ADR-0144 beslut 6: delens Asana-deluppgift bokförs i split.gid när
1782
2161
  // request-sync har skapat den, så en omsynk uppdaterar deluppgiften i
@@ -1813,6 +2192,186 @@ if (cmd === 'add-run') {
1813
2192
  }
1814
2193
  releaseLock(lockPath)
1815
2194
  console.log(oforandrat ? `OK: split-gid ${gid} oförändrat` : `OK: split-gid ${gid}`)
2195
+ } else if (cmd === 'approve') {
2196
+ // ADR-0153 beslut 2: approval skrivs bara här. Grinden (designa eller
2197
+ // bedöm) läser brädan och skickar Offer status som den lästes; allt annat
2198
+ // än kärnan specok vägras, så en grind som inte lästs kan inte noteras.
2199
+ // En tidigare notering skrivs över: grinden läses om varje gång.
2200
+ avvisaOkandaFlaggor('approve', ['source', 'status', 'by'])
2201
+ const source = requireFlag('source')
2202
+ if (source !== 'asana' && source !== 'manual') {
2203
+ console.error(`--source måste vara asana eller manual, fick "${source}"`)
2204
+ process.exit(2)
2205
+ }
2206
+ // En flagga som hör till den andra källan tappas aldrig tyst: den vägras.
2207
+ if (source === 'asana' && has('by')) {
2208
+ console.error('--by hör inte till --source asana')
2209
+ process.exit(2)
2210
+ }
2211
+ if (source === 'manual' && has('status')) {
2212
+ console.error('--status hör inte till --source manual')
2213
+ process.exit(2)
2214
+ }
2215
+ let approval
2216
+ if (source === 'asana') {
2217
+ const status = requireFlag('status')
2218
+ if (alfabetiskKarna(status) !== 'specok') {
2219
+ console.error(`approve vägrar: Offer status "${status}" är inte ✅ Spec ok`)
2220
+ process.exit(2)
2221
+ }
2222
+ approval = { source, status_read: status, date: today() }
2223
+ } else {
2224
+ const by = requireFlag('by')
2225
+ if (by.trim() === '') {
2226
+ console.error('--by får inte vara tom')
2227
+ process.exit(2)
2228
+ }
2229
+ approval = { source, by, date: today() }
2230
+ }
2231
+ const lockPath = acquireLock()
2232
+ try {
2233
+ const state = readStateOrThrow()
2234
+ if (state.experiment === true) throw new ArkivFel('approve vägrar: experimentkatalog')
2235
+ writeStateOrThrow({ ...state, approval })
2236
+ } catch (e) {
2237
+ releaseLock(lockPath)
2238
+ if (e instanceof ArkivFel) {
2239
+ console.error(e.message)
2240
+ process.exit(2)
2241
+ }
2242
+ throw e
2243
+ }
2244
+ releaseLock(lockPath)
2245
+ console.log(source === 'asana' ? 'OK: approval asana' : `OK: approval manual by ${approval.by}`)
2246
+ } else if (cmd === 'korning-start') {
2247
+ // ADR-0155 beslut 3: bedom.mjs bokför sin process här innan den spawnar
2248
+ // prisningen, så nasta-steg kan säga "pågår" och en andra start vägras.
2249
+ // Ett dött pid övertas med en varning; EPERM räknas som levande (isAlive).
2250
+ avvisaOkandaFlaggor('korning-start', ['pid', 'katalog-korning'])
2251
+ const pidTxt = flag('pid')
2252
+ if (pidTxt === null || !/^\d+$/.test(pidTxt) || Number(pidTxt) <= 0) {
2253
+ console.error('korning-start kräver --pid <heltal>')
2254
+ process.exit(2)
2255
+ }
2256
+ const pid = Number(pidTxt)
2257
+ const katalogKorning = flag('katalog-korning')
2258
+ const lockPath = acquireLock()
2259
+ let varning = null
2260
+ try {
2261
+ const state = readStateOrThrow()
2262
+ if (state.experiment === true) throw new ArkivFel('korning-start vägrar: experimentkatalog')
2263
+ if (state.korning) {
2264
+ if (isAlive(state.korning.pid))
2265
+ throw new ArkivFel(`korning-start vägrar: pågår (pid ${state.korning.pid})`)
2266
+ varning = `VARNING: tog över död körning (pid ${state.korning.pid})`
2267
+ }
2268
+ // `started` är en full ISO-tidsstämpel, den enda i state.json (övriga
2269
+ // datum är today()): en körning behöver klockslag för att kunna läsas.
2270
+ writeStateOrThrow({
2271
+ ...state,
2272
+ korning: { pid, started: new Date().toISOString(), katalog: katalogKorning },
2273
+ })
2274
+ } catch (e) {
2275
+ releaseLock(lockPath)
2276
+ if (e instanceof ArkivFel) {
2277
+ console.error(e.message)
2278
+ process.exit(2)
2279
+ }
2280
+ throw e
2281
+ }
2282
+ releaseLock(lockPath)
2283
+ if (varning) console.error(varning)
2284
+ console.log(`OK: korning-start pid ${pid}`)
2285
+ } else if (cmd === 'korning-slut') {
2286
+ // ADR-0155 beslut 3: kvittot rensas när bedom.mjs är klar. Utan kvitto är
2287
+ // det ett no-op, aldrig ett fel: slutet ska kunna köras i en finally.
2288
+ avvisaOkandaFlaggor('korning-slut', [])
2289
+ const lockPath = acquireLock()
2290
+ try {
2291
+ const state = readStateOrThrow()
2292
+ if (state.experiment === true) throw new ArkivFel('korning-slut vägrar: experimentkatalog')
2293
+ if (state.korning !== null) writeStateOrThrow({ ...state, korning: null })
2294
+ } catch (e) {
2295
+ releaseLock(lockPath)
2296
+ if (e instanceof ArkivFel) {
2297
+ console.error(e.message)
2298
+ process.exit(2)
2299
+ }
2300
+ throw e
2301
+ }
2302
+ releaseLock(lockPath)
2303
+ console.log('OK: korning-slut')
2304
+ } else if (cmd === 'rot') {
2305
+ // ADR-0154: arkivets enda hem, löst från katalogen (default cwd). `katalog`
2306
+ // är här cwd att lösa från, inte en post; en flagga i första läget är ingen
2307
+ // katalog. Läs-enbart, ingen lås.
2308
+ const flaggaForst = katalog !== undefined && katalog.startsWith('-')
2309
+ const tokens = flaggaForst ? [katalog, ...rest] : rest
2310
+ if (tokens.length > 0) {
2311
+ console.error(
2312
+ `rot: okänd eller ej konsumerad flagga/argument (${tokens.join(' ')}), kommandot tar inga flaggor`,
2313
+ )
2314
+ process.exit(2)
2315
+ }
2316
+ const fran = path.resolve(katalog ?? process.cwd())
2317
+ const r = arkivRot(fran)
2318
+ if (!r) {
2319
+ console.error(`rot vägrar: ingen git-katalog (${fran})`)
2320
+ process.exit(2)
2321
+ }
2322
+ if (r.arkivRot === null) {
2323
+ console.error(
2324
+ `rot vägrar: git-common-dir heter inte .git (${r.commonDir}); kör från superprojektets egen utcheckning`,
2325
+ )
2326
+ process.exit(2)
2327
+ }
2328
+ console.log(`ARKIVROT: ${r.arkivRot}`)
2329
+ console.log(`IGNORERAD: ${r.ignorerad ? 'ja' : 'nej'}`)
2330
+ // Ett herrelöst arkiv i cwd:ns egen utcheckning (en worktree med eget
2331
+ // requests/, Convendums läge mellan släppet och flytten) får en VARNING på
2332
+ // stdout som precheck.mjs, exit fortfarande 0.
2333
+ const topp = spawnSync('git', ['rev-parse', '--show-toplevel'], {
2334
+ cwd: fran,
2335
+ encoding: 'utf8',
2336
+ timeout: 5000,
2337
+ })
2338
+ const toppDir = topp.status === 0 ? String(topp.stdout ?? '').trim() : ''
2339
+ if (toppDir) {
2340
+ const strat = path.join(path.resolve(toppDir), '.haus-workflow', 'requests')
2341
+ if (strat !== path.resolve(r.arkivRot) && existsSync(strat))
2342
+ console.log(`VARNING: arkiv i worktree: ${strat} (flytta per ADR-0154)`)
2343
+ }
2344
+ } else if (cmd === 'synced') {
2345
+ // ADR-0153 beslut 3: brädskrivningen i speca steg 7 och bedöm steg 8 följs
2346
+ // av det här kvittot i state.board, Offer status ordagrant som request-sync
2347
+ // satte den. Varje sync är ett nytt kvitto och skriver över det förra.
2348
+ // Arkivet är fortfarande en cache: Closing-radens brädläge läses ur Asana.
2349
+ avvisaOkandaFlaggor('synced', ['offer-status'])
2350
+ const offerStatus = requireFlag('offer-status')
2351
+ if (offerStatus.trim() === '') {
2352
+ console.error('--offer-status får inte vara tom')
2353
+ process.exit(2)
2354
+ }
2355
+ const board = { offer_status: offerStatus, date: today() }
2356
+ const lockPath = acquireLock()
2357
+ try {
2358
+ const state = readStateOrThrow()
2359
+ if (state.experiment === true) throw new ArkivFel('synced vägrar: experimentkatalog')
2360
+ if (state.split?.parent != null)
2361
+ throw new ArkivFel(
2362
+ 'synced vägrar: en delningsdel har ingen egen Offer status (split.gid är kvittot)',
2363
+ )
2364
+ writeStateOrThrow({ ...state, board })
2365
+ } catch (e) {
2366
+ releaseLock(lockPath)
2367
+ if (e instanceof ArkivFel) {
2368
+ console.error(e.message)
2369
+ process.exit(2)
2370
+ }
2371
+ throw e
2372
+ }
2373
+ releaseLock(lockPath)
2374
+ console.log(`OK: synced ${offerStatus}`)
1816
2375
  } else if (cmd === 'paraply') {
1817
2376
  // ADR-0144 beslut 5: förälderns spec skrivs om som en paraplyspec efter ett
1818
2377
  // ja. Den gamla spec.md läggs undan (spec-superseded-<datum>.md),
@@ -1898,7 +2457,9 @@ if (cmd === 'add-run') {
1898
2457
  ? 'prisavgörande val (granskarblock.md)'
1899
2458
  : DESIGN_AVVISAD_RE.test(state.stopp.kalla ?? '')
1900
2459
  ? `designkonflikt (${state.stopp.kalla})`
1901
- : 'motsägelse (impact.md)'
2460
+ : arIntervjuStopp(state.stopp)
2461
+ ? `intervju krävs (${state.stopp.kalla})`
2462
+ : 'motsägelse (impact.md)'
1902
2463
  }
1903
2464
  // ADR-0144 beslut 6: delningsroten visar hur många delar som är klara,
1904
2465
  // samma räkning som nasta-steg (delningslage), före täckningsposten.
@@ -1957,7 +2518,7 @@ if (cmd === 'add-run') {
1957
2518
  console.log(rader.join('\n'))
1958
2519
  } else {
1959
2520
  console.error(
1960
- 'okänt kommando (add-run, next-n, ny-korning, ny-del, proveniens, sparr, utslag, set-status, stopp, respec, add-design, set-split-gid, paraply, bluf, nasta-steg, kalibrering, experiment)',
2521
+ 'okänt kommando (add-run, next-n, ny-korning, ny-del, proveniens, sparr, utslag, set-status, stopp, respec, add-design, add-design-review, antaganden, set-split-gid, approve, synced, rot, paraply, bluf, nasta-steg, kalibrering, experiment)',
1961
2522
  )
1962
2523
  process.exit(2)
1963
2524
  }
@@ -1985,6 +2546,10 @@ function tolkaSatt(par) {
1985
2546
  }
1986
2547
  return [nyckel, ra === 'true']
1987
2548
  }
2549
+ if (nyckel === 'design_mode' && ra !== 'draft' && ra !== 'intervju') {
2550
+ console.error(`experiment: design_mode tar bara draft eller intervju, fick "${ra}"`)
2551
+ process.exit(2)
2552
+ }
1988
2553
  return [nyckel, ra]
1989
2554
  }
1990
2555
 
@@ -2034,7 +2599,9 @@ function delKataloger(dir) {
2034
2599
 
2035
2600
  /** Lägger undan <bas>.md som <bas>-superseded-<datum>.md, numrerad svans vid
2036
2601
  * kollision (-2, -3 …), aldrig en överskrivning: arkivet raderar inget.
2037
- * Delad av respec (impact.md och design.md) och add-design (design.md).
2602
+ * Delad av respec (impact.md, design.md och design-review.md), add-design
2603
+ * (design.md och design-review.md), add-design-review (design-review.md),
2604
+ * paraply (spec.md) och add-design:s designkonfliktstopp (impact.md).
2038
2605
  * Returnerar true när en fil flyttades. */
2039
2606
  function laggUndan(d, bas) {
2040
2607
  const fil = path.join(d, `${bas}.md`)
@@ -2050,6 +2617,28 @@ function laggUndan(d, bas) {
2050
2617
  return true
2051
2618
  }
2052
2619
 
2620
+ /** antaganden (ADR-0156 beslut 5): fyndräkningen i design-review.md som text
2621
+ * för Granskning-raden. Ett fynd är en listpunkt (`- ` eller `* `) under
2622
+ * `## Findings` på den första punktens indrag; en djupare indragen
2623
+ * underpunkt hör till fyndet ovanför och räknas inte. Den enda punkten
2624
+ * `none` är 0. En saknad fil eller sektion säger det, aldrig en gissad
2625
+ * nolla. */
2626
+ function antalFynd(fil) {
2627
+ let text
2628
+ try {
2629
+ text = readFileSync(fil, 'utf8')
2630
+ } catch {
2631
+ return 'fynd okända, design-review.md saknas'
2632
+ }
2633
+ const rader = sectionLines(text.replace(/\r\n/g, '\n'), 'Findings')
2634
+ if (rader === null) return 'fynd okända, ## Findings saknas'
2635
+ const listpunkter = rader.map((r) => /^([ \t]*)[-*] (.*)$/.exec(r)).filter((m) => m !== null)
2636
+ const indrag = listpunkter[0]?.[1] ?? ''
2637
+ const punkter = listpunkter.filter((m) => m[1] === indrag).map((m) => m[2].trim())
2638
+ const antal = punkter.length === 1 && punkter[0].toLowerCase() === 'none' ? 0 : punkter.length
2639
+ return `${antal} fynd`
2640
+ }
2641
+
2053
2642
  /** Repo-roten för en arkivkatalog: föräldern till närmaste `.haus-workflow`
2054
2643
  * uppåt i sökvägen (arkivet bor i <rot>/.haus-workflow/requests/). null när
2055
2644
  * katalogen inte ligger under någon; add-design vägrar då i stället för att
@@ -2065,14 +2654,17 @@ function repoRotFor(d) {
2065
2654
  }
2066
2655
 
2067
2656
  /** add-design: vägransreglerna i ordning (rot, fil, inneslutning, arkivet,
2068
- * frontmatter, spec-hash, konflikter, status, stopp), sedan undanläggning,
2657
+ * frontmatter, spec-hash, konflikter, status, stopp, utkastsektioner,
2658
+ * symboler), sedan undanläggning,
2069
2659
  * kopia och state. Inneslutningen prövas på realpath (ADR-0082: en länk
2070
2660
  * matchas på målet), och sökvägen som bokförs är realpath så precheck
2071
2661
  * --design hittar samma fil oavsett länkar. Ett designkonfliktstopp ur
2072
2662
  * impact.md nollas (en omdesign besvarar det, slutgranskning I2) och
2073
2663
  * impact.md läggs undan med det; varje annat stopp vägras. Kastar
2074
- * ArkivFel, skriver inget före sista steget. Returnerar designkvittot och
2075
- * om en analys lades undan. */
2664
+ * ArkivFel, skriver inget före sista steget. Returnerar designkvittot, om
2665
+ * en analys lades undan och varningarna (ADR-0156 beslut 4: utkastsektioner
2666
+ * på ett intervjudokument, en symbolkontroll som inte kunde köras), som
2667
+ * anroparen skriver på stderr. */
2076
2668
  function laggTillDesign(state, fil) {
2077
2669
  const rot = repoRotFor(katalog)
2078
2670
  if (rot === null)
@@ -2131,17 +2723,44 @@ function laggTillDesign(state, fil) {
2131
2723
  `${statePath()} har status ${state.status}, en prissatt post designas inte om utan respec`,
2132
2724
  )
2133
2725
  const stopp = state.stopp ?? null
2134
- if (stopp !== null && !arDesignkonflikt(stopp))
2726
+ // P4-24: ett intervjukrav (design-intervju-kravs-<datum>.md) besvaras av
2727
+ // designa, så designen nollar det som designkonfliktstoppet.
2728
+ if (stopp !== null && !arDesignkonflikt(stopp) && !arIntervjuStopp(stopp))
2135
2729
  throw new ArkivFel(
2136
2730
  `${statePath()} har ett stopp (${stopp.utfall} ur ${stopp.kalla}) som en design inte löser, kör respec eller avgör stoppet först`,
2137
2731
  )
2732
+ // ADR-0156 beslut 4, efter de billiga statusvägrarna så en vägrad post
2733
+ // aldrig startar git: ett utkast (mode: draft) bär Assumptions, Removed or
2734
+ // renamed och Files touched, samma rader som precheck --design (på ett
2735
+ // intervjudokument varningar). Varje borttagen eller omdöpt symbol greppas
2736
+ // i repot där dokumentet bor, utanför filerna designen rör: en träff
2737
+ // vägrar, en kontroll som inte kunde köras är en varning.
2738
+ const designRot = repoRotForFil(real)
2739
+ const form = designForm(text, { repoRot: designRot })
2740
+ if (form.brister.length > 0) throw new ArkivFel(form.brister.join('\n'))
2741
+ const { traffar, varning } = designSymbolkontroll(text, designRot)
2742
+ if (traffar.length > 0)
2743
+ throw new ArkivFel(
2744
+ traffar
2745
+ .map(
2746
+ (t) =>
2747
+ `add-design vägrar: symbolen \`${t.symbol}\` används fortfarande: ${t.fil}:${t.rad}`,
2748
+ )
2749
+ .join('\n'),
2750
+ )
2751
+ const varningar = [...form.varningar, ...(varning ? [varning] : [])]
2138
2752
  laggUndan(katalog, 'design')
2753
+ // Granskningen gällde den ersatta designen och följer den, undanlagd som i
2754
+ // respec; state.design.review följer aldrig med (nytt objekt nedan).
2755
+ laggUndan(katalog, 'design-review')
2139
2756
  const mal = path.join(katalog, 'design.md')
2140
2757
  try {
2141
2758
  copyFileSync(real, mal)
2142
2759
  } catch (e) {
2143
2760
  throw new ArkivFel(`kunde inte kopiera ${real} till ${mal}: ${e.message}`)
2144
2761
  }
2762
+ // Ett nytt objekt: en ny designversion har ingen granskning än, så en
2763
+ // tidigare state.design.review följer aldrig med (ADR-0156 beslut 3).
2145
2764
  const design = {
2146
2765
  path: real,
2147
2766
  file: 'design.md',
@@ -2155,14 +2774,14 @@ function laggTillDesign(state, fil) {
2155
2774
  // en ny analys är skyldig oavsett omdesignens bytes, och en byteidentisk
2156
2775
  // omdesign hade annars lämnat motsägelseanalysen proveniensgiltig. runs[]
2157
2776
  // rörs inte, som i respec.
2158
- const impactUndanlagd = stopp !== null ? laggUndan(katalog, 'impact') : false
2777
+ const impactUndanlagd = arDesignkonflikt(stopp) ? laggUndan(katalog, 'impact') : false
2159
2778
  writeStateOrThrow({
2160
2779
  ...state,
2161
2780
  design,
2162
2781
  status: 'designed',
2163
2782
  ...(stopp !== null ? { stopp: null } : {}),
2164
2783
  })
2165
- return { design, impactUndanlagd }
2784
+ return { design, impactUndanlagd, varningar }
2166
2785
  }
2167
2786
 
2168
2787
  /** paraply (ADR-0144 beslut 5): vägransreglerna i ordning (delningsdel,
@@ -2280,6 +2899,9 @@ function delKlar(state, d) {
2280
2899
  * | ------------------------------------- | ------------------------------------------------------------- |
2281
2900
  * | bunt-rot (ingen spec.md, del-N finns) | The experiment area: `experiments/`, buntregeln; |
2282
2901
  * | | bluf:s `bunt (N delar)` |
2902
+ * | korning.pid lever | ADR-0155 beslut 3: bedom.mjs bokför sin process med |
2903
+ * | | korning-start; raden säger pågår, ett dött pid prefixar |
2904
+ * | | `står` med `avbruten körning, ` och raden räknas som vanligt |
2283
2905
  * | spärr står (sparrSkal) | bedöm steg 6 OLIKA "Hänvisa sedan till `döm <post>`"; `döm` |
2284
2906
  * | stopp KONCEPT/DIAGNOS | speca steg 6 "Kandidatens katalog kan fortfarande läggas på |
2285
2907
  * | | brädan … men `bedöm` hoppar över den"; stopputfall → kunddialog |
@@ -2287,6 +2909,8 @@ function delKlar(state, d) {
2287
2909
  * | med orsak `design conflict: …` | routes it to `designa <adress>`"; add-design nollar stoppet |
2288
2910
  * | | och lägger undan impact.md, ett kvitto ur designa steg 5 |
2289
2911
  * | | ersätter det (då gäller raden nedan) |
2912
+ * | stopp ESKALERA ur | designa Draft mode (P4-24): designern svarade INTERVJU |
2913
+ * | design-intervju-kravs-<datum>.md | KRÄVS; designa (intervjun) löser det, add-design nollar det |
2290
2914
  * | stopp TILLBAKA_TILL_SPEC | speca steg 6 (ADR-0118) "När valet avgjorts skrivs svaret |
2291
2915
  * | | under `## Avgjorda val` …, sedan `respec` och ny dispatch"; |
2292
2916
  * | | designa steg 5, ett kvitto design-avvisad-<datum>.md |
@@ -2307,7 +2931,8 @@ function delKlar(state, d) {
2307
2931
  * | | Closing ISÄR-raden "`designed` ↔ ✅ Spec ok → `bedöm`" |
2308
2932
  * | assessed utan poängvarv | bedöm steg 3–7: analysen finns, poängen saknas |
2309
2933
  * | assessed, scorerns DELA_UPP, delar | ADR-0144 beslut 4 och 6: "se delarna (K av M delar |
2310
- * | finns (delningslage) | prissatta)"; alla klara (delKlar) → sync föräldern (⚖️) |
2934
+ * | finns (delningslage) | prissatta)"; alla klara (delKlar) → sync föräldern (⚖️), |
2935
+ * | | utom när rotens kvitto board har kärnan ⚖️ (#693) |
2311
2936
  * | assessed, scorerns DELA_UPP | stopputfall → "ny analysrunda per del … `split` satt" |
2312
2937
  * | assessed, scorerns OVER_SKALAN, del | ADR-0144 beslut 3: deluppgift utan Score; split.gid säger |
2313
2938
  * | (split.parent satt) | synkad, annars sync och set-split-gid |
@@ -2317,7 +2942,8 @@ function delKlar(state, d) {
2317
2942
  * | | godkänn/underkänn från ⚖️ |
2318
2943
  * | priced, del (split.parent satt) | ADR-0144 beslut 3 och 6: deluppgift med Score; split.gid |
2319
2944
  * | | säger synkad, annars sync och set-split-gid |
2320
- * | priced | godkänn-grinden: ⚖️ → 📤 Klar – till kund |
2945
+ * | priced | godkänn-grinden: ⚖️ → 📤 Klar – till kund; ADR-0153 beslut 3: |
2946
+ * | | kvittot board med kärnan ⚖️ säger synkad, annars om inte gjort |
2321
2947
  * | guard_run | `guard_run` reserveras för guard-kommandot: inget väntar |
2322
2948
  * | allt annat | `okänt läge: <status>`, aldrig en gissning |
2323
2949
  *
@@ -2339,8 +2965,13 @@ function nastaStegFor(state, { post, antalDelar, scorerUtfall, delning = null })
2339
2965
  const vantarDom = sparrSkal(state).length > 0
2340
2966
  const scorerStopp =
2341
2967
  scorerUtfall !== null && SCORE_STOP.includes(scorerUtfall.outcome) ? scorerUtfall : null
2968
+ // ADR-0155 beslut 3: ett kvitto vars pid lever är en pågående körning; ett
2969
+ // dött är en avbruten (bedom.mjs hann aldrig köra korning-slut).
2970
+ const korningPid = Number.isInteger(state.korning?.pid) ? state.korning.pid : null
2971
+ const korningLever = korningPid !== null && isAlive(korningPid)
2972
+ const avbruten = korningPid !== null && !korningLever ? 'avbruten körning, ' : ''
2342
2973
  const star = [
2343
- status,
2974
+ avbruten + status,
2344
2975
  stopp ? `stopp ${stopp.utfall} (${stopp.kalla})` : null,
2345
2976
  !stopp && scorerStopp ? `${scorerStopp.outcome} (${scorerStopp.fil})` : null,
2346
2977
  olikaOlyft ? 'OLIKA olyft' : vantarDom ? 'omkörning olyft' : null,
@@ -2369,6 +3000,14 @@ function nastaStegFor(state, { post, antalDelar, scorerUtfall, delning = null })
2369
3000
  ? `synkad som deluppgift ${state.split.gid}${score}, se föräldern ${state.split.parent}${offert}`
2370
3001
  : `sync till brädan som deluppgift${score} under ${state.split?.parent} (request-sync) om inte gjort, sedan archive.mjs set-split-gid <del-katalog> --gid <deluppgiftens gid>${offert}`
2371
3002
  }
3003
+ // ADR-0153 beslut 3: kvittot ur archive.mjs synced med kärnan ⚖️ säger att
3004
+ // ⚖️-synken är gjord. Allt annat (null, saknat, en annan status) säger inte det.
3005
+ const kvitto = state.board
3006
+ const synkadVag =
3007
+ kvitto !== null &&
3008
+ typeof kvitto === 'object' &&
3009
+ typeof kvitto.offer_status === 'string' &&
3010
+ alfabetiskKarna(kvitto.offer_status) === alfabetiskKarna('⚖️ Poängsättning redo för granskning')
2372
3011
  const tillbakaTillSpec = (val) =>
2373
3012
  rad(
2374
3013
  `avgör valet: ${val}`,
@@ -2376,6 +3015,8 @@ function nastaStegFor(state, { post, antalDelar, scorerUtfall, delning = null })
2376
3015
  )
2377
3016
 
2378
3017
  if (antalDelar > 0) return rad('se delarna', 'se delarna', `bunt (${antalDelar} delar)`)
3018
+ if (korningLever)
3019
+ return rad('processen', 'vänta; bedöm igen när bedom.mjs är klar', `pågår (pid ${korningPid})`)
2379
3020
  if (vantarDom) return rad('dom (döm)', `döm ${post}`)
2380
3021
  if (stopp) {
2381
3022
  const val = stopp.orsak ?? stopp.kalla
@@ -2383,6 +3024,13 @@ function nastaStegFor(state, { post, antalDelar, scorerUtfall, delning = null })
2383
3024
  // Slutgranskning I2: en designkonflikt besvaras av en omdesign, inte av
2384
3025
  // en respec; add-design nollar stoppet.
2385
3026
  if (arDesignkonflikt(stopp)) return rad('omdesign (designa)', `designa ${adress}`)
3027
+ // P4-24: designern bad om en intervju; designa löser stoppet.
3028
+ if (arIntervjuStopp(stopp))
3029
+ return rad(
3030
+ 'människa',
3031
+ `designa ${adress} (intervju; se ${stopp.kalla})`,
3032
+ `${avbruten}stopp ${stopp.utfall} ur ${stopp.kalla}`,
3033
+ )
2386
3034
  if (stopp.utfall === 'TILLBAKA_TILL_SPEC') return tillbakaTillSpec(val)
2387
3035
  if (stopp.utfall === 'ESKALERA')
2388
3036
  return rad(`avgör valet: ${val}`, 'människa dömer, ingen ny specrunda')
@@ -2410,6 +3058,14 @@ function nastaStegFor(state, { post, antalDelar, scorerUtfall, delning = null })
2410
3058
  )
2411
3059
  : rad('godkännande av spec på brädan', godkannEller)
2412
3060
  }
3061
+ // ADR-0156 beslut 3: en granskning som stoppade avslutar kedjan på designed
3062
+ // och går till det interaktiva designa; ett utslag skickas aldrig om.
3063
+ if (status === 'designed' && state.design?.review?.verdict === 'stopp')
3064
+ return rad(
3065
+ 'människa',
3066
+ `designa ${adress} (intervju; se design-review.md)`,
3067
+ 'designed, granskningen stoppade',
3068
+ )
2413
3069
  if (status === 'designed') return rad('körning av bedöm', `bedöm ${adress}`)
2414
3070
  if (status === 'assessed') {
2415
3071
  if (nScore === 0) return rad('körning av bedöm', `bedöm ${adress} (poängsättning saknas)`)
@@ -2421,11 +3077,15 @@ function nastaStegFor(state, { post, antalDelar, scorerUtfall, delning = null })
2421
3077
  // klar (delKlar) flyttar synken den till ⚖️.
2422
3078
  if (delning !== null && delning.klara < delning.av)
2423
3079
  return rad('se delarna', `se delarna (${delning.klara} av ${delning.av} delar prissatta)`)
3080
+ // #693: synken som flyttar föräldern bokförs med synced på roten;
3081
+ // kvittot läses som i priced-grenen (ADR-0153 beslut 3).
2424
3082
  if (delning !== null)
2425
- return rad(
2426
- 'granskning av poäng på brädan',
2427
- `sync föräldern till brädan (request-sync ⚖️), sedan ${godkannEller}`,
2428
- )
3083
+ return synkadVag
3084
+ ? rad('granskning av poäng på brädan', godkannEller)
3085
+ : rad(
3086
+ 'granskning av poäng på brädan',
3087
+ `sync föräldern till brädan (request-sync ⚖️), sedan ${godkannEller}`,
3088
+ )
2429
3089
  return rad('ny analysrunda per del', 'dela posten (split), sedan bedöm per del')
2430
3090
  }
2431
3091
  if (o === 'OVER_SKALAN')
@@ -2450,10 +3110,14 @@ function nastaStegFor(state, { post, antalDelar, scorerUtfall, delning = null })
2450
3110
  state.split.gid ? 'granskning av föräldern på brädan' : 'sync av deluppgiften',
2451
3111
  deluppgift(false),
2452
3112
  )
2453
- return rad(
2454
- 'granskning av poäng på brädan',
2455
- `sync till brädan (request-sync ⚖️) om inte gjort, sedan godkänn ${gid} (📤 Klar – till kund) eller underkänn ${gid} <skäl>`,
2456
- )
3113
+ // ADR-0153 beslut 3: synkadVag (ovan) läser kvittot; utan det "om inte gjort".
3114
+ const godkannKlar = `godkänn ${gid} (📤 Klar – till kund) eller underkänn ${gid} <skäl>`
3115
+ return synkadVag
3116
+ ? rad('granskning av poäng på brädan', godkannKlar)
3117
+ : rad(
3118
+ 'granskning av poäng på brädan',
3119
+ `sync till brädan (request-sync ⚖️) om inte gjort, sedan ${godkannKlar}`,
3120
+ )
2457
3121
  }
2458
3122
  if (status === 'guard_run') return rad('inget', 'inget')
2459
3123
  return rad('–', '–', `okänt läge: ${status}`)
@@ -2589,6 +3253,22 @@ function gallandeDokumentFil(state, typ, n) {
2589
3253
  return liftingUtslag(state, typ, n)?.galler_fil ?? `${typ}-pricing-v${n}.json`
2590
3254
  }
2591
3255
 
3256
+ /** Täckningsposten (ADR-0159, ändrar ADR-0119 beslut 4): `coverage_post` ur
3257
+ * den poängfil som gäller, `gallandeDokumentFil(state, 'score', maxN)`, så
3258
+ * att granskarens `galler_fil` styr på samma sätt som för poäng och utfall.
3259
+ * null när ingen poängrunda finns, när den gällande filen ännu inte skrivits
3260
+ * (den stämplas ofta före agentens fil; nästa omräkning fyller i den) och
3261
+ * när filen saknar ett objekt i fältet. Finns filen men är trasig kastar
3262
+ * readPricingFileOrThrow ArkivFel, samma arkivfel som andra läsare i filen. */
3263
+ function gallandeTackning(state, d = katalog) {
3264
+ const n = maxN(state.runs ?? [], 'score')
3265
+ if (n === 0) return null
3266
+ const fil = gallandeDokumentFil(state, 'score', n)
3267
+ if (!existsSync(path.join(d, fil))) return null
3268
+ const cp = readPricingFileOrThrow(fil, d).coverage_post
3269
+ return cp !== null && typeof cp === 'object' && !Array.isArray(cp) ? cp : null
3270
+ }
3271
+
2592
3272
  /** Läser en gällande pricing-fil (classification eller score) ur katalogen
2593
3273
  * och returnerar den PARSADE, validerade datan. Arkivet PÅSTÅR via sin
2594
3274
  * runs-rad (eller ett viktningsutslags `galler_fil`) att filen finns och är