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.
@@ -42,7 +42,13 @@ export const BEFEHLE = [
42
42
  tippen: "! kimal3 skill install",
43
43
  terminal: "kimal3 skill install",
44
44
  details: [
45
- "Der Befehl, der aus den anderen erst etwas macht: Er legt 🤖 Claudes Anleitung ab (den „Skill“) und 🧑 deine vier Slash-Befehle.",
45
+ // ⚠️ KEINE Anzahl hier (Review-Runde 5): Hier stand „deine vier
46
+ // Slash-Befehle“, und die Zahl war ab `/k3kontext` falsch — auf der
47
+ // Pro-Seite UND in der Terminal-Hilfe, denn dieser Text wird an beiden
48
+ // Stellen ausgegeben. Dieselbe Lehre wie bei „13 Pruefungen“ in
49
+ // `check:arbeitsplatz`: Eine Anzahl im Fliesstext ist eine Behauptung,
50
+ // die verrottet. Welche es sind, sagt `/k3` beim Laufen.
51
+ "Der Befehl, der aus den anderen erst etwas macht: Er legt 🤖 Claudes Anleitung ab (den „Skill“) und 🧑 deine Slash-Befehle.",
46
52
  "Er läuft NICHT automatisch — einmal nach der Installation, und nach jedem Update noch einmal. Sonst arbeitet Claude weiter mit der alten Anleitung.",
47
53
  "Geklappt hat es, wenn er „Skill angelegt“ meldet und die Slash-Befehle aufzählt. Beides kennt Claude ab der NÄCHSTEN Sitzung — die laufende einmal neu starten.",
48
54
  ],
@@ -84,6 +90,56 @@ export const BEFEHLE = [
84
90
  "Gib deinen Vornamen mit — der Ordner trägt ihn: aus sabine wird werkbank-sabine. Ohne Angabe bildet der Server ihn aus deiner Anmelde-Adresse. So sieht man später, wem welche Werkbank gehört.",
85
91
  ],
86
92
  },
