kimal3 0.1.6 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,616 @@
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
+ export const VERWALTETE_DATEIEN = [
46
+ "CLAUDE.md",
47
+ "kontext/kurzprofil.md",
48
+ "kontext/firma.md",
49
+ "kontext/marke.md",
50
+ "kontext/systeme.md",
51
+ "kontext/sicherheit.md",
52
+ ];
53
+ /**
54
+ * Die FUENF Texte, die eine Firma selbst pflegt -- welche Datei welches Feld
55
+ * der App fuellt, wie sie dort heisst, und woran man sie wiedererkennt.
56
+ *
57
+ * 🔑 **Warum es diese Tabelle gibt.** `kimal3 kontext hoch` muss jede Datei dem
58
+ * Feld zuordnen, das `/api/cli/kontext` (POST) erwartet. Bis Task 7 leitete
59
+ * eine Funktion den Feldnamen aus dem Dateinamen ab (`marke.md` -> `marke_md`)
60
+ * -- fuer vier Dateien richtig und fuer die fuenfte falsch: `kurzprofil.md`
61
+ * fuellt `drei_saetze`. Eine Ableitung, die einen Sonderfall braucht, ist
62
+ * keine Ableitung mehr, sondern eine Tabelle mit einem Loch.
63
+ *
64
+ * 🔴 **Sie ist NICHT der Fragenkatalog.** Hier stehen fuenf Dateinamen, fuenf
65
+ * Feldnamen, fuenf Anzeigenamen und fuenf Praefixe -- kein einziger Fragetext.
66
+ * Die 17 Fragen wohnen genau einmal, in `arbeitsplatz/vorlage/`, und erreichen
67
+ * die CLI ueber die ausgelieferte Datei selbst (s. `fragen-lesen.ts`).
68
+ *
69
+ * ⚠️ **`titel` ist ANZEIGETEXT und traegt deshalb echte Umlaute** -- ein Mensch
70
+ * liest ihn im Abbruch von `kontext hoch` („Diese Datei sieht nicht aus wie
71
+ * …"). Die ASCII-Umschrift dieser Datei gilt nur fuer Kommentare.
72
+ *
73
+ * 🔴 **`ankerPraefix` ist keine Verabredung, sondern eine gemessene Zusage.**
74
+ * `check:kontext` haelt jeden Anker aus `FRAGEN` (der App) gegen den Praefix
75
+ * seiner Datei und prueft ausserdem, dass kein Praefix im Anfang eines anderen
76
+ * steckt. Wer eine 18. Frage `firma_neu` in `marke.md` schreibt, wird dort rot
77
+ * -- nicht erst beim Kunden, dem `kontext hoch` seine Marke verweigert.
78
+ *
79
+ * ⚠️ **`FELDER` (`src/app/firma/kontext-felder.ts`) fuehrt dieselben fuenf
80
+ * Zuordnungen.** Die App darf diese Datei lesen, die CLI aber nicht die
81
+ * App -- deshalb steht die Tabelle hier und die Uebereinstimmung ist eine
82
+ * Pruefung in `check:kontext`, keine Hoffnung.
83
+ */
84
+ export const FIRMEN_TEXTE = [
85
+ { pfad: "kontext/kurzprofil.md", feld: "drei_saetze", titel: "Die Firma in drei Sätzen", ankerPraefix: "kurz_" },
86
+ { pfad: "kontext/firma.md", feld: "firma_md", titel: "Die Firma", ankerPraefix: "firma_" },
87
+ { pfad: "kontext/marke.md", feld: "marke_md", titel: "Die Marke", ankerPraefix: "marke_" },
88
+ { pfad: "kontext/systeme.md", feld: "systeme_md", titel: "Die Systeme", ankerPraefix: "systeme_" },
89
+ { pfad: "kontext/sicherheit.md", feld: "sicherheit_md", titel: "Sicherheit", ankerPraefix: "sicher_" },
90
+ ];
91
+ /**
92
+ * Der Merkzettel liegt im WURZELVERZEICHNIS des Arbeitsplatzes, eine Ebene
93
+ * ueber `kontext/`.
94
+ *
95
+ * ⚠️ Das ist kein Schoenheitsfehler: Er fuehrt alle verwalteten Dateien, und
96
+ * eine davon -- die Firmen-`CLAUDE.md` -- liegt gar nicht in `kontext/`. Ein
97
+ * Merkzettel in `kontext/`, der eine Datei ausserhalb verwaltet, waere die Sorte
98
+ * stiller Falschaussage, die niemand bemerkt, bis sie schadet.
99
+ */
100
+ export const MERKZETTEL_PFAD = ".kimal3-stand.json";
101
+ /** Wo die Sicherungen hinkommen. Siehe die Frage am Kopf des Bauplans. */
102
+ export const SICHERUNG_BASIS = "kontext";
103
+ /**
104
+ * Der Stand einer Firma, die nichts gepflegt hat.
105
+ *
106
+ * 🔴 Ohne diesen Wert saehe JEDE frische Installation aus wie "lokal
107
+ * geaendert" -- und das ist der haeufigste Fall ueberhaupt.
108
+ */
109
+ export const LEER = "leer";
110
+ /**
111
+ * Die aktuelle Fassung des MERKZETTEL-FORMATS selbst (Task 2, Spec US-B4) --
112
+ * NICHT die Fassung eines einzelnen Vordrucks (das ist `MerkEintrag.vorlage`).
113
+ * Sie steht hier als eigene, benannte Zahl, damit ein kuenftiger Umbau des
114
+ * Merkzettel-AUFBAUS (nicht seines Inhalts) einen Ankerpunkt hat, an dem er
115
+ * unterscheiden kann, was er vor sich hat.
116
+ */
117
+ export const FORMAT_AKTUELL = 1;
118
+ /** Der leere Merkzettel -- weder Datei-Eintraege noch eine bekannte Firma. */
119
+ const LEERER_MERKZETTEL = { firma_id: null, dateien: {} };
120
+ /**
121
+ * Kurz-Pruefsumme des Inhalts. Zwoelf Hexzeichen aus SHA-256.
122
+ *
123
+ * 🔑 Warum gekuerzt: Der Merkzettel wird von Menschen aufgemacht, wenn etwas
124
+ * schiefgeht. Sechzig Zeichen pro Zeile machen ihn unlesbar, und 48 Bit reichen
125
+ * fuer eine Handvoll Dateien um Groessenordnungen.
126
+ *
127
+ * ⚠️ Der Text wird NICHT normalisiert -- kein `trim`, keine Zeilenenden-
128
+ * Angleichung. Ein Leerzeichen am Zeilenende IST eine Aenderung; wer sie
129
+ * wegrechnet, ueberschreibt eines Tages die Arbeit eines Menschen und meldet
130
+ * dabei "unveraendert".
131
+ */
132
+ export function pruefsumme(inhalt) {
133
+ return createHash("sha256").update(inhalt, "utf8").digest("hex").slice(0, 12);
134
+ }
135
+ /**
136
+ * Ist `wert` sicher genug fuer einen HTTP-Kopf -- druckbares ASCII, keine
137
+ * Steuerzeichen? (Task 10, Fix-Runde 1, Fund 1.)
138
+ *
139
+ * 🔴 DER MERKZETTEL IST EINE DATEI, DIE EIN MENSCH VON HAND OEFFNEN UND
140
+ * BEARBEITEN KANN -- das ist ausdruecklich ihr Zweck (s. Kopf-Kommentar oben:
141
+ * "wird von Menschen aufgemacht, wenn etwas schiefgeht"). `leseMerkzettel`
142
+ * prueft `firma_id` und jeden `stand` nur auf `typeof === "string"`, NIE auf
143
+ * den Inhalt. Ein einziges Nicht-ASCII-Zeichen darin -- von Hand eingetippt,
144
+ * aus einem anderen Editor hereinkopiert -- reicht, damit `headers.set(...)`
145
+ * in `ruf()` (`api.ts`) WIRFT ("Cannot convert argument to a ByteString"),
146
+ * und zwar fuer JEDEN folgenden CLI-Befehl, nicht nur fuer `kontext`-Befehle.
147
+ * Der Kunde saehe "kimal3 ist gerade nicht erreichbar" und suchte den Fehler
148
+ * im Netz, waehrend er auf seiner eigenen Platte sitzt.
149
+ *
150
+ * ⚠️ ECHTES DRUCKBARES ASCII (0x20-0x7E), NICHT NUR "unter 256": Der
151
+ * ByteString-Fehler selbst kippt erst jenseits von Codepunkt 255 -- aber
152
+ * dieselbe Grenze wuerde "ä" (0xE4 = 228) noch durchlassen, und CLAUDE.md
153
+ * zieht sie bewusst enger ("ein Kopf traegt kein ... ä"). Druckbares ASCII
154
+ * ist ausserdem die einzige Menge, die auch Steuerzeichen (Zeilenumbrueche,
155
+ * Tabs) ausschliesst -- die in einem HTTP-Kopf ohnehin nichts verloren haben.
156
+ */
157
+ export function istKopfwertSicher(wert) {
158
+ return /^[\x20-\x7E]*$/.test(wert);
159
+ }
160
+ /**
161
+ * Den Merkzettel aus dem rohen Dateiinhalt lesen. Wirft nie.
162
+ *
163
+ * 🔴 Ein Eintrag ohne BEIDE Felder faellt raus. Ohne diese Bedingung truege
164
+ * `eintrag.pruef` spaeter `undefined`, und `undefined !== summe` ist wahr --
165
+ * also hielte die Rechnung jede Datei fuer lokal geaendert und legte bei jedem
166
+ * Lauf eine neue Sicherung an.
167
+ *
168
+ * 🔴 **ZWEI FORMATE, ABSICHTLICH (Task 10a).** Vor dieser Aufgabe war ein
169
+ * Merkzettel eine FLACHE Zuordnung Pfad -> Eintrag, ohne eigenes Feld fuer die
170
+ * Firma -- und genau solche Dateien liegen schon auf Platten von heute
171
+ * Nachmittag, geschrieben vom Ausliefer-Weg, bevor es diese Aufgabe gab. Das
172
+ * neue Format erkennt sich an einem eigenen `dateien`-Feld (Objekt, keine
173
+ * Liste); fehlt es, wird das GANZE Objekt wie bisher als flache
174
+ * Pfad-Zuordnung gelesen und `firma_id` bleibt `null`. *Ein Werkzeug, das an
175
+ * seiner eigenen aelteren Datei abstuerzt, ist schlimmer als eines, das sie
176
+ * ignoriert.*
177
+ *
178
+ * 🔴 **TASK 2, US-B4: `format` UND `vorlage` WERDEN NUR UEBERNOMMEN, NIE
179
+ * ERFUNDEN.** Beide Felder sind auf ihrem Typ optional (s. dort) -- ein
180
+ * Merkzettel von VOR dieser Aufgabe hat sie nicht, und das ist die "alte
181
+ * Fassung" aus dem Acceptance-Kriterium, kein Fehler. Steht `format` nicht
182
+ * oder nicht als Zahl im Objekt, bleibt es im Ergebnis schlicht weg; dasselbe
183
+ * gilt je Eintrag fuer `vorlage`. Ein Wert, der nicht im Text stand, wird hier
184
+ * nicht geraten -- das waere genau die Sorte stiller Falschaussage, die
185
+ * anderswo in dieser Datei schon einmal Schaden angerichtet hat (s.
186
+ * `bekannterStandAus`).
187
+ */
188
+ export function leseMerkzettel(roh) {
189
+ if (roh === null)
190
+ return LEERER_MERKZETTEL;
191
+ try {
192
+ const daten = JSON.parse(roh);
193
+ if (!daten || typeof daten !== "object" || Array.isArray(daten))
194
+ return LEERER_MERKZETTEL;
195
+ const objekt = daten;
196
+ const hatNeueForm = "dateien" in objekt &&
197
+ typeof objekt.dateien === "object" &&
198
+ objekt.dateien !== null &&
199
+ !Array.isArray(objekt.dateien);
200
+ const dateienRoh = (hatNeueForm ? objekt.dateien : objekt);
201
+ const firmenIdRoh = hatNeueForm ? objekt.firma_id : undefined;
202
+ const formatRoh = hatNeueForm ? objekt.format : undefined;
203
+ const dateien = {};
204
+ for (const [pfad, wert] of Object.entries(dateienRoh)) {
205
+ if (!wert || typeof wert !== "object")
206
+ continue;
207
+ const e = wert;
208
+ if (typeof e.stand !== "string" || typeof e.pruef !== "string")
209
+ continue;
210
+ const eintrag = { stand: e.stand, pruef: e.pruef };
211
+ if (typeof e.vorlage === "string")
212
+ eintrag.vorlage = e.vorlage;
213
+ dateien[pfad] = eintrag;
214
+ }
215
+ // 🔑 `format` steht per Spread VOR `firma_id`, wenn es da ist -- dieselbe
216
+ // Feldreihenfolge wie bei `alsMerkzettel` und `baueMerkzettel`. Reine
217
+ // Kosmetik fuer Menschen, die die Datei aufmachen -- aber genau deshalb
218
+ // WICHTIG fuer `check-kontext.mts`: Die dortige Pruefung vergleicht per
219
+ // `JSON.stringify`, und das ist reihenfolge-empfindlich.
220
+ return {
221
+ ...(typeof formatRoh === "number" ? { format: formatRoh } : {}),
222
+ firma_id: typeof firmenIdRoh === "string" ? firmenIdRoh : null,
223
+ dateien,
224
+ };
225
+ }
226
+ catch {
227
+ return LEERER_MERKZETTEL;
228
+ }
229
+ }
230
+ /** Der Merkzettel als Dateiinhalt -- eingerueckt, weil ihn Menschen aufmachen. */
231
+ export function schreibeMerkzettel(m) {
232
+ return `${JSON.stringify(m, null, 2)}\n`;
233
+ }
234
+ /**
235
+ * Prueft, ob `wert` (aus einer bereits geparsten JSON-ANTWORT der App) ein
236
+ * gueltiger Merkzettel ist -- die Form-Pruefung, die `kontext-runter.ts` und
237
+ * `kontext-hoch.ts` sonst je einmal von Hand nachbauen wuerden (Task 10a).
238
+ *
239
+ * ⚠️ ANDERS STRENG ALS `leseMerkzettel`: Die App liefert ab dieser Aufgabe
240
+ * IMMER das neue Format (`dateien` + `firma_id`) -- Stufe 2 ist nicht
241
+ * veroeffentlicht, es gibt also keine Antwort einer AELTEREN Version, gegen
242
+ * die sich diese Funktion abfedern muesste. Nur das neue Format wird
243
+ * akzeptiert; alles andere ist `null` ("nicht lesbar"), nicht "leer geraten".
244
+ *
245
+ * 🔴 TASK 2: Aus demselben Grund werden jetzt AUCH `format` und je Eintrag
246
+ * `vorlage` verlangt. `baueMerkzettel` (unten) setzt beide seit dieser
247
+ * Aufgabe IMMER -- jede Antwort, die durch diese Rechnung entstanden ist,
248
+ * traegt sie also. Fehlt eines trotzdem, ist das keine aeltere Version,
249
+ * sondern eine kaputte Antwort -- `null`, nicht geraten.
250
+ */
251
+ export function alsMerkzettel(wert) {
252
+ if (!wert || typeof wert !== "object" || Array.isArray(wert))
253
+ return null;
254
+ const objekt = wert;
255
+ if (typeof objekt.format !== "number")
256
+ return null;
257
+ const dateienRoh = objekt.dateien;
258
+ if (!dateienRoh || typeof dateienRoh !== "object" || Array.isArray(dateienRoh))
259
+ return null;
260
+ const firmenIdRoh = objekt.firma_id;
261
+ if (firmenIdRoh !== null && typeof firmenIdRoh !== "string")
262
+ return null;
263
+ const dateien = {};
264
+ for (const [pfad, eintragRoh] of Object.entries(dateienRoh)) {
265
+ if (!eintragRoh || typeof eintragRoh !== "object")
266
+ continue;
267
+ const e = eintragRoh;
268
+ if (typeof e.stand !== "string" || typeof e.pruef !== "string" || typeof e.vorlage !== "string")
269
+ continue;
270
+ dateien[pfad] = { stand: e.stand, pruef: e.pruef, vorlage: e.vorlage };
271
+ }
272
+ return { format: objekt.format, firma_id: firmenIdRoh, dateien };
273
+ }
274
+ /**
275
+ * Der lokal bekannte Firmenstand aus einem Merkzettel als EINE Zeichenkette --
276
+ * **Fix-Runde 1, Review-Fund 1 (Important)**, hierher gezogen fuer Task 10.
277
+ *
278
+ * 🔴 **URSPRUENGLICHER FEHLER (bei `kontext hoch`):** Eine Rechnung, die nur
279
+ * die VIER `kontext/*.md`-Eintraege des Merkzettels ansah, uebersah, dass
280
+ * `CLAUDE.md` den Stand des FUeNFTEN Feldes traegt, `drei_saetze` (s.
281
+ * `setzeKontextEin` in `ausliefern.ts`). Eine Firma, die bisher NUR die drei
282
+ * Saetze ueber das Formular auf `/firma` gepflegt hat -- ein ganz normaler
283
+ * erster Schritt --, hatte deshalb alle vier `kontext/*.md`-Eintraege auf
284
+ * `LEER`, waehrend der Server einen echten Stand fuehrte.
285
+ *
286
+ * 🔑 **WARUM „DER EINE NICHT-LEERE STAND GEWINNT" RICHTIG IST:** Die
287
+ * Firmenzeile traegt GENAU EIN `geaendert_am` fuer alle fuenf Felder. Jede
288
+ * Lieferung stempelt dieses EINE `zeitpunkt` auf jedes gepflegte Feld und
289
+ * `LEER` auf jedes ungepflegte -- es gibt also nie zwei verschiedene,
290
+ * gleichzeitig gueltige NICHT-leere Staende in einer einzigen Lieferung.
291
+ *
292
+ * ⚠️ **TROTZDEM, DIE GEGENPROBE:** Der Merkzettel ist eine Datei auf der
293
+ * Platte des Kunden, keine Datenbank mit Integritaetsregel. Findet diese
294
+ * Funktion mehr als einen unterschiedlichen nicht-leeren Stand, liefert sie
295
+ * `LEER` -- derselbe Wert wie "noch nie heruntergeladen". Sie RAET nicht,
296
+ * welcher stimmt.
297
+ *
298
+ * 🔁 **Task 10:** Wohnte bis hierher in `befehle/kontext-hoch.ts` und wird
299
+ * jetzt auch von `api.ts` gebraucht (fuer den Kopf `X-Kimal3-Kontext-Stand`).
300
+ * `kontext-hoch.ts` importiert `ruf` aus `./api.js` -- ein Umzug hierher, in
301
+ * die Datei ohne jeden Import ausser `node:crypto`, ist der einzige Weg, der
302
+ * `api.ts` nicht in einen Kreisimport mit sich selbst zwingt.
303
+ */
304
+ export function bekannterStandAus(merkzettel) {
305
+ const staende = new Set();
306
+ for (const pfad of VERWALTETE_DATEIEN) {
307
+ const stand = merkzettel.dateien[pfad]?.stand;
308
+ if (stand !== undefined && stand !== LEER)
309
+ staende.add(stand);
310
+ }
311
+ if (staende.size === 1) {
312
+ const [einziger] = staende;
313
+ return einziger;
314
+ }
315
+ return LEER;
316
+ }
317
+ /**
318
+ * Der Merkzettel zu einer Lieferung.
319
+ *
320
+ * 🔴 ER ENTSTEHT MIT DEM INHALT, NICHT DANACH. Wer ihn spaeter schreibt, baut
321
+ * denselben Fehler wie Runde 5 des Update-Kanals: Der Stempel stand am Ende und
322
+ * behauptete taeglich, der Skill sei veraltet.
323
+ *
324
+ * ⚠️ `staende` nennt je Datei den Stand der FIRMA (`geaendert_am`) oder `leer`.
325
+ * Fehlt eine verwaltete Datei in der Lieferung, entsteht kein Eintrag -- ein
326
+ * Eintrag mit `undefined` waere schlimmer als keiner.
327
+ *
328
+ * 🔴 `firmenId` ist ABSICHTLICH PFLICHT, nicht optional (Task 10a). Ohne die
329
+ * ID muesste der Server spaeter raten, welche Firma gemeint ist -- wer zu
330
+ * ZWEI Firmen gehoert, staende im Ordner von A und laese "B hat etwas
331
+ * geaendert". `null` ist ein gueltiger, ausdruecklicher Wert ("keine
332
+ * eindeutige Firma bekannt", z. B. beim Vordruck ohne Kontext) -- ein
333
+ * vergessenes Argument ist das nicht: Wer diese Funktion aufruft, muss sich
334
+ * entscheiden, nicht stillschweigend `null` bekommen.
335
+ *
336
+ * 🔴 TASK 2: `vorlagenPruefsummen` ist das vierte, LETZTE und OPTIONALE
337
+ * Argument (Default `{}`) -- bewusst ans Ende gehaengt, nicht dazwischen. Der
338
+ * einzige heutige Aufrufer (`ausliefern.ts`) reicht die echten
339
+ * Vordruck-Pruefsummen noch nicht durch (das wiederum durchzuziehen ist Sache
340
+ * einer spaeteren Aufgabe -- diese hier baut nur die Rechnung, s. Auftrag);
341
+ * ein PFLICHT-Argument haette genau diesen bestehenden Aufruf sofort zum
342
+ * Kompilierfehler gemacht, und diese Aufgabe darf ausschliesslich in dieser
343
+ * Datei und in `check-kontext.mts` etwas aendern. Fehlt eine Pruefsumme fuer
344
+ * eine Datei, traegt ihr `vorlage`-Feld `LEER` -- derselbe Sicherheitswert
345
+ * wie beim ungepflegten `stand` zwei Zeilen weiter unten, aus demselben
346
+ * Grund: *wer nichts weiss, behauptet nichts.*
347
+ *
348
+ * 🔑 `format` wird HIER, beim NEU-Bauen einer Lieferung, immer gesetzt --
349
+ * anders als beim blossen LESEN einer womoeglich alten Datei
350
+ * (`leseMerkzettel` oben). Jede Lieferung, die durch diese Funktion entsteht,
351
+ * ist per Definition die aktuelle Fassung.
352
+ */
353
+ export function baueMerkzettel(dateien, staende, firmenId, vorlagenPruefsummen = {}) {
354
+ const raus = {};
355
+ for (const pfad of VERWALTETE_DATEIEN) {
356
+ const datei = dateien.find((d) => d.pfad === pfad);
357
+ if (!datei)
358
+ continue;
359
+ raus[pfad] = {
360
+ stand: staende[pfad] ?? LEER,
361
+ pruef: pruefsumme(datei.inhalt),
362
+ vorlage: vorlagenPruefsummen[pfad] ?? LEER,
363
+ };
364
+ }
365
+ return { format: FORMAT_AKTUELL, firma_id: firmenId, dateien: raus };
366
+ }
367
+ /**
368
+ * Der Merkzettel, den `kimal3 kontext hoch` nach einem gelungenen Lauf auf die
369
+ * Platte schreibt.
370
+ *
371
+ * 🔴 **DER FUND, DEN DIE KUNDENSTRECKE AM 2026-08-25 GEFUNDEN HAT.** Vorher
372
+ * wurde die Antwort des Servers eins zu eins hingeschrieben. Der Merkzettel ist
373
+ * aber eine Aussage darueber, WAS AUF DER PLATTE LIEGT -- und `kontext hoch`
374
+ * schreibt keine einzige Datei. Fuer die fuenf gesendeten Texte geht das
375
+ * trotzdem auf: Was der Server zurueckgibt, entsteht aus dem, was die CLI ihm
376
+ * gerade geschickt hat, und dasselbe steht auf der Platte.
377
+ *
378
+ * ⚠️ **Fuer die Firmen-`CLAUDE.md` geht es NICHT auf, seit Ruling K-6.** Sie
379
+ * wird nicht mehr gesendet, aber der Server BAUT sie neu (er setzt die frischen
380
+ * drei Saetze ein). Die Pruefsumme in seiner Antwort gehoert damit zu einem
381
+ * Text, den niemand auf die Platte geschrieben hat.
382
+ *
383
+ * 🔴 **Was das anrichtete, gemessen:** Direkt nach einem erfolgreichen
384
+ * `kontext hoch` meldete `kimal3 kontext runter` „Ich fasse nichts an -- du
385
+ * hast lokal geaendert: CLAUDE.md" und riet: *„Deine Fassung ist die neuere?
386
+ * Dann schick sie hoch: kimal3 kontext hoch"* -- ein Rat auf einen Befehl, der
387
+ * die `CLAUDE.md` gar nicht mehr mitnimmt. Der Admin haette das beliebig oft
388
+ * wiederholen koennen. *Das ist woertlich die Form von Fund F: ein Befehl
389
+ * schickt den Menschen zu einem zweiten, der seine Lage nicht aendern kann,
390
+ * und beide melden Erfolg.*
391
+ *
392
+ * 🔑 **Die Regel: Ueber eine Datei, die wir nicht gesendet haben, uebernehmen
393
+ * wir nichts Neues.** Ihr Eintrag bleibt der lokale von vorher. Der naechste
394
+ * `kontext runter` sieht dann sauber „Server neuer" -- der Stand weicht ab, die
395
+ * Pruefsumme passt zur Platte -- und holt die Datei ohne Sicherung. Genau das
396
+ * soll passieren: Die Firma hat ihre drei Saetze geaendert, also gehoert die
397
+ * abgeleitete Kopie in der `CLAUDE.md` aufgefrischt.
398
+ *
399
+ * ⚠️ Gibt es lokal gar keinen Eintrag, entsteht auch keiner. `null` heisst
400
+ * „wir wissen nichts" und fuehrt beim naechsten Holen zur Sicherung -- der
401
+ * sichere Weg, nicht der bequeme.
402
+ *
403
+ * 🔑 `format` und `firma_id` kommen weiterhin vom SERVER: Beide sagen nichts
404
+ * ueber den Inhalt einer Datei, und bei der Firma ist der Server die Wahrheit.
405
+ */
406
+ export function merkzettelNachHoch(vomServer, lokal, gesendet) {
407
+ const dateien = {};
408
+ for (const pfad of VERWALTETE_DATEIEN) {
409
+ const eintrag = gesendet.includes(pfad) ? vomServer.dateien[pfad] : lokal.dateien[pfad];
410
+ if (eintrag)
411
+ dateien[pfad] = eintrag;
412
+ }
413
+ return { ...vomServer, dateien };
414
+ }
415
+ /**
416
+ * Die Entscheidungstabelle aus Spec §7b -- fuer EINE Datei.
417
+ *
418
+ * 🔑 DIE ABKUERZUNG STEHT ZUERST, und sie nimmt den haeufigsten Aerger weg: Ist
419
+ * der lokale Text identisch mit dem, was der Server liefert, lautet die Antwort
420
+ * "schon aktuell" -- egal was der Merkzettel sagt oder ob es ihn gibt. Wer
421
+ * frisch klont, hat die Dateien aus dem Repo, aber keinen Merkzettel; ohne diese
422
+ * Zeile bekaeme er eine sinnlose Sicherung seiner eigenen, unveraenderten Dateien.
423
+ *
424
+ * 🔴 "SERVER NEUER" HEISST HIER **ANDERS**, NICHT **SPAETER** (Bauplan D-2). Ein
425
+ * echter Zeitvergleich haette zwei Uhren und einen Sonderwert (`leer`) zu
426
+ * bewaeltigen. Weil laut E-4 ohnehin die Firma gewinnt, reicht die Ungleichheit
427
+ * -- und ein Zurueckrollen in der App erreicht den Kunden damit ebenfalls.
428
+ *
429
+ * 🔑 KEIN MERKZETTEL = BEIDES UNBEKANNT = der sichere Fall. Eine Datei ohne
430
+ * Eintrag kann von Hand angelegt, aus einer alten Fassung kopiert oder muehsam
431
+ * ausgefuellt worden sein. *Wer nichts weiss, loescht nichts* -- also wird
432
+ * gesichert, bevor ueberschrieben wird.
433
+ *
434
+ * 🔴 **TASK 2 (Spec US-B1/US-B4): DIE FUeNFTE LAGE, `vorlage-neuer`.** Das ist
435
+ * die Reparatur des schwersten Befundes des ganzen Umbaus: Bis hierher
436
+ * verglich diese Funktion nur, OB sich der ausgelieferte Text geaendert hat --
437
+ * nie, WESHALB. Verbessern wir unseren eigenen Vordruck, aendert sich der
438
+ * ausgelieferte Text bei jeder Firma, die ihn nicht selbst gepflegt hat, genau
439
+ * so wie bei einer echten Firmen-Aenderung -- und `serverStand` (der Stand DER
440
+ * FIRMA) bleibt dabei unveraendert `leer`. Ohne die dritte Dimension gab es
441
+ * daher zwei falsche Wege: Entweder mass die Funktion den Inhalt direkt (dann
442
+ * riss die Abkuerzung ganz oben nie, und ein reiner Vordruck-Wechsel landete
443
+ * unter "schon-aktuell" -- die Firma saehe die Verbesserung NIE) oder sie
444
+ * ignorierte den Vordruck-Stand komplett (dann waere jede Vordruck-Aenderung
445
+ * unsichtbar). `aktuelleVorlage` traegt deshalb die Pruefsumme des Vordrucks
446
+ * HEUTE herein; verglichen wird sie gegen `eintrag.vorlage` -- die Pruefsumme
447
+ * des Vordrucks, aus dem die Datei beim letzten Holen stammte.
448
+ *
449
+ * 🔑 DIE PRIORITAET, WENN MEHRERES ZUTRIFFT: Hat die FIRMA inzwischen etwas
450
+ * gesagt (`serverNeuer`), gewinnt das IMMER -- ob sich der Vordruck ebenfalls
451
+ * geaendert hat, spielt dann keine Rolle mehr; ihr Text ersetzt ohnehin den
452
+ * Vordruck. Hat dagegen der KUNDE lokal geaendert (`lokalGeaendert`), zaehlt
453
+ * eine geaenderte Vorlage genauso als Grund zu sichern wie eine geaenderte
454
+ * Firmen-Zeile -- in beiden Faellen wuerde ein sang- und klangloses
455
+ * Ueberschreiben echte Arbeit verlieren, also wird "beides".
456
+ *
457
+ * 🔴 `aktuelleVorlage` ist OPTIONAL, NICHT weil die dritte Dimension optional
458
+ * waere, sondern weil das Verdrahten mit echten Vordruck-Pruefsummen an den
459
+ * Aufrufstellen (`kontext-runter.ts`) NICHT zu dieser Aufgabe gehoert (s.
460
+ * Auftrag: "Bau die Lage, nicht ihre Ausgabe") -- und diese Aufgabe darf
461
+ * ausschliesslich in dieser Datei und in `check-kontext.mts` etwas aendern.
462
+ * Bleibt das Argument weg, verhaelt sich diese Funktion BUCHSTAeBLICH wie vor
463
+ * dieser Aufgabe: `vorlage-neuer` kann dann gar nicht entstehen. Das ist kein
464
+ * Kompromiss an der Rechnung, sondern die Rechnung UND der bestehende,
465
+ * unveraenderte Aufrufer bleiben beide wahr.
466
+ */
467
+ export function lageFuerDatei(a) {
468
+ if (a.lokalerInhalt === a.serverInhalt)
469
+ return "schon-aktuell";
470
+ // Es gibt nichts zu verlieren -- also holen, ohne zu sichern.
471
+ if (a.lokalerInhalt === null)
472
+ return "server-neuer";
473
+ const lokalGeaendert = a.eintrag === null ? true : pruefsumme(a.lokalerInhalt) !== a.eintrag.pruef;
474
+ const serverNeuer = a.eintrag === null ? true : a.eintrag.stand !== a.serverStand;
475
+ const vorlageNeuer = a.aktuelleVorlage === undefined
476
+ ? false
477
+ : a.eintrag === null
478
+ ? true
479
+ : a.eintrag.vorlage !== a.aktuelleVorlage;
480
+ if (lokalGeaendert)
481
+ return serverNeuer || vorlageNeuer ? "beides" : "lokal-geaendert";
482
+ if (serverNeuer)
483
+ return "server-neuer";
484
+ if (vorlageNeuer)
485
+ return "vorlage-neuer";
486
+ return "schon-aktuell";
487
+ }
488
+ /** `kontext/vorher-2026-08-21` */
489
+ export function sicherungsOrdner(datum) {
490
+ return `${SICHERUNG_BASIS}/vorher-${datum}`;
491
+ }
492
+ /**
493
+ * Wohin die lokale Fassung gesichert wird.
494
+ *
495
+ * ⚠️ FLACH, mit dem Basisnamen -- `CLAUDE.md` und `kontext/systeme.md` landen
496
+ * nebeneinander. Dass sich dabei nichts ueberschreiben kann, sichert der
497
+ * Waechter: Er prueft, dass alle verwalteten Dateien verschiedene
498
+ * Basisnamen haben. Eine kuenftige sechste faellt damit auf, bevor sie eine
499
+ * Sicherung ueberschreibt.
500
+ */
501
+ export function sicherungsZiel(datum, pfad) {
502
+ const name = pfad.split("/").pop() ?? pfad;
503
+ return `${sicherungsOrdner(datum)}/${name}`;
504
+ }
505
+ /**
506
+ * Von welchem Ordner aus `kimal3 kontext runter` ueberhaupt wissen kann,
507
+ * welche Firma gemeint ist -- **ohne** das Dateisystem selbst anzufassen.
508
+ *
509
+ * 🔑 WARUM DIESE FUNKTION HIER UND NICHT ERST IN EINER SPAETEREN AUFGABE
510
+ * WOHNT: Diese Datei importiert bewusst nur `node:crypto` (s. Kopf). Die
511
+ * Fundregel selbst braucht kein Dateisystem -- nur eine Liste von Kandidaten,
512
+ * die der Aufrufer (spaeter) mit `readdirSync`/`existsSync` gefuellt hat.
513
+ * Die eigentliche Suche gehoert deshalb woanders hin (zieht Umgebung und
514
+ * Heimverzeichnis hoch); ein Waechter fuer eine reine Pfadregel soll das
515
+ * nicht mit auslösen.
516
+ *
517
+ * Fundregel: Vom aktuellen Verzeichnis aufwaerts suchen, bis ein Ordner
518
+ * gefunden ist, der `kontext/` UND `CLAUDE.md` enthaelt.
519
+ *
520
+ * 🔴 DER ERSTE TREFFER GEWINNT, NICHT DER OBERSTE. Die Kandidaten kommen von
521
+ * UNTEN NACH OBEN (aktuelles Verzeichnis zuerst) -- wer in
522
+ * `Kunden/ARNO/KI-Arbeitsplatz` steht, meint ARNO, nicht `Kunden/`. Wuerde
523
+ * stattdessen der oberste Treffer gewinnen, faende der Befehl bei jedem
524
+ * verschachtelten Arbeitsplatz den falschen.
525
+ *
526
+ * 🔑 BEIDES MUSS STIMMEN. Ein Ordner mit `kontext/`, aber ohne `CLAUDE.md`,
527
+ * ist kein Arbeitsplatz -- sonst traefe der Befehl irgendeinen Ordner, in dem
528
+ * jemand zufaellig etwas "kontext" genannt hat.
529
+ *
530
+ * Kein Treffer -> `null`.
531
+ */
532
+ export function arbeitsplatzAus(kandidaten) {
533
+ for (const k of kandidaten) {
534
+ if (k.hatKontext && k.hatClaude)
535
+ return k.pfad;
536
+ }
537
+ return null;
538
+ }
539
+ /*
540
+ 🪦 **Hier wohnten `DREI_SAETZE_UEBERSCHRIFT`, der Typ `DreiSaetzeSchnitt` und
541
+ `dreiSaetzeAus` -- ersatzlos gestrichen mit Ruling K-6 (Task 7).**
542
+
543
+ Was sie taten: Sie schnitten den Abschnitt „Die Firma in drei Saetzen" aus
544
+ dem Text der Firmen-`CLAUDE.md`, damit `kimal3 kontext hoch` ihn als
545
+ `drei_saetze` mitschicken konnte. Sie waren die Antwort auf Fund F -- der
546
+ Befehl liess dieses eine Feld aus und meldete trotzdem einen Haken.
547
+
548
+ 🔴 **Warum ihr Fall nicht mehr entstehen kann:** Seit Ruling K-18 hat „Die
549
+ Firma in drei Saetzen" eine EIGENE Datei, `kontext/kurzprofil.md`, und seit
550
+ Task 6 stehen in der `CLAUDE.md` nur noch die drei ANTWORTEN -- ohne Anker,
551
+ ohne Ueberschriften. Der Umweg schnitt also ab Task 6 einen Text heraus, den
552
+ die App nicht mehr als beantwortete Fragen lesen kann: EIN `kontext hoch`
553
+ haette drei ankerlose Zeilen in die Spalte geschrieben, `alsStand` haette sie
554
+ als ungepflegt gelesen, und das Formular haette wieder das leere
555
+ Fragengeruest gezeigt -- mit den Saetzen der Firma unsichtbar dahinter.
556
+
557
+ 🔑 **Was ihre Aufgabe uebernommen hat:** `kontext/kurzprofil.md` ist seither
558
+ eine Datei wie die vier anderen. Sie steht in {@link FIRMEN_TEXTE} und wird
559
+ gelesen, hochgeschickt und nachgezogen wie `marke.md` auch. *Fuenf
560
+ gleichrangige Dateien, kein Sonderfall, keine Extra-Meldung.*
561
+
562
+ ⚠️ Die Ueberschrift steht weiterhin in der Vorlage -- sie ist jetzt aber
563
+ blosser Fliesstext. Kein Code sucht sie mehr; `ausliefern.ts` setzt die drei
564
+ Antworten an der Stelle von `EINSETZ_MARKE_DREI_SAETZE` ein, und dass DIESE
565
+ Marke woertlich in der Vorlage steht, prueft `check:arbeitsplatz`.
566
+ */
567
+ /**
568
+ * Was ein Mensch lesen soll, wenn `kimal3 kontext runter` Dateien in Ruhe
569
+ * laesst, weil er sie lokal geaendert hat -- je nach Rolle und je nach DATEI.
570
+ *
571
+ * 🔴 **DAS IST FUND F, ZUM ZWEITEN MAL -- und diesmal die echte Fassung
572
+ * (Review-Befund F-3, 2026-08-25).** Der Rat lautete fuer die ganze Liste auf
573
+ * einmal: „Deine Fassung ist die neuere? Dann schick sie hoch: kimal3 kontext
574
+ * hoch". Fuer `CLAUDE.md` ist dieser Satz eine Sackgasse: Sie steht in
575
+ * {@link VERWALTETE_DATEIEN} und landet deshalb in der Liste -- aber
576
+ * `kontext hoch` liest ausschliesslich die fuenf Dateien aus
577
+ * {@link FIRMEN_TEXTE} und meldet danach Erfolg, ohne sie. *Der Mensch tippt
578
+ * den geratenen Befehl, sieht einen Haken, und seine Aenderung steht weiter
579
+ * allein auf seiner Platte.*
580
+ *
581
+ * 🔑 Die ausgelieferte `CLAUDE.md` laedt diese Bearbeitung foermlich ein: Sie
582
+ * sagt selbst, aendern muesse man sie nur, wenn sich die Firma aendert.
583
+ *
584
+ * 🔴 **Der Rat wohnt hier und nicht im Befehl, damit ihn eine Wache sehen
585
+ * kann.** Die spurious Fassung desselben Fehlers (`merkzettelNachHoch`) war
586
+ * behoben, die echte lag vier Runden lang offen -- *ein Satz ueber das
587
+ * Verhalten eines Befehls wohnte im Befehl selbst, wo keine Vorlagen-Wache je
588
+ * hinsieht.* `check:kontext` prueft jetzt genau diese Zuordnung.
589
+ *
590
+ * ⚠️ ECHTE UMLAUTE -- das liest ein Kunde. Die ASCII-Umschrift dieser Datei
591
+ * gilt nur fuer Kommentare.
592
+ */
593
+ export function ratNachUnberuehrt(unberuehrt, rolle, adminName) {
594
+ const zeilen = [];
595
+ // Fuer alles, was `kontext hoch` wirklich tragen kann.
596
+ if (unberuehrt.some((p) => FIRMEN_TEXTE.some((t) => t.pfad === p))) {
597
+ if (rolle === "admin") {
598
+ zeilen.push(" Deine Fassung ist die neuere? Dann schick sie hoch: kimal3 kontext hoch");
599
+ }
600
+ else {
601
+ const wer = adminName ? ` an ${adminName}` : " an euren Firmen-Admin";
602
+ zeilen.push(" Deine Fassung bleibt stehen — hochschicken darf nur der Firmen-Admin.\n" +
603
+ ` Gib deine Ergänzung${wer} weiter; von dort geht sie in der App unter\n` +
604
+ " Firma → Kontext oder mit `kimal3 kontext hoch` an alle.");
605
+ }
606
+ }
607
+ // Und fuer die eine Datei, die er nicht tragen kann.
608
+ if (unberuehrt.includes("CLAUDE.md")) {
609
+ zeilen.push(" Die `CLAUDE.md` kann `kimal3 kontext hoch` nicht mitnehmen — der Befehl schickt nur\n" +
610
+ " die fünf Dateien aus `kontext/`. Die drei Sätze in dieser Datei kommen aus\n" +
611
+ " `kontext/kurzprofil.md`, der Firmenname aus der App: Ändere sie dort, dann holt\n" +
612
+ " der nächste `kimal3 kontext runter` sie hierher.\n" +
613
+ " Alles Übrige darin ist unsere Vorlage — dafür gibt es keinen Weg zurück.");
614
+ }
615
+ return zeilen;
616
+ }
package/dist/nachricht.js CHANGED
@@ -196,3 +196,54 @@ export function skillZustand(inhalt, eigeneVersion) {
196
196
  export function skillMeldung() {
197
197
  return "\n → Deine kimal3-Skilldateien sind aelter als die CLI. kimal3 skill install\n";
198
198
  }
199
+ /**
200
+ * Zerlegt den Kopf `X-Kimal3-Kontext-Stand`: `<firmen-id>:<stand>` (Task 10).
201
+ *
202
+ * ⚠️ AM ERSTEN DOPPELPUNKT TRENNEN, nicht am `split(":")[1]`: `stand` ist oft
203
+ * ein ISO-Zeitstempel und traegt selbst Doppelpunkte
204
+ * (`2026-08-21T07:12:33.456Z`). Ein `split` wuerde ihn mitten durchschneiden.
205
+ *
206
+ * Fehlt der Kopf, ist er leer, hat keinen Doppelpunkt oder ist eine Seite
207
+ * leer -> `null`. Diese Datei rechnet nur; SIE PRUEFT NICHT, ob die Firmen-ID
208
+ * echt ist oder der Anrufer ihr Mitglied ist -- das ist Datenbankzugriff und
209
+ * gehoert der Aufrufstelle (`src/lib/cli/nachricht.ts`).
210
+ */
211
+ export function kontextStandAusKopf(wert) {
212
+ if (!wert)
213
+ return null;
214
+ const trenner = wert.indexOf(":");
215
+ if (trenner <= 0)
216
+ return null;
217
+ const firmaId = wert.slice(0, trenner);
218
+ const stand = wert.slice(trenner + 1);
219
+ if (stand === "")
220
+ return null;
221
+ return { firmaId, stand };
222
+ }
223
+ /**
224
+ * Die ZWEITE Quelle des Update-Kanals (Task 10) -- rein: Sie bekommt den
225
+ * lokalen Stand (aus dem Kopf, schon geparst) und den server-gefuehrten
226
+ * Stand samt Firmennamen fertig nachgeschlagen und ENTSCHEIDET nur noch.
227
+ *
228
+ * 🔴 WARUM DER SERVER-STAND UND NICHT DER ROHE KOPF VERGLICHEN WIRD: Der
229
+ * Server weiss nicht, was auf der Platte des Kunden liegt -- die CLI schickt
230
+ * ihren Stand, die App vergleicht ihn nur mit ihrem eigenen (Bauplan Step 2).
231
+ *
232
+ * 🔑 `id: kontext-<serverStand>` (Step 3): Die Tagessperre der CLI merkt sich
233
+ * Hinweise nach `id` -- aendert die Firma den Kontext ein zweites Mal am
234
+ * selben Tag, aendert sich `serverStand` und damit automatisch die `id`. Der
235
+ * Hinweis erscheint also je NEUEM Stand genau einmal, ohne dass diese
236
+ * Funktion selbst je ein Datum ansieht.
237
+ *
238
+ * 🔑 US-8: `firmenName` steht IM TEXT, nicht nur die ID -- wer in zwei Firmen
239
+ * ist, muss lesen koennen, WELCHE gemeint ist.
240
+ */
241
+ export function firmenHinweisAus(lokalerStand, serverStand, firmenName) {
242
+ if (lokalerStand === serverStand)
243
+ return null;
244
+ return {
245
+ id: `kontext-${serverStand}`,
246
+ text: `${firmenName} hat den Firmenkontext aktualisiert. kimal3 kontext runter`,
247
+ gilt_unter_version: null,
248
+ };
249
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kimal3",
3
- "version": "0.1.6",
3
+ "version": "0.2.0",
4
4
  "description": "Trag deine KI-Projekte dort ein, wo sie entstehen: im Terminal.",
5
5
  "type": "module",
6
6
  "bin": {