@haus-tech/haus-workflow 5.12.0 → 6.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,34 @@ 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
1001
+ // Täckningspost (ADR-0119, #508): senaste HUVUDFILEN som bär fältet
1002
+ // vinner, precis som titeln följer spec.md på disk. -run2 och -avvisad
1003
+ // sätter den aldrig, oavsett stämplingsordning: under LIKA_TOLERANS blir
1004
+ // den ersatta körningen -run2 och stämplas ofta efter huvudfilen, och
1005
+ // dess yta nådde då state, bluf och request-syncs täckningsdeluppgift
1006
+ // (Convendum 1219234151224225, 2026-10-14). Bara poängaxelns PRISfil
1007
+ // (GALLANDE_RE, samma mönster som maxN läser): en guardfil har en egen
1008
+ // N-sekvens och dess utfall synkas aldrig (SKILL.md, arkivträdet), så
1009
+ // den får inte ersätta den prissatta postens täckning. En huvudfil som
899
1010
  // (ännu) inte finns är inte add-runs jobb att fälla, den stämplas ofta
900
1011
  // innan agenten skrivit filen. Finns den redan är trasig JSON eller ett
901
1012
  // icke-objekt ett arkivfel som andra anropare i den här filen, fångas
902
1013
  // inte här: readPricingFileOrThrow kastar oskyddad.