93
+ /*
94
+ 🔴 Auch diese zwei Eintraege stehen direkt NEBEN `werkbank neu` und nicht
95
+ am Ende der Liste, aus demselben Grund wie oben bei `arbeitsplatz neu`:
96
+ Wer hier liest, sucht den naechsten Schritt, nicht das Alphabet. Wer
97
+ einen KI-Arbeitsplatz gerade angelegt oder betreten hat, fragt sich als
98
+ Naechstes, wie der Firmenkontext (CLAUDE.md + kontext/*.md) aktuell
99
+ bleibt -- nicht, wie man ein Projekt eintraegt.
100
+ */
101
+ {
102
+ befehl: "kimal3 kontext runter",
103
+ beschreibung: "Den Firmenkontext holen — CLAUDE.md und die fünf Dateien in kontext/",
104
+ wer: "du",
105
+ rhythmus: "wann immer die Firma etwas geändert haben könnte",
106
+ tippen: "! kimal3 kontext runter",
107
+ terminal: "kimal3 kontext runter",
108
+ details: [
109
+ "Holt CLAUDE.md und die fünf Dateien in kontext/ von deiner Firma und legt sie in deinem KI-Arbeitsplatz ab. Was du selbst lokal geändert hast, fasst er nicht an — er sagt dir, wie du es einbringst.",
110
+ "Hast du eine Datei lokal geändert UND hat die Firma sie inzwischen auch geändert, sichert er deine Fassung zuerst nach kontext/vorher-<datum>/, bevor er sie ersetzt — nichts geht verloren.",
111
+ "Geklappt hat es, wenn er die aktualisierten Dateien nennt oder „Dein Firmenkontext ist aktuell“ sagt.",
112
+ "⚠️ Wie werkbank neu braucht er ein eigenes Pro-Konto. Ohne das kommt Rückgabewert 4 — dann nicht wiederholen.",
113
+ ],
114
+ },
115
+ {
116
+ befehl: "kimal3 kontext fragen",
117
+ beschreibung: "Die Fragen über deine Firma beantworten — Claude führt dich durch",
118
+ wer: "claude",
119
+ ausloeser: "beantworten wir die Fragen über meine Firma",
120
+ tippen: "/k3kontext",
121
+ terminal: "kimal3 kontext fragen",
122
+ details: [
123
+ "Liest die fünf Dateien in kontext/ und gibt jede Frage aus, unter der noch nichts steht — mit Datei, Frage und Hinweis. Sind alle beantwortet, sagt er das in einem Satz.",
124
+ "Er liest nur: kein Netz, keine Anmeldung, kein Schreiben. Deshalb ist er der richtige erste Schritt — Claude stellt dir die Fragen dann einzeln und trägt deine Antworten in die Dateien ein.",
125
+ "Danach schickt kimal3 kontext hoch sie an die Firma. Das darf nur der Admin; alle anderen geben ihre Antworten an ihn weiter.",
126
+ ],
127
+ },
128
+ {
129
+ befehl: "kimal3 kontext hoch",
130
+ beschreibung: "Deine lokalen Änderungen an den fünf Firmen-Texten hochschicken",
131
+ wer: "du",
132
+ rhythmus: "nachdem du kontext/*.md bearbeitet hast — nur als Admin",
133
+ tippen: "! kimal3 kontext hoch",
134
+ terminal: "kimal3 kontext hoch",
135
+ details: [
136
+ "Schickt alle fünf Firmen-Texte an deine Firma — die Dateien in kontext/ (kurzprofil.md, firma.md, marke.md, systeme.md, sicherheit.md). Damit kommen sie beim nächsten kontext runter auch bei den Kolleginnen und Kollegen an.",
137
+ "🔴 Nur der Firmen-Admin darf das. Bist du keiner, sagt er es dir — dann gib deine Ergänzung an den Admin weiter, ein zweiter Versuch ändert daran nichts.",
138
+ "⚠️ Klappt etwas nicht, zählt die MELDUNG, nicht die Zahl dahinter: Dieselbe 1 steht für „du bist kein Admin“, „hier steht kein Arbeitsplatz“, „jemand anderes hat inzwischen geändert“ (Ausweg --ueberschreiben) und mehreres mehr. Sie steht sogar unter einer Meldung, die mit „Die Firma hat deine Änderungen gespeichert“ beginnt und nur sagt, dass eine Datei auf deiner Platte nicht geschrieben werden konnte — DAS ist ein Erfolg: alles ist oben angekommen. Lies den Satz, nicht die Zahl.",
139
+ "Sieht eine der fünf Dateien nicht mehr aus wie ihre Frageliste — etwa weil etwas Fremdes darüberkopiert wurde —, bricht er ab und schickt gar nichts, auch die vier anderen nicht. kimal3 kontext runter holt die Datei zurück.",
140
+ "Geklappt hat es, wenn er „✓ hochgeschickt“ mit den Dateinamen meldet.",
141
+ ],
142
+ },
87
143
  {
88
144
  befehl: "kimal3 projekt neu …",
89
145
  beschreibung: "Ein Projekt ins Erfolgsprofil eintragen",
@@ -113,7 +169,7 @@ export const BEFEHLE = [
113
169
  beschreibung: "Die Daten deiner Firma holen, damit Claude sie auswerten kann",
114
170
  wer: "claude",
115
171
  ausloeser: "werte meine Firma aus",
116
- tippen: "/k3firma <deine Frage>",
172
+ tippen: "/k3analyse <deine Frage>",
117
173
  terminal: 'kimal3 firma daten --firma "Nordwind Handel GmbH"',
118
174
  details: [
119
175
  "Holt Kennzahlen, Menschen und Projekte deiner Firma — auswerten tut Claude. Frag einfach los: „Welche drei Projekte bringen den meisten messbaren Ertrag?“",
@@ -2,8 +2,10 @@
2
2
  * Die Firmen-Auswahl, die eine Absage dem Menschen anbietet.
3
3
  *
4
4
  * 🔑 Eine reine Rechnung, damit `check:cli` sie ohne Server pruefen kann --
5
- * dieselbe Ueberlegung wie bei `fehler.ts`.
5
+ * dieselbe Ueberlegung wie bei `fehler.ts`. `alsWort` unten ist ebenfalls eine
6
+ * reine Rechnung, der Import bricht das also nicht.
6
7
  */
8
+ import { alsWort } from "./argumente.js";
7
9
  /**
8
10
  * Aus den Namen der eigenen Firmen die Zeilen bauen, die unter „Deine Firmen:"
9
11
  * stehen.
@@ -42,7 +44,7 @@ export function firmenZeilen(namen) {
42
44
  // Fertiger Befehl statt blosser Liste: Der naechste Schritt ist dann
43
45
  // Kopieren statt Abtippen -- und ein Name mit Leerzeichen braucht
44
46
  // Anfuehrungszeichen, an die sonst niemand denkt.
45
- zeilen.push(` kimal3 firma daten --firma "${name}"`);
47
+ zeilen.push(` kimal3 firma daten --firma ${alsWort(name, true)}`);
46
48
  }
47
49
  else {
48
50
  zeilen.push(` „${name}" — kommt ${wieOft}× vor und ist über den Namen nicht wählbar.`);
@@ -0,0 +1,178 @@
1
+ /*
2
+ Die Fragen aus einer Kontext-Datei lesen -- die reine Rechnung hinter
3
+ `kimal3 kontext fragen` und hinter dem Abbruch von `kimal3 kontext hoch`.
4
+
5
+ 🔑 WOHER DIE FRAGEN KOMMEN -- die eine Entscheidung dieser Datei:
6
+ **Aus der Datei selbst, nicht aus einem Katalog.** Jede ausgelieferte
7
+ Kontext-Datei traegt ihre Fragen bereits vollstaendig bei sich: einen Anker
8
+ (`<!-- k3:marke_anrede -->`), darunter die Frage als `## `-Ueberschrift,
9
+ darunter unsere Hinweiszeilen mit `>`, darunter die Antwort des Menschen.
10
+ Das Format beschreibt sich selbst.
11
+
12
+ 🔴 WARUM DAS NICHT NUR BEQUEM, SONDERN DAS EINZIG EHRLICHE IST: Die CLI kann
13
+ nichts aus `src/` importieren (eigenes Paket, eigenes tsconfig -- s. Kopf von
14
+ `kontext-stand.ts`). Ein Katalog in `cli/src/` waere eine ZWEITE Fassung der
15
+ 17 Fragen, und sie friert in dem Moment ein, in dem `npm publish` laeuft: Der
16
+ Kunde hat dann eine CLI von gestern und Dateien von heute. Die Datei auf
17
+ seiner Platte kommt dagegen IMMER vom Server -- sie ist die einzige Quelle,
18
+ die nicht gegenueber dem abweichen kann, was er wirklich vor sich hat.
19
+
20
+ ⚠️ DIESE DATEI IMPORTIERT NICHTS -- dieselbe Auflage wie `kontext-stand.ts`.
21
+ Nur so kann `scripts/check-kontext.mts` sie neben `FRAGEN` (aus der App) in
22
+ denselben Lauf holen und beide gegeneinander messen.
23
+
24
+ 📌 Der Preis, ausgesprochen: Wer den ANKER einer Frage aus seiner Datei
25
+ loescht, die Ueberschrift aber stehen laesst, hat eine Frage, die hier nicht
26
+ mehr auftaucht. Die App faengt diesen Fall ab (sie erkennt die Frage am
27
+ Wortlaut) und setzt den Anker beim naechsten Speichern wieder ein. Die
28
+ Ausgabe von `kontext fragen` sagt deshalb "Fragen in deinen Dateien" und
29
+ nicht "alle Fragen deiner Firma" -- sie behauptet nur, was sie messen kann.
30
+ */
31
+ /**
32
+ * `<!-- k3:anrede -->` bzw. `<!-- k3:unwoerter optional -->`.
33
+ *
34
+ * ⚠️ Woertlich dasselbe Muster wie `ANKER_MUSTER` in
35
+ * `src/app/firma/kontext-zerlegen.ts`. Es steht hier ein zweites Mal, weil
36
+ * diese Datei nichts importieren darf (s. Kopf) -- dass beide dasselbe meinen,
37
+ * misst `check:kontext`, indem er die ECHTEN Vorlagen durch beide Seiten
38
+ * schickt und die Ergebnisse vergleicht.
39
+ */
40
+ const ANKER_MUSTER = /^<!--\s*k3:([a-z0-9_]+)(\s+optional)?\s*-->$/;
41
+ function istHinweis(zeile) {
42
+ return zeile.trimStart().startsWith(">");
43
+ }
44
+ function istUeberschrift(zeile) {
45
+ return zeile.startsWith("## ");
46
+ }
47
+ /**
48
+ * Alle Fragen, die in `text` stehen -- in der Reihenfolge der Datei.
49
+ *
50
+ * Der Durchgang ist zeilenweise und haelt drei Dinge auseinander:
51
+ * 1. **Anker** -- er merkt sich, wessen Frage als Naechstes kommt.
52
+ * 2. **Ueberschrift** -- steht ein Anker offen, beginnt hier seine Frage.
53
+ * 3. **Darunter** -- die unmittelbar folgenden `>`-Zeilen sind UNSER Hinweis,
54
+ * alles Weitere bis zur naechsten Ueberschrift ist die Antwort.
55
+ *
56
+ * 🔑 DER HINWEIS ZAEHLT NUR UNMITTELBAR UNTER DER UEBERSCHRIFT -- genau wie in
57
+ * `scripts/arbeitsplatz-inhalt.mts`, wo derselbe Katalog fuer die App entsteht.
58
+ * Sonst kaeme eine `>`-Zeile, die ein Mensch mitten in seine Antwort setzt,
59
+ * als unser Hinweis heraus.
60
+ *
61
+ * 🔴 AUS DER ANTWORT FALLEN TROTZDEM ALLE `>`-ZEILEN HERAUS, auch spaetere --
62
+ * das ist die Regel, nach der auch die App zerlegt (`zerlege`). Ein Zitatblock
63
+ * gehoert uns; ginge er als Antwort durch, stuende unser eigener Satz als
64
+ * Aussage der Firma in der Datenbank.
65
+ *
66
+ * ⚠️ Ein Anker OHNE Ueberschrift darunter zaehlt nicht -- er wird vom naechsten
67
+ * Anker oder von der naechsten Ueberschrift ueberschrieben. *Ein Anker, der auf
68
+ * eine Ueberschrift drei Absaetze weiter zeigt, ist kein Anker, sondern ein
69
+ * Zufall, der aussieht wie Absicht.*
70
+ */
71
+ export function fragenAus(text) {
72
+ const zeilen = text.split(/\r?\n/);
73
+ const fragen = [];
74
+ let offenerAnker = null;
75
+ let aktuell = null;
76
+ let hinweisNochOffen = false;
77
+ const hinweise = [];
78
+ const antwort = [];
79
+ /** Die laufende Frage abschliessen -- Hinweis und Antwort festschreiben. */
80
+ const schliesse = () => {
81
+ if (aktuell === null)
82
+ return;
83
+ aktuell.hinweis = hinweise.join(" ").trim();
84
+ aktuell.antwort = antwort.join("\n").trim();
85
+ aktuell = null;
86
+ hinweise.length = 0;
87
+ antwort.length = 0;
88
+ };
89
+ for (const zeile of zeilen) {
90
+ const treffer = ANKER_MUSTER.exec(zeile.trim());
91
+ if (treffer) {
92
+ schliesse();
93
+ offenerAnker = { anker: treffer[1], pflicht: !treffer[2] };
94
+ continue;
95
+ }
96
+ if (istUeberschrift(zeile)) {
97
+ schliesse();
98
+ if (offenerAnker) {
99
+ aktuell = {
100
+ anker: offenerAnker.anker,
101
+ ueberschrift: zeile.slice(3).trim(),
102
+ hinweis: "",
103
+ pflicht: offenerAnker.pflicht,
104
+ antwort: "",
105
+ };
106
+ hinweisNochOffen = true;
107
+ fragen.push(aktuell);
108
+ }
109
+ offenerAnker = null;
110
+ continue;
111
+ }
112
+ /*
113
+ 🔴 **RULING K-14: EIN ANKER, UNTER DEM NICHT UNMITTELBAR SEINE FRAGE
114
+ STEHT, ZAEHLT NICHT.** Leerzeilen sind erlaubt, Text nicht. Ohne diese
115
+ drei Zeilen machte ein verwaister Anker die naechstbeste Ueberschrift zur
116
+ Frage -- und `kimal3 kontext fragen` stellte einem Menschen eine Frage,
117
+ die niemand geschrieben hat. Dieselbe Regel wie im Erzeuger
118
+ (`frageZeileNach` in `scripts/arbeitsplatz-inhalt.mts`).
119
+
120
+ ⚠️ Die App ist an dieser Stelle NACHSICHTIGER: `zerlege` haengt einen
121
+ verwaisten Anker an die naechste Ueberschrift, wie weit sie auch weg ist.
122
+ Der Unterschied ist gewollt und folgenlos, solange keine ausgelieferte
123
+ Datei einen verwaisten Anker hat -- und genau das prueft
124
+ `check:arbeitsplatz` fuer jede Vorlage.
125
+ */
126
+ if (offenerAnker && zeile.trim() !== "")
127
+ offenerAnker = null;
128
+ if (aktuell === null)
129
+ continue;
130
+ if (istHinweis(zeile)) {
131
+ // Nur der Block DIREKT unter der Ueberschrift ist unser Hinweis;
132
+ // spaetere `>`-Zeilen fallen weg, gehoeren aber auch nicht zur Antwort.
133
+ if (hinweisNochOffen)
134
+ hinweise.push(zeile.trimStart().slice(1).trim());
135
+ continue;
136
+ }
137
+ // ⚠️ SCHON EINE LEERZEILE BEENDET DEN HINWEISBLOCK -- woertlich dieselbe
138
+ // Grenze wie im Erzeuger (`katalogAus` in `scripts/arbeitsplatz-inhalt.mts`).
139
+ // Zoege sie hier weiter, kaeme aus einer Datei mit einem Zitat weiter unten
140
+ // ein anderer Hinweis heraus als in der App, und `check:kontext` haette
141
+ // recht, wenn er rot wird.
142
+ hinweisNochOffen = false;
143
+ antwort.push(zeile);
144
+ }
145
+ schliesse();
146
+ return fragen;
147
+ }
148
+ /**
149
+ * Sieht dieser Text aus wie die Datei, die er sein soll?
150
+ *
151
+ * 🔴 **DER ABBRUCH VON `kimal3 kontext hoch` HAENGT AN DIESER ZEILE** (Spec,
152
+ * Abschnitt 11). Ohne sie schickt der Befehl JEDEN Dateiinhalt hoch, den er an
153
+ * der Stelle findet. Die App zerlegt ihn dann gegen den Katalog DIESER Datei,
154
+ * findet keine einzige Antwort -- und legt den kompletten Text als "Zusatz"
155
+ * ab. Die Firma haette danach einen Zusatz statt eines Firmentextes, und die
156
+ * CLI haette dazu einen Haken gemeldet. *Ein Fehlerfall darf nie denselben
157
+ * Namen tragen wie ein Erfolgsfall.*
158
+ *
159
+ * 🔑 **WORAN ERKANNT WIRD: an mindestens einem Anker, der zu DIESER Datei
160
+ * gehoert.** Die Anker sind je Datei benannt (`marke_anrede`, `firma_angebot`,
161
+ * `kurz_kunden` …), und `FIRMEN_TEXTE` (`kontext-stand.ts`) fuehrt je Datei
162
+ * ihren Praefix. Damit faellt nicht nur die voellig fremde Datei durch, sondern
163
+ * auch der haeufigere Unfall: `marke.md` ueber `firma.md` kopiert. Ein blosses
164
+ * "irgendein Anker steht drin" haette genau den durchgewunken.
165
+ *
166
+ * ⚠️ **Dass die Praefixe wirklich passen, ist keine Verabredung, sondern eine
167
+ * Messung:** `check:kontext` haelt jeden Anker aus `FRAGEN` (der App) gegen den
168
+ * Praefix, den `FIRMEN_TEXTE` fuer seine Datei nennt.
169
+ *
170
+ * 📌 **Der Preis, ausgesprochen:** Wer ALLE Anker einer Datei loescht und nur
171
+ * die Ueberschriften stehen laesst, bekommt hier ein Nein, obwohl die App den
172
+ * Text noch am Wortlaut erkennen wuerde. Das ist die sichere Seite: Es geht
173
+ * nichts verloren, die Meldung sagt warum, und `kimal3 kontext runter` stellt
174
+ * die Datei wieder her. Die andere Seite waere der stille Datenverlust oben.
175
+ */
176
+ export function traegtFragenVon(text, ankerPraefix) {
177
+ return fragenAus(text).some((f) => f.anker.startsWith(ankerPraefix));
178
+ }
package/dist/kimal3.js CHANGED
@@ -13,7 +13,7 @@
13
13
  */
14
14
  import { existsSync, readFileSync } from "node:fs";
15
15
  import { BASIS_URL, leseAnmeldung } from "./anmeldung.js";
16
- import { gemerkterHinweis, NichtAngemeldet } from "./api.js";
16
+ import { gemerkterHinweis, lokalerKontextStandJetzt, NichtAngemeldet } from "./api.js";
17
17
  import { leseGesehen, merkeGesehen } from "./gesehen.js";
18
18
  import { formatiere, heuteAus, skillMeldung, skillZustand, sollZeigen, } from "./nachricht.js";
19
19
  import { eigeneVersion } from "./version.js";
@@ -21,6 +21,9 @@ import { hilfeText } from "./befehle-katalog.js";
21
21
  import { arbeitsplatzNeu } from "./befehle/arbeitsplatz.js";
22
22
  import { werkbankNeu } from "./befehle/werkbank.js";
23
23
  import { firmaDaten } from "./befehle/firma-daten.js";
24
+ import { kontextFragen } from "./befehle/kontext-fragen.js";
25
+ import { kontextRunter } from "./befehle/kontext-runter.js";
26
+ import { kontextHoch } from "./befehle/kontext-hoch.js";
24
27
  import { login } from "./befehle/login.js";
25
28
  import { logout } from "./befehle/logout.js";
26
29
  import { projektListe } from "./befehle/projekt-liste.js";
@@ -96,8 +99,26 @@ function hinweisText() {
96
99
  if (eigene === null)
97
100
  return null;
98
101
  const heute = heuteAus(new Date());
102
+ /*
103
+ 🔴 HIER, UND NICHT FRUEHER (offener Punkt A-4). Diese Zeile liest den
104
+ Merkzettel auf der Platte in genau dem Augenblick, in dem der Hinweis
105
+ ausgegeben werden soll -- also NACH dem Befehl.
106
+
107
+ Genau darin lag der Fehler: Den Stand, den der Server fuer seinen
108
+ Hinweis vergleicht, schickt die CLI im Kopf mit, und der wird VOR dem
109
+ Netzaufruf gelesen (`kontextStandKopf` in `api.ts`). Direkt nach
110
+ `kimal3 kontext runter` stand deshalb "Dein Firmenkontext ist aktuell."
111
+ und darunter der Rat, `kimal3 kontext runter` auszufuehren. Der zweite
112
+ Aufruf schwieg dann von selbst -- die Meldung kam schlicht einen Aufruf
113
+ zu spaet.
114
+
115
+ 🔑 `sollZeigen` VERLANGT diesen Wert (kein optionales Argument): Wer ihn
116
+ hier vergisst, bekommt einen Uebersetzungsfehler statt einer Rechnung,
117
+ die niemand mehr benutzt.
118
+ */
119
+ const standJetzt = lokalerKontextStandJetzt();
99
120
  const h = gemerkterHinweis();
100
- if (h && sollZeigen(h, eigene, leseGesehen(), heute)) {
121
+ if (h && sollZeigen(h, eigene, leseGesehen(), heute, standJetzt)) {
101
122
  // Hoechstens EIN Hinweis je Aufruf -- zwei sind eine Wand.
102
123
  return { text: formatiere(h), kennung: h.id, tag: heute };
103
124
  }
@@ -279,6 +300,12 @@ async function haupt() {
279
300
  return arbeitsplatzNeu(argv.slice(2));
280
301
  if (argv[0] === "werkbank" && argv[1] === "neu")
281
302
  return werkbankNeu(argv.slice(2));
303
+ if (argv[0] === "kontext" && argv[1] === "runter")
304
+ return kontextRunter(argv.slice(2));
305
+ if (argv[0] === "kontext" && argv[1] === "hoch")
306
+ return kontextHoch(argv.slice(2));
307
+ if (argv[0] === "kontext" && argv[1] === "fragen")
308
+ return kontextFragen(argv.slice(2));
282
309
  console.error(`Den Befehl „${wort}" kenne ich nicht.\n${HILFE}`);
283
310
  return 1;
284
311
  }
@@ -0,0 +1,248 @@
1
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { istUnsichererPfad } from "./arbeitsplatz-dateien.js";
4
+ import { MERKZETTEL_PFAD, VERWALTETE_DATEIEN, arbeitsplatzAus, leseMerkzettel, schreibeMerkzettel, sicherungsZiel, } from "./kontext-stand.js";
5
+ /**
6
+ * Das Dateisystem hinter `kimal3 kontext runter` -- Ordner finden, die
7
+ * verwalteten Dateien lesen, sichern, schreiben, den Merkzettel lesen und
8
+ * schreiben.
9
+ *
10
+ * 🔴 **Ruling T8-1: Die reine Fundregel `arbeitsplatzAus` wird hier NICHT noch
11
+ * einmal geschrieben** -- sie steht seit Task 1 in `kontext-stand.ts` und wird
12
+ * hier nur VERBRAUCHT (`findeArbeitsplatz` unten fuellt sie mit echten
13
+ * Kandidaten vom Dateisystem). Zwei Fassungen derselben Fundregel waeren
14
+ * dieselbe zweite Wahrheit, die dieses Projekt schon bei `werkbankAusDateien`
15
+ * teuer bezahlt hat.
16
+ *
17
+ * ⚠️ **Dieselben Netze wie in `arbeitsplatz-dateien.ts`**, aus denselben,
18
+ * teuer bezahlten Gruenden: `existsSync`/`statSync`/`readFileSync` gehoeren
19
+ * in ein EIGENES `try` -- ein `EACCES` fluege sonst bis in den Auffang-Block
20
+ * von `kimal3.ts`, der "kimal3 ist gerade nicht erreichbar" druckt und `3`
21
+ * zurueckgibt. Der Mensch suchte den Fehler dann beim Server, obwohl er auf
22
+ * seiner eigenen Platte sitzt -- und `3` heisst "gleich noch einmal", was
23
+ * hier nie helfen kann. Ein Schreibfehler (Platte voll, `EACCES`) gibt
24
+ * deshalb `1` zurueck, nicht `3`.
25
+ */
26
+ /** Ist `pfad` (relativ zu `wurzel`) ein lesbarer ORDNER? Unlesbar zaehlt als "nein". */
27
+ function istLesbarerOrdner(pfad) {
28
+ try {
29
+ return existsSync(pfad) && statSync(pfad).isDirectory();
30
+ }
31
+ catch {
32
+ return false;
33
+ }
34
+ }
35
+ /** Ist `pfad` (relativ zu `wurzel`) eine lesbare DATEI? Unlesbar zaehlt als "nein". */
36
+ function istLesbareDatei(pfad) {
37
+ try {
38
+ return existsSync(pfad) && statSync(pfad).isFile();
39
+ }
40
+ catch {
41
+ return false;
42
+ }
43
+ }
44
+ /**
45
+ * Von `start` aufwaerts nach dem Arbeitsplatz suchen -- die Kandidaten kommen
46
+ * von UNTEN NACH OBEN, `start` zuerst.
47
+ *
48
+ * 🔑 Diese Funktion FUELLT nur die Kandidatenliste vom Dateisystem; die
49
+ * eigentliche Entscheidung ("welcher Ordner gewinnt") trifft `arbeitsplatzAus`
50
+ * in `kontext-stand.ts` (Ruling P-4/T8-1).
51
+ *
52
+ * Die Schleife hat eine Sicherheitsgrenze (200 Ebenen): `dirname()` konvergiert
53
+ * auf jedem realen Dateisystem zur Wurzel, aber ein Netzlaufwerk mit einem
54
+ * kaputten Elternverweis soll trotzdem nie eine Endlosschleife erzeugen.
55
+ */
56
+ export function findeArbeitsplatz(start) {
57
+ const kandidaten = [];
58
+ let aktuell = resolve(start);
59
+ for (let i = 0; i < 200; i++) {
60
+ kandidaten.push({
61
+ pfad: aktuell,
62
+ hatKontext: istLesbarerOrdner(join(aktuell, "kontext")),
63
+ hatClaude: istLesbareDatei(join(aktuell, "CLAUDE.md")),
64
+ });
65
+ const eltern = dirname(aktuell);
66
+ if (eltern === aktuell)
67
+ break;
68
+ aktuell = eltern;
69
+ }
70
+ return arbeitsplatzAus(kandidaten);
71
+ }
72
+ /**
73
+ * Eine der verwalteten Dateien lesen -- fehlend wird zu `inhalt: null`,
74
+ * NICHT zu einem Fehler (ein frisch geklonter Ordner hat oft noch keine).
75
+ */
76
+ export function leseEineDatei(ordner, pfad) {
77
+ const ort = join(ordner, ...pfad.split("/"));
78
+ try {
79
+ if (!existsSync(ort))
80
+ return { ok: true, inhalt: null };
81
+ if (!statSync(ort).isFile()) {
82
+ return {
83
+ ok: false,
84
+ meldung: `„${pfad}" existiert, ist aber keine Datei -- ich fasse das nicht an. Bitte pruefen.`,
85
+ };
86
+ }
87
+ return { ok: true, inhalt: readFileSync(ort, "utf8") };
88
+ }
89
+ catch (fehler) {
90
+ return {
91
+ ok: false,
92
+ meldung: `Ich komme an „${pfad}" nicht heran: ${fehler instanceof Error ? fehler.message : String(fehler)}. ` +
93
+ "Das liegt an diesem Rechner, nicht am Server -- Rechte pruefen.",
94
+ };
95
+ }
96
+ }
97
+ /**
98
+ * Den lokalen Merkzettel lesen. Unlesbar oder nicht vorhanden -> leeres
99
+ * Objekt, GENAU wie `leseMerkzettel(null)` -- der sichere Fall aus §7b: "wer
100
+ * nichts weiss, loescht nichts". Eine unlesbare Datei fuehrt also zur
101
+ * Sicherung vor dem Ueberschreiben, nie zum stillen Verlust.
102
+ */
103
+ export function leseMerkzettelDatei(ordner) {
104
+ try {
105
+ const ort = join(ordner, MERKZETTEL_PFAD);
106
+ const roh = existsSync(ort) ? readFileSync(ort, "utf8") : null;
107
+ return leseMerkzettel(roh);
108
+ }
109
+ catch {
110
+ return leseMerkzettel(null);
111
+ }
112
+ }
113
+ /** Den Merkzettel schreiben -- MIT dem Stand, der zum ausgelieferten Inhalt passt. */
114
+ export function schreibeMerkzettelDatei(ordner, m) {
115
+ try {
116
+ const ort = join(ordner, MERKZETTEL_PFAD);
117
+ writeFileSync(ort, schreibeMerkzettel(m), "utf8");
118
+ return { ok: true };
119
+ }
120
+ catch (fehler) {
121
+ return {
122
+ ok: false,
123
+ meldung: `Der Merkzettel liess sich nicht schreiben: ${fehler instanceof Error ? fehler.message : String(fehler)}. Das liegt an diesem Rechner, nicht am Server -- Platz und Rechte pruefen.`,
124
+ };
125
+ }
126
+ }
127
+ /**
128
+ * Der naechste NICHT belegte Sicherungspfad -- reine Rechnung, kein
129
+ * Dateisystem. `istBelegt(pfad)` sagt, ob dieser Pfad schon existiert (der
130
+ * Aufrufer entscheidet, WIE das geprueft wird); diese Funktion zaehlt vom
131
+ * Basisnamen aus hoch, bis eine freie Stelle gefunden ist.
132
+ *
133
+ * 🔴 **Ruling T8-2 (Review-Fund 1, Fix-Runde 1).** Vorher gab `sichereDatei`
134
+ * bei einem belegten Ziel `{ uebersprungen: true }` zurueck und schrieb dabei
135
+ * GAR NICHTS -- eine zweite Aenderung am selben Tag verschwand beim
136
+ * anschliessenden Ueberschreiben spurlos, waehrend die Ausgabe im Befehl
137
+ * "gesichert" behauptete. **Diese Funktion darf deshalb NIE einen belegten
138
+ * Pfad als Endergebnis zurueckgeben und NIE einen Pfad, der schon existiert,
139
+ * unkommentiert ueberschreiben lassen** -- sie liefert IMMER einen Pfad, der
140
+ * (laut `istBelegt`) frei ist.
141
+ *
142
+ * ⚠️ **Die erste Sicherung eines Tages -- der Basisname -- bleibt fuer immer
143
+ * unangetastet.** Das ist US-7 woertlich: "Laeuft der Befehl am selben Tag
144
+ * ein zweites Mal, wird die erste Sicherung NICHT ueberschrieben." Wer diese
145
+ * Funktion vereinfacht ("dann eben doch den Basisnamen nehmen, wenn `nummer`
146
+ * gross wird") loescht genau die Sicherung, die US-7 ausdruecklich schuetzt.
147
+ * Eine dritte, vierte, ... Aenderung bekommt stattdessen einen weiteren,
148
+ * eigenen Namen daneben -- nie einen schon vergebenen.
149
+ */
150
+ export function naechstesFreiesSicherungsziel(basisZielRel, istBelegt) {
151
+ if (!istBelegt(basisZielRel))
152
+ return basisZielRel;
153
+ for (let nummer = 2; nummer <= 1000; nummer++) {
154
+ const kandidat = nummeriertesZiel(basisZielRel, nummer);
155
+ if (!istBelegt(kandidat))
156
+ return kandidat;
157
+ }
158
+ /*
159
+ Praktisch unerreichbar (1000 Sicherungen derselben Datei am selben Tag).
160
+ Diese reine Funktion wirft nicht -- sie liefert den letzten geprueften
161
+ Namen, und der Aufrufer (`sichereDatei`) trifft beim tatsaechlichen
162
+ Schreiben erneut auf `existsSync` und meldet dort einen echten Fehler,
163
+ statt dass eine reine Rechnung selbst eine Ausnahme auslöst.
164
+ */
165
+ return nummeriertesZiel(basisZielRel, 1000);
166
+ }
167
+ /**
168
+ * `kontext/vorher-2026-08-21/firma.md` + 2 -> `.../firma-2.md`.
169
+ *
170
+ * Nur der DATEINAME bekommt die Nummer, nicht der Ordner -- alle
171
+ * verwalteten Dateien landen weiter FLACH im selben `vorher-<datum>/`
172
+ * (unveraendert gegenueber `sicherungsZiel`).
173
+ */
174
+ function nummeriertesZiel(basisZielRel, nummer) {
175
+ const teile = basisZielRel.split("/");
176
+ const dateiname = teile.pop() ?? "";
177
+ const punkt = dateiname.lastIndexOf(".");
178
+ const name = punkt === -1 ? dateiname : dateiname.slice(0, punkt);
179
+ const endung = punkt === -1 ? "" : dateiname.slice(punkt);
180
+ return [...teile, `${name}-${nummer}${endung}`].join("/");
181
+ }
182
+ /**
183
+ * Die lokale Fassung von `pfad` nach `kontext/vorher-<datum>/` sichern --
184
+ * FLACH, mit dem Basisnamen (`sicherungsZiel`), oder -- ist der schon belegt
185
+ * -- unter einem durchnummerierten Namen DANEBEN (`naechstesFreiesSicherungsziel`,
186
+ * Ruling T8-2).
187
+ *
188
+ * 🔴 **Es wird NIE uebersprungen und NIE ueberschrieben.** Beides waere eine
189
+ * stille Datenvernichtung: Ueberspringen ohne Kopie loescht die zweite
190
+ * Aenderung eines Tages beim anschliessenden Ueberschreiben der Arbeitsdatei;
191
+ * Ueberschreiben der ersten Sicherung verstoesst woertlich gegen US-7.
192
+ * `nebenVorhandener: true` im Ergebnis sagt dem Aufrufer, dass HEUTE schon
193
+ * eine andere Sicherung derselben Datei existiert -- er muss das in der
194
+ * Ausgabe unterscheiden (§8: kein Fehlerfall traegt den Namen eines
195
+ * Erfolgsfalls, und hier sind es zwei verschiedene ECHTE Erfolgsfaelle).
196
+ */
197
+ export function sichereDatei(ordner, pfad, inhalt, datum) {
198
+ const basisZielRel = sicherungsZiel(datum, pfad);
199
+ const istBelegt = (zielRel) => existsSync(join(ordner, ...zielRel.split("/")));
200
+ const zielRel = naechstesFreiesSicherungsziel(basisZielRel, istBelegt);
201
+ const ort = join(ordner, ...zielRel.split("/"));
202
+ try {
203
+ mkdirSync(dirname(ort), { recursive: true });
204
+ writeFileSync(ort, inhalt, "utf8");
205
+ return { ok: true, ziel: zielRel, nebenVorhandener: zielRel !== basisZielRel };
206
+ }
207
+ catch (fehler) {
208
+ return {
209
+ ok: false,
210
+ meldung: `Sichern von „${pfad}" ist fehlgeschlagen: ${fehler instanceof Error ? fehler.message : String(fehler)}. Das liegt an diesem Rechner, nicht am Server -- Platz und Rechte pruefen.`,
211
+ };
212
+ }
213
+ }
214
+ /**
215
+ * Eine der verwalteten Dateien schreiben.
216
+ *
217
+ * 🔴 **Positivliste, keine Verbotsliste** (Auflage aus dem Brief): `pfad` muss
218
+ * WOERTLICH einer der Eintraege aus `VERWALTETE_DATEIEN` sein, sonst wird
219
+ * NICHTS geschrieben. Diese Funktion bekommt `pfad` im echten Ablauf immer aus
220
+ * der eigenen Iteration ueber `VERWALTETE_DATEIEN` (nie roh aus der Server-
221
+ * Antwort) -- die Pruefung hier ist trotzdem ausdruecklich verlangt und bleibt
222
+ * als zweites Netz stehen, falls sich der Aufrufer einmal aendert.
223
+ *
224
+ * ⚠️ `istUnsichererPfad` zusaetzlich, aus demselben Grund wie in
225
+ * `dateienSchreiben` (`arbeitsplatz-dateien.ts`): Ein Pfad kommt letztlich aus
226
+ * einer Server-Antwort, und eine Pruefung, die nur dort gilt, wo man sie
227
+ * gerade braucht, ist keine Pruefung dieser Funktion.
228
+ */
229
+ export function schreibeVerwalteteDatei(ordner, pfad, inhalt) {
230
+ if (!VERWALTETE_DATEIEN.includes(pfad)) {
231
+ return { ok: false, meldung: `„${pfad}" ist keine verwaltete Datei -- wird nicht geschrieben. Bitte melden.` };
232
+ }
233
+ if (istUnsichererPfad(pfad)) {
234
+ return { ok: false, meldung: `„${pfad}" ist kein sicherer Pfad -- wird nicht geschrieben. Bitte melden.` };
235
+ }
236
+ const ort = join(ordner, ...pfad.split("/"));
237
+ try {
238
+ mkdirSync(dirname(ort), { recursive: true });
239
+ writeFileSync(ort, inhalt, "utf8");
240
+ return { ok: true };
241
+ }
242
+ catch (fehler) {
243
+ return {
244
+ ok: false,
245
+ meldung: `Schreiben von „${pfad}" ist fehlgeschlagen: ${fehler instanceof Error ? fehler.message : String(fehler)}. Das liegt an diesem Rechner, nicht am Server -- Platz und Rechte pruefen.`,
246
+ };
247
+ }
248
+ }