@haus-tech/haus-workflow 5.0.1 → 5.2.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.
@@ -8,17 +8,32 @@
8
8
  *
9
9
  * Del 1: add-run, next-n, sparr. Del 2 (Task 7): utslag (granskardomar),
10
10
  * set-status (statusövergångar, med spärr på priced) och kalibrering
11
- * (läs-enbart svepvy över kalibreringsnotiser).
11
+ * (läs-enbart svepvy över kalibreringsnotiser). Del 3 (plan requestmotor-14001,
12
+ * Task 6): stopp (stopputfall utan scorer, ur spec.md eller impact.md), respec
13
+ * (nollställning inför en ny spec) och bluf (tabellvy över en katalog och dess
14
+ * delar), samt effort ur agentdefinitionen i add-run och ett state.json-skelett
15
+ * från ny-korning. Del 4 (plan utvardera-parallellt-svep, Task 1): experiment,
16
+ * den enda skrivaren av experiment.json på experimentytan, låst och atomisk,
17
+ * så samtidiga svep-processer aldrig tappar varandras rader (#493). Del 5
18
+ * (#530): nasta-steg, läs-enbart tabell över var posten står, vad en människa
19
+ * väntas göra och vilket /request-kommando som kör vidare, ur SKILL.md:s
20
+ * egna grindar (regeltabellen står ovanför `nastaStegFor`).
12
21
  *
13
22
  * Användning:
14
23
  * node archive.mjs add-run <katalog> --file F --skill S --agent A --model M [flaggor]
15
24
  * node archive.mjs next-n <katalog> --typ classification [--typ score]
16
- * node archive.mjs ny-korning <modellkatalog>
25
+ * node archive.mjs ny-korning <modellkatalog> [--request-id G]
26
+ * node archive.mjs ny-del <körningskatalog> --typ bundle|split --part N --of M --request-id G
17
27
  * node archive.mjs proveniens <körningskatalog> --modell M --konto K --underlag F [--datum D]
18
28
  * node archive.mjs sparr <katalog>
19
29
  * node archive.mjs utslag <katalog> --typ viktning|kalibrering|sakandring --beslutad-av N [...]
20
30
  * node archive.mjs set-status <katalog> <status>
31
+ * node archive.mjs stopp <katalog> --utfall KONCEPT|DIAGNOS|TILLBAKA_TILL_SPEC|ESKALERA --kalla spec.md|impact.md|granskarblock.md [--orsak <text>]
32
+ * node archive.mjs respec <katalog>
33
+ * node archive.mjs bluf <katalog>
34
+ * node archive.mjs nasta-steg <katalog>
21
35
  * node archive.mjs kalibrering <arkivrot>
36
+ * node archive.mjs experiment <experimentkatalog> [--satt K=V]... [--variant M]... [--korning M/kN --status S [--orsak T] [--pid N]]
22
37
  * Exit 0 = ok, 1 = spärrad (sparr/set-status priced), 2 = användnings-/tillståndsfel.
23
38
  */
24
39
  import {
@@ -38,8 +53,13 @@ import path from 'node:path'
38
53
  import { createHash } from 'node:crypto'
39
54
  import {
40
55
  markerStrippedSha256,
56
+ stripHausMarker,
41
57
  claudeDir,
42
58
  canonicalModel,
59
+ kandidatIn,
60
+ avvikelseEtikett,
61
+ prisavgorandeVal,
62
+ findUpward,
43
63
  CLASS_UTFALL,
44
64
  SCORE_OUTCOMES,
45
65
  } from './lib.mjs'
@@ -48,7 +68,7 @@ const [cmd, katalog, ...rest] = process.argv.slice(2)
48
68
 
49
69
  if (!cmd || !katalog) {
50
70
  console.error(
51
- 'användning: archive.mjs <add-run|next-n|ny-korning|proveniens|sparr|utslag|set-status|kalibrering> <katalog> [flaggor]',
71
+ 'användning: archive.mjs <add-run|next-n|ny-korning|ny-del|proveniens|sparr|utslag|set-status|stopp|respec|bluf|nasta-steg|kalibrering|experiment> <katalog> [flaggor]',
52
72
  )
53
73
  process.exit(2)
54
74
  }
@@ -81,6 +101,35 @@ const requireFlag = (name) => {
81
101
  }
82
102
  const has = (name) => rest.includes(`--${name}`)
83
103
 
104
+ /** Fail-closed flaggkontroll, samma princip som add-run och next-n tillämpar
105
+ * inline: varje token i `rest` måste höra till en KÄND flagga, annars är det
106
+ * ett fel (exit 2), aldrig ett tyst struntvärde. Värdeflaggor konsumerar
107
+ * nästa token, booleska flaggor bara sig själva. */
108
+ const avvisaOkandaFlaggor = (kommando, vardeflaggor, boolflaggor = []) => {
109
+ const konsumerat = new Set()
110
+ rest.forEach((v, i) => {
111
+ if (!v.startsWith('--')) return
112
+ const namn = v.slice(2)
113
+ if (vardeflaggor.includes(namn)) {
114
+ konsumerat.add(i)
115
+ konsumerat.add(i + 1)
116
+ } else if (boolflaggor.includes(namn)) {
117
+ konsumerat.add(i)
118
+ }
119
+ })
120
+ const ovantat = rest.filter((_, i) => !konsumerat.has(i))
121
+ if (ovantat.length > 0) {
122
+ const giltiga = [...vardeflaggor, ...boolflaggor].map((n) => `--${n}`)
123
+ console.error(
124
+ `${kommando}: okänd eller ej konsumerad flagga/argument (${ovantat.join(' ')}), ` +
125
+ (giltiga.length === 0
126
+ ? 'kommandot tar inga flaggor'
127
+ : `giltiga flaggor: ${giltiga.join(', ')}`),
128
+ )
129
+ process.exit(2)
130
+ }
131
+ }
132
+
84
133
  /** Sökvägen till en katalogs state.json. Parametern har `katalog` som
85
134
  * default så varje befintlig anropare är oförändrad; `proveniens` behöver
86
135
  * den för att kunna stämpla delkatalogerna med samma kod som föräldern, i
@@ -158,6 +207,16 @@ const readStateOrThrow = (d = katalog) => {
158
207
  }
159
208
  assertRowArrayField(parsed, 'runs', d)
160
209
  assertRowArrayField(parsed, 'granskarutslag', d)
210
+ // `stopp` är null, saknat (äldre arkiv) eller ett objekt { utfall, kalla,
211
+ // date }. Allt annat är trasigt: pricedBlockers och bluf läser fältet, och
212
+ // en sträng där hade tyst lästs som "inget stopp".
213
+ if (
214
+ parsed.stopp !== undefined &&
215
+ parsed.stopp !== null &&
216
+ (typeof parsed.stopp !== 'object' || Array.isArray(parsed.stopp))
217
+ ) {
218
+ throw new ArkivFel(`${statePath(d)} har ett stopp-fält som varken är null eller ett objekt`)
219
+ }
161
220
  return parsed
162
221
  }
163
222
 
@@ -180,31 +239,73 @@ const loadStateOrExit = () => {
180
239
  * skrivfel (t.ex. EACCES) i stället för att låta undantaget nå toppen som
181
240
  * en okontrollerad krasch med exit 1 — i det här kontraktet betyder exit 1
182
241
  * spärrad, och ett misslyckat skrivförsök är inte det. */
183
- const writeStateOrThrow = (s, d = katalog) => {
242
+ const writeStateOrThrow = (s, d = katalog) => writeJsonAtomiskOrThrow(s, d, 'state.json')
243
+
244
+ /** Samma rename-dans för vilken JSON-fil som helst i en katalog: state.json
245
+ * och experiment.json delar en enda skrivväg i stället för två kopior som
246
+ * kan glida isär (filnamnet är den enda skillnaden). */
247
+ const writeJsonAtomiskOrThrow = (s, d, fil) => {
248
+ const mal = path.join(d, fil)
184
249
  const rand = Math.random().toString(36).slice(2, 8)
185
- const tmp = path.join(d, `.state.json.tmp-${process.pid}-${rand}`)
250
+ const tmp = path.join(d, `.${fil}.tmp-${process.pid}-${rand}`)
186
251
  try {
187
252
  writeFileSync(tmp, JSON.stringify(s, null, 2) + '\n')
188
253
  // rename(2) byter inod, så temp-filens läge (satt av umask vid writeFileSync)
189
254
  // skulle annars tyst ersätta en avsiktligt hårdare rättighet på den
190
- // befintliga state.json (t.ex. 0600) med standardläget. Bevara det gamla
191
- // läget om filen redan fanns; en ny post har inget läge att bevara.
255
+ // befintliga filen (t.ex. 0600) med standardläget. Bevara det gamla
256
+ // läget om filen redan fanns; en ny fil har inget läge att bevara.
192
257
  try {
193
- chmodSync(tmp, statSync(statePath(d)).mode)
258
+ chmodSync(tmp, statSync(mal).mode)
194
259
  } catch {
195
- // ingen befintlig state.json (ny post) — inget läge att bevara.
260
+ // ingen befintlig fil (ny post), inget läge att bevara.
196
261
  }
197
- renameSync(tmp, statePath(d))
262
+ renameSync(tmp, mal)
198
263
  } catch (e) {
199
264
  try {
200
265
  unlinkSync(tmp)
201
266
  } catch {
202
267
  // temp-filen skapades aldrig eller är redan borta — inget att städa.
203
268
  }
204
- throw new ArkivFel(`kunde inte skriva ${statePath(d)}: ${e.message}`)
269
+ throw new ArkivFel(`kunde inte skriva ${mal}: ${e.message}`)
205
270
  }
206
271
  }
207
272
 
273
+ /** Läser experiment.json i katalogen d. Saknad fil är GILTIG (ny post, tom
274
+ * behållare), trasig JSON eller en icke-objekt-fil är ett arkivfel. De två
275
+ * fält kommandot skriver strukturerat valideras på samma sätt som runs i
276
+ * state.json: `varianter` måste vara en array av strängar och `korningar`
277
+ * ett objekt, annars sprids skräp tyst vid nästa skrivning. Alla andra
278
+ * fält (även äldre `baselinemodell`/`kandidatmodell`) lämnas orörda. */
279
+ const readExperimentOrThrow = (d) => {
280
+ const fil = path.join(d, 'experiment.json')
281
+ if (!existsSync(fil)) return { state: {}, fanns: false }
282
+ let parsed
283
+ try {
284
+ parsed = JSON.parse(readFileSync(fil, 'utf8'))
285
+ } catch (e) {
286
+ throw new ArkivFel(`${fil} är trasig JSON: ${e.message}`)
287
+ }
288
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
289
+ const fick = parsed === null ? 'null' : Array.isArray(parsed) ? 'array' : typeof parsed
290
+ throw new ArkivFel(`${fil} är inte ett objekt (fick ${fick})`)
291
+ }
292
+ if (
293
+ parsed.varianter !== undefined &&
294
+ (!Array.isArray(parsed.varianter) || parsed.varianter.some((v) => typeof v !== 'string'))
295
+ ) {
296
+ throw new ArkivFel(`${fil} har ett varianter-fält som inte är en array av strängar`)
297
+ }
298
+ if (
299
+ parsed.korningar !== undefined &&
300
+ (parsed.korningar === null ||
301
+ typeof parsed.korningar !== 'object' ||
302
+ Array.isArray(parsed.korningar))
303
+ ) {
304
+ throw new ArkivFel(`${fil} har ett korningar-fält som inte är ett objekt`)
305
+ }
306
+ return { state: parsed, fanns: true }
307
+ }
308
+
208
309
  /** Synkron sömn (blockerar tråden) för låsets pollning. Standardknepet för
209
310
  * att sova utan async i ett rent CLI-script: Atomics.wait somnar tills
210
311
  * timeouten går ut eftersom ingen annan agent notifierar den här cellen. */
@@ -461,6 +562,16 @@ const SCORE_STOP = [
461
562
  'ESKALERA',
462
563
  ]
463
564
 
565
+ /** `stopp`-kommandots utfall och källor. Kandidatmarkören i spec.md
566
+ * (KONCEPT/DIAGNOS, ADR-0105), motsägelse-etiketten i impact.md
567
+ * (TILLBAKA_TILL_SPEC, ADR-0106) och raden Prisavgörande öppet val i
568
+ * granskarblock.md (TILLBAKA_TILL_SPEC eller ESKALERA, ADR-0118) är de tre
569
+ * källor som kan bära ett stopp UTAN scorer. DELA_UPP, OVER_SKALAN och
570
+ * INPUT_SAKNAS är scorerns utfall och når arkivet via prisfilen, aldrig via
571
+ * stopp; ESKALERA når arkivet på båda vägarna. */
572
+ const STOPP_UTFALL = ['KONCEPT', 'DIAGNOS', 'TILLBAKA_TILL_SPEC', 'ESKALERA']
573
+ const STOPP_KALLOR = ['spec.md', 'impact.md', 'granskarblock.md']
574
+
464
575
  /** Tak för antal körningar per modell. Skyddet är mot en trasig loop, inte mot
465
576
  * Tims arbetssätt: 999 körningar av samma modell på samma post är alltid ett
466
577
  * fel, aldrig en avsikt. */
@@ -488,6 +599,13 @@ const PROVENIENS_REFERENSER = [
488
599
  * gett en tyst, obegriplig status i stället för ett fel. */
489
600
  const STATUSAR = ['spec_draft', 'spec_synced', 'assessed', 'priced', 'guard_run']
490
601
 
602
+ /** Körningsstatus i experiment.json, exakt de literaler SKILL.md:s
603
+ * återupptagningsregel och svep.mjs läser. */
604
+ const EXPERIMENT_STATUSAR = ['klar', 'ofärdig', 'stopp', 'väntar på dom']
605
+ /** En körningsnyckel är <modell>/k<N>, samma form som katalogen under
606
+ * experimentkatalogen. */
607
+ const KORNING_NYCKEL_RE = /^[^/]+\/k\d+$/
608
+
491
609
  /** Giltiga `--double-run`-värden (SKILL.md runs-kontraktet: "bekräftad" |
492
610
  * "olika" | "tolerans" | null). `add-run` skrev tidigare VILKEN STRÄNG SOM
493
611
  * HELST rakt in i fältet, samma klass av brist som `STATUSAR` ovan stänger
@@ -507,6 +625,53 @@ const GALLANDE_RE = (typ) => new RegExp(`^${typ}-pricing-v(\\d+)\\.json$`)
507
625
  const maxN = (runs, typ) =>
508
626
  Math.max(0, ...runs.map((r) => Number(GALLANDE_RE(typ).exec(r.file ?? '')?.[1] ?? 0)))
509
627
 
628
+ /** Första rubrikraden i ett specdokument, utan markdown-tecken; null om ingen. */
629
+ const forstaRubrik = (text) => {
630
+ const rad = String(text)
631
+ .split('\n')
632
+ .map((l) => l.trim())
633
+ .find((l) => /^#{1,6}\s+\S/.test(l) || /^\*\*[^*]+\*\*\s*$/.test(l))
634
+ return rad
635
+ ? rad
636
+ .replace(/^#{1,6}\s+/, '')
637
+ .replace(/^\*\*|\*\*$/g, '')
638
+ .trim()
639
+ : null
640
+ }
641
+
642
+ /** Kebab-slug: gemener, åäö till aao, allt annat än [a-z0-9] blir bindestreck. */
643
+ const slugAv = (titel) =>
644
+ titel
645
+ .toLowerCase()
646
+ .replace(/å/g, 'a')
647
+ .replace(/ä/g, 'a')
648
+ .replace(/ö/g, 'o')
649
+ .replace(/[^a-z0-9]+/g, '-')
650
+ .replace(/^-+|-+$/g, '')
651
+
652
+ /** Skelettet ny-korning och ny-del skriver, delat på ett ställe: samma form
653
+ * som SKILL.md:s state.json plus `experiment: true` (aldrig priced) och
654
+ * `stopp: null`. `bundle`/`split` överstyrs bara när anroparen ger dem, så
655
+ * ny-korning (ingen del) och ny-del (precis en av dem) delar exakt samma
656
+ * funktion i stället för två skrivvägar som kan glida isär. */
657
+ const skelett = (requestId, { bundle, split } = {}) => ({
658
+ experiment: true,
659
+ request_id: requestId,
660
+ slug: null,
661
+ former_slug: null,
662
+ title: null,
663
+ status: 'spec_draft',
664
+ bundle: bundle ?? { parent_slug: null, part: null, of: null },
665
+ split: split ?? { parent: null, part: null, of: null },
666
+ approval: null,
667
+ granskarutslag: [],
668
+ note: null,
669
+ stopp: null,
670
+ respec_varv: 0,
671
+ coverage_post: null,
672
+ runs: [],
673
+ })
674
+
510
675
  if (cmd === 'add-run') {
511
676
  // Samma fail-closed-princip som `next-n` redan tillämpar (se dess kommentar
512
677
  // ovan): varje token i `rest` måste höra till en KÄND flagga, annars är det
@@ -534,30 +699,17 @@ if (cmd === 'add-run') {
534
699
  'pairs-with',
535
700
  ]
536
701
  const ADD_RUN_BOOLFLAGGOR = ['partial-rerun']
537
- const konsumerat = new Set()
538
- rest.forEach((v, i) => {
539
- if (!v.startsWith('--')) return
540
- const namn = v.slice(2)
541
- if (ADD_RUN_VARDEFLAGGOR.includes(namn)) {
542
- konsumerat.add(i)
543
- konsumerat.add(i + 1)
544
- } else if (ADD_RUN_BOOLFLAGGOR.includes(namn)) {
545
- konsumerat.add(i)
546
- }
547
- })
548
- const ovantat = rest.filter((_, i) => !konsumerat.has(i))
549
- if (ovantat.length > 0) {
550
- console.error(
551
- `add-run: okänd eller ej konsumerad flagga/argument (${ovantat.join(' ')}), ` +
552
- `giltiga flaggor: ${[...ADD_RUN_VARDEFLAGGOR, ...ADD_RUN_BOOLFLAGGOR].map((n) => `--${n}`).join(', ')}`,
553
- )
554
- process.exit(2)
555
- }
702
+ avvisaOkandaFlaggor('add-run', ADD_RUN_VARDEFLAGGOR, ADD_RUN_BOOLFLAGGOR)
556
703
 
557
704
  const file = flag('file')
558
705
  const skill = flag('skill')
559
706
  const agent = flag('agent')
560
- const model = flag('model')
707
+ // --model förblir obligatorisk (plan requestmotor-14001, align: modellfallback
708
+ // struken; under utvärdera körs agenterna med override, så definitionens
709
+ // modell är per konstruktion inte den som kördes). Värdet kanoniseras:
710
+ // ett kontextsuffix som `[1m]` är samma modell (ADR-0099).
711
+ const modelRaw = flag('model')
712
+ const model = modelRaw === null ? null : canonicalModel(modelRaw)
561
713
  if (!file || !skill || !agent || !model) {
562
714
  console.error('add-run kräver --file, --skill, --agent, --model')
563
715
  process.exit(2)
@@ -580,7 +732,10 @@ if (cmd === 'add-run') {
580
732
  }
581
733
  referens[kv.slice(0, eq)] = kv.slice(eq + 1)
582
734
  }
583
- const effort = flag('effort')
735
+ // Effort ÄR agentdefinitionens (till skillnad från modellen, se ovan): en
736
+ // utelämnad --effort läses ur <claudeDir>/agents/<agent>.md. Explicit flagga
737
+ // vinner alltid.
738
+ const effort = flag('effort') ?? effortFranAgent(agent)
584
739
  const note = flag('note')
585
740
  const doubleRun = flag('double-run')
586
741
  // Samma vitlista som lässidan (`sparrSkal`, `DOUBLE_RUN_VALUES`) delar,
@@ -651,9 +806,37 @@ if (cmd === 'add-run') {
651
806
  // hoppa över den, arkivera aldrig en påhittad hash.
652
807
  }
653
808
  }
654
- state.runs = [...(state.runs ?? []), row]
655
- writeStateOrThrow(state)
656
- rowCount = state.runs.length
809
+ // #497: en ny spec.md/impact.md ersätter den förra läggningen av samma fil.
810
+ // Den föregående raden pekas ut, i stället för att två likadana rader
811
+ // står olänkade och läsaren måste gissa vilken som är på disk. Exakt
812
+ // filnamn med flit, inte SPEC_RE/IMPACT_RE: de mönstren matchar även
813
+ // t.ex. spec-avvisad-v1.md, som aldrig ska plockas in i supersede-kedjan.
814
+ const ersattbar = file === 'spec.md' || file === 'impact.md'
815
+ const runs = (state.runs ?? []).map((r) =>
816
+ ersattbar && r.file === file && (r.superseded_by ?? null) === null
817
+ ? { ...r, superseded_by: file }
818
+ : r,
819
+ )
820
+ // #495: titeln följer det dokument som faktiskt ligger på disk.
821
+ let titelFalt = {}
822
+ if (file === 'spec.md' && existsSync(path.join(katalog, 'spec.md'))) {
823
+ const rubrik = forstaRubrik(readFileSync(path.join(katalog, 'spec.md'), 'utf8'))
824
+ if (rubrik) titelFalt = { title: rubrik, slug: slugAv(rubrik) }
825
+ }
826
+ // Täckningspost (ADR-0119, #508): senaste pricing-filen som bär fältet
827
+ // vinner, precis som titeln följer spec.md på disk. En pricing-fil som
828
+ // (ännu) inte finns är inte add-runs jobb att fälla, den stämplas ofta
829
+ // innan agenten skrivit filen. Finns den redan är trasig JSON eller ett
830
+ // icke-objekt ett arkivfel som andra anropare i den här filen, fångas
831
+ // inte här: readPricingFileOrThrow kastar oskyddad.
832
+ if (PRICING_RE.test(file) && existsSync(path.join(katalog, file))) {
833
+ const data = readPricingFileOrThrow(file, katalog)
834
+ if (data.coverage_post !== null && typeof data.coverage_post === 'object')
835
+ titelFalt = { ...titelFalt, coverage_post: data.coverage_post }
836
+ }
837
+ const nyState = { ...state, ...titelFalt, runs: [...runs, row] }
838
+ writeStateOrThrow(nyState)
839
+ rowCount = nyState.runs.length
657
840
  } catch (e) {
658
841
  releaseLock(lockPath)
659
842
  if (e instanceof ArkivFel) {
@@ -957,6 +1140,8 @@ if (cmd === 'add-run') {
957
1140
  // katalog är HÄR modellkatalogen (experiments/<slug>/<modell>), inte en
958
1141
  // postkatalog. Den skapas om den saknas: första körningen av en modell ska
959
1142
  // inte kräva ett förberedande steg.
1143
+ avvisaOkandaFlaggor('ny-korning', ['request-id'])
1144
+ const requestId = flag('request-id')
960
1145
  try {
961
1146
  mkdirSync(katalog, { recursive: true })
962
1147
  } catch (e) {
@@ -971,16 +1156,196 @@ if (cmd === 'add-run') {
971
1156
  // två samtidiga processer skulle dela körningskatalog. Det är hela
972
1157
  // poängen med det här subkommandot.
973
1158
  mkdirSync(p)
974
- console.log(p)
975
- process.exit(0)
976
1159
  } catch (e) {
977
1160
  if (e.code === 'EEXIST') continue
978
1161
  console.error(`kunde inte skapa ${p}: ${e.message}`)
979
1162
  process.exit(2)
980
1163
  }
1164
+ // Skelettet skrivs av kommandot, inte av orkestratorn: IPC 14001 visade
1165
+ // körningar helt utan state.json på körningsnivå, så proveniens hade
1166
+ // ingenting att stämpla.
1167
+ try {
1168
+ writeStateOrThrow(skelett(requestId), p)
1169
+ } catch (e) {
1170
+ if (!(e instanceof ArkivFel)) throw e
1171
+ // Katalogen finns redan när skrivningen faller, och sökvägen får INTE
1172
+ // skrivas på stdout: anroparen skulle läsa den som en färdig körning.
1173
+ console.error(`${e.message}; katalogen ${p} är skapad men saknar state.json`)
1174
+ process.exit(2)
1175
+ }
1176
+ console.log(p)
1177
+ process.exit(0)
981
1178
  }
982
1179
  console.error(`fler än ${MAX_KORNINGAR} körningar i ${katalog}, något är fel`)
983
1180
  process.exit(2)
1181
+ } else if (cmd === 'ny-del') {
1182
+ // katalog är körningskatalogen (k<N>) eller en buntdel (DELA_UPP inuti en
1183
+ // del ger del-N/del-M). Skelettet skrivs av kommandot, aldrig av den inre
1184
+ // processen: samma skäl som ny-korning (ADR-0107 beslut 4).
1185
+ avvisaOkandaFlaggor('ny-del', ['typ', 'part', 'of', 'request-id'])
1186
+ const typ = requireFlag('typ')
1187
+ const part = Number(requireFlag('part'))
1188
+ const of = Number(requireFlag('of'))
1189
+ const requestId = requireFlag('request-id')
1190
+ if (!['bundle', 'split'].includes(typ)) {
1191
+ console.error(`--typ måste vara bundle eller split (fick "${typ}")`)
1192
+ process.exit(2)
1193
+ }
1194
+ if (!Number.isInteger(part) || !Number.isInteger(of) || part < 1 || of < 1 || part > of) {
1195
+ console.error(`--part och --of måste vara heltal >= 1 med part <= of (fick ${part}/${of})`)
1196
+ process.exit(2)
1197
+ }
1198
+ const p = path.join(katalog, `del-${part}`)
1199
+ try {
1200
+ mkdirSync(p)
1201
+ } catch (e) {
1202
+ console.error(e.code === 'EEXIST' ? `${p} finns redan` : `kunde inte skapa ${p}: ${e.message}`)
1203
+ process.exit(2)
1204
+ }
1205
+ const ref = { part, of }
1206
+ const st = skelett(
1207
+ requestId,
1208
+ typ === 'bundle'
1209
+ ? { bundle: { parent_slug: requestId, ...ref } }
1210
+ : { split: { parent: requestId, ...ref } },
1211
+ )
1212
+ try {
1213
+ writeStateOrThrow(st, p)
1214
+ } catch (e) {
1215
+ if (!(e instanceof ArkivFel)) throw e
1216
+ console.error(`${e.message}; katalogen ${p} är skapad men saknar state.json`)
1217
+ process.exit(2)
1218
+ }
1219
+ console.log(p)
1220
+ process.exit(0)
1221
+ } else if (cmd === 'experiment') {
1222
+ // katalog är HÄR experimentkatalogen (experiments/<slug>). experiment.json
1223
+ // är den enda filen på experimentytan som flera samtidiga processer
1224
+ // skriver (#493: sonnet/k4 försvann i en läs-modifiera-skriv utan lås), så
1225
+ // den enda skrivaren är det här kommandot, under samma lås som state.json-
1226
+ // kommandona. acquireLock låser i `katalog`, alltså i experimentkatalogen.
1227
+ avvisaOkandaFlaggor('experiment', ['satt', 'variant', 'korning', 'status', 'orsak', 'pid'])
1228
+ const sattPar = flags('satt').map(tolkaSatt)
1229
+ const nyaVarianter = flags('variant')
1230
+ const korningsflaggor = flags('korning')
1231
+ const status = flag('status')
1232
+ const orsak = flag('orsak')
1233
+ // #516: startraden (`ofärdig`, "startad, inte avslutad") bär startarens pid
1234
+ // medan körningen pågår, så ett andra svep kan skilja en körning som lever
1235
+ // från en som dog. Slutstatusen skrivs utan --pid och ersätter raden helt,
1236
+ // pid:n försvinner alltså av sig själv när processen är klar.
1237
+ const pidText = flag('pid')
1238
+ if (korningsflaggor.length > 1) {
1239
+ console.error('experiment: högst en --korning per anrop')
1240
+ process.exit(2)
1241
+ }
1242
+ const korning = korningsflaggor[0] ?? null
1243
+ if (korning === null && (status !== null || orsak !== null || pidText !== null)) {
1244
+ console.error('experiment: --status, --orsak och --pid kräver --korning')
1245
+ process.exit(2)
1246
+ }
1247
+ if (pidText !== null && !/^\d+$/.test(pidText)) {
1248
+ console.error(`experiment: --pid måste vara ett heltal (fick "${pidText}")`)
1249
+ process.exit(2)
1250
+ }
1251
+ const pid = pidText === null ? null : Number(pidText)
1252
+ if (korning !== null) {
1253
+ if (!KORNING_NYCKEL_RE.test(korning)) {
1254
+ console.error(`experiment: --korning ska ha formen <modell>/k<N>, fick "${korning}"`)
1255
+ process.exit(2)
1256
+ }
1257
+ if (status === null) {
1258
+ console.error('experiment: --korning kräver --status')
1259
+ process.exit(2)
1260
+ }
1261
+ if (!EXPERIMENT_STATUSAR.includes(status)) {
1262
+ console.error(`okänd status: ${status} (giltiga: ${EXPERIMENT_STATUSAR.join(', ')})`)
1263
+ process.exit(2)
1264
+ }
1265
+ // Katalogen måste finnas: en statusrad för en körning som inte finns
1266
+ // är ett stavfel i anroparen, inte en körning.
1267
+ const korningsdir = path.join(katalog, korning)
1268
+ let finns = false
1269
+ try {
1270
+ finns = statSync(korningsdir).isDirectory()
1271
+ } catch {
1272
+ // saknas
1273
+ }
1274
+ if (!finns) {
1275
+ console.error(`experiment: körningskatalogen ${korningsdir} finns inte`)
1276
+ process.exit(2)
1277
+ }
1278
+ }
1279
+ // Katalogen skapas om den saknas, precis som ny-korning gör med sin: första
1280
+ // steget i ett svep ska inte kräva ett förberedande mkdir. Låset ligger i
1281
+ // katalogen, så den måste finnas innan acquireLock.
1282
+ try {
1283
+ mkdirSync(katalog, { recursive: true })
1284
+ } catch (e) {
1285
+ console.error(`kunde inte skapa experimentkatalogen ${katalog}: ${e.message}`)
1286
+ process.exit(2)
1287
+ }
1288
+ const lockPath = acquireLock()
1289
+ let andringar = 0
1290
+ let fannsFil = false
1291
+ try {
1292
+ const { state, fanns } = readExperimentOrThrow(katalog)
1293
+ fannsFil = fanns
1294
+ let nasta = { ...state }
1295
+ // --satt är append-only som proveniens: ett fält skrivs en gång, samma
1296
+ // värde igen är en no-op, ett annat värde är ett fel. Alla fält prövas
1297
+ // före skrivningen, så ett vägrat anrop lämnar ingenting efter sig.
1298
+ for (const [nyckel, varde] of sattPar) {
1299
+ if (nasta[nyckel] === undefined) {
1300
+ nasta = { ...nasta, [nyckel]: varde }
1301
+ andringar++
1302
+ } else if (nasta[nyckel] !== varde) {
1303
+ throw new ArkivFel(
1304
+ `experiment: fältet ${nyckel} har redan värdet ${JSON.stringify(nasta[nyckel])}, ` +
1305
+ `begärt ${JSON.stringify(varde)}; ett fält skrivs bara en gång`,
1306
+ )
1307
+ }
1308
+ }
1309
+ // UNION med bevarad ordning: befintliga först, invokationens nya efter.
1310
+ // En headless-process med en enmodellslista rör alltså aldrig de andra.
1311
+ const befintliga = nasta.varianter ?? []
1312
+ const tillagda = nyaVarianter.filter(
1313
+ (v, i, alla) => !befintliga.includes(v) && alla.indexOf(v) === i,
1314
+ )
1315
+ if (tillagda.length > 0) {
1316
+ nasta = { ...nasta, varianter: [...befintliga, ...tillagda] }
1317
+ andringar += tillagda.length
1318
+ }
1319
+ // Bara den egna raden skrivs; andra körningars rader kopieras orörda.
1320
+ if (korning !== null) {
1321
+ const rad = {
1322
+ status,
1323
+ ...(orsak === null ? {} : { orsak }),
1324
+ ...(pid === null ? {} : { pid }),
1325
+ }
1326
+ const befintlig = nasta.korningar?.[korning]
1327
+ if (JSON.stringify(befintlig) !== JSON.stringify(rad)) {
1328
+ nasta = { ...nasta, korningar: { ...(nasta.korningar ?? {}), [korning]: rad } }
1329
+ andringar++
1330
+ }
1331
+ }
1332
+ if (andringar > 0 || !fanns) writeJsonAtomiskOrThrow(nasta, katalog, 'experiment.json')
1333
+ } catch (e) {
1334
+ releaseLock(lockPath)
1335
+ if (e instanceof ArkivFel) {
1336
+ console.error(e.message)
1337
+ process.exit(2)
1338
+ }
1339
+ throw e
1340
+ }
1341
+ releaseLock(lockPath)
1342
+ // En nyskapad fil är en ändring även utan begärda fält, därför räknas
1343
+ // "oförändrad" bara när filen redan fanns.
1344
+ console.log(
1345
+ andringar === 0 && fannsFil
1346
+ ? 'OK: experiment oförändrad'
1347
+ : `OK: experiment (${andringar} ändringar)`,
1348
+ )
984
1349
  } else if (cmd === 'proveniens') {
985
1350
  // katalog är HÄR körningskatalogen (experiments/<slug>/<modell>/k<N>).
986
1351
  // Stämplingen stod tidigare bara som prosa i SKILL.md steg 4 och blev
@@ -1039,29 +1404,27 @@ if (cmd === 'add-run') {
1039
1404
  // nyckelmängden ur modelUsage (suffix som `[1m]` strippas) — det är den
1040
1405
  // startaren jämför mot varvets begärda modell. Saknas filen skrivs inget
1041
1406
  // fält: en påhittad tom lista ser ut som "inga modeller anropades".
1042
- try {
1043
- const korning = JSON.parse(readFileSync(path.join(katalog, 'korning.json'), 'utf8'))
1044
- const nycklar = Object.keys(korning.modelUsage ?? {})
1045
- if (nycklar.length > 0)
1046
- stampel.fakturerade_modeller = [...new Set(nycklar.map(canonicalModel))].sort()
1047
- } catch (e) {
1048
- if (e.code !== 'ENOENT') {
1049
- console.error(`kunde inte läsa korning.json för fakturerade_modeller: ${e.message}`)
1407
+ // Filen ligger på körningsnivå medan stämplingen ibland görs per del-N
1408
+ // (plan requestmotor-14001), så den söks uppåt från katalogen, aldrig
1409
+ // utanför experimentytan och högst tre nivåer.
1410
+ const korningFil = findUpward(katalog, 'korning.json')
1411
+ if (korningFil !== null) {
1412
+ try {
1413
+ const korning = JSON.parse(readFileSync(korningFil, 'utf8'))
1414
+ const nycklar = Object.keys(korning.modelUsage ?? {})
1415
+ if (nycklar.length > 0)
1416
+ stampel.fakturerade_modeller = [...new Set(nycklar.map(canonicalModel))].sort()
1417
+ } catch (e) {
1418
+ console.error(`kunde inte läsa ${korningFil} för fakturerade_modeller: ${e.message}`)
1050
1419
  process.exit(2)
1051
1420
  }
1052
1421
  }
1053
1422
 
1054
1423
  // Delarna är samma körning: samma modell, samma datum, samma hashar. De
1055
1424
  // stämplas var för sig i sin egen state.json, aldrig genom att rapporten
1056
- // låter delkolumnen ärva förälderns värden vid läsning.
1057
- const delar = readdirSync(katalog, { withFileTypes: true })
1058
- .filter((e) => e.isDirectory() && /^del-\d+$/.test(e.name))
1059
- .map((e) => path.join(katalog, e.name))
1060
- // En delkatalog utan state.json är inte en körning ännu och hoppas över;
1061
- // att fälla hela stämplingen på den vore att låta ett halvfärdigt DELA_UPP
1062
- // blockera proveniensen för allt annat.
1063
- .filter((d) => existsSync(path.join(d, 'state.json')))
1064
- .sort()
1425
+ // låter delkolumnen ärva förälderns värden vid läsning. Rekursivt: ett
1426
+ // DELA_UPP inuti en buntdel ger del-N/del-M (align F).
1427
+ const delar = delKataloger(katalog)
1065
1428
 
1066
1429
  const lockPath = acquireLock()
1067
1430
  try {
@@ -1076,8 +1439,19 @@ if (cmd === 'add-run') {
1076
1439
  attSkriva.push([d, state])
1077
1440
  continue
1078
1441
  }
1079
- const skiljer = Object.keys(stampel).filter(
1080
- (k) => (befintlig === null ? undefined : befintlig[k]) !== stampel[k],
1442
+ // Djup jämförelse per fält: `fakturerade_modeller` är en array, och en
1443
+ // referensjämförelse hade fått varje omanrop att "skilja sig" i just
1444
+ // det fältet.
1445
+ // Unionen av nycklar: ett fält som fanns i den lagrade stämpeln men
1446
+ // saknas i den nya (korning.json hittas inte längre) är också drift.
1447
+ const nycklar = new Set([
1448
+ ...Object.keys(stampel),
1449
+ ...Object.keys(befintlig === null ? {} : befintlig),
1450
+ ])
1451
+ const skiljer = [...nycklar].filter(
1452
+ (k) =>
1453
+ JSON.stringify(befintlig === null ? undefined : befintlig[k]) !==
1454
+ JSON.stringify(stampel[k]),
1081
1455
  )
1082
1456
  if (skiljer.length > 0) {
1083
1457
  throw new ArkivFel(
@@ -1100,13 +1474,493 @@ if (cmd === 'add-run') {
1100
1474
  }
1101
1475
  throw e
1102
1476
  }
1477
+ } else if (cmd === 'stopp') {
1478
+ // Stopputfall UTAN scorer (ADR-0105/0106/0118): en kandidatspec routar direkt
1479
+ // till KONCEPT/DIAGNOS, en analys med motsägelse direkt till
1480
+ // TILLBAKA_TILL_SPEC, och ett prisavgörande öppet val i granskarblock.md
1481
+ // direkt till TILLBAKA_TILL_SPEC eller ESKALERA. Kommandot litar aldrig på
1482
+ // anroparens påstående: källan måste bära det.
1483
+ avvisaOkandaFlaggor('stopp', ['utfall', 'kalla', 'orsak'])
1484
+ const utfall = requireFlag('utfall')
1485
+ const kalla = requireFlag('kalla')
1486
+ const orsak = flag('orsak')
1487
+ if (!STOPP_UTFALL.includes(utfall)) {
1488
+ console.error(`okänt --utfall: ${utfall} (giltiga: ${STOPP_UTFALL.join(', ')})`)
1489
+ process.exit(2)
1490
+ }
1491
+ if (!STOPP_KALLOR.includes(kalla)) {
1492
+ console.error(`okänd --kalla: ${kalla} (giltiga: ${STOPP_KALLOR.join(', ')})`)
1493
+ process.exit(2)
1494
+ }
1495
+ const kallaPath = path.join(katalog, kalla)
1496
+ let text
1497
+ try {
1498
+ text = readFileSync(kallaPath, 'utf8')
1499
+ } catch (e) {
1500
+ console.error(`kunde inte läsa källan ${kallaPath}: ${e.message}`)
1501
+ process.exit(2)
1502
+ }
1503
+ if (kalla === 'spec.md') {
1504
+ const kandidat = kandidatIn(text)
1505
+ if (kandidat === null) {
1506
+ console.error(
1507
+ `${kallaPath} bär ingen kandidatmarkör (hel rubrikrad Koncept-kandidat/Diagnos-kandidat), ` +
1508
+ `kan inte stoppas som ${utfall}`,
1509
+ )
1510
+ process.exit(2)
1511
+ }
1512
+ if (kandidat.utfall !== utfall) {
1513
+ console.error(
1514
+ `${kallaPath} bär markören för ${kandidat.utfall} (rad ${kandidat.line + 1}), inte ${utfall}`,
1515
+ )
1516
+ process.exit(2)
1517
+ }
1518
+ } else if (kalla === 'granskarblock.md') {
1519
+ // ADR-0118: bara ett ja på raden Prisavgörande öppet val är ett stopp.
1520
+ if (prisavgorandeVal(text) === null) {
1521
+ console.error(
1522
+ `${kallaPath} bär inget prisavgörande öppet val (raden "Prisavgörande öppet val: ja: …"), ` +
1523
+ 'kan inte stoppas',
1524
+ )
1525
+ process.exit(2)
1526
+ }
1527
+ if (!['TILLBAKA_TILL_SPEC', 'ESKALERA'].includes(utfall)) {
1528
+ console.error(
1529
+ `ett prisavgörande val i ${kalla} stoppar som TILLBAKA_TILL_SPEC eller ESKALERA, inte ${utfall}`,
1530
+ )
1531
+ process.exit(2)
1532
+ }
1533
+ if (utfall === 'ESKALERA' && !orsak) {
1534
+ console.error(
1535
+ 'ESKALERA kräver --orsak: en människa ska se varför posten inte gick tillbaka till specen en gång till',
1536
+ )
1537
+ process.exit(2)
1538
+ }
1539
+ } else {
1540
+ const etikett = avvikelseEtikett(text)
1541
+ if (etikett !== 'motsägelse') {
1542
+ console.error(
1543
+ `${kallaPath} bär ${etikett === null ? 'ingen avvikelseetikett' : `etiketten "${etikett}"`} ` +
1544
+ 'på raden "Avvikelse mot specen:", bara "motsägelse" är ett stopp',
1545
+ )
1546
+ process.exit(2)
1547
+ }
1548
+ if (utfall !== 'TILLBAKA_TILL_SPEC') {
1549
+ console.error(`en motsägelse i ${kalla} stoppar som TILLBAKA_TILL_SPEC, inte ${utfall}`)
1550
+ process.exit(2)
1551
+ }
1552
+ }
1553
+
1554
+ const lockPath = acquireLock()
1555
+ let skrivet = false
1556
+ try {
1557
+ const state = readStateOrThrow()
1558
+ const befintlig = state.stopp ?? null
1559
+ if (befintlig !== null) {
1560
+ // Write-once på utfall+kalla; datumet och orsaken ingår inte, ett
1561
+ // identiskt omanrop en annan dag (eller med en annan orsakstext) är en
1562
+ // no-op.
1563
+ const begart = { utfall, kalla }
1564
+ const skiljer = Object.keys(begart).filter((k) => befintlig[k] !== begart[k])
1565
+ if (skiljer.length > 0) {
1566
+ throw new ArkivFel(
1567
+ `${statePath()} har redan ett stopp (${befintlig.utfall} ur ${befintlig.kalla}) som skiljer sig ` +
1568
+ `från ${utfall} ur ${kalla} (${skiljer.join(', ')}); arkivet är append-only, kör respec för att nolla`,
1569
+ )
1570
+ }
1571
+ } else {
1572
+ state.stopp = { utfall, kalla, date: today(), ...(orsak ? { orsak } : {}) }
1573
+ // impact.md: posten gick genom bedöm och är analyserad. spec.md och
1574
+ // granskarblock.md: kandidaten/det prisavgörande valet passerade aldrig
1575
+ // bedöms grind, posten stannar i spec_draft som förut.
1576
+ if (kalla === 'impact.md') state.status = 'assessed'
1577
+ writeStateOrThrow(state)
1578
+ skrivet = true
1579
+ }
1580
+ } catch (e) {
1581
+ releaseLock(lockPath)
1582
+ if (e instanceof ArkivFel) {
1583
+ console.error(e.message)
1584
+ process.exit(2)
1585
+ }
1586
+ throw e
1587
+ }
1588
+ releaseLock(lockPath)
1589
+ console.log(
1590
+ skrivet ? `OK: stopp ${utfall} ur ${kalla}` : `OK: stopp ${utfall} ur ${kalla} oförändrat`,
1591
+ )
1592
+ } else if (cmd === 'respec') {
1593
+ // Speca steg 5 som kommando: en ny spec över befintlig katalog invaliderar
1594
+ // analysen, godkännandet, domarna och stoppet. runs[] rörs aldrig, arkivet
1595
+ // är append-only; den gamla analysen döps om, raderas inte.
1596
+ avvisaOkandaFlaggor('respec', [])
1597
+ const lockPath = acquireLock()
1598
+ let antal = 0
1599
+ try {
1600
+ const state = readStateOrThrow()
1601
+ const impact = path.join(katalog, 'impact.md')
1602
+ if (existsSync(impact)) {
1603
+ const bas = path.join(katalog, `impact-superseded-${today()}`)
1604
+ let mal = `${bas}.md`
1605
+ for (let n = 2; existsSync(mal); n++) mal = `${bas}-${n}.md`
1606
+ try {
1607
+ renameSync(impact, mal)
1608
+ } catch (e) {
1609
+ throw new ArkivFel(`kunde inte döpa om ${impact} till ${mal}: ${e.message}`)
1610
+ }
1611
+ antal++
1612
+ }
1613
+ const nytt = { ...state }
1614
+ if (nytt.status !== 'spec_draft') {
1615
+ nytt.status = 'spec_draft'
1616
+ antal++
1617
+ }
1618
+ if (nytt.approval !== null) {
1619
+ nytt.approval = null
1620
+ antal++
1621
+ }
1622
+ if (!Array.isArray(nytt.granskarutslag) || nytt.granskarutslag.length > 0) {
1623
+ nytt.granskarutslag = []
1624
+ antal++
1625
+ }
1626
+ if (nytt.stopp !== null) {
1627
+ nytt.stopp = null
1628
+ antal++
1629
+ }
1630
+ // Monoton räknare (#505): varje respec-anrop är ett nytt varv oavsett om
1631
+ // övriga fält redan var normaliserade, till skillnad från fälten ovan
1632
+ // finns inget "redan rätt värde" att jämföra mot, så det här villkoret
1633
+ // är alltid sant.
1634
+ nytt.respec_varv = (state.respec_varv ?? 0) + 1
1635
+ antal++
1636
+ if (antal > 0) writeStateOrThrow(nytt)
1637
+ } catch (e) {
1638
+ releaseLock(lockPath)
1639
+ if (e instanceof ArkivFel) {
1640
+ console.error(e.message)
1641
+ process.exit(2)
1642
+ }
1643
+ throw e
1644
+ }
1645
+ releaseLock(lockPath)
1646
+ console.log(`OK: respec (${antal} ändringar)`)
1647
+ } else if (cmd === 'bluf') {
1648
+ // Läs-enbart tabellvy över katalogen och dess delar. Poängen tas ur
1649
+ // prisfilerna och stoppet ur state.json, aldrig ur `note` eller prosa: den
1650
+ // handskrivna BLUF:en i IPC 14001 felrapporterade.
1651
+ avvisaOkandaFlaggor('bluf', [])
1652
+ const SAKNAS = '–'
1653
+ const cell = (v) => String(v ?? SAKNAS).replace(/\|/g, '\\|')
1654
+ const radFor = (d, post) => {
1655
+ const state = readStateOrThrow(d)
1656
+ const runs = state.runs ?? []
1657
+ const direktaDelar = delKataloger(d).filter((p) => path.dirname(p) === d)
1658
+ let poang = SAKNAS
1659
+ let utfall = SAKNAS
1660
+ let konfidens = SAKNAS
1661
+ let anm = SAKNAS
1662
+ const nScore = maxN(runs, 'score')
1663
+ if (nScore > 0) {
1664
+ const fil = gallandeDokumentFil(state, 'score', nScore)
1665
+ const data = readPricingFileOrThrow(fil, d)
1666
+ poang = data.score ?? SAKNAS
1667
+ utfall = data.outcome ?? SAKNAS
1668
+ konfidens = data.confidence ?? SAKNAS
1669
+ const rad = [...runs].reverse().find((r) => r.file === fil)
1670
+ // En OLIKA som en dom redan lyft (liftingUtslag pekade ut gällande fil
1671
+ // ovan) väntar inte längre på någon; bara en olyft OLIKA får raden.
1672
+ if (rad?.double_run === 'olika' && !liftedByUtslag(state, 'score', nScore)) {
1673
+ anm = 'OLIKA, väntar på dom'
1674
+ const run2 = fil.replace(/\.json$/, '-run2.json')
1675
+ if (existsSync(path.join(d, run2))) {
1676
+ const b = readPricingFileOrThrow(run2, d)
1677
+ poang = `${poang} / ${b.score ?? SAKNAS}`
1678
+ }
1679
+ } else if (rad?.double_run === 'tolerans') {
1680
+ anm = 'tolerans'
1681
+ }
1682
+ } else if (!existsSync(path.join(d, 'spec.md')) && direktaDelar.length > 0) {
1683
+ poang = `bunt (${direktaDelar.length} delar)`
1684
+ utfall = poang
1685
+ } else if (state.stopp == null) {
1686
+ poang = 'ofärdig'
1687
+ }
1688
+ // Täckningspost (ADR-0119, #508): synlig i anm så granskaren ser att en
1689
+ // egen post väntar, utan att leta i pricing-filen. Beräknas separat och
1690
+ // läggs till sist (final-review M6) så att den varken tystas av
1691
+ // OLIKA/tolerans-texten ovan eller av ett stopp nedan.
1692
+ const taeckning = state.coverage_post?.yta ? `täckningspost: ${state.coverage_post.yta}` : null
1693
+ // Stopp trumfar OLIKA och tolerans: posten är stoppad oavsett vad
1694
+ // prisfilen säger, det tidigare anm-innehållet (OLIKA/tolerans) faller
1695
+ // bort. Täckningsposten är inte OLIKA/tolerans-text och överlever detta.
1696
+ if (state.stopp != null) {
1697
+ utfall = state.stopp.utfall
1698
+ anm =
1699
+ state.stopp.kalla === 'spec.md'
1700
+ ? 'kandidat (spec.md)'
1701
+ : state.stopp.kalla === 'granskarblock.md'
1702
+ ? 'prisavgörande val (granskarblock.md)'
1703
+ : 'motsägelse (impact.md)'
1704
+ }
1705
+ if (taeckning) anm = anm === SAKNAS ? taeckning : `${anm}; ${taeckning}`
1706
+ return `| ${[post, poang, utfall, konfidens, anm].map(cell).join(' | ')} |`
1707
+ }
1708
+ const rot = path.resolve(katalog)
1709
+ const rader = ['| post | poäng | utfall | konfidens | anm |', '| --- | --- | --- | --- | --- |']
1710
+ try {
1711
+ rader.push(radFor(rot, path.basename(rot)))
1712
+ for (const d of delKataloger(rot)) rader.push(radFor(d, path.relative(rot, d)))
1713
+ } catch (e) {
1714
+ if (e instanceof ArkivFel) {
1715
+ console.error(e.message)
1716
+ process.exit(2)
1717
+ }
1718
+ throw e
1719
+ }
1720
+ console.log(rader.join('\n'))
1721
+ } else if (cmd === 'nasta-steg') {
1722
+ // #530: läs-enbart tabell över var posten står, vad en människa väntas
1723
+ // göra och vilket /request-kommando som kör vidare. Samma regler som
1724
+ // SKILL.md:s grindar, härledda i `nastaStegFor` nedan, aldrig handskrivna:
1725
+ // en handskriven avslutning tappade exakt den raden i skarp körning.
1726
+ avvisaOkandaFlaggor('nasta-steg', [])
1727
+ const cell = (v) => String(v).replace(/\|/g, '\\|')
1728
+ const radFor = (d, post) => {
1729
+ const state = readStateOrThrow(d)
1730
+ const direktaDelar = delKataloger(d).filter((p) => path.dirname(p) === d)
1731
+ const arBuntRot = !existsSync(path.join(d, 'spec.md')) && direktaDelar.length > 0
1732
+ const { star, vantar, nasta } = nastaStegFor(state, {
1733
+ post,
1734
+ antalDelar: arBuntRot ? direktaDelar.length : 0,
1735
+ scorerUtfall: gallandeScorerUtfall(state, d),
1736
+ })
1737
+ return `| ${[post, star, vantar, nasta].map(cell).join(' | ')} |`
1738
+ }
1739
+ const rot = path.resolve(katalog)
1740
+ const rader = ['| post | står | väntar på | nästa |', '| --- | --- | --- | --- |']
1741
+ try {
1742
+ rader.push(radFor(rot, path.basename(rot)))
1743
+ for (const d of delKataloger(rot)) rader.push(radFor(d, path.relative(rot, d)))
1744
+ } catch (e) {
1745
+ if (e instanceof ArkivFel) {
1746
+ console.error(e.message)
1747
+ process.exit(2)
1748
+ }
1749
+ throw e
1750
+ }
1751
+ console.log(rader.join('\n'))
1103
1752
  } else {
1104
1753
  console.error(
1105
- 'okänt kommando (add-run, next-n, ny-korning, proveniens, sparr, utslag, set-status, kalibrering)',
1754
+ 'okänt kommando (add-run, next-n, ny-korning, ny-del, proveniens, sparr, utslag, set-status, stopp, respec, bluf, nasta-steg, kalibrering, experiment)',
1106
1755
  )
1107
1756
  process.exit(2)
1108
1757
  }
1109
1758
 
1759
+ /** Tolkar ett --satt-par "nyckel=värde". Första "=" delar; `eskalerad` är
1760
+ * det enda booleska fältet och tar bara true/false, allt annat är en sträng.
1761
+ * `varianter` och `korningar` har egna flaggor med egen semantik (union,
1762
+ * egen rad) och får inte skrivas rakt av här. */
1763
+ function tolkaSatt(par) {
1764
+ const i = par.indexOf('=')
1765
+ if (i < 1) {
1766
+ console.error(`experiment: --satt ska ha formen nyckel=värde, fick "${par}"`)
1767
+ process.exit(2)
1768
+ }
1769
+ const nyckel = par.slice(0, i)
1770
+ const ra = par.slice(i + 1)
1771
+ if (nyckel === 'varianter' || nyckel === 'korningar') {
1772
+ console.error(`experiment: ${nyckel} skrivs med --variant respektive --korning, inte --satt`)
1773
+ process.exit(2)
1774
+ }
1775
+ if (nyckel === 'eskalerad') {
1776
+ if (ra !== 'true' && ra !== 'false') {
1777
+ console.error(`experiment: eskalerad tar bara true eller false, fick "${ra}"`)
1778
+ process.exit(2)
1779
+ }
1780
+ return [nyckel, ra === 'true']
1781
+ }
1782
+ return [nyckel, ra]
1783
+ }
1784
+
1785
+ /** Effort ur agentdefinitionens frontmatter: raden `effort: <värde>` mellan
1786
+ * de inledande `---`-raderna, markörrader (haus_managed:) tål den. Saknas
1787
+ * filen eller raden blir det null och en VARNING på stderr: IPC 14001 hade
1788
+ * effort null på alla rader utan att någon såg det. Läser aldrig brödtexten. */
1789
+ function effortFranAgent(agent) {
1790
+ const p = path.join(claudeDir(), 'agents', `${agent}.md`)
1791
+ let text
1792
+ try {
1793
+ text = stripHausMarker(readFileSync(p, 'utf8'))
1794
+ } catch (e) {
1795
+ console.error(`VARNING: kunde inte läsa ${p} för effort (${e.message}), effort blir null`)
1796
+ return null
1797
+ }
1798
+ const lines = text.split('\n')
1799
+ if (lines[0]?.trim() === '---') {
1800
+ for (let i = 1; i < lines.length && lines[i].trim() !== '---'; i++) {
1801
+ const m = /^effort:\s*(\S+)$/.exec(lines[i])
1802
+ if (m) return m[1]
1803
+ }
1804
+ }
1805
+ console.error(`VARNING: ${p} saknar en effort-rad i sin frontmatter, effort blir null`)
1806
+ return null
1807
+ }
1808
+
1809
+ /** Delkataloger under `dir`, REKURSIVT och i numerisk ordning: del-N som har
1810
+ * en state.json, djupet först (del-1, del-1/del-1, del-1/del-2, del-2, del-10).
1811
+ * En del utan state.json är inte en körning ännu och hoppas över, liksom
1812
+ * allt under den. Delad av proveniens och bluf så de två aldrig kan se
1813
+ * olika delar. */
1814
+ function delKataloger(dir) {
1815
+ let entries
1816
+ try {
1817
+ entries = readdirSync(dir, { withFileTypes: true })
1818
+ } catch (e) {
1819
+ throw new ArkivFel(`kunde inte lista ${dir}: ${e.message}`)
1820
+ }
1821
+ return entries
1822
+ .filter((e) => e.isDirectory() && /^del-\d+$/.test(e.name))
1823
+ .sort((a, b) => Number(a.name.slice(4)) - Number(b.name.slice(4)))
1824
+ .map((e) => path.join(dir, e.name))
1825
+ .filter((d) => existsSync(path.join(d, 'state.json')))
1826
+ .flatMap((d) => [d, ...delKataloger(d)])
1827
+ }
1828
+
1829
+ /** Scorerns gällande utfall ur prisfilen, eller null när inget poängvarv
1830
+ * finns. Läses bara för att `nasta-steg` inte ska säga "sync till brädan"
1831
+ * om en post vars gällande varv är DELA_UPP eller OVER_SKALAN: de utfallen
1832
+ * når arkivet via prisfilen, aldrig via `stopp` (se STOPP_UTFALL). Samma
1833
+ * fil som `bluf` och `pricedBlockers` läser, via samma hjälpare. */
1834
+ function gallandeScorerUtfall(state, d) {
1835
+ const n = maxN(state.runs ?? [], 'score')
1836
+ if (n === 0) return null
1837
+ const fil = gallandeDokumentFil(state, 'score', n)
1838
+ return { fil, outcome: readPricingFileOrThrow(fil, d).outcome ?? null }
1839
+ }
1840
+
1841
+ /** Regeltabellen bakom `nasta-steg` (#530). Varje regel är en avläsning av
1842
+ * en grind som redan står i SKILL.md; kolumnen "väntar på" är det en
1843
+ * MÄNNISKA måste göra, "nästa" det /request-kommando som kör vidare.
1844
+ * Första träff vinner, uppifrån:
1845
+ *
1846
+ * | läge | källa i SKILL.md |
1847
+ * | ------------------------------------- | ------------------------------------------------------------- |
1848
+ * | bunt-rot (ingen spec.md, del-N finns) | Experimentytan, buntregeln; bluf:s `bunt (N delar)` |
1849
+ * | spärr står (sparrSkal) | bedöm steg 6 OLIKA "Hänvisa sedan till `döm <post>`"; `döm` |
1850
+ * | stopp KONCEPT/DIAGNOS | speca steg 6 "Kandidatens katalog kan fortfarande läggas på |
1851
+ * | | brädan … men `bedöm` hoppar över den"; stopputfall → kunddialog |
1852
+ * | stopp TILLBAKA_TILL_SPEC | speca steg 6 (ADR-0118) "När valet avgjorts skrivs svaret |
1853
+ * | | under `## Avgjorda val` …, sedan `respec` och ny dispatch" |
1854
+ * | stopp ESKALERA | speca steg 6 "en människa dömer, posten går aldrig en tredje |
1855
+ * | | specrunda"; stopputfall ESKALERA → visa skälet och stanna |
1856
+ * | experiment | Experimentytan: "kan inte förväxlas med en prispost"; |
1857
+ * | | Hårda regler "Experimentdata når aldrig Asana" |
1858
+ * | spec_draft utan approval | speca steg 7 "Erbjud att lägga specarna på brädan (🔍)"; |
1859
+ * | | godkänn/underkänn verkar från 🔍 |
1860
+ * | spec_synced utan approval | godkänn/underkänn-lägesgrinden: 🔍 → ✅ Spec ok |
1861
+ * | spec_draft/spec_synced med approval | bedöm-grinden: "Endast `✅ Spec ok` … släpper igenom", |
1862
+ * | | "Post utan bräda: `godkänn` antecknar approval manual" |
1863
+ * | assessed utan poängvarv | bedöm steg 3–7: analysen finns, poängen saknas |
1864
+ * | assessed, scorerns DELA_UPP | stopputfall → "ny analysrunda per del … `split` satt" |
1865
+ * | assessed, scorerns OVER_SKALAN | stopputfall → "offereras separat; delas aldrig vidare" |
1866
+ * | assessed, scorerns övriga stopp | stopputfall → kunddialog / spec-steget / visa skälet, stanna |
1867
+ * | assessed (poäng) | bedöm steg 8: sync via request-sync tar ✅ → ⚖️, sedan |
1868
+ * | | godkänn/underkänn från ⚖️ |
1869
+ * | priced | godkänn-grinden: ⚖️ → 📤 Klar – till kund |
1870
+ * | guard_run | `guard_run` reserveras för guard-kommandot: inget väntar |
1871
+ * | allt annat | `okänt läge: <status>`, aldrig en gissning |
1872
+ *
1873
+ * `står` är statusen plus det som avviker från grundläget: stoppet,
1874
+ * scorerns stopputfall, olyft OLIKA, `respec_varv` > 0, manuell approval
1875
+ * och experimentmarkören. Funktionen är ren (ingen I/O) så tabellen går
1876
+ * att testa rad för rad. */
1877
+ function nastaStegFor(state, { post, antalDelar, scorerUtfall }) {
1878
+ const status = state.status
1879
+ const gid = state.request_id ?? '<gid>'
1880
+ const adress = state.request_id ?? post
1881
+ const stopp = state.stopp ?? null
1882
+ const runs = state.runs ?? []
1883
+ const nScore = maxN(runs, 'score')
1884
+ const gallandeRad =
1885
+ nScore > 0 ? [...runs].reverse().find((r) => r.file === `score-pricing-v${nScore}.json`) : null
1886
+ const olikaOlyft = gallandeRad?.double_run === 'olika' && !liftedByUtslag(state, 'score', nScore)
1887
+ const vantarDom = sparrSkal(state).length > 0
1888
+ const scorerStopp =
1889
+ scorerUtfall !== null && SCORE_STOP.includes(scorerUtfall.outcome) ? scorerUtfall : null
1890
+ const star = [
1891
+ status,
1892
+ stopp ? `stopp ${stopp.utfall} (${stopp.kalla})` : null,
1893
+ !stopp && scorerStopp ? `${scorerStopp.outcome} (${scorerStopp.fil})` : null,
1894
+ olikaOlyft ? 'OLIKA olyft' : vantarDom ? 'omkörning olyft' : null,
1895
+ (state.respec_varv ?? 0) > 0 ? `respec_varv ${state.respec_varv}` : null,
1896
+ state.approval?.source === 'manual' ? 'approval manual' : null,
1897
+ state.experiment === true ? 'experiment' : null,
1898
+ ]
1899
+ .filter((v) => v !== null)
1900
+ .join(', ')
1901
+ const rad = (vantar, nasta, egenStar = star) => ({ star: egenStar, vantar, nasta })
1902
+ const godkannEller = `godkänn ${gid} eller underkänn ${gid} <skäl>`
1903
+ const kunddialog = () =>
1904
+ rad(
1905
+ 'kunddialog',
1906
+ 'kunddialog; lägg posten på brädan via request-sync (🔍) om den saknas där, bedöm hoppar över den',
1907
+ )
1908
+ const tillbakaTillSpec = (val) =>
1909
+ rad(
1910
+ `avgör valet: ${val}`,
1911
+ `avgör valet, skriv det under ## Avgjorda val i kallmanifest.md, sedan archive.mjs respec och speca ${adress} om`,
1912
+ )
1913
+
1914
+ if (antalDelar > 0) return rad('se delarna', 'se delarna', `bunt (${antalDelar} delar)`)
1915
+ if (vantarDom) return rad('dom (döm)', `döm ${post}`)
1916
+ if (stopp) {
1917
+ const val = stopp.orsak ?? stopp.kalla
1918
+ if (stopp.utfall === 'KONCEPT' || stopp.utfall === 'DIAGNOS') return kunddialog()
1919
+ if (stopp.utfall === 'TILLBAKA_TILL_SPEC') return tillbakaTillSpec(val)
1920
+ if (stopp.utfall === 'ESKALERA')
1921
+ return rad(`avgör valet: ${val}`, 'människa dömer, ingen ny specrunda')
1922
+ }
1923
+ if (state.experiment === true)
1924
+ return rad('inget (experiment, når aldrig Asana)', 'utvärdera rapport')
1925
+ const godkand = state.approval !== null && state.approval !== undefined
1926
+ if (status === 'spec_draft' || status === 'spec_synced') {
1927
+ if (godkand) return rad('körning av bedöm', `bedöm ${adress}`)
1928
+ return status === 'spec_draft'
1929
+ ? rad(
1930
+ 'godkännande av spec på brädan',
1931
+ `sync till brädan (request-sync 🔍), sedan ${godkannEller}`,
1932
+ )
1933
+ : rad('godkännande av spec på brädan', godkannEller)
1934
+ }
1935
+ if (status === 'assessed') {
1936
+ if (nScore === 0) return rad('körning av bedöm', `bedöm ${adress} (poängsättning saknas)`)
1937
+ if (scorerStopp) {
1938
+ const o = scorerStopp.outcome
1939
+ if (o === 'DELA_UPP')
1940
+ return rad('ny analysrunda per del', 'dela posten (split), sedan bedöm per del')
1941
+ if (o === 'OVER_SKALAN')
1942
+ return rad('manuell offert', 'offereras separat, delas aldrig vidare')
1943
+ if (o === 'KONCEPT' || o === 'DIAGNOS') return kunddialog()
1944
+ if (o === 'TILLBAKA_TILL_SPEC') return tillbakaTillSpec(scorerStopp.fil)
1945
+ return rad(
1946
+ `människa: ${o} ur ${scorerStopp.fil}`,
1947
+ `visa skälet och stanna (detaljer ${adress} poäng)`,
1948
+ )
1949
+ }
1950
+ return rad(
1951
+ 'granskning av poäng på brädan',
1952
+ `sync till brädan (request-sync ⚖️), sedan ${godkannEller}`,
1953
+ )
1954
+ }
1955
+ if (status === 'priced')
1956
+ return rad(
1957
+ 'granskning av poäng på brädan',
1958
+ `sync till brädan (request-sync ⚖️) om inte gjort, sedan godkänn ${gid} (📤 Klar – till kund) eller underkänn ${gid} <skäl>`,
1959
+ )
1960
+ if (status === 'guard_run') return rad('inget', 'inget')
1961
+ return rad('–', '–', `okänt läge: ${status}`)
1962
+ }
1963
+
1110
1964
  /** Det SENASTE viktningsutslaget (om något) som lyfter en axel vid ett givet
1111
1965
  * gällande n — namnger axeln, har ett `n` som är EXAKT det gällande n:et
1112
1966
  * (inte "minst lika högt"), och bär en `galler_fil`.
@@ -1250,8 +2104,8 @@ function gallandeDokumentFil(state, typ, n) {
1250
2104
  * grinden rapporterade att inget blockerar. Kastar ArkivFel; anroparen
1251
2105
  * (set-status) fångar det och avslutar med exit 2, skilt från den vanliga
1252
2106
  * spärr-vägran (exit 1). */
1253
- function readPricingFileOrThrow(fil) {
1254
- const p = path.join(katalog, fil)
2107
+ function readPricingFileOrThrow(fil, d = katalog) {
2108
+ const p = path.join(d, fil)
1255
2109
  let raw
1256
2110
  try {
1257
2111
  raw = readFileSync(p, 'utf8')
@@ -1345,6 +2199,13 @@ function pricedBlockers(state) {
1345
2199
  if (state.experiment === true) {
1346
2200
  skal.push('experimentvariant blir aldrig priced')
1347
2201
  }
2202
+ // Ett stopputfall skrivet av `stopp` (kandidat ur spec.md, motsägelse ur
2203
+ // impact.md) gäller tills `respec` nollar det. Bara HÄR, aldrig i
2204
+ // `sparrSkal`: sparr betyder "väntar på dom" och styr utvärderas
2205
+ // återupptagning; ett stopp där hade låst posten för alltid.
2206
+ if (state.stopp !== null && typeof state.stopp === 'object') {
2207
+ skal.push(`stopputfall ${state.stopp.utfall} ur ${state.stopp.kalla}`)
2208
+ }
1348
2209
  const runs = state.runs ?? []
1349
2210
  const nClass = maxN(runs, 'classification')
1350
2211
  if (nClass > 0) {