@haus-tech/haus-workflow 5.0.0 → 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 +13 -0
- package/dist/cli.js +913 -751
- 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 +590 -227
- 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-sync/SKILL.md +3 -3
- 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
|
@@ -0,0 +1,622 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Scorerns kontrollkedja i EN process (plan requestmotor-korning, Task 1):
|
|
4
|
+
* svansrader, JSON-extraktion, deterministiska reparationer, check-lasta,
|
|
5
|
+
* resolve-anchors och contract-check, med valfri fältomskrivning av kundtext.
|
|
6
|
+
* Ersätter fyra separata anrop (och fyra orkestratorturer) per scorerkörning.
|
|
7
|
+
*
|
|
8
|
+
* Reparationerna ändrar aldrig poäng, utfall eller belägg: de rättar det
|
|
9
|
+
* dispatchern redan vet (request_id), form på enumvärden och siffersträngar,
|
|
10
|
+
* och LÄSTA-radens sökvägsform. Varje reparation skrivs som REPARERAT-rad och
|
|
11
|
+
* bokförs av anroparen i runs-radens note. Allt annat är modellfel och går
|
|
12
|
+
* till omdispatch enligt ADR-0092.
|
|
13
|
+
*
|
|
14
|
+
* Fältomskrivning (--fixa-kundtext): ett protokollterm-brott i ett rent
|
|
15
|
+
* kundtextfält kostar idag en hel scorerkörning, som dessutom kan ge en ny
|
|
16
|
+
* poäng, alltså brus i dubbelkörningen. Här skrivs bara fältet om av en
|
|
17
|
+
* billig modell (claude -p --model sonnet --max-turns 1) och kedjan körs om,
|
|
18
|
+
* högst MAX_FIXRUNDOR gånger. Råsvaren sparas via --fix-fil. Fixen är en
|
|
19
|
+
* valfri besparing: faller anropet (inget claude på PATH, exit skilt från 0,
|
|
20
|
+
* ogiltig JSON, is_error, tomt result) loggas en rad på stderr och kedjans
|
|
21
|
+
* ursprungliga BROTT står kvar, så posten går till omdispatch som vanligt.
|
|
22
|
+
* En brusten fix får aldrig fälla den obligatoriska kontrollkedjan.
|
|
23
|
+
*
|
|
24
|
+
* Användning:
|
|
25
|
+
* node kontroll.mjs <svar.txt> <spec.md> <impact.md> --expect-id <id> --ut <ren.json>
|
|
26
|
+
* [--fixa-kundtext] [--claude <kommando>] [--fix-modell sonnet] [--fix-fil <rå.json>]
|
|
27
|
+
* stdout: MODELL:, LÄSTA:, [AVVIKELSE:], REPARERAT: (0..n), kedjans rader, sist
|
|
28
|
+
* "OK: kontrollkedjan håller" (exit 0) eller "BROTT (n):" + rader (exit 1).
|
|
29
|
+
* Exit 2 = användningsfel eller ett delscript med exit 2.
|
|
30
|
+
*/
|
|
31
|
+
import { readFileSync, writeFileSync, realpathSync } from 'node:fs'
|
|
32
|
+
import { spawnSync } from 'node:child_process'
|
|
33
|
+
import { homedir } from 'node:os'
|
|
34
|
+
import path from 'node:path'
|
|
35
|
+
import { fileURLToPath } from 'node:url'
|
|
36
|
+
|
|
37
|
+
import {
|
|
38
|
+
parseSvansrader,
|
|
39
|
+
extraheraJson,
|
|
40
|
+
SCORE_OUTCOMES,
|
|
41
|
+
MODES,
|
|
42
|
+
STEP_RELATIONS,
|
|
43
|
+
KONFIDENS,
|
|
44
|
+
TYPER,
|
|
45
|
+
} from './lib.mjs'
|
|
46
|
+
|
|
47
|
+
const HAR = path.dirname(fileURLToPath(import.meta.url))
|
|
48
|
+
export const MAX_FIXRUNDOR = 1
|
|
49
|
+
const VIK = { hög: 'hog', låg: 'lag', större: 'storre' }
|
|
50
|
+
const KUNDTEXT_RE =
|
|
51
|
+
/^- (customer_rationale|conditions\[\d+\]|assumptions_stated\[\d+\]|alternatives\[\d+\]\.(?:label|adds)): protokollterm "([^"]+)" i kundtext$/u
|
|
52
|
+
|
|
53
|
+
const VARDEFLAGGOR = ['expect-id', 'ut', 'claude', 'fix-modell', 'fix-fil', 'state']
|
|
54
|
+
const BOOLFLAGGOR = ['fixa-kundtext']
|
|
55
|
+
|
|
56
|
+
export const ANVANDNING = [
|
|
57
|
+
'användning: kontroll.mjs <svar.txt> <spec.md> <impact.md> --expect-id <id> --ut <ren.json>',
|
|
58
|
+
' [--fixa-kundtext] [--claude <kommando>] [--fix-modell sonnet] [--fix-fil <rå.json>] [--state <state.json>]',
|
|
59
|
+
].join('\n')
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Rena hjälpare (testas in-process)
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
/** Argumenttolkning, fail-closed som svep.mjs tolkaArgv: alla saknade
|
|
66
|
+
* flaggor namnges på en gång, en okänd flagga eller ett värdelöst
|
|
67
|
+
* flaggvärde stoppar direkt i stället för att gissa. */
|
|
68
|
+
export function tolkaArgv(argv) {
|
|
69
|
+
const positionella = []
|
|
70
|
+
const enkla = {}
|
|
71
|
+
const bool = { 'fixa-kundtext': false }
|
|
72
|
+
for (let i = 0; i < argv.length; i++) {
|
|
73
|
+
const a = argv[i]
|
|
74
|
+
if (!a.startsWith('--')) {
|
|
75
|
+
positionella.push(a)
|
|
76
|
+
continue
|
|
77
|
+
}
|
|
78
|
+
const namn = a.slice(2)
|
|
79
|
+
if (BOOLFLAGGOR.includes(namn)) {
|
|
80
|
+
bool[namn] = true
|
|
81
|
+
continue
|
|
82
|
+
}
|
|
83
|
+
if (!VARDEFLAGGOR.includes(namn)) return { fel: `okänd flagga: --${namn}` }
|
|
84
|
+
const v = argv[i + 1]
|
|
85
|
+
if (v === undefined || v.startsWith('--')) return { fel: `--${namn} kräver ett värde` }
|
|
86
|
+
i++
|
|
87
|
+
enkla[namn] = v
|
|
88
|
+
}
|
|
89
|
+
if (positionella.length !== 3)
|
|
90
|
+
return {
|
|
91
|
+
fel: `förväntade tre positionella sökvägar (svar.txt spec.md impact.md), fick ${positionella.length}`,
|
|
92
|
+
}
|
|
93
|
+
const saknas = ['expect-id', 'ut'].filter((n) => enkla[n] === undefined)
|
|
94
|
+
if (saknas.length > 0) return { fel: `saknar ${saknas.map((n) => `--${n}`).join(', ')}` }
|
|
95
|
+
return {
|
|
96
|
+
svarPath: positionella[0],
|
|
97
|
+
specPath: positionella[1],
|
|
98
|
+
impactPath: positionella[2],
|
|
99
|
+
expectId: enkla['expect-id'],
|
|
100
|
+
utPath: enkla.ut,
|
|
101
|
+
fixaKundtext: bool['fixa-kundtext'],
|
|
102
|
+
claudeCmd: enkla.claude ?? 'claude',
|
|
103
|
+
fixModell: enkla['fix-modell'] ?? 'sonnet',
|
|
104
|
+
fixFil: enkla['fix-fil'] ?? null,
|
|
105
|
+
statePath: enkla.state ?? null,
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const enumForm = (v, tillatna, { versal = false } = {}) => {
|
|
110
|
+
if (typeof v !== 'string') return null
|
|
111
|
+
let k = v.trim()
|
|
112
|
+
k = versal ? k.toUpperCase() : k.toLowerCase()
|
|
113
|
+
if (!versal && VIK[k]) k = VIK[k]
|
|
114
|
+
return tillatna.includes(k) && k !== v ? k : null
|
|
115
|
+
}
|
|
116
|
+
const talForm = (v) =>
|
|
117
|
+
typeof v === 'string' && /^\d+([.,]\d+)?$/.test(v.trim())
|
|
118
|
+
? Number(v.trim().replace(',', '.'))
|
|
119
|
+
: null
|
|
120
|
+
|
|
121
|
+
const typForm = (v) => {
|
|
122
|
+
if (typeof v !== 'string') return null
|
|
123
|
+
const k = v.trim().toLowerCase()
|
|
124
|
+
const traff = TYPER.find((t) => t.toLowerCase() === k)
|
|
125
|
+
return traff !== undefined && traff !== v ? traff : null
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Ankarnycklarnas alias: modellen skriver ibland den svenska stavningen
|
|
129
|
+
// (samma ord som i prosan) i stället för resolve-anchors.mjs:s ASCII-schema
|
|
130
|
+
// (samma stil som kalla/hog/storre). Bara nyckeln byts, aldrig värdet.
|
|
131
|
+
const ANKAR_NYCKEL_ALIAS = [
|
|
132
|
+
['delsträng', 'delstrang'],
|
|
133
|
+
['källa', 'kalla'],
|
|
134
|
+
]
|
|
135
|
+
// De två fält resolve-anchors.mjs:s walk() letar efter på valfri nivå i
|
|
136
|
+
// dokumentet (MAPPINGS i resolve-anchors.mjs): ankare (poängens drivers) och
|
|
137
|
+
// belagg_ankare (klassaxelns frågerader, kvar för äldre arkiv).
|
|
138
|
+
const ANKAR_FALT = ['ankare', 'belagg_ankare']
|
|
139
|
+
|
|
140
|
+
/** Normaliserar ankarobjektens nycklar var resolve-anchors.mjs än letar
|
|
141
|
+
* efter dem (samma fält, samma djup som dess walk()). Bara när ASCII-
|
|
142
|
+
* nyckeln saknas; värdet kopieras ordagrant, aldrig rört. */
|
|
143
|
+
const normaliseraAnkarnycklar = (node, sokvag, rader) => {
|
|
144
|
+
if (Array.isArray(node))
|
|
145
|
+
return node.map((v, i) => normaliseraAnkarnycklar(v, `${sokvag}[${i}]`, rader))
|
|
146
|
+
if (node === null || typeof node !== 'object') return node
|
|
147
|
+
const ut = {}
|
|
148
|
+
for (const [nyckel, varde] of Object.entries(node)) {
|
|
149
|
+
const barnvag = sokvag ? `${sokvag}.${nyckel}` : nyckel
|
|
150
|
+
if (
|
|
151
|
+
ANKAR_FALT.includes(nyckel) &&
|
|
152
|
+
varde &&
|
|
153
|
+
typeof varde === 'object' &&
|
|
154
|
+
!Array.isArray(varde)
|
|
155
|
+
) {
|
|
156
|
+
let a = { ...varde }
|
|
157
|
+
for (const [gammal, ny] of ANKAR_NYCKEL_ALIAS) {
|
|
158
|
+
if (a[gammal] !== undefined && a[ny] === undefined) {
|
|
159
|
+
rader.push(`${barnvag}: nyckeln "${gammal}" normaliserad till "${ny}"`)
|
|
160
|
+
const { [gammal]: flyttat, ...rest } = a
|
|
161
|
+
a = { ...rest, [ny]: flyttat }
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
ut[nyckel] = a
|
|
165
|
+
} else {
|
|
166
|
+
ut[nyckel] = normaliseraAnkarnycklar(varde, barnvag, rader)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return ut
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const stegRelationer = (bracket, prefix, rader) => {
|
|
173
|
+
if (!Array.isArray(bracket?.steps)) return bracket
|
|
174
|
+
const steps = bracket.steps.map((s, i) => {
|
|
175
|
+
const r = enumForm(s?.relation, STEP_RELATIONS)
|
|
176
|
+
if (r === null) return s
|
|
177
|
+
rader.push(`${prefix}steps[${i}].relation "${s.relation}" normaliserad till "${r}"`)
|
|
178
|
+
return { ...s, relation: r }
|
|
179
|
+
})
|
|
180
|
+
return { ...bracket, steps }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Ny doc med reparationerna applicerade plus en rad per reparation. */
|
|
184
|
+
export function reparera(doc, { expectId }) {
|
|
185
|
+
const rader = []
|
|
186
|
+
let d = { ...doc }
|
|
187
|
+
if (expectId != null && d.request_id !== expectId) {
|
|
188
|
+
rader.push(`request_id "${d.request_id}" ersatt med "${expectId}" (dispatchern vet id:t)`)
|
|
189
|
+
d = { ...d, request_id: expectId }
|
|
190
|
+
}
|
|
191
|
+
const o = enumForm(d.outcome, SCORE_OUTCOMES, { versal: true })
|
|
192
|
+
if (o !== null) {
|
|
193
|
+
rader.push(`outcome "${d.outcome}" normaliserad till "${o}"`)
|
|
194
|
+
d = { ...d, outcome: o }
|
|
195
|
+
}
|
|
196
|
+
const m = enumForm(d.mode, MODES)
|
|
197
|
+
if (m !== null) {
|
|
198
|
+
rader.push(`mode "${d.mode}" normaliserad till "${m}"`)
|
|
199
|
+
d = { ...d, mode: m }
|
|
200
|
+
}
|
|
201
|
+
const c = enumForm(d.confidence, KONFIDENS)
|
|
202
|
+
if (c !== null) {
|
|
203
|
+
rader.push(`confidence "${d.confidence}" normaliserad till "${c}"`)
|
|
204
|
+
d = { ...d, confidence: c }
|
|
205
|
+
}
|
|
206
|
+
// type (#514 p1): översiktstabellens fem värden, exakt modulo skiftläge och
|
|
207
|
+
// blanksteg. Fritext hängd på värdet är inte reparerbar och faller i
|
|
208
|
+
// contract-check, avsiktligt.
|
|
209
|
+
const t = typForm(d.type)
|
|
210
|
+
if (t !== null) {
|
|
211
|
+
rader.push(`type "${d.type}" normaliserad till "${t}"`)
|
|
212
|
+
d = { ...d, type: t }
|
|
213
|
+
}
|
|
214
|
+
const s = talForm(d.score)
|
|
215
|
+
if (s !== null) {
|
|
216
|
+
rader.push(`score "${d.score}" tolkad som talet ${s}`)
|
|
217
|
+
d = { ...d, score: s }
|
|
218
|
+
}
|
|
219
|
+
if (d.bracket) d = { ...d, bracket: stegRelationer(d.bracket, 'bracket.', rader) }
|
|
220
|
+
if (Array.isArray(d.alternatives))
|
|
221
|
+
d = {
|
|
222
|
+
...d,
|
|
223
|
+
alternatives: d.alternatives.map((alt, j) => {
|
|
224
|
+
let a = alt
|
|
225
|
+
const as = talForm(a?.score)
|
|
226
|
+
if (as !== null) {
|
|
227
|
+
rader.push(`alternatives[${j}].score "${a.score}" tolkad som talet ${as}`)
|
|
228
|
+
a = { ...a, score: as }
|
|
229
|
+
}
|
|
230
|
+
if (a?.bracket)
|
|
231
|
+
a = { ...a, bracket: stegRelationer(a.bracket, `alternatives[${j}].bracket.`, rader) }
|
|
232
|
+
return a
|
|
233
|
+
}),
|
|
234
|
+
}
|
|
235
|
+
const ankarRader = []
|
|
236
|
+
const dAnkare = normaliseraAnkarnycklar(d, '', ankarRader)
|
|
237
|
+
if (ankarRader.length > 0) {
|
|
238
|
+
d = dAnkare
|
|
239
|
+
rader.push(...ankarRader)
|
|
240
|
+
}
|
|
241
|
+
return { doc: d, reparerat: rader }
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** LÄSTA-radens sökvägsform: ~ expanderas, avslutande skiljetecken strippas,
|
|
245
|
+
* relativa sökvägar löses mot underlagets katalog. Scopet prövas av
|
|
246
|
+
* check-lasta, aldrig här. */
|
|
247
|
+
export function normaliseraLasta(lasta, basDir) {
|
|
248
|
+
if (lasta == null || lasta.trim() === '' || lasta.trim() === 'inga') return 'inga'
|
|
249
|
+
return lasta
|
|
250
|
+
.split(',')
|
|
251
|
+
.map((s) => s.trim().replace(/[.,;]+$/u, ''))
|
|
252
|
+
.filter(Boolean)
|
|
253
|
+
.map((p) => (p.startsWith('~/') ? path.join(homedir(), p.slice(2)) : p))
|
|
254
|
+
.map((p) => (path.isAbsolute(p) ? p : path.resolve(basDir, p)))
|
|
255
|
+
.join(', ')
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Brotten som är rena kundtextbrott: alla eller inga. Ett enda brott av
|
|
259
|
+
* annan sort betyder att en fältomskrivning inte räcker. */
|
|
260
|
+
export function kundtextBrott(brottRader) {
|
|
261
|
+
const ut = []
|
|
262
|
+
for (const rad of brottRader) {
|
|
263
|
+
const m = KUNDTEXT_RE.exec(rad.trim())
|
|
264
|
+
if (!m) return []
|
|
265
|
+
ut.push({ falt: m[1], term: m[2] })
|
|
266
|
+
}
|
|
267
|
+
return ut
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Ny doc med fältet (customer_rationale, conditions[n], assumptions_stated[n],
|
|
271
|
+
* alternatives[n].label|adds) ersatt. */
|
|
272
|
+
export function ersattFalt(doc, falt, text) {
|
|
273
|
+
const m = /^(\w+)(?:\[(\d+)\])?(?:\.(\w+))?$/u.exec(falt)
|
|
274
|
+
if (!m) throw new Error(`okänt fält: ${falt}`)
|
|
275
|
+
const [, namn, idx, under] = m
|
|
276
|
+
if (idx === undefined) return { ...doc, [namn]: text }
|
|
277
|
+
const lista = [...(doc[namn] ?? [])]
|
|
278
|
+
const i = Number(idx)
|
|
279
|
+
lista[i] = under === undefined ? text : { ...lista[i], [under]: text }
|
|
280
|
+
return { ...doc, [namn]: lista }
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Läser fältets nuvarande text, motsvarigheten till ersattFalt fast läsande.
|
|
284
|
+
* Samma fältgrammatik: customer_rationale, conditions[n],
|
|
285
|
+
* assumptions_stated[n], alternatives[n].label|adds. */
|
|
286
|
+
function hamtaFalt(doc, falt) {
|
|
287
|
+
const m = /^(\w+)(?:\[(\d+)\])?(?:\.(\w+))?$/u.exec(falt)
|
|
288
|
+
if (!m) throw new Error(`okänt fält: ${falt}`)
|
|
289
|
+
const [, namn, idx, under] = m
|
|
290
|
+
if (idx === undefined) return doc[namn]
|
|
291
|
+
const post = (doc[namn] ?? [])[Number(idx)]
|
|
292
|
+
return under === undefined ? post : post?.[under]
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export function omskrivningsPrompt(text, term) {
|
|
296
|
+
return [
|
|
297
|
+
'Skriv om följande kundtext så att ordet "' +
|
|
298
|
+
term +
|
|
299
|
+
'" (och böjningar av det) inte förekommer.',
|
|
300
|
+
'Behåll innebörden och längden. Använd inga ord som beskriver hur ett pris räknats fram',
|
|
301
|
+
'(poäng, ankare, skala, referensuppdrag, inringning, konfidens, klass, protokoll).',
|
|
302
|
+
'Svara med enbart den omskrivna texten, utan citattecken och utan kommentar.',
|
|
303
|
+
'',
|
|
304
|
+
text,
|
|
305
|
+
].join('\n')
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Läser och parsar en JSON-fil. { data } eller { fel }, aldrig ett kastat
|
|
309
|
+
* undantag, så anroparen kan bokföra felet som ett dispatcherfel (exit 2). */
|
|
310
|
+
function lasJson(path) {
|
|
311
|
+
try {
|
|
312
|
+
return { data: JSON.parse(readFileSync(path, 'utf8')) }
|
|
313
|
+
} catch (e) {
|
|
314
|
+
return { fel: `kunde inte läsa ${path}: ${e.message}` }
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Skriver en JSON-fil (två mellanslags indrag, avslutande radbrytning).
|
|
319
|
+
* null vid lyckad skrivning, annars ett felmeddelande. */
|
|
320
|
+
function skrivJson(path, data) {
|
|
321
|
+
try {
|
|
322
|
+
writeFileSync(path, JSON.stringify(data, null, 2) + '\n')
|
|
323
|
+
return null
|
|
324
|
+
} catch (e) {
|
|
325
|
+
return `kunde inte skriva ${path}: ${e.message}`
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Tolkar ett `claude -p --output-format json`-svar från kundtextfixen.
|
|
331
|
+
* { text } bara när CLI:n svarade utan fel OCH `result` är en icke-tom sträng.
|
|
332
|
+
* Utan den kontrollen skrevs `undefined` eller tomma strängen rakt in i
|
|
333
|
+
* kundtextfältet: en tyst radering av kundens motivering, som dessutom
|
|
334
|
+
* passerar kedjan (ingen protokollterm i en tom sträng).
|
|
335
|
+
*/
|
|
336
|
+
export function tolkaOmskrivning(parsed) {
|
|
337
|
+
if (parsed?.is_error === true)
|
|
338
|
+
return { fel: `CLI:n rapporterade is_error (${String(parsed.result ?? '').slice(0, 200)})` }
|
|
339
|
+
const text = parsed?.result
|
|
340
|
+
if (typeof text !== 'string') return { fel: 'svaret saknar en result-sträng' }
|
|
341
|
+
if (text.trim() === '') return { fel: 'svarets result är tomt' }
|
|
342
|
+
return { text }
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Skriver om ETT kundtextfält via en billig `claude -p`. { fel }, eller
|
|
346
|
+
* { d, svar, rad }: ny doc med fältet ersatt, råsvaret (för --fix-fil) och
|
|
347
|
+
* REPARERAT-raden. */
|
|
348
|
+
function omskrivFalt({ d, falt, term, claudeCmd, fixModell }) {
|
|
349
|
+
const svar = spawnSync(
|
|
350
|
+
claudeCmd,
|
|
351
|
+
['-p', '--model', fixModell, '--output-format', 'json', '--max-turns', '1'],
|
|
352
|
+
{ input: omskrivningsPrompt(hamtaFalt(d, falt), term), encoding: 'utf8' },
|
|
353
|
+
)
|
|
354
|
+
// spawnSync lämnar stdout och stderr som null när kommandot inte finns på
|
|
355
|
+
// PATH, så `(svar.stderr || svar.stdout).trim()` hade kastat här.
|
|
356
|
+
if (svar.error)
|
|
357
|
+
return { fel: `${claudeCmd}: omskrivning av ${falt} startade inte (${svar.error.message})` }
|
|
358
|
+
if (svar.status !== 0) {
|
|
359
|
+
const text = (svar.stderr || svar.stdout || '').trim() || `exit ${svar.status}`
|
|
360
|
+
return { fel: `${claudeCmd}: omskrivning av ${falt} misslyckades (${text})` }
|
|
361
|
+
}
|
|
362
|
+
let parsed
|
|
363
|
+
try {
|
|
364
|
+
parsed = JSON.parse(svar.stdout)
|
|
365
|
+
} catch (e) {
|
|
366
|
+
return { fel: `${claudeCmd}: ogiltigt JSON-svar vid omskrivning av ${falt}: ${e.message}` }
|
|
367
|
+
}
|
|
368
|
+
const tolkad = tolkaOmskrivning(parsed)
|
|
369
|
+
if (tolkad.fel) return { fel: `${claudeCmd}: omskrivning av ${falt}: ${tolkad.fel}` }
|
|
370
|
+
return {
|
|
371
|
+
d: ersattFalt(d, falt, tolkad.text),
|
|
372
|
+
svar: parsed,
|
|
373
|
+
rad: `${falt} omskrivet av ${fixModell} (protokollterm "${term}")`,
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** Ett varvs alla fältomskrivningar. { fel } vid det första misslyckade
|
|
378
|
+
* anropet, och dokumentet lämnas då orört: en halvskriven runda får aldrig
|
|
379
|
+
* nå disk. */
|
|
380
|
+
function skrivOmFalten(doc, brott, { claudeCmd, fixModell }) {
|
|
381
|
+
let d = doc
|
|
382
|
+
const rader = []
|
|
383
|
+
const svar = []
|
|
384
|
+
for (const { falt, term } of brott) {
|
|
385
|
+
const fix = omskrivFalt({ d, falt, term, claudeCmd, fixModell })
|
|
386
|
+
if (fix.fel) return { fel: fix.fel }
|
|
387
|
+
d = fix.d
|
|
388
|
+
svar.push(fix.svar)
|
|
389
|
+
rader.push(fix.rad)
|
|
390
|
+
}
|
|
391
|
+
return { d, rader, svar }
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Upp till MAX_FIXRUNDOR varv av: läs --ut-filens aktuella skick, skriv om
|
|
396
|
+
* varje kundtextfält som bär ett brott via omskrivFalt, skriv --ut igen, kör
|
|
397
|
+
* kedjan om. Anropas bara när ALLA kvarvarande brott är kundtextbrott
|
|
398
|
+
* (anroparens ansvar).
|
|
399
|
+
*
|
|
400
|
+
* En misslyckad omskrivning är INTE ett dispatcherfel: fixen är en valfri
|
|
401
|
+
* besparing ovanpå den obligatoriska kedjan, och fälldes den som exit 2 kunde
|
|
402
|
+
* ett saknat `claude` på PATH slå ut hela kontrollkedjan. Då loggas en rad på
|
|
403
|
+
* stderr och kedjans ursprungliga BROTT returneras, så anroparen går den
|
|
404
|
+
* vanliga omdispatchvägen. Kvar som { fel } (exit 2) är bara läs- och
|
|
405
|
+
* skrivfel mot --ut-filen och ett delscript med exit 2.
|
|
406
|
+
*/
|
|
407
|
+
function fixaKundtextrundor({ startResultat, utPath, claudeCmd, fixModell, kedja }) {
|
|
408
|
+
let resultat = startResultat
|
|
409
|
+
const reparatRader = []
|
|
410
|
+
const raSvar = []
|
|
411
|
+
let rundor = 0
|
|
412
|
+
while (
|
|
413
|
+
resultat.brott.length > 0 &&
|
|
414
|
+
kundtextBrott(resultat.brott).length > 0 &&
|
|
415
|
+
rundor < MAX_FIXRUNDOR
|
|
416
|
+
) {
|
|
417
|
+
// resolve-anchors kan ha skrivit om --ut-filen (lösta citat) sedan den
|
|
418
|
+
// senaste skrivningen, så aktuellt skick läses från disk.
|
|
419
|
+
const last = lasJson(utPath)
|
|
420
|
+
if (last.fel) return { fel: `${last.fel} (inför fältomskrivning)` }
|
|
421
|
+
const runda = skrivOmFalten(last.data, kundtextBrott(resultat.brott), { claudeCmd, fixModell })
|
|
422
|
+
if (runda.fel) {
|
|
423
|
+
process.stderr.write(
|
|
424
|
+
`kundtextfix misslyckades: ${runda.fel}, faller tillbaka på omdispatch\n`,
|
|
425
|
+
)
|
|
426
|
+
return { resultat, reparatRader, raSvar }
|
|
427
|
+
}
|
|
428
|
+
const skrivFel = skrivJson(utPath, runda.d)
|
|
429
|
+
if (skrivFel) return { fel: skrivFel }
|
|
430
|
+
reparatRader.push(...runda.rader)
|
|
431
|
+
raSvar.push(...runda.svar)
|
|
432
|
+
rundor++
|
|
433
|
+
resultat = kedja()
|
|
434
|
+
if (resultat.exit2) return { fel: resultat.exit2 }
|
|
435
|
+
}
|
|
436
|
+
return { resultat, reparatRader, raSvar }
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// check-lasta.mjs och contract-check.mjs skriver sina brott som "- ...".
|
|
440
|
+
// resolve-anchors.mjs skriver "OLÖST: ..."/"FLERTYDIG: ..." (inget
|
|
441
|
+
// bindestreck), samma sorts brott, bara en annan yta. Normaliserar båda
|
|
442
|
+
// formerna till "- <raden>" så BROTT-blocket alltid har en enhetlig radform.
|
|
443
|
+
const brottFranRader = (rader) =>
|
|
444
|
+
rader
|
|
445
|
+
.filter((l) => l.startsWith('- ') || l.startsWith('OLÖST:') || l.startsWith('FLERTYDIG:'))
|
|
446
|
+
.map((l) => (l.startsWith('- ') ? l : `- ${l}`))
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Bygger `kor`/`kedja` (barnprocesserna check-lasta/resolve-anchors/contract-
|
|
450
|
+
* check), kör kedjan en gång, och vid `--fixa-kundtext` med enbart
|
|
451
|
+
* kundtextbrott (kundtextBrott returnerar tomt om ens ett brott är av annan
|
|
452
|
+
* sort, den vanliga BROTT-vägen tar då över i stället) delegerar till
|
|
453
|
+
* fixaKundtextrundor för resten. Returnerar { fel } vid ett dispatcherfel
|
|
454
|
+
* (exit 2 hos anroparen), annars { resultat, reparatRader, raSvar } där
|
|
455
|
+
* resultat är kedjans SISTA utfall.
|
|
456
|
+
*/
|
|
457
|
+
function korKedjaOchFixa({
|
|
458
|
+
utPath,
|
|
459
|
+
specPath,
|
|
460
|
+
impactPath,
|
|
461
|
+
lastaNorm,
|
|
462
|
+
expectId,
|
|
463
|
+
fixaKundtext,
|
|
464
|
+
claudeCmd,
|
|
465
|
+
fixModell,
|
|
466
|
+
statePath,
|
|
467
|
+
}) {
|
|
468
|
+
const kor = (script, args) =>
|
|
469
|
+
spawnSync(process.execPath, [path.join(HAR, script), ...args], { encoding: 'utf8' })
|
|
470
|
+
const kedja = () => {
|
|
471
|
+
const brott = []
|
|
472
|
+
const utskrift = []
|
|
473
|
+
// Vid ett olöst/flertydigt ankare lämnar resolve-anchors --ut-filen med
|
|
474
|
+
// citat obesatt. contract-check kan då bara ge exit 2 (citat saknas,
|
|
475
|
+
// "kör resolve-anchors först"), vilket kontroll.mjs annars felaktigt
|
|
476
|
+
// läser som ett dispatcherfel i stället för det retrybara BROTT det
|
|
477
|
+
// egentligen är. Kedjan stannar därför här, kör aldrig contract-check
|
|
478
|
+
// på det skicket.
|
|
479
|
+
for (const [script, args, stoppaVidBrott] of [
|
|
480
|
+
['check-lasta.mjs', [lastaNorm, specPath, impactPath], false],
|
|
481
|
+
['resolve-anchors.mjs', [utPath, specPath, impactPath], true],
|
|
482
|
+
[
|
|
483
|
+
'contract-check.mjs',
|
|
484
|
+
[
|
|
485
|
+
utPath,
|
|
486
|
+
specPath,
|
|
487
|
+
impactPath,
|
|
488
|
+
'--expect-id',
|
|
489
|
+
expectId,
|
|
490
|
+
...(statePath ? ['--state', statePath] : []),
|
|
491
|
+
],
|
|
492
|
+
false,
|
|
493
|
+
],
|
|
494
|
+
]) {
|
|
495
|
+
const r = kor(script, args)
|
|
496
|
+
if (r.status === 2) return { exit2: `${script}: ${(r.stderr || r.stdout).trim()}` }
|
|
497
|
+
const rader = r.stdout.split('\n').filter(Boolean)
|
|
498
|
+
utskrift.push(...rader)
|
|
499
|
+
if (r.status !== 0) {
|
|
500
|
+
brott.push(...brottFranRader(rader))
|
|
501
|
+
if (stoppaVidBrott) return { brott, utskrift }
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
return { brott, utskrift }
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
let resultat = kedja()
|
|
508
|
+
if (resultat.exit2) return { fel: resultat.exit2 }
|
|
509
|
+
|
|
510
|
+
const reparatRader = []
|
|
511
|
+
let raSvar = []
|
|
512
|
+
if (fixaKundtext && resultat.brott.length > 0 && kundtextBrott(resultat.brott).length > 0) {
|
|
513
|
+
const fix = fixaKundtextrundor({ startResultat: resultat, utPath, claudeCmd, fixModell, kedja })
|
|
514
|
+
if (fix.fel) return { fel: fix.fel }
|
|
515
|
+
resultat = fix.resultat
|
|
516
|
+
reparatRader.push(...fix.reparatRader)
|
|
517
|
+
raSvar = fix.raSvar
|
|
518
|
+
}
|
|
519
|
+
return { resultat, reparatRader, raSvar }
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** Skriver MODELL/LÄSTA/[AVVIKELSE]-raderna och sist verdikten: antingen det
|
|
523
|
+
* egna BROTT (1) när svaret aldrig gav JSON (`ingenJson`), eller
|
|
524
|
+
* REPARERAT-raderna, kedjans egna rader ordagrant och OK/BROTT. Returnerar
|
|
525
|
+
* exit-koden. */
|
|
526
|
+
function skrivUtfall({ modell, lastaNorm, avvikelse, ingenJson, reparatRader, resultat }) {
|
|
527
|
+
console.log(`MODELL: ${modell ?? '-'}`)
|
|
528
|
+
console.log(`LÄSTA: ${lastaNorm}`)
|
|
529
|
+
if (avvikelse) console.log(`AVVIKELSE: ${avvikelse}`)
|
|
530
|
+
if (ingenJson) {
|
|
531
|
+
console.log('BROTT (1):')
|
|
532
|
+
console.log('- svaret innehåller ingen JSON')
|
|
533
|
+
return 1
|
|
534
|
+
}
|
|
535
|
+
for (const r of reparatRader) console.log(`REPARERAT: ${r}`)
|
|
536
|
+
for (const l of resultat.utskrift) console.log(l)
|
|
537
|
+
if (resultat.brott.length === 0) {
|
|
538
|
+
console.log('OK: kontrollkedjan håller')
|
|
539
|
+
return 0
|
|
540
|
+
}
|
|
541
|
+
console.log(`BROTT (${resultat.brott.length}):`)
|
|
542
|
+
for (const b of resultat.brott) console.log(b)
|
|
543
|
+
return 1
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/** Läser svar.txt, delar svansraderna och extraherar JSON. { fel } vid ett
|
|
547
|
+
* läsfel, annars { modell, lastaNorm, avvikelse, raw } där raw är null när
|
|
548
|
+
* svaret aldrig gav JSON. */
|
|
549
|
+
function lasSvar(svarPath, specPath) {
|
|
550
|
+
let svarText
|
|
551
|
+
try {
|
|
552
|
+
svarText = readFileSync(svarPath, 'utf8')
|
|
553
|
+
} catch (e) {
|
|
554
|
+
return { fel: `kunde inte läsa ${svarPath}: ${e.message}` }
|
|
555
|
+
}
|
|
556
|
+
const { body, modell, lasta, avvikelse } = parseSvansrader(svarText)
|
|
557
|
+
return {
|
|
558
|
+
modell,
|
|
559
|
+
lastaNorm: normaliseraLasta(lasta, path.dirname(specPath)),
|
|
560
|
+
avvikelse,
|
|
561
|
+
raw: extraheraJson(body),
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// ---------------------------------------------------------------------------
|
|
566
|
+
// main (körs bara direkt, se arDirektKord längst ner)
|
|
567
|
+
// ---------------------------------------------------------------------------
|
|
568
|
+
|
|
569
|
+
export function main(argv) {
|
|
570
|
+
const val = tolkaArgv(argv)
|
|
571
|
+
if (val.fel) {
|
|
572
|
+
console.error(val.fel)
|
|
573
|
+
console.error(ANVANDNING)
|
|
574
|
+
return 2
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const inlast = lasSvar(val.svarPath, val.specPath)
|
|
578
|
+
if (inlast.fel) {
|
|
579
|
+
console.error(inlast.fel)
|
|
580
|
+
return 2
|
|
581
|
+
}
|
|
582
|
+
const { modell, lastaNorm, avvikelse, raw } = inlast
|
|
583
|
+
if (raw === null) return skrivUtfall({ modell, lastaNorm, avvikelse, ingenJson: true })
|
|
584
|
+
|
|
585
|
+
const { doc: reparerad, reparerat } = reparera(raw, { expectId: val.expectId })
|
|
586
|
+
const skrivFel = skrivJson(val.utPath, reparerad)
|
|
587
|
+
if (skrivFel) {
|
|
588
|
+
console.error(skrivFel)
|
|
589
|
+
return 2
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const kf = korKedjaOchFixa({ ...val, lastaNorm })
|
|
593
|
+
if (kf.fel) {
|
|
594
|
+
console.error(kf.fel)
|
|
595
|
+
return 2
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
if (val.fixFil && kf.raSvar.length > 0) {
|
|
599
|
+
const fixFilFel = skrivJson(val.fixFil, kf.raSvar)
|
|
600
|
+
if (fixFilFel) {
|
|
601
|
+
console.error(fixFilFel)
|
|
602
|
+
return 2
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
return skrivUtfall({
|
|
607
|
+
modell,
|
|
608
|
+
lastaNorm,
|
|
609
|
+
avvikelse,
|
|
610
|
+
reparatRader: [...reparerat, ...kf.reparatRader],
|
|
611
|
+
resultat: kf.resultat,
|
|
612
|
+
})
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
const arDirektKord = () => {
|
|
616
|
+
try {
|
|
617
|
+
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))
|
|
618
|
+
} catch {
|
|
619
|
+
return false
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
if (arDirektKord()) process.exit(main(process.argv.slice(2)))
|