@haus-tech/haus-workflow 5.9.0 → 5.10.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.
@@ -17,7 +17,9 @@
17
17
  * så samtidiga svep-processer aldrig tappar varandras rader (#493). Del 5
18
18
  * (#530): nasta-steg, läs-enbart tabell över var posten står, vad en människa
19
19
  * väntas göra och vilket /request-kommando som kör vidare, ur SKILL.md:s
20
- * egna grindar (regeltabellen står ovanför `nastaStegFor`).
20
+ * egna grindar (regeltabellen står ovanför `nastaStegFor`). Del 6 (designa,
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.
21
23
  *
22
24
  * Användning:
23
25
  * node archive.mjs add-run <katalog> --file F --skill S --agent A --model M [flaggor]
@@ -28,12 +30,15 @@
28
30
  * node archive.mjs sparr <katalog>
29
31
  * node archive.mjs utslag <katalog> --typ viktning|kalibrering|sakandring --beslutad-av N [...]
30
32
  * node archive.mjs set-status <katalog> <status>
31
- * node archive.mjs stopp <katalog> --utfall KONCEPT|DIAGNOS|TILLBAKA_TILL_SPEC|ESKALERA --kalla spec.md|impact.md|granskarblock.md [--orsak <text>]
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>]
32
34
  * node archive.mjs respec <katalog>
35
+ * node archive.mjs add-design <katalog> --file <absolut sökväg till designdokumentet>
33
36
  * node archive.mjs bluf <katalog>
34
37
  * node archive.mjs nasta-steg <katalog>
35
38
  * node archive.mjs kalibrering <arkivrot>
36
39
  * node archive.mjs experiment <experimentkatalog> [--satt K=V]... [--variant M]... [--korning M/kN --status S [--orsak T] [--pid N]]
40
+ * node archive.mjs set-split-gid <del-katalog> --gid <gid>
41
+ * node archive.mjs paraply <katalog> --file <spec>
37
42
  * Exit 0 = ok, 1 = spärrad (sparr/set-status priced), 2 = användnings-/tillståndsfel.
38
43
  */
39
44
  import {
@@ -48,6 +53,8 @@ import {
48
53
  readdirSync,
49
54
  existsSync,
50
55
  mkdirSync,
56
+ realpathSync,
57
+ copyFileSync,
51
58
  } from 'node:fs'
52
59
  import path from 'node:path'
53
60
  import { createHash } from 'node:crypto'
@@ -62,13 +69,18 @@ import {
62
69
  findUpward,
63
70
  CLASS_UTFALL,
64
71
  SCORE_OUTCOMES,
72
+ designFrontmatter,
73
+ specConflicts,
74
+ katalogId,
75
+ specForm,
76
+ SPEC_HEADINGS,
65
77
  } from './lib.mjs'
66
78
 
67
79
  const [cmd, katalog, ...rest] = process.argv.slice(2)
68
80
 
69
81
  if (!cmd || !katalog) {
70
82
  console.error(
71
- 'användning: archive.mjs <add-run|next-n|ny-korning|ny-del|proveniens|sparr|utslag|set-status|stopp|respec|bluf|nasta-steg|kalibrering|experiment> <katalog> [flaggor]',
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]',
72
84
  )
73
85
  process.exit(2)
74
86
  }
@@ -188,6 +200,11 @@ const assertRowArrayField = (parsed, field, d = katalog) => {
188
200
  }
189
201
  }
190
202
 
203
+ /** Ett Asana-gid: bara siffror (ADR-0144, split.gid). Delat av
204
+ * readStateOrThrow och set-split-gid så läs- och skrivsidan aldrig kan
205
+ * godkänna olika former. */
206
+ const GID_RE = /^\d+$/
207
+
191
208
  const readStateOrThrow = (d = katalog) => {
192
209
  let raw
193
210
  try {
@@ -217,6 +234,40 @@ const readStateOrThrow = (d = katalog) => {
217
234
  ) {
218
235
  throw new ArkivFel(`${statePath(d)} har ett stopp-fält som varken är null eller ett objekt`)
219
236
  }
237
+ // `design` är null, saknat (äldre arkiv) eller ett objekt { path, file,
238
+ // sha256, spec_sha256, date } skrivet av add-design. Allt annat är trasigt:
239
+ // nasta-steg och precheck --design läser fältet, och en sträng där hade
240
+ // tyst lästs som "designad".
241
+ if (
242
+ parsed.design !== undefined &&
243
+ parsed.design !== null &&
244
+ (typeof parsed.design !== 'object' || Array.isArray(parsed.design))
245
+ ) {
246
+ throw new ArkivFel(`${statePath(d)} har ett design-fält som varken är null eller ett objekt`)
247
+ }
248
+ // ADR-0144 beslut 6: split.gid är saknat, null eller ett Asana-gid som
249
+ // sträng, skrivet av set-split-gid. Allt annat är trasigt: nasta-steg läser
250
+ // fältet för att avgöra om delens deluppgift finns, och ett tal eller en
251
+ // sträng med bokstäver där hade tyst lästs som "synkad".
252
+ if (
253
+ parsed.split !== null &&
254
+ typeof parsed.split === 'object' &&
255
+ parsed.split.gid !== undefined &&
256
+ parsed.split.gid !== null &&
257
+ (typeof parsed.split.gid !== 'string' || !GID_RE.test(parsed.split.gid))
258
+ ) {
259
+ throw new ArkivFel(`${statePath(d)} har ett split.gid som inte är ett Asana-gid (bara siffror)`)
260
+ }
261
+ // ADR-0144 beslut 5: `umbrella` är saknat eller ett objekt { date, sha256 }
262
+ // skrivet av paraply. Allt annat är trasigt, samma regel som för stopp och
263
+ // design.
264
+ if (
265
+ parsed.umbrella !== undefined &&
266
+ parsed.umbrella !== null &&
267
+ (typeof parsed.umbrella !== 'object' || Array.isArray(parsed.umbrella))
268
+ ) {
269
+ throw new ArkivFel(`${statePath(d)} har ett umbrella-fält som varken är null eller ett objekt`)
270
+ }
220
271
  return parsed
221
272
  }
222
273
 
@@ -529,9 +580,10 @@ const isKnownFile = (file) => FILE_PATTERNS.some((re) => re.test(file))
529
580
  * spec-v1-avvisad.md-raden) bär tomt underlag_sha256, en
530
581
  * impact(-superseded)-rad bär bara spec.md, en pricing-rad bär båda.
531
582
  */
532
- const underlagFor = (file) => {
583
+ const underlagFor = (file, d = katalog) => {
533
584
  if (SPEC_RE.test(file)) return []
534
- if (IMPACT_RE.test(file)) return ['spec.md']
585
+ if (IMPACT_RE.test(file))
586
+ return existsSync(path.join(d, 'design.md')) ? ['spec.md', 'design.md'] : ['spec.md']
535
587
  return ['spec.md', 'impact.md']
536
588
  }
537
589
 
@@ -566,11 +618,27 @@ const SCORE_STOP = [
566
618
  * (KONCEPT/DIAGNOS, ADR-0105), motsägelse-etiketten i impact.md
567
619
  * (TILLBAKA_TILL_SPEC, ADR-0106) och raden Prisavgörande öppet val i
568
620
  * granskarblock.md (TILLBAKA_TILL_SPEC eller ESKALERA, ADR-0118) är de tre
569
- * källor som kan bära ett stopp UTAN scorer. DELA_UPP, OVER_SKALAN och
570
- * INPUT_SAKNAS är scorerns utfall och når arkivet via prisfilen, aldrig via
571
- * stopp; ESKALERA når arkivet på båda vägarna. */
621
+ * literala källor som kan bära ett stopp UTAN scorer. Den fjärde är ett
622
+ * mönster: ett designdokument som add-design vägrade på "## Spec conflicts"
623
+ * kopieras in som kvitto (design-avvisad-<datum>.md, `-2` vid kollision) och
624
+ * stoppar som TILLBAKA_TILL_SPEC eller ESKALERA (designa steg 5,
625
+ * slutgranskning I1). DELA_UPP, OVER_SKALAN och INPUT_SAKNAS är scorerns
626
+ * utfall och når arkivet via prisfilen, aldrig via stopp; ESKALERA når
627
+ * arkivet på båda vägarna. */
572
628
  const STOPP_UTFALL = ['KONCEPT', 'DIAGNOS', 'TILLBAKA_TILL_SPEC', 'ESKALERA']
573
629
  const STOPP_KALLOR = ['spec.md', 'impact.md', 'granskarblock.md']
630
+ const DESIGN_AVVISAD_RE = /^design-avvisad-\d{4}-\d{2}-\d{2}(-\d+)?\.md$/
631
+
632
+ /** Ett designkonfliktstopp (slutgranskning I2): assessorn fann designen
633
+ * obyggbar, motsägelseraden i impact.md börjar med `design conflict:` och
634
+ * bedöm bokförde den texten som `orsak`. En omdesign besvarar det, inte en
635
+ * respec: nasta-steg routar till designa och add-design nollar stoppet.
636
+ * Delad av de två så de aldrig kan läsa samma stopp olika. */
637
+ const arDesignkonflikt = (stopp) =>
638
+ stopp?.kalla === 'impact.md' &&
639
+ stopp.utfall === 'TILLBAKA_TILL_SPEC' &&
640
+ typeof stopp.orsak === 'string' &&
641
+ /^design conflict:/i.test(stopp.orsak.trim())
574
642
 
575
643
  /** Tak för antal körningar per modell. Skyddet är mot en trasig loop, inte mot
576
644
  * Tims arbetssätt: 999 körningar av samma modell på samma post är alltid ett
@@ -592,12 +660,14 @@ const PROVENIENS_REFERENSER = [
592
660
  'skills/request/SKILL.md',
593
661
  ]
594
662
 
595
- /** Kända statusvärden (SKILL.md: "spec_draft | spec_synced | assessed |
596
- * priced | guard_run"). `set-status` skrev tidigare VILKEN STRÄNG SOM HELST
597
- * rakt in i state.json — inklusive en flaggformad sträng om anroparen råkat
598
- * glömma statusvärdet (`set-status <katalog> --nagot-annat`), vilket hade
599
- * gett en tyst, obegriplig status i stället för ett fel. */
600
- const STATUSAR = ['spec_draft', 'spec_synced', 'assessed', 'priced', 'guard_run']
663
+ /** Kända statusvärden (SKILL.md: "spec_draft | spec_synced | designed |
664
+ * assessed | priced | guard_run"). `designed` (designa, spec 2026-09-24)
665
+ * ligger mellan godkännandet och analysen: add-design sätter det. `set-status` skrev
666
+ * tidigare VILKEN STRÄNG SOM HELST rakt in i state.json — inklusive en
667
+ * flaggformad sträng om anroparen råkat glömma statusvärdet (`set-status
668
+ * <katalog> --nagot-annat`), vilket hade gett en tyst, obegriplig status i
669
+ * stället för ett fel. */
670
+ const STATUSAR = ['spec_draft', 'spec_synced', 'designed', 'assessed', 'priced', 'guard_run']
601
671
 
602
672
  /** Körningsstatus i experiment.json, exakt de literaler SKILL.md:s
603
673
  * återupptagningsregel och svep.mjs läser. */
@@ -669,6 +739,7 @@ const skelett = (requestId, { bundle, split } = {}) => ({
669
739
  stopp: null,
670
740
  respec_varv: 0,
671
741
  coverage_post: null,
742
+ design: null,
672
743
  runs: [],
673
744
  })
674
745
 
@@ -1478,6 +1549,7 @@ if (cmd === 'add-run') {
1478
1549
  // Stopputfall UTAN scorer (ADR-0105/0106/0118): en kandidatspec routar direkt
1479
1550
  // till KONCEPT/DIAGNOS, en analys med motsägelse direkt till
1480
1551
  // TILLBAKA_TILL_SPEC, och ett prisavgörande öppet val i granskarblock.md
1552
+ // eller ett vägrat designdokuments kvitto (design-avvisad-<datum>.md)
1481
1553
  // direkt till TILLBAKA_TILL_SPEC eller ESKALERA. Kommandot litar aldrig på
1482
1554
  // anroparens påstående: källan måste bära det.
1483
1555
  avvisaOkandaFlaggor('stopp', ['utfall', 'kalla', 'orsak'])
@@ -1488,8 +1560,11 @@ if (cmd === 'add-run') {
1488
1560
  console.error(`okänt --utfall: ${utfall} (giltiga: ${STOPP_UTFALL.join(', ')})`)
1489
1561
  process.exit(2)
1490
1562
  }
1491
- if (!STOPP_KALLOR.includes(kalla)) {
1492
- console.error(`okänd --kalla: ${kalla} (giltiga: ${STOPP_KALLOR.join(', ')})`)
1563
+ const arDesignKvitto = DESIGN_AVVISAD_RE.test(kalla)
1564
+ if (!STOPP_KALLOR.includes(kalla) && !arDesignKvitto) {
1565
+ console.error(
1566
+ `okänd --kalla: ${kalla} (giltiga: ${STOPP_KALLOR.join(', ')}, design-avvisad-<datum>.md)`,
1567
+ )
1493
1568
  process.exit(2)
1494
1569
  }
1495
1570
  const kallaPath = path.join(katalog, kalla)
@@ -1515,6 +1590,27 @@ if (cmd === 'add-run') {
1515
1590
  )
1516
1591
  process.exit(2)
1517
1592
  }
1593
+ } else if (arDesignKvitto) {
1594
+ // Slutgranskning I1: kvittot är det vägrade designdokumentet självt, och
1595
+ // bara ett dokument med minst en konflikt under "## Spec conflicts" är ett
1596
+ // stopp. Statusen rörs inte, som för spec.md: designen bokfördes aldrig.
1597
+ const konflikter = specConflicts(text)
1598
+ if (konflikter === null || konflikter.length === 0) {
1599
+ console.error(`${kallaPath} bär inga konflikter under "## Spec conflicts", kan inte stoppas`)
1600
+ process.exit(2)
1601
+ }
1602
+ if (!['TILLBAKA_TILL_SPEC', 'ESKALERA'].includes(utfall)) {
1603
+ console.error(
1604
+ `en designkonflikt i ${kalla} stoppar som TILLBAKA_TILL_SPEC eller ESKALERA, inte ${utfall}`,
1605
+ )
1606
+ process.exit(2)
1607
+ }
1608
+ if (utfall === 'ESKALERA' && !orsak) {
1609
+ console.error(
1610
+ 'ESKALERA kräver --orsak: en människa ska se varför posten inte gick tillbaka till specen en gång till',
1611
+ )
1612
+ process.exit(2)
1613
+ }
1518
1614
  } else if (kalla === 'granskarblock.md') {
1519
1615
  // ADR-0118: bara ett ja på raden Prisavgörande öppet val är ett stopp.
1520
1616
  if (prisavgorandeVal(text) === null) {
@@ -1553,10 +1649,21 @@ if (cmd === 'add-run') {
1553
1649
 
1554
1650
  const lockPath = acquireLock()
1555
1651
  let skrivet = false
1652
+ let ersatter = false
1556
1653
  try {
1557
1654
  const state = readStateOrThrow()
1558
1655
  const befintlig = state.stopp ?? null
1559
- if (befintlig !== null) {
1656
+ if (befintlig !== null && arDesignkonflikt(befintlig) && arDesignKvitto) {
1657
+ // Slutgranskning omgång 2 (F1): omdesignen som designkonfliktstoppet
1658
+ // routade till vägrades på "## Spec conflicts", så specen är problemet
1659
+ // och spec-vägen vinner. Kvittot ersätter stoppet; utan det föll stopp
1660
+ // på write-once nedan och nasta-steg sa designa igen, en återvändsgränd.
1661
+ // Statusen rörs inte, orsaksreglerna är kvittots egna ovan.
1662
+ state.stopp = { utfall, kalla, date: today(), ...(orsak ? { orsak } : {}) }
1663
+ writeStateOrThrow(state)
1664
+ skrivet = true
1665
+ ersatter = true
1666
+ } else if (befintlig !== null) {
1560
1667
  // Write-once på utfall+kalla; datumet och orsaken ingår inte, ett
1561
1668
  // identiskt omanrop en annan dag (eller med en annan orsakstext) är en
1562
1669
  // no-op.
@@ -1570,9 +1677,10 @@ if (cmd === 'add-run') {
1570
1677
  }
1571
1678
  } else {
1572
1679
  state.stopp = { utfall, kalla, date: today(), ...(orsak ? { orsak } : {}) }
1573
- // impact.md: posten gick genom bedöm och är analyserad. spec.md och
1574
- // granskarblock.md: kandidaten/det prisavgörande valet passerade aldrig
1575
- // bedöms grind, posten stannar i spec_draft som förut.
1680
+ // impact.md: posten gick genom bedöm och är analyserad. spec.md,
1681
+ // granskarblock.md och ett designkvitto: kandidaten, det prisavgörande
1682
+ // valet eller den vägrade designen passerade aldrig bedöms grind,
1683
+ // statusen står kvar som förut.
1576
1684
  if (kalla === 'impact.md') state.status = 'assessed'
1577
1685
  writeStateOrThrow(state)
1578
1686
  skrivet = true
@@ -1587,29 +1695,26 @@ if (cmd === 'add-run') {
1587
1695
  }
1588
1696
  releaseLock(lockPath)
1589
1697
  console.log(
1590
- skrivet ? `OK: stopp ${utfall} ur ${kalla}` : `OK: stopp ${utfall} ur ${kalla} oförändrat`,
1698
+ ersatter
1699
+ ? `OK: stopp ${utfall} ur ${kalla} (ersätter designkonflikten ur impact.md)`
1700
+ : skrivet
1701
+ ? `OK: stopp ${utfall} ur ${kalla}`
1702
+ : `OK: stopp ${utfall} ur ${kalla} oförändrat`,
1591
1703
  )
1592
1704
  } else if (cmd === 'respec') {
1593
1705
  // Speca steg 5 som kommando: en ny spec över befintlig katalog invaliderar
1594
- // analysen, godkännandet, domarna och stoppet. runs[] rörs aldrig, arkivet
1595
- // är append-only; den gamla analysen döps om, raderas inte.
1706
+ // analysen, designen, godkännandet, domarna och stoppet. runs[] rörs
1707
+ // aldrig, arkivet är append-only; den gamla analysen och designen döps
1708
+ // om, raderas inte.
1596
1709
  avvisaOkandaFlaggor('respec', [])
1597
1710
  const lockPath = acquireLock()
1598
1711
  let antal = 0
1599
1712
  try {
1600
1713
  const state = readStateOrThrow()
1601
- const impact = path.join(katalog, 'impact.md')
1602
- if (existsSync(impact)) {
1603
- const bas = path.join(katalog, `impact-superseded-${today()}`)
1604
- let mal = `${bas}.md`
1605
- for (let n = 2; existsSync(mal); n++) mal = `${bas}-${n}.md`
1606
- try {
1607
- renameSync(impact, mal)
1608
- } catch (e) {
1609
- throw new ArkivFel(`kunde inte döpa om ${impact} till ${mal}: ${e.message}`)
1610
- }
1611
- antal++
1612
- }
1714
+ if (laggUndan(katalog, 'impact')) antal++
1715
+ // Designa (spec 2026-09-24): en ny spec gör den gamla designen inaktuell
1716
+ // av samma skäl som den gör analysen inaktuell. Läggs undan, raderas inte.
1717
+ if (laggUndan(katalog, 'design')) antal++
1613
1718
  const nytt = { ...state }
1614
1719
  if (nytt.status !== 'spec_draft') {
1615
1720
  nytt.status = 'spec_draft'
@@ -1623,10 +1728,14 @@ if (cmd === 'add-run') {
1623
1728
  nytt.granskarutslag = []
1624
1729
  antal++
1625
1730
  }
1626
- if (nytt.stopp !== null) {
1731
+ if (nytt.stopp !== null && nytt.stopp !== undefined) {
1627
1732
  nytt.stopp = null
1628
1733
  antal++
1629
1734
  }
1735
+ if (nytt.design !== null && nytt.design !== undefined) {
1736
+ nytt.design = null
1737
+ antal++
1738
+ }
1630
1739
  // Monoton räknare (#505): varje respec-anrop är ett nytt varv oavsett om
1631
1740
  // övriga fält redan var normaliserade, till skillnad från fälten ovan
1632
1741
  // finns inget "redan rätt värde" att jämföra mot, så det här villkoret
@@ -1644,6 +1753,93 @@ if (cmd === 'add-run') {
1644
1753
  }
1645
1754
  releaseLock(lockPath)
1646
1755
  console.log(`OK: respec (${antal} ändringar)`)
1756
+ } else if (cmd === 'add-design') {
1757
+ // Designa steg 5 (spec 2026-09-24): designdokumentet verifieras, kopieras
1758
+ // byteidentiskt in som design.md, bokförs i state.design och sätter status
1759
+ // designed. Ingen runs-rad: designen är ingen agentdispatch, kvittot är
1760
+ // fältet självt, write-once per version (en ny version lägger undan den
1761
+ // gamla som design-superseded-<datum>.md, se laggUndan).
1762
+ avvisaOkandaFlaggor('add-design', ['file'])
1763
+ const fil = requireFlag('file')
1764
+ const lockPath = acquireLock()
1765
+ let kvitto
1766
+ try {
1767
+ const state = readStateOrThrow()
1768
+ kvitto = laggTillDesign(state, fil)
1769
+ } catch (e) {
1770
+ releaseLock(lockPath)
1771
+ if (e instanceof ArkivFel) {
1772
+ console.error(e.message)
1773
+ process.exit(2)
1774
+ }
1775
+ throw e
1776
+ }
1777
+ releaseLock(lockPath)
1778
+ console.log(`OK: design ${kvitto.design.path} (${kvitto.design.sha256})`)
1779
+ if (kvitto.impactUndanlagd) console.log('OK: impact.md undanlagd (designkonfliktstoppet löst)')
1780
+ } else if (cmd === 'set-split-gid') {
1781
+ // ADR-0144 beslut 6: delens Asana-deluppgift bokförs i split.gid när
1782
+ // request-sync har skapat den, så en omsynk uppdaterar deluppgiften i
1783
+ // stället för att skapa en till. Write-once: samma gid igen är en no-op,
1784
+ // ett annat gid vägras (en del har en deluppgift, aldrig två).
1785
+ avvisaOkandaFlaggor('set-split-gid', ['gid'])
1786
+ const gid = requireFlag('gid')
1787
+ if (!GID_RE.test(gid)) {
1788
+ console.error(`--gid måste vara ett Asana-gid (bara siffror), fick "${gid}"`)
1789
+ process.exit(2)
1790
+ }
1791
+ const lockPath = acquireLock()
1792
+ let oforandrat = false
1793
+ try {
1794
+ const state = readStateOrThrow()
1795
+ const split = state.split
1796
+ if (split === null || typeof split !== 'object' || split.parent == null)
1797
+ throw new ArkivFel(
1798
+ `${statePath()} har ingen split (split.parent saknas), set-split-gid gäller bara en delningsdel`,
1799
+ )
1800
+ if (split.gid != null && split.gid !== gid)
1801
+ throw new ArkivFel(
1802
+ `${statePath()} har redan split.gid "${split.gid}" som skiljer sig från "${gid}"; en del har en deluppgift, aldrig två`,
1803
+ )
1804
+ if (split.gid === gid) oforandrat = true
1805
+ else writeStateOrThrow({ ...state, split: { ...split, gid } })
1806
+ } catch (e) {
1807
+ releaseLock(lockPath)
1808
+ if (e instanceof ArkivFel) {
1809
+ console.error(e.message)
1810
+ process.exit(2)
1811
+ }
1812
+ throw e
1813
+ }
1814
+ releaseLock(lockPath)
1815
+ console.log(oforandrat ? `OK: split-gid ${gid} oförändrat` : `OK: split-gid ${gid}`)
1816
+ } else if (cmd === 'paraply') {
1817
+ // ADR-0144 beslut 5: förälderns spec skrivs om som en paraplyspec efter ett
1818
+ // ja. Den gamla spec.md läggs undan (spec-superseded-<datum>.md),
1819
+ // paraplyet kopieras in byteidentiskt, titeln följer dess rubrik och
1820
+ // state.umbrella bokför datum och hash. Godkännande, design, runs, stopp och
1821
+ // delarna rörs inte: delarna bär sina ärvda kopior av den godkända specen,
1822
+ // den som deras assessorer körde mot.
1823
+ avvisaOkandaFlaggor('paraply', ['file'])
1824
+ const fil = requireFlag('file')
1825
+ const lockPath = acquireLock()
1826
+ let resultat
1827
+ try {
1828
+ const state = readStateOrThrow()
1829
+ resultat = laggInParaply(state, fil)
1830
+ } catch (e) {
1831
+ releaseLock(lockPath)
1832
+ if (e instanceof ArkivFel) {
1833
+ console.error(e.message)
1834
+ process.exit(2)
1835
+ }
1836
+ throw e
1837
+ }
1838
+ releaseLock(lockPath)
1839
+ // F9: specForms varningar (samma form som precheck.mjs) skrivs ut före
1840
+ // OK-raden i stället för att tystas.
1841
+ for (const w of resultat.varningar) console.log(`VARNING: ${w}`)
1842
+ console.log(`OK: paraply ${resultat.umbrella.sha256}`)
1647
1843
  } else if (cmd === 'bluf') {
1648
1844
  // Läs-enbart tabellvy över katalogen och dess delar. Poängen tas ur
1649
1845
  // prisfilerna och stoppet ur state.json, aldrig ur `note` eller prosa: den
@@ -1700,7 +1896,16 @@ if (cmd === 'add-run') {
1700
1896
  ? 'kandidat (spec.md)'
1701
1897
  : state.stopp.kalla === 'granskarblock.md'
1702
1898
  ? 'prisavgörande val (granskarblock.md)'
1703
- : 'motsägelse (impact.md)'
1899
+ : DESIGN_AVVISAD_RE.test(state.stopp.kalla ?? '')
1900
+ ? `designkonflikt (${state.stopp.kalla})`
1901
+ : 'motsägelse (impact.md)'
1902
+ }
1903
+ // ADR-0144 beslut 6: delningsroten visar hur många delar som är klara,
1904
+ // samma räkning som nasta-steg (delningslage), före täckningsposten.
1905
+ const delning = delningslage(d)
1906
+ if (delning !== null) {
1907
+ const text = `${delning.klara} av ${delning.av} delar prissatta`
1908
+ anm = anm === SAKNAS ? text : `${anm}; ${text}`
1704
1909
  }
1705
1910
  if (taeckning) anm = anm === SAKNAS ? taeckning : `${anm}; ${taeckning}`
1706
1911
  return `| ${[post, poang, utfall, konfidens, anm].map(cell).join(' | ')} |`
@@ -1733,6 +1938,7 @@ if (cmd === 'add-run') {
1733
1938
  post,
1734
1939
  antalDelar: arBuntRot ? direktaDelar.length : 0,
1735
1940
  scorerUtfall: gallandeScorerUtfall(state, d),
1941
+ delning: delningslage(d),
1736
1942
  })
1737
1943
  return `| ${[post, star, vantar, nasta].map(cell).join(' | ')} |`
1738
1944
  }
@@ -1751,7 +1957,7 @@ if (cmd === 'add-run') {
1751
1957
  console.log(rader.join('\n'))
1752
1958
  } else {
1753
1959
  console.error(
1754
- 'okänt kommando (add-run, next-n, ny-korning, ny-del, proveniens, sparr, utslag, set-status, stopp, respec, bluf, nasta-steg, kalibrering, experiment)',
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)',
1755
1961
  )
1756
1962
  process.exit(2)
1757
1963
  }
@@ -1826,6 +2032,196 @@ function delKataloger(dir) {
1826
2032
  .flatMap((d) => [d, ...delKataloger(d)])
1827
2033
  }
1828
2034
 
2035
+ /** Lägger undan <bas>.md som <bas>-superseded-<datum>.md, numrerad svans vid
2036
+ * kollision (-2, -3 …), aldrig en överskrivning: arkivet raderar inget.
2037
+ * Delad av respec (impact.md och design.md) och add-design (design.md).
2038
+ * Returnerar true när en fil flyttades. */
2039
+ function laggUndan(d, bas) {
2040
+ const fil = path.join(d, `${bas}.md`)
2041
+ if (!existsSync(fil)) return false
2042
+ const stam = path.join(d, `${bas}-superseded-${today()}`)
2043
+ let mal = `${stam}.md`
2044
+ for (let n = 2; existsSync(mal); n++) mal = `${stam}-${n}.md`
2045
+ try {
2046
+ renameSync(fil, mal)
2047
+ } catch (e) {
2048
+ throw new ArkivFel(`kunde inte döpa om ${fil} till ${mal}: ${e.message}`)
2049
+ }
2050
+ return true
2051
+ }
2052
+
2053
+ /** Repo-roten för en arkivkatalog: föräldern till närmaste `.haus-workflow`
2054
+ * uppåt i sökvägen (arkivet bor i <rot>/.haus-workflow/requests/). null när
2055
+ * katalogen inte ligger under någon; add-design vägrar då i stället för att
2056
+ * gissa en rot. Inget git-anrop: scripten är node-inbyggt bara. */
2057
+ function repoRotFor(d) {
2058
+ let cur = path.resolve(d)
2059
+ for (;;) {
2060
+ if (path.basename(cur) === '.haus-workflow') return path.dirname(cur)
2061
+ const parent = path.dirname(cur)
2062
+ if (parent === cur) return null
2063
+ cur = parent
2064
+ }
2065
+ }
2066
+
2067
+ /** add-design: vägransreglerna i ordning (rot, fil, inneslutning, arkivet,
2068
+ * frontmatter, spec-hash, konflikter, status, stopp), sedan undanläggning,
2069
+ * kopia och state. Inneslutningen prövas på realpath (ADR-0082: en länk
2070
+ * matchas på målet), och sökvägen som bokförs är realpath så precheck
2071
+ * --design hittar samma fil oavsett länkar. Ett designkonfliktstopp ur
2072
+ * impact.md nollas (en omdesign besvarar det, slutgranskning I2) och
2073
+ * 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. */
2076
+ function laggTillDesign(state, fil) {
2077
+ const rot = repoRotFor(katalog)
2078
+ if (rot === null)
2079
+ throw new ArkivFel(
2080
+ `${katalog} ligger inte under någon .haus-workflow-katalog, kan inte avgöra repo-roten`,
2081
+ )
2082
+ let real
2083
+ try {
2084
+ real = realpathSync(path.resolve(fil))
2085
+ } catch (e) {
2086
+ throw new ArkivFel(`kan inte läsa ${fil}: ${e.message}`)
2087
+ }
2088
+ const rotReal = realpathSync(rot)
2089
+ if (!real.startsWith(rotReal + path.sep))
2090
+ throw new ArkivFel(`${real} ligger utanför repo-roten ${rotReal}`)
2091
+ // Slutgranskning M3: arkivet är aldrig designdokumentets hem. Arkivets egen
2092
+ // design.md som --file hade lagts undan av laggUndan innan kopian, som då
2093
+ // föll på en fil som inte längre fanns.
2094
+ if (real.startsWith(path.join(rotReal, '.haus-workflow') + path.sep))
2095
+ throw new ArkivFel(
2096
+ `${real} ligger under .haus-workflow, designdokumentet ska ligga i repot, aldrig i arkivet`,
2097
+ )
2098
+ let text
2099
+ try {
2100
+ text = readFileSync(real, 'utf8')
2101
+ } catch (e) {
2102
+ throw new ArkivFel(`kan inte läsa ${real}: ${e.message}`)
2103
+ }
2104
+ const fm = designFrontmatter(text)
2105
+ if (fm === null || !fm.request_id) throw new ArkivFel(`${real}: frontmattern saknar request_id`)
2106
+ const id = katalogId(state, katalog)
2107
+ if (fm.request_id !== id)
2108
+ throw new ArkivFel(`${real}: request_id "${fm.request_id}" är inte katalogens id "${id}"`)
2109
+ const specFil = path.join(katalog, 'spec.md')
2110
+ let specHash
2111
+ try {
2112
+ specHash = markerStrippedSha256(specFil)
2113
+ } catch (e) {
2114
+ throw new ArkivFel(`kan inte hasha ${specFil}: ${e.message}`)
2115
+ }
2116
+ if (fm.spec_sha256 !== specHash)
2117
+ throw new ArkivFel(
2118
+ `${real}: spec_sha256 "${fm.spec_sha256 ?? ''}" är inte nuvarande spec.md (${specHash}), ` +
2119
+ 'designen gjordes mot en annan spec',
2120
+ )
2121
+ const konflikter = specConflicts(text)
2122
+ if (konflikter === null) throw new ArkivFel(`${real}: sektionen "## Spec conflicts" saknas`)
2123
+ if (konflikter.length > 0)
2124
+ throw new ArkivFel(
2125
+ `${real}: "## Spec conflicts" är inte none (${konflikter.length}): kunddialog, respec och ny speca (ADR-0118)`,
2126
+ )
2127
+ // Slutgranskning M4: en prissatt post designas inte om, analysen och
2128
+ // poängen står på den bokförda designen.
2129
+ if (state.status === 'priced' || state.status === 'guard_run')
2130
+ throw new ArkivFel(
2131
+ `${statePath()} har status ${state.status}, en prissatt post designas inte om utan respec`,
2132
+ )
2133
+ const stopp = state.stopp ?? null
2134
+ if (stopp !== null && !arDesignkonflikt(stopp))
2135
+ throw new ArkivFel(
2136
+ `${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
+ )
2138
+ laggUndan(katalog, 'design')
2139
+ const mal = path.join(katalog, 'design.md')
2140
+ try {
2141
+ copyFileSync(real, mal)
2142
+ } catch (e) {
2143
+ throw new ArkivFel(`kunde inte kopiera ${real} till ${mal}: ${e.message}`)
2144
+ }
2145
+ const design = {
2146
+ path: real,
2147
+ file: 'design.md',
2148
+ sha256: markerStrippedSha256(mal),
2149
+ spec_sha256: specHash,
2150
+ date: today(),
2151
+ }
2152
+ // Ett designkonfliktstopp besvaras av just den här designen (I2); det är
2153
+ // det enda stopp som når hit, alla andra vägrades ovan. Analysen som fann
2154
+ // designen obyggbar läggs undan med stoppet (slutgranskning omgång 2, F3):
2155
+ // en ny analys är skyldig oavsett omdesignens bytes, och en byteidentisk
2156
+ // omdesign hade annars lämnat motsägelseanalysen proveniensgiltig. runs[]
2157
+ // rörs inte, som i respec.
2158
+ const impactUndanlagd = stopp !== null ? laggUndan(katalog, 'impact') : false
2159
+ writeStateOrThrow({
2160
+ ...state,
2161
+ design,
2162
+ status: 'designed',
2163
+ ...(stopp !== null ? { stopp: null } : {}),
2164
+ })
2165
+ return { design, impactUndanlagd }
2166
+ }
2167
+
2168
+ /** paraply (ADR-0144 beslut 5): vägransreglerna i ordning (delningsdel,
2169
+ * delningsrot, godkännande, läsbar fil, specens form enligt specForm,
2170
+ * titelrad), sedan undanläggning, kopia och state. Beslutslåset (Decision)
2171
+ * är synkens sak, inte arkivets. Titeln läses med forstaRubrik, samma regel
2172
+ * som add-run använder för spec.md (#495). Filen läses som bytes före
2173
+ * undanläggningen, så även katalogens egen spec.md går att peka ut. Kastar
2174
+ * ArkivFel. Returnerar { umbrella: kvittot { date, sha256 }, varningar:
2175
+ * specForms varningar, tryckta av anroparen som VARNING-rader före
2176
+ * OK-raden (F9) }. */
2177
+ function laggInParaply(state, fil) {
2178
+ if (state.split?.parent != null)
2179
+ throw new ArkivFel(
2180
+ `${statePath()} är en delningsdel (split.parent ${state.split.parent}), paraply gäller bara föräldern`,
2181
+ )
2182
+ // Slutgranskning F1: förälderns spec.md får inte skrivas om innan del-N/
2183
+ // finns, annars ärver varje del (bedöm-valet 1) paraplyet i stället för
2184
+ // specen den prissattes mot.
2185
+ if (delningslage(katalog) === null)
2186
+ throw new ArkivFel(
2187
+ `${statePath()} är ingen delningsrot (inga del-N/ med split.parent), paraply gäller bara en uppdelad förälder`,
2188
+ )
2189
+ if (state.approval == null)
2190
+ throw new ArkivFel(`${statePath()} saknar approval, paraply skriver bara om en godkänd spec`)
2191
+ let bytes
2192
+ try {
2193
+ bytes = readFileSync(path.resolve(fil))
2194
+ } catch (e) {
2195
+ throw new ArkivFel(`kan inte läsa ${fil}: ${e.message}`)
2196
+ }
2197
+ const text = bytes.toString('utf8')
2198
+ const namn = path.basename(fil)
2199
+ const form = specForm(text, namn)
2200
+ const fel = [
2201
+ ...form.brister,
2202
+ ...form.kandidater.map((k) => `${namn}: kandidatformen (${k}) är ingen paraplyspec`),
2203
+ ]
2204
+ if (fel.length > 0)
2205
+ throw new ArkivFel(
2206
+ `${fil} klarar inte specens formkontroll:\n${fel.map((f) => `- ${f}`).join('\n')}`,
2207
+ )
2208
+ const titel = forstaRubrik(text)
2209
+ if (titel === null || SPEC_HEADINGS.some((r) => r.sv === titel || r.en === titel))
2210
+ throw new ArkivFel(
2211
+ `${fil} saknar titelrad före "Problem": paraplyets titel är det ändrade läget för helheten`,
2212
+ )
2213
+ laggUndan(katalog, 'spec')
2214
+ const mal = path.join(katalog, 'spec.md')
2215
+ try {
2216
+ writeFileSync(mal, bytes)
2217
+ } catch (e) {
2218
+ throw new ArkivFel(`kunde inte skriva ${mal}: ${e.message}`)
2219
+ }
2220
+ const umbrella = { date: today(), sha256: markerStrippedSha256(mal) }
2221
+ writeStateOrThrow({ ...state, title: titel, umbrella })
2222
+ return { umbrella, varningar: form.varningar }
2223
+ }
2224
+
1829
2225
  /** Scorerns gällande utfall ur prisfilen, eller null när inget poängvarv
1830
2226
  * finns. Läses bara för att `nasta-steg` inte ska säga "sync till brädan"
1831
2227
  * om en post vars gällande varv är DELA_UPP eller OVER_SKALAN: de utfallen
@@ -1838,6 +2234,43 @@ function gallandeScorerUtfall(state, d) {
1838
2234
  return { fil, outcome: readPricingFileOrThrow(fil, d).outcome ?? null }
1839
2235
  }
1840
2236
 
2237
+ /** Delningsläget för en katalog (ADR-0144 beslut 4 och 6): null när ingen
2238
+ * direkt del-N bär split.parent (ingen delningsrot), annars { klara, av }.
2239
+ * `av` är delarnas split.of: arkivet vet hur många delar det finns även när
2240
+ * alla kataloger inte skapats än. `klara` räknar delarna som delKlar godtar.
2241
+ * Delar som är oeniga om split.of, ett split.of som inte är ett heltal >= 1
2242
+ * eller fler delkataloger än split.of är ett arkivfel, aldrig en gissning.
2243
+ * Delad av nasta-steg och bluf så de två aldrig räknar olika. */
2244
+ function delningslage(d) {
2245
+ const delar = delKataloger(d)
2246
+ .filter((p) => path.dirname(p) === d)
2247
+ .map((p) => ({ p, state: readStateOrThrow(p) }))
2248
+ .filter(({ state }) => state.split?.parent != null)
2249
+ if (delar.length === 0) return null
2250
+ const antal = [...new Set(delar.map(({ state }) => state.split.of))]
2251
+ if (antal.length > 1)
2252
+ throw new ArkivFel(
2253
+ `${d}: delarna är oeniga om split.of (${antal.join(', ')}), en delning har ett antal delar`,
2254
+ )
2255
+ const av = antal[0]
2256
+ if (!Number.isInteger(av) || av < 1)
2257
+ throw new ArkivFel(`${d}: delarnas split.of är inte ett heltal >= 1 (fick ${av})`)
2258
+ if (delar.length > av) throw new ArkivFel(`${d}: ${delar.length} delkataloger men split.of ${av}`)
2259
+ return { klara: delar.filter(({ p, state }) => delKlar(state, p)).length, av }
2260
+ }
2261
+
2262
+ /** En del är klar när den är priced (eller guard_run, som bara nås efter
2263
+ * priced), eller står på scorerns OVER_SKALAN med status assessed, utan stopp
2264
+ * och utan olyft spärr: exakt de delar vars egen nasta-steg-rad är
2265
+ * deluppgiftsraden (ADR-0144 beslut 4, en OVER_SKALAN-del får en deluppgift
2266
+ * utan Score och räknas som prissatt). */
2267
+ function delKlar(state, d) {
2268
+ if (state.status === 'priced' || state.status === 'guard_run') return true
2269
+ if (state.status !== 'assessed' || state.stopp != null) return false
2270
+ if (sparrSkal(state).length > 0) return false
2271
+ return gallandeScorerUtfall(state, d)?.outcome === 'OVER_SKALAN'
2272
+ }
2273
+
1841
2274
  /** Regeltabellen bakom `nasta-steg` (#530). Varje regel är en avläsning av
1842
2275
  * en grind som redan står i SKILL.md; kolumnen "väntar på" är det en
1843
2276
  * MÄNNISKA måste göra, "nästa" det /request-kommando som kör vidare.
@@ -1850,8 +2283,13 @@ function gallandeScorerUtfall(state, d) {
1850
2283
  * | spärr står (sparrSkal) | bedöm steg 6 OLIKA "Hänvisa sedan till `döm <post>`"; `döm` |
1851
2284
  * | stopp KONCEPT/DIAGNOS | speca steg 6 "Kandidatens katalog kan fortfarande läggas på |
1852
2285
  * | | brädan … men `bedöm` hoppar över den"; stopputfall → kunddialog |
2286
+ * | stopp TILLBAKA_TILL_SPEC ur impact.md | bedöm steg 3 "starts with `design conflict:` … the Closing |
2287
+ * | med orsak `design conflict: …` | routes it to `designa <adress>`"; add-design nollar stoppet |
2288
+ * | | och lägger undan impact.md, ett kvitto ur designa steg 5 |
2289
+ * | | ersätter det (då gäller raden nedan) |
1853
2290
  * | stopp TILLBAKA_TILL_SPEC | speca steg 6 (ADR-0118) "När valet avgjorts skrivs svaret |
1854
- * | | under `## Avgjorda val` …, sedan `respec` och ny dispatch" |
2291
+ * | | under `## Avgjorda val` …, sedan `respec` och ny dispatch"; |
2292
+ * | | designa steg 5, ett kvitto design-avvisad-<datum>.md |
1855
2293
  * | stopp ESKALERA | speca steg 6 "en människa dömer, posten går aldrig en tredje |
1856
2294
  * | | specrunda"; stopputfall ESKALERA → visa skälet och stanna |
1857
2295
  * | experiment | The experiment area: `experiments/`: "kan inte förväxlas |
@@ -1859,23 +2297,36 @@ function gallandeScorerUtfall(state, d) {
1859
2297
  * | spec_draft utan approval | speca steg 7 "Erbjud att lägga specarna på brädan (🔍)"; |
1860
2298
  * | | godkänn/underkänn verkar från 🔍 |
1861
2299
  * | spec_synced utan approval | godkänn/underkänn-lägesgrinden: 🔍 → ✅ Spec ok |
1862
- * | spec_draft/spec_synced med approval | bedöm-grinden: "Endast `✅ Spec ok` … släpper igenom", |
1863
- * | | "Post utan bräda: `godkänn` antecknar approval manual" |
2300
+ * | spec_draft/spec_synced med approval, | bedöm-grinden 1b: en del ärver förälderns design, grindas |
2301
+ * | split.parent satt | aldrig |
2302
+ * | spec_draft/spec_synced med approval, | designa steg 5: designen är bokförd, bara statusen släpar |
2303
+ * | design satt | (t.ex. en sync efter add-design) → bedöm, aldrig designa igen |
2304
+ * | spec_draft/spec_synced med approval, | designa-grinden: "Only `✅ Spec ok` … lets it through"; |
2305
+ * | ingen design | Closing ISÄR-raden "with `approval` set → `designa`" |
2306
+ * | designed | bedöm grind 1b: "`DESIGN: saknas` or any BRIST … refuse"; |
2307
+ * | | Closing ISÄR-raden "`designed` ↔ ✅ Spec ok → `bedöm`" |
1864
2308
  * | assessed utan poängvarv | bedöm steg 3–7: analysen finns, poängen saknas |
2309
+ * | 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 (⚖️) |
1865
2311
  * | assessed, scorerns DELA_UPP | stopputfall → "ny analysrunda per del … `split` satt" |
2312
+ * | assessed, scorerns OVER_SKALAN, del | ADR-0144 beslut 3: deluppgift utan Score; split.gid säger |
2313
+ * | (split.parent satt) | synkad, annars sync och set-split-gid |
1866
2314
  * | assessed, scorerns OVER_SKALAN | stopputfall → "offereras separat; delas aldrig vidare" |
1867
2315
  * | assessed, scorerns övriga stopp | stopputfall → kunddialog / spec-steget / visa skälet, stanna |
1868
2316
  * | assessed (poäng) | bedöm steg 8: sync via request-sync tar ✅ → ⚖️, sedan |
1869
2317
  * | | godkänn/underkänn från ⚖️ |
2318
+ * | priced, del (split.parent satt) | ADR-0144 beslut 3 och 6: deluppgift med Score; split.gid |
2319
+ * | | säger synkad, annars sync och set-split-gid |
1870
2320
  * | priced | godkänn-grinden: ⚖️ → 📤 Klar – till kund |
1871
2321
  * | guard_run | `guard_run` reserveras för guard-kommandot: inget väntar |
1872
2322
  * | allt annat | `okänt läge: <status>`, aldrig en gissning |
1873
2323
  *
1874
2324
  * `står` är statusen plus det som avviker från grundläget: stoppet,
1875
- * scorerns stopputfall, olyft OLIKA, `respec_varv` > 0, manuell approval
1876
- * och experimentmarkören. Funktionen är ren (ingen I/O) så tabellen går
1877
- * att testa rad för rad. */
1878
- function nastaStegFor(state, { post, antalDelar, scorerUtfall }) {
2325
+ * scorerns stopputfall, olyft OLIKA, `respec_varv` > 0, manuell approval,
2326
+ * designfältet (`design <datum>`) och experimentmarkören. `delning` är
2327
+ * delningslage för katalogen (null utom på en delningsrot). Funktionen är
2328
+ * ren (ingen I/O) så tabellen går att testa rad för rad. */
2329
+ function nastaStegFor(state, { post, antalDelar, scorerUtfall, delning = null }) {
1879
2330
  const status = state.status
1880
2331
  const gid = state.request_id ?? '<gid>'
1881
2332
  const adress = state.request_id ?? post
@@ -1895,6 +2346,7 @@ function nastaStegFor(state, { post, antalDelar, scorerUtfall }) {
1895
2346
  olikaOlyft ? 'OLIKA olyft' : vantarDom ? 'omkörning olyft' : null,
1896
2347
  (state.respec_varv ?? 0) > 0 ? `respec_varv ${state.respec_varv}` : null,
1897
2348
  state.approval?.source === 'manual' ? 'approval manual' : null,
2349
+ state.design ? `design ${state.design.date}` : null,
1898
2350
  state.experiment === true ? 'experiment' : null,
1899
2351
  ]
1900
2352
  .filter((v) => v !== null)
@@ -1906,6 +2358,17 @@ function nastaStegFor(state, { post, antalDelar, scorerUtfall }) {
1906
2358
  'kunddialog',
1907
2359
  'kunddialog; lägg posten på brädan via request-sync (🔍) om den saknas där, bedöm hoppar över den',
1908
2360
  )
2361
+ // ADR-0144 beslut 3 och 6: en prissatt del, eller en OVER_SKALAN-del, blir
2362
+ // en deluppgift under föräldern. Med split.gid satt är den synkad; utan
2363
+ // säger raden sync och set-split-gid, samma "om inte gjort" som för en
2364
+ // prissatt post.
2365
+ const deluppgift = (utanScore) => {
2366
+ const score = utanScore ? ' utan Score' : ''
2367
+ const offert = utanScore ? ', offereras separat' : ''
2368
+ return state.split?.gid
2369
+ ? `synkad som deluppgift ${state.split.gid}${score}, se föräldern ${state.split.parent}${offert}`
2370
+ : `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
+ }
1909
2372
  const tillbakaTillSpec = (val) =>
1910
2373
  rad(
1911
2374
  `avgör valet: ${val}`,
@@ -1917,6 +2380,9 @@ function nastaStegFor(state, { post, antalDelar, scorerUtfall }) {
1917
2380
  if (stopp) {
1918
2381
  const val = stopp.orsak ?? stopp.kalla
1919
2382
  if (stopp.utfall === 'KONCEPT' || stopp.utfall === 'DIAGNOS') return kunddialog()
2383
+ // Slutgranskning I2: en designkonflikt besvaras av en omdesign, inte av
2384
+ // en respec; add-design nollar stoppet.
2385
+ if (arDesignkonflikt(stopp)) return rad('omdesign (designa)', `designa ${adress}`)
1920
2386
  if (stopp.utfall === 'TILLBAKA_TILL_SPEC') return tillbakaTillSpec(val)
1921
2387
  if (stopp.utfall === 'ESKALERA')
1922
2388
  return rad(`avgör valet: ${val}`, 'människa dömer, ingen ny specrunda')
@@ -1924,8 +2390,19 @@ function nastaStegFor(state, { post, antalDelar, scorerUtfall }) {
1924
2390
  if (state.experiment === true)
1925
2391
  return rad('inget (experiment, når aldrig Asana)', 'utvärdera rapport')
1926
2392
  const godkand = state.approval !== null && state.approval !== undefined
2393
+ const delAvSplit = state.split?.parent !== null && state.split?.parent !== undefined
1927
2394
  if (status === 'spec_draft' || status === 'spec_synced') {
1928
- if (godkand) return rad('körning av bedöm', `bedöm ${adress}`)
2395
+ // Designa (spec 2026-09-24): en godkänd post går till designa, aldrig
2396
+ // direkt till bedöm. Hashkontrollen är prechecks jobb, inte tabellens.
2397
+ // En delningsdel (split.parent satt) ärver förälderns design (Ruling B):
2398
+ // den grindas aldrig i designa-grinden, utan går direkt till bedöm. En
2399
+ // post med designen bokförd men statusen kvar (slutgranskning M6) går
2400
+ // också till bedöm: designa igen hade frågat om en design som finns.
2401
+ if (godkand) {
2402
+ if (delAvSplit) return rad('körning av bedöm', `bedöm ${adress}`)
2403
+ if (state.design) return rad('körning av bedöm', `bedöm ${adress}`)
2404
+ return rad('körning av designa', `designa ${adress}`)
2405
+ }
1929
2406
  return status === 'spec_draft'
1930
2407
  ? rad(
1931
2408
  'godkännande av spec på brädan',
@@ -1933,14 +2410,28 @@ function nastaStegFor(state, { post, antalDelar, scorerUtfall }) {
1933
2410
  )
1934
2411
  : rad('godkännande av spec på brädan', godkannEller)
1935
2412
  }
2413
+ if (status === 'designed') return rad('körning av bedöm', `bedöm ${adress}`)
1936
2414
  if (status === 'assessed') {
1937
2415
  if (nScore === 0) return rad('körning av bedöm', `bedöm ${adress} (poängsättning saknas)`)
1938
2416
  if (scorerStopp) {
1939
2417
  const o = scorerStopp.outcome
1940
- if (o === 'DELA_UPP')
2418
+ if (o === 'DELA_UPP') {
2419
+ // ADR-0144 beslut 4 och 6: delningsroten räknar sina delar. Så länge
2420
+ // någon återstår står föräldern kvar på ✅ Spec ok; när varje del är
2421
+ // klar (delKlar) flyttar synken den till ⚖️.
2422
+ if (delning !== null && delning.klara < delning.av)
2423
+ return rad('se delarna', `se delarna (${delning.klara} av ${delning.av} delar prissatta)`)
2424
+ 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
+ )
1941
2429
  return rad('ny analysrunda per del', 'dela posten (split), sedan bedöm per del')
2430
+ }
1942
2431
  if (o === 'OVER_SKALAN')
1943
- return rad('manuell offert', 'offereras separat, delas aldrig vidare')
2432
+ return delAvSplit
2433
+ ? rad('manuell offert', deluppgift(true))
2434
+ : rad('manuell offert', 'offereras separat, delas aldrig vidare')
1944
2435
  if (o === 'KONCEPT' || o === 'DIAGNOS') return kunddialog()
1945
2436
  if (o === 'TILLBAKA_TILL_SPEC') return tillbakaTillSpec(scorerStopp.fil)
1946
2437
  return rad(
@@ -1953,11 +2444,17 @@ function nastaStegFor(state, { post, antalDelar, scorerUtfall }) {
1953
2444
  `sync till brädan (request-sync ⚖️), sedan ${godkannEller}`,
1954
2445
  )
1955
2446
  }
1956
- if (status === 'priced')
2447
+ if (status === 'priced') {
2448
+ if (delAvSplit)
2449
+ return rad(
2450
+ state.split.gid ? 'granskning av föräldern på brädan' : 'sync av deluppgiften',
2451
+ deluppgift(false),
2452
+ )
1957
2453
  return rad(
1958
2454
  'granskning av poäng på brädan',
1959
2455
  `sync till brädan (request-sync ⚖️) om inte gjort, sedan godkänn ${gid} (📤 Klar – till kund) eller underkänn ${gid} <skäl>`,
1960
2456
  )
2457
+ }
1961
2458
  if (status === 'guard_run') return rad('inget', 'inget')
1962
2459
  return rad('–', '–', `okänt läge: ${status}`)
1963
2460
  }