@haus-tech/haus-workflow 5.12.1 → 6.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -19,19 +19,41 @@
19
19
  * --request-id <gid eller slug> --modell <modell|default>
20
20
  * [--claude <kommando>] [--delar-parallellt 2]
21
21
  * [--steg-timeout 1800000] [--effort <low|medium|high|xhigh|max|default>]
22
+ * [--sprak <svenska|engelska>] [--design-mode <draft|intervju>]
23
+ * node korning.mjs --katalog <abs körningskatalog> --post <abs postkatalog> --repo-rot <abs>
24
+ * --modell <modell|default> [--startare <pid>] [samma valfria flaggor]
22
25
  *
23
- * Läser `<exp>/experiment.json` (`sprak` OBLIGATORISK, ADR-0114: språket
24
- * gissas aldrig), `<exp>/onskemal.md`, `<exp>/kallmanifest.md` och `<exp>/design.md`
25
- * (valfri). Skriver `steg/*.json`, spec/impact/prisfiler i körningskatalogen
26
- * och `korning.json`;
26
+ * Experimentläget (`--exp`) läser `<exp>/experiment.json` (`sprak` OBLIGATORISK,
27
+ * ADR-0114: språket gissas aldrig), `<exp>/onskemal.md`, `<exp>/kallmanifest.md`
28
+ * och `<exp>/design.md` (valfri). Skriver `steg/*.json`, spec/impact/prisfiler i
29
+ * körningskatalogen och `korning.json`;
27
30
  * `experiment.json` och `state.json` rörs BARA via `archive.mjs`.
28
31
  *
29
- * stdout används inte (svep sparar stderr som `stderr.log`). Exit 0 = körningen
32
+ * Produktionsläget (`--post`, ADR-0155) prissätter en godkänd, designad post ur
33
+ * dess egen arkivkatalog: `spec.md`, `granskarblock.md`, `kallmanifest.md`
34
+ * (`SPRÅK:` obligatorisk, eller `--sprak`), `design.md` och `state.json` med
35
+ * `approval`. Ingen specifier körs; analysen körs bara när den saknas eller
36
+ * gjordes mot en annan spec eller design. `--katalog` är körningskatalogen
37
+ * (`<post>/korningar/<stämpel>/`) med `steg/` och `korning.json`; id:t kommer
38
+ * ur postens state.json, så `--request-id` ges aldrig med `--post`.
39
+ *
40
+ * Draft-läget (ADR-0156, `--design-mode draft` eller `experiment.json`
41
+ * `design_mode: draft`; utan båda `intervju`): saknas designen skriver
42
+ * request-designer ett utkast och request-design-reviewer läser det före
43
+ * assessorn, i båda kanalerna. Produktionen bokför utkastet via
44
+ * `archive.mjs add-design` och granskningen via `add-design-review`;
45
+ * experimentkanalen skriver `design.md` och `design-review.md` i delen.
46
+ *
47
+ * stdout används inte i experimentläget (svep sparar stderr som `stderr.log`);
48
+ * produktionsläget skriver en enda rad `STATUS: <…>` för en människa. Exit 0 = körningen
30
49
  * gick igenom (status `klar` sätts av svep), 1 = minst en del blev ofärdig,
31
50
  * 2 = användningsfel, 130 = avbruten med SIGTERM eller SIGINT.
32
51
  *
33
52
  * SIGTERM och SIGINT dödar varje pågående `claude`-process, skriver
34
- * `korning.json` ur stegen så långt och lämnar körningen som `ofärdig`. Varje
53
+ * `korning.json` ur stegen så långt och lämnar körningen som `ofärdig`.
54
+ * `--startare <pid>` (bara med `--post`) är bedom.mjs:s pid: var 30:e sekund
55
+ * prövas den, och en död startare avbryter körningen på samma sätt, så en
56
+ * startare dödad med SIGKILL inte lämnar en körning som ingen äger. Varje
35
57
  * agentsteg har dessutom ett väggtidstak (`--steg-timeout`, 30 minuter): ett
36
58
  * steg som hänger utan att svara kostar inga turer men blockerar körningen.
37
59
  */
@@ -43,6 +65,7 @@ import {
43
65
  renameSync,
44
66
  rmSync,
45
67
  realpathSync,
68
+ readdirSync,
46
69
  } from 'node:fs'
47
70
  import { spawn, spawnSync } from 'node:child_process'
48
71
  import path from 'node:path'
