@haus-tech/haus-workflow 5.9.1 → 5.10.1

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.
@@ -21,8 +21,9 @@
21
21
  * [--steg-timeout 1800000] [--effort <low|medium|high|xhigh|max|default>]
22
22
  *
23
23
  * Läser `<exp>/experiment.json` (`sprak` OBLIGATORISK, ADR-0114: språket
24
- * gissas aldrig), `<exp>/onskemal.md` och `<exp>/kallmanifest.md`. Skriver
25
- * `steg/*.json`, spec/impact/prisfiler i körningskatalogen och `korning.json`;
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
27
  * `experiment.json` och `state.json` rörs BARA via `archive.mjs`.
27
28
  *
28
29
  * stdout används inte (svep sparar stderr som `stderr.log`). Exit 0 = körningen
@@ -223,7 +224,15 @@ export function specifierPrompt({
223
224
  /** Assessorns prompt. KÄLLOR ligger med (hävstång 5): analysen skrevs förr
224
225
  * mot ett repo den fick leta i själv, medan manifestet låg färdigt bredvid.
225
226
  * SPRÅK efter DEPS (#521): analysens kundvända delar följer raden. */
226
- export function assessorPrompt({ repoRot, deps, sprak, spec, granskarblock, manifest }) {
227
+ export function assessorPrompt({
228
+ repoRot,
229
+ deps,
230
+ sprak,
231
+ spec,
232
+ granskarblock,
233
+ manifest,
234
+ design = null,
235
+ }) {
227
236
  const sprakRad = kravSprak(sprak)
228
237
  return [
229
238
  `REPO-ROT: ${repoRot}`,
@@ -247,6 +256,17 @@ export function assessorPrompt({ repoRot, deps, sprak, spec, granskarblock, mani
247
256
  'GRANSKARBLOCK (internt underlag, följer aldrig med i analysens kundtext):',
248
257
  granskarblock.trim(),
249
258
  '',
259
+ // Designdokumentet (designa, spec 2026-09-24): beslutat med en människa i
260
+ // brainstorming och inklistrat bytetroget som granskarblocket (hårda
261
+ // regeln: aldrig per sökväg). Utan design skrivs raden DESIGN: ingen, så
262
+ // assessorn ser skillnaden mellan "ingen design" och en trunkerad prompt.
263
+ ...(design == null
264
+ ? ['DESIGN: ingen']
265
+ : [
266
+ 'DESIGN (beslutad i brainstorming, internt underlag, följer aldrig med i analysens kundtext):',
267
+ design.trim(),
268
+ ]),
269
+ '',
250
270
  ].join('\n')
251
271
  }
252
272
 
@@ -580,7 +600,7 @@ export function tolkaArgv(argv) {
580
600
 
581
601
  /** Underlaget på disk. Språket är OBLIGATORISKT (ADR-0114): utan det finns
582
602
  * ingen rubrikuppsättning att be om, och en gissning fäller hela specvarvet. */
583
- function lasUnderlag(val) {
603
+ export function lasUnderlag(val) {
584
604
  let experiment
585
605
  try {
586
606
  experiment = JSON.parse(readFileSync(path.join(val.exp, 'experiment.json'), 'utf8'))
@@ -594,6 +614,10 @@ function lasUnderlag(val) {
594
614
  }
595
615
  const onskemal = lasFil(path.join(val.exp, 'onskemal.md'))
596
616
  const manifest = lasFil(path.join(val.exp, 'kallmanifest.md'))
617
+ // Designen är ett valfritt pinnat underlag som manifestet (designa, spec
618
+ // 2026-09-24): finns filen går den bytetroget in i varje varv, saknas den
619
+ // körs varvet som förut. Aldrig ett fel.
620
+ const design = lasFil(path.join(val.exp, 'design.md'))
597
621
  const saknas = [
598
622
  ...(onskemal === null ? ['onskemal.md'] : []),
599
623
  ...(manifest === null ? ['kallmanifest.md'] : []),
@@ -606,6 +630,7 @@ function lasUnderlag(val) {
606
630
  return {
607
631
  onskemal,
608
632
  manifest,
633
+ design,
609
634
  sprak,
610
635
  eskalerad: experiment.eskalerad === true,
611
636
  eskaleringsorsak:
@@ -1085,11 +1110,22 @@ async function specOmdispatch(del, spec, brister, ctx) {
1085
1110
  return { ok: true, meta }
1086
1111
  }
1087
1112
 
1113
+ /** Designen in i delens katalog som design.md före assessorsteget, så att
1114
+ * add-run hashar den i impact-radens underlag_sha256 och precheck --impact
1115
+ * prövar proveniensen (designa, spec 2026-09-24). Byteidentisk kopia av
1116
+ * ctx.design; utan design skrivs inget och false returneras. */
1117
+ export function laggDesignIDel(del, ctx) {
1118
+ if (ctx.design == null) return false
1119
+ writeFileSync(path.join(del.dir, 'design.md'), ctx.design)
1120
+ return true
1121
+ }
1122
+
1088
1123
  /** Analysen: ett agentsteg, impact.md med runs-rad, formkontroll med EN
1089
1124
  * omdispatch, och motsägelse mot specen som stopputfall (ADR-0106). */
1090
1125
  async function korAssessor(del, ctx) {
1091
1126
  const spec = lasFil(path.join(del.dir, 'spec.md'))
1092
1127
  if (spec === null) return { status: 'ofärdig', orsak: 'impact: spec.md saknas' }
1128
+ laggDesignIDel(del, ctx)
1093
1129
  const prompt = assessorPrompt({
1094
1130
  repoRot: ctx.repoRot,
1095
1131
  deps: ctx.deps,
@@ -1097,6 +1133,7 @@ async function korAssessor(del, ctx) {
1097
1133
  spec,
1098
1134
  granskarblock: lasFil(path.join(del.dir, 'granskarblock.md')) ?? 'inget granskarblock',
1099
1135
  manifest: ctx.manifest,
1136
+ design: ctx.design,
1100
1137
  })
1101
1138
  const fil = `steg/1${del.nr}-assessor.json`
1102
1139
  const res = await korAgent({
@@ -721,6 +721,190 @@ export const sectionLines = (block, heading) => {
721
721
  return rest.slice(0, end === -1 ? rest.length : end)
722
722
  }
723
723
 
724
+ /** Unicode-medvetna ordgränser: JS \b bryter vid å/ä/ö, så svenska ord kräver
725
+ * egna gränser ("kräver" får aldrig matcha förbudet "kr"). */
726
+ const W = (pat) => new RegExp(`(?<![\\p{L}\\d_])(?:${pat})(?![\\p{L}\\d_])`, 'iu')
727
+
728
+ /** Rubrikradens text utan fetstil- eller #-markering. */
729
+ const headingText = (line) =>
730
+ line
731
+ .trim()
732
+ .replace(/^\*\*(.+)\*\*$/, '$1')
733
+ .replace(/^#{1,6}\s*/, '')
734
+ .trim()
735
+
736
+ /** En specrubrik enligt tabellen, på något av språken. */
737
+ const isSpecHeading = (line) => SPEC_HEADINGS.some((row) => headingRe(row.key).test(line.trim()))
738
+
739
+ /** Vilka språkbärande rubriker blocket har, per språk, för valfri rubriktabell
740
+ * (SPEC_HEADINGS eller IMPACT_HEADINGS). Anroparen väljer tabellen explicit,
741
+ * så specens och analysens rubriker aldrig kan blandas ihop av misstag, samma
742
+ * skäl som gör headingLanguage och impactHeadingLanguage till skilda
743
+ * funktioner. Hjälparen LISTAR bara träffar, den avgör aldrig blandad, sv
744
+ * eller en åt någon. Delad av specForm och precheck --impact. */
745
+ export const headingsByLanguage = (block, table) => {
746
+ const sv = []
747
+ const en = []
748
+ for (const line of block.split('\n')) {
749
+ if (!isHeadingLine(line)) continue
750
+ const text = headingText(line)
751
+ for (const row of table) {
752
+ if (row.sv === row.en) continue
753
+ if (text === row.sv) sv.push(row.sv)
754
+ else if (text === row.en) en.push(row.en)
755
+ }
756
+ }
757
+ return { sv, en }
758
+ }
759
+
760
+ /** Kandidatformen (ADR-0105): titel, markör direkt under titeln, Problem.
761
+ * Titel = första rubrikrad som varken är en specrubrik eller en markör;
762
+ * saknas titel ska markören vara blockets första rubrikrad. Lösning och När
763
+ * det är klart är då fel form, antingen spec eller kandidat. Ingår inte,
764
+ * Kräver och Villkor tillåts, och ingen 2–5-punktskontroll görs. Returnerar
765
+ * bristraderna. */
766
+ const kandidatFormBrister = (block, kandidat, label) => {
767
+ const brister = []
768
+ const lines = block.split('\n')
769
+ const headings = lines.map((l, i) => (isHeadingLine(l) ? i : -1)).filter((i) => i !== -1)
770
+ const title = headings.find((i) => !isSpecHeading(lines[i]) && kandidatIn(lines[i]) == null)
771
+ const expected = title == null ? headings[0] : headings.find((i) => i > title)
772
+ if (expected !== kandidat.line)
773
+ brister.push(
774
+ `${label}: kandidatmarkören ska stå som första rubrikrad${title == null ? '' : ' efter titeln'}`,
775
+ )
776
+ for (const heading of ['Lösning', 'När det är klart'])
777
+ if (headingRe(heading).test(block))
778
+ brister.push(`${label}: kandidat med ${heading}: antingen spec eller kandidat`)
779
+ return brister
780
+ }
781
+
782
+ /**
783
+ * Specens formkontroll: rubriker enligt aliastabellen, exakt ett specblock,
784
+ * 2–5 punkter under När det är klart, förbjuden vokabulär. En kopia av regeln,
785
+ * delad av precheck.mjs (spec.md) och archive.mjs paraply (ADR-0144), så en
786
+ * paraplyspec prövas med exakt samma regler som varje spec. Kontrollerar bara
787
+ * form, aldrig innehåll. `fil` är etiketten i raderna. Returnerar
788
+ * { brister, varningar, kandidater }: brister är hårda formfel, varningar
789
+ * heuristik, kandidater utfallet per block som bär kandidatmarkören.
790
+ */
791
+ export const specForm = (spec, fil = 'spec.md') => {
792
+ const brister = []
793
+ const varningar = []
794
+ const kandidater = []
795
+ // En katalog = en spec. Specformatet föreskriver ingen separator mellan
796
+ // specar, så buntdetektionen räknar den obligatoriska Problem-rubriken över
797
+ // hela filen: fler än en betyder att en bunt inte delats i egna kataloger,
798
+ // och då prissätts två beslut som en post (fel pris åt något håll).
799
+ const problemCount = (spec.match(new RegExp(headingRe('Problem').source, 'gm')) ?? []).length
800
+ if (problemCount > 1)
801
+ brister.push(
802
+ `${fil}: ${problemCount} "Problem"-rubriker i en katalog, en spec per katalog, dela bunten först`,
803
+ )
804
+ const blocks = spec
805
+ .split(/\n---\n/)
806
+ .map((b) => b.trim())
807
+ .filter(Boolean)
808
+ if (blocks.length === 0) brister.push(`${fil}: tom`)
809
+ blocks.forEach((block, i) => {
810
+ const label = blocks.length > 1 ? `${fil} block ${i + 1}` : fil
811
+ // Rubrikspråk (ADR-0114): specen skrivs på önskemålets språk och rubrikerna
812
+ // på samma språk som brödtexten. Brödtexten kontrolleras inte, men rubriker
813
+ // ur båda språken i samma block fälls, kunden ser rubrikerna. Vilka som
814
+ // hittades per språk namnges så rättningen blir riktad.
815
+ if (headingLanguage(block) === 'blandad') {
816
+ const { sv, en } = headingsByLanguage(block, SPEC_HEADINGS)
817
+ brister.push(
818
+ `${label}: rubriker på två språk (sv: ${sv.join(', ')}, en: ${en.join(', ')}), välj ett`,
819
+ )
820
+ }
821
+ // Obligatoriskt per specformatet: Problem, alltid. Lösning och När det är
822
+ // klart bara i spec-formen; kandidatformen (ADR-0105) ersätter dem med
823
+ // markören och routar posten till sitt stopputfall utan analys och poäng.
824
+ if (!headingRe('Problem').test(block)) brister.push(`${label}: rubriken "Problem" saknas`)
825
+ const kandidat = kandidatIn(block)
826
+ if (kandidat) {
827
+ kandidater.push(kandidat.utfall)
828
+ brister.push(...kandidatFormBrister(block, kandidat, label))
829
+ return
830
+ }
831
+ for (const heading of ['Lösning', 'När det är klart'])
832
+ if (!headingRe(heading).test(block)) brister.push(`${label}: rubriken "${heading}" saknas`)
833
+ // Ingår inte är valfri i formatet ("ibland ingen alls"), saknad blir varning.
834
+ if (!headingRe('Ingår inte').test(block))
835
+ varningar.push(`${label}: "Ingår inte" saknas, ofta rätt att ringa in vad som inte ingår`)
836
+ // 2–5 numrerade punkter under När det är klart, avgränsat vid nästa
837
+ // rubrikrad (inte vid första **: fetstil inuti en punkt är tillåten).
838
+ const sect = sectionLines(block, 'När det är klart')
839
+ if (sect != null) {
840
+ const items = sect.filter((l) => /^\d+\.\s/.test(l.trim()))
841
+ if (items.length < 2 || items.length > 5)
842
+ brister.push(`${label}: "När det är klart" har ${items.length} punkter (kräver 2–5)`)
843
+ for (const it of items)
844
+ if (W('eller').test(it))
845
+ brister.push(`${label}: "eller" i utfallspunkt: ${it.trim().slice(0, 60)}`)
846
+ }
847
+ })
848
+ // Förbjuden vokabulär i hela specen (tid, pengar, hedge-ord). Tidsord matchas
849
+ // utan inledande gräns så sammansättningar (arbetstimmar, halvtimme) fälls.
850
+ // "kr" kräver siffra före sig, annars fälls identifierare som "KR-2".
851
+ const banned = ['förslagsvis', 'alternativt', 'beroende på', 'en lämplig', 'kronor', 'SEK']
852
+ for (const word of banned) {
853
+ const m = spec.match(W(word))
854
+ if (m) brister.push(`${fil}: förbjudet ord "${m[0]}"`)
855
+ }
856
+ // Bindestreck friar identifierare ("punkt 3: KR-2" är inget belopp).
857
+ const kr = spec.match(/\d[\s.,:]*kr(?![\p{L}\d_-])/iu)
858
+ if (kr) brister.push(`${fil}: belopp "${kr[0].trim()}"`)
859
+ const time = spec.match(TIDSORD_RE)
860
+ if (time) brister.push(`${fil}: förbjudet tidsord "${time[0]}"`)
861
+ return { brister, varningar, kandidater }
862
+ }
863
+
864
+ /** Designdokumentets frontmatter (designa, spec 2026-09-24): det första
865
+ * blocket mellan två `---`-rader, `nyckel: värde` per rad, värdena trimmade
866
+ * och aldrig tolkade. add-design och precheck --design jämför strängarna
867
+ * literalt mot katalogens id och specens hash. null när texten inte börjar
868
+ * med ett block, eller blocket aldrig stängs. */
869
+ export const designFrontmatter = (text) => {
870
+ const lines = String(text).split('\n')
871
+ if (lines[0]?.trim() !== '---') return null
872
+ const end = lines.findIndex((l, i) => i > 0 && l.trim() === '---')
873
+ if (end === -1) return null
874
+ const falt = {}
875
+ for (const l of lines.slice(1, end)) {
876
+ const m = /^([A-Za-z_][\w-]*):\s*(.*)$/.exec(l)
877
+ if (m) falt[m[1]] = m[2].trim()
878
+ }
879
+ return falt
880
+ }
881
+
882
+ /** Brödtexten efter frontmattern, hela texten när blocket saknas. Det är den
883
+ * precheck --design läser tids- och poängord i. */
884
+ export const designBody = (text) => {
885
+ const lines = String(text).split('\n')
886
+ if (lines[0]?.trim() !== '---') return String(text)
887
+ const end = lines.findIndex((l, i) => i > 0 && l.trim() === '---')
888
+ return end === -1 ? String(text) : lines.slice(end + 1).join('\n')
889
+ }
890
+
891
+ /** Sektionen `## Spec conflicts`: null när rubriken saknas, [] när sektionen
892
+ * är exakt ordet `none` (skiftläge ignorerat), annars de icke-tomma raderna,
893
+ * en konflikt per rad. add-design vägrar allt utom [], precheck --design
894
+ * fäller det som BRIST. */
895
+ export const specConflicts = (text) => {
896
+ const rader = sectionLines(String(text), 'Spec conflicts')
897
+ if (rader === null) return null
898
+ const ickeTomma = rader.map((r) => r.trim()).filter((r) => r !== '')
899
+ if (ickeTomma.length === 1 && ickeTomma[0].toLowerCase() === 'none') return []
900
+ return ickeTomma
901
+ }
902
+
903
+ /** Katalogens id: gid:t ur state.json, annars katalognamnet (en slug-katalog
904
+ * bär request_id null). En definition, delad av add-design och precheck
905
+ * --design, så frontmatterns request_id alltid prövas mot samma svar. */
906
+ export const katalogId = (state, dir) => state?.request_id ?? basename(resolve(dir))
907
+
724
908
  /** Specifierns svar: specblock, vart och ett följt av sitt eget "## Till
725
909
  * granskaren" (ADR-0118). Titel = rubrikrad som varken är specrubrik (något
726
910
  * språk), kandidatmarkör eller granskarrubrik; varje titel startar en ny
@@ -923,8 +1107,13 @@ export const FRYSTA_LITERALER = Object.freeze({
923
1107
  'SPEC (godkänd):',
924
1108
  'GRANSKARBLOCK',
925
1109
  'GRANSKARBLOCK (internt underlag, följer aldrig med i analysens kundtext):',
1110
+ 'DESIGN:',
1111
+ 'DESIGN (beslutad i brainstorming, internt underlag, följer aldrig med i analysens kundtext):',
926
1112
  'FULLPLAN:',
927
1113
  'TIDIGARE_POANG:',
1114
+ // ADR-0144: paraplyläget i specifierns prompt (bedöms DELA_UPP-val 2).
1115
+ 'PARAPLY:',
1116
+ 'DELAR:',
928
1117
  ]),
929
1118
  promptvarden: Object.freeze([
930
1119
  'installerade',
@@ -9,8 +9,9 @@
9
9
  * BRISTER (exit 1) är hårda formfel. VARNING är heuristik som kräver mänsklig
10
10
  * blick (poängord i analys, saknad Ingår inte) — de skrivs ut men stoppar inget.
11
11
  *
12
- * Användning: node precheck.mjs <arkivkatalog> [--impact]
12
+ * Användning: node precheck.mjs <arkivkatalog> [--impact] [--design]
13
13
  * --impact kräv och kontrollera även impact.md (bedöm-steget)
14
+ * --design kräv och kontrollera även design.md (designa-steget och bedöms grind 1b)
14
15
  * Exit 0 = ok, 1 = brister (listade på stdout), 2 = användningsfel.
15
16
  */
16
17
  import { readFileSync, existsSync } from 'node:fs'
@@ -20,13 +21,10 @@ import {
20
21
  markerStrippedSha256,
21
22
  refDriftWarning,
22
23
  headingRe,
23
- sectionLines,
24
- isHeadingLine,
25
- SPEC_HEADINGS,
26
24
  IMPACT_HEADINGS,
27
- headingLanguage,
28
25
  impactHeadingLanguage,
29
- kandidatIn,
26
+ specForm,
27
+ headingsByLanguage,
30
28
  avvikelseEtikett,
31
29
  AVVIKELSE_ETIKETTER,
32
30
  AVVIKELSE_ALIAS,
@@ -41,12 +39,17 @@ import {
41
39
  tillGranskarenRe,
42
40
  TIDSORD_RE,
43
41
  POANGORD_RE,
42
+ designFrontmatter,
43
+ designBody,
44
+ specConflicts,
45
+ katalogId,
44
46
  } from './lib.mjs'
45
47
 
46
48
  const dir = process.argv[2]
47
49
  const requireImpact = process.argv.includes('--impact')
50
+ const requireDesign = process.argv.includes('--design')
48
51
  if (!dir) {
49
- console.error('användning: node precheck.mjs <arkivkatalog> [--impact]')
52
+ console.error('användning: node precheck.mjs <arkivkatalog> [--impact] [--design]')
50
53
  process.exit(2)
51
54
  }
52
55
 
@@ -73,10 +76,6 @@ const SPECFORMAT_SHA = '2cd9912f8ea91edb04fb7772e809482236d72d1b3e3fb4db6c196d9f
73
76
  // Tidsord förbjuds i både spec och analys — EN regex, TIDSORD_RE i lib.mjs, så
74
77
  // reglerna aldrig glider isär och driftvakten läser samma regex som grinden.
75
78
 
76
- // JS \b bryter vid å/ä/ö — svenska ord kräver Unicode-medvetna gränser
77
- // ("kräver" får aldrig matcha förbudet "kr").
78
- const W = (pat) => new RegExp(`(?<![\\p{L}\\d_])(?:${pat})(?![\\p{L}\\d_])`, 'iu')
79
-
80
79
  const read = (name, required) => {
81
80
  const p = join(dir, name)
82
81
  if (!existsSync(p)) {
@@ -86,59 +85,6 @@ const read = (name, required) => {
86
85
  return readFileSync(p, 'utf8')
87
86
  }
88
87
 
89
- /** Rubrikradens text utan fetstil- eller #-markering. */
90
- const headingText = (line) =>
91
- line
92
- .trim()
93
- .replace(/^\*\*(.+)\*\*$/, '$1')
94
- .replace(/^#{1,6}\s*/, '')
95
- .trim()
96
-
97
- /** En specrubrik enligt tabellen, på något av språken. */
98
- const isSpecHeading = (line) => SPEC_HEADINGS.some((row) => headingRe(row.key).test(line.trim()))
99
-
100
- /** Vilka språkbärande rubriker blocket har, per språk, för valfri rubriktabell
101
- * (SPEC_HEADINGS eller IMPACT_HEADINGS). Anroparen väljer tabellen explicit,
102
- * så specens och analysens rubriker aldrig kan blandas ihop av misstag —
103
- * samma skäl som gör headingLanguage/impactHeadingLanguage till skilda
104
- * funktioner i lib.mjs, men här räcker en parameter: den här hjälparen
105
- * bara LISTAR träffar, den avgör aldrig blandad/sv/en åt någon. */
106
- const headingsByLanguage = (block, table) => {
107
- const sv = []
108
- const en = []
109
- for (const line of block.split('\n')) {
110
- if (!isHeadingLine(line)) continue
111
- const text = headingText(line)
112
- for (const row of table) {
113
- if (row.sv === row.en) continue
114
- if (text === row.sv) sv.push(row.sv)
115
- else if (text === row.en) en.push(row.en)
116
- }
117
- }
118
- return { sv, en }
119
- }
120
-
121
- /**
122
- * Kandidatformen (ADR-0105): titel, markör direkt under titeln, Problem.
123
- * Titel = första rubrikrad som varken är en specrubrik eller en markör;
124
- * saknas titel ska markören vara blockets första rubrikrad. Lösning och När
125
- * det är klart är då fel form, antingen spec eller kandidat. Ingår inte,
126
- * Kräver och Villkor tillåts, och ingen 2–5-punktskontroll görs.
127
- */
128
- const checkKandidatForm = (block, kandidat, label) => {
129
- const lines = block.split('\n')
130
- const headings = lines.map((l, i) => (isHeadingLine(l) ? i : -1)).filter((i) => i !== -1)
131
- const title = headings.find((i) => !isSpecHeading(lines[i]) && kandidatIn(lines[i]) == null)
132
- const expected = title == null ? headings[0] : headings.find((i) => i > title)
133
- if (expected !== kandidat.line)
134
- problems.push(
135
- `${label}: kandidatmarkören ska stå som första rubrikrad${title == null ? '' : ' efter titeln'}`,
136
- )
137
- for (const heading of ['Lösning', 'När det är klart'])
138
- if (headingRe(heading).test(block))
139
- problems.push(`${label}: kandidat med ${heading}: antingen spec eller kandidat`)
140
- }
141
-
142
88
  /** S1: en felrad ska namnge literalen i DOKUMENTETS eget språk, inte alltid
143
89
  * engelska — annars skickar en rättning/omdispatch agenten tillbaka till
144
90
  * det språk vi just lämnat. Signalen är den svenska "## Till granskaren"-
@@ -173,74 +119,14 @@ const RATTNINGSVARDEN = {
173
119
  }
174
120
 
175
121
  // ---- spec.md ---------------------------------------------------------------
122
+ // Formreglerna bor i lib.mjs (specForm), delade med archive.mjs paraply
123
+ // (ADR-0144): en paraplyspec prövas med exakt samma regler som varje spec.
176
124
  const spec = read('spec.md', true)
177
125
  if (spec != null) {
178
- // En katalog = en spec. Specformatet föreskriver ingen separator mellan
179
- // specar, så bunt-detektionen räknar den obligatoriska Problem-rubriken över
180
- // hela filen — fler än en betyder att en bunt inte delats i egna kataloger,
181
- // och då klassas två beslut som en post (fel pris åt något håll).
182
- const problemCount = (spec.match(new RegExp(headingRe('Problem').source, 'gm')) ?? []).length
183
- if (problemCount > 1)
184
- problems.push(
185
- `spec.md: ${problemCount} "Problem"-rubriker i en katalog — en spec per katalog, dela bunten först`,
186
- )
187
- const blocks = spec
188
- .split(/\n---\n/)
189
- .map((b) => b.trim())
190
- .filter(Boolean)
191
- if (blocks.length === 0) problems.push('spec.md: tom')
192
- blocks.forEach((block, i) => {
193
- const label = blocks.length > 1 ? `spec.md block ${i + 1}` : 'spec.md'
194
- // Rubrikspråk (ADR-0114): specen skrivs på önskemålets språk och rubrikerna
195
- // på samma språk som brödtexten. Brödtexten kontrolleras inte, men rubriker
196
- // ur båda språken i samma block fälls, kunden ser rubrikerna. Vilka som
197
- // hittades per språk namnges så rättningen blir riktad.
198
- if (headingLanguage(block) === 'blandad') {
199
- const { sv, en } = headingsByLanguage(block, SPEC_HEADINGS)
200
- problems.push(
201
- `${label}: rubriker på två språk (sv: ${sv.join(', ')}, en: ${en.join(', ')}), välj ett`,
202
- )
203
- }
204
- // Obligatoriskt per specformatet: Problem, alltid. Lösning och När det är
205
- // klart bara i spec-formen; kandidatformen (ADR-0105) ersätter dem med
206
- // markören och routar posten till sitt stopputfall utan analys och poäng.
207
- if (!headingRe('Problem').test(block)) problems.push(`${label}: rubriken "Problem" saknas`)
208
- const kandidat = kandidatIn(block)
209
- if (kandidat) {
210
- kandidater.push(kandidat.utfall)
211
- checkKandidatForm(block, kandidat, label)
212
- return
213
- }
214
- for (const heading of ['Lösning', 'När det är klart'])
215
- if (!headingRe(heading).test(block)) problems.push(`${label}: rubriken "${heading}" saknas`)
216
- // Ingår inte är valfri i formatet ("ibland ingen alls"), saknad blir varning.
217
- if (!headingRe('Ingår inte').test(block))
218
- warnings.push(`${label}: "Ingår inte" saknas — ofta rätt att ringa in vad som inte ingår`)
219
- // 2–5 numrerade punkter under När det är klart, avgränsat vid nästa
220
- // rubrikrad (inte vid första ** — fetstil inuti en punkt är tillåten).
221
- const sect = sectionLines(block, 'När det är klart')
222
- if (sect != null) {
223
- const items = sect.filter((l) => /^\d+\.\s/.test(l.trim()))
224
- if (items.length < 2 || items.length > 5)
225
- problems.push(`${label}: "När det är klart" har ${items.length} punkter (kräver 2–5)`)
226
- for (const it of items)
227
- if (W('eller').test(it))
228
- problems.push(`${label}: "eller" i utfallspunkt: ${it.trim().slice(0, 60)}`)
229
- }
230
- })
231
- // Förbjuden vokabulär i hela specen (tid, pengar, hedge-ord). Tidsord matchas
232
- // utan inledande gräns så sammansättningar (arbetstimmar, halvtimme) fälls.
233
- // "kr" kräver siffra före sig — annars fälls identifierare som "KR-2".
234
- const banned = ['förslagsvis', 'alternativt', 'beroende på', 'en lämplig', 'kronor', 'SEK']
235
- for (const word of banned) {
236
- const m = spec.match(W(word))
237
- if (m) problems.push(`spec.md: förbjudet ord "${m[0]}"`)
238
- }
239
- // Bindestreck friar identifierare ("punkt 3: KR-2" är inget belopp).
240
- const kr = spec.match(/\d[\s.,:]*kr(?![\p{L}\d_-])/iu)
241
- if (kr) problems.push(`spec.md: belopp "${kr[0].trim()}"`)
242
- const time = spec.match(TIDSORD_RE)
243
- if (time) problems.push(`spec.md: förbjudet tidsord "${time[0]}"`)
126
+ const form = specForm(spec)
127
+ problems.push(...form.brister)
128
+ warnings.push(...form.varningar)
129
+ kandidater.push(...form.kandidater)
244
130
  }
245
131
 
246
132
  // ---- granskarblock.md ------------------------------------------------------
@@ -362,6 +248,16 @@ if (requireImpact) {
362
248
  }
363
249
  }
364
250
 
251
+ // ---- design.md (designa-steget, spec 2026-09-24) --------------------------
252
+ // Läses bara med --design. Raden `DESIGN: ok|saknas` skrivs ALLTID med
253
+ // flaggan, även vid exit 1: orkestratorn (designa steg 3, bedöm grind 1b)
254
+ // läser läget ur raden, aldrig ur filerna. `ok` betyder att design.md finns
255
+ // och att state.design är satt; giltigheten (hasharna, frontmattern,
256
+ // konflikterna) står som BRISTER bredvid. Kontrollerna som behöver state
257
+ // ligger i state.json-blocket nedan.
258
+ const designText = requireDesign ? read('design.md', true) : null
259
+ let designRad = null
260
+
365
261
  // ---- state.json ------------------------------------------------------------
366
262
  const state = read('state.json', true)
367
263
  if (state != null) {
@@ -408,6 +304,84 @@ if (state != null) {
408
304
  )
409
305
  }
410
306
  }
307
+ // Designproveniens (designa): finns design.md i katalogen ska analysens
308
+ // rad bära dess hash, annars gjordes analysen utan designen eller mot en
309
+ // annan version, samma skevhet som en utbytt spec.
310
+ if (requireImpact && existsSync(join(dir, 'design.md')) && Array.isArray(s.runs)) {
311
+ const impactRun = [...s.runs].reverse().find((r) => r.file === 'impact.md')
312
+ const designRec = impactRun?.underlag_sha256?.['design.md']
313
+ if (impactRun && designRec == null)
314
+ problems.push(
315
+ 'impact.md: analysen gjordes utan det designdokument som nu finns, kör om analysen innan posten bedöms',
316
+ )
317
+ else if (impactRun && designRec !== markerStrippedSha256(join(dir, 'design.md')))
318
+ problems.push(
319
+ 'impact.md: analysen gjordes mot en annan version av design.md, kör om analysen innan posten bedöms',
320
+ )
321
+ }
322
+ if (requireDesign) {
323
+ const design = s.design ?? null
324
+ if (design === null)
325
+ problems.push('state.json: fältet "design" är null, kör archive.mjs add-design')
326
+ designRad = designText != null && design !== null ? 'ok' : 'saknas'
327
+ if (designText != null && design !== null) {
328
+ const hash = markerStrippedSha256(join(dir, 'design.md'))
329
+ if (design.sha256 !== hash)
330
+ problems.push(
331
+ 'design.md: hashen är inte state.design.sha256, filen är ändrad efter add-design',
332
+ )
333
+ if (typeof design.path !== 'string' || !existsSync(design.path))
334
+ problems.push(`design.md: den kanoniska filen ${design.path ?? '?'} saknas i repot`)
335
+ else {
336
+ // Egen try/catch (slutgranskning M1): en sökväg som finns men inte går
337
+ // att läsa (en katalog, EACCES) är en BRIST om den kanoniska filen,
338
+ // aldrig state-blockets "ogiltig JSON" som döljer den.
339
+ let kanoniskHash = null
340
+ try {
341
+ kanoniskHash = markerStrippedSha256(design.path)
342
+ } catch (e) {
343
+ problems.push(
344
+ `design.md: den kanoniska filen ${design.path} går inte att läsa: ${e.message}`,
345
+ )
346
+ }
347
+ if (kanoniskHash !== null && kanoniskHash !== hash)
348
+ problems.push(
349
+ `design.md: den kanoniska filen ${design.path} har en annan hash än design.md`,
350
+ )
351
+ }
352
+ const fm = designFrontmatter(designText) ?? {}
353
+ const id = katalogId(s, dir)
354
+ if (fm.request_id !== id)
355
+ problems.push(
356
+ `design.md: frontmatterns request_id "${fm.request_id ?? ''}" är inte katalogens id "${id}"`,
357
+ )
358
+ if (spec != null && fm.spec_sha256 !== markerStrippedSha256(join(dir, 'spec.md')))
359
+ problems.push(
360
+ 'design.md: spec_sha256 är inte nuvarande spec.md, designen gjordes mot en annan spec',
361
+ )
362
+ const konflikter = specConflicts(designText)
363
+ if (konflikter === null) problems.push('design.md: sektionen "## Spec conflicts" saknas')
364
+ else if (konflikter.length > 0)
365
+ problems.push(
366
+ `design.md: "## Spec conflicts" är inte none (${konflikter.length}), posten går till kunddialog och respec`,
367
+ )
368
+ // Storleks- och poängord i designen är VARNING, aldrig BRIST: dokumentet
369
+ // skrivs av en människostyrd session och assessorn instrueras att
370
+ // bortse från dem, men granskaren ska se dem. Bara brödtexten, aldrig
371
+ // frontmattern (en hash kan råka innehålla "tim").
372
+ const body = designBody(designText)
373
+ const p = body.match(POANGORD_RE)
374
+ if (p)
375
+ warnings.push(
376
+ `design.md: poängord "${p[0]}", assessorn ska bortse från det, granskaren bör se det`,
377
+ )
378
+ const t = body.match(TIDSORD_RE)
379
+ if (t)
380
+ warnings.push(
381
+ `design.md: tidsord "${t[0]}", assessorn ska bortse från det, granskaren bör se det`,
382
+ )
383
+ }
384
+ }
411
385
  } catch {
412
386
  problems.push('state.json: ogiltig JSON')
413
387
  }
@@ -418,6 +392,7 @@ if (state != null) {
418
392
  if (prisavgorande != null) console.log(`PRISAVGORANDE: ${prisavgorande}`)
419
393
  for (const k of kandidater) console.log(`KANDIDAT: ${k}`)
420
394
  if (avvikelse != null) console.log(`AVVIKELSE: ${avvikelse}`)
395
+ if (requireDesign) console.log(`DESIGN: ${designRad ?? 'saknas'}`)
421
396
  for (const w of warnings) console.log(`VARNING: ${w}`)
422
397
  if (problems.length === 0) {
423
398
  console.log('OK: formkontrollen passerad')
@@ -19,6 +19,18 @@ The analysis is written for `request-score`, which points out evidence in it as
19
19
 
20
20
  **The codebase.** Without a repo you cannot answer most of what follows. Say so before you start; do not write an analysis of guesses.
21
21
 
22
+ **Design doc (when present).** The `DESIGN` block is a design decided with a human in
23
+ brainstorming, against this spec. Treat every decision in it as taken: do not reopen
24
+ it, do not list it under _Decisions taken_ as your own, and do not list an alternative
25
+ to it under _Unanswered outcome choices_. Your job is to verify it against the code:
26
+ where the design names files, patterns, storage or integrations, ground them; where
27
+ the code says the design cannot be built as written, that is a `contradiction` on the
28
+ deviation line, named as a design conflict: the free text after the label starts with
29
+ `design conflict:`, and the item goes back to the design before it is priced.
30
+ _Decisions taken_ and _Assumptions_ then hold only what the design left open. Size or
31
+ time wording in the design is not yours to repeat. Without a design the block is the
32
+ line `DESIGN: ingen` and the analysis is written as before.
33
+
22
34
  ## The format
23
35
 
24
36
  Fixed headings, prose under each. No yes/no fields, no checkboxes, no table that looks like a form: an analysis that looks like an answer form is read as an answer form, and then it is you who assigned the points.