kimal3 0.1.6 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,870 @@
1
+ import { createHash } from "node:crypto";
2
+ /*
3
+ Die reine Rechnung hinter `kimal3 kontext runter` -- und die einzige Stelle,
4
+ an der Server und CLI sich einig sein muessen.
5
+
6
+ 🔁 WARUM SIE IN `cli/src/` WOHNT UND NICHT IN `src/lib/arbeitsplatz/`:
7
+ Beide Seiten brauchen sie. Der Server schreibt den Merkzettel beim Ausliefern,
8
+ die CLI rechnet die Pruefsummen auf der Platte des Kunden nach. Die CLI kann
9
+ aber nichts aus `src/` importieren -- eigenes Paket, eigenes tsconfig. An der
10
+ anderen Stelle entstuende also zwangslaeufig eine zweite Kopie, und dann
11
+ hiesse derselbe Ordner je nach Weg "geaendert" oder "unveraendert".
12
+ Dasselbe Muster wie bei `werkbank-name.ts`, aus demselben Grund.
13
+
14
+ ⚠️ DIESE DATEI IMPORTIERT NUR `node:crypto`. Kommt hier je ein Import aus
15
+ `./api.js` oder `./anmeldung.js` hinzu, ist sie aus der App nicht mehr lesbar
16
+ -- und die zweite Kopie ist wieder da.
17
+ */
18
+ /**
19
+ * Die Dateien, die der Firma gehoeren und von `kimal3 kontext runter`
20
+ * nachgezogen werden -- die fuenf Fragedateien in `kontext/` und die
21
+ * Firmen-`CLAUDE.md`.
22
+ *
23
+ * 🔑 `CLAUDE.md` ist bewusst dabei (Spec §7c): Aendert die Firma ihre drei
24
+ * Saetze, erreichten sie einen bestehenden Ordner sonst NIE. Und der
25
+ * Nebengewinn ist groesser als der Anlass -- Verbesserungen an unserer eigenen
26
+ * Vorlage erreichen damit auch Ordner, die laengst beim Kunden liegen.
27
+ *
28
+ * 🔴 **`kontext/kurzprofil.md` steht seit Ruling K-24 (Task 7) hier -- und ohne
29
+ * sie war ein Satz unwahr, den wir bereits an Kunden ausliefern.** Die Datei
30
+ * wurde beim ERSTEN Ausliefern gefuellt, ein spaeteres `kimal3 kontext runter`
31
+ * frischte sie aber nie auf. Wer die drei Saetze in der App aenderte und danach
32
+ * den Ordner abglich, bekam still den alten Stand -- waehrend die Datei selbst
33
+ * ihm zusicherte, sie werde „zentral in der App gepflegt". *Ein Abgleich, der
34
+ * eine Datei auslaesst, meldet trotzdem Erfolg.*
35
+ *
36
+ * ⚠️ Ein Merkzettel, der VOR dieser Aenderung geschrieben wurde, fuehrt fuenf
37
+ * Eintraege bei sechs verwalteten Dateien. Der fehlende Eintrag ist KEIN
38
+ * Konflikt: `lageFuerDatei` liest `eintrag: null` als "wir wissen nichts" und
39
+ * sichert, bevor sie ueberschreibt (s. dort). Ist die lokale Datei ohnehin
40
+ * unveraendert, greift die Abkuerzung ganz oben und es passiert gar nichts.
41
+ *
42
+ * ⛔ Die Werkbank-`CLAUDE.md` steht NICHT hier. Sie traegt einen Namen im
43
+ * Ordner und gehoert einem Menschen (Spec §4).
44
+ */
45
+ /**
46
+ * Die oberste `CLAUDE.md` des Firmen-Ordners -- die einzige verwaltete Datei,
47
+ * die den Firmennamen im Klartext traegt (seit A-11) UND den Weg zum zweiten
48
+ * Menschen mitgeht (sie steht in der Positiv-Liste der `.gitignore`).
49
+ * Deshalb ist sie das Erkennungszeichen in `firmennameAusClaudeMd`.
50
+ *
51
+ * ⚠️ Sie ist zugleich der erste Eintrag von `VERWALTETE_DATEIEN` darunter --
52
+ * dass beide dieselbe Datei meinen, prueft `check:cli`. Dort haengt auch die
53
+ * Pruefung, dass die ECHTE Vorlage ihre Ueberschrift noch so schreibt, wie
54
+ * `firmennameAusClaudeMd` sie liest.
55
+ */
56
+ export const FIRMEN_CLAUDE_MD = "CLAUDE.md";
57
+ export const VERWALTETE_DATEIEN = [
58
+ "CLAUDE.md",
59
+ "kontext/kurzprofil.md",
60
+ "kontext/firma.md",
61
+ "kontext/marke.md",
62
+ "kontext/systeme.md",
63
+ "kontext/sicherheit.md",
64
+ ];
65
+ /**
66
+ * Die FUENF Texte, die eine Firma selbst pflegt -- welche Datei welches Feld
67
+ * der App fuellt, wie sie dort heisst, und woran man sie wiedererkennt.
68
+ *
69
+ * 🔑 **Warum es diese Tabelle gibt.** `kimal3 kontext hoch` muss jede Datei dem
70
+ * Feld zuordnen, das `/api/cli/kontext` (POST) erwartet. Bis Task 7 leitete
71
+ * eine Funktion den Feldnamen aus dem Dateinamen ab (`marke.md` -> `marke_md`)
72
+ * -- fuer vier Dateien richtig und fuer die fuenfte falsch: `kurzprofil.md`
73
+ * fuellt `drei_saetze`. Eine Ableitung, die einen Sonderfall braucht, ist
74
+ * keine Ableitung mehr, sondern eine Tabelle mit einem Loch.
75
+ *
76
+ * 🔴 **Sie ist NICHT der Fragenkatalog.** Hier stehen fuenf Dateinamen, fuenf
77
+ * Feldnamen, fuenf Anzeigenamen und fuenf Praefixe -- kein einziger Fragetext.
78
+ * Die 17 Fragen wohnen genau einmal, in `arbeitsplatz/vorlage/`, und erreichen
79
+ * die CLI ueber die ausgelieferte Datei selbst (s. `fragen-lesen.ts`).
80
+ *
81
+ * ⚠️ **`titel` ist ANZEIGETEXT und traegt deshalb echte Umlaute** -- ein Mensch
82
+ * liest ihn im Abbruch von `kontext hoch` („Diese Datei sieht nicht aus wie
83
+ * …"). Die ASCII-Umschrift dieser Datei gilt nur fuer Kommentare.
84
+ *
85
+ * 🔴 **`ankerPraefix` ist keine Verabredung, sondern eine gemessene Zusage.**
86
+ * `check:kontext` haelt jeden Anker aus `FRAGEN` (der App) gegen den Praefix
87
+ * seiner Datei und prueft ausserdem, dass kein Praefix im Anfang eines anderen
88
+ * steckt. Wer eine 18. Frage `firma_neu` in `marke.md` schreibt, wird dort rot
89
+ * -- nicht erst beim Kunden, dem `kontext hoch` seine Marke verweigert.
90
+ *
91
+ * ⚠️ **`FELDER` (`src/app/firma/kontext-felder.ts`) fuehrt dieselben fuenf
92
+ * Zuordnungen.** Die App darf diese Datei lesen, die CLI aber nicht die
93
+ * App -- deshalb steht die Tabelle hier und die Uebereinstimmung ist eine
94
+ * Pruefung in `check:kontext`, keine Hoffnung.
95
+ */
96
+ export const FIRMEN_TEXTE = [
97
+ { pfad: "kontext/kurzprofil.md", feld: "drei_saetze", titel: "Die Firma in drei Sätzen", ankerPraefix: "kurz_" },
98
+ { pfad: "kontext/firma.md", feld: "firma_md", titel: "Die Firma", ankerPraefix: "firma_" },
99
+ { pfad: "kontext/marke.md", feld: "marke_md", titel: "Die Marke", ankerPraefix: "marke_" },
100
+ { pfad: "kontext/systeme.md", feld: "systeme_md", titel: "Die Systeme", ankerPraefix: "systeme_" },
101
+ { pfad: "kontext/sicherheit.md", feld: "sicherheit_md", titel: "Sicherheit", ankerPraefix: "sicher_" },
102
+ ];
103
+ /**
104
+ * Der Merkzettel liegt im WURZELVERZEICHNIS des Arbeitsplatzes, eine Ebene
105
+ * ueber `kontext/`.
106
+ *
107
+ * ⚠️ Das ist kein Schoenheitsfehler: Er fuehrt alle verwalteten Dateien, und
108
+ * eine davon -- die Firmen-`CLAUDE.md` -- liegt gar nicht in `kontext/`. Ein
109
+ * Merkzettel in `kontext/`, der eine Datei ausserhalb verwaltet, waere die Sorte
110
+ * stiller Falschaussage, die niemand bemerkt, bis sie schadet.
111
+ */
112
+ export const MERKZETTEL_PFAD = ".kimal3-stand.json";
113
+ /** Wo die Sicherungen hinkommen. Siehe die Frage am Kopf des Bauplans. */
114
+ export const SICHERUNG_BASIS = "kontext";
115
+ /**
116
+ * Der Stand einer Firma, die nichts gepflegt hat.
117
+ *
118
+ * 🔴 Ohne diesen Wert saehe JEDE frische Installation aus wie "lokal
119
+ * geaendert" -- und das ist der haeufigste Fall ueberhaupt.
120
+ */
121
+ export const LEER = "leer";
122
+ /**
123
+ * Die aktuelle Fassung des MERKZETTEL-FORMATS selbst (Task 2, Spec US-B4) --
124
+ * NICHT die Fassung eines einzelnen Vordrucks (das ist `MerkEintrag.vorlage`).
125
+ * Sie steht hier als eigene, benannte Zahl, damit ein kuenftiger Umbau des
126
+ * Merkzettel-AUFBAUS (nicht seines Inhalts) einen Ankerpunkt hat, an dem er
127
+ * unterscheiden kann, was er vor sich hat.
128
+ */
129
+ export const FORMAT_AKTUELL = 1;
130
+ /** Der leere Merkzettel -- weder Datei-Eintraege noch eine bekannte Firma. */
131
+ const LEERER_MERKZETTEL = { firma_id: null, dateien: {} };
132
+ /**
133
+ * Den Firmennamen aus der obersten `CLAUDE.md` lesen -- ihre erste Zeile
134
+ * lautet `# CLAUDE.md — <Firma> · KI-Arbeitsplatz`.
135
+ *
136
+ * 🔴 **Warum es diese zweite Quelle ueberhaupt braucht** (Befund 2 der DRITTEN
137
+ * Review-Runde, 2026-08-27, und er trifft die Reparatur der zweiten):
138
+ * `firmenAbgleich` verglich zuerst nur die IDs aus den Merkzetteln. Der
139
+ * Merkzettel steht aber in der `.gitignore` der Firmenvorlage -- bewusst, er
140
+ * ist Maschinenzustand. **Genau der Fall, fuer den die Wache gebaut wurde, hat
141
+ * ihn deshalb nie:** Wer den Firmenordner als Kopie oder ueber das Repo der
142
+ * Firma bekommt -- also der zweite Mensch, fuer den `werkbank neu` da ist --
143
+ * hat kein `.kimal3-stand.json`. `firma_id` war `null`, die Antwort lautete
144
+ * `unbekannt`, und die Wache liess durch.
145
+ * *Eine Wache, die ihr Erkennungszeichen aus einer Datei liest, die den Weg
146
+ * zum Kunden gar nicht mitgeht, leuchtet fuer immer gruen.*
147
+ *
148
+ * 🔑 Die `CLAUDE.md` geht den Weg mit: Sie steht ausdruecklich in der
149
+ * Positiv-Liste der `.gitignore` (`!/CLAUDE.md`), und seit A-11 traegt sie den
150
+ * echten Firmennamen statt eines Platzhalters.
151
+ *
152
+ * ⚠️ Ein unersetzter `[FIRMA]`-Platzhalter zaehlt als **kein** Name: Er steht
153
+ * genau dann da, wenn der Server die Firma nicht kannte -- also im
154
+ * Vordruck-Fall, in dem es nichts zu vergleichen gibt.
155
+ */
156
+ export function firmennameAusClaudeMd(inhalt) {
157
+ if (inhalt === null)
158
+ return null;
159
+ const treffer = /^#\s*CLAUDE\.md\s*—\s*(.+?)\s*·\s*KI-Arbeitsplatz\s*$/m.exec(inhalt);
160
+ if (treffer === null)
161
+ return null;
162
+ const name = treffer[1].trim();
163
+ return name === "" || name === "[FIRMA]" ? null : name;
164
+ }
165
+ /**
166
+ * 🔑 **ZWEI Erkennungszeichen, in dieser Reihenfolge -- die ID schlaegt den
167
+ * Namen.** Zwei Firmen duerfen gleich heissen (dafuer gibt es `--firma-id`),
168
+ * aber nie dieselbe ID haben. Wo beide Seiten eine ID kennen, ist die Antwort
169
+ * also sicher; der Name ist der Rueckfall fuer den geklonten Ordner, der
170
+ * keinen Merkzettel hat.
171
+ *
172
+ * ⚠️ **Der Name ist die schwaechere Auskunft und wird nur gefragt, wenn die
173
+ * ID schweigt** -- nicht zusaetzlich. Sonst widersprechen sich zwei Quellen
174
+ * bei zwei gleichnamigen Firmen, und die staerkere verloere.
175
+ */
176
+ export function firmenAbgleich(lokal, geliefert) {
177
+ if (lokal.id !== null && geliefert.id !== null) {
178
+ return lokal.id === geliefert.id ? { art: "passt" } : { art: "andere-firma" };
179
+ }
180
+ if (lokal.name !== null && geliefert.name !== null) {
181
+ if (lokal.name === geliefert.name)
182
+ return { art: "passt" };
183
+ /*
184
+ 🔴 **EIN VERSCHIEDENER NAME IST EIN VERDACHT, KEIN BEWEIS** (Befund 1
185
+ der vierten Review-Runde, 2026-08-27 -- und er trifft die Reparatur der
186
+ dritten).
187
+
188
+ Die `CLAUDE.md` ist ausdruecklich eine Datei, die der Kunde AENDERN
189
+ darf: `kontext runter` sagt ihm woertlich „Ich fasse nichts an -- du
190
+ hast lokal geaendert". Schreibt eine Admin in die erste Zeile den vollen
191
+ Firmennamen („Nordwind Handel GmbH"), waehrend die App noch „Nordwind"
192
+ fuehrt, und legt den Ordner ins Firmen-Repo, dann liest ihr Kollege beim
193
+ Klonen einen Namen, der nicht zum gelieferten passt.
194
+
195
+ Mit `andere-firma` waere er damit HART GESPERRT worden -- mit einem Satz,
196
+ der falsch ist („eine andere Firma") und keinen gangbaren Ausweg nennt.
197
+ *Und gesperrt worden waere genau der Mensch, fuer den `werkbank neu`
198
+ ueberhaupt gebaut ist.*
199
+
200
+ 🔑 Deshalb `zweifel`: Der Name ist das SCHWAECHERE Erkennungszeichen und
201
+ bekommt die schwaechere Folge. Sperren darf nur, was sicher ist -- die
202
+ ID. *Wer auf einen Verdacht hin zusperrt, schadet oefter dem Unschuldigen
203
+ als dem Fall, den er treffen wollte.*
204
+ */
205
+ return { art: "zweifel", hier: lokal.name, gewaehlt: geliefert.name };
206
+ }
207
+ return { art: "unbekannt" };
208
+ }
209
+ /**
210
+ * Kurz-Pruefsumme des Inhalts. Zwoelf Hexzeichen aus SHA-256.
211
+ *
212
+ * 🔑 Warum gekuerzt: Der Merkzettel wird von Menschen aufgemacht, wenn etwas
213
+ * schiefgeht. Sechzig Zeichen pro Zeile machen ihn unlesbar, und 48 Bit reichen
214
+ * fuer eine Handvoll Dateien um Groessenordnungen.
215
+ *
216
+ * ⚠️ Der Text wird NICHT normalisiert -- kein `trim`, keine Zeilenenden-
217
+ * Angleichung. Ein Leerzeichen am Zeilenende IST eine Aenderung; wer sie
218
+ * wegrechnet, ueberschreibt eines Tages die Arbeit eines Menschen und meldet
219
+ * dabei "unveraendert".
220
+ */
221
+ export function pruefsumme(inhalt) {
222
+ return createHash("sha256").update(inhalt, "utf8").digest("hex").slice(0, 12);
223
+ }
224
+ /**
225
+ * Ist `wert` sicher genug fuer einen HTTP-Kopf -- druckbares ASCII, keine
226
+ * Steuerzeichen? (Task 10, Fix-Runde 1, Fund 1.)
227
+ *
228
+ * 🔴 DER MERKZETTEL IST EINE DATEI, DIE EIN MENSCH VON HAND OEFFNEN UND
229
+ * BEARBEITEN KANN -- das ist ausdruecklich ihr Zweck (s. Kopf-Kommentar oben:
230
+ * "wird von Menschen aufgemacht, wenn etwas schiefgeht"). `leseMerkzettel`
231
+ * prueft `firma_id` und jeden `stand` nur auf `typeof === "string"`, NIE auf
232
+ * den Inhalt. Ein einziges Nicht-ASCII-Zeichen darin -- von Hand eingetippt,
233
+ * aus einem anderen Editor hereinkopiert -- reicht, damit `headers.set(...)`
234
+ * in `ruf()` (`api.ts`) WIRFT ("Cannot convert argument to a ByteString"),
235
+ * und zwar fuer JEDEN folgenden CLI-Befehl, nicht nur fuer `kontext`-Befehle.
236
+ * Der Kunde saehe "kimal3 ist gerade nicht erreichbar" und suchte den Fehler
237
+ * im Netz, waehrend er auf seiner eigenen Platte sitzt.
238
+ *
239
+ * ⚠️ ECHTES DRUCKBARES ASCII (0x20-0x7E), NICHT NUR "unter 256": Der
240
+ * ByteString-Fehler selbst kippt erst jenseits von Codepunkt 255 -- aber
241
+ * dieselbe Grenze wuerde "ä" (0xE4 = 228) noch durchlassen, und CLAUDE.md
242
+ * zieht sie bewusst enger ("ein Kopf traegt kein ... ä"). Druckbares ASCII
243
+ * ist ausserdem die einzige Menge, die auch Steuerzeichen (Zeilenumbrueche,
244
+ * Tabs) ausschliesst -- die in einem HTTP-Kopf ohnehin nichts verloren haben.
245
+ */
246
+ export function istKopfwertSicher(wert) {
247
+ return /^[\x20-\x7E]*$/.test(wert);
248
+ }
249
+ /**
250
+ * Den Merkzettel aus dem rohen Dateiinhalt lesen. Wirft nie.
251
+ *
252
+ * 🔴 Ein Eintrag ohne BEIDE Felder faellt raus. Ohne diese Bedingung truege
253
+ * `eintrag.pruef` spaeter `undefined`, und `undefined !== summe` ist wahr --
254
+ * also hielte die Rechnung jede Datei fuer lokal geaendert und legte bei jedem
255
+ * Lauf eine neue Sicherung an.
256
+ *
257
+ * 🔴 **ZWEI FORMATE, ABSICHTLICH (Task 10a).** Vor dieser Aufgabe war ein
258
+ * Merkzettel eine FLACHE Zuordnung Pfad -> Eintrag, ohne eigenes Feld fuer die
259
+ * Firma -- und genau solche Dateien liegen schon auf Platten von heute
260
+ * Nachmittag, geschrieben vom Ausliefer-Weg, bevor es diese Aufgabe gab. Das
261
+ * neue Format erkennt sich an einem eigenen `dateien`-Feld (Objekt, keine
262
+ * Liste); fehlt es, wird das GANZE Objekt wie bisher als flache
263
+ * Pfad-Zuordnung gelesen und `firma_id` bleibt `null`. *Ein Werkzeug, das an
264
+ * seiner eigenen aelteren Datei abstuerzt, ist schlimmer als eines, das sie
265
+ * ignoriert.*
266
+ *
267
+ * 🔴 **TASK 2, US-B4: `format` UND `vorlage` WERDEN NUR UEBERNOMMEN, NIE
268
+ * ERFUNDEN.** Beide Felder sind auf ihrem Typ optional (s. dort) -- ein
269
+ * Merkzettel von VOR dieser Aufgabe hat sie nicht, und das ist die "alte
270
+ * Fassung" aus dem Acceptance-Kriterium, kein Fehler. Steht `format` nicht
271
+ * oder nicht als Zahl im Objekt, bleibt es im Ergebnis schlicht weg; dasselbe
272
+ * gilt je Eintrag fuer `vorlage`. Ein Wert, der nicht im Text stand, wird hier
273
+ * nicht geraten -- das waere genau die Sorte stiller Falschaussage, die
274
+ * anderswo in dieser Datei schon einmal Schaden angerichtet hat (s.
275
+ * `bekannterStandAus`).
276
+ */
277
+ export function leseMerkzettel(roh) {
278
+ if (roh === null)
279
+ return LEERER_MERKZETTEL;
280
+ try {
281
+ const daten = JSON.parse(roh);
282
+ if (!daten || typeof daten !== "object" || Array.isArray(daten))
283
+ return LEERER_MERKZETTEL;
284
+ const objekt = daten;
285
+ const hatNeueForm = "dateien" in objekt &&
286
+ typeof objekt.dateien === "object" &&
287
+ objekt.dateien !== null &&
288
+ !Array.isArray(objekt.dateien);
289
+ const dateienRoh = (hatNeueForm ? objekt.dateien : objekt);
290
+ const firmenIdRoh = hatNeueForm ? objekt.firma_id : undefined;
291
+ const formatRoh = hatNeueForm ? objekt.format : undefined;
292
+ const dateien = {};
293
+ for (const [pfad, wert] of Object.entries(dateienRoh)) {
294
+ if (!wert || typeof wert !== "object")
295
+ continue;
296
+ const e = wert;
297
+ if (typeof e.stand !== "string" || typeof e.pruef !== "string")
298
+ continue;
299
+ const eintrag = { stand: e.stand, pruef: e.pruef };
300
+ if (typeof e.vorlage === "string")
301
+ eintrag.vorlage = e.vorlage;
302
+ dateien[pfad] = eintrag;
303
+ }
304
+ // 🔑 `format` steht per Spread VOR `firma_id`, wenn es da ist -- dieselbe
305
+ // Feldreihenfolge wie bei `alsMerkzettel` und `baueMerkzettel`. Reine
306
+ // Kosmetik fuer Menschen, die die Datei aufmachen -- aber genau deshalb
307
+ // WICHTIG fuer `check-kontext.mts`: Die dortige Pruefung vergleicht per
308
+ // `JSON.stringify`, und das ist reihenfolge-empfindlich.
309
+ return {
310
+ ...(typeof formatRoh === "number" ? { format: formatRoh } : {}),
311
+ firma_id: typeof firmenIdRoh === "string" ? firmenIdRoh : null,
312
+ dateien,
313
+ };
314
+ }
315
+ catch {
316
+ return LEERER_MERKZETTEL;
317
+ }
318
+ }
319
+ /** Der Merkzettel als Dateiinhalt -- eingerueckt, weil ihn Menschen aufmachen. */
320
+ export function schreibeMerkzettel(m) {
321
+ return `${JSON.stringify(m, null, 2)}\n`;
322
+ }
323
+ /**
324
+ * Prueft, ob `wert` (aus einer bereits geparsten JSON-ANTWORT der App) ein
325
+ * gueltiger Merkzettel ist -- die Form-Pruefung, die `kontext-runter.ts` und
326
+ * `kontext-hoch.ts` sonst je einmal von Hand nachbauen wuerden (Task 10a).
327
+ *
328
+ * ⚠️ ANDERS STRENG ALS `leseMerkzettel`: Die App liefert ab dieser Aufgabe
329
+ * IMMER das neue Format (`dateien` + `firma_id`) -- Stufe 2 ist nicht
330
+ * veroeffentlicht, es gibt also keine Antwort einer AELTEREN Version, gegen
331
+ * die sich diese Funktion abfedern muesste. Nur das neue Format wird
332
+ * akzeptiert; alles andere ist `null` ("nicht lesbar"), nicht "leer geraten".
333
+ *
334
+ * 🔴 TASK 2: Aus demselben Grund werden jetzt AUCH `format` und je Eintrag
335
+ * `vorlage` verlangt. `baueMerkzettel` (unten) setzt beide seit dieser
336
+ * Aufgabe IMMER -- jede Antwort, die durch diese Rechnung entstanden ist,
337
+ * traegt sie also. Fehlt eines trotzdem, ist das keine aeltere Version,
338
+ * sondern eine kaputte Antwort -- `null`, nicht geraten.
339
+ */
340
+ export function alsMerkzettel(wert) {
341
+ if (!wert || typeof wert !== "object" || Array.isArray(wert))
342
+ return null;
343
+ const objekt = wert;
344
+ if (typeof objekt.format !== "number")
345
+ return null;
346
+ const dateienRoh = objekt.dateien;
347
+ if (!dateienRoh || typeof dateienRoh !== "object" || Array.isArray(dateienRoh))
348
+ return null;
349
+ const firmenIdRoh = objekt.firma_id;
350
+ if (firmenIdRoh !== null && typeof firmenIdRoh !== "string")
351
+ return null;
352
+ const dateien = {};
353
+ for (const [pfad, eintragRoh] of Object.entries(dateienRoh)) {
354
+ if (!eintragRoh || typeof eintragRoh !== "object")
355
+ continue;
356
+ const e = eintragRoh;
357
+ if (typeof e.stand !== "string" || typeof e.pruef !== "string" || typeof e.vorlage !== "string")
358
+ continue;
359
+ dateien[pfad] = { stand: e.stand, pruef: e.pruef, vorlage: e.vorlage };
360
+ }
361
+ return { format: objekt.format, firma_id: firmenIdRoh, dateien };
362
+ }
363
+ /**
364
+ * Der lokal bekannte Firmenstand aus einem Merkzettel als EINE Zeichenkette --
365
+ * **Fix-Runde 1, Review-Fund 1 (Important)**, hierher gezogen fuer Task 10.
366
+ *
367
+ * 🔴 **URSPRUENGLICHER FEHLER (bei `kontext hoch`):** Eine Rechnung, die nur
368
+ * die VIER `kontext/*.md`-Eintraege des Merkzettels ansah, uebersah, dass
369
+ * `CLAUDE.md` den Stand des FUeNFTEN Feldes traegt, `drei_saetze` (s.
370
+ * `setzeKontextEin` in `ausliefern.ts`). Eine Firma, die bisher NUR die drei
371
+ * Saetze ueber das Formular auf `/firma` gepflegt hat -- ein ganz normaler
372
+ * erster Schritt --, hatte deshalb alle vier `kontext/*.md`-Eintraege auf
373
+ * `LEER`, waehrend der Server einen echten Stand fuehrte.
374
+ *
375
+ * 🔑 **WARUM „DER EINE NICHT-LEERE STAND GEWINNT" RICHTIG IST:** Die
376
+ * Firmenzeile traegt GENAU EIN `geaendert_am` fuer alle fuenf Felder. Jede
377
+ * Lieferung stempelt dieses EINE `zeitpunkt` auf jedes gepflegte Feld und
378
+ * `LEER` auf jedes ungepflegte -- es gibt also nie zwei verschiedene,
379
+ * gleichzeitig gueltige NICHT-leere Staende in einer einzigen Lieferung.
380
+ *
381
+ * ⚠️ **TROTZDEM, DIE GEGENPROBE:** Der Merkzettel ist eine Datei auf der
382
+ * Platte des Kunden, keine Datenbank mit Integritaetsregel. Findet diese
383
+ * Funktion mehr als einen unterschiedlichen nicht-leeren Stand, liefert sie
384
+ * `LEER` -- derselbe Wert wie "noch nie heruntergeladen". Sie RAET nicht,
385
+ * welcher stimmt.
386
+ *
387
+ * 🔁 **Task 10:** Wohnte bis hierher in `befehle/kontext-hoch.ts` und wird
388
+ * jetzt auch von `api.ts` gebraucht (fuer den Kopf `X-Kimal3-Kontext-Stand`).
389
+ * `kontext-hoch.ts` importiert `ruf` aus `./api.js` -- ein Umzug hierher, in
390
+ * die Datei ohne jeden Import ausser `node:crypto`, ist der einzige Weg, der
391
+ * `api.ts` nicht in einen Kreisimport mit sich selbst zwingt.
392
+ */
393
+ export function bekannterStandAus(merkzettel) {
394
+ const staende = new Set();
395
+ for (const pfad of VERWALTETE_DATEIEN) {
396
+ const stand = merkzettel.dateien[pfad]?.stand;
397
+ if (stand !== undefined && stand !== LEER)
398
+ staende.add(stand);
399
+ }
400
+ if (staende.size === 1) {
401
+ const [einziger] = staende;
402
+ return einziger;
403
+ }
404
+ return LEER;
405
+ }
406
+ /**
407
+ * Der Merkzettel zu einer Lieferung.
408
+ *
409
+ * 🔴 ER ENTSTEHT MIT DEM INHALT, NICHT DANACH. Wer ihn spaeter schreibt, baut
410
+ * denselben Fehler wie Runde 5 des Update-Kanals: Der Stempel stand am Ende und
411
+ * behauptete taeglich, der Skill sei veraltet.
412
+ *
413
+ * ⚠️ `staende` nennt je Datei den Stand der FIRMA (`geaendert_am`) oder `leer`.
414
+ * Fehlt eine verwaltete Datei in der Lieferung, entsteht kein Eintrag -- ein
415
+ * Eintrag mit `undefined` waere schlimmer als keiner.
416
+ *
417
+ * 🔴 `firmenId` ist ABSICHTLICH PFLICHT, nicht optional (Task 10a). Ohne die
418
+ * ID muesste der Server spaeter raten, welche Firma gemeint ist -- wer zu
419
+ * ZWEI Firmen gehoert, staende im Ordner von A und laese "B hat etwas
420
+ * geaendert". `null` ist ein gueltiger, ausdruecklicher Wert ("keine
421
+ * eindeutige Firma bekannt", z. B. beim Vordruck ohne Kontext) -- ein
422
+ * vergessenes Argument ist das nicht: Wer diese Funktion aufruft, muss sich
423
+ * entscheiden, nicht stillschweigend `null` bekommen.
424
+ *
425
+ * 🔴 TASK 2: `vorlagenPruefsummen` traegt je Datei den Fingerabdruck UNSERER
426
+ * VORLAGE -- nicht den des ausgelieferten Inhalts (das ist `pruef`). Fehlt
427
+ * eine Pruefsumme fuer eine Datei, traegt ihr `vorlage`-Feld `LEER` --
428
+ * derselbe Sicherheitswert wie beim ungepflegten `stand` zwei Zeilen weiter
429
+ * unten, aus demselben Grund: *wer nichts weiss, behauptet nichts.*
430
+ *
431
+ * 🔴 **PUNKT A-1 (2026-08-26): DIESES ARGUMENT IST JETZT PFLICHT.** Es war
432
+ * optional, mit Default `{}` -- und genau deshalb hat es ein halbes Jahr lang
433
+ * niemand uebergeben. `ausliefern.ts` rief die Funktion mit drei Werten auf,
434
+ * jeder ausgelieferte Merkzettel trug bei allen sechs Dateien
435
+ * `"vorlage": "leer"`, und die Lage `vorlage-neuer` unten konnte in der
436
+ * Wirklichkeit nie entstehen: *keine fehlende Zeile, kein Compiler-Fehler,
437
+ * kein roter Waechter.* Ein Pflicht-Argument ist die einzige Absicherung, die
438
+ * auch den erwischt, der diesen Kommentar nie liest.
439
+ *
440
+ * 🔑 `format` wird HIER, beim NEU-Bauen einer Lieferung, immer gesetzt --
441
+ * anders als beim blossen LESEN einer womoeglich alten Datei
442
+ * (`leseMerkzettel` oben). Jede Lieferung, die durch diese Funktion entsteht,
443
+ * ist per Definition die aktuelle Fassung.
444
+ */
445
+ export function baueMerkzettel(dateien, staende, firmenId, vorlagenPruefsummen) {
446
+ const raus = {};
447
+ for (const pfad of VERWALTETE_DATEIEN) {
448
+ const datei = dateien.find((d) => d.pfad === pfad);
449
+ if (!datei)
450
+ continue;
451
+ raus[pfad] = {
452
+ stand: staende[pfad] ?? LEER,
453
+ pruef: pruefsumme(datei.inhalt),
454
+ vorlage: vorlagenPruefsummen[pfad] ?? LEER,
455
+ };
456
+ }
457
+ return { format: FORMAT_AKTUELL, firma_id: firmenId, dateien: raus };
458
+ }
459
+ /**
460
+ * Der Merkzettel, den `kimal3 kontext hoch` nach einem gelungenen Lauf auf die
461
+ * Platte schreibt.
462
+ *
463
+ * 🔴 **DER FUND, DEN DIE KUNDENSTRECKE AM 2026-08-25 GEFUNDEN HAT.** Vorher
464
+ * wurde die Antwort des Servers eins zu eins hingeschrieben. Der Merkzettel ist
465
+ * aber eine Aussage darueber, WAS AUF DER PLATTE LIEGT -- und `kontext hoch`
466
+ * schreibt keine einzige Datei. Fuer die fuenf gesendeten Texte geht das
467
+ * trotzdem auf: Was der Server zurueckgibt, entsteht aus dem, was die CLI ihm
468
+ * gerade geschickt hat, und dasselbe steht auf der Platte.
469
+ *
470
+ * ⚠️ **Fuer die Firmen-`CLAUDE.md` geht es NICHT auf, seit Ruling K-6.** Sie
471
+ * wird nicht mehr gesendet, aber der Server BAUT sie neu (er setzt die frischen
472
+ * drei Saetze ein). Die Pruefsumme in seiner Antwort gehoert damit zu einem
473
+ * Text, den niemand auf die Platte geschrieben hat.
474
+ *
475
+ * 🔴 **Was das anrichtete, gemessen:** Direkt nach einem erfolgreichen
476
+ * `kontext hoch` meldete `kimal3 kontext runter` „Ich fasse nichts an -- du
477
+ * hast lokal geaendert: CLAUDE.md" und riet: *„Deine Fassung ist die neuere?
478
+ * Dann schick sie hoch: kimal3 kontext hoch"* -- ein Rat auf einen Befehl, der
479
+ * die `CLAUDE.md` gar nicht mehr mitnimmt. Der Admin haette das beliebig oft
480
+ * wiederholen koennen. *Das ist woertlich die Form von Fund F: ein Befehl
481
+ * schickt den Menschen zu einem zweiten, der seine Lage nicht aendern kann,
482
+ * und beide melden Erfolg.*
483
+ *
484
+ * 🔑 **Die Regel: Ueber eine Datei, die wir nicht gesendet haben, uebernehmen
485
+ * wir nichts Neues.** Ihr Eintrag bleibt der lokale von vorher. Der naechste
486
+ * `kontext runter` sieht dann sauber „Server neuer" -- der Stand weicht ab, die
487
+ * Pruefsumme passt zur Platte -- und holt die Datei ohne Sicherung. Genau das
488
+ * soll passieren: Die Firma hat ihre drei Saetze geaendert, also gehoert die
489
+ * abgeleitete Kopie in der `CLAUDE.md` aufgefrischt.
490
+ *
491
+ * ⚠️ Gibt es lokal gar keinen Eintrag, entsteht auch keiner. `null` heisst
492
+ * „wir wissen nichts" und fuehrt beim naechsten Holen zur Sicherung -- der
493
+ * sichere Weg, nicht der bequeme.
494
+ *
495
+ * 🔑 `format` und `firma_id` kommen weiterhin vom SERVER: Beide sagen nichts
496
+ * ueber den Inhalt einer Datei, und bei der Firma ist der Server die Wahrheit.
497
+ */
498
+ export function merkzettelNachHoch(vomServer, lokal, gesendet) {
499
+ const dateien = {};
500
+ for (const pfad of VERWALTETE_DATEIEN) {
501
+ const eintrag = gesendet.includes(pfad) ? vomServer.dateien[pfad] : lokal.dateien[pfad];
502
+ if (eintrag)
503
+ dateien[pfad] = eintrag;
504
+ }
505
+ return { ...vomServer, dateien };
506
+ }
507
+ /**
508
+ * Die Entscheidungstabelle aus Spec §7b -- fuer EINE Datei.
509
+ *
510
+ * 🔑 DIE ABKUERZUNG STEHT ZUERST, und sie nimmt den haeufigsten Aerger weg: Ist
511
+ * der lokale Text identisch mit dem, was der Server liefert, lautet die Antwort
512
+ * "schon aktuell" -- egal was der Merkzettel sagt oder ob es ihn gibt. Wer
513
+ * frisch klont, hat die Dateien aus dem Repo, aber keinen Merkzettel; ohne diese
514
+ * Zeile bekaeme er eine sinnlose Sicherung seiner eigenen, unveraenderten Dateien.
515
+ *
516
+ * 🔴 "SERVER NEUER" HEISST HIER **ANDERS**, NICHT **SPAETER** (Bauplan D-2). Ein
517
+ * echter Zeitvergleich haette zwei Uhren und einen Sonderwert (`leer`) zu
518
+ * bewaeltigen. Weil laut E-4 ohnehin die Firma gewinnt, reicht die Ungleichheit
519
+ * -- und ein Zurueckrollen in der App erreicht den Kunden damit ebenfalls.
520
+ *
521
+ * 🔑 KEIN MERKZETTEL = BEIDES UNBEKANNT = der sichere Fall. Eine Datei ohne
522
+ * Eintrag kann von Hand angelegt, aus einer alten Fassung kopiert oder muehsam
523
+ * ausgefuellt worden sein. *Wer nichts weiss, loescht nichts* -- also wird
524
+ * gesichert, bevor ueberschrieben wird.
525
+ *
526
+ * 🔴 **TASK 2 (Spec US-B1/US-B4): DIE FUeNFTE LAGE, `vorlage-neuer`.** Das ist
527
+ * die Reparatur des schwersten Befundes des ganzen Umbaus: Bis hierher
528
+ * verglich diese Funktion nur, OB sich der ausgelieferte Text geaendert hat --
529
+ * nie, WESHALB. Verbessern wir unseren eigenen Vordruck, aendert sich der
530
+ * ausgelieferte Text bei jeder Firma, die ihn nicht selbst gepflegt hat, genau
531
+ * so wie bei einer echten Firmen-Aenderung -- und `serverStand` (der Stand DER
532
+ * FIRMA) bleibt dabei unveraendert `leer`. Ohne die dritte Dimension gab es
533
+ * daher zwei falsche Wege: Entweder mass die Funktion den Inhalt direkt (dann
534
+ * riss die Abkuerzung ganz oben nie, und ein reiner Vordruck-Wechsel landete
535
+ * unter "schon-aktuell" -- die Firma saehe die Verbesserung NIE) oder sie
536
+ * ignorierte den Vordruck-Stand komplett (dann waere jede Vordruck-Aenderung
537
+ * unsichtbar). `aktuelleVorlage` traegt deshalb die Pruefsumme des Vordrucks
538
+ * HEUTE herein; verglichen wird sie gegen `eintrag.vorlage` -- die Pruefsumme
539
+ * des Vordrucks, aus dem die Datei beim letzten Holen stammte.
540
+ *
541
+ * 🔑 DIE PRIORITAET, WENN MEHRERES ZUTRIFFT: Hat die FIRMA inzwischen etwas
542
+ * gesagt (`serverNeuer`), gewinnt das IMMER -- ob sich der Vordruck ebenfalls
543
+ * geaendert hat, spielt dann keine Rolle mehr; ihr Text ersetzt ohnehin den
544
+ * Vordruck. Hat dagegen der KUNDE lokal geaendert (`lokalGeaendert`), zaehlt
545
+ * eine geaenderte Vorlage genauso als Grund zu sichern wie eine geaenderte
546
+ * Firmen-Zeile -- in beiden Faellen wuerde ein sang- und klangloses
547
+ * Ueberschreiben echte Arbeit verlieren, also wird "beides".
548
+ *
549
+ * 🔴 **PUNKT A-1 (2026-08-26): `aktuelleVorlage` IST JETZT PFLICHT.** Es war
550
+ * optional -- mit der Begruendung, das Verdrahten gehoere zu einer spaeteren
551
+ * Aufgabe. Zurueckgekommen ist danach nie jemand: `kontext-runter.ts` rief
552
+ * diese Funktion ohne den Wert auf, `vorlage-neuer` konnte in der
553
+ * Wirklichkeit nicht entstehen, und der Kunde las weiter „Dein Firmenkontext
554
+ * ist aktuell". *Die Rechnung war gebaut, geprueft, dokumentiert -- und tot.*
555
+ * Wer den Wert jetzt weglaesst, bekommt einen Kompilierfehler.
556
+ *
557
+ * ⚠️ **Ein Merkzettel-Eintrag OHNE `vorlage` gilt als „unbekannt", und
558
+ * unbekannt zaehlt als GEAeNDERT.** Solche Eintraege liegen echt auf Platten:
559
+ * Sie stammen von einer Auslieferung, bevor es dieses Feld gab. Der Vergleich
560
+ * `undefined !== aktuelleVorlage` ist wahr, es entsteht also einmalig
561
+ * `vorlage-neuer` bzw. `beides` -- dieselbe Antwort wie bei einem fehlenden
562
+ * Eintrag, aus demselben Grund: *wer nichts weiss, sichert, bevor er
563
+ * ueberschreibt.* Die Gegenrichtung waere schlimmer: Wuerde „unbekannt" als
564
+ * „unveraendert" gelesen, bekaeme der Kunde die Verbesserung nicht -- und der
565
+ * naechste Lauf haette obendrein einen Merkzettel, der den Server-Text
566
+ * beschreibt, waehrend die alte Datei danebenliegt, und meldete faelschlich
567
+ * „du hast lokal geaendert".
568
+ */
569
+ export function lageFuerDatei(a) {
570
+ if (a.lokalerInhalt === a.serverInhalt)
571
+ return "schon-aktuell";
572
+ // Es gibt nichts zu verlieren -- also holen, ohne zu sichern.
573
+ if (a.lokalerInhalt === null)
574
+ return "server-neuer";
575
+ const lokalGeaendert = a.eintrag === null ? true : pruefsumme(a.lokalerInhalt) !== a.eintrag.pruef;
576
+ const serverNeuer = a.eintrag === null ? true : a.eintrag.stand !== a.serverStand;
577
+ const vorlageNeuer = a.eintrag === null ? true : a.eintrag.vorlage !== a.aktuelleVorlage;
578
+ /*
579
+ 🔴 **`vorlageNeuer` zaehlt hier BEWUSST nicht mit** (Review-Befund 1 zu
580
+ Punkt A-1, 2026-08-26 -- gemessen, nicht vermutet).
581
+
582
+ Hier stand einen Nachmittag lang `serverNeuer || vorlageNeuer`. Die Folge:
583
+ Wer seine Datei lokal geaendert hatte, um sie spaeter mit `kimal3 kontext
584
+ hoch` zu schicken, verlor beim naechsten `runter` seinen Arbeitsstand,
585
+ sobald WIR irgendeine Frage umformuliert hatten -- gesichert zwar, aber
586
+ aus der Datei verschwunden, und ohne dass er danach gefragt haette.
587
+ *Eine Verbesserung an unserem Text darf nicht die Arbeit von jemandem
588
+ wegraeumen, der gerade an seiner sitzt.*
589
+
590
+ 🔑 Der richtige Weg ist der, den `lokal-geaendert` ohnehin nennt: erst
591
+ hochschicken, dann herunterladen. Dann liegen seine Antworten auf dem
592
+ Server, der Stand aendert sich, und der naechste `runter` bringt seine
593
+ Antworten UND unsere neuen Fragen -- zusammengefuehrt statt eines gegen
594
+ das andere. Wer nie hochschickt, behaelt seine Datei; sie gehoert ihm.
595
+
596
+ ⚠️ `vorlage-neuer` unten bleibt davon unberuehrt. Genau dafuer wurde A-1
597
+ gebaut, und es ist der haeufige Fall: eine Datei, die unangetastet so
598
+ dasteht, wie wir sie geliefert haben.
599
+ */
600
+ if (lokalGeaendert)
601
+ return serverNeuer ? "beides" : "lokal-geaendert";
602
+ if (serverNeuer)
603
+ return "server-neuer";
604
+ if (vorlageNeuer)
605
+ return "vorlage-neuer";
606
+ return "schon-aktuell";
607
+ }
608
+ /**
609
+ * Dieselbe Entscheidung wie {@link lageFuerDatei} -- aber gefuettert aus den
610
+ * ZWEI Merkzetteln, zwischen denen `kimal3 kontext runter` steht: dem auf der
611
+ * Platte und dem aus der Antwort des Servers.
612
+ *
613
+ * 🔴 **WARUM ES DIESE FUNKTION GIBT (Punkt A-1, 2026-08-26).** Der eigentliche
614
+ * Fehler war nicht die Rechnung -- die stimmte. Er war, WOHER ihre Eingaben
615
+ * kommen: `kontext-runter.ts` gab ihr den Vordruck-Abdruck gar nicht erst mit.
616
+ * Genau dieser Schritt ("aus welchem der beiden Merkzettel kommt welcher
617
+ * Wert?") liess sich von keiner Wache sehen, weil er mitten im Befehl wohnte
618
+ * -- zwischen `fetch` und Dateisystem, wo `check:kontext` nie hinkommt.
619
+ *
620
+ * 🔴 **DIE ZUORDNUNG, DIE HIER ABGESICHERT WIRD -- sie ist nicht offensichtlich
621
+ * und in beide Richtungen falsch machbar:**
622
+ * - `eintrag` kommt aus dem LOKALEN Merkzettel. Er sagt, was beim letzten
623
+ * Holen galt: die eine Seite des Vergleichs.
624
+ * - `serverStand` UND `aktuelleVorlage` kommen aus dem Merkzettel DES SERVERS.
625
+ * Sie sagen, was heute gilt: die andere Seite.
626
+ *
627
+ * ⚠️ **Nimmt man beide Seiten aus derselben Datei, vergleicht die Rechnung
628
+ * eine Zahl mit sich selbst und findet NIE einen Unterschied** -- kein Fehler,
629
+ * keine Meldung, der Kunde liest „Dein Firmenkontext ist aktuell". Gemessen:
630
+ * Mit `aktuelleVorlage` aus dem lokalen Merkzettel blieb die verbesserte
631
+ * Datei liegen, und der naechste Lauf behauptete obendrein „du hast lokal
632
+ * geaendert". *Das ist derselbe Schaden wie vorher, nur eine Zeile weiter.*
633
+ *
634
+ * 🔑 Fehlt die Datei im Merkzettel des Servers, gilt fuer beide Werte `LEER`
635
+ * -- „wir wissen nichts", derselbe Rueckfall wie ueberall in dieser Datei.
636
+ */
637
+ export function lageAusMerkzetteln(a) {
638
+ const vomServer = a.vomServer.dateien[a.pfad] ?? null;
639
+ return lageFuerDatei({
640
+ lokalerInhalt: a.lokalerInhalt,
641
+ serverInhalt: a.serverInhalt,
642
+ eintrag: a.lokal.dateien[a.pfad] ?? null,
643
+ serverStand: vomServer?.stand ?? LEER,
644
+ aktuelleVorlage: vomServer?.vorlage ?? LEER,
645
+ });
646
+ }
647
+ /** `kontext/vorher-2026-08-21` */
648
+ export function sicherungsOrdner(datum) {
649
+ return `${SICHERUNG_BASIS}/vorher-${datum}`;
650
+ }
651
+ /**
652
+ * Wohin die lokale Fassung gesichert wird.
653
+ *
654
+ * ⚠️ FLACH, mit dem Basisnamen -- `CLAUDE.md` und `kontext/systeme.md` landen
655
+ * nebeneinander. Dass sich dabei nichts ueberschreiben kann, sichert der
656
+ * Waechter: Er prueft, dass alle verwalteten Dateien verschiedene
657
+ * Basisnamen haben. Eine kuenftige sechste faellt damit auf, bevor sie eine
658
+ * Sicherung ueberschreibt.
659
+ */
660
+ export function sicherungsZiel(datum, pfad) {
661
+ const name = pfad.split("/").pop() ?? pfad;
662
+ return `${sicherungsOrdner(datum)}/${name}`;
663
+ }
664
+ /**
665
+ * Von welchem Ordner aus `kimal3 kontext runter` ueberhaupt wissen kann,
666
+ * welche Firma gemeint ist -- **ohne** das Dateisystem selbst anzufassen.
667
+ *
668
+ * 🔑 WARUM DIESE FUNKTION HIER UND NICHT ERST IN EINER SPAETEREN AUFGABE
669
+ * WOHNT: Diese Datei importiert bewusst nur `node:crypto` (s. Kopf). Die
670
+ * Fundregel selbst braucht kein Dateisystem -- nur eine Liste von Kandidaten,
671
+ * die der Aufrufer (spaeter) mit `readdirSync`/`existsSync` gefuellt hat.
672
+ * Die eigentliche Suche gehoert deshalb woanders hin (zieht Umgebung und
673
+ * Heimverzeichnis hoch); ein Waechter fuer eine reine Pfadregel soll das
674
+ * nicht mit auslösen.
675
+ *
676
+ * Fundregel: Vom aktuellen Verzeichnis aufwaerts suchen, bis ein Ordner
677
+ * gefunden ist, der `kontext/` UND `CLAUDE.md` enthaelt.
678
+ *
679
+ * 🔴 DER ERSTE TREFFER GEWINNT, NICHT DER OBERSTE. Die Kandidaten kommen von
680
+ * UNTEN NACH OBEN (aktuelles Verzeichnis zuerst) -- wer in
681
+ * `Kunden/ARNO/KI-Arbeitsplatz` steht, meint ARNO, nicht `Kunden/`. Wuerde
682
+ * stattdessen der oberste Treffer gewinnen, faende der Befehl bei jedem
683
+ * verschachtelten Arbeitsplatz den falschen.
684
+ *
685
+ * 🔑 BEIDES MUSS STIMMEN. Ein Ordner mit `kontext/`, aber ohne `CLAUDE.md`,
686
+ * ist kein Arbeitsplatz -- sonst traefe der Befehl irgendeinen Ordner, in dem
687
+ * jemand zufaellig etwas "kontext" genannt hat.
688
+ *
689
+ * Kein Treffer -> `null`.
690
+ */
691
+ export function arbeitsplatzAus(kandidaten) {
692
+ for (const k of kandidaten) {
693
+ if (k.hatKontext && k.hatClaude)
694
+ return k.pfad;
695
+ }
696
+ return null;
697
+ }
698
+ /*
699
+ 🪦 **Hier wohnten `DREI_SAETZE_UEBERSCHRIFT`, der Typ `DreiSaetzeSchnitt` und
700
+ `dreiSaetzeAus` -- ersatzlos gestrichen mit Ruling K-6 (Task 7).**
701
+
702
+ Was sie taten: Sie schnitten den Abschnitt „Die Firma in drei Saetzen" aus
703
+ dem Text der Firmen-`CLAUDE.md`, damit `kimal3 kontext hoch` ihn als
704
+ `drei_saetze` mitschicken konnte. Sie waren die Antwort auf Fund F -- der
705
+ Befehl liess dieses eine Feld aus und meldete trotzdem einen Haken.
706
+
707
+ 🔴 **Warum ihr Fall nicht mehr entstehen kann:** Seit Ruling K-18 hat „Die
708
+ Firma in drei Saetzen" eine EIGENE Datei, `kontext/kurzprofil.md`, und seit
709
+ Task 6 stehen in der `CLAUDE.md` nur noch die drei ANTWORTEN -- ohne Anker,
710
+ ohne Ueberschriften. Der Umweg schnitt also ab Task 6 einen Text heraus, den
711
+ die App nicht mehr als beantwortete Fragen lesen kann: EIN `kontext hoch`
712
+ haette drei ankerlose Zeilen in die Spalte geschrieben, `alsStand` haette sie
713
+ als ungepflegt gelesen, und das Formular haette wieder das leere
714
+ Fragengeruest gezeigt -- mit den Saetzen der Firma unsichtbar dahinter.
715
+
716
+ 🔑 **Was ihre Aufgabe uebernommen hat:** `kontext/kurzprofil.md` ist seither
717
+ eine Datei wie die vier anderen. Sie steht in {@link FIRMEN_TEXTE} und wird
718
+ gelesen, hochgeschickt und nachgezogen wie `marke.md` auch. *Fuenf
719
+ gleichrangige Dateien, kein Sonderfall, keine Extra-Meldung.*
720
+
721
+ ⚠️ Die Ueberschrift steht weiterhin in der Vorlage -- sie ist jetzt aber
722
+ blosser Fliesstext. Kein Code sucht sie mehr; `ausliefern.ts` setzt die drei
723
+ Antworten an der Stelle von `EINSETZ_MARKE_DREI_SAETZE` ein, und dass DIESE
724
+ Marke woertlich in der Vorlage steht, prueft `check:arbeitsplatz`.
725
+ */
726
+ /**
727
+ * Die ARTEN von Rat, die es gibt -- **in der Reihenfolge, in der sie im
728
+ * Terminal stehen.**
729
+ *
730
+ * 🔑 Diese Liste ist zugleich die Quelle des Typs {@link RatArt}. Wer eine
731
+ * dritte Art erfindet, muss sie hier eintragen -- und bekommt danach vom
732
+ * Compiler gesagt, dass {@link RAT_TEXT} noch keinen Text dafuer hat. *Eine
733
+ * Reihenfolge, die nebenher den Typ traegt, kann nicht unvollstaendig sein.*
734
+ *
735
+ * ⚠️ Die Reihenfolge ist nicht Geschmack: `kimal3 kontext runter` druckt seit
736
+ * Befund F-3 erst den Rat fuer die Firmen-Texte und darunter den fuer unsere
737
+ * Vorlage. Wer sie dreht, dreht die Ausgabe beim Kunden.
738
+ */
739
+ const RAT_REIHENFOLGE = ["hochschickbar", "nicht-hochschickbar"];
740
+ /**
741
+ * 🔴 **PUNKT A-7 (2026-08-26): DIESE TABELLE IST DIE ABSICHERUNG, NICHT NUR
742
+ * EINE AUFRAEUMAKTION.**
743
+ *
744
+ * Bis hierher zaehlte {@link ratNachUnberuehrt} ihre Faelle auf: ein `if` fuer
745
+ * „irgendetwas aus {@link FIRMEN_TEXTE}" und ein zweites fuer den woertlichen
746
+ * Dateinamen `"CLAUDE.md"`. {@link VERWALTETE_DATEIEN} hat aber SECHS
747
+ * Eintraege, und eine kuenftige SIEBTE waere durch beide `if`s gefallen --
748
+ * ohne Fehler, ohne Meldung, ohne Rat. *Der Mensch haette gelesen, dass seine
749
+ * Datei in Ruhe gelassen wurde, und nirgends, was er jetzt tun soll.*
750
+ *
751
+ * 🔑 **Warum ein `Record` und keine zweite Liste:** Der Schluesseltyp ist die
752
+ * Vereinigung aller Pfade aus {@link VERWALTETE_DATEIEN}. Eine siebte Datei
753
+ * dort weitet ihn -- und dieses Objekt hat dann einen Schluessel zu wenig,
754
+ * *also uebersetzt die CLI nicht mehr*. Das ist die einzige Absicherung, die
755
+ * auch den erwischt, der diesen Kommentar nie liest.
756
+ *
757
+ * ⚠️ **Warum die Zuordnung nicht IN {@link VERWALTETE_DATEIEN} steht**, wo sie
758
+ * inhaltlich hingehoerte: Diese Liste ist an sechs Stellen ausserhalb dieser
759
+ * Datei eine reine Pfadliste (`ausliefern.ts`, `route.ts`,
760
+ * `kontext-runter.ts`, `kontext-dateien.ts`, zwei Waechter). Aus ihr eine
761
+ * Liste von Objekten zu machen, haette all das angefasst -- fuer denselben
762
+ * Schutz, den der `Record` hier ohne einen einzigen fremden Eingriff gibt.
763
+ *
764
+ * ⚠️ **Sie sagt dasselbe wie {@link FIRMEN_TEXTE}, und das ist eine
765
+ * Doppelung** -- gehalten wird sie von einer Pruefung in `check:kontext`
766
+ * („der `hoch`-Rat geht an genau die Dateien, die `kontext hoch` wirklich
767
+ * traegt"). Die konnte vor diesem Umbau gar nicht rot werden, weil der Rat
768
+ * SELBST aus {@link FIRMEN_TEXTE} entstand; jetzt sind es zwei Tabellen, und
769
+ * ihr Auseinanderlaufen ist messbar.
770
+ */
771
+ const RAT_ART_JE_DATEI = {
772
+ "CLAUDE.md": "nicht-hochschickbar",
773
+ "kontext/kurzprofil.md": "hochschickbar",
774
+ "kontext/firma.md": "hochschickbar",
775
+ "kontext/marke.md": "hochschickbar",
776
+ "kontext/systeme.md": "hochschickbar",
777
+ "kontext/sicherheit.md": "hochschickbar",
778
+ };
779
+ /**
780
+ * Der Wortlaut je Art und Rolle.
781
+ *
782
+ * ⚠️ ECHTE UMLAUTE -- das liest ein Kunde. Die ASCII-Umschrift dieser Datei
783
+ * gilt nur fuer Kommentare.
784
+ *
785
+ * 🔑 `wer` ist die fertige Anrede (` an Lena` oder ` an euren Firmen-Admin`),
786
+ * damit der Rueckfall auf den Sammelbegriff an EINER Stelle steht und nicht in
787
+ * jedem Zweig neu. Ruling T7-1: nie `null` im Klartext.
788
+ */
789
+ const RAT_TEXT = {
790
+ hochschickbar: (rolle, wer) => rolle === "admin"
791
+ ? " Deine Fassung ist die neuere? Dann schick sie hoch: kimal3 kontext hoch"
792
+ : " Deine Fassung bleibt stehen — hochschicken darf nur der Firmen-Admin.\n" +
793
+ ` Gib deine Ergänzung${wer} weiter; von dort geht sie in der App unter\n` +
794
+ " Firma → Kontext oder mit `kimal3 kontext hoch` an alle.",
795
+ /*
796
+ 🔴 **Auch dieser Zweig kennt die ROLLE -- Punkt A-5, 2026-08-26.** Er tat
797
+ es bis dahin nicht und riet jedem dasselbe: „Aendere sie dort", also im
798
+ Formular unter Firma → Kontext. Ein MITGLIED kann das nicht. `kimal3
799
+ kontext hoch` weist es ab, und den Firmenkontext der Firma darf es in der
800
+ App nicht schreiben. *Ein Rat, der die Lage nicht aendern kann, ist keiner*
801
+ -- er schickt einen Menschen an eine Tuer, vor der er keinen Schluessel
802
+ hat, und laesst ihn den Fehler bei sich suchen.
803
+
804
+ 🔑 Was das Mitglied stattdessen liest: WAS es weitergeben soll (die drei
805
+ Saetze aus `kontext/kurzprofil.md`) und AN WEN -- genau die Form, die der
806
+ Zweig fuer die fuenf `kontext/`-Dateien oben schon hat.
807
+ */
808
+ "nicht-hochschickbar": (rolle, wer) => rolle === "admin"
809
+ ? " Die `CLAUDE.md` kann `kimal3 kontext hoch` nicht mitnehmen — der Befehl schickt nur\n" +
810
+ " die fünf Dateien aus `kontext/`. Die drei Sätze in dieser Datei kommen aus\n" +
811
+ " `kontext/kurzprofil.md`, der Firmenname aus der App: Ändere sie dort, dann holt\n" +
812
+ " der nächste `kimal3 kontext runter` sie hierher.\n" +
813
+ " Alles Übrige darin ist unsere Vorlage — dafür gibt es keinen Weg zurück."
814
+ : " Die `CLAUDE.md` kann `kimal3 kontext hoch` nicht mitnehmen — der Befehl schickt nur\n" +
815
+ " die fünf Dateien aus `kontext/`. Deine Änderung bleibt stehen.\n" +
816
+ " Die drei Sätze in dieser Datei kommen aus `kontext/kurzprofil.md`, und die ändert\n" +
817
+ ` nur der Firmen-Admin. Gib deinen Wunsch${wer} weiter; von dort geht\n` +
818
+ " `kontext/kurzprofil.md` in der App unter Firma → Kontext oder mit\n" +
819
+ " `kimal3 kontext hoch` an alle.\n" +
820
+ " Alles Übrige darin ist unsere Vorlage — dafür gibt es keinen Weg zurück.",
821
+ };
822
+ /**
823
+ * Was ein Mensch lesen soll, wenn `kimal3 kontext runter` Dateien in Ruhe
824
+ * laesst, weil er sie lokal geaendert hat -- je nach Rolle und je nach DATEI.
825
+ *
826
+ * 🔴 **DAS IST FUND F, ZUM ZWEITEN MAL -- und diesmal die echte Fassung
827
+ * (Review-Befund F-3, 2026-08-25).** Der Rat lautete fuer die ganze Liste auf
828
+ * einmal: „Deine Fassung ist die neuere? Dann schick sie hoch: kimal3 kontext
829
+ * hoch". Fuer `CLAUDE.md` ist dieser Satz eine Sackgasse: Sie steht in
830
+ * {@link VERWALTETE_DATEIEN} und landet deshalb in der Liste -- aber
831
+ * `kontext hoch` liest ausschliesslich die fuenf Dateien aus
832
+ * {@link FIRMEN_TEXTE} und meldet danach Erfolg, ohne sie. *Der Mensch tippt
833
+ * den geratenen Befehl, sieht einen Haken, und seine Aenderung steht weiter
834
+ * allein auf seiner Platte.*
835
+ *
836
+ * 🔑 Die ausgelieferte `CLAUDE.md` laedt diese Bearbeitung foermlich ein: Sie
837
+ * sagt selbst, aendern muesse man sie nur, wenn sich die Firma aendert.
838
+ *
839
+ * 🔴 **Der Rat wohnt hier und nicht im Befehl, damit ihn eine Wache sehen
840
+ * kann.** Die spurious Fassung desselben Fehlers (`merkzettelNachHoch`) war
841
+ * behoben, die echte lag vier Runden lang offen -- *ein Satz ueber das
842
+ * Verhalten eines Befehls wohnte im Befehl selbst, wo keine Vorlagen-Wache je
843
+ * hinsieht.* `check:kontext` prueft jetzt genau diese Zuordnung.
844
+ *
845
+ * 🔴 **PUNKT A-7 (2026-08-26): DER RAT WIRD ERZEUGT, NICHT AUFGEZAEHLT.** Diese
846
+ * Funktion hatte zwei `if`s -- eines fuer „irgendetwas aus
847
+ * {@link FIRMEN_TEXTE}", eines fuer den woertlichen Namen `"CLAUDE.md"`. Eine
848
+ * siebte verwaltete Datei waere durch beide gefallen und haette schweigend gar
849
+ * keinen Rat bekommen. Jetzt laeuft die Schleife ueber
850
+ * {@link VERWALTETE_DATEIEN} selbst und schlaegt die Art in
851
+ * {@link RAT_ART_JE_DATEI} nach -- eine Datei ohne Zuordnung uebersetzt nicht.
852
+ *
853
+ * ⚠️ **Eine ART, ein Satz -- auch wenn drei ihrer Dateien betroffen sind.** Das
854
+ * war vorher so (ein `if` feuerte einmal) und bleibt so: Der Rat spricht ueber
855
+ * den Weg, nicht ueber die einzelne Datei; dreimal derselbe Absatz waere
856
+ * Laerm.
857
+ */
858
+ export function ratNachUnberuehrt(unberuehrt, rolle, adminName) {
859
+ // Ruling T7-1: kein `null` im Klartext, sondern der Sammelbegriff.
860
+ const wer = adminName ? ` an ${adminName}` : " an euren Firmen-Admin";
861
+ const betroffeneArten = new Set();
862
+ for (const pfad of VERWALTETE_DATEIEN) {
863
+ if (unberuehrt.includes(pfad))
864
+ betroffeneArten.add(RAT_ART_JE_DATEI[pfad]);
865
+ }
866
+ // 🔑 Die Reihenfolge kommt aus RAT_REIHENFOLGE, NICHT aus der Einfuegereihen-
867
+ // folge des `Set` -- sonst haenge die Ausgabe beim Kunden davon ab, in
868
+ // welcher Reihenfolge VERWALTETE_DATEIEN zufaellig steht.
869
+ return RAT_REIHENFOLGE.filter((art) => betroffeneArten.has(art)).map((art) => RAT_TEXT[art](rolle, wer));
870
+ }