@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.
- package/CHANGELOG.md +13 -0
- package/dist/cli.js +13 -19
- package/library/catalog/manifest.json +4 -4
- package/library/global/agents/request-assessor.md +5 -1
- package/library/global/agents/request-specifier.md +3 -1
- package/library/global/skills/request/SKILL.md +394 -59
- package/library/global/skills/request/scripts/archive.mjs +546 -49
- package/library/global/skills/request/scripts/korning.mjs +41 -4
- package/library/global/skills/request/scripts/lib.mjs +189 -0
- package/library/global/skills/request/scripts/precheck.mjs +105 -130
- package/library/global/skills/request-assess-impact/SKILL.md +12 -0
- package/library/global/skills/request-sync/SKILL.md +61 -100
- package/library/global/skills/request-write-spec/SKILL.md +15 -1
- package/package.json +3 -3
|
@@ -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>/
|
|
25
|
-
* `steg/*.json`, spec/impact/prisfiler i körningskatalogen
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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.
|