@haus-tech/haus-workflow 5.1.0 → 5.3.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.
@@ -18,7 +18,7 @@
18
18
  * node korning.mjs --katalog <abs k<N>> --exp <abs experimentkatalog> --repo-rot <abs>
19
19
  * --request-id <gid eller slug> --modell <modell|default>
20
20
  * [--claude <kommando>] [--delar-parallellt 2]
21
- * [--steg-timeout 1800000]
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
24
  * gissas aldrig), `<exp>/onskemal.md` och `<exp>/kallmanifest.md`. Skriver
@@ -81,6 +81,11 @@ const STANDARD_DELAR_PARALLELLT = 2
81
81
  export const STANDARD_STEG_TIMEOUT = 1800000
82
82
  /** Exitkoden efter ett signalavbrott, kutym för dödad av SIGINT. */
83
83
  const SIGNAL_EXIT = 130
84
+ /** Effortnivåerna `claude --effort` tar, plus `default` som betyder "agentens
85
+ * egen effort ur definitionen" och därför ingen flagga alls, samma regel som
86
+ * `--modell default` (#525, ADR-0120). */
87
+ export const EFFORTNIVAER = ['low', 'medium', 'high', 'xhigh', 'max', 'default']
88
+ const STANDARD_EFFORT = 'default'
84
89
  const VARDEFLAGGOR = [
85
90
  'katalog',
86
91
  'exp',
@@ -90,13 +95,14 @@ const VARDEFLAGGOR = [
90
95
  'claude',
91
96
  'delar-parallellt',
92
97
  'steg-timeout',
98
+ 'effort',
93
99
  ]
94
100
 
95
101
  export const ANVANDNING = [
96
102
  'användning: korning.mjs --katalog <abs k<N>> --exp <abs experimentkatalog> --repo-rot <abs>',
97
103
  ' --request-id <gid eller slug> --modell <modell|default>',
98
104
  ` [--claude <kommando>] [--delar-parallellt ${STANDARD_DELAR_PARALLELLT}]`,
99
- ` [--steg-timeout ${STANDARD_STEG_TIMEOUT}]`,
105
+ ` [--steg-timeout ${STANDARD_STEG_TIMEOUT}] [--effort <${EFFORTNIVAER.join('|')}>]`,
100
106
  ].join('\n')
101
107
 
102
108
  // ---------------------------------------------------------------------------
@@ -112,9 +118,18 @@ export const ANVANDNING = [
112
118
  * poängsätta, precis som dess kontrakt kräver. Listan sätts därför explicit i
113
119
  * stället för att slå av behörighetslagret (`bypassPermissions` hade tillåtit
114
120
  * allt, inklusive skrivningar in i arkivet som bara archive.mjs får göra).
115
- * Assessorns "bara läsande git"-regel blir här mekanisk: fyra prefix, inte en
121
+ * Assessorns "bara läsande git"-regel blir här mekanisk: prefix, inte en
116
122
  * mening i prosa som modellen kan tolka fel.
123
+ *
124
+ * Assessorns lista (#523): sex läsande git-subkommandon, deras
125
+ * `rtk git`-speglingar (den globala CLAUDE.md instruerar agenten att prefixa
126
+ * git med rtk, så `git log` nekades tyst i k2-svepet), och de fyra
127
+ * pipe-målen `head`/`grep`/`wc`/`ls`, som en allow-regel matchar bara som
128
+ * kommandon i kedjan. ALDRIG `Bash(git -C:*)`: prefixet säger inget om vilket
129
+ * subkommando som följer katalogen, så det kan inte matchas säkert. ALDRIG
130
+ * `Bash(echo:*)`: ett fritt echo är ett skrivkommando i förklädnad.
117
131
  */
132
+ const ASSESSOR_GIT = ['ls-files', 'check-ignore', 'log', 'grep', 'status', 'rev-parse']
118
133
  export const AGENT_VERKTYG = {
119
134
  'request-scorer': ['Read', 'Skill'],
120
135
  'request-specifier': ['Read', 'Grep', 'Glob', 'Skill'],
@@ -124,17 +139,21 @@ export const AGENT_VERKTYG = {
124
139
  'Grep',
125
140
  'Glob',
126
141
  'Skill',
127
- 'Bash(git ls-files:*)',
128
- 'Bash(git check-ignore:*)',
129
- 'Bash(git log:*)',
130
- 'Bash(git grep:*)',
142
+ ...ASSESSOR_GIT.map((sub) => `Bash(git ${sub}:*)`),
143
+ ...ASSESSOR_GIT.map((sub) => `Bash(rtk git ${sub}:*)`),
144
+ 'Bash(head:*)',
145
+ 'Bash(grep:*)',
146
+ 'Bash(wc:*)',
147
+ 'Bash(ls:*)',
131
148
  ],
132
149
  }
133
150
 
134
151
  /** Den pinnade headless-invokationen per agentsteg: agentdefinitionen ur
135
- * ~/.claude/agents/ kör med sitt eget verktygsurval och sin egen effort,
136
- * `--model` läggs på bara som override. `default` betyder "agentens egen
137
- * modell" och får därför ingen flagga alls. `--add-dir` öppnar BARA
152
+ * ~/.claude/agents/ kör med sitt eget verktygsurval, `--model` och
153
+ * `--effort` läggs på bara som override. `default` betyder "agentens egen
154
+ * modell" respektive "agentens egen effort" och får därför ingen flagga
155
+ * alls (#525: en effort-override som inte når processen är en proveniens
156
+ * som ljuger). `--add-dir` öppnar BARA
138
157
  * skills-katalogen under Claude-konfigkatalogen: det är den agenten läser
139
158
  * med Read (skillfiler och referensfiler ligger utanför repo-roten som är
140
159
  * cwd), medan agentdefinitionen läses av CLI:n själv och aldrig via Read.
@@ -143,7 +162,7 @@ export const AGENT_VERKTYG = {
143
162
  * check-lasta släpper ändå bara igenom request-score-skillens egen katalog.
144
163
  * `--allowedTools` ligger SIST: flaggan är variadisk och skulle annars svälja
145
164
  * allt som kom efter den. Prompten går på stdin som förut. */
146
- export const agentArgs = ({ agent, modell, maxTurer }) => [
165
+ export const agentArgs = ({ agent, modell, effort = STANDARD_EFFORT, maxTurer }) => [
147
166
  '-p',
148
167
  '--agent',
149
168
  agent,
@@ -154,6 +173,7 @@ export const agentArgs = ({ agent, modell, maxTurer }) => [
154
173
  '--add-dir',
155
174
  path.join(claudeDir(), 'skills'),
156
175
  ...(modell === 'default' ? [] : ['--model', modell]),
176
+ ...(effort === STANDARD_EFFORT ? [] : ['--effort', effort]),
157
177
  ...(AGENT_VERKTYG[agent] === undefined ? [] : ['--allowedTools', ...AGENT_VERKTYG[agent]]),
158
178
  ]
159
179
 
@@ -161,9 +181,17 @@ export const agentArgs = ({ agent, modell, maxTurer }) => [
161
181
  * (ADR-0114). Tabellen bor i lib.mjs; här väljs bara kolumn. */
162
182
  const rubrikrader = (sprak) => SPEC_HEADINGS.map((r) => `**${sprak === 'engelska' ? r.en : r.sv}**`)
163
183
 
164
- /** Specifierns prompt, dispatch-kontraktets mall ordagrant. Ett okänt språk
165
- * kastar: scriptet gissar aldrig rubrikspråk (IPC 14001 fällde tio utkast på
166
- * just den gissningen). */
184
+ /** Ett okänt språk kastar innan någon process startas: scriptet gissar aldrig
185
+ * språk (ADR-0114; IPC 14001 fällde tio utkast på just den gissningen).
186
+ * Samma spärr för alla tre prompterna, #521: kundtexten skrevs på svenska i
187
+ * ett engelskt ärende när bara specifiern fick raden. */
188
+ const kravSprak = (sprak) => {
189
+ if (sprak !== 'svenska' && sprak !== 'engelska')
190
+ throw new Error(`SPRÅK måste vara svenska eller engelska (fick "${sprak}")`)
191
+ return `SPRÅK: ${sprak}`
192
+ }
193
+
194
+ /** Specifierns prompt, dispatch-kontraktets mall ordagrant. */
167
195
  export function specifierPrompt({
168
196
  onskemal,
169
197
  manifest,
@@ -173,8 +201,7 @@ export function specifierPrompt({
173
201
  eskalerad,
174
202
  eskaleringsorsak = null,
175
203
  }) {
176
- if (sprak !== 'svenska' && sprak !== 'engelska')
177
- throw new Error(`SPRÅK måste vara svenska eller engelska (fick "${sprak}")`)
204
+ const sprakRad = kravSprak(sprak)
178
205
  return [
179
206
  'ÖNSKEMÅL:',
180
207
  onskemal.trim(),
@@ -184,7 +211,7 @@ export function specifierPrompt({
184
211
  '',
185
212
  `REPO-ROT: ${repoRot}`,
186
213
  `DEPS: ${deps}`,
187
- `SPRÅK: ${sprak}`,
214
+ sprakRad,
188
215
  'RUBRIKER:',
189
216
  ...rubrikrader(sprak),
190
217
  `ESKALERAD: ${eskalerad ? (eskaleringsorsak ? `ja, ${eskaleringsorsak}` : 'ja') : 'nej'}`,
@@ -193,11 +220,14 @@ export function specifierPrompt({
193
220
  }
194
221
 
195
222
  /** Assessorns prompt. KÄLLOR ligger med (hävstång 5): analysen skrevs förr
196
- * mot ett repo den fick leta i själv, medan manifestet låg färdigt bredvid. */
197
- export function assessorPrompt({ repoRot, deps, spec, granskarblock, manifest }) {
223
+ * mot ett repo den fick leta i själv, medan manifestet låg färdigt bredvid.
224
+ * SPRÅK efter DEPS (#521): analysens kundvända delar följer raden. */
225
+ export function assessorPrompt({ repoRot, deps, sprak, spec, granskarblock, manifest }) {
226
+ const sprakRad = kravSprak(sprak)
198
227
  return [
199
228
  `REPO-ROT: ${repoRot}`,
200
229
  `DEPS: ${deps}`,
230
+ sprakRad,
201
231
  'KÄLLOR:',
202
232
  manifest.trim(),
203
233
  '',
@@ -210,12 +240,15 @@ export function assessorPrompt({ repoRot, deps, spec, granskarblock, manifest })
210
240
  ].join('\n')
211
241
  }
212
242
 
213
- /** Scorerns prompt: underlaget per sökväg, aldrig inklistrat (ADR-0099). */
214
- export function scorerPrompt({ requestId, delpost, specPath, impactPath }) {
243
+ /** Scorerns prompt: underlaget per sökväg, aldrig inklistrat (ADR-0099).
244
+ * SPRÅK efter DELPOST (#521): kundtexten i kuvertet följer raden. */
245
+ export function scorerPrompt({ requestId, delpost, sprak, specPath, impactPath }) {
246
+ const sprakRad = kravSprak(sprak)
215
247
  return [
216
248
  `REQUEST_ID: ${requestId}`,
217
249
  'MODE: pricing',
218
250
  `DELPOST: ${delpost ? `ja, del ${delpost.part} av ${delpost.of}` : 'nej'}`,
251
+ sprakRad,
219
252
  '',
220
253
  `SPEC: ${specPath}`,
221
254
  `PÅVERKANSANALYS: ${impactPath}`,
@@ -385,10 +418,23 @@ export function slaIhopKorning(steg) {
385
418
  fil: s.fil,
386
419
  agent: s.agent,
387
420
  modell_begard: s.modell_begard,
421
+ // "begärd", inte verifierad: modellen prövas mot modelUsage (ADR-0099),
422
+ // men faktureringen bär ingen effort, så fältet kan inte kontrolleras
423
+ // mot något och ingen effortverifiering uppfinns (#525, ADR-0120).
424
+ effort_begard: s.effort_begard ?? STANDARD_EFFORT,
388
425
  total_cost_usd: s.svar?.total_cost_usd ?? 0,
389
426
  usage: summera(USAGE_FALT, null, s.svar?.usage),
390
427
  num_turns: s.svar?.num_turns ?? 0,
391
428
  wall_ms: s.wall_ms ?? null,
429
+ // Lasten wall_ms mättes under (#526): maskinvid och best effort, null
430
+ // när räknaren föll eller steget är äldre än fältet.
431
+ samtidiga_processer: s.samtidiga_processer ?? null,
432
+ // #523: CLI:ns JSON-svar bär `permission_denials` som lista; antalet
433
+ // bokförs så en tyst nekning syns. Saknas fältet är det null, aldrig
434
+ // noll: en nolla skulle läsas som "inget nekades".
435
+ permission_denials: Array.isArray(s.svar?.permission_denials)
436
+ ? s.svar.permission_denials.length
437
+ : null,
392
438
  is_error: s.svar ? s.svar.is_error === true : true,
393
439
  })),
394
440
  }
@@ -482,7 +528,7 @@ const lasFil = (p) => {
482
528
  /** Argumenttolkning, fail-closed som svep.mjs och kontroll.mjs: alla saknade
483
529
  * flaggor namnges på en gång, en okänd flagga eller ett värdelöst flaggvärde
484
530
  * stoppar direkt i stället för att gissa. */
485
- function tolkaArgv(argv) {
531
+ export function tolkaArgv(argv) {
486
532
  const enkla = {}
487
533
  for (let i = 0; i < argv.length; i++) {
488
534
  const a = argv[i]
@@ -506,6 +552,9 @@ function tolkaArgv(argv) {
506
552
  const timeoutText = enkla['steg-timeout'] ?? String(STANDARD_STEG_TIMEOUT)
507
553
  if (!/^\d+$/.test(timeoutText) || Number(timeoutText) < 1)
508
554
  return { fel: `--steg-timeout måste vara ett heltal >= 1 (fick "${timeoutText}")` }
555
+ const effort = enkla.effort ?? STANDARD_EFFORT
556
+ if (!EFFORTNIVAER.includes(effort))
557
+ return { fel: `--effort måste vara en av ${EFFORTNIVAER.join(', ')} (fick "${effort}")` }
509
558
  return {
510
559
  katalog: enkla.katalog,
511
560
  exp: enkla.exp,
@@ -515,6 +564,7 @@ function tolkaArgv(argv) {
515
564
  claude: enkla.claude ?? 'claude',
516
565
  delarParallellt: Number(takText),
517
566
  stegTimeout: Number(timeoutText),
567
+ effort,
518
568
  }
519
569
  }
520
570
 
@@ -591,13 +641,33 @@ function tolkaSteg({ stdout, stegFil, modell, spawnfel, tidsgransMs, exitCode })
591
641
  }
592
642
  }
593
643
 
644
+ /** Levande agentprocesser på maskinen vid ett stegs start (#526, ADR-0120):
645
+ * `pgrep -f` över mönstret varje agentsteg bär (`--agent request-`), räknat
646
+ * EFTER att det egna steget startats, så det ingår i talet. Värdet är
647
+ * MASKINVIDT, inte körningens: det fångar även en annan körnings steg och ett
648
+ * annat kontos svep mot samma checkout (ADR-0093), vilket är precis den last
649
+ * wall_ms mättes under utan att den bokfördes. Best effort: exit 1 är
650
+ * "ingen träff" (0), allt annat (pgrep saknas, exit >= 2, ett kast) blir
651
+ * null, aldrig ett kast som fäller steget. `kor` är injicerbar för testerna. */
652
+ export function raknaSamtidiga(kor = spawnSync) {
653
+ try {
654
+ const r = kor('pgrep', ['-f', '--', '--agent request-'], { encoding: 'utf8' })
655
+ if (r.error || (r.status !== 0 && r.status !== 1)) return null
656
+ return String(r.stdout ?? '')
657
+ .split('\n')
658
+ .filter((rad) => rad.trim() !== '').length
659
+ } catch {
660
+ return null
661
+ }
662
+ }
663
+
594
664
  /** Ett agentsteg: en egen `claude -p --agent`-process med prompten på stdin,
595
665
  * svaret RÅTT i `steg/<fil>` och klockan runt den. Barnet läggs i `ctx.barn`
596
666
  * så signalhanteraren kan döda det: en dödad korning.mjs får aldrig lämna
597
667
  * betalda `claude`-processer efter sig. Väggtidstaket (`--steg-timeout`)
598
668
  * dödar ett steg som hänger utan att svara. Anroparen avgör vad ett fallet
599
669
  * steg betyder. */
600
- function korAgent({ agent, modell, prompt, fil, maxTurer, ctx }) {
670
+ export function korAgent({ agent, modell, prompt, fil, maxTurer, ctx }) {
601
671
  return new Promise((los) => {
602
672
  const start = Date.now()
603
673
  const stegFil = path.join(ctx.katalog, fil)
@@ -606,23 +676,38 @@ function korAgent({ agent, modell, prompt, fil, maxTurer, ctx }) {
606
676
  let klar = false
607
677
  let tidsgransMs = null
608
678
  let timer = null
679
+ let samtidiga = null
609
680
  const avsluta = ({ exitCode, spawnfel = null }) => {
610
681
  if (klar) return
611
682
  klar = true
612
683
  if (timer !== null) clearTimeout(timer)
613
684
  const wall_ms = Date.now() - start
614
685
  const t = tolkaSteg({ stdout, stegFil, modell, spawnfel, tidsgransMs, exitCode })
615
- ctx.samla({ fil, agent, modell_begard: modell, svar: t.svar, wall_ms })
686
+ ctx.samla({
687
+ fil,
688
+ agent,
689
+ modell_begard: modell,
690
+ effort_begard: ctx.effort ?? STANDARD_EFFORT,
691
+ svar: t.svar,
692
+ wall_ms,
693
+ samtidiga_processer: samtidiga,
694
+ })
616
695
  if (t.orsak !== null && stderr.trim() !== '')
617
696
  logg(`${fil} stderr: ${stderr.trim().slice(0, 200)}`)
618
697
  logg(
619
698
  `STEG ${fil} ${agent} ${modell} ${wall_ms}ms ${t.orsak === null ? 'ok' : `fallet: ${t.orsak}`}`,
620
699
  )
700
+ // #523: en nekning i headless-läget syns annars ingenstans förrän
701
+ // analysen saknar det den skulle ha verifierat.
702
+ const nekade = Array.isArray(t.svar?.permission_denials)
703
+ ? t.svar.permission_denials.length
704
+ : 0
705
+ if (nekade > 0) logg(`STEG ${fil} ${nekade} nekade verktygsanrop`)
621
706
  los({ ...t, exitCode, wall_ms, ok: t.orsak === null })
622
707
  }
623
708
  let barn
624
709
  try {
625
- barn = spawn(ctx.claude, agentArgs({ agent, modell, maxTurer }), {
710
+ barn = spawn(ctx.claude, agentArgs({ agent, modell, effort: ctx.effort, maxTurer }), {
626
711
  cwd: ctx.repoRot,
627
712
  stdio: ['pipe', 'pipe', 'pipe'],
628
713
  })
@@ -631,6 +716,9 @@ function korAgent({ agent, modell, prompt, fil, maxTurer, ctx }) {
631
716
  return
632
717
  }
633
718
  ctx.barn.add(barn)
719
+ // Räknas efter spawn, så det egna steget ingår; spawn är synkron i libuv
720
+ // och barnet finns med sin argv när anropet återvänder.
721
+ samtidiga = (ctx.raknaSamtidiga ?? raknaSamtidiga)()
634
722
  timer = setTimeout(() => {
635
723
  tidsgransMs = ctx.stegTimeout
636
724
  logg(`STEG ${fil} dödas: tidsgräns ${ctx.stegTimeout} ms`)
@@ -679,30 +767,35 @@ const SPEC_REF = () =>
679
767
  refFlagga('skills/request-write-spec/references/specformat.md', 'references/specformat.md')
680
768
  const SCORE_REF = () => refFlagga('skills/request-score/references/poangskala.md', 'poangskala.md')
681
769
 
770
+ /** Argumenten till `archive.mjs add-run` för en runs-rad. `--effort` skickas
771
+ * exakt när körningen begärt en override: utan flaggan läser add-run
772
+ * `effort:` ur agentdefinitionen (ADR-0107 beslut 3), vilket är rätt för
773
+ * `default` och fel för allt annat (#525: 40 av 44 rader i IPC 14001 bar
774
+ * definitionens värde över en körning som begärt ett annat). */
775
+ export const addRunArgs = (dir, rad, effort = STANDARD_EFFORT) => [
776
+ 'add-run',
777
+ dir,
778
+ '--file',
779
+ rad.fil,
780
+ '--skill',
781
+ rad.skill,
782
+ '--agent',
783
+ rad.agent,
784
+ '--model',
785
+ rad.model ?? 'okänd',
786
+ ...(effort === STANDARD_EFFORT ? [] : ['--effort', effort]),
787
+ ...(rad.fakt ? ['--fakturerad', rad.fakt] : []),
788
+ ...(rad.ref ?? []),
789
+ ...(rad.note ? ['--note', rad.note] : []),
790
+ ...(rad.doubleRun ? ['--double-run', rad.doubleRun] : []),
791
+ ...(rad.supersededBy ? ['--superseded-by', rad.supersededBy] : []),
792
+ ]
793
+
682
794
  /** En runs-rad. Arkivet är append-only och äger sina egna vägransregler; en
683
795
  * vägrad stämpling loggas men fäller aldrig körningen, kvittot på vad som
684
796
  * hände ligger kvar i steg/. */
685
797
  function addRun(dir, rad, ctx) {
686
- const r = arkiv(
687
- [
688
- 'add-run',
689
- dir,
690
- '--file',
691
- rad.fil,
692
- '--skill',
693
- rad.skill,
694
- '--agent',
695
- rad.agent,
696
- '--model',
697
- rad.model ?? 'okänd',
698
- ...(rad.fakt ? ['--fakturerad', rad.fakt] : []),
699
- ...(rad.ref ?? []),
700
- ...(rad.note ? ['--note', rad.note] : []),
701
- ...(rad.doubleRun ? ['--double-run', rad.doubleRun] : []),
702
- ...(rad.supersededBy ? ['--superseded-by', rad.supersededBy] : []),
703
- ],
704
- ctx,
705
- )
798
+ const r = arkiv(addRunArgs(dir, rad, ctx.effort), ctx)
706
799
  if (r.status !== 0) logg(`VARNING add-run ${rad.fil}: ${felText(r)}`)
707
800
  return r.status === 0
708
801
  }
@@ -987,6 +1080,7 @@ async function korAssessor(del, ctx) {
987
1080
  const prompt = assessorPrompt({
988
1081
  repoRot: ctx.repoRot,
989
1082
  deps: ctx.deps,
1083
+ sprak: ctx.sprak,
990
1084
  spec,
991
1085
  granskarblock: lasFil(path.join(del.dir, 'granskarblock.md')) ?? 'inget granskarblock',
992
1086
  manifest: ctx.manifest,
@@ -1098,6 +1192,11 @@ function korKontroll(txt, ren, fixFil, del, ctx) {
1098
1192
  fixPath,
1099
1193
  '--state',
1100
1194
  path.join(del.dir, 'state.json'),
1195
+ // #521: kundtextens språk till contract-check. Ett språkbrott är inte
1196
+ // kundtextfixbart (fixen byter ord, översätter inte) och går till
1197
+ // omdispatch som varje annat modellfel.
1198
+ '--sprak',
1199
+ ctx.sprak,
1101
1200
  ],
1102
1201
  ctx,
1103
1202
  )
@@ -1114,7 +1213,15 @@ function samlaFixsteg(fil, fixPath, ctx) {
1114
1213
  const rasvar = lasJson(fixPath)
1115
1214
  if (!Array.isArray(rasvar)) return
1116
1215
  for (const svar of rasvar)
1117
- ctx.samla({ fil, agent: 'kundtextfix', modell_begard: FIXMODELL, svar, wall_ms: null })
1216
+ ctx.samla({
1217
+ fil,
1218
+ agent: 'kundtextfix',
1219
+ modell_begard: FIXMODELL,
1220
+ // kontroll.mjs får ingen effort-override, fixanropet kör CLI:ns default.
1221
+ effort_begard: STANDARD_EFFORT,
1222
+ svar,
1223
+ wall_ms: null,
1224
+ })
1118
1225
  }
1119
1226
 
1120
1227
  /** En scorerkörning från agentsvar till dom: kontrollkedjan, EN omdispatch vid
@@ -1137,6 +1244,9 @@ async function kontrolleraScorer(del, k, res, prompt, ctx) {
1137
1244
  modellSvans: kontroll.modell ?? parseSvansrader(sista?.text ?? '').modell,
1138
1245
  fakt: fakturerad(sista?.svar, ctx.modell),
1139
1246
  reparerat: kontroll.reparerat ?? [],
1247
+ // Brotten som tvingade en lyckad omdispatch (#522): kasserades de här
1248
+ // saknade runs-raden spår av varför omkörningen betalades.
1249
+ omdispatchadFor: kontroll.omdispatchadFor ?? [],
1140
1250
  }
1141
1251
  }
1142
1252
  if (!res.ok) {
@@ -1168,7 +1278,11 @@ async function kontrolleraScorer(del, k, res, prompt, ctx) {
1168
1278
  writeFileSync(txt, om.text)
1169
1279
  const andra = korKontroll(txt, ren, `${bas}-omdispatch-kundtextfix.json`, del, ctx)
1170
1280
  if (andra.exit2) return { exit2: true, orsak: `kontroll run${k} (omdispatch): ${andra.orsak}` }
1171
- kontroll = { ...andra, reparerat: [...kontroll.reparerat, ...andra.reparerat] }
1281
+ kontroll = {
1282
+ ...andra,
1283
+ reparerat: [...kontroll.reparerat, ...andra.reparerat],
1284
+ omdispatchadFor: kontroll.brott,
1285
+ }
1172
1286
  sista = om
1173
1287
  }
1174
1288
  return svarsrad(kontroll, sista)
@@ -1180,6 +1294,7 @@ async function korScorers(del, ctx) {
1180
1294
  const prompt = scorerPrompt({
1181
1295
  requestId: ctx.requestId,
1182
1296
  delpost: del.delpost,
1297
+ sprak: ctx.sprak,
1183
1298
  specPath: path.join(del.dir, 'spec.md'),
1184
1299
  impactPath: path.join(del.dir, 'impact.md'),
1185
1300
  })
@@ -1227,8 +1342,20 @@ function skrivPris(del, namn, kr) {
1227
1342
  writeFileSync(path.join(del.dir, namn), JSON.stringify(innehall, null, 2) + '\n')
1228
1343
  }
1229
1344
 
1230
- const scorerNote = (kr, extra = []) =>
1231
- [...extra, ...kr.reparerat.map((r) => `reparerat: ${r}`)].filter(Boolean).join('; ') || null
1345
+ /** Runs-radens note: extra först, sedan varför en lyckad omdispatch behövdes
1346
+ * (#522) och sist vad kedjan reparerade. Null när inget finns. */
1347
+ export const scorerNote = (kr, extra = []) => {
1348
+ const omdispatchad = (kr.omdispatchadFor ?? []).map((b) => b.replace(/^- /, ''))
1349
+ return (
1350
+ [
1351
+ ...extra,
1352
+ ...(omdispatchad.length > 0 ? [`omdispatchad för: ${omdispatchad.join('; ')}`] : []),
1353
+ ...kr.reparerat.map((r) => `reparerat: ${r}`),
1354
+ ]
1355
+ .filter(Boolean)
1356
+ .join('; ') || null
1357
+ )
1358
+ }
1232
1359
 
1233
1360
  const scorerRad = (kr, fil, extra = {}) => ({
1234
1361
  fil,
@@ -1439,7 +1566,13 @@ async function main(argv) {
1439
1566
  return 2
1440
1567
  }
1441
1568
  const allaSteg = []
1442
- const ctx = { ...val, ...underlag, barn: new Set(), samla: (steg) => allaSteg.push(steg) }
1569
+ const ctx = {
1570
+ ...val,
1571
+ ...underlag,
1572
+ barn: new Set(),
1573
+ samla: (steg) => allaSteg.push(steg),
1574
+ raknaSamtidiga,
1575
+ }
1443
1576
  mkdirSync(path.join(ctx.katalog, 'steg'), { recursive: true })
1444
1577
  const nyckel = `${path.basename(path.dirname(ctx.katalog))}/${path.basename(ctx.katalog)}`
1445
1578
  for (const signal of ['SIGTERM', 'SIGINT'])
@@ -441,6 +441,60 @@ export const prisavgorandeForm = (text) => {
441
441
 
442
442
  const INGA_ORD = ['inga', 'none', '-', 'tom']
443
443
 
444
+ /**
445
+ * Funktionsord per språk för språkgrinden på kundtext (#521). Heuristiken är
446
+ * medvetet smal: minst två OLIKA hela ord ur fel språk fäller, ett enda är
447
+ * ett lån eller ett namn. Inget ord får vara vanligt i det andra språket
448
+ * ("for" är inte svenska, "för" är inte engelska); homograferna som ändå
449
+ * finns (svenskans "is", "and", "not", "for") fångas av tvåordsgränsen.
450
+ * Diakritiska tecken är ingen signal: Göteborg står i engelsk text.
451
+ */
452
+ export const FUNKTIONSORD = {
453
+ svenska: ['och', 'att', 'inte', 'för', 'som', 'är', 'med', 'till', 'av', 'en', 'ett'],
454
+ engelska: ['the', 'and', 'with', 'that', 'this', 'for', 'is', 'are', 'will', 'not', 'of', 'to'],
455
+ }
456
+ const ANDRA_SPRAKET = { svenska: 'engelska', engelska: 'svenska' }
457
+
458
+ /** De olika funktionsorden ur FEL språk som `text` bär som hela ord,
459
+ * skiftlägesokänsligt, i listans ordning. `sprak` är det väntade språket;
460
+ * ett okänt språk kastar, grinden gissar aldrig (ADR-0114). */
461
+ export const felSprakOrd = (text, sprak) => {
462
+ const fel = ANDRA_SPRAKET[sprak]
463
+ if (fel === undefined)
464
+ throw new Error(`SPRÅK måste vara svenska eller engelska (fick "${sprak}")`)
465
+ return FUNKTIONSORD[fel].filter((ord) =>
466
+ new RegExp(`(?<![\\p{L}\\d_])${escapeRe(ord)}(?![\\p{L}\\d_])`, 'iu').test(String(text)),
467
+ )
468
+ }
469
+
470
+ const utfallsvalRadRe = () =>
471
+ new RegExp(
472
+ `^\\*\\*(?:${[UTFALLSVAL_RAD.sv, UTFALLSVAL_RAD.en].map(escapeRe).join('|')}):\\*\\*\\s*(.*)$`,
473
+ 'u',
474
+ )
475
+ /** Raderna under raden Obesvarade utfallsval fram till nästa fetstilsrad eller
476
+ * rubrik, plus texten efter kolonet; null när raden saknas. Raden är en
477
+ * fetstilsrad, inte en rubrik, så sektionen slutar vid nästa `**…:**`. */
478
+ const utfallsvalAvsnitt = (text) => {
479
+ const lines = String(text).replace(/\r\n/g, '\n').split('\n')
480
+ const radRe = utfallsvalRadRe()
481
+ const start = lines.findIndex((l) => radRe.test(l.trim()))
482
+ if (start === -1) return null
483
+ const efterKolon = radRe.exec(lines[start].trim())[1].trim()
484
+ const rader = []
485
+ for (const l of lines.slice(start + 1)) {
486
+ const t = l.trim()
487
+ if (isHeadingLine(t) || /^\*\*[^*]+:\*\*/.test(t)) break
488
+ rader.push(l)
489
+ }
490
+ return { efterKolon, rader }
491
+ }
492
+ const LISTPUNKT_RE = /^(?:[-*+]|\d+[.)])\s+(.*)$/
493
+ /** Ett inga-ord som första ord efter kolonet räknas som inga oavsett svans
494
+ * ("inga, eftersom …"); "ingalunda" börjar bara som ett och räknas inte. */
495
+ const arIngaOrd = (efterKolon) =>
496
+ /^(?:inga|none|tom)(?![\p{L}\d])/iu.test(efterKolon) || /^-\s*\.?$/.test(efterKolon)
497
+
444
498
  /**
445
499
  * Antalet listade, ostrukna alternativ under raden Obesvarade utfallsval
446
500
  * (#507). Raden är en fetstilsrad, inte en rubrik: sektionen är allt till
@@ -451,24 +505,17 @@ const INGA_ORD = ['inga', 'none', '-', 'tom']
451
505
  * äger precheck felet).
452
506
  */
453
507
  export const utfallsvalAlternativ = (text) => {
454
- const lines = String(text).replace(/\r\n/g, '\n').split('\n')
455
- const radRe = new RegExp(
456
- `^\\*\\*(?:${[UTFALLSVAL_RAD.sv, UTFALLSVAL_RAD.en].map(escapeRe).join('|')}):\\*\\*\\s*(.*)$`,
457
- 'u',
458
- )
459
- const start = lines.findIndex((l) => radRe.test(l.trim()))
460
- if (start === -1) return null
461
- const efterKolon = radRe.exec(lines[start].trim())[1].trim().toLowerCase()
508
+ const avsnitt = utfallsvalAvsnitt(text)
509
+ if (avsnitt === null) return null
510
+ const efterKolon = avsnitt.efterKolon.toLowerCase()
462
511
  if (INGA_ORD.includes(efterKolon.replace(/[.]$/, ''))) return 0
463
512
  let antal = 0
464
- for (const l of lines.slice(start + 1)) {
465
- const t = l.trim()
466
- if (isHeadingLine(t) || /^\*\*[^*]+:\*\*/.test(t)) break
513
+ for (const l of avsnitt.rader) {
467
514
  // Ett alternativ är ett listobjekt på kolumn 0 av originalraden (#507
468
515
  // uppföljning): en indenterad rad under ett objekt är en underrad
469
516
  // (delar av samma alternativ, t.ex. en struket-motivering), inte ett
470
- // eget alternativ. Kravet ligger på `l`, inte på den trimmade `t`.
471
- const m = /^(?:[-*+]|\d+[.)])\s+(.*)$/.exec(l)
517
+ // eget alternativ. Kravet ligger på `l`, inte på en trimmad rad.
518
+ const m = LISTPUNKT_RE.exec(l)
472
519
  if (!m) continue
473
520
  // Prefixtestet körs mot normaliserad text: markdown-emfas (**fetstil**,
474
521
  // _kursiv_, `kod`) runt "struket:" får inte dölja markören för
@@ -479,6 +526,23 @@ export const utfallsvalAlternativ = (text) => {
479
526
  return antal
480
527
  }
481
528
 
529
+ /**
530
+ * Sant när avsnittet under Obesvarade utfallsval bär annan text än en lista
531
+ * eller ett inga-ord (#520): alternativ i löptext ("(a) … (b) …"), inline
532
+ * efter kolonet eller på följande rader. Finns minst en listpunkt på kolumn 0
533
+ * är det aldrig prosa, oavsett text runt om: då räknar utfallsvalAlternativ.
534
+ * Ett inga-ord som första ord räknas som inga oavsett svans ("inga, eftersom
535
+ * …"). null när raden saknas (precheck äger felet). Hjälparen tolkar aldrig
536
+ * vad prosan säger, den ser bara att formen saknas.
537
+ */
538
+ export const utfallsvalProsa = (text) => {
539
+ const avsnitt = utfallsvalAvsnitt(text)
540
+ if (avsnitt === null) return null
541
+ if (arIngaOrd(avsnitt.efterKolon)) return false
542
+ if (avsnitt.rader.some((l) => LISTPUNKT_RE.test(l))) return false
543
+ return avsnitt.efterKolon !== '' || avsnitt.rader.some((l) => l.trim() !== '')
544
+ }
545
+
482
546
  /**
483
547
  * Närmaste katalog, från `dir` och uppåt, som innehåller `file`; sökvägen
484
548
  * till filen eller null. Stannar vid en katalog med ett namn i `stopNames`
@@ -28,6 +28,7 @@ import {
28
28
  avvikelseEtikett,
29
29
  AVVIKELSE_ETIKETTER,
30
30
  utfallsvalAlternativ,
31
+ utfallsvalProsa,
31
32
  prisavgorandeVal,
32
33
  prisavgorandeForm,
33
34
  } from './lib.mjs'
@@ -264,6 +265,21 @@ if (requireImpact) {
264
265
  problems.push(
265
266
  `impact.md: motsägelse med ${alternativ} listade alternativ under "Obesvarade utfallsval": välj precisering och låt fyrstegsregeln avgöra, eller stryk alternativen med prefixet "struket:" och skälet`,
266
267
  )
268
+ // #520: alternativ i löptext ("(a) … (b) …") räknas inte som listade
269
+ // och passerade grinden ovan som om raden vore "inga". Formen krävs:
270
+ // vid motsägelse är prosa en BRIST (grinden kan annars inte avgöra om
271
+ // etiketten är rätt), vid precisering en VARNING (scorerns
272
+ // fyrstegsregel läser listan, prosa läser den inte). Aldrig en
273
+ // tolkning av vad "(a)" betyder.
274
+ const formkrav =
275
+ 'skriv en listpunkt per alternativ på kolumn 0 ("- …"), stryk med prefixet "struket:" och skälet, eller skriv "inga"'
276
+ if (
277
+ (avvikelse === 'motsägelse' || avvikelse === 'precisering') &&
278
+ utfallsvalProsa(impact) === true
279
+ )
280
+ (avvikelse === 'motsägelse' ? problems : warnings).push(
281
+ `impact.md: ${avvikelse} med alternativ i löptext under "Obesvarade utfallsval": ${formkrav}`,
282
+ )
267
283
  }
268
284
  // Analysen får inte läcka poängsättning eller tid (ADR-0098: kontrollen
269
285
  // gällde tidigare klassord; klassaxeln är borttagen och regeln riktas nu