@haus-tech/haus-workflow 5.0.1 → 5.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/library/catalog/manifest.json +67 -61
- package/library/global/agents/request-assessor.md +10 -2
- package/library/global/agents/request-specifier-eskalerad.md +8 -0
- package/library/global/agents/request-specifier.md +8 -3
- package/library/global/skills/request/SKILL.md +575 -217
- package/library/global/skills/request/scripts/archive.mjs +736 -59
- package/library/global/skills/request/scripts/compare-scores.mjs +119 -13
- package/library/global/skills/request/scripts/contract-check.mjs +268 -40
- package/library/global/skills/request/scripts/kontroll.mjs +622 -0
- package/library/global/skills/request/scripts/korning.mjs +1502 -0
- package/library/global/skills/request/scripts/lib.mjs +477 -5
- package/library/global/skills/request/scripts/precheck.mjs +132 -5
- package/library/global/skills/request/scripts/report.mjs +85 -14
- package/library/global/skills/request/scripts/svep.mjs +640 -0
- package/library/global/skills/request-assess-impact/SKILL.md +45 -4
- package/library/global/skills/request-score/SKILL.md +51 -11
- package/library/global/skills/request-score/references/poangskala.md +3 -0
- package/library/global/skills/request-write-spec/SKILL.md +9 -4
- package/library/global/skills/request-write-spec/references/specformat.md +48 -0
- package/package.json +1 -1
|
@@ -8,17 +8,28 @@
|
|
|
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).
|
|
12
18
|
*
|
|
13
19
|
* Användning:
|
|
14
20
|
* node archive.mjs add-run <katalog> --file F --skill S --agent A --model M [flaggor]
|
|
15
21
|
* node archive.mjs next-n <katalog> --typ classification [--typ score]
|
|
16
|
-
* node archive.mjs ny-korning <modellkatalog>
|
|
22
|
+
* node archive.mjs ny-korning <modellkatalog> [--request-id G]
|
|
23
|
+
* node archive.mjs ny-del <körningskatalog> --typ bundle|split --part N --of M --request-id G
|
|
17
24
|
* node archive.mjs proveniens <körningskatalog> --modell M --konto K --underlag F [--datum D]
|
|
18
25
|
* node archive.mjs sparr <katalog>
|
|
19
26
|
* node archive.mjs utslag <katalog> --typ viktning|kalibrering|sakandring --beslutad-av N [...]
|
|
20
27
|
* node archive.mjs set-status <katalog> <status>
|
|
28
|
+
* node archive.mjs stopp <katalog> --utfall KONCEPT|DIAGNOS|TILLBAKA_TILL_SPEC|ESKALERA --kalla spec.md|impact.md|granskarblock.md [--orsak <text>]
|
|
29
|
+
* node archive.mjs respec <katalog>
|
|
30
|
+
* node archive.mjs bluf <katalog>
|
|
21
31
|
* node archive.mjs kalibrering <arkivrot>
|
|
32
|
+
* node archive.mjs experiment <experimentkatalog> [--satt K=V]... [--variant M]... [--korning M/kN --status S [--orsak T]]
|
|
22
33
|
* Exit 0 = ok, 1 = spärrad (sparr/set-status priced), 2 = användnings-/tillståndsfel.
|
|
23
34
|
*/
|
|
24
35
|
import {
|
|
@@ -38,8 +49,13 @@ import path from 'node:path'
|
|
|
38
49
|
import { createHash } from 'node:crypto'
|
|
39
50
|
import {
|
|
40
51
|
markerStrippedSha256,
|
|
52
|
+
stripHausMarker,
|
|
41
53
|
claudeDir,
|
|
42
54
|
canonicalModel,
|
|
55
|
+
kandidatIn,
|
|
56
|
+
avvikelseEtikett,
|
|
57
|
+
prisavgorandeVal,
|
|
58
|
+
findUpward,
|
|
43
59
|
CLASS_UTFALL,
|
|
44
60
|
SCORE_OUTCOMES,
|
|
45
61
|
} from './lib.mjs'
|
|
@@ -48,7 +64,7 @@ const [cmd, katalog, ...rest] = process.argv.slice(2)
|
|
|
48
64
|
|
|
49
65
|
if (!cmd || !katalog) {
|
|
50
66
|
console.error(
|
|
51
|
-
'användning: archive.mjs <add-run|next-n|ny-korning|proveniens|sparr|utslag|set-status|kalibrering> <katalog> [flaggor]',
|
|
67
|
+
'användning: archive.mjs <add-run|next-n|ny-korning|ny-del|proveniens|sparr|utslag|set-status|stopp|respec|bluf|kalibrering|experiment> <katalog> [flaggor]',
|
|
52
68
|
)
|
|
53
69
|
process.exit(2)
|
|
54
70
|
}
|
|
@@ -81,6 +97,35 @@ const requireFlag = (name) => {
|
|
|
81
97
|
}
|
|
82
98
|
const has = (name) => rest.includes(`--${name}`)
|
|
83
99
|
|
|
100
|
+
/** Fail-closed flaggkontroll, samma princip som add-run och next-n tillämpar
|
|
101
|
+
* inline: varje token i `rest` måste höra till en KÄND flagga, annars är det
|
|
102
|
+
* ett fel (exit 2), aldrig ett tyst struntvärde. Värdeflaggor konsumerar
|
|
103
|
+
* nästa token, booleska flaggor bara sig själva. */
|
|
104
|
+
const avvisaOkandaFlaggor = (kommando, vardeflaggor, boolflaggor = []) => {
|
|
105
|
+
const konsumerat = new Set()
|
|
106
|
+
rest.forEach((v, i) => {
|
|
107
|
+
if (!v.startsWith('--')) return
|
|
108
|
+
const namn = v.slice(2)
|
|
109
|
+
if (vardeflaggor.includes(namn)) {
|
|
110
|
+
konsumerat.add(i)
|
|
111
|
+
konsumerat.add(i + 1)
|
|
112
|
+
} else if (boolflaggor.includes(namn)) {
|
|
113
|
+
konsumerat.add(i)
|
|
114
|
+
}
|
|
115
|
+
})
|
|
116
|
+
const ovantat = rest.filter((_, i) => !konsumerat.has(i))
|
|
117
|
+
if (ovantat.length > 0) {
|
|
118
|
+
const giltiga = [...vardeflaggor, ...boolflaggor].map((n) => `--${n}`)
|
|
119
|
+
console.error(
|
|
120
|
+
`${kommando}: okänd eller ej konsumerad flagga/argument (${ovantat.join(' ')}), ` +
|
|
121
|
+
(giltiga.length === 0
|
|
122
|
+
? 'kommandot tar inga flaggor'
|
|
123
|
+
: `giltiga flaggor: ${giltiga.join(', ')}`),
|
|
124
|
+
)
|
|
125
|
+
process.exit(2)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
84
129
|
/** Sökvägen till en katalogs state.json. Parametern har `katalog` som
|
|
85
130
|
* default så varje befintlig anropare är oförändrad; `proveniens` behöver
|
|
86
131
|
* den för att kunna stämpla delkatalogerna med samma kod som föräldern, i
|
|
@@ -158,6 +203,16 @@ const readStateOrThrow = (d = katalog) => {
|
|
|
158
203
|
}
|
|
159
204
|
assertRowArrayField(parsed, 'runs', d)
|
|
160
205
|
assertRowArrayField(parsed, 'granskarutslag', d)
|
|
206
|
+
// `stopp` är null, saknat (äldre arkiv) eller ett objekt { utfall, kalla,
|
|
207
|
+
// date }. Allt annat är trasigt: pricedBlockers och bluf läser fältet, och
|
|
208
|
+
// en sträng där hade tyst lästs som "inget stopp".
|
|
209
|
+
if (
|
|
210
|
+
parsed.stopp !== undefined &&
|
|
211
|
+
parsed.stopp !== null &&
|
|
212
|
+
(typeof parsed.stopp !== 'object' || Array.isArray(parsed.stopp))
|
|
213
|
+
) {
|
|
214
|
+
throw new ArkivFel(`${statePath(d)} har ett stopp-fält som varken är null eller ett objekt`)
|
|
215
|
+
}
|
|
161
216
|
return parsed
|
|
162
217
|
}
|
|
163
218
|
|
|
@@ -180,29 +235,71 @@ const loadStateOrExit = () => {
|
|
|
180
235
|
* skrivfel (t.ex. EACCES) i stället för att låta undantaget nå toppen som
|
|
181
236
|
* en okontrollerad krasch med exit 1 — i det här kontraktet betyder exit 1
|
|
182
237
|
* spärrad, och ett misslyckat skrivförsök är inte det. */
|
|
183
|
-
const writeStateOrThrow = (s, d = katalog) =>
|
|
238
|
+
const writeStateOrThrow = (s, d = katalog) => writeJsonAtomiskOrThrow(s, d, 'state.json')
|
|
239
|
+
|
|
240
|
+
/** Samma rename-dans för vilken JSON-fil som helst i en katalog: state.json
|
|
241
|
+
* och experiment.json delar en enda skrivväg i stället för två kopior som
|
|
242
|
+
* kan glida isär (filnamnet är den enda skillnaden). */
|
|
243
|
+
const writeJsonAtomiskOrThrow = (s, d, fil) => {
|
|
244
|
+
const mal = path.join(d, fil)
|
|
184
245
|
const rand = Math.random().toString(36).slice(2, 8)
|
|
185
|
-
const tmp = path.join(d,
|
|
246
|
+
const tmp = path.join(d, `.${fil}.tmp-${process.pid}-${rand}`)
|
|
186
247
|
try {
|
|
187
248
|
writeFileSync(tmp, JSON.stringify(s, null, 2) + '\n')
|
|
188
249
|
// rename(2) byter inod, så temp-filens läge (satt av umask vid writeFileSync)
|
|
189
250
|
// skulle annars tyst ersätta en avsiktligt hårdare rättighet på den
|
|
190
|
-
// befintliga
|
|
191
|
-
// läget om filen redan fanns; en ny
|
|
251
|
+
// befintliga filen (t.ex. 0600) med standardläget. Bevara det gamla
|
|
252
|
+
// läget om filen redan fanns; en ny fil har inget läge att bevara.
|
|
192
253
|
try {
|
|
193
|
-
chmodSync(tmp, statSync(
|
|
254
|
+
chmodSync(tmp, statSync(mal).mode)
|
|
194
255
|
} catch {
|
|
195
|
-
// ingen befintlig
|
|
256
|
+
// ingen befintlig fil (ny post), inget läge att bevara.
|
|
196
257
|
}
|
|
197
|
-
renameSync(tmp,
|
|
258
|
+
renameSync(tmp, mal)
|
|
198
259
|
} catch (e) {
|
|
199
260
|
try {
|
|
200
261
|
unlinkSync(tmp)
|
|
201
262
|
} catch {
|
|
202
263
|
// temp-filen skapades aldrig eller är redan borta — inget att städa.
|
|
203
264
|
}
|
|
204
|
-
throw new ArkivFel(`kunde inte skriva ${
|
|
265
|
+
throw new ArkivFel(`kunde inte skriva ${mal}: ${e.message}`)
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Läser experiment.json i katalogen d. Saknad fil är GILTIG (ny post, tom
|
|
270
|
+
* behållare), trasig JSON eller en icke-objekt-fil är ett arkivfel. De två
|
|
271
|
+
* fält kommandot skriver strukturerat valideras på samma sätt som runs i
|
|
272
|
+
* state.json: `varianter` måste vara en array av strängar och `korningar`
|
|
273
|
+
* ett objekt, annars sprids skräp tyst vid nästa skrivning. Alla andra
|
|
274
|
+
* fält (även äldre `baselinemodell`/`kandidatmodell`) lämnas orörda. */
|
|
275
|
+
const readExperimentOrThrow = (d) => {
|
|
276
|
+
const fil = path.join(d, 'experiment.json')
|
|
277
|
+
if (!existsSync(fil)) return { state: {}, fanns: false }
|
|
278
|
+
let parsed
|
|
279
|
+
try {
|
|
280
|
+
parsed = JSON.parse(readFileSync(fil, 'utf8'))
|
|
281
|
+
} catch (e) {
|
|
282
|
+
throw new ArkivFel(`${fil} är trasig JSON: ${e.message}`)
|
|
283
|
+
}
|
|
284
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
285
|
+
const fick = parsed === null ? 'null' : Array.isArray(parsed) ? 'array' : typeof parsed
|
|
286
|
+
throw new ArkivFel(`${fil} är inte ett objekt (fick ${fick})`)
|
|
287
|
+
}
|
|
288
|
+
if (
|
|
289
|
+
parsed.varianter !== undefined &&
|
|
290
|
+
(!Array.isArray(parsed.varianter) || parsed.varianter.some((v) => typeof v !== 'string'))
|
|
291
|
+
) {
|
|
292
|
+
throw new ArkivFel(`${fil} har ett varianter-fält som inte är en array av strängar`)
|
|
293
|
+
}
|
|
294
|
+
if (
|
|
295
|
+
parsed.korningar !== undefined &&
|
|
296
|
+
(parsed.korningar === null ||
|
|
297
|
+
typeof parsed.korningar !== 'object' ||
|
|
298
|
+
Array.isArray(parsed.korningar))
|
|
299
|
+
) {
|
|
300
|
+
throw new ArkivFel(`${fil} har ett korningar-fält som inte är ett objekt`)
|
|
205
301
|
}
|
|
302
|
+
return { state: parsed, fanns: true }
|
|
206
303
|
}
|
|
207
304
|
|
|
208
305
|
/** Synkron sömn (blockerar tråden) för låsets pollning. Standardknepet för
|
|
@@ -461,6 +558,16 @@ const SCORE_STOP = [
|
|
|
461
558
|
'ESKALERA',
|
|
462
559
|
]
|
|
463
560
|
|
|
561
|
+
/** `stopp`-kommandots utfall och källor. Kandidatmarkören i spec.md
|
|
562
|
+
* (KONCEPT/DIAGNOS, ADR-0105), motsägelse-etiketten i impact.md
|
|
563
|
+
* (TILLBAKA_TILL_SPEC, ADR-0106) och raden Prisavgörande öppet val i
|
|
564
|
+
* granskarblock.md (TILLBAKA_TILL_SPEC eller ESKALERA, ADR-0118) är de tre
|
|
565
|
+
* källor som kan bära ett stopp UTAN scorer. DELA_UPP, OVER_SKALAN och
|
|
566
|
+
* INPUT_SAKNAS är scorerns utfall och når arkivet via prisfilen, aldrig via
|
|
567
|
+
* stopp; ESKALERA når arkivet på båda vägarna. */
|
|
568
|
+
const STOPP_UTFALL = ['KONCEPT', 'DIAGNOS', 'TILLBAKA_TILL_SPEC', 'ESKALERA']
|
|
569
|
+
const STOPP_KALLOR = ['spec.md', 'impact.md', 'granskarblock.md']
|
|
570
|
+
|
|
464
571
|
/** Tak för antal körningar per modell. Skyddet är mot en trasig loop, inte mot
|
|
465
572
|
* Tims arbetssätt: 999 körningar av samma modell på samma post är alltid ett
|
|
466
573
|
* fel, aldrig en avsikt. */
|
|
@@ -488,6 +595,13 @@ const PROVENIENS_REFERENSER = [
|
|
|
488
595
|
* gett en tyst, obegriplig status i stället för ett fel. */
|
|
489
596
|
const STATUSAR = ['spec_draft', 'spec_synced', 'assessed', 'priced', 'guard_run']
|
|
490
597
|
|
|
598
|
+
/** Körningsstatus i experiment.json, exakt de literaler SKILL.md:s
|
|
599
|
+
* återupptagningsregel och svep.mjs läser. */
|
|
600
|
+
const EXPERIMENT_STATUSAR = ['klar', 'ofärdig', 'stopp', 'väntar på dom']
|
|
601
|
+
/** En körningsnyckel är <modell>/k<N>, samma form som katalogen under
|
|
602
|
+
* experimentkatalogen. */
|
|
603
|
+
const KORNING_NYCKEL_RE = /^[^/]+\/k\d+$/
|
|
604
|
+
|
|
491
605
|
/** Giltiga `--double-run`-värden (SKILL.md runs-kontraktet: "bekräftad" |
|
|
492
606
|
* "olika" | "tolerans" | null). `add-run` skrev tidigare VILKEN STRÄNG SOM
|
|
493
607
|
* HELST rakt in i fältet, samma klass av brist som `STATUSAR` ovan stänger
|
|
@@ -507,6 +621,53 @@ const GALLANDE_RE = (typ) => new RegExp(`^${typ}-pricing-v(\\d+)\\.json$`)
|
|
|
507
621
|
const maxN = (runs, typ) =>
|
|
508
622
|
Math.max(0, ...runs.map((r) => Number(GALLANDE_RE(typ).exec(r.file ?? '')?.[1] ?? 0)))
|
|
509
623
|
|
|
624
|
+
/** Första rubrikraden i ett specdokument, utan markdown-tecken; null om ingen. */
|
|
625
|
+
const forstaRubrik = (text) => {
|
|
626
|
+
const rad = String(text)
|
|
627
|
+
.split('\n')
|
|
628
|
+
.map((l) => l.trim())
|
|
629
|
+
.find((l) => /^#{1,6}\s+\S/.test(l) || /^\*\*[^*]+\*\*\s*$/.test(l))
|
|
630
|
+
return rad
|
|
631
|
+
? rad
|
|
632
|
+
.replace(/^#{1,6}\s+/, '')
|
|
633
|
+
.replace(/^\*\*|\*\*$/g, '')
|
|
634
|
+
.trim()
|
|
635
|
+
: null
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/** Kebab-slug: gemener, åäö till aao, allt annat än [a-z0-9] blir bindestreck. */
|
|
639
|
+
const slugAv = (titel) =>
|
|
640
|
+
titel
|
|
641
|
+
.toLowerCase()
|
|
642
|
+
.replace(/å/g, 'a')
|
|
643
|
+
.replace(/ä/g, 'a')
|
|
644
|
+
.replace(/ö/g, 'o')
|
|
645
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
646
|
+
.replace(/^-+|-+$/g, '')
|
|
647
|
+
|
|
648
|
+
/** Skelettet ny-korning och ny-del skriver, delat på ett ställe: samma form
|
|
649
|
+
* som SKILL.md:s state.json plus `experiment: true` (aldrig priced) och
|
|
650
|
+
* `stopp: null`. `bundle`/`split` överstyrs bara när anroparen ger dem, så
|
|
651
|
+
* ny-korning (ingen del) och ny-del (precis en av dem) delar exakt samma
|
|
652
|
+
* funktion i stället för två skrivvägar som kan glida isär. */
|
|
653
|
+
const skelett = (requestId, { bundle, split } = {}) => ({
|
|
654
|
+
experiment: true,
|
|
655
|
+
request_id: requestId,
|
|
656
|
+
slug: null,
|
|
657
|
+
former_slug: null,
|
|
658
|
+
title: null,
|
|
659
|
+
status: 'spec_draft',
|
|
660
|
+
bundle: bundle ?? { parent_slug: null, part: null, of: null },
|
|
661
|
+
split: split ?? { parent: null, part: null, of: null },
|
|
662
|
+
approval: null,
|
|
663
|
+
granskarutslag: [],
|
|
664
|
+
note: null,
|
|
665
|
+
stopp: null,
|
|
666
|
+
respec_varv: 0,
|
|
667
|
+
coverage_post: null,
|
|
668
|
+
runs: [],
|
|
669
|
+
})
|
|
670
|
+
|
|
510
671
|
if (cmd === 'add-run') {
|
|
511
672
|
// Samma fail-closed-princip som `next-n` redan tillämpar (se dess kommentar
|
|
512
673
|
// ovan): varje token i `rest` måste höra till en KÄND flagga, annars är det
|
|
@@ -534,30 +695,17 @@ if (cmd === 'add-run') {
|
|
|
534
695
|
'pairs-with',
|
|
535
696
|
]
|
|
536
697
|
const ADD_RUN_BOOLFLAGGOR = ['partial-rerun']
|
|
537
|
-
|
|
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
|
-
}
|
|
698
|
+
avvisaOkandaFlaggor('add-run', ADD_RUN_VARDEFLAGGOR, ADD_RUN_BOOLFLAGGOR)
|
|
556
699
|
|
|
557
700
|
const file = flag('file')
|
|
558
701
|
const skill = flag('skill')
|
|
559
702
|
const agent = flag('agent')
|
|
560
|
-
|
|
703
|
+
// --model förblir obligatorisk (plan requestmotor-14001, align: modellfallback
|
|
704
|
+
// struken; under utvärdera körs agenterna med override, så definitionens
|
|
705
|
+
// modell är per konstruktion inte den som kördes). Värdet kanoniseras:
|
|
706
|
+
// ett kontextsuffix som `[1m]` är samma modell (ADR-0099).
|
|
707
|
+
const modelRaw = flag('model')
|
|
708
|
+
const model = modelRaw === null ? null : canonicalModel(modelRaw)
|
|
561
709
|
if (!file || !skill || !agent || !model) {
|
|
562
710
|
console.error('add-run kräver --file, --skill, --agent, --model')
|
|
563
711
|
process.exit(2)
|
|
@@ -580,7 +728,10 @@ if (cmd === 'add-run') {
|
|
|
580
728
|
}
|
|
581
729
|
referens[kv.slice(0, eq)] = kv.slice(eq + 1)
|
|
582
730
|
}
|
|
583
|
-
|
|
731
|
+
// Effort ÄR agentdefinitionens (till skillnad från modellen, se ovan): en
|
|
732
|
+
// utelämnad --effort läses ur <claudeDir>/agents/<agent>.md. Explicit flagga
|
|
733
|
+
// vinner alltid.
|
|
734
|
+
const effort = flag('effort') ?? effortFranAgent(agent)
|
|
584
735
|
const note = flag('note')
|
|
585
736
|
const doubleRun = flag('double-run')
|
|
586
737
|
// Samma vitlista som lässidan (`sparrSkal`, `DOUBLE_RUN_VALUES`) delar,
|
|
@@ -651,9 +802,37 @@ if (cmd === 'add-run') {
|
|
|
651
802
|
// hoppa över den, arkivera aldrig en påhittad hash.
|
|
652
803
|
}
|
|
653
804
|
}
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
805
|
+
// #497: en ny spec.md/impact.md ersätter den förra läggningen av samma fil.
|
|
806
|
+
// Den föregående raden pekas ut, i stället för att två likadana rader
|
|
807
|
+
// står olänkade och läsaren måste gissa vilken som är på disk. Exakt
|
|
808
|
+
// filnamn med flit, inte SPEC_RE/IMPACT_RE: de mönstren matchar även
|
|
809
|
+
// t.ex. spec-avvisad-v1.md, som aldrig ska plockas in i supersede-kedjan.
|
|
810
|
+
const ersattbar = file === 'spec.md' || file === 'impact.md'
|
|
811
|
+
const runs = (state.runs ?? []).map((r) =>
|
|
812
|
+
ersattbar && r.file === file && (r.superseded_by ?? null) === null
|
|
813
|
+
? { ...r, superseded_by: file }
|
|
814
|
+
: r,
|
|
815
|
+
)
|
|
816
|
+
// #495: titeln följer det dokument som faktiskt ligger på disk.
|
|
817
|
+
let titelFalt = {}
|
|
818
|
+
if (file === 'spec.md' && existsSync(path.join(katalog, 'spec.md'))) {
|
|
819
|
+
const rubrik = forstaRubrik(readFileSync(path.join(katalog, 'spec.md'), 'utf8'))
|
|
820
|
+
if (rubrik) titelFalt = { title: rubrik, slug: slugAv(rubrik) }
|
|
821
|
+
}
|
|
822
|
+
// Täckningspost (ADR-0119, #508): senaste pricing-filen som bär fältet
|
|
823
|
+
// vinner, precis som titeln följer spec.md på disk. En pricing-fil som
|
|
824
|
+
// (ännu) inte finns är inte add-runs jobb att fälla, den stämplas ofta
|
|
825
|
+
// innan agenten skrivit filen. Finns den redan är trasig JSON eller ett
|
|
826
|
+
// icke-objekt ett arkivfel som andra anropare i den här filen, fångas
|
|
827
|
+
// inte här: readPricingFileOrThrow kastar oskyddad.
|
|
828
|
+
if (PRICING_RE.test(file) && existsSync(path.join(katalog, file))) {
|
|
829
|
+
const data = readPricingFileOrThrow(file, katalog)
|
|
830
|
+
if (data.coverage_post !== null && typeof data.coverage_post === 'object')
|
|
831
|
+
titelFalt = { ...titelFalt, coverage_post: data.coverage_post }
|
|
832
|
+
}
|
|
833
|
+
const nyState = { ...state, ...titelFalt, runs: [...runs, row] }
|
|
834
|
+
writeStateOrThrow(nyState)
|
|
835
|
+
rowCount = nyState.runs.length
|
|
657
836
|
} catch (e) {
|
|
658
837
|
releaseLock(lockPath)
|
|
659
838
|
if (e instanceof ArkivFel) {
|
|
@@ -957,6 +1136,8 @@ if (cmd === 'add-run') {
|
|
|
957
1136
|
// katalog är HÄR modellkatalogen (experiments/<slug>/<modell>), inte en
|
|
958
1137
|
// postkatalog. Den skapas om den saknas: första körningen av en modell ska
|
|
959
1138
|
// inte kräva ett förberedande steg.
|
|
1139
|
+
avvisaOkandaFlaggor('ny-korning', ['request-id'])
|
|
1140
|
+
const requestId = flag('request-id')
|
|
960
1141
|
try {
|
|
961
1142
|
mkdirSync(katalog, { recursive: true })
|
|
962
1143
|
} catch (e) {
|
|
@@ -971,16 +1152,182 @@ if (cmd === 'add-run') {
|
|
|
971
1152
|
// två samtidiga processer skulle dela körningskatalog. Det är hela
|
|
972
1153
|
// poängen med det här subkommandot.
|
|
973
1154
|
mkdirSync(p)
|
|
974
|
-
console.log(p)
|
|
975
|
-
process.exit(0)
|
|
976
1155
|
} catch (e) {
|
|
977
1156
|
if (e.code === 'EEXIST') continue
|
|
978
1157
|
console.error(`kunde inte skapa ${p}: ${e.message}`)
|
|
979
1158
|
process.exit(2)
|
|
980
1159
|
}
|
|
1160
|
+
// Skelettet skrivs av kommandot, inte av orkestratorn: IPC 14001 visade
|
|
1161
|
+
// körningar helt utan state.json på körningsnivå, så proveniens hade
|
|
1162
|
+
// ingenting att stämpla.
|
|
1163
|
+
try {
|
|
1164
|
+
writeStateOrThrow(skelett(requestId), p)
|
|
1165
|
+
} catch (e) {
|
|
1166
|
+
if (!(e instanceof ArkivFel)) throw e
|
|
1167
|
+
// Katalogen finns redan när skrivningen faller, och sökvägen får INTE
|
|
1168
|
+
// skrivas på stdout: anroparen skulle läsa den som en färdig körning.
|
|
1169
|
+
console.error(`${e.message}; katalogen ${p} är skapad men saknar state.json`)
|
|
1170
|
+
process.exit(2)
|
|
1171
|
+
}
|
|
1172
|
+
console.log(p)
|
|
1173
|
+
process.exit(0)
|
|
981
1174
|
}
|
|
982
1175
|
console.error(`fler än ${MAX_KORNINGAR} körningar i ${katalog}, något är fel`)
|
|
983
1176
|
process.exit(2)
|
|
1177
|
+
} else if (cmd === 'ny-del') {
|
|
1178
|
+
// katalog är körningskatalogen (k<N>) eller en buntdel (DELA_UPP inuti en
|
|
1179
|
+
// del ger del-N/del-M). Skelettet skrivs av kommandot, aldrig av den inre
|
|
1180
|
+
// processen: samma skäl som ny-korning (ADR-0107 beslut 4).
|
|
1181
|
+
avvisaOkandaFlaggor('ny-del', ['typ', 'part', 'of', 'request-id'])
|
|
1182
|
+
const typ = requireFlag('typ')
|
|
1183
|
+
const part = Number(requireFlag('part'))
|
|
1184
|
+
const of = Number(requireFlag('of'))
|
|
1185
|
+
const requestId = requireFlag('request-id')
|
|
1186
|
+
if (!['bundle', 'split'].includes(typ)) {
|
|
1187
|
+
console.error(`--typ måste vara bundle eller split (fick "${typ}")`)
|
|
1188
|
+
process.exit(2)
|
|
1189
|
+
}
|
|
1190
|
+
if (!Number.isInteger(part) || !Number.isInteger(of) || part < 1 || of < 1 || part > of) {
|
|
1191
|
+
console.error(`--part och --of måste vara heltal >= 1 med part <= of (fick ${part}/${of})`)
|
|
1192
|
+
process.exit(2)
|
|
1193
|
+
}
|
|
1194
|
+
const p = path.join(katalog, `del-${part}`)
|
|
1195
|
+
try {
|
|
1196
|
+
mkdirSync(p)
|
|
1197
|
+
} catch (e) {
|
|
1198
|
+
console.error(e.code === 'EEXIST' ? `${p} finns redan` : `kunde inte skapa ${p}: ${e.message}`)
|
|
1199
|
+
process.exit(2)
|
|
1200
|
+
}
|
|
1201
|
+
const ref = { part, of }
|
|
1202
|
+
const st = skelett(
|
|
1203
|
+
requestId,
|
|
1204
|
+
typ === 'bundle'
|
|
1205
|
+
? { bundle: { parent_slug: requestId, ...ref } }
|
|
1206
|
+
: { split: { parent: requestId, ...ref } },
|
|
1207
|
+
)
|
|
1208
|
+
try {
|
|
1209
|
+
writeStateOrThrow(st, p)
|
|
1210
|
+
} catch (e) {
|
|
1211
|
+
if (!(e instanceof ArkivFel)) throw e
|
|
1212
|
+
console.error(`${e.message}; katalogen ${p} är skapad men saknar state.json`)
|
|
1213
|
+
process.exit(2)
|
|
1214
|
+
}
|
|
1215
|
+
console.log(p)
|
|
1216
|
+
process.exit(0)
|
|
1217
|
+
} else if (cmd === 'experiment') {
|
|
1218
|
+
// katalog är HÄR experimentkatalogen (experiments/<slug>). experiment.json
|
|
1219
|
+
// är den enda filen på experimentytan som flera samtidiga processer
|
|
1220
|
+
// skriver (#493: sonnet/k4 försvann i en läs-modifiera-skriv utan lås), så
|
|
1221
|
+
// den enda skrivaren är det här kommandot, under samma lås som state.json-
|
|
1222
|
+
// kommandona. acquireLock låser i `katalog`, alltså i experimentkatalogen.
|
|
1223
|
+
avvisaOkandaFlaggor('experiment', ['satt', 'variant', 'korning', 'status', 'orsak'])
|
|
1224
|
+
const sattPar = flags('satt').map(tolkaSatt)
|
|
1225
|
+
const nyaVarianter = flags('variant')
|
|
1226
|
+
const korningsflaggor = flags('korning')
|
|
1227
|
+
const status = flag('status')
|
|
1228
|
+
const orsak = flag('orsak')
|
|
1229
|
+
if (korningsflaggor.length > 1) {
|
|
1230
|
+
console.error('experiment: högst en --korning per anrop')
|
|
1231
|
+
process.exit(2)
|
|
1232
|
+
}
|
|
1233
|
+
const korning = korningsflaggor[0] ?? null
|
|
1234
|
+
if (korning === null && (status !== null || orsak !== null)) {
|
|
1235
|
+
console.error('experiment: --status och --orsak kräver --korning')
|
|
1236
|
+
process.exit(2)
|
|
1237
|
+
}
|
|
1238
|
+
if (korning !== null) {
|
|
1239
|
+
if (!KORNING_NYCKEL_RE.test(korning)) {
|
|
1240
|
+
console.error(`experiment: --korning ska ha formen <modell>/k<N>, fick "${korning}"`)
|
|
1241
|
+
process.exit(2)
|
|
1242
|
+
}
|
|
1243
|
+
if (status === null) {
|
|
1244
|
+
console.error('experiment: --korning kräver --status')
|
|
1245
|
+
process.exit(2)
|
|
1246
|
+
}
|
|
1247
|
+
if (!EXPERIMENT_STATUSAR.includes(status)) {
|
|
1248
|
+
console.error(`okänd status: ${status} (giltiga: ${EXPERIMENT_STATUSAR.join(', ')})`)
|
|
1249
|
+
process.exit(2)
|
|
1250
|
+
}
|
|
1251
|
+
// Katalogen måste finnas: en statusrad för en körning som inte finns
|
|
1252
|
+
// är ett stavfel i anroparen, inte en körning.
|
|
1253
|
+
const korningsdir = path.join(katalog, korning)
|
|
1254
|
+
let finns = false
|
|
1255
|
+
try {
|
|
1256
|
+
finns = statSync(korningsdir).isDirectory()
|
|
1257
|
+
} catch {
|
|
1258
|
+
// saknas
|
|
1259
|
+
}
|
|
1260
|
+
if (!finns) {
|
|
1261
|
+
console.error(`experiment: körningskatalogen ${korningsdir} finns inte`)
|
|
1262
|
+
process.exit(2)
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
// Katalogen skapas om den saknas, precis som ny-korning gör med sin: första
|
|
1266
|
+
// steget i ett svep ska inte kräva ett förberedande mkdir. Låset ligger i
|
|
1267
|
+
// katalogen, så den måste finnas innan acquireLock.
|
|
1268
|
+
try {
|
|
1269
|
+
mkdirSync(katalog, { recursive: true })
|
|
1270
|
+
} catch (e) {
|
|
1271
|
+
console.error(`kunde inte skapa experimentkatalogen ${katalog}: ${e.message}`)
|
|
1272
|
+
process.exit(2)
|
|
1273
|
+
}
|
|
1274
|
+
const lockPath = acquireLock()
|
|
1275
|
+
let andringar = 0
|
|
1276
|
+
let fannsFil = false
|
|
1277
|
+
try {
|
|
1278
|
+
const { state, fanns } = readExperimentOrThrow(katalog)
|
|
1279
|
+
fannsFil = fanns
|
|
1280
|
+
let nasta = { ...state }
|
|
1281
|
+
// --satt är append-only som proveniens: ett fält skrivs en gång, samma
|
|
1282
|
+
// värde igen är en no-op, ett annat värde är ett fel. Alla fält prövas
|
|
1283
|
+
// före skrivningen, så ett vägrat anrop lämnar ingenting efter sig.
|
|
1284
|
+
for (const [nyckel, varde] of sattPar) {
|
|
1285
|
+
if (nasta[nyckel] === undefined) {
|
|
1286
|
+
nasta = { ...nasta, [nyckel]: varde }
|
|
1287
|
+
andringar++
|
|
1288
|
+
} else if (nasta[nyckel] !== varde) {
|
|
1289
|
+
throw new ArkivFel(
|
|
1290
|
+
`experiment: fältet ${nyckel} har redan värdet ${JSON.stringify(nasta[nyckel])}, ` +
|
|
1291
|
+
`begärt ${JSON.stringify(varde)}; ett fält skrivs bara en gång`,
|
|
1292
|
+
)
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
// UNION med bevarad ordning: befintliga först, invokationens nya efter.
|
|
1296
|
+
// En headless-process med en enmodellslista rör alltså aldrig de andra.
|
|
1297
|
+
const befintliga = nasta.varianter ?? []
|
|
1298
|
+
const tillagda = nyaVarianter.filter(
|
|
1299
|
+
(v, i, alla) => !befintliga.includes(v) && alla.indexOf(v) === i,
|
|
1300
|
+
)
|
|
1301
|
+
if (tillagda.length > 0) {
|
|
1302
|
+
nasta = { ...nasta, varianter: [...befintliga, ...tillagda] }
|
|
1303
|
+
andringar += tillagda.length
|
|
1304
|
+
}
|
|
1305
|
+
// Bara den egna raden skrivs; andra körningars rader kopieras orörda.
|
|
1306
|
+
if (korning !== null) {
|
|
1307
|
+
const rad = orsak === null ? { status } : { status, orsak }
|
|
1308
|
+
const befintlig = nasta.korningar?.[korning]
|
|
1309
|
+
if (JSON.stringify(befintlig) !== JSON.stringify(rad)) {
|
|
1310
|
+
nasta = { ...nasta, korningar: { ...(nasta.korningar ?? {}), [korning]: rad } }
|
|
1311
|
+
andringar++
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
if (andringar > 0 || !fanns) writeJsonAtomiskOrThrow(nasta, katalog, 'experiment.json')
|
|
1315
|
+
} catch (e) {
|
|
1316
|
+
releaseLock(lockPath)
|
|
1317
|
+
if (e instanceof ArkivFel) {
|
|
1318
|
+
console.error(e.message)
|
|
1319
|
+
process.exit(2)
|
|
1320
|
+
}
|
|
1321
|
+
throw e
|
|
1322
|
+
}
|
|
1323
|
+
releaseLock(lockPath)
|
|
1324
|
+
// En nyskapad fil är en ändring även utan begärda fält, därför räknas
|
|
1325
|
+
// "oförändrad" bara när filen redan fanns.
|
|
1326
|
+
console.log(
|
|
1327
|
+
andringar === 0 && fannsFil
|
|
1328
|
+
? 'OK: experiment oförändrad'
|
|
1329
|
+
: `OK: experiment (${andringar} ändringar)`,
|
|
1330
|
+
)
|
|
984
1331
|
} else if (cmd === 'proveniens') {
|
|
985
1332
|
// katalog är HÄR körningskatalogen (experiments/<slug>/<modell>/k<N>).
|
|
986
1333
|
// Stämplingen stod tidigare bara som prosa i SKILL.md steg 4 och blev
|
|
@@ -1039,29 +1386,27 @@ if (cmd === 'add-run') {
|
|
|
1039
1386
|
// nyckelmängden ur modelUsage (suffix som `[1m]` strippas) — det är den
|
|
1040
1387
|
// startaren jämför mot varvets begärda modell. Saknas filen skrivs inget
|
|
1041
1388
|
// fält: en påhittad tom lista ser ut som "inga modeller anropades".
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1389
|
+
// Filen ligger på körningsnivå medan stämplingen ibland görs per del-N
|
|
1390
|
+
// (plan requestmotor-14001), så den söks uppåt från katalogen, aldrig
|
|
1391
|
+
// utanför experimentytan och högst tre nivåer.
|
|
1392
|
+
const korningFil = findUpward(katalog, 'korning.json')
|
|
1393
|
+
if (korningFil !== null) {
|
|
1394
|
+
try {
|
|
1395
|
+
const korning = JSON.parse(readFileSync(korningFil, 'utf8'))
|
|
1396
|
+
const nycklar = Object.keys(korning.modelUsage ?? {})
|
|
1397
|
+
if (nycklar.length > 0)
|
|
1398
|
+
stampel.fakturerade_modeller = [...new Set(nycklar.map(canonicalModel))].sort()
|
|
1399
|
+
} catch (e) {
|
|
1400
|
+
console.error(`kunde inte läsa ${korningFil} för fakturerade_modeller: ${e.message}`)
|
|
1050
1401
|
process.exit(2)
|
|
1051
1402
|
}
|
|
1052
1403
|
}
|
|
1053
1404
|
|
|
1054
1405
|
// Delarna är samma körning: samma modell, samma datum, samma hashar. De
|
|
1055
1406
|
// 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
|
-
|
|
1058
|
-
|
|
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()
|
|
1407
|
+
// låter delkolumnen ärva förälderns värden vid läsning. Rekursivt: ett
|
|
1408
|
+
// DELA_UPP inuti en buntdel ger del-N/del-M (align F).
|
|
1409
|
+
const delar = delKataloger(katalog)
|
|
1065
1410
|
|
|
1066
1411
|
const lockPath = acquireLock()
|
|
1067
1412
|
try {
|
|
@@ -1076,8 +1421,19 @@ if (cmd === 'add-run') {
|
|
|
1076
1421
|
attSkriva.push([d, state])
|
|
1077
1422
|
continue
|
|
1078
1423
|
}
|
|
1079
|
-
|
|
1080
|
-
|
|
1424
|
+
// Djup jämförelse per fält: `fakturerade_modeller` är en array, och en
|
|
1425
|
+
// referensjämförelse hade fått varje omanrop att "skilja sig" i just
|
|
1426
|
+
// det fältet.
|
|
1427
|
+
// Unionen av nycklar: ett fält som fanns i den lagrade stämpeln men
|
|
1428
|
+
// saknas i den nya (korning.json hittas inte längre) är också drift.
|
|
1429
|
+
const nycklar = new Set([
|
|
1430
|
+
...Object.keys(stampel),
|
|
1431
|
+
...Object.keys(befintlig === null ? {} : befintlig),
|
|
1432
|
+
])
|
|
1433
|
+
const skiljer = [...nycklar].filter(
|
|
1434
|
+
(k) =>
|
|
1435
|
+
JSON.stringify(befintlig === null ? undefined : befintlig[k]) !==
|
|
1436
|
+
JSON.stringify(stampel[k]),
|
|
1081
1437
|
)
|
|
1082
1438
|
if (skiljer.length > 0) {
|
|
1083
1439
|
throw new ArkivFel(
|
|
@@ -1100,13 +1456,327 @@ if (cmd === 'add-run') {
|
|
|
1100
1456
|
}
|
|
1101
1457
|
throw e
|
|
1102
1458
|
}
|
|
1459
|
+
} else if (cmd === 'stopp') {
|
|
1460
|
+
// Stopputfall UTAN scorer (ADR-0105/0106/0118): en kandidatspec routar direkt
|
|
1461
|
+
// till KONCEPT/DIAGNOS, en analys med motsägelse direkt till
|
|
1462
|
+
// TILLBAKA_TILL_SPEC, och ett prisavgörande öppet val i granskarblock.md
|
|
1463
|
+
// direkt till TILLBAKA_TILL_SPEC eller ESKALERA. Kommandot litar aldrig på
|
|
1464
|
+
// anroparens påstående: källan måste bära det.
|
|
1465
|
+
avvisaOkandaFlaggor('stopp', ['utfall', 'kalla', 'orsak'])
|
|
1466
|
+
const utfall = requireFlag('utfall')
|
|
1467
|
+
const kalla = requireFlag('kalla')
|
|
1468
|
+
const orsak = flag('orsak')
|
|
1469
|
+
if (!STOPP_UTFALL.includes(utfall)) {
|
|
1470
|
+
console.error(`okänt --utfall: ${utfall} (giltiga: ${STOPP_UTFALL.join(', ')})`)
|
|
1471
|
+
process.exit(2)
|
|
1472
|
+
}
|
|
1473
|
+
if (!STOPP_KALLOR.includes(kalla)) {
|
|
1474
|
+
console.error(`okänd --kalla: ${kalla} (giltiga: ${STOPP_KALLOR.join(', ')})`)
|
|
1475
|
+
process.exit(2)
|
|
1476
|
+
}
|
|
1477
|
+
const kallaPath = path.join(katalog, kalla)
|
|
1478
|
+
let text
|
|
1479
|
+
try {
|
|
1480
|
+
text = readFileSync(kallaPath, 'utf8')
|
|
1481
|
+
} catch (e) {
|
|
1482
|
+
console.error(`kunde inte läsa källan ${kallaPath}: ${e.message}`)
|
|
1483
|
+
process.exit(2)
|
|
1484
|
+
}
|
|
1485
|
+
if (kalla === 'spec.md') {
|
|
1486
|
+
const kandidat = kandidatIn(text)
|
|
1487
|
+
if (kandidat === null) {
|
|
1488
|
+
console.error(
|
|
1489
|
+
`${kallaPath} bär ingen kandidatmarkör (hel rubrikrad Koncept-kandidat/Diagnos-kandidat), ` +
|
|
1490
|
+
`kan inte stoppas som ${utfall}`,
|
|
1491
|
+
)
|
|
1492
|
+
process.exit(2)
|
|
1493
|
+
}
|
|
1494
|
+
if (kandidat.utfall !== utfall) {
|
|
1495
|
+
console.error(
|
|
1496
|
+
`${kallaPath} bär markören för ${kandidat.utfall} (rad ${kandidat.line + 1}), inte ${utfall}`,
|
|
1497
|
+
)
|
|
1498
|
+
process.exit(2)
|
|
1499
|
+
}
|
|
1500
|
+
} else if (kalla === 'granskarblock.md') {
|
|
1501
|
+
// ADR-0118: bara ett ja på raden Prisavgörande öppet val är ett stopp.
|
|
1502
|
+
if (prisavgorandeVal(text) === null) {
|
|
1503
|
+
console.error(
|
|
1504
|
+
`${kallaPath} bär inget prisavgörande öppet val (raden "Prisavgörande öppet val: ja: …"), ` +
|
|
1505
|
+
'kan inte stoppas',
|
|
1506
|
+
)
|
|
1507
|
+
process.exit(2)
|
|
1508
|
+
}
|
|
1509
|
+
if (!['TILLBAKA_TILL_SPEC', 'ESKALERA'].includes(utfall)) {
|
|
1510
|
+
console.error(
|
|
1511
|
+
`ett prisavgörande val i ${kalla} stoppar som TILLBAKA_TILL_SPEC eller ESKALERA, inte ${utfall}`,
|
|
1512
|
+
)
|
|
1513
|
+
process.exit(2)
|
|
1514
|
+
}
|
|
1515
|
+
if (utfall === 'ESKALERA' && !orsak) {
|
|
1516
|
+
console.error(
|
|
1517
|
+
'ESKALERA kräver --orsak: en människa ska se varför posten inte gick tillbaka till specen en gång till',
|
|
1518
|
+
)
|
|
1519
|
+
process.exit(2)
|
|
1520
|
+
}
|
|
1521
|
+
} else {
|
|
1522
|
+
const etikett = avvikelseEtikett(text)
|
|
1523
|
+
if (etikett !== 'motsägelse') {
|
|
1524
|
+
console.error(
|
|
1525
|
+
`${kallaPath} bär ${etikett === null ? 'ingen avvikelseetikett' : `etiketten "${etikett}"`} ` +
|
|
1526
|
+
'på raden "Avvikelse mot specen:", bara "motsägelse" är ett stopp',
|
|
1527
|
+
)
|
|
1528
|
+
process.exit(2)
|
|
1529
|
+
}
|
|
1530
|
+
if (utfall !== 'TILLBAKA_TILL_SPEC') {
|
|
1531
|
+
console.error(`en motsägelse i ${kalla} stoppar som TILLBAKA_TILL_SPEC, inte ${utfall}`)
|
|
1532
|
+
process.exit(2)
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
const lockPath = acquireLock()
|
|
1537
|
+
let skrivet = false
|
|
1538
|
+
try {
|
|
1539
|
+
const state = readStateOrThrow()
|
|
1540
|
+
const befintlig = state.stopp ?? null
|
|
1541
|
+
if (befintlig !== null) {
|
|
1542
|
+
// Write-once på utfall+kalla; datumet och orsaken ingår inte, ett
|
|
1543
|
+
// identiskt omanrop en annan dag (eller med en annan orsakstext) är en
|
|
1544
|
+
// no-op.
|
|
1545
|
+
const begart = { utfall, kalla }
|
|
1546
|
+
const skiljer = Object.keys(begart).filter((k) => befintlig[k] !== begart[k])
|
|
1547
|
+
if (skiljer.length > 0) {
|
|
1548
|
+
throw new ArkivFel(
|
|
1549
|
+
`${statePath()} har redan ett stopp (${befintlig.utfall} ur ${befintlig.kalla}) som skiljer sig ` +
|
|
1550
|
+
`från ${utfall} ur ${kalla} (${skiljer.join(', ')}); arkivet är append-only, kör respec för att nolla`,
|
|
1551
|
+
)
|
|
1552
|
+
}
|
|
1553
|
+
} else {
|
|
1554
|
+
state.stopp = { utfall, kalla, date: today(), ...(orsak ? { orsak } : {}) }
|
|
1555
|
+
// impact.md: posten gick genom bedöm och är analyserad. spec.md och
|
|
1556
|
+
// granskarblock.md: kandidaten/det prisavgörande valet passerade aldrig
|
|
1557
|
+
// bedöms grind, posten stannar i spec_draft som förut.
|
|
1558
|
+
if (kalla === 'impact.md') state.status = 'assessed'
|
|
1559
|
+
writeStateOrThrow(state)
|
|
1560
|
+
skrivet = true
|
|
1561
|
+
}
|
|
1562
|
+
} catch (e) {
|
|
1563
|
+
releaseLock(lockPath)
|
|
1564
|
+
if (e instanceof ArkivFel) {
|
|
1565
|
+
console.error(e.message)
|
|
1566
|
+
process.exit(2)
|
|
1567
|
+
}
|
|
1568
|
+
throw e
|
|
1569
|
+
}
|
|
1570
|
+
releaseLock(lockPath)
|
|
1571
|
+
console.log(
|
|
1572
|
+
skrivet ? `OK: stopp ${utfall} ur ${kalla}` : `OK: stopp ${utfall} ur ${kalla} oförändrat`,
|
|
1573
|
+
)
|
|
1574
|
+
} else if (cmd === 'respec') {
|
|
1575
|
+
// Speca steg 5 som kommando: en ny spec över befintlig katalog invaliderar
|
|
1576
|
+
// analysen, godkännandet, domarna och stoppet. runs[] rörs aldrig, arkivet
|
|
1577
|
+
// är append-only; den gamla analysen döps om, raderas inte.
|
|
1578
|
+
avvisaOkandaFlaggor('respec', [])
|
|
1579
|
+
const lockPath = acquireLock()
|
|
1580
|
+
let antal = 0
|
|
1581
|
+
try {
|
|
1582
|
+
const state = readStateOrThrow()
|
|
1583
|
+
const impact = path.join(katalog, 'impact.md')
|
|
1584
|
+
if (existsSync(impact)) {
|
|
1585
|
+
const bas = path.join(katalog, `impact-superseded-${today()}`)
|
|
1586
|
+
let mal = `${bas}.md`
|
|
1587
|
+
for (let n = 2; existsSync(mal); n++) mal = `${bas}-${n}.md`
|
|
1588
|
+
try {
|
|
1589
|
+
renameSync(impact, mal)
|
|
1590
|
+
} catch (e) {
|
|
1591
|
+
throw new ArkivFel(`kunde inte döpa om ${impact} till ${mal}: ${e.message}`)
|
|
1592
|
+
}
|
|
1593
|
+
antal++
|
|
1594
|
+
}
|
|
1595
|
+
const nytt = { ...state }
|
|
1596
|
+
if (nytt.status !== 'spec_draft') {
|
|
1597
|
+
nytt.status = 'spec_draft'
|
|
1598
|
+
antal++
|
|
1599
|
+
}
|
|
1600
|
+
if (nytt.approval !== null) {
|
|
1601
|
+
nytt.approval = null
|
|
1602
|
+
antal++
|
|
1603
|
+
}
|
|
1604
|
+
if (!Array.isArray(nytt.granskarutslag) || nytt.granskarutslag.length > 0) {
|
|
1605
|
+
nytt.granskarutslag = []
|
|
1606
|
+
antal++
|
|
1607
|
+
}
|
|
1608
|
+
if (nytt.stopp !== null) {
|
|
1609
|
+
nytt.stopp = null
|
|
1610
|
+
antal++
|
|
1611
|
+
}
|
|
1612
|
+
// Monoton räknare (#505): varje respec-anrop är ett nytt varv oavsett om
|
|
1613
|
+
// övriga fält redan var normaliserade, till skillnad från fälten ovan
|
|
1614
|
+
// finns inget "redan rätt värde" att jämföra mot, så det här villkoret
|
|
1615
|
+
// är alltid sant.
|
|
1616
|
+
nytt.respec_varv = (state.respec_varv ?? 0) + 1
|
|
1617
|
+
antal++
|
|
1618
|
+
if (antal > 0) writeStateOrThrow(nytt)
|
|
1619
|
+
} catch (e) {
|
|
1620
|
+
releaseLock(lockPath)
|
|
1621
|
+
if (e instanceof ArkivFel) {
|
|
1622
|
+
console.error(e.message)
|
|
1623
|
+
process.exit(2)
|
|
1624
|
+
}
|
|
1625
|
+
throw e
|
|
1626
|
+
}
|
|
1627
|
+
releaseLock(lockPath)
|
|
1628
|
+
console.log(`OK: respec (${antal} ändringar)`)
|
|
1629
|
+
} else if (cmd === 'bluf') {
|
|
1630
|
+
// Läs-enbart tabellvy över katalogen och dess delar. Poängen tas ur
|
|
1631
|
+
// prisfilerna och stoppet ur state.json, aldrig ur `note` eller prosa: den
|
|
1632
|
+
// handskrivna BLUF:en i IPC 14001 felrapporterade.
|
|
1633
|
+
avvisaOkandaFlaggor('bluf', [])
|
|
1634
|
+
const SAKNAS = '–'
|
|
1635
|
+
const cell = (v) => String(v ?? SAKNAS).replace(/\|/g, '\\|')
|
|
1636
|
+
const radFor = (d, post) => {
|
|
1637
|
+
const state = readStateOrThrow(d)
|
|
1638
|
+
const runs = state.runs ?? []
|
|
1639
|
+
const direktaDelar = delKataloger(d).filter((p) => path.dirname(p) === d)
|
|
1640
|
+
let poang = SAKNAS
|
|
1641
|
+
let utfall = SAKNAS
|
|
1642
|
+
let konfidens = SAKNAS
|
|
1643
|
+
let anm = SAKNAS
|
|
1644
|
+
const nScore = maxN(runs, 'score')
|
|
1645
|
+
if (nScore > 0) {
|
|
1646
|
+
const fil = gallandeDokumentFil(state, 'score', nScore)
|
|
1647
|
+
const data = readPricingFileOrThrow(fil, d)
|
|
1648
|
+
poang = data.score ?? SAKNAS
|
|
1649
|
+
utfall = data.outcome ?? SAKNAS
|
|
1650
|
+
konfidens = data.confidence ?? SAKNAS
|
|
1651
|
+
const rad = [...runs].reverse().find((r) => r.file === fil)
|
|
1652
|
+
// En OLIKA som en dom redan lyft (liftingUtslag pekade ut gällande fil
|
|
1653
|
+
// ovan) väntar inte längre på någon; bara en olyft OLIKA får raden.
|
|
1654
|
+
if (rad?.double_run === 'olika' && !liftedByUtslag(state, 'score', nScore)) {
|
|
1655
|
+
anm = 'OLIKA, väntar på dom'
|
|
1656
|
+
const run2 = fil.replace(/\.json$/, '-run2.json')
|
|
1657
|
+
if (existsSync(path.join(d, run2))) {
|
|
1658
|
+
const b = readPricingFileOrThrow(run2, d)
|
|
1659
|
+
poang = `${poang} / ${b.score ?? SAKNAS}`
|
|
1660
|
+
}
|
|
1661
|
+
} else if (rad?.double_run === 'tolerans') {
|
|
1662
|
+
anm = 'tolerans'
|
|
1663
|
+
}
|
|
1664
|
+
} else if (!existsSync(path.join(d, 'spec.md')) && direktaDelar.length > 0) {
|
|
1665
|
+
poang = `bunt (${direktaDelar.length} delar)`
|
|
1666
|
+
utfall = poang
|
|
1667
|
+
} else if (state.stopp == null) {
|
|
1668
|
+
poang = 'ofärdig'
|
|
1669
|
+
}
|
|
1670
|
+
// Täckningspost (ADR-0119, #508): synlig i anm så granskaren ser att en
|
|
1671
|
+
// egen post väntar, utan att leta i pricing-filen. Beräknas separat och
|
|
1672
|
+
// läggs till sist (final-review M6) så att den varken tystas av
|
|
1673
|
+
// OLIKA/tolerans-texten ovan eller av ett stopp nedan.
|
|
1674
|
+
const taeckning = state.coverage_post?.yta ? `täckningspost: ${state.coverage_post.yta}` : null
|
|
1675
|
+
// Stopp trumfar OLIKA och tolerans: posten är stoppad oavsett vad
|
|
1676
|
+
// prisfilen säger, det tidigare anm-innehållet (OLIKA/tolerans) faller
|
|
1677
|
+
// bort. Täckningsposten är inte OLIKA/tolerans-text och överlever detta.
|
|
1678
|
+
if (state.stopp != null) {
|
|
1679
|
+
utfall = state.stopp.utfall
|
|
1680
|
+
anm =
|
|
1681
|
+
state.stopp.kalla === 'spec.md'
|
|
1682
|
+
? 'kandidat (spec.md)'
|
|
1683
|
+
: state.stopp.kalla === 'granskarblock.md'
|
|
1684
|
+
? 'prisavgörande val (granskarblock.md)'
|
|
1685
|
+
: 'motsägelse (impact.md)'
|
|
1686
|
+
}
|
|
1687
|
+
if (taeckning) anm = anm === SAKNAS ? taeckning : `${anm}; ${taeckning}`
|
|
1688
|
+
return `| ${[post, poang, utfall, konfidens, anm].map(cell).join(' | ')} |`
|
|
1689
|
+
}
|
|
1690
|
+
const rot = path.resolve(katalog)
|
|
1691
|
+
const rader = ['| post | poäng | utfall | konfidens | anm |', '| --- | --- | --- | --- | --- |']
|
|
1692
|
+
try {
|
|
1693
|
+
rader.push(radFor(rot, path.basename(rot)))
|
|
1694
|
+
for (const d of delKataloger(rot)) rader.push(radFor(d, path.relative(rot, d)))
|
|
1695
|
+
} catch (e) {
|
|
1696
|
+
if (e instanceof ArkivFel) {
|
|
1697
|
+
console.error(e.message)
|
|
1698
|
+
process.exit(2)
|
|
1699
|
+
}
|
|
1700
|
+
throw e
|
|
1701
|
+
}
|
|
1702
|
+
console.log(rader.join('\n'))
|
|
1103
1703
|
} else {
|
|
1104
1704
|
console.error(
|
|
1105
|
-
'okänt kommando (add-run, next-n, ny-korning, proveniens, sparr, utslag, set-status, kalibrering)',
|
|
1705
|
+
'okänt kommando (add-run, next-n, ny-korning, ny-del, proveniens, sparr, utslag, set-status, stopp, respec, bluf, kalibrering, experiment)',
|
|
1106
1706
|
)
|
|
1107
1707
|
process.exit(2)
|
|
1108
1708
|
}
|
|
1109
1709
|
|
|
1710
|
+
/** Tolkar ett --satt-par "nyckel=värde". Första "=" delar; `eskalerad` är
|
|
1711
|
+
* det enda booleska fältet och tar bara true/false, allt annat är en sträng.
|
|
1712
|
+
* `varianter` och `korningar` har egna flaggor med egen semantik (union,
|
|
1713
|
+
* egen rad) och får inte skrivas rakt av här. */
|
|
1714
|
+
function tolkaSatt(par) {
|
|
1715
|
+
const i = par.indexOf('=')
|
|
1716
|
+
if (i < 1) {
|
|
1717
|
+
console.error(`experiment: --satt ska ha formen nyckel=värde, fick "${par}"`)
|
|
1718
|
+
process.exit(2)
|
|
1719
|
+
}
|
|
1720
|
+
const nyckel = par.slice(0, i)
|
|
1721
|
+
const ra = par.slice(i + 1)
|
|
1722
|
+
if (nyckel === 'varianter' || nyckel === 'korningar') {
|
|
1723
|
+
console.error(`experiment: ${nyckel} skrivs med --variant respektive --korning, inte --satt`)
|
|
1724
|
+
process.exit(2)
|
|
1725
|
+
}
|
|
1726
|
+
if (nyckel === 'eskalerad') {
|
|
1727
|
+
if (ra !== 'true' && ra !== 'false') {
|
|
1728
|
+
console.error(`experiment: eskalerad tar bara true eller false, fick "${ra}"`)
|
|
1729
|
+
process.exit(2)
|
|
1730
|
+
}
|
|
1731
|
+
return [nyckel, ra === 'true']
|
|
1732
|
+
}
|
|
1733
|
+
return [nyckel, ra]
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
/** Effort ur agentdefinitionens frontmatter: raden `effort: <värde>` mellan
|
|
1737
|
+
* de inledande `---`-raderna, markörrader (haus_managed:) tål den. Saknas
|
|
1738
|
+
* filen eller raden blir det null och en VARNING på stderr: IPC 14001 hade
|
|
1739
|
+
* effort null på alla rader utan att någon såg det. Läser aldrig brödtexten. */
|
|
1740
|
+
function effortFranAgent(agent) {
|
|
1741
|
+
const p = path.join(claudeDir(), 'agents', `${agent}.md`)
|
|
1742
|
+
let text
|
|
1743
|
+
try {
|
|
1744
|
+
text = stripHausMarker(readFileSync(p, 'utf8'))
|
|
1745
|
+
} catch (e) {
|
|
1746
|
+
console.error(`VARNING: kunde inte läsa ${p} för effort (${e.message}), effort blir null`)
|
|
1747
|
+
return null
|
|
1748
|
+
}
|
|
1749
|
+
const lines = text.split('\n')
|
|
1750
|
+
if (lines[0]?.trim() === '---') {
|
|
1751
|
+
for (let i = 1; i < lines.length && lines[i].trim() !== '---'; i++) {
|
|
1752
|
+
const m = /^effort:\s*(\S+)$/.exec(lines[i])
|
|
1753
|
+
if (m) return m[1]
|
|
1754
|
+
}
|
|
1755
|
+
}
|
|
1756
|
+
console.error(`VARNING: ${p} saknar en effort-rad i sin frontmatter, effort blir null`)
|
|
1757
|
+
return null
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
/** Delkataloger under `dir`, REKURSIVT och i numerisk ordning: del-N som har
|
|
1761
|
+
* en state.json, djupet först (del-1, del-1/del-1, del-1/del-2, del-2, del-10).
|
|
1762
|
+
* En del utan state.json är inte en körning ännu och hoppas över, liksom
|
|
1763
|
+
* allt under den. Delad av proveniens och bluf så de två aldrig kan se
|
|
1764
|
+
* olika delar. */
|
|
1765
|
+
function delKataloger(dir) {
|
|
1766
|
+
let entries
|
|
1767
|
+
try {
|
|
1768
|
+
entries = readdirSync(dir, { withFileTypes: true })
|
|
1769
|
+
} catch (e) {
|
|
1770
|
+
throw new ArkivFel(`kunde inte lista ${dir}: ${e.message}`)
|
|
1771
|
+
}
|
|
1772
|
+
return entries
|
|
1773
|
+
.filter((e) => e.isDirectory() && /^del-\d+$/.test(e.name))
|
|
1774
|
+
.sort((a, b) => Number(a.name.slice(4)) - Number(b.name.slice(4)))
|
|
1775
|
+
.map((e) => path.join(dir, e.name))
|
|
1776
|
+
.filter((d) => existsSync(path.join(d, 'state.json')))
|
|
1777
|
+
.flatMap((d) => [d, ...delKataloger(d)])
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1110
1780
|
/** Det SENASTE viktningsutslaget (om något) som lyfter en axel vid ett givet
|
|
1111
1781
|
* gällande n — namnger axeln, har ett `n` som är EXAKT det gällande n:et
|
|
1112
1782
|
* (inte "minst lika högt"), och bär en `galler_fil`.
|
|
@@ -1250,8 +1920,8 @@ function gallandeDokumentFil(state, typ, n) {
|
|
|
1250
1920
|
* grinden rapporterade att inget blockerar. Kastar ArkivFel; anroparen
|
|
1251
1921
|
* (set-status) fångar det och avslutar med exit 2, skilt från den vanliga
|
|
1252
1922
|
* spärr-vägran (exit 1). */
|
|
1253
|
-
function readPricingFileOrThrow(fil) {
|
|
1254
|
-
const p = path.join(
|
|
1923
|
+
function readPricingFileOrThrow(fil, d = katalog) {
|
|
1924
|
+
const p = path.join(d, fil)
|
|
1255
1925
|
let raw
|
|
1256
1926
|
try {
|
|
1257
1927
|
raw = readFileSync(p, 'utf8')
|
|
@@ -1345,6 +2015,13 @@ function pricedBlockers(state) {
|
|
|
1345
2015
|
if (state.experiment === true) {
|
|
1346
2016
|
skal.push('experimentvariant blir aldrig priced')
|
|
1347
2017
|
}
|
|
2018
|
+
// Ett stopputfall skrivet av `stopp` (kandidat ur spec.md, motsägelse ur
|
|
2019
|
+
// impact.md) gäller tills `respec` nollar det. Bara HÄR, aldrig i
|
|
2020
|
+
// `sparrSkal`: sparr betyder "väntar på dom" och styr utvärderas
|
|
2021
|
+
// återupptagning; ett stopp där hade låst posten för alltid.
|
|
2022
|
+
if (state.stopp !== null && typeof state.stopp === 'object') {
|
|
2023
|
+
skal.push(`stopputfall ${state.stopp.utfall} ur ${state.stopp.kalla}`)
|
|
2024
|
+
}
|
|
1348
2025
|
const runs = state.runs ?? []
|
|
1349
2026
|
const nClass = maxN(runs, 'classification')
|
|
1350
2027
|
if (nClass > 0) {
|