903
- if (PRICING_RE.test(file) && existsSync(path.join(katalog, file))) {
1014
+ if (GALLANDE_RE('score').test(file) && existsSync(path.join(katalog, file))) {
904
1015
  const data = readPricingFileOrThrow(file, katalog)
905
1016
  if (data.coverage_post !== null && typeof data.coverage_post === 'object')
906
1017
  titelFalt = { ...titelFalt, coverage_post: data.coverage_post }
907
1018
  }
1019
+ // ADR-0153 beslut 3: brädkvittot betyder att den gällande prisrundan
1020
+ // ligger på brädan. En ny rundas huvudfil gör ett tidigare kvitto
1021
+ // inaktuellt, så nasta-steg säger "om inte gjort" igen. -run2, -avvisad,
1022
+ // spec.md och impact.md rör aldrig kvittot.
1023
+ if (HUVUDFIL_RE.test(file) && state.board !== null && state.board !== undefined)
1024
+ titelFalt = { ...titelFalt, board: null }
908
1025
  const nyState = { ...state, ...titelFalt, runs: [...runs, row] }
909
1026
  writeStateOrThrow(nyState)
910
1027
  rowCount = nyState.runs.length
1028
+ if (HUVUDFIL_RE.test(file)) delStateForRot = state
911
1029
  } catch (e) {
912
1030
  releaseLock(lockPath)
913
1031
  if (e instanceof ArkivFel) {
@@ -917,7 +1035,8 @@ if (cmd === 'add-run') {
917
1035
  throw e
918
1036
  }
919
1037
  releaseLock(lockPath)
920
- console.log(`OK: runs-rad för ${file} (${rowCount} rader)`)
1038
+ const rotSuffix = nollaRotensKvitto(delStateForRot)
1039
+ console.log(`OK: runs-rad för ${file} (${rowCount} rader)${rotSuffix}`)
921
1040
  } else if (cmd === 'next-n') {
922
1041
  // `flags('typ')` bara SAMLAR IN varje värde som direkt följer ett `--typ`
923
1042
  // — den validerar aldrig att RESTEN av `rest` är just sådana par. Ett
@@ -1253,19 +1372,96 @@ if (cmd === 'add-run') {
1253
1372
  // katalog är körningskatalogen (k<N>) eller en buntdel (DELA_UPP inuti en
1254
1373
  // del ger del-N/del-M). Skelettet skrivs av kommandot, aldrig av den inre
1255
1374
  // processen: samma skäl som ny-korning (ADR-0107 beslut 4).
1375
+ //
1376
+ // #599: under en produktionspost (state.json finns och saknar
1377
+ // experiment: true) skriver `--typ split` en produktionsdel i stället:
1378
+ // ingen experimentmarkör (set-status priced vägrar den annars), split.parent
1379
+ // är förälderns gid (katalognamnet när gid saknas), förälderns approval
1380
+ // ärvs (delen ärver den godkända specen, ADR-0144), och split valideras som
1381
+ // heltal vid skrivning, så ett handskrivet "of": "2" aldrig fäller
1382
+ // delningslage för hela trädet efteråt. --request-id är då valfri och måste
1383
+ // vara förälderns id.
1256
1384
  avvisaOkandaFlaggor('ny-del', ['typ', 'part', 'of', 'request-id'])
1257
1385
  const typ = requireFlag('typ')
1258
- const part = Number(requireFlag('part'))
1259
- const of = Number(requireFlag('of'))
1260
- const requestId = requireFlag('request-id')
1386
+ const partRa = requireFlag('part')
1387
+ const ofRa = requireFlag('of')
1388
+ const heltal = (v) => (/^[1-9]\d{0,2}$/.test(v) ? Number(v) : NaN)
1389
+ const part = heltal(partRa)
1390
+ const of = heltal(ofRa)
1261
1391
  if (!['bundle', 'split'].includes(typ)) {
1262
1392
  console.error(`--typ måste vara bundle eller split (fick "${typ}")`)
1263
1393
  process.exit(2)
1264
1394
  }
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})`)
1395
+ if (!Number.isInteger(part) || !Number.isInteger(of) || part > of) {
1396
+ console.error(
1397
+ `--part och --of måste vara heltal 1-999 med part <= of (fick "${partRa}"/"${ofRa}")`,
1398
+ )
1267
1399
  process.exit(2)
1268
1400
  }
1401
+ let foralder = null
1402
+ if (existsSync(path.join(katalog, 'state.json'))) {
1403
+ // Förälderns lås hålls från läsningen till delens state.json är skriven:
1404
+ // två samtidiga ny-del med olika --of kan annars båda passera
1405
+ // syskonkontrollen. Varje utgång nedan är process.exit, så låset släpps
1406
+ // i exit-hooken.
1407
+ const lockPath = acquireLock(katalog)
1408
+ process.on('exit', () => releaseLock(lockPath))
1409
+ try {
1410
+ foralder = readStateOrThrow(katalog)
1411
+ } catch (e) {
1412
+ if (!(e instanceof ArkivFel)) throw e
1413
+ console.error(e.message)
1414
+ process.exit(2)
1415
+ }
1416
+ }
1417
+ const produktion = foralder !== null && foralder.experiment !== true
1418
+ let requestId
1419
+ let arvtGodkannande = null
1420
+ if (produktion) {
1421
+ const vagra = (msg) => {
1422
+ console.error(`ny-del vägrar: ${msg}`)
1423
+ process.exit(2)
1424
+ }
1425
+ if (typ !== 'split')
1426
+ vagra('--typ bundle under en produktionspost (produktionsbuntar skrivs av speca)')
1427
+ if (foralder.split?.parent != null)
1428
+ vagra('föräldern är själv en delningsdel (en del delas aldrig vidare, den blir OVER_SKALAN)')
1429
+ if (foralder.approval === null || foralder.approval === undefined)
1430
+ vagra('föräldern saknar godkännande (delning följer bedöm, som kräver ett godkännande)')
1431
+ // Delningen följer scorerns DELA_UPP (bedöm-routningen, ADR-0144): samma
1432
+ // avläsning som nasta-steg gör, aldrig en handdelning av en annan post.
1433
+ let utfall
1434
+ try {
1435
+ utfall = gallandeScorerUtfall(foralder, katalog)?.outcome ?? null
1436
+ } catch (e) {
1437
+ if (!(e instanceof ArkivFel)) throw e
1438
+ vagra(e.message)
1439
+ }
1440
+ if (foralder.status !== 'assessed' || utfall !== 'DELA_UPP')
1441
+ vagra(
1442
+ `föräldern står inte på scorerns DELA_UPP (status ${foralder.status}, utfall ${utfall ?? 'inget'})`,
1443
+ )
1444
+ if (of < 2) vagra(`en delning har minst två delar (fick --of ${of})`)
1445
+ const foralderId = foralder.request_id ?? path.basename(path.resolve(katalog))
1446
+ const givet = flag('request-id')
1447
+ if (givet !== null && givet !== foralderId)
1448
+ vagra(`--request-id ${givet} är inte förälderns id ${foralderId}`)
1449
+ for (const syskon of delKataloger(katalog).filter((d) => path.dirname(d) === katalog)) {
1450
+ let st
1451
+ try {
1452
+ st = readStateOrThrow(syskon)
1453
+ } catch (e) {
1454
+ if (!(e instanceof ArkivFel)) throw e
1455
+ vagra(e.message)
1456
+ }
1457
+ if (st.split?.parent != null && st.split.of !== of)
1458
+ vagra(`${path.basename(syskon)} har split.of ${st.split.of}, inte ${of}`)
1459
+ }
1460
+ requestId = foralderId
1461
+ arvtGodkannande = foralder.approval
1462
+ } else {
1463
+ requestId = requireFlag('request-id')
1464
+ }
1269
1465
  const p = path.join(katalog, `del-${part}`)
1270
1466
  try {
1271
1467
  mkdirSync(p)
@@ -1274,12 +1470,17 @@ if (cmd === 'add-run') {
1274
1470
  process.exit(2)
1275
1471
  }
1276
1472
  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
- )
1473
+ const st = produktion
1474
+ ? {
1475
+ ...skelett(null, { split: { parent: requestId, ...ref }, experiment: false }),
1476
+ approval: arvtGodkannande,
1477
+ }
1478
+ : skelett(
1479
+ requestId,
1480
+ typ === 'bundle'
1481
+ ? { bundle: { parent_slug: requestId, ...ref } }
1482
+ : { split: { parent: requestId, ...ref } },
1483
+ )
1283
1484
  try {
1284
1485
  writeStateOrThrow(st, p)
1285
1486
  } catch (e) {
@@ -1555,15 +1756,16 @@ if (cmd === 'add-run') {
1555
1756
  avvisaOkandaFlaggor('stopp', ['utfall', 'kalla', 'orsak'])
1556
1757
  const utfall = requireFlag('utfall')
1557
1758
  const kalla = requireFlag('kalla')
1558
- const orsak = flag('orsak')
1759
+ let orsak = flag('orsak')
1559
1760
  if (!STOPP_UTFALL.includes(utfall)) {
1560
1761
  console.error(`okänt --utfall: ${utfall} (giltiga: ${STOPP_UTFALL.join(', ')})`)
1561
1762
  process.exit(2)
1562
1763
  }
1563
1764
  const arDesignKvitto = DESIGN_AVVISAD_RE.test(kalla)
1564
- if (!STOPP_KALLOR.includes(kalla) && !arDesignKvitto) {
1765
+ const arIntervjuKvitto = DESIGN_INTERVJU_RE.test(kalla)
1766
+ if (!STOPP_KALLOR.includes(kalla) && !arDesignKvitto && !arIntervjuKvitto) {
1565
1767
  console.error(
1566
- `okänd --kalla: ${kalla} (giltiga: ${STOPP_KALLOR.join(', ')}, design-avvisad-<datum>.md)`,
1768
+ `okänd --kalla: ${kalla} (giltiga: ${STOPP_KALLOR.join(', ')}, design-avvisad-<datum>.md, design-intervju-kravs-<datum>.md)`,
1567
1769
  )
1568
1770
  process.exit(2)
1569
1771
  }
@@ -1611,6 +1813,23 @@ if (cmd === 'add-run') {
1611
1813
  )
1612
1814
  process.exit(2)
1613
1815
  }
1816
+ } else if (arIntervjuKvitto) {
1817
+ // P4-24: kvittot är designerns svar, och bara en första rad
1818
+ // `INTERVJU KRÄVS: <skäl>` är ett stopp. Det går till en människa
1819
+ // (designa, intervjun), aldrig tillbaka till specen.
1820
+ const forsta = text.replace(/\r\n/g, '\n').trimStart().split('\n')[0].trim()
1821
+ if (!/^INTERVJU KRÄVS:\s*\S/u.test(forsta)) {
1822
+ console.error(`${kallaPath} börjar inte med "INTERVJU KRÄVS: <skäl>", kan inte stoppas`)
1823
+ process.exit(2)
1824
+ }
1825
+ if (utfall !== 'ESKALERA') {
1826
+ console.error(`ett intervjukrav i ${kalla} stoppar som ESKALERA, inte ${utfall}`)
1827
+ process.exit(2)
1828
+ }
1829
+ if (!orsak) {
1830
+ console.error('ESKALERA kräver --orsak: en människa ska se varför designern inte skrev något')
1831
+ process.exit(2)
1832
+ }
1614
1833
  } else if (kalla === 'granskarblock.md') {
1615
1834
  // ADR-0118: bara ett ja på raden Prisavgörande öppet val är ett stopp.
1616
1835
  if (prisavgorandeVal(text) === null) {
@@ -1645,6 +1864,26 @@ if (cmd === 'add-run') {
1645
1864
  console.error(`en motsägelse i ${kalla} stoppar som TILLBAKA_TILL_SPEC, inte ${utfall}`)
1646
1865
  process.exit(2)
1647
1866
  }
1867
+ // #595: designkonflikten (texten efter etiketten börjar med `design
1868
+ // conflict:`) avgör routningen till designa (arDesignkonflikt), så den
1869
+ // läses ur filen, aldrig bara ur flaggan. Utan --orsak blir filens text
1870
+ // orsaken; en --orsak som motsäger filen (en annan konflikttext, en
1871
+ // specorsak mot en designkonflikt, eller en designkonflikt filen inte
1872
+ // bär) vägras. En vanlig motsägelse behåller anroparens fria orsak.
1873
+ const filText = avvikelseText(text)
1874
+ const filKonflikt = filText !== null && /^design conflict:/i.test(filText) ? filText : null
1875
+ const givenKonflikt = orsak !== null && /^design conflict:/i.test(orsak.trim())
1876
+ const motsager =
1877
+ orsak !== null && (filKonflikt !== null ? orsak.trim() !== filKonflikt : givenKonflikt)
1878
+ if (motsager) {
1879
+ console.error(
1880
+ `--orsak motsäger impact.md: filen säger ${
1881
+ filKonflikt === null ? 'ingen designkonflikt' : `"${filKonflikt}"`
1882
+ }, flaggan "${orsak}"`,
1883
+ )
1884
+ process.exit(2)
1885
+ }
1886
+ if (filKonflikt !== null) orsak = filKonflikt
1648
1887
  }