@@ -53,10 +76,15 @@ import {
53
76
  delaSpecifierSvar,
54
77
  matcharModell,
55
78
  avgorDeps,
79
+ katalogId,
80
+ sprakUrManifest,
56
81
  schemalagg,
57
82
  markerStrippedSha256,
58
83
  claudeDir,
59
84
  canonicalModel,
85
+ designMode as designModeFor,
86
+ specConflicts,
87
+ sectionLines,
60
88
  SPEC_HEADINGS,
61
89
  IMPACT_HEADINGS,
62
90
  SCORE_OUTCOMES,
@@ -67,7 +95,13 @@ const ARCHIVE = path.join(HAR, 'archive.mjs')
67
95
 
68
96
  /** Turtak per agent. Taken är generösa men ändliga: en agent som snurrar
69
97
  * kostar pengar tills den dör, och en körning utan tak har inget slut. */
70
- export const MAX_TURER = { specifier: 120, assessor: 200, scorer: 80 }
98
+ export const MAX_TURER = {
99
+ specifier: 120,
100
+ assessor: 200,
101
+ scorer: 80,
102
+ designer: 200,
103
+ designgranskare: 120,
104
+ }
71
105
 
72
106
  /** Modellen som skriver om kundtextfält i kontrollkedjan (kontroll.mjs
73
107
  * --fixa-kundtext). Pinnad där, upprepad här bara som stegets modell_begard. */
@@ -98,13 +132,29 @@ const VARDEFLAGGOR = [
98
132
  'delar-parallellt',
99
133
  'steg-timeout',
100
134
  'effort',
135
+ 'post',
136
+ 'sprak',
137
+ 'startare',
138
+ 'design-mode',
101
139
  ]
140
+ const SPRAKEN = ['svenska', 'engelska']
141
+ /** Designlägena (ADR-0156): `draft` låter designern skriva ett saknat
142
+ * designdokument, `intervju` kräver ett ur designa som förut. */
143
+ const DESIGNLAGEN = ['draft', 'intervju']
144
+ const STANDARD_DESIGNLAGE = 'intervju'
102
145
 
103
146
  export const ANVANDNING = [
104
147
  'användning: korning.mjs --katalog <abs k<N>> --exp <abs experimentkatalog> --repo-rot <abs>',
105
148
  ' --request-id <gid eller slug> --modell <modell|default>',
106
149
  ` [--claude <kommando>] [--delar-parallellt ${STANDARD_DELAR_PARALLELLT}]`,
107
150
  ` [--steg-timeout ${STANDARD_STEG_TIMEOUT}] [--effort <${EFFORTNIVAER.join('|')}>]`,
151
+ ` [--sprak <${SPRAKEN.join('|')}>]`,
152
+ ' korning.mjs --katalog <abs körningskatalog> --post <abs postkatalog> --repo-rot <abs>',
153
+ ' --modell <modell|default> [--startare <pid>] [samma valfria flaggor] (produktion, ADR-0155)',
154
+ '(--startare ges bara med --post: startarens pid, en död startare avbryter körningen inom 30 s)',
155
+ '(--request-id hör till --exp och ges aldrig med --post, id:t kommer ur postens state.json)',
156
+ '--sprak skriver över språket ur experiment.json eller kallmanifest.md.',
157
+ `[--design-mode <${DESIGNLAGEN.join('|')}>] skriver över design_mode ur experiment.json; utan båda ${STANDARD_DESIGNLAGE} (ADR-0156).`,
108
158
  ].join('\n')
109
159
 
110
160
  // ---------------------------------------------------------------------------
@@ -132,6 +182,17 @@ export const ANVANDNING = [
132
182
  * `Bash(echo:*)`: ett fritt echo är ett skrivkommando i förklädnad.
133
183
  */
134
184
  const ASSESSOR_GIT = ['ls-files', 'check-ignore', 'log', 'grep', 'status', 'rev-parse']
185
+ const DESIGN_VERKTYG = [
186
+ 'Read',
187
+ 'Grep',
188
+ 'Glob',
189
+ ...ASSESSOR_GIT.map((sub) => `Bash(git ${sub}:*)`),
190
+ ...ASSESSOR_GIT.map((sub) => `Bash(rtk git ${sub}:*)`),
191
+ 'Bash(head:*)',
192
+ 'Bash(grep:*)',
193
+ 'Bash(wc:*)',
194
+ 'Bash(ls:*)',
195
+ ]
135
196
  export const AGENT_VERKTYG = {
136
197
  'request-scorer': ['Read', 'Skill'],
137
198
  'request-specifier': ['Read', 'Grep', 'Glob', 'Skill'],
@@ -148,6 +209,9 @@ export const AGENT_VERKTYG = {
148
209
  'Bash(wc:*)',
149
210
  'Bash(ls:*)',
150
211
  ],
212
+ // ADR-0156: assessorns läsande Bash, utan Skill (ingen av dem laddar en skill).
213
+ 'request-designer': DESIGN_VERKTYG,
214
+ 'request-design-reviewer': DESIGN_VERKTYG,
151
215
  }
152
216
 
153
217
  /** Den pinnade headless-invokationen per agentsteg: agentdefinitionen ur
@@ -286,6 +350,88 @@ export function scorerPrompt({ requestId, delpost, sprak, specPath, impactPath }
286
350
  ].join('\n')
287
351
  }
288
352
 
353
+ /** Designerns prompt (ADR-0156), etiketterna i exakt den ordning
354
+ * request-designer.md listar dem. Den ursprungliga förfrågan finns inte
355
+ * alltid i arkivet; då står det så, och designern läser aldrig tavlan.
356
+ * KONFIG är sökvägen även när filen saknas: agenten läser den om den finns. */
357
+ export function designerPrompt({
358
+ requestId,
359
+ specSha,
360
+ datum,
361
+ onskemal,
362
+ spec,
363
+ granskarblock,
364
+ manifest,
365
+ repoRot,
366
+ deps,
367
+ sprak,
368
+ konfigPath,
369
+ }) {
370
+ const sprakRad = kravSprak(sprak)
371
+ return [
372
+ 'SPEC:',
373
+ spec.trim(),
374
+ '',
375
+ 'GRANSKARBLOCK:',
376
+ granskarblock.trim(),
377
+ '',
378
+ 'URSPRUNGLIG FÖRFRÅGAN:',
379
+ onskemal == null || onskemal.trim() === '' ? '(saknas i arkivet)' : onskemal.trim(),
380
+ '',
381
+ `REPO-ROT: ${repoRot}`,
382
+ `DEPS: ${deps}`,
383
+ sprakRad,
384
+ 'KÄLLOR:',
385
+ manifest.trim(),
386
+ '',
387
+ `KONFIG: ${konfigPath}`,
388
+ `ID: ${requestId}`,
389
+ `SPEC-SHA256: ${specSha}`,
390
+ `DATUM: ${datum}`,
391
+ '',
392
+ ].join('\n')
393
+ }
394
+
395
+ /** Designgranskarens prompt (ADR-0156 beslut 3), etiketterna i
396
+ * request-design-reviewer.md:s ordning. */
397
+ export function designReviewerPrompt({ spec, granskarblock, design, repoRot, deps, sprak }) {
398
+ const sprakRad = kravSprak(sprak)
399
+ return [
400
+ 'SPEC:',
401
+ spec.trim(),
402
+ '',
403
+ 'GRANSKARBLOCK:',
404
+ granskarblock.trim(),
405
+ '',
406
+ 'DESIGN:',
407
+ design.trim(),
408
+ '',
409
+ `REPO-ROT: ${repoRot}`,
410
+ `DEPS: ${deps}`,
411
+ sprakRad,
412
+ '',
413
+ ].join('\n')
414
+ }
415
+
416
+ /** En kebab av en rubrik: diakriter strippade (å blir a), gemener, allt som
417
+ * inte är a-z eller 0-9 blir ett bindestreck, ihopslaget och trimmat, högst
418
+ * 60 tecken. En rubrik utan ett enda tecken kvar blir `design`. */
419
+ export const slugAv = (titel) => {
420
+ const slug = String(titel)
421
+ .normalize('NFD')
422
+ .replace(/\p{M}+/gu, '')
423
+ .toLowerCase()
424
+ .replace(/[^a-z0-9]+/g, '-')
425
+ .replace(/^-+|-+$/g, '')
426
+ .slice(0, 60)
427
+ .replace(/-+$/, '')
428
+ return slug === '' ? 'design' : slug
429
+ }
430
+
431
+ /** Designdokumentets sökväg relativt repo-roten, samma som designa steg 4. */
432
+ export const designFilnamn = ({ datum, requestId, titel }) =>
433
+ `docs/superpowers/specs/${datum}-${requestId}-${slugAv(titel)}-design.md`
434
+
289
435
  /** Omdispatchen: HELA ursprungsprompten oförändrad plus felraderna ordagrant.
290
436
  * En ny subagent minns inget, enbart felrader utan underlag är obrukbart. */
291
437
  export const omdispatchPrompt = (original, felrader, { endastSpec = null } = {}) =>
@@ -320,8 +466,8 @@ const raderEfter = (stdout, rubrikRe) => {
320
466
  return i === -1 ? [] : bulletRader(rader.slice(i + 1).join('\n'))
321
467
  }
322
468
 
323
- /** precheck.mjs stdout: KANDIDAT, AVVIKELSE, VARNING, sedan OK eller
324
- * BRISTER (n) + "- "-rader. */
469
+ /** precheck.mjs stdout: KANDIDAT, AVVIKELSE (+ AVVIKELSE-TEXT), DESIGN,
470
+ * VARNING, sedan OK eller BRISTER (n) + "- "-rader. */
325
471
  export function tolkaPrecheck(stdout, status) {
326
472
  const kandidater = raderMed(stdout, 'KANDIDAT: ')
327
473
  const avvikelser = raderMed(stdout, 'AVVIKELSE: ')
@@ -329,6 +475,8 @@ export function tolkaPrecheck(stdout, status) {
329
475
  ok: status === 0,
330
476
  kandidat: kandidater[0] ?? null,
331
477
  avvikelse: avvikelser[0] ?? null,
478
+ avvikelseText: raderMed(stdout, 'AVVIKELSE-TEXT: ')[0] ?? null,
479
+ design: raderMed(stdout, 'DESIGN: ')[0] ?? null,
332
480
  brister: raderEfter(stdout, /^BRISTER \(\d+\):/),
333
481
  varningar: raderMed(stdout, 'VARNING: '),
334
482
  prisavgorande: raderMed(stdout, 'PRISAVGORANDE: ')[0] ?? null,
@@ -538,13 +686,24 @@ export const behoverDubbelkorning = (doc) =>
538
686
  const logg = (rad) =>
539
687
  process.stderr.write(`[korning] ${new Date().toISOString().slice(11, 19)} ${rad}\n`)
540
688
 
541
- const arkiv = (args, ctx) =>
689
+ const arkivSpawn = (args, ctx) =>
542
690
  spawnSync(process.execPath, [ARCHIVE, ...args], { encoding: 'utf8', cwd: ctx.repoRot })
543
- const script = (namn, args, ctx) =>
691
+ const scriptSpawn = (namn, args, ctx) =>
544
692
  spawnSync(process.execPath, [path.join(HAR, namn), ...args], {
545
693
  encoding: 'utf8',
546
694
  cwd: ctx.repoRot,
547
695
  })
696
+ /** Varje archive.mjs- och scriptanrop går genom de här två, så ett test kan
697
+ * byta ut körningen på ett ställe (`ctx.arkivKor`, `ctx.scriptKor`); main
698
+ * sätter dem aldrig. */
699
+ const arkiv = (args, ctx) => {
700
+ const kor = ctx.arkivKor ?? arkivSpawn
701
+ return kor(args, ctx)
702
+ }
703
+ const script = (namn, args, ctx) => {
704
+ const kor = ctx.scriptKor ?? scriptSpawn
705
+ return kor(namn, args, ctx)
706
+ }
548
707
  const felText = (r) => (r.stderr || r.stdout || '').trim() || `exit ${r.status}`
549
708
  const idag = () => new Date().toISOString().slice(0, 10)
550
709
  const lasFil = (p) => {
@@ -570,10 +729,21 @@ export function tolkaArgv(argv) {
570
729
  i++
571
730
  enkla[namn] = v
572
731
  }
573
- const obligatoriska = ['katalog', 'exp', 'repo-rot', 'request-id', 'modell']
732
+ // Läget (ADR-0155): exakt en av --post (produktion) och --exp (experiment).
733
+ // Med --post kommer id:t ur postens state.json, så --request-id vore ett
734
+ // andra svar på samma fråga och vägras i stället för att tyst vinna.
735
+ const produktion = enkla.post !== undefined
736
+ if (produktion && enkla.exp !== undefined) return { fel: '--post och --exp utesluter varandra' }
737
+ if (!produktion && enkla.exp === undefined) return { fel: 'kräver --post eller --exp' }
738
+ if (produktion && enkla['request-id'] !== undefined)
739
+ return { fel: '--request-id ges bara med --exp, med --post kommer id:t ur state.json' }
740
+ if (!produktion && enkla.startare !== undefined) return { fel: '--startare ges bara med --post' }
741
+ const obligatoriska = ['katalog', 'repo-rot', 'modell', ...(produktion ? [] : ['request-id'])]
574
742
  const saknas = obligatoriska.filter((n) => enkla[n] === undefined)
575
743
  if (saknas.length > 0) return { fel: `saknar ${saknas.map((n) => `--${n}`).join(', ')}` }
576
- const relativa = ['katalog', 'exp', 'repo-rot'].filter((n) => !path.isAbsolute(enkla[n]))
744
+ const relativa = ['katalog', produktion ? 'post' : 'exp', 'repo-rot'].filter(
745
+ (n) => !path.isAbsolute(enkla[n]),
746
+ )
577
747
  if (relativa.length > 0)
578
748
  return { fel: `${relativa.map((n) => `--${n}`).join(', ')} måste vara absoluta sökvägar` }
579
749
  const takText = enkla['delar-parallellt'] ?? String(STANDARD_DELAR_PARALLELLT)
@@ -585,16 +755,33 @@ export function tolkaArgv(argv) {
585
755
  const effort = enkla.effort ?? STANDARD_EFFORT
586
756
  if (!EFFORTNIVAER.includes(effort))
587
757
  return { fel: `--effort måste vara en av ${EFFORTNIVAER.join(', ')} (fick "${effort}")` }
758
+ if (enkla.sprak !== undefined && !SPRAKEN.includes(enkla.sprak))
759
+ return { fel: `--sprak måste vara ${SPRAKEN.join(' eller ')} (fick "${enkla.sprak}")` }
760
+ if (enkla['design-mode'] !== undefined && !DESIGNLAGEN.includes(enkla['design-mode']))
761
+ return {
762
+ fel: `--design-mode måste vara ${DESIGNLAGEN.join(' eller ')} (fick "${enkla['design-mode']}")`,
763
+ }
764
+ if (
765
+ enkla.startare !== undefined &&
766
+ (!/^\d+$/.test(enkla.startare) || Number(enkla.startare) <= 0)
767
+ )
768
+ return { fel: `--startare måste vara ett heltal > 0 (fick "${enkla.startare}")` }
588
769
  return {
589
770
  katalog: enkla.katalog,
590
- exp: enkla.exp,
771
+ exp: enkla.exp ?? null,
772
+ post: enkla.post ?? null,
591
773
  repoRot: enkla['repo-rot'],
592
- requestId: enkla['request-id'],
774
+ requestId: enkla['request-id'] ?? null,
593
775
  modell: enkla.modell,
776
+ sprak: enkla.sprak ?? null,
777
+ // null betyder "ingen flagga": källans eget värde gäller (experiment.json
778
+ // design_mode), annars intervju. Samma regel som --sprak.
779
+ designMode: enkla['design-mode'] ?? null,
594
780
  claude: enkla.claude ?? 'claude',
595
781
  delarParallellt: Number(takText),
596
782
  stegTimeout: Number(timeoutText),
597
783
  effort,
784
+ startare: enkla.startare === undefined ? null : Number(enkla.startare),
598
785
  }
599
786
  }
600
787
 
@@ -607,11 +794,16 @@ export function lasUnderlag(val) {
607
794
  } catch (e) {
608
795
  return { fel: `kunde inte läsa ${path.join(val.exp, 'experiment.json')}: ${e.message}` }
609
796
  }
610
- const sprak = experiment?.sprak
797
+ const sprak = val.sprak ?? experiment?.sprak
611
798
  if (sprak !== 'svenska' && sprak !== 'engelska')
612
799
  return {
613
800
  fel: 'sprak saknas i experiment.json, sätt med archive.mjs experiment --satt sprak=svenska|engelska',
614
801
  }
802
+ const designMode = val.designMode ?? experiment?.design_mode ?? STANDARD_DESIGNLAGE
803
+ if (!DESIGNLAGEN.includes(designMode))
804
+ return {
805
+ fel: `design_mode i experiment.json måste vara ${DESIGNLAGEN.join(' eller ')} (fick "${designMode}")`,
806
+ }
615
807
  const onskemal = lasFil(path.join(val.exp, 'onskemal.md'))
616
808
  const manifest = lasFil(path.join(val.exp, 'kallmanifest.md'))
617
809
  // Designen är ett valfritt pinnat underlag som manifestet (designa, spec
@@ -632,6 +824,7 @@ export function lasUnderlag(val) {
632
824
  manifest,
633
825
  design,
634
826
  sprak,
827
+ designMode,
635
828
  eskalerad: experiment.eskalerad === true,
636
829
  eskaleringsorsak:
637
830
  typeof experiment.eskaleringsorsak === 'string' ? experiment.eskaleringsorsak : null,
@@ -639,6 +832,52 @@ export function lasUnderlag(val) {
639
832
  }
640
833
  }
641
834
 
835
+ /** Underlaget i produktionsläget (ADR-0155 beslut 2), ur postens egen
836
+ * katalog och aldrig ur experiment.json. Posten måste vara godkänd
837
+ * (`approval` satt av bedöm-grinden) och får inte vara en experimentkatalog.
838
+ * Språket kommer ur `--sprak` eller manifestets `SPRÅK:`-rad och gissas
839
+ * aldrig (ADR-0114, #521). `granskarblock.md`, `kallmanifest.md` och
840
+ * `design.md` är valfria; `spec.md` krävs. Designläget är `--design-mode`
841
+ * eller intervju (ADR-0156). Den ursprungliga förfrågan till designern
842
+ * läses bara ur `onskemal.md` i posten, annars null (prompten säger då att
843
+ * den saknas i arkivet). */
844
+ export function lasUnderlagProduktion({ post, repoRot, sprak = null, designMode = null }) {
845
+ const statefil = path.join(post, 'state.json')
846
+ let state
847
+ try {
848
+ state = JSON.parse(readFileSync(statefil, 'utf8'))
849
+ } catch (e) {
850
+ return { fel: `kunde inte läsa ${statefil}: ${e.message}` }
851
+ }
852
+ if (state === null || typeof state !== 'object' || Array.isArray(state))
853
+ return { fel: `${statefil} är inget objekt` }
854
+ if (state.experiment === true) return { fel: 'experimentkatalog: kör utvärdera' }
855
+ if (
856
+ state.approval === null ||
857
+ typeof state.approval !== 'object' ||
858
+ Array.isArray(state.approval)
859
+ )
860
+ return { fel: 'approval saknas: bedöm-grinden har inte körts (archive.mjs approve)' }
861
+ const spec = lasFil(path.join(post, 'spec.md'))
862
+ if (spec === null) return { fel: `${post} saknar spec.md` }
863
+ const manifest = lasFil(path.join(post, 'kallmanifest.md'))
864
+ const sprakVald = sprak ?? (manifest === null ? null : sprakUrManifest(manifest))
865
+ if (sprakVald === null)
866
+ return { fel: 'SPRÅK saknas i kallmanifest.md: sätt raden eller ge --sprak' }
867
+ return {
868
+ state,
869
+ spec,
870
+ granskarblock: lasFil(path.join(post, 'granskarblock.md')) ?? '',
871
+ manifest: manifest ?? 'inga',
872
+ design: lasFil(path.join(post, 'design.md')),
873
+ onskemal: lasFil(path.join(post, 'onskemal.md')),
874
+ designMode: designMode ?? STANDARD_DESIGNLAGE,
875
+ sprak: sprakVald,
876
+ deps: avgorDeps(repoRot),
877
+ requestId: katalogId(state, post),
878
+ }
879
+ }
880
+
642
881
  /** Orsaken till att ett agentsteg räknas som fallet, eller null. Ordningen är
643
882
  * från grövst till finast: en process som inte startade säger inget om
644
883
  * modellen, en tom text säger inget om innehållet. `tidsgransMs` sätts när
@@ -701,7 +940,13 @@ export function raknaSamtidiga(kor = spawnSync) {
701
940
  * så signalhanteraren kan döda det: en dödad korning.mjs får aldrig lämna
702
941
  * betalda `claude`-processer efter sig. Väggtidstaket (`--steg-timeout`)
703
942
  * dödar ett steg som hänger utan att svara. Anroparen avgör vad ett fallet
704
- * steg betyder. */
943
+ * steg betyder.
944
+ *
945
+ * `ctx.korAgent` är testsömmen (ADR-0111 beslut 4: inget fejkat `claude`):
946
+ * den ersätter BARA processen, får stegets argument och returnerar (eller
947
+ * löser) `{ stdout, exitCode }`, processens råa utskrift. Allt efter
948
+ * processen är detsamma som i en riktig körning: steg/-filen, tolkningen,
949
+ * modellverifieringen och stegposten via `ctx.samla`. main sätter den aldrig. */
705
950
  export function korAgent({ agent, modell, prompt, fil, maxTurer, ctx }) {
706
951
  return new Promise((los) => {
707
952
  const start = Date.now()
@@ -740,6 +985,18 @@ export function korAgent({ agent, modell, prompt, fil, maxTurer, ctx }) {
740
985
  if (nekade > 0) logg(`STEG ${fil} ${nekade} nekade verktygsanrop`)
741
986
  los({ ...t, exitCode, wall_ms, ok: t.orsak === null })
742
987
  }
988
+ if (ctx.korAgent) {
989
+ Promise.resolve()
990
+ .then(() => ctx.korAgent({ agent, modell, prompt, fil, maxTurer, ctx }))
991
+ .then(
992
+ (r) => {
993
+ stdout = String(r?.stdout ?? '')
994
+ avsluta({ exitCode: r?.exitCode ?? 0 })
995
+ },
996
+ (e) => avsluta({ exitCode: null, spawnfel: e?.message ?? String(e) }),
997
+ )
998
+ return
999
+ }
743
1000
  let barn
744
1001
  try {
745
1002
  barn = spawn(ctx.claude, agentArgs({ agent, modell, effort: ctx.effort, maxTurer }), {
@@ -824,11 +1081,13 @@ export const addRunArgs = (dir, rad, effort = STANDARD_EFFORT) => [
824
1081
  ...(rad.note ? ['--note', rad.note] : []),
825
1082
  ...(rad.doubleRun ? ['--double-run', rad.doubleRun] : []),
826
1083
  ...(rad.supersededBy ? ['--superseded-by', rad.supersededBy] : []),
1084
+ ...(rad.repoSha ? ['--repo-sha', rad.repoSha] : []),
827
1085
  ]
828
1086
 
829
1087
  /** En runs-rad. Arkivet är append-only och äger sina egna vägransregler; en
830
1088
  * vägrad stämpling loggas men fäller aldrig körningen, kvittot på vad som
831
- * hände ligger kvar i steg/. */
1089
+ * hände ligger kvar i steg/. Anropet går genom `arkiv`, alltså genom
1090
+ * testsömmen `ctx.arkivKor`. */
832
1091
  function addRun(dir, rad, ctx) {
833
1092
  const r = arkiv(addRunArgs(dir, rad, ctx.effort), ctx)
834
1093
  if (r.status !== 0) logg(`VARNING add-run ${rad.fil}: ${felText(r)}`)
@@ -994,17 +1253,26 @@ function skrivSpec(del, spec, granskarblock, meta, ctx) {
994
1253
  }
995
1254
 
996
1255
  /** Delens namn i loggen och delraderna: sökvägen relativt körningskatalogen,
997
- * eller katalognamnet när delen ÄR körningsroten. */
998
- const delNamn = (ctx, dir) => path.relative(ctx.katalog, dir) || path.basename(dir)
1256
+ * eller katalognamnet när delen ÄR körningsroten eller ligger utanför den
1257
+ * (produktionsläget: posten är körningskatalogens förälder, ADR-0155). */
1258
+ const delNamn = (ctx, dir) => {
1259
+ const rel = path.relative(ctx.katalog, dir)
1260
+ return rel && !rel.startsWith('..') ? rel : path.basename(dir)
1261
+ }
999
1262
 
1000
1263
  /** Ett precheck-anrop: varningarna loggas, brister och kandidat tolkas, och en
1001
1264
  * exitkod skild från 0 UTAN tolkbart BRISTER-block är ett fel, aldrig ett
1002
1265
  * godkänt steg. En grind som failar öppet när den själv kraschar är ingen
1003
1266
  * grind. */
1004
- function korPrecheck(del, ctx, { impact = false } = {}) {
1005
- const r = script('precheck.mjs', [del.dir, ...(impact ? ['--impact'] : [])], ctx)
1267
+ function korPrecheck(del, ctx, { impact = false, design = false, utkast = null } = {}) {
1268
+ const flaggor = [
1269
+ ...(impact ? ['--impact'] : []),
1270
+ ...(design ? ['--design'] : []),
1271
+ ...(utkast ? ['--design-utkast', utkast, '--repo-rot', ctx.repoRot] : []),
1272
+ ]
1273
+ const r = script('precheck.mjs', [del.dir, ...flaggor], ctx)
1006
1274
  const tolkad = tolkaPrecheck(r.stdout, r.status)
1007
- const etikett = `precheck${impact ? ' --impact' : ''}`
1275
+ const etikett = ['precheck', ...flaggor].join(' ')
1008
1276
  for (const v of tolkad.varningar) logg(`VARNING ${etikett} ${delNamn(ctx, del.dir)}: ${v}`)
1009
1277
  if (tolkad.ok || tolkad.brister.length > 0) return { ...tolkad, fel: null }
1010
1278
  const svans = (r.stderr || r.stdout || '').trim().split('\n').slice(-3).join(' | ').slice(0, 200)
@@ -1113,13 +1381,323 @@ async function specOmdispatch(del, spec, brister, ctx) {
1113
1381
  /** Designen in i delens katalog som design.md före assessorsteget, så att
1114
1382
  * add-run hashar den i impact-radens underlag_sha256 och precheck --impact
1115
1383
  * prövar proveniensen (designa, spec 2026-09-24). Byteidentisk kopia av
1116
- * ctx.design; utan design skrivs inget och false returneras. */
1384
+ * ctx.design; utan design skrivs inget och false returneras. I
1385
+ * produktionsläget ÄR ctx.design postens egen design.md, så en fil som
1386
+ * redan bär exakt de bytena skrivs aldrig om. */
1117
1387
  export function laggDesignIDel(del, ctx) {
1118
1388
  if (ctx.design == null) return false
1119
- writeFileSync(path.join(del.dir, 'design.md'), ctx.design)
1389
+ const mal = path.join(del.dir, 'design.md')
1390
+ if (lasFil(mal) !== ctx.design) writeFileSync(mal, ctx.design)
1120
1391
  return true
1121
1392
  }
1122
1393
 
1394
+ // ---------------------------------------------------------------------------
1395
+ // Draft-läget (ADR-0156): designern skriver, granskaren läser, före assessorn
1396
+ // ---------------------------------------------------------------------------
1397
+
1398
+ /** En splitdel i produktionen ärver förälderns design och designas aldrig. */
1399
+ const arSplitdel = (ctx) => Boolean(ctx.post) && (ctx.state?.split?.parent ?? null) !== null
1400
+
1401
+ /** Designern körs bara i draft-läget, på en del som saknar design: ingen
1402
+ * design i ctx (experimentets pinnade design.md eller postens egen), ingen
1403
+ * design.md i delen och ingen splitdel. Annars är steget en no-op. */
1404
+ const designernGaller = (del, ctx) =>
1405
+ ctx.designMode === 'draft' &&
1406
+ ctx.design == null &&
1407
+ !existsSync(path.join(del.dir, 'design.md')) &&
1408
+ !arSplitdel(ctx)
1409
+
1410
+ /** Specens första rubrik, utan #-tecknen; slugen i designa-sökvägen. */
1411
+ const forstaRubrikI = (text) => {
1412
+ const m = /^#{1,6}\s+(.+?)\s*#*\s*$/mu.exec(String(text))
1413
+ return m ? m[1] : 'design'
1414
+ }
1415
+
1416
+ const INTERVJU_RE = /^INTERVJU KRÄVS:\s*(.*)$/u
1417
+
1418
+ /** Designern (ADR-0156 beslut 1 och 6): ett agentsteg som skriver ett utkast
1419
+ * eller svarar `INTERVJU KRÄVS:`. Returnerar `{ status: null }` när ett
1420
+ * utkast bokfördes (eller steget inte gäller), `stopp` på INTERVJU KRÄVS
1421
+ * (inget i repot; produktionen skriver kvittot och ESKALERA-stoppet, se
1422
+ * stoppaIntervju, experimentet bara raden) och på designkonflikter
1423
+ * (kvittot och stoppet som designa steg 5), `ofärdig` på allt annat. */
1424
+ export async function korDesigner(del, ctx) {
1425
+ if (!designernGaller(del, ctx)) return { status: null }
1426
+ const specFil = path.join(del.dir, 'spec.md')
1427
+ const spec = lasFil(specFil)
1428
+ if (spec === null) return { status: 'ofärdig', orsak: 'design: spec.md saknas' }
1429
+ const datum = idag()
1430
+ const prompt = designerPrompt({
1431
+ requestId: ctx.requestId,
1432
+ specSha: markerStrippedSha256(specFil),
1433
+ datum,
1434
+ onskemal: ctx.onskemal ?? null,
1435
+ spec,
1436
+ granskarblock: lasFil(path.join(del.dir, 'granskarblock.md')) ?? 'inget granskarblock',
1437
+ manifest: ctx.manifest,
1438
+ repoRot: ctx.repoRot,
1439
+ deps: ctx.deps,
1440
+ sprak: ctx.sprak,
1441
+ konfigPath: path.join(ctx.repoRot, '.haus-workflow', 'workflow-config.md'),
1442
+ })
1443
+ const res = await korAgent({
1444
+ agent: 'request-designer',
1445
+ modell: ctx.modell,
1446
+ prompt,
1447
+ fil: `steg/1${del.nr}-designer.json`,
1448
+ maxTurer: MAX_TURER.designer,
1449
+ ctx,
1450
+ })
1451
+ if (!res.ok) return { status: 'ofärdig', orsak: `design: ${res.orsak}` }
1452
+ const svar = utanYttreStaket(res.text)
1453
+ const intervju = INTERVJU_RE.exec(svar.trim().split('\n')[0].trim())
1454
+ if (intervju) {
1455
+ const orsak = `intervju krävs: ${intervju[1].trim()}`
1456
+ logg(`STOPP ${delNamn(ctx, del.dir)} ${orsak}`)
1457
+ if (ctx.post) return stoppaIntervju(del, intervju[1].trim(), parseSvansrader(svar).modell, ctx)
1458
+ return { status: 'stopp', orsak }
1459
+ }
1460
+ const dok = parseSvansrader(svar).body.trim() + '\n'
1461
+ return ctx.post ? bokforUtkast(del, dok, datum, spec, ctx) : skrivUtkastIDel(del, dok, ctx)
1462
+ }
1463
+
1464
+ /** Ett yttre kodstaket runt hela svaret (en första rad med tre eller fler
1465
+ * backticks och en valfri språktagg, en sista rad med samma staket) tas
1466
+ * bort, ETT lager; annat lämnas orört. Utan det fick frontmattern null och
1467
+ * add-design vägrade ett annars giltigt utkast. */
1468
+ export const utanYttreStaket = (text) => {
1469
+ const rader = String(text).replace(/\r\n/g, '\n').trim().split('\n')
1470
+ const forsta = /^(`{3,})[\w-]*\s*$/.exec(rader[0] ?? '')
1471
+ if (!forsta || rader.length < 2) return String(text)
1472
+ // Svansraderna (MODELL: m.fl.) kan stå efter staketet; de följer med ut.
1473
+ let slut = rader.length - 1
1474
+ const svans = []
1475
+ while (slut > 0 && /^(MODELL|LÄSTA|AVVIKELSE):|^\s*$/u.test(rader[slut]))
1476
+ svans.unshift(rader[slut--])
1477
+ if (rader[slut].trim() !== forsta[1]) return String(text)
1478
+ return [...rader.slice(1, slut), ...svans].join('\n') + '\n'
1479
+ }
1480
+
1481
+ /** INTERVJU KRÄVS i produktionen (P4-24): svaret skrivs som kvittot
1482
+ * design-intervju-kravs-<datum>.md (-2 vid kollision, som
1483
+ * design-avvisad-<datum>.md) och posten stoppas som ESKALERA på det via
1484
+ * archive.mjs stopp, så nästa batch inte betalar designern igen. Inget
1485
+ * skrivs i repot och state.design rörs inte; designa löser stoppet. */
1486
+ function stoppaIntervju(del, skal, modell, ctx) {
1487
+ const orsak = `intervju krävs: ${skal}`
1488
+ const kvitto = ledigtNamn(del.dir, `design-intervju-kravs-${idag()}`, '.md')
1489
+ const rader = [`INTERVJU KRÄVS: ${skal}`, ...(modell ? [`MODELL: ${modell}`] : [])]
1490
+ writeFileSync(path.join(del.dir, kvitto), rader.join('\n') + '\n')
1491
+ const s = arkiv(
1492
+ ['stopp', del.dir, '--utfall', 'ESKALERA', '--kalla', kvitto, '--orsak', orsak],
1493
+ ctx,
1494
+ )
1495
+ if (s.status !== 0) return { status: 'ofärdig', orsak: `stopp: ${felText(s)}` }
1496
+ logg(`STOPP ${delNamn(ctx, del.dir)} ESKALERA ur ${kvitto}`)
1497
+ return { status: 'stopp', orsak }
1498
+ }
1499
+
1500
+ /** Repots formatter före bokföringen (designa steg 5): en format-on-commit
1501
+ * får aldrig ändra bytes efter add-design. Bara när repot har prettier; ett
1502
+ * fel är en varning, add-design och precheck --design prövar sedan bytes. */
1503
+ function formatera(abs, ctx) {
1504
+ const bin = path.join(ctx.repoRot, 'node_modules', '.bin', 'prettier')
1505
+ if (!existsSync(bin)) return
1506
+ const r = spawnSync(bin, ['--write', abs], { cwd: ctx.repoRot, encoding: 'utf8', timeout: 60000 })
1507
+ if (r.error || r.status !== 0) logg(`VARNING prettier ${abs}: ${r.error?.message ?? felText(r)}`)
1508
+ }
1509
+
1510
+ /** Produktionen: dokumentet i repot på designa-sökvägen (ett ledigt namn,
1511
+ * aldrig en överskrivning), formaterat, bokfört med add-design och prövat
1512
+ * med precheck --design. När add-design vägrar (utom konfliktstoppet) tas
1513
+ * exakt den fil körningen skrev bort (och de kataloger den skapade, när de
1514
+ * är tomma), så arbetskopian står som före körningen; det råa svaret under
1515
+ * steg/ är kvittot. Konfliktstoppet behåller filen, som designa steg 5. Ett
1516
+ * bokfört utkast tas aldrig bort: state.design pekar då på filen, och en
1517
+ * BRIST i precheck --design efteråt är ofärdig med filen kvar. */
1518
+ function bokforUtkast(del, dok, datum, spec, ctx) {
1519
+ const rel = designFilnamn({ datum, requestId: ctx.requestId, titel: forstaRubrikI(spec) })
1520
+ const katalog = path.dirname(path.join(ctx.repoRot, rel))
1521
+ const skapad = mkdirSync(katalog, { recursive: true }) ?? null
1522
+ const abs = path.join(katalog, ledigtNamn(katalog, path.basename(rel, '.md'), '.md'))
1523
+ writeFileSync(abs, dok)
1524
+ const ofardig = (orsak) => {
1525
+ stadaUtkast(abs, skapad)
1526
+ return { status: 'ofärdig', orsak }
1527
+ }
1528
+ formatera(abs, ctx)
1529
+ const r = arkiv(['add-design', del.dir, '--file', abs], ctx)
1530
+ for (const v of raderMed(r.stderr ?? '', 'VARNING: '))
1531
+ logg(`VARNING add-design ${delNamn(ctx, del.dir)}: ${v}`)
1532
+ if (r.status !== 0) {
1533
+ const text = lasFil(abs) ?? dok
1534
+ const konflikter = specConflicts(text) ?? []
1535
+ if (konflikter.length > 0 && /"## Spec conflicts" är inte none/u.test(r.stderr ?? ''))
1536
+ return stoppaDesignkonflikt(del, text, konflikter, ctx)
1537
+ return ofardig(`add-design: ${felText(r)}`)
1538
+ }
1539
+ logg(`DESIGN ${delNamn(ctx, del.dir)} utkast bokfört: ${path.relative(ctx.repoRot, abs)}`)
1540
+ const p = korPrecheck(del, ctx, { design: true })
1541
+ if (p.fel) return { status: 'ofärdig', orsak: p.fel }
1542
+ if (p.brister.length > 0) return { status: 'ofärdig', orsak: `design: ${bristText(p.brister)}` }
1543
+ return { status: null }
1544
+ }
1545
+
1546
+ /** Tar bort ett vägrat utkast: filen på sin exakta sökväg, sedan uppåt varje
1547
+ * katalog som den här körningen skapade (`skapad` är mkdirSync:s första
1548
+ * skapade katalog, null när alla fanns) så länge den är tom. Ett fel loggas,
1549
+ * det fäller aldrig körningen. */
1550
+ function stadaUtkast(abs, skapad) {
1551
+ try {
1552
+ rmSync(abs, { force: true })
1553
+ if (skapad === null) return
1554
+ for (let d = path.dirname(abs); ; d = path.dirname(d)) {
1555
+ if (readdirSync(d).length > 0) break
1556
+ rmSync(d, { recursive: true, force: true })
1557
+ if (d === skapad) break
1558
+ }
1559
+ } catch (e) {
1560
+ logg(`VARNING kunde inte ta bort det vägrade utkastet ${abs}: ${e.message}`)
1561
+ }
1562
+ }
1563
+
1564
+ /** Experimentkanalen: utkastet som design.md i delen, prövat med precheck
1565
+ * --design-utkast (sektionerna och symbolerna, symbolerna i målrepot via
1566
+ * --repo-rot: delen ligger i arkivets repo). Ett fallet utkast läggs undan
1567
+ * som design-utkast-avvisad-<datum>.md, så assessorn aldrig läser det. */
1568
+ function skrivUtkastIDel(del, dok, ctx) {
1569
+ const konflikter = specConflicts(dok) ?? []
1570
+ if (konflikter.length > 0) return stoppaDesignkonflikt(del, dok, konflikter, ctx)
1571
+ const fil = path.join(del.dir, 'design.md')
1572
+ writeFileSync(fil, dok)
1573
+ const p = korPrecheck(del, ctx, { utkast: fil })
1574
+ if (!p.fel && p.brister.length === 0) {
1575
+ logg(`DESIGN ${delNamn(ctx, del.dir)} utkast skrivet`)
1576
+ return { status: null }
1577
+ }
1578
+ try {
1579
+ renameSync(
1580
+ fil,
1581
+ path.join(del.dir, ledigtNamn(del.dir, `design-utkast-avvisad-${idag()}`, '.md')),
1582
+ )
1583
+ } catch (e) {
1584
+ logg(`VARNING kunde inte lägga undan design.md: ${e.message}`)
1585
+ }
1586
+ return { status: 'ofärdig', orsak: p.fel ?? `design: ${bristText(p.brister)}` }
1587
+ }
1588
+
1589
+ /** Designkonflikter (designa steg 5): dokumentet kopieras in som kvittot
1590
+ * design-avvisad-<datum>.md (-2 vid kollision) och posten stoppas på det,
1591
+ * TILLBAKA_TILL_SPEC eller ESKALERA efter en respec, med konfliktraderna som
1592
+ * orsak. Dokumentet i repot ligger kvar under sitt eget namn. */
1593
+ function stoppaDesignkonflikt(del, text, konflikter, ctx) {
1594
+ const kvitto = ledigtNamn(del.dir, `design-avvisad-${idag()}`, '.md')
1595
+ writeFileSync(path.join(del.dir, kvitto), text)
1596
+ const state = lasJson(path.join(del.dir, 'state.json'))
1597
+ const utfall = (state?.respec_varv ?? 0) >= 1 ? 'ESKALERA' : 'TILLBAKA_TILL_SPEC'
1598
+ const rader = konflikter.map((k) => k.replace(/^[-*]\s+/, ''))
1599
+ const orsak = rader.join('; ')
1600
+ const s = arkiv(['stopp', del.dir, '--utfall', utfall, '--kalla', kvitto, '--orsak', orsak], ctx)
1601
+ if (s.status !== 0) return { status: 'ofärdig', orsak: `stopp: ${felText(s)}` }
1602
+ logg(`STOPP ${delNamn(ctx, del.dir)} ${utfall} ur ${kvitto}`)
1603
+ return { status: 'stopp', orsak: `designkonflikter (${rader.length}): ${orsak}` }
1604
+ }
1605
+
1606
+ /** Granskarens första rad, `fortsätt`, `stopp` eller null (ett brott). */
1607
+ export const tolkaVerdict = (text) => {
1608
+ const forsta = String(text).replace(/\r\n/g, '\n').trimStart().split('\n')[0].trim()
1609
+ return /^VERDICT: (fortsätt|stopp)$/u.exec(forsta)?.[1] ?? null
1610
+ }
1611
+ const verdictFel = (text) =>
1612
+ `- första raden måste vara "VERDICT: fortsätt" eller "VERDICT: stopp" (fick "${String(text).trimStart().split('\n')[0].trim().slice(0, 80)}")`
1613
+
1614
+ /** Första fyndet under `## Findings`, utan listmarkör. */
1615
+ const forstaFynd = (text) => {
1616
+ const punkt = (sectionLines(text, 'Findings') ?? [])
1617
+ .map((r) => r.trim())
1618
+ .find((r) => r.startsWith('- ') || r.startsWith('* '))
1619
+ return punkt ? punkt.slice(2).trim() : 'inga fynd angivna'
1620
+ }
1621
+
1622
+ /** Granskaren körs bara efter ett bokfört utkast: draft-läget, ingen
1623
+ * splitdel, design.md i delen med `mode: draft`, och ingen granskning än
1624
+ * (produktionen: state.design satt utan review; experimentet: ingen
1625
+ * design-review.md i delen). Ett intervjudokument granskas aldrig, och ett
1626
+ * dokument med ett kvitto (granskarens eller människans godkännande via
1627
+ * add-design-review) granskas aldrig igen: ett utslag omdispatchas inte
1628
+ * (P4-23). */
1629
+ const granskarenGaller = (del, ctx) => {
1630
+ if (ctx.designMode !== 'draft' || arSplitdel(ctx)) return false
1631
+ const design = lasFil(path.join(del.dir, 'design.md'))
1632
+ if (design === null || designModeFor(design) !== 'draft') return false
1633
+ if (!ctx.post) return !existsSync(path.join(del.dir, 'design-review.md'))
1634
+ const state = lasJson(path.join(del.dir, 'state.json'))
1635
+ return state?.design != null && state.design.review == null
1636
+ }
1637
+
1638
+ /** Designgranskaren (ADR-0156 beslut 3): ett agentsteg vars första rad är
1639
+ * `VERDICT: fortsätt|stopp`. Annat är ett scriptbevisat brott: EN
1640
+ * omdispatch med felraden, sedan ofärdig. Svaret utan svansraden skrivs som
1641
+ * design-review.md: produktionen i körningskatalogen och via
1642
+ * add-design-review (MODELL-svansen som --model), experimentet i delen.
1643
+ * `stopp` stoppar raden med det första fyndet. */
1644
+ export async function korDesignReviewer(del, ctx) {
1645
+ if (!granskarenGaller(del, ctx)) return { status: null }
1646
+ const prompt = designReviewerPrompt({
1647
+ spec: lasFil(path.join(del.dir, 'spec.md')) ?? '',
1648
+ granskarblock: lasFil(path.join(del.dir, 'granskarblock.md')) ?? 'inget granskarblock',
1649
+ design: lasFil(path.join(del.dir, 'design.md')),
1650
+ repoRot: ctx.repoRot,
1651
+ deps: ctx.deps,
1652
+ sprak: ctx.sprak,
1653
+ })
1654
+ const bas = `steg/1${del.nr}-design-reviewer`
1655
+ const steg = (p, fil) =>
1656
+ korAgent({
1657
+ agent: 'request-design-reviewer',
1658
+ modell: ctx.modell,
1659
+ prompt: p,
1660
+ fil,
1661
+ maxTurer: MAX_TURER.designgranskare,
1662
+ ctx,
1663
+ })
1664
+ let res = await steg(prompt, `${bas}.json`)
1665
+ if (!res.ok) return { status: 'ofärdig', orsak: `designgranskning: ${res.orsak}` }
1666
+ let verdict = tolkaVerdict(res.text)
1667
+ if (verdict === null) {
1668
+ const felrad = verdictFel(res.text)
1669
+ logg(`OMDISPATCH ${delNamn(ctx, del.dir)} designgranskning: ${felrad.slice(2)}`)
1670
+ res = await steg(omdispatchPrompt(prompt, [felrad]), `${bas}-omdispatch.json`)
1671
+ if (!res.ok) return { status: 'ofärdig', orsak: `designgranskning: ${res.orsak}` }
1672
+ verdict = tolkaVerdict(res.text)
1673
+ if (verdict === null)
1674
+ return { status: 'ofärdig', orsak: `designgranskning: ${verdictFel(res.text).slice(2)}` }
1675
+ }
1676
+ const { body, modell } = parseSvansrader(res.text)
1677
+ const dok = body.trim() + '\n'
1678
+ const mal = path.join(ctx.post ? ctx.katalog : del.dir, 'design-review.md')
1679
+ writeFileSync(mal, dok)
1680
+ if (ctx.post) {
1681
+ const r = arkiv(
1682
+ [
1683
+ 'add-design-review',
1684
+ del.dir,
1685
+ '--file',
1686
+ mal,
1687
+ '--verdict',
1688
+ verdict,
1689
+ '--model',
1690
+ modell ?? fakturerad(res.svar, ctx.modell) ?? 'okänd',
1691
+ ],
1692
+ ctx,
1693
+ )
1694
+ if (r.status !== 0) return { status: 'ofärdig', orsak: `add-design-review: ${felText(r)}` }
1695
+ }
1696
+ logg(`DESIGNGRANSKNING ${delNamn(ctx, del.dir)} ${verdict}`)
1697
+ if (verdict === 'stopp') return { status: 'stopp', orsak: `designgranskning: ${forstaFynd(dok)}` }
1698
+ return { status: null }
1699
+ }
1700
+
1123
1701
  /** Analysen: ett agentsteg, impact.md med runs-rad, formkontroll med EN
1124
1702
  * omdispatch, och motsägelse mot specen som stopputfall (ADR-0106). */
1125
1703
  async function korAssessor(del, ctx) {
@@ -1133,7 +1711,9 @@ async function korAssessor(del, ctx) {
1133
1711
  spec,
1134
1712
  granskarblock: lasFil(path.join(del.dir, 'granskarblock.md')) ?? 'inget granskarblock',
1135
1713
  manifest: ctx.manifest,
1136
- design: ctx.design,
1714
+ // Ett utkast från korDesigner ligger i delen men aldrig i ctx.design (ctx
1715
+ // delas av en bunts delar): det läses ur delens egen design.md.
1716
+ design: ctx.design ?? lasFil(path.join(del.dir, 'design.md')),
1137
1717
  })
1138
1718
  const fil = `steg/1${del.nr}-assessor.json`
1139
1719
  const res = await korAgent({
@@ -1161,17 +1741,37 @@ async function korAssessor(del, ctx) {
1161
1741
  }
1162
1742
  }
1163
1743
  }
1164
- if (p.avvikelse === 'motsägelse') {
1165
- const s = arkiv(
1166
- ['stopp', del.dir, '--utfall', 'TILLBAKA_TILL_SPEC', '--kalla', 'impact.md'],
1167
- ctx,
1168
- )
1169
- if (s.status !== 0) return { status: 'ofärdig', orsak: `stopp: ${felText(s)}` }
1170
- return { status: 'stopp', orsak: 'TILLBAKA_TILL_SPEC' }
1171
- }
1744
+ if (p.avvikelse === 'motsägelse') return stoppaMotsagelse(del, p, ctx)
1172
1745
  return { status: null }
1173
1746
  }
1174
1747
 
1748
+ /** En analys med etiketten motsägelse stoppar som TILLBAKA_TILL_SPEC ur
1749
+ * impact.md (ADR-0106). Börjar texten efter etiketten med `design conflict:`
1750
+ * bär stoppet den texten som `--orsak` (#596): posten går tillbaka till
1751
+ * designen, inte till specen. Texten läses ur precheck-raden
1752
+ * `AVVIKELSE-TEXT:`, aldrig ur filen. */
1753
+ function stoppaMotsagelse(del, p, ctx) {
1754
+ const orsak =
1755
+ typeof p.avvikelseText === 'string' && /^design conflict:/i.test(p.avvikelseText)
1756
+ ? p.avvikelseText
1757
+ : null
1758
+ const s = arkiv(
1759
+ [
1760
+ 'stopp',
1761
+ del.dir,
1762
+ '--utfall',
1763
+ 'TILLBAKA_TILL_SPEC',
1764
+ '--kalla',
1765
+ 'impact.md',
1766
+ ...(orsak ? ['--orsak', orsak] : []),
1767
+ ],
1768
+ ctx,
1769
+ )
1770
+ if (s.status !== 0) return { status: 'ofärdig', orsak: `stopp: ${felText(s)}` }
1771
+ logg(`STOPP ${delNamn(ctx, del.dir)} TILLBAKA_TILL_SPEC${orsak ? `: ${orsak}` : ''}`)
1772
+ return { status: 'stopp', orsak: orsak ? `TILLBAKA_TILL_SPEC: ${orsak}` : 'TILLBAKA_TILL_SPEC' }
1773
+ }
1774
+
1175
1775
  const impactRad = (svansModell, fakt) => ({
1176
1776
  skill: 'request-assess-impact',
1177
1777
  agent: 'request-assessor',
@@ -1180,15 +1780,35 @@ const impactRad = (svansModell, fakt) => ({
1180
1780
  })
1181
1781
 
1182
1782
  /** Analysens kropp (svansraderna strippade) som impact.md, med runs-rad.
1183
- * Ingen --ref: analysen har ingen referensfil att pinna. */
1184
- function skrivImpact(del, res, ctx) {
1783
+ * Ingen --ref: analysen har ingen referensfil att pinna. Raden bär
1784
+ * `--repo-sha`, checkoutens HEAD som assessorn läste, när den är känd
1785
+ * (ADR-0155). */
1786
+ export function skrivImpact(del, res, ctx) {
1185
1787
  const { body, modell: svansModell } = parseSvansrader(res.text)
1186
1788
  writeFileSync(path.join(del.dir, 'impact.md'), body.trim() + '\n')
1187
1789
  const fakt = fakturerad(res.svar, ctx.modell)
1188
- addRun(del.dir, { ...impactRad(svansModell, fakt), fil: 'impact.md' }, ctx)
1790
+ const repoSha = typeof ctx.repoSha === 'string' ? ctx.repoSha : null
1791
+ addRun(del.dir, { ...impactRad(svansModell, fakt), fil: 'impact.md', repoSha }, ctx)
1189
1792
  return { svansModell, fakt }
1190
1793
  }
1191
1794
 
1795
+ /** HEAD för checkouten assessorn läser, 40 hex, eller null vid varje fel:
1796
+ * ingen repo, git saknas, en utskrift som inte är en sha. Kastar aldrig. */
1797
+ export function repoSha(repoRot, { kor = spawnSync } = {}) {
1798
+ try {
1799
+ const r = kor('git', ['rev-parse', 'HEAD'], {
1800
+ cwd: repoRot,
1801
+ encoding: 'utf8',
1802
+ timeout: 5000,
1803
+ })
1804
+ if (r?.status !== 0) return null
1805
+ const sha = String(r.stdout ?? '').trim()
1806
+ return /^[0-9a-f]{40}$/.test(sha) ? sha : null
1807
+ } catch {
1808
+ return null
1809
+ }
1810
+ }
1811
+
1192
1812
  /** EN omdispatch av assessorn. Den avvisade analysen bokförs före omtaget. */
1193
1813
  async function impactOmdispatch(del, prompt, brister, meta, ctx) {
1194
1814
  avvisaDokument(del.dir, 'impact', brister, impactRad(meta.svansModell, meta.fakt), ctx)
@@ -1278,8 +1898,7 @@ function samlaFixsteg(fil, fixPath, ctx) {
1278
1898
  * BROTT, kedjan igen. Kvarstående brott gör körningen avvisad (aldrig hela
1279
1899
  * delen: den andra körningen kan vara giltig). Exit 2 är dispatcherfel. */
1280
1900
  async function kontrolleraScorer(del, k, res, prompt, ctx) {
1281
- const txt = path.join(del.dir, `tmp-scorer-run${k}.txt`)
1282
- const ren = path.join(del.dir, `tmp-scorer-run${k}.json`)
1901
+ const { txt, ren } = tmpFiler(del, k, ctx)
1283
1902
  const bas = `steg/2${del.nr}-scorer-run${k}`
1284
1903
  const svarsrad = (kontroll, sista) => {
1285
1904
  const doc = lasJson(ren)
@@ -1364,13 +1983,7 @@ async function korScorers(del, ctx) {
1364
1983
  for (const [i, res] of svar.entries()) {
1365
1984
  const kr = await kontrolleraScorer(del, i + 1, res, prompt, ctx)
1366
1985
  if (kr.exit2) {
1367
- stadaTmp([
1368
- ...korningar,
1369
- {
1370
- txt: path.join(del.dir, `tmp-scorer-run${i + 1}.txt`),
1371
- ren: path.join(del.dir, `tmp-scorer-run${i + 1}.json`),
1372
- },
1373
- ])
1986
+ stadaTmp([...korningar, tmpFiler(del, i + 1, ctx)])
1374
1987
  return { status: 'ofärdig', orsak: `poäng: ${kr.orsak}` }
1375
1988
  }
1376
1989
  korningar.push(kr)
@@ -1378,6 +1991,18 @@ async function korScorers(del, ctx) {
1378
1991
  return { korningar }
1379
1992
  }
1380
1993
 
1994
+ /** Kontrollkedjans arbetsfiler för körning `k`. I experimentläget ligger de
1995
+ * i delens katalog, som är körningens egen; i produktionsläget i
1996
+ * körningskatalogen, aldrig i posten, som bara arkivfiler hör hemma i
1997
+ * (ADR-0155 beslut 2). */
1998
+ const tmpFiler = (del, k, ctx) => {
1999
+ const dir = ctx.post ? ctx.katalog : del.dir
2000
+ return {
2001
+ txt: path.join(dir, `tmp-scorer-run${k}.txt`),
2002
+ ren: path.join(dir, `tmp-scorer-run${k}.json`),
2003
+ }
2004
+ }
2005
+
1381
2006
  /** Kontrollkedjans arbetsyta, aldrig arkivinnehåll: tmp-filerna tas bort när
1382
2007
  * varvet är arkiverat eller avbrutet. */
1383
2008
  const stadaTmp = (korningar) => {
@@ -1517,9 +2142,14 @@ function arkiveraVarv(del, korningar, ctx) {
1517
2142
  }
1518
2143
  }
1519
2144
 
1520
- /** En del hela vägen: analys, två scorerkörningar, arkivering. */
1521
- async function korDel(del, ctx) {
2145
+ /** En del hela vägen: utkast och granskning i draft-läget (ADR-0156), analys,
2146
+ * två scorerkörningar, arkivering. */
2147
+ export async function korDel(del, ctx) {
1522
2148
  const namn = delNamn(ctx, del.dir)
2149
+ const utkast = await korDesigner(del, ctx)
2150
+ if (utkast.status !== null) return { namn, ...utkast }
2151
+ const granskning = await korDesignReviewer(del, ctx)
2152
+ if (granskning.status !== null) return { namn, ...granskning }
1523
2153
  const analys = await korAssessor(del, ctx)
1524
2154
  if (analys.status !== null) return { namn, ...analys }
1525
2155
  const scorers = await korScorers(del, ctx)
@@ -1538,12 +2168,14 @@ const skrivKorningsjson = (ctx, allaSteg) =>
1538
2168
 
1539
2169
  /** Avslutet: korning.json skrivs ALLTID (kostnaden är verklig även när
1540
2170
  * körningen föll), status vägs samman och sätts via archive.mjs, och sist
1541
- * skrivs delraderna och bluf-tabellen på stderr. */
1542
- function avsluta(ctx, allaSteg, rader, nyckel) {
2171
+ * skrivs delraderna och bluf-tabellen på stderr. Produktionsläget har inget
2172
+ * experiment att skriva till (postens state.json är redan satt av stegen),
2173
+ * tar bluf-tabellen över posten och skriver en `STATUS:`-rad på stdout. */
2174
+ export function avsluta(ctx, allaSteg, rader, nyckel = null) {
1543
2175
  let statusfel = null
1544
2176
  skrivKorningsjson(ctx, allaSteg)
1545
2177
  const status = sammanvagdStatus(rader.map((r) => r.status ?? null))
1546
- if (status !== null) {
2178
+ if (status !== null && !ctx.post) {
1547
2179
  const orsak = rader
1548
2180
  .filter((r) => r.status === status && r.orsak)
1549
2181
  .map((r) => `${r.namn}: ${r.orsak}`)
@@ -1569,13 +2201,30 @@ function avsluta(ctx, allaSteg, rader, nyckel) {
1569
2201
  }
1570
2202
  for (const r of rader)
1571
2203
  logg(`DEL ${r.namn} ${r.status ?? 'klar'}${r.orsak ? ` (${r.orsak})` : ''}`)
1572
- const bluf = arkiv(['bluf', ctx.katalog], ctx)
2204
+ const bluf = arkiv(['bluf', ctx.post ?? ctx.katalog], ctx)
1573
2205
  process.stderr.write(
1574
2206
  (bluf.status === 0 ? bluf.stdout : `bluf: ${felText(bluf)}`).trimEnd() + '\n',
1575
2207
  )
2208
+ if (ctx.post) process.stdout.write(`STATUS: ${postStatus(ctx, status)}\n`)
1576
2209
  return status === 'ofärdig' || statusfel !== null ? 1 : 0
1577
2210
  }
1578
2211
 
2212
+ /** Postens läge efter körningen, läst ur state.json, för STATUS-raden: ett
2213
+ * stopp först, sedan en ofärdig körning (state.json kan stå kvar på
2214
+ * `designed` eller `assessed` och skulle annars se färdig ut), en spärr som
2215
+ * väntar på dom (`archive.mjs sparr`), och sist statusen när den är
2216
+ * `priced` eller `assessed`. Allt annat är ofärdigt. */
2217
+ function postStatus(ctx, korStatus) {
2218
+ const state = lasJson(path.join(ctx.post, 'state.json'))
2219
+ if (state?.stopp !== null && typeof state?.stopp === 'object') return 'stopp'
2220
+ if (korStatus === 'ofärdig') return 'ofärdig'
2221
+ if (arkiv(['sparr', ctx.post], ctx).status === 1) return 'väntar på dom'
2222
+ if (state?.status === 'priced' || state?.status === 'assessed') return state.status
2223
+ // Ett stopp som inte står i state.json (designerns INTERVJU KRÄVS,
2224
+ // ADR-0156: inget skrivs) är fortfarande ett stopp, aldrig ofärdigt.
2225
+ return korStatus === 'stopp' ? 'stopp' : 'ofärdig'
2226
+ }
2227
+
1579
2228
  /** Signalavbrottet: döda barnen, skriv mätningen och lämna körningen som
1580
2229
  * `ofärdig` efter sig. Utan den här vägen överlever betalda `claude`-processer
1581
2230
  * en dödad korning.mjs, och korning.json skrivs aldrig, så både pengarna och
@@ -1585,24 +2234,63 @@ function avbryt(ctx, allaSteg, nyckel, signal) {
1585
2234
  const antal = ctx.barn.size
1586
2235
  for (const barn of ctx.barn) barn.kill('SIGTERM')
1587
2236
  skrivKorningsjson(ctx, allaSteg)
1588
- const r = arkiv(
1589
- [
1590
- 'experiment',
1591
- ctx.exp,
1592
- '--korning',
1593
- nyckel,
1594
- '--status',
1595
- 'ofärdig',
1596
- '--orsak',
1597
- `avbruten (${signal})`,
1598
- ],
1599
- ctx,
1600
- )
1601
- if (r.status !== 0) logg(`VARNING experiment --status ofärdig: ${felText(r)}`)
2237
+ // Produktionsläget har inget experiment; kvittot (state.korning) är
2238
+ // startarens att släppa (ADR-0155 beslut 3).
2239
+ if (!ctx.post) {
2240
+ const r = arkiv(
2241
+ [
2242
+ 'experiment',
2243
+ ctx.exp,
2244
+ '--korning',
2245
+ nyckel,
2246
+ '--status',
2247
+ 'ofärdig',
2248
+ '--orsak',
2249
+ `avbruten (${signal})`,
2250
+ ],
2251
+ ctx,
2252
+ )
2253
+ if (r.status !== 0) logg(`VARNING experiment --status ofärdig: ${felText(r)}`)
2254
+ }
1602
2255
  logg(`AVBRUTEN ${signal}: ${antal} barnprocess(er) dödade, korning.json skriven`)
1603
2256
  process.exit(SIGNAL_EXIT)
1604
2257
  }
1605
2258
 
2259
+ /** Vaktens intervall: startaren prövas var 30:e sekund. */
2260
+ export const VAKT_INTERVALL_MS = 30000
2261
+
2262
+ /** Lever processen? EPERM betyder att den finns men tillhör någon annan,
2263
+ * samma regel som archive.mjs isAlive. */
2264
+ const arLevande = (pid) => {
2265
+ try {
2266
+ process.kill(pid, 0)
2267
+ return true
2268
+ } catch (e) {
2269
+ return e?.code === 'EPERM'
2270
+ }
2271
+ }
2272
+
2273
+ /** Vakten över startaren (ADR-0155): en bedom.mjs dödad med SIGKILL rensar
2274
+ * inget kvitto och dödar inget barn, så körningen prövar själv startarens pid
2275
+ * och avbryter (`vidDod`, i main `avbryt`) när den är borta. `vidDod` anropas
2276
+ * högst en gång; timern är `unref()` och håller aldrig processen vid liv. */
2277
+ export function vaktaStartaren({
2278
+ pid,
2279
+ vidDod,
2280
+ isAlive = arLevande,
2281
+ intervallMs = VAKT_INTERVALL_MS,
2282
+ }) {
2283
+ let utlost = false
2284
+ const timer = setInterval(() => {
2285
+ if (utlost || isAlive(pid)) return
2286
+ utlost = true
2287
+ clearInterval(timer)
2288
+ vidDod(`startaren dog (pid ${pid})`)
2289
+ }, intervallMs)
2290
+ timer.unref()
2291
+ return timer
2292
+ }
2293
+
1606
2294
  async function main(argv) {
1607
2295
  const val = tolkaArgv(argv)
1608
2296
  if (val.fel) {
@@ -1610,7 +2298,14 @@ async function main(argv) {
1610
2298
  console.error(ANVANDNING)
1611
2299
  return 2
1612
2300
  }
1613
- const underlag = lasUnderlag(val)
2301
+ const underlag = val.post
2302
+ ? lasUnderlagProduktion({
2303
+ post: val.post,
2304
+ repoRot: val.repoRot,
2305
+ sprak: val.sprak,
2306
+ designMode: val.designMode,
2307
+ })
2308
+ : lasUnderlag(val)
1614
2309
  if (underlag.fel) {
1615
2310
  console.error(underlag.fel)
1616
2311
  return 2
@@ -1619,21 +2314,28 @@ async function main(argv) {
1619
2314
  const ctx = {
1620
2315
  ...val,
1621
2316
  ...underlag,
2317
+ repoSha: repoSha(val.repoRot),
1622
2318
  barn: new Set(),
1623
2319
  samla: (steg) => allaSteg.push(steg),
1624
2320
  raknaSamtidiga,
1625
2321
  }
1626
2322
  mkdirSync(path.join(ctx.katalog, 'steg'), { recursive: true })
1627
- const nyckel = `${path.basename(path.dirname(ctx.katalog))}/${path.basename(ctx.katalog)}`
2323
+ // Nyckeln är experimentkanalens `<modell>/k<N>`; i produktionsläget är den
2324
+ // bara postens namn i loggen.
2325
+ const nyckel = ctx.post
2326
+ ? path.basename(ctx.post)
2327
+ : `${path.basename(path.dirname(ctx.katalog))}/${path.basename(ctx.katalog)}`
1628
2328
  for (const signal of ['SIGTERM', 'SIGINT'])
1629
2329
  process.on(signal, () => avbryt(ctx, allaSteg, nyckel, signal))
2330
+ if (ctx.post && ctx.startare !== null)
2331
+ vaktaStartaren({ pid: ctx.startare, vidDod: (orsak) => avbryt(ctx, allaSteg, nyckel, orsak) })
1630
2332
  logg(
1631
2333
  `START ${nyckel} ${ctx.requestId} ${ctx.modell} (${ctx.sprak}${ctx.eskalerad ? ', eskalerad' : ''})`,
1632
2334
  )
1633
2335
 
1634
2336
  let rader
1635
2337
  try {
1636
- rader = await korKorningen(ctx)
2338
+ rader = ctx.post ? await korProduktion(ctx) : await korKorningen(ctx)
1637
2339
  } catch (e) {
1638
2340
  // korning.json skrivs ÄVEN när något kastar: kostnaden är redan dragen och
1639
2341
  // stegen ligger på disk. Ett kast som tar mätningen med sig gör körningen
@@ -1641,7 +2343,7 @@ async function main(argv) {
1641
2343
  logg(`FEL oväntat: ${e?.stack ?? e}`)
1642
2344
  rader = [
1643
2345
  {
1644
- namn: path.basename(ctx.katalog),
2346
+ namn: path.basename(ctx.post ?? ctx.katalog),
1645
2347
  status: 'ofärdig',
1646
2348
  orsak: `oväntat fel: ${e?.message ?? e}`,
1647
2349
  },
@@ -1672,6 +2374,145 @@ async function korKorningen(ctx) {
1672
2374
  return rader
1673
2375
  }
1674
2376
 
2377
+ // ---------------------------------------------------------------------------
2378
+ // Produktionsläget (ADR-0155): en godkänd, designad post prissätts ur sin katalog
2379
+ // ---------------------------------------------------------------------------
2380
+
2381
+ /** Precheck-raderna som bara säger att analysen hör till en annan spec eller
2382
+ * design (precheck.mjs, underlags- och designproveniensen under `--impact`).
2383
+ * Exakt de tre formuleringarna: varje annan brist i analysen är ett formfel
2384
+ * som en ny analys inte självklart rättar, och den ska en människa se. */
2385
+ const PROVENIENS_RADER = [
2386
+ 'impact.md: analysen gjordes mot en annan version av spec.md',
2387
+ 'impact.md: analysen gjordes utan det designdokument som nu finns',
2388
+ 'impact.md: analysen gjordes mot en annan version av design.md',
2389
+ ]
2390
+ export const arProveniensBrist = (rad) => {
2391
+ const text = String(rad).replace(/^- /, '')
2392
+ return PROVENIENS_RADER.some((p) => text.startsWith(p))
2393
+ }
2394
+
2395
+ const bristText = (brister) => brister.map((b) => b.replace(/^- /, '')).join('; ')
2396
+
2397
+ /** Delposten scorern ska veta om: en split- eller buntdel bär `part`/`of` i
2398
+ * state.json, allt annat är en hel post. */
2399
+ const delpostUr = (state) => {
2400
+ for (const falt of [state?.split, state?.bundle])
2401
+ if (Number.isInteger(falt?.part) && Number.isInteger(falt?.of))
2402
+ return { part: falt.part, of: falt.of }
2403
+ return null
2404
+ }
2405
+
2406
+ /** Steg 2 och 3: analysen återanvänds när den finns och håller proveniensen,
2407
+ * annars körs assessorn, men först efter att designen prövats (en del av en
2408
+ * split ärver förälderns design och prövas inte här). */
2409
+ async function produktionsAnalys(del, ctx) {
2410
+ let korAssessorn = !existsSync(path.join(del.dir, 'impact.md'))
2411
+ if (!korAssessorn) {
2412
+ const p = korPrecheck(del, ctx, { impact: true })
2413
+ if (p.fel) return { status: 'ofärdig', orsak: p.fel }
2414
+ if (p.brister.length > 0 && !p.brister.every(arProveniensBrist))
2415
+ return { status: 'ofärdig', orsak: `impact: ${bristText(p.brister)}` }
2416
+ if (p.brister.length > 0) {
2417
+ logg(`ANALYS ${del.namn} körs om: ${bristText(p.brister)}`)
2418
+ korAssessorn = true
2419
+ } else {
2420
+ logg(`ANALYS ${del.namn} återanvänds`)
2421
+ // En återanvänd analys som redan säger motsägelse prissätts aldrig:
2422
+ // samma stopp som efter en ny analys (idempotent i archive.mjs).
2423
+ return p.avvikelse === 'motsägelse' ? stoppaMotsagelse(del, p, ctx) : { status: null }
2424
+ }
2425
+ }
2426
+ if ((ctx.state?.split?.parent ?? null) === null) {
2427
+ const d = korPrecheck(del, ctx, { design: true })
2428
+ if (d.fel) return { status: 'ofärdig', orsak: d.fel }
2429
+ if (d.design !== 'ok' || d.brister.length > 0) {
2430
+ const rader = [
2431
+ ...(d.design !== 'ok' ? [`DESIGN: ${d.design ?? 'saknas'}`] : []),
2432
+ ...d.brister,
2433
+ ]
2434
+ return { status: 'ofärdig', orsak: `designa ${ctx.requestId}: ${bristText(rader)}` }
2435
+ }
2436
+ }
2437
+ return korAssessor(del, ctx)
2438
+ }
2439
+
2440
+ /** Steg 4, efter ett rent arkiveringsvarv: `priced` sätts bara när inget
2441
+ * stopp står och ingen spärr väntar på dom. archive.mjs prövar sedan själv
2442
+ * den gällande prisfilen (pricedBlockers); en vägran är ofärdig med dess
2443
+ * rader, aldrig en tyst `assessed`. */
2444
+ function sattPriced(del, ctx) {
2445
+ const state = lasJson(path.join(del.dir, 'state.json'))
2446
+ if (state?.stopp !== null && typeof state?.stopp === 'object')
2447
+ return { status: 'stopp', orsak: `${state.stopp.utfall} ur ${state.stopp.kalla}` }
2448
+ const sp = arkiv(['sparr', del.dir], ctx)
2449
+ if (sp.status === 1)
2450
+ return { status: 'väntar på dom', orsak: raderMed(sp.stdout, 'SPÄRRAD: ').join('; ') }
2451
+ if (sp.status !== 0) return { status: 'ofärdig', orsak: `sparr: ${felText(sp)}` }
2452
+ const r = arkiv(['set-status', del.dir, 'priced'], ctx)
2453
+ if (r.status === 0) {
2454
+ logg(`PRISSATT ${del.namn}`)
2455
+ return { status: null }
2456
+ }
2457
+ const vagrar = raderMed(r.stdout, 'VÄGRAR priced: ')
2458
+ return {
2459
+ status: 'ofärdig',
2460
+ orsak:
2461
+ vagrar.length > 0
2462
+ ? `VÄGRAR priced: ${vagrar.join('; ')}`
2463
+ : `set-status priced: ${felText(r)}`,
2464
+ }
2465
+ }
2466
+
2467
+ /** Produktionsläget för EN post (ADR-0155 beslut 1): formkontroll av den
2468
+ * godkända specen, analys vid behov, två scorers, arkivering och `priced`.
2469
+ * Returnerar samma radform som korKorningen. Specen godkändes av en
2470
+ * människa, så en brist i den är ofärdig och omdispatchas aldrig här. */
2471
+ export async function korProduktion(ctx) {
2472
+ const del = {
2473
+ dir: ctx.post,
2474
+ namn: path.basename(ctx.post),
2475
+ nr: 1,
2476
+ delpost: delpostUr(ctx.state),
2477
+ }
2478
+ const rad = (r) => ({ namn: del.namn, status: r.status ?? null, orsak: r.orsak ?? null })
2479
+
2480
+ const p = korPrecheck(del, ctx)
2481
+ if (p.fel) return [rad({ status: 'ofärdig', orsak: p.fel })]
2482
+ if (p.brister.length > 0)
2483
+ return [rad({ status: 'ofärdig', orsak: `spec: ${bristText(p.brister)}` })]
2484
+ if (p.kandidat) {
2485
+ const s = arkiv(['stopp', del.dir, '--utfall', p.kandidat, '--kalla', 'spec.md'], ctx)
2486
+ if (s.status !== 0) return [rad({ status: 'ofärdig', orsak: `stopp: ${felText(s)}` })]
2487
+ logg(`STOPP ${del.namn} ${p.kandidat}`)
2488
+ return [rad({ status: 'stopp', orsak: p.kandidat })]
2489
+ }
2490
+ // Ett stopp som redan står (TILLBAKA_TILL_SPEC eller ESKALERA ur
2491
+ // granskarblock.md, ett designkvitto eller en tidigare analys) köper ingen
2492
+ // analys och inga scorers: posten går till respec eller designa först.
2493
+ const stopp = ctx.state?.stopp
2494
+ if (stopp !== null && typeof stopp === 'object') {
2495
+ logg(`STOPP ${del.namn} står: ${stopp.utfall} (${stopp.kalla})`)
2496
+ return [rad({ status: 'stopp', orsak: `stopp står: ${stopp.utfall} (${stopp.kalla})` })]
2497
+ }
2498
+
2499
+ // Draft-läget (ADR-0156 beslut 1 och 3): ett saknat designdokument skrivs
2500
+ // av designern och läses av granskaren innan analysbeslutet.
2501
+ const utkast = await korDesigner(del, ctx)
2502
+ if (utkast.status !== null) return [rad(utkast)]
2503
+ const granskning = await korDesignReviewer(del, ctx)
2504
+ if (granskning.status !== null) return [rad(granskning)]
2505
+
2506
+ const analys = await produktionsAnalys(del, ctx)
2507
+ if (analys.status !== null) return [rad(analys)]
2508
+
2509
+ const scorers = await korScorers(del, ctx)
2510
+ if (scorers.status) return [rad(scorers)]
2511
+ const varv = arkiveraVarv(del, scorers.korningar, ctx)
2512
+ if (varv.status !== null) return [rad(varv)]
2513
+ return [rad(sattPriced(del, ctx))]
2514
+ }
2515
+
1675
2516
  /** Huvudloopen körs bara när scriptet startas direkt; en import (testerna)
1676
2517
  * exekverar ingenting. */
1677
2518
  const arDirektKord = () => {