1649
1888
 
1650
1889
  const lockPath = acquireLock()
@@ -1703,18 +1942,28 @@ if (cmd === 'add-run') {
1703
1942
  )
1704
1943
  } else if (cmd === 'respec') {
1705
1944
  // Speca steg 5 som kommando: en ny spec över befintlig katalog invaliderar
1706
- // analysen, designen, godkännandet, domarna och stoppet. runs[] rörs
1945
+ // analysen, designen, godkännandet, domarna, stoppet och brädkvittot
1946
+ // (ADR-0153 beslut 3: kvittot gällde en prisrunda som inte längre gäller). runs[] rörs
1707
1947
  // aldrig, arkivet är append-only; den gamla analysen och designen döps
1708
1948
  // om, raderas inte.
1709
1949
  avvisaOkandaFlaggor('respec', [])
1710
1950
  const lockPath = acquireLock()
1711
1951
  let antal = 0
1952
+ let delState = null
1712
1953
  try {
1713
1954
  const state = readStateOrThrow()
1955
+ // ADR-0155 beslut 3: en levande produktionskörning skriver mot samma
1956
+ // katalog; vägra innan något läggs undan. Ett dött pid rensas nedan.
1957
+ if (state.korning && isAlive(state.korning.pid))
1958
+ throw new ArkivFel(`respec vägrar: pågår (pid ${state.korning.pid})`)
1959
+ delState = state
1714
1960
  if (laggUndan(katalog, 'impact')) antal++
1715
1961
  // Designa (spec 2026-09-24): en ny spec gör den gamla designen inaktuell
1716
1962
  // av samma skäl som den gör analysen inaktuell. Läggs undan, raderas inte.
1717
1963
  if (laggUndan(katalog, 'design')) antal++
1964
+ // ADR-0156 beslut 3: granskningen gällde den gamla designen och följer
1965
+ // den, undanlagd; state.design.review nollas med state.design nedan.
1966
+ if (laggUndan(katalog, 'design-review')) antal++
1718
1967
  const nytt = { ...state }
1719
1968
  if (nytt.status !== 'spec_draft') {
1720
1969
  nytt.status = 'spec_draft'
@@ -1736,6 +1985,16 @@ if (cmd === 'add-run') {
1736
1985
  nytt.design = null
1737
1986
  antal++
1738
1987
  }
1988
+ if (nytt.board !== null && nytt.board !== undefined) {
1989
+ nytt.board = null
1990
+ antal++
1991
+ }
1992
+ // Ett dött kvitto (levande vägrades ovan) hör till en körning som inte
1993
+ // finns; en ny spec nollar det.
1994
+ if (nytt.korning !== null && nytt.korning !== undefined) {
1995
+ nytt.korning = null
1996
+ antal++
1997
+ }
1739
1998
  // Monoton räknare (#505): varje respec-anrop är ett nytt varv oavsett om
1740
1999
  // övriga fält redan var normaliserade, till skillnad från fälten ovan
1741
2000
  // finns inget "redan rätt värde" att jämföra mot, så det här villkoret
@@ -1752,7 +2011,7 @@ if (cmd === 'add-run') {
1752
2011
  throw e
1753
2012
  }
1754
2013
  releaseLock(lockPath)
1755
- console.log(`OK: respec (${antal} ändringar)`)
2014
+ console.log(`OK: respec (${antal} ändringar${nollaRotensKvitto(delState)})`)
1756
2015
  } else if (cmd === 'add-design') {
1757
2016
  // Designa steg 5 (spec 2026-09-24): designdokumentet verifieras, kopieras
1758
2017
  // byteidentiskt in som design.md, bokförs i state.design och sätter status
@@ -1775,8 +2034,113 @@ if (cmd === 'add-run') {
1775
2034
  throw e
1776
2035
  }
1777
2036
  releaseLock(lockPath)
2037
+ // ADR-0156 beslut 4: intervjudokumentets saknade utkastsektioner och en
2038
+ // symbolkontroll som inte kunde köras vägrar inte; de syns på stderr.
2039
+ for (const v of kvitto.varningar) console.error(`VARNING: ${v}`)
1778
2040
  console.log(`OK: design ${kvitto.design.path} (${kvitto.design.sha256})`)
1779
2041
  if (kvitto.impactUndanlagd) console.log('OK: impact.md undanlagd (designkonfliktstoppet löst)')
2042
+ } else if (cmd === 'add-design-review') {
2043
+ // ADR-0156 beslut 3: designgranskarens svar kopieras byteidentiskt in som
2044
+ // design-review.md och bokförs i state.design.review. Ingen runs-rad:
2045
+ // granskningen är intern som designen, kostnaden står i korning.json. En
2046
+ // tidigare granskning läggs undan som design-review-superseded-<datum>.md.
2047
+ // --model lagras som det ges (korning.mjs skickar MODELL:-svansraden).
2048
+ avvisaOkandaFlaggor('add-design-review', ['file', 'verdict', 'model'])
2049
+ const fil = requireFlag('file')
2050
+ const verdict = requireFlag('verdict')
2051
+ const model = requireFlag('model')
2052
+ if (verdict !== 'fortsätt' && verdict !== 'stopp') {
2053
+ console.error(
2054
+ `add-design-review vägrar: --verdict måste vara fortsätt eller stopp, fick "${verdict}"`,
2055
+ )
2056
+ process.exit(2)
2057
+ }
2058
+ const lockPath = acquireLock()
2059
+ let sha256
2060
+ try {
2061
+ const state = readStateOrThrow()
2062
+ if (state.experiment === true) throw new ArkivFel('add-design-review vägrar: experimentkatalog')
2063
+ if (state.design === null || state.design === undefined)
2064
+ throw new ArkivFel('add-design-review vägrar: ingen design att granska')
2065
+ // Bytes läses före undanläggningen, så även katalogens egen
2066
+ // design-review.md går att peka ut utan att kopian faller på en flyttad fil.
2067
+ let bytes
2068
+ try {
2069
+ bytes = readFileSync(path.resolve(fil))
2070
+ } catch (e) {
2071
+ throw new ArkivFel(`add-design-review vägrar: kan inte läsa ${fil}: ${e.message}`)
2072
+ }
2073
+ laggUndan(katalog, 'design-review')
2074
+ const mal = path.join(katalog, 'design-review.md')
2075
+ try {
2076
+ writeFileSync(mal, bytes)
2077
+ } catch (e) {
2078
+ throw new ArkivFel(`kunde inte skriva ${mal}: ${e.message}`)
2079
+ }
2080
+ sha256 = markerStrippedSha256(mal)
2081
+ writeStateOrThrow({
2082
+ ...state,
2083
+ design: { ...state.design, review: { verdict, sha256, date: today(), model } },
2084
+ })
2085
+ } catch (e) {
2086
+ releaseLock(lockPath)
2087
+ if (e instanceof ArkivFel) {
2088
+ console.error(e.message)
2089
+ process.exit(2)
2090
+ }
2091
+ throw e
2092
+ }
2093
+ releaseLock(lockPath)
2094
+ console.log(`OK: design-review ${verdict} (${sha256})`)
2095
+ } else if (cmd === 'antaganden') {
2096
+ // ADR-0156 beslut 5: läs-enbart lista inför "synka" över varje antagande
2097
+ // utkastet gjorde själv (fråga, valt, ej valt, skäl), granskningens utslag
2098
+ // med antal fynd och spec conflicts, ur design.md och state.json, aldrig
2099
+ // handskriven. Inget lås, ingen skrivning.
2100
+ avvisaOkandaFlaggor('antaganden', [])
2101
+ const ut = []
2102
+ try {
2103
+ let text
2104
+ try {
2105
+ text = readFileSync(path.join(katalog, 'design.md'), 'utf8')
2106
+ } catch {
2107
+ throw new ArkivFel('antaganden vägrar: ingen design')
2108
+ }
2109
+ const state = readStateOrThrow()
2110
+ ut.push(`Designläge: ${designMode(text)}`)
2111
+ const antaganden = designAssumptions(text)
2112
+ if (antaganden === null) ut.push('Antaganden: avsnitt saknas')
2113
+ else if (!Array.isArray(antaganden)) ut.push(`Antaganden: felformade (${antaganden.fel})`)
2114
+ else if (antaganden.length === 0) ut.push('Antaganden: inga')
2115
+ else {
2116
+ ut.push(`Antaganden (${antaganden.length}):`)
2117
+ antaganden.forEach((a, i) => {
2118
+ ut.push(
2119
+ `${i + 1}. ${a.fraga}`,
2120
+ ` vald: ${a.vald}`,
2121
+ ` ej valda: ${a.ejValda.length > 0 ? a.ejValda.join('; ') : 'inga'}`,
2122
+ ` skäl: ${a.skal}`,
2123
+ )
2124
+ })
2125
+ }
2126
+ const review = state.design?.review ?? null
2127
+ ut.push(
2128
+ review === null
2129
+ ? 'Granskning: ingen'
2130
+ : `Granskning: ${review.verdict} (${antalFynd(path.join(katalog, 'design-review.md'))})`,
2131
+ )
2132
+ const konflikter = specConflicts(text)
2133
+ if (konflikter === null) ut.push('Spec conflicts: avsnitt saknas')
2134
+ else if (konflikter.length === 0) ut.push('Spec conflicts: none')
2135
+ else ut.push(`Spec conflicts: ${konflikter.length}`, ...konflikter.map((k) => ` ${k}`))
2136
+ } catch (e) {
2137
+ if (e instanceof ArkivFel) {
2138
+ console.error(e.message)
2139
+ process.exit(2)
2140
+ }
2141
+ throw e
2142
+ }
2143
+ console.log(ut.join('\n'))
1780
2144
  } else if (cmd === 'set-split-gid') {
1781
2145
  // ADR-0144 beslut 6: delens Asana-deluppgift bokförs i split.gid när
1782
2146
  // request-sync har skapat den, så en omsynk uppdaterar deluppgiften i
@@ -1813,6 +2177,186 @@ if (cmd === 'add-run') {
1813
2177
  }
1814
2178
  releaseLock(lockPath)
1815
2179
  console.log(oforandrat ? `OK: split-gid ${gid} oförändrat` : `OK: split-gid ${gid}`)
2180
+ } else if (cmd === 'approve') {
2181
+ // ADR-0153 beslut 2: approval skrivs bara här. Grinden (designa eller
2182
+ // bedöm) läser brädan och skickar Offer status som den lästes; allt annat
2183
+ // än kärnan specok vägras, så en grind som inte lästs kan inte noteras.
2184
+ // En tidigare notering skrivs över: grinden läses om varje gång.
2185
+ avvisaOkandaFlaggor('approve', ['source', 'status', 'by'])
2186
+ const source = requireFlag('source')
2187
+ if (source !== 'asana' && source !== 'manual') {
2188
+ console.error(`--source måste vara asana eller manual, fick "${source}"`)
2189
+ process.exit(2)
2190
+ }
2191
+ // En flagga som hör till den andra källan tappas aldrig tyst: den vägras.
2192
+ if (source === 'asana' && has('by')) {
2193
+ console.error('--by hör inte till --source asana')
2194
+ process.exit(2)
2195
+ }
2196
+ if (source === 'manual' && has('status')) {
2197
+ console.error('--status hör inte till --source manual')
2198
+ process.exit(2)
2199
+ }
2200
+ let approval
2201
+ if (source === 'asana') {
2202
+ const status = requireFlag('status')
2203
+ if (alfabetiskKarna(status) !== 'specok') {
2204
+ console.error(`approve vägrar: Offer status "${status}" är inte ✅ Spec ok`)
2205
+ process.exit(2)
2206
+ }
2207
+ approval = { source, status_read: status, date: today() }
2208
+ } else {
2209
+ const by = requireFlag('by')
2210
+ if (by.trim() === '') {
2211
+ console.error('--by får inte vara tom')
2212
+ process.exit(2)
2213
+ }
2214
+ approval = { source, by, date: today() }
2215
+ }
2216
+ const lockPath = acquireLock()
2217
+ try {
2218
+ const state = readStateOrThrow()
2219
+ if (state.experiment === true) throw new ArkivFel('approve vägrar: experimentkatalog')
2220
+ writeStateOrThrow({ ...state, approval })
2221
+ } catch (e) {
2222
+ releaseLock(lockPath)
2223
+ if (e instanceof ArkivFel) {
2224
+ console.error(e.message)
2225
+ process.exit(2)
2226
+ }
2227
+ throw e
2228
+ }
2229
+ releaseLock(lockPath)
2230
+ console.log(source === 'asana' ? 'OK: approval asana' : `OK: approval manual by ${approval.by}`)
2231
+ } else if (cmd === 'korning-start') {
2232
+ // ADR-0155 beslut 3: bedom.mjs bokför sin process här innan den spawnar
2233
+ // prisningen, så nasta-steg kan säga "pågår" och en andra start vägras.
2234
+ // Ett dött pid övertas med en varning; EPERM räknas som levande (isAlive).
2235
+ avvisaOkandaFlaggor('korning-start', ['pid', 'katalog-korning'])
2236
+ const pidTxt = flag('pid')
2237
+ if (pidTxt === null || !/^\d+$/.test(pidTxt) || Number(pidTxt) <= 0) {
2238
+ console.error('korning-start kräver --pid <heltal>')
2239
+ process.exit(2)
2240
+ }
2241
+ const pid = Number(pidTxt)
2242
+ const katalogKorning = flag('katalog-korning')
2243
+ const lockPath = acquireLock()
2244
+ let varning = null
2245
+ try {
2246
+ const state = readStateOrThrow()
2247
+ if (state.experiment === true) throw new ArkivFel('korning-start vägrar: experimentkatalog')
2248
+ if (state.korning) {
2249
+ if (isAlive(state.korning.pid))
2250
+ throw new ArkivFel(`korning-start vägrar: pågår (pid ${state.korning.pid})`)
2251
+ varning = `VARNING: tog över död körning (pid ${state.korning.pid})`
2252
+ }
2253
+ // `started` är en full ISO-tidsstämpel, den enda i state.json (övriga
2254
+ // datum är today()): en körning behöver klockslag för att kunna läsas.
2255
+ writeStateOrThrow({
2256
+ ...state,
2257
+ korning: { pid, started: new Date().toISOString(), katalog: katalogKorning },
2258
+ })
2259
+ } catch (e) {
2260
+ releaseLock(lockPath)
2261
+ if (e instanceof ArkivFel) {
2262
+ console.error(e.message)
2263
+ process.exit(2)
2264
+ }
2265
+ throw e
2266
+ }
2267
+ releaseLock(lockPath)
2268
+ if (varning) console.error(varning)
2269
+ console.log(`OK: korning-start pid ${pid}`)
2270
+ } else if (cmd === 'korning-slut') {
2271
+ // ADR-0155 beslut 3: kvittot rensas när bedom.mjs är klar. Utan kvitto är
2272
+ // det ett no-op, aldrig ett fel: slutet ska kunna köras i en finally.
2273
+ avvisaOkandaFlaggor('korning-slut', [])
2274
+ const lockPath = acquireLock()
2275
+ try {
2276
+ const state = readStateOrThrow()
2277
+ if (state.experiment === true) throw new ArkivFel('korning-slut vägrar: experimentkatalog')
2278
+ if (state.korning !== null) writeStateOrThrow({ ...state, korning: null })
2279
+ } catch (e) {
2280
+ releaseLock(lockPath)
2281
+ if (e instanceof ArkivFel) {
2282
+ console.error(e.message)
2283
+ process.exit(2)
2284
+ }
2285
+ throw e
2286
+ }
2287
+ releaseLock(lockPath)
2288
+ console.log('OK: korning-slut')
2289
+ } else if (cmd === 'rot') {
2290
+ // ADR-0154: arkivets enda hem, löst från katalogen (default cwd). `katalog`
2291
+ // är här cwd att lösa från, inte en post; en flagga i första läget är ingen
2292
+ // katalog. Läs-enbart, ingen lås.
2293
+ const flaggaForst = katalog !== undefined && katalog.startsWith('-')
2294
+ const tokens = flaggaForst ? [katalog, ...rest] : rest
2295
+ if (tokens.length > 0) {
2296
+ console.error(
2297
+ `rot: okänd eller ej konsumerad flagga/argument (${tokens.join(' ')}), kommandot tar inga flaggor`,
2298
+ )
2299
+ process.exit(2)
2300
+ }
2301
+ const fran = path.resolve(katalog ?? process.cwd())
2302
+ const r = arkivRot(fran)
2303
+ if (!r) {
2304
+ console.error(`rot vägrar: ingen git-katalog (${fran})`)
2305
+ process.exit(2)
2306
+ }
2307
+ if (r.arkivRot === null) {
2308
+ console.error(
2309
+ `rot vägrar: git-common-dir heter inte .git (${r.commonDir}); kör från superprojektets egen utcheckning`,
2310
+ )
2311
+ process.exit(2)
2312
+ }
2313
+ console.log(`ARKIVROT: ${r.arkivRot}`)
2314
+ console.log(`IGNORERAD: ${r.ignorerad ? 'ja' : 'nej'}`)
2315
+ // Ett herrelöst arkiv i cwd:ns egen utcheckning (en worktree med eget
2316
+ // requests/, Convendums läge mellan släppet och flytten) får en VARNING på
2317
+ // stdout som precheck.mjs, exit fortfarande 0.
2318
+ const topp = spawnSync('git', ['rev-parse', '--show-toplevel'], {
2319
+ cwd: fran,
2320
+ encoding: 'utf8',
2321
+ timeout: 5000,
2322
+ })
2323
+ const toppDir = topp.status === 0 ? String(topp.stdout ?? '').trim() : ''
2324
+ if (toppDir) {
2325
+ const strat = path.join(path.resolve(toppDir), '.haus-workflow', 'requests')
2326
+ if (strat !== path.resolve(r.arkivRot) && existsSync(strat))
2327
+ console.log(`VARNING: arkiv i worktree: ${strat} (flytta per ADR-0154)`)
2328
+ }
2329
+ } else if (cmd === 'synced') {
2330
+ // ADR-0153 beslut 3: brädskrivningen i speca steg 7 och bedöm steg 8 följs
2331
+ // av det här kvittot i state.board, Offer status ordagrant som request-sync
2332
+ // satte den. Varje sync är ett nytt kvitto och skriver över det förra.
2333
+ // Arkivet är fortfarande en cache: Closing-radens brädläge läses ur Asana.
2334
+ avvisaOkandaFlaggor('synced', ['offer-status'])
2335
+ const offerStatus = requireFlag('offer-status')
2336
+ if (offerStatus.trim() === '') {
2337
+ console.error('--offer-status får inte vara tom')
2338
+ process.exit(2)
2339
+ }
2340
+ const board = { offer_status: offerStatus, date: today() }
2341
+ const lockPath = acquireLock()
2342
+ try {
2343
+ const state = readStateOrThrow()
2344
+ if (state.experiment === true) throw new ArkivFel('synced vägrar: experimentkatalog')
2345
+ if (state.split?.parent != null)
2346
+ throw new ArkivFel(
2347
+ 'synced vägrar: en delningsdel har ingen egen Offer status (split.gid är kvittot)',
2348
+ )
2349
+ writeStateOrThrow({ ...state, board })
2350
+ } catch (e) {
2351
+ releaseLock(lockPath)
2352
+ if (e instanceof ArkivFel) {
2353
+ console.error(e.message)
2354
+ process.exit(2)
2355
+ }
2356
+ throw e
2357
+ }
2358
+ releaseLock(lockPath)
2359
+ console.log(`OK: synced ${offerStatus}`)
1816
2360
  } else if (cmd === 'paraply') {
1817
2361
  // ADR-0144 beslut 5: förälderns spec skrivs om som en paraplyspec efter ett
1818
2362
  // ja. Den gamla spec.md läggs undan (spec-superseded-<datum>.md),
@@ -1898,7 +2442,9 @@ if (cmd === 'add-run') {
1898
2442
  ? 'prisavgörande val (granskarblock.md)'
1899
2443
  : DESIGN_AVVISAD_RE.test(state.stopp.kalla ?? '')
1900
2444
  ? `designkonflikt (${state.stopp.kalla})`
1901
- : 'motsägelse (impact.md)'
2445
+ : arIntervjuStopp(state.stopp)
2446
+ ? `intervju krävs (${state.stopp.kalla})`
2447
+ : 'motsägelse (impact.md)'
1902
2448
  }
1903
2449
  // ADR-0144 beslut 6: delningsroten visar hur många delar som är klara,
1904
2450
  // samma räkning som nasta-steg (delningslage), före täckningsposten.
@@ -1957,7 +2503,7 @@ if (cmd === 'add-run') {
1957
2503
  console.log(rader.join('\n'))
1958
2504
  } else {
1959
2505
  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)',
2506
+ '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
2507
  )
1962
2508
  process.exit(2)
1963
2509
  }
@@ -1985,6 +2531,10 @@ function tolkaSatt(par) {
1985
2531
  }
1986
2532
  return [nyckel, ra === 'true']
1987
2533
  }
2534
+ if (nyckel === 'design_mode' && ra !== 'draft' && ra !== 'intervju') {
2535
+ console.error(`experiment: design_mode tar bara draft eller intervju, fick "${ra}"`)
2536
+ process.exit(2)
2537
+ }
1988
2538
  return [nyckel, ra]
1989
2539
  }
1990
2540
 
@@ -2034,7 +2584,9 @@ function delKataloger(dir) {
2034
2584
 
2035
2585
  /** Lägger undan <bas>.md som <bas>-superseded-<datum>.md, numrerad svans vid
2036
2586
  * kollision (-2, -3 …), aldrig en överskrivning: arkivet raderar inget.
2037
- * Delad av respec (impact.md och design.md) och add-design (design.md).
2587
+ * Delad av respec (impact.md, design.md och design-review.md), add-design
2588
+ * (design.md och design-review.md), add-design-review (design-review.md),
2589
+ * paraply (spec.md) och add-design:s designkonfliktstopp (impact.md).
2038
2590
  * Returnerar true när en fil flyttades. */
2039
2591
  function laggUndan(d, bas) {
2040
2592
  const fil = path.join(d, `${bas}.md`)
@@ -2050,6 +2602,28 @@ function laggUndan(d, bas) {
2050
2602
  return true
2051
2603
  }
2052
2604
 
2605
+ /** antaganden (ADR-0156 beslut 5): fyndräkningen i design-review.md som text
2606
+ * för Granskning-raden. Ett fynd är en listpunkt (`- ` eller `* `) under
2607
+ * `## Findings` på den första punktens indrag; en djupare indragen
2608
+ * underpunkt hör till fyndet ovanför och räknas inte. Den enda punkten
2609
+ * `none` är 0. En saknad fil eller sektion säger det, aldrig en gissad
2610
+ * nolla. */
2611
+ function antalFynd(fil) {
2612
+ let text
2613
+ try {
2614
+ text = readFileSync(fil, 'utf8')
2615
+ } catch {
2616
+ return 'fynd okända, design-review.md saknas'
2617
+ }
2618
+ const rader = sectionLines(text.replace(/\r\n/g, '\n'), 'Findings')
2619
+ if (rader === null) return 'fynd okända, ## Findings saknas'
2620
+ const listpunkter = rader.map((r) => /^([ \t]*)[-*] (.*)$/.exec(r)).filter((m) => m !== null)
2621
+ const indrag = listpunkter[0]?.[1] ?? ''
2622
+ const punkter = listpunkter.filter((m) => m[1] === indrag).map((m) => m[2].trim())
2623
+ const antal = punkter.length === 1 && punkter[0].toLowerCase() === 'none' ? 0 : punkter.length
2624
+ return `${antal} fynd`
2625
+ }
2626
+
2053
2627
  /** Repo-roten för en arkivkatalog: föräldern till närmaste `.haus-workflow`
2054
2628
  * uppåt i sökvägen (arkivet bor i <rot>/.haus-workflow/requests/). null när
2055
2629
  * katalogen inte ligger under någon; add-design vägrar då i stället för att
@@ -2065,14 +2639,17 @@ function repoRotFor(d) {
2065
2639
  }
2066
2640
 
2067
2641
  /** add-design: vägransreglerna i ordning (rot, fil, inneslutning, arkivet,
2068
- * frontmatter, spec-hash, konflikter, status, stopp), sedan undanläggning,
2642
+ * frontmatter, spec-hash, konflikter, status, stopp, utkastsektioner,
2643
+ * symboler), sedan undanläggning,
2069
2644
  * kopia och state. Inneslutningen prövas på realpath (ADR-0082: en länk
2070
2645
  * matchas på målet), och sökvägen som bokförs är realpath så precheck
2071
2646
  * --design hittar samma fil oavsett länkar. Ett designkonfliktstopp ur
2072
2647
  * impact.md nollas (en omdesign besvarar det, slutgranskning I2) och
2073
2648
  * 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. */
2649
+ * ArkivFel, skriver inget före sista steget. Returnerar designkvittot, om
2650
+ * en analys lades undan och varningarna (ADR-0156 beslut 4: utkastsektioner
2651
+ * på ett intervjudokument, en symbolkontroll som inte kunde köras), som
2652
+ * anroparen skriver på stderr. */
2076
2653
  function laggTillDesign(state, fil) {
2077
2654
  const rot = repoRotFor(katalog)
2078
2655
  if (rot === null)
@@ -2131,17 +2708,44 @@ function laggTillDesign(state, fil) {
2131
2708
  `${statePath()} har status ${state.status}, en prissatt post designas inte om utan respec`,
2132
2709
  )
2133
2710
  const stopp = state.stopp ?? null
2134
- if (stopp !== null && !arDesignkonflikt(stopp))
2711
+ // P4-24: ett intervjukrav (design-intervju-kravs-<datum>.md) besvaras av
2712
+ // designa, så designen nollar det som designkonfliktstoppet.
2713
+ if (stopp !== null && !arDesignkonflikt(stopp) && !arIntervjuStopp(stopp))
2135
2714
  throw new ArkivFel(
2136
2715
  `${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
2716
  )
2717
+ // ADR-0156 beslut 4, efter de billiga statusvägrarna så en vägrad post
2718
+ // aldrig startar git: ett utkast (mode: draft) bär Assumptions, Removed or
2719
+ // renamed och Files touched, samma rader som precheck --design (på ett
2720
+ // intervjudokument varningar). Varje borttagen eller omdöpt symbol greppas
2721
+ // i repot där dokumentet bor, utanför filerna designen rör: en träff
2722
+ // vägrar, en kontroll som inte kunde köras är en varning.
2723
+ const designRot = repoRotForFil(real)
2724
+ const form = designForm(text, { repoRot: designRot })
2725
+ if (form.brister.length > 0) throw new ArkivFel(form.brister.join('\n'))
2726
+ const { traffar, varning } = designSymbolkontroll(text, designRot)
2727
+ if (traffar.length > 0)
2728
+ throw new ArkivFel(
2729
+ traffar
2730
+ .map(
2731
+ (t) =>
2732
+ `add-design vägrar: symbolen \`${t.symbol}\` används fortfarande: ${t.fil}:${t.rad}`,
2733
+ )
2734
+ .join('\n'),
2735
+ )
2736
+ const varningar = [...form.varningar, ...(varning ? [varning] : [])]
2138
2737
  laggUndan(katalog, 'design')
2738
+ // Granskningen gällde den ersatta designen och följer den, undanlagd som i
2739
+ // respec; state.design.review följer aldrig med (nytt objekt nedan).
2740
+ laggUndan(katalog, 'design-review')
2139
2741
  const mal = path.join(katalog, 'design.md')
2140
2742
  try {
2141
2743
  copyFileSync(real, mal)
2142
2744
  } catch (e) {
2143
2745
  throw new ArkivFel(`kunde inte kopiera ${real} till ${mal}: ${e.message}`)
2144
2746
  }
2747
+ // Ett nytt objekt: en ny designversion har ingen granskning än, så en
2748
+ // tidigare state.design.review följer aldrig med (ADR-0156 beslut 3).
2145
2749
  const design = {
2146
2750
  path: real,
2147
2751
  file: 'design.md',
@@ -2155,14 +2759,14 @@ function laggTillDesign(state, fil) {
2155
2759
  // en ny analys är skyldig oavsett omdesignens bytes, och en byteidentisk
2156
2760
  // omdesign hade annars lämnat motsägelseanalysen proveniensgiltig. runs[]
2157
2761
  // rörs inte, som i respec.
2158
- const impactUndanlagd = stopp !== null ? laggUndan(katalog, 'impact') : false
2762
+ const impactUndanlagd = arDesignkonflikt(stopp) ? laggUndan(katalog, 'impact') : false
2159
2763
  writeStateOrThrow({
2160
2764
  ...state,
2161
2765
  design,
2162
2766
  status: 'designed',
2163
2767
  ...(stopp !== null ? { stopp: null } : {}),
2164
2768
  })
2165
- return { design, impactUndanlagd }
2769
+ return { design, impactUndanlagd, varningar }
2166
2770
  }
2167
2771
 
2168
2772
  /** paraply (ADR-0144 beslut 5): vägransreglerna i ordning (delningsdel,
@@ -2280,6 +2884,9 @@ function delKlar(state, d) {
2280
2884
  * | ------------------------------------- | ------------------------------------------------------------- |
2281
2885
  * | bunt-rot (ingen spec.md, del-N finns) | The experiment area: `experiments/`, buntregeln; |
2282
2886
  * | | bluf:s `bunt (N delar)` |
2887
+ * | korning.pid lever | ADR-0155 beslut 3: bedom.mjs bokför sin process med |
2888
+ * | | korning-start; raden säger pågår, ett dött pid prefixar |
2889
+ * | | `står` med `avbruten körning, ` och raden räknas som vanligt |
2283
2890
  * | spärr står (sparrSkal) | bedöm steg 6 OLIKA "Hänvisa sedan till `döm <post>`"; `döm` |
2284
2891
  * | stopp KONCEPT/DIAGNOS | speca steg 6 "Kandidatens katalog kan fortfarande läggas på |
2285
2892
  * | | brädan … men `bedöm` hoppar över den"; stopputfall → kunddialog |
@@ -2287,6 +2894,8 @@ function delKlar(state, d) {
2287
2894
  * | med orsak `design conflict: …` | routes it to `designa <adress>`"; add-design nollar stoppet |
2288
2895
  * | | och lägger undan impact.md, ett kvitto ur designa steg 5 |
2289
2896
  * | | ersätter det (då gäller raden nedan) |
2897
+ * | stopp ESKALERA ur | designa Draft mode (P4-24): designern svarade INTERVJU |
2898
+ * | design-intervju-kravs-<datum>.md | KRÄVS; designa (intervjun) löser det, add-design nollar det |
2290
2899
  * | stopp TILLBAKA_TILL_SPEC | speca steg 6 (ADR-0118) "När valet avgjorts skrivs svaret |
2291
2900
  * | | under `## Avgjorda val` …, sedan `respec` och ny dispatch"; |
2292
2901
  * | | designa steg 5, ett kvitto design-avvisad-<datum>.md |
@@ -2307,7 +2916,8 @@ function delKlar(state, d) {
2307
2916
  * | | Closing ISÄR-raden "`designed` ↔ ✅ Spec ok → `bedöm`" |
2308
2917
  * | assessed utan poängvarv | bedöm steg 3–7: analysen finns, poängen saknas |
2309
2918
  * | 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 (⚖️) |
2919
+ * | finns (delningslage) | prissatta)"; alla klara (delKlar) → sync föräldern (⚖️), |
2920
+ * | | utom när rotens kvitto board har kärnan ⚖️ (#693) |
2311
2921
  * | assessed, scorerns DELA_UPP | stopputfall → "ny analysrunda per del … `split` satt" |
2312
2922
  * | assessed, scorerns OVER_SKALAN, del | ADR-0144 beslut 3: deluppgift utan Score; split.gid säger |
2313
2923
  * | (split.parent satt) | synkad, annars sync och set-split-gid |
@@ -2317,7 +2927,8 @@ function delKlar(state, d) {
2317
2927
  * | | godkänn/underkänn från ⚖️ |
2318
2928
  * | priced, del (split.parent satt) | ADR-0144 beslut 3 och 6: deluppgift med Score; split.gid |
2319
2929
  * | | säger synkad, annars sync och set-split-gid |
2320
- * | priced | godkänn-grinden: ⚖️ → 📤 Klar – till kund |
2930
+ * | priced | godkänn-grinden: ⚖️ → 📤 Klar – till kund; ADR-0153 beslut 3: |
2931
+ * | | kvittot board med kärnan ⚖️ säger synkad, annars om inte gjort |
2321
2932
  * | guard_run | `guard_run` reserveras för guard-kommandot: inget väntar |
2322
2933
  * | allt annat | `okänt läge: <status>`, aldrig en gissning |
2323
2934
  *
@@ -2339,8 +2950,13 @@ function nastaStegFor(state, { post, antalDelar, scorerUtfall, delning = null })
2339
2950
  const vantarDom = sparrSkal(state).length > 0
2340
2951
  const scorerStopp =
2341
2952
  scorerUtfall !== null && SCORE_STOP.includes(scorerUtfall.outcome) ? scorerUtfall : null
2953
+ // ADR-0155 beslut 3: ett kvitto vars pid lever är en pågående körning; ett
2954
+ // dött är en avbruten (bedom.mjs hann aldrig köra korning-slut).
2955
+ const korningPid = Number.isInteger(state.korning?.pid) ? state.korning.pid : null
2956
+ const korningLever = korningPid !== null && isAlive(korningPid)
2957
+ const avbruten = korningPid !== null && !korningLever ? 'avbruten körning, ' : ''
2342
2958
  const star = [
2343
- status,
2959
+ avbruten + status,
2344
2960
  stopp ? `stopp ${stopp.utfall} (${stopp.kalla})` : null,
2345
2961
  !stopp && scorerStopp ? `${scorerStopp.outcome} (${scorerStopp.fil})` : null,
2346
2962
  olikaOlyft ? 'OLIKA olyft' : vantarDom ? 'omkörning olyft' : null,
@@ -2369,6 +2985,14 @@ function nastaStegFor(state, { post, antalDelar, scorerUtfall, delning = null })
2369
2985
  ? `synkad som deluppgift ${state.split.gid}${score}, se föräldern ${state.split.parent}${offert}`
2370
2986
  : `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
2987
  }
2988
+ // ADR-0153 beslut 3: kvittot ur archive.mjs synced med kärnan ⚖️ säger att
2989
+ // ⚖️-synken är gjord. Allt annat (null, saknat, en annan status) säger inte det.
2990
+ const kvitto = state.board
2991
+ const synkadVag =
2992
+ kvitto !== null &&
2993
+ typeof kvitto === 'object' &&
2994
+ typeof kvitto.offer_status === 'string' &&
2995
+ alfabetiskKarna(kvitto.offer_status) === alfabetiskKarna('⚖️ Poängsättning redo för granskning')
2372
2996
  const tillbakaTillSpec = (val) =>
2373
2997
  rad(
2374
2998
  `avgör valet: ${val}`,
@@ -2376,6 +3000,8 @@ function nastaStegFor(state, { post, antalDelar, scorerUtfall, delning = null })
2376
3000
  )
2377
3001
 
2378
3002
  if (antalDelar > 0) return rad('se delarna', 'se delarna', `bunt (${antalDelar} delar)`)
3003
+ if (korningLever)
3004
+ return rad('processen', 'vänta; bedöm igen när bedom.mjs är klar', `pågår (pid ${korningPid})`)
2379
3005
  if (vantarDom) return rad('dom (döm)', `döm ${post}`)
2380
3006
  if (stopp) {
2381
3007
  const val = stopp.orsak ?? stopp.kalla
@@ -2383,6 +3009,13 @@ function nastaStegFor(state, { post, antalDelar, scorerUtfall, delning = null })
2383
3009
  // Slutgranskning I2: en designkonflikt besvaras av en omdesign, inte av
2384
3010
  // en respec; add-design nollar stoppet.
2385
3011
  if (arDesignkonflikt(stopp)) return rad('omdesign (designa)', `designa ${adress}`)
3012
+ // P4-24: designern bad om en intervju; designa löser stoppet.
3013
+ if (arIntervjuStopp(stopp))
3014
+ return rad(
3015
+ 'människa',
3016
+ `designa ${adress} (intervju; se ${stopp.kalla})`,
3017
+ `${avbruten}stopp ${stopp.utfall} ur ${stopp.kalla}`,
3018
+ )
2386
3019
  if (stopp.utfall === 'TILLBAKA_TILL_SPEC') return tillbakaTillSpec(val)
2387
3020
  if (stopp.utfall === 'ESKALERA')
2388
3021
  return rad(`avgör valet: ${val}`, 'människa dömer, ingen ny specrunda')
@@ -2410,6 +3043,14 @@ function nastaStegFor(state, { post, antalDelar, scorerUtfall, delning = null })
2410
3043
  )
2411
3044
  : rad('godkännande av spec på brädan', godkannEller)
2412
3045
  }
3046
+ // ADR-0156 beslut 3: en granskning som stoppade avslutar kedjan på designed
3047
+ // och går till det interaktiva designa; ett utslag skickas aldrig om.
3048
+ if (status === 'designed' && state.design?.review?.verdict === 'stopp')
3049
+ return rad(
3050
+ 'människa',
3051
+ `designa ${adress} (intervju; se design-review.md)`,
3052
+ 'designed, granskningen stoppade',
3053
+ )
2413
3054
  if (status === 'designed') return rad('körning av bedöm', `bedöm ${adress}`)
2414
3055
  if (status === 'assessed') {
2415
3056
  if (nScore === 0) return rad('körning av bedöm', `bedöm ${adress} (poängsättning saknas)`)
@@ -2421,11 +3062,15 @@ function nastaStegFor(state, { post, antalDelar, scorerUtfall, delning = null })
2421
3062
  // klar (delKlar) flyttar synken den till ⚖️.
2422
3063
  if (delning !== null && delning.klara < delning.av)
2423
3064
  return rad('se delarna', `se delarna (${delning.klara} av ${delning.av} delar prissatta)`)
3065
+ // #693: synken som flyttar föräldern bokförs med synced på roten;
3066
+ // kvittot läses som i priced-grenen (ADR-0153 beslut 3).
2424
3067
  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
- )
3068
+ return synkadVag
3069
+ ? rad('granskning av poäng på brädan', godkannEller)
3070
+ : rad(
3071
+ 'granskning av poäng på brädan',
3072
+ `sync föräldern till brädan (request-sync ⚖️), sedan ${godkannEller}`,
3073
+ )
2429
3074
  return rad('ny analysrunda per del', 'dela posten (split), sedan bedöm per del')
2430
3075
  }
2431
3076
  if (o === 'OVER_SKALAN')
@@ -2450,10 +3095,14 @@ function nastaStegFor(state, { post, antalDelar, scorerUtfall, delning = null })
2450
3095
  state.split.gid ? 'granskning av föräldern på brädan' : 'sync av deluppgiften',
2451
3096
  deluppgift(false),
2452
3097
  )
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
- )
3098
+ // ADR-0153 beslut 3: synkadVag (ovan) läser kvittot; utan det "om inte gjort".
3099
+ const godkannKlar = `godkänn ${gid} (📤 Klar – till kund) eller underkänn ${gid} <skäl>`
3100
+ return synkadVag
3101
+ ? rad('granskning av poäng på brädan', godkannKlar)
3102
+ : rad(
3103
+ 'granskning av poäng på brädan',
3104
+ `sync till brädan (request-sync ⚖️) om inte gjort, sedan ${godkannKlar}`,
3105
+ )
2457
3106
  }
2458
3107
  if (status === 'guard_run') return rad('inget', 'inget')
2459
3108
  return rad('–', '–', `okänt läge: ${status}`)