kimal3 0.1.5 β†’ 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/kimal3.js CHANGED
@@ -11,17 +11,21 @@
11
11
  * `kimal3 login`, bei 4 hilft genau das nicht. Wer beide auf dieselbe Zahl
12
12
  * legt, laesst Claude den falschen Rat geben.
13
13
  */
14
- import { readFileSync } from "node:fs";
14
+ import { existsSync, readFileSync } from "node:fs";
15
15
  import { BASIS_URL, leseAnmeldung } from "./anmeldung.js";
16
- import { NichtAngemeldet } from "./api.js";
16
+ import { gemerkterHinweis, NichtAngemeldet } from "./api.js";
17
+ import { leseGesehen, merkeGesehen } from "./gesehen.js";
18
+ import { formatiere, heuteAus, skillMeldung, skillZustand, sollZeigen, } from "./nachricht.js";
19
+ import { eigeneVersion } from "./version.js";
17
20
  import { hilfeText } from "./befehle-katalog.js";
18
21
  import { arbeitsplatzNeu } from "./befehle/arbeitsplatz.js";
22
+ import { werkbankNeu } from "./befehle/werkbank.js";
19
23
  import { firmaDaten } from "./befehle/firma-daten.js";
20
24
  import { login } from "./befehle/login.js";
21
25
  import { logout } from "./befehle/logout.js";
22
26
  import { projektListe } from "./befehle/projekt-liste.js";
23
27
  import { projektNeu } from "./befehle/projekt-neu.js";
24
- import { skillInstall } from "./befehle/skill-install.js";
28
+ import { skillDatei, skillInstall } from "./befehle/skill-install.js";
25
29
  // πŸ“Œ Kein handgeschriebener Block mehr (2026-08-18): Die Liste wohnt in
26
30
  // `befehle-katalog.ts` und wird von dort AUCH im Pro-Bereich der App angezeigt.
27
31
  // Zwei Listen ueber dieselben Befehle driften β€” die eine bekaeme den naechsten
@@ -57,20 +61,167 @@ const HILFE = hilfeText();
57
61
  * eine Ebene unter `cli/`.
58
62
  */
59
63
  function versionsZeilen() {
60
- let version = "unbekannt";
64
+ // πŸ“Œ Seit dem 2026-08-21 wird die Nummer NICHT mehr hier gelesen, sondern in
65
+ // `version.ts` -- sie hat einen zweiten Aufrufer bekommen (den Skill-Stempel
66
+ // in `skill install`). Zwei Lesestellen fuer dieselbe Zahl waeren genau das
67
+ // Muster, gegen das der Kommentar oben schon einmal geschrieben wurde.
68
+ //
69
+ // "unbekannt" bleibt hier stehen und wandert NICHT in `version.ts`: Diese
70
+ // Zeile zeigt einem Menschen etwas an, und da ist "unbekannt" die ehrliche
71
+ // Antwort. Der andere Aufrufer RECHNET mit dem Wert und braucht dort das
72
+ // Gegenteil -- er will lieber gar keine Nummer als eine erfundene.
73
+ const version = eigeneVersion() ?? "unbekannt";
74
+ return ` kimal3 ${version}\n spricht mit ${BASIS_URL}`;
75
+ }
76
+ /**
77
+ * Welcher Update-Hinweis soll ans Ende -- oder keiner?
78
+ *
79
+ * πŸ”‘ WARUM stderr (entschieden wird es erst beim Schreiben, gemeint ist es
80
+ * hier): Dort stoert der Hinweis keine Weiterverarbeitung --
81
+ * `kimal3 firma daten > firma.json` bleibt sauberes JSON --, und Claude sieht
82
+ * ihn trotzdem, denn es liest beide Kanaele.
83
+ *
84
+ * πŸ”΄ WARUM ALLES IN EINEM try STECKT: Diese Funktion laeuft NACH dem
85
+ * eigentlichen Befehl. Wenn sie wirft, faellt ein bereits erfolgreicher Befehl
86
+ * auf Rueckgabewert 3 -- und Claude gaebe den Rat "gleich noch einmal" fuer
87
+ * etwas, das laengst erledigt ist. Die Zahlen sind laut dem Kopf dieser Datei
88
+ * die eigentliche Schnittstelle; ein Nebenschauplatz darf sie nicht anfassen.
89
+ */
90
+ function hinweisText() {
61
91
  try {
62
- const roh = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
63
- if (roh && typeof roh === "object" && "version" in roh) {
64
- const v = roh.version;
65
- if (typeof v === "string")
66
- version = v;
92
+ const eigene = eigeneVersion();
93
+ // Ohne eigene Nummer ist jeder Vergleich geraten -- dann lieber schweigen.
94
+ // Das ist der Fall "package.json unlesbar", und da hat der Kunde groessere
95
+ // Sorgen als einen Update-Hinweis.
96
+ if (eigene === null)
97
+ return null;
98
+ const heute = heuteAus(new Date());
99
+ const h = gemerkterHinweis();
100
+ if (h && sollZeigen(h, eigene, leseGesehen(), heute)) {
101
+ // Hoechstens EIN Hinweis je Aufruf -- zwei sind eine Wand.
102
+ return { text: formatiere(h), kennung: h.id, tag: heute };
103
+ }
104
+ /*
105
+ Weg 2: ohne Netz.
106
+ ⚠️ Er greift immer dann, wenn oben NICHTS gezeigt wird -- nicht nur,
107
+ wenn gar kein Netz-Hinweis ankam (praezisiert nach der dritten
108
+ Review-Runde). Auch "die Nachricht stand heute schon da" und "sie gilt
109
+ fuer diese Fassung nicht" fuehren hierher. Das ist gewollt: Es bleibt
110
+ bei einem Hinweis je Aufruf.
111
+ *Hier stand vorher "nur, wenn kein Netz-Hinweis kam" -- eine Bedingung,
112
+ die der Code nicht hat. Genau der Kommentartyp, den dieses Projekt schon
113
+ zweimal teuer bezahlt hat.*
114
+ */
115
+ // Der Pfad kommt aus `skill-install.ts` -- dort wird er geschrieben, hier
116
+ // gelesen. Zwei Zusammenbauten desselben Ortes driften, und der zweite
117
+ // verrottet lautlos (Befund der vierten Review-Runde).
118
+ const datei = skillDatei();
119
+ const inhalt = existsSync(datei) ? readFileSync(datei, "utf8") : null;
120
+ if (skillZustand(inhalt, eigene) === "veraltet") {
121
+ // Auch dieser Hinweis nur einmal am Tag, unter einer festen Kennung.
122
+ // Sie kann mit keiner Nachrichten-UUID kollidieren.
123
+ const gesehen = leseGesehen();
124
+ if (gesehen["skill-veraltet"] !== heute) {
125
+ return { text: skillMeldung(), kennung: "skill-veraltet", tag: heute };
126
+ }
67
127
  }
68
128
  }
69
129
  catch {
70
- // Kein Absturz, wenn die Datei fehlt: "unbekannt" ist eine ehrliche
71
- // Antwort, ein Stapelabzug waere keine.
130
+ /* Ein Nebenschauplatz darf den Befehl nicht umwerfen. */
131
+ }
132
+ return null;
133
+ }
134
+ /**
135
+ * Den Hinweis schreiben und ERST DANN aussteigen.
136
+ *
137
+ * πŸ”΄ WARUM NICHT EINFACH `console.error` UND `process.exit`: Auf Windows ist
138
+ * `process.stderr` auch bei Roehren und Terminals ASYNCHRON, und
139
+ * `process.exit()` wirft ausstehende Schreibvorgaenge weg (Befund der ersten
140
+ * Review-Runde). Der Hinweis ist das Letzte, was geschrieben wird -- er waere
141
+ * damit genau das Erste, was verlorengeht. *Ein Merkmal, dessen ganzer Wert an
142
+ * der letzten Zeile haengt, darf diese Zeile nicht dem Zufall ueberlassen.*
143
+ *
144
+ * `process.exitCode` wird vorher gesetzt: Sollte der Rueckruf wider Erwarten
145
+ * ausbleiben und der Prozess von selbst enden, stimmt die Zahl trotzdem -- und
146
+ * die Zahl ist laut dem Kopf dieser Datei die eigentliche Schnittstelle.
147
+ */
148
+ function endeMitHinweis(code) {
149
+ try {
150
+ process.exitCode = code;
151
+ /*
152
+ πŸ”΄ NUR NACH EINEM GELUNGENEN BEFEHL (Befund der sechsten Review-Runde).
153
+ `haupt()` WIRFT bei den meisten Fehlern nicht, sondern gibt eine Zahl
154
+ zurueck -- 1 fuer einen unbekannten Befehl, 2 und 4 aus den Befehlen
155
+ selbst. Der Hinweis landete damit unter Fehlermeldungen, obwohl der
156
+ `.catch`-Zweig unten und der Wrapper auf dem Server beide das Gegenteil
157
+ begruenden: "Eine Fehlermeldung ist der schlechteste Augenblick fuer eine
158
+ Nebenbemerkung."
159
+
160
+ ⚠️ Schlimmer als die Optik ist die Nebenwirkung: Der gescheiterte Lauf
161
+ verbrauchte den EINEN Platz des Tages. Wer sich vertippt und danach
162
+ richtig tippt, saehe die Nachricht dann heute gar nicht mehr.
163
+ */
164
+ const raus = code === 0 ? hinweisText() : null;
165
+ /*
166
+ πŸ”΄ BEIDE KANAELE LEEREN, NICHT NUR STDERR. Die erste Fassung dieser
167
+ Funktion sicherte stderr -- und liess den haeufigeren Weg (kein Hinweis)
168
+ bei einem nackten `process.exit` stehen. Genau der Fehler, gegen den sie
169
+ geschrieben war, stand also noch daneben.
170
+
171
+ Es trifft naemlich vor allem STDOUT: `firma daten` schreibt ein
172
+ mehrere Kilobyte grosses JSON mit `console.log` und landet dann hier.
173
+ Auf Windows sind beide Kanaele asynchron; `process.exit()` wirft
174
+ Ausstehendes weg. Das Ergebnis waere das schlimmstmoegliche: ein
175
+ abgeschnittenes JSON MIT Rueckgabewert 0 -- Claude liest es als
176
+ gelungene Auskunft.
177
+
178
+ πŸ”‘ Warum ueberhaupt noch `process.exit` und kein natuerliches Ende:
179
+ Nach einem `fetch` haelt Node seine Verbindungen eine Weile offen. Der
180
+ Befehl wuerde sekundenlang scheinbar haengen, nachdem er fertig ist.
181
+
182
+ Die Notbremse ist `unref`t: Sie haelt die Schleife nicht am Leben,
183
+ feuert aber, falls ein Kanal wirklich haengt. Und weil `exitCode` schon
184
+ oben steht, stimmt die Zahl in JEDEM dieser Wege -- auch dann, wenn der
185
+ Prozess von selbst endet.
186
+
187
+ πŸ”΄ UND DIE REIHENFOLGE IST DER PUNKT (Befund der sechsten Review-Runde):
188
+ ERST stdout leeren, DANN den Hinweis auf stderr schreiben. Die beiden
189
+ Kanaele haben eigene Puffer, und ihre Reihenfolge ist nur dann sicher,
190
+ wenn beide synchron sind -- auf einer POSIX-Roehre ist stdout es NICHT.
191
+ Bei `kimal3 firma daten 2>&1 | tee firma.json` mit mehr als 64 KiB
192
+ Nutzlast koennte die kleine stderr-Zeile also VOR dem grossen JSON
193
+ ankommen und mitten hineinrutschen. Genau das verbietet US-1 ("nie
194
+ mittendrin"), und es waere eine Ausgabe, die SKILL.md als Firmendaten
195
+ zu lesen anweist.
196
+ πŸ“Œ Auf Windows liess es sich nicht herstellen -- dort sind Roehren
197
+ synchron. *Eine Reihenfolge, die nur auf dem Rechner haelt, auf dem man
198
+ sie gemessen hat, ist keine Reihenfolge, sondern ein Zufall.*
199
+ */
200
+ setTimeout(() => process.exit(code), 2000).unref();
201
+ process.stdout.write("", () => {
202
+ if (!raus) {
203
+ process.stderr.write("", () => process.exit(code));
204
+ return;
205
+ }
206
+ // Erst schreiben, DANN vermerken -- und nur, wenn kein Fehler zurueckkam.
207
+ process.stderr.write(`${raus.text}\n`, (fehler) => {
208
+ if (!fehler)
209
+ merkeGesehen(raus.kennung, raus.tag);
210
+ process.exit(code);
211
+ });
212
+ });
213
+ }
214
+ catch {
215
+ /*
216
+ ⚠️ Auch das Schreiben selbst kann werfen -- ein geschlossener
217
+ Ausgabekanal genuegt. Ohne dieses `catch` liefe der Wurf in den
218
+ `.catch`-Zweig unten, und der macht aus einem GELUNGENEN Befehl eine
219
+ 3 ("gleich noch einmal"). Das ist genau das, was der Kommentar an
220
+ `hinweisText` verspricht, dass es nie passiert -- versprochen war es
221
+ dort aber nur fuer die halbe Strecke.
222
+ */
223
+ process.exit(code);
72
224
  }
73
- return ` kimal3 ${version}\n spricht mit ${BASIS_URL}`;
74
225
  }
75
226
  /**
76
227
  * Was diese Zeile sagen KANN β€” und was nicht.
@@ -126,11 +277,16 @@ async function haupt() {
126
277
  return skillInstall();
127
278
  if (argv[0] === "arbeitsplatz" && argv[1] === "neu")
128
279
  return arbeitsplatzNeu(argv.slice(2));
280
+ if (argv[0] === "werkbank" && argv[1] === "neu")
281
+ return werkbankNeu(argv.slice(2));
129
282
  console.error(`Den Befehl β€ž${wort}" kenne ich nicht.\n${HILFE}`);
130
283
  return 1;
131
284
  }
132
285
  haupt()
133
- .then((code) => process.exit(code))
286
+ .then((code) => endeMitHinweis(code))
287
+ // ⚠️ Im Fehlerzweig bewusst KEIN Hinweis. Wer gerade eine Fehlermeldung
288
+ // liest, braucht keine zweite Zeile ueber Updates -- und ein Netz-Hinweis
289
+ // kann hier ohnehin nicht angekommen sein, denn genau das Netz hat versagt.
134
290
  .catch((f) => {
135
291
  if (f instanceof NichtAngemeldet) {
136
292
  console.error("Bitte zuerst `kimal3 login` ausfΓΌhren.");
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Die reinen Rechnungen des Update-Kanals -- und sonst nichts.
3
+ *
4
+ * πŸ”‘ WARUM DIESE DATEI NICHTS ANFASST: Kein fetch, kein readFileSync, kein
5
+ * `new Date()` ohne Uebergabe. Genau deshalb kann `npm run check:nachrichten`
6
+ * Faelle pruefen, die es in der Wirklichkeit nur durch Warten gaebe --
7
+ * "gestern gesehen", "0.10.0 gegen 0.9.0", "Stempel aus der Zukunft".
8
+ * Die Raender (Datei, Netz, Uhr) wohnen in gesehen.ts, api.ts und kimal3.ts.
9
+ */
10
+ /** Der Stempel, den `kimal3 skill install` ans Ende der SKILL.md schreibt. */
11
+ export const STEMPEL_MARKE = "kimal3:erzeugt version=";
12
+ /**
13
+ * Zieht den Hinweis aus einer Server-Antwort -- oder gibt `null` zurueck.
14
+ *
15
+ * ⚠️ Jedes Feld einzeln pruefen, obwohl der Server unserer ist. Diese Zeilen
16
+ * laufen auf dem Rechner des Kunden und muessen auch dann halten, wenn dort
17
+ * eine ALTE CLI auf eine NEUE Antwort trifft oder umgekehrt. Genau dieser
18
+ * Versatz ist bei einem Werkzeug, das man selbst aktualisieren muss, der
19
+ * Normalfall -- kein Sonderfall.
20
+ */
21
+ export function hinweisAusAntwort(koerper) {
22
+ if (!koerper || typeof koerper !== "object")
23
+ return null;
24
+ const roh = koerper.hinweis;
25
+ if (!roh || typeof roh !== "object")
26
+ return null;
27
+ const h = roh;
28
+ if (typeof h.id !== "string" || typeof h.text !== "string")
29
+ return null;
30
+ return {
31
+ id: h.id,
32
+ text: h.text,
33
+ gilt_unter_version: typeof h.gilt_unter_version === "string" ? h.gilt_unter_version : null,
34
+ };
35
+ }
36
+ function teile(v) {
37
+ /*
38
+ ⚠️ Ein Anhaengsel wie `-rc.1` oder `+build3` faellt vorher weg (Befund der
39
+ ersten Review-Runde). Ohne diese Zeile legt ein einziges `npm version
40
+ prerelease` den GANZEN Kanal still: `eigeneVersion()` gaebe "0.2.0-rc.1"
41
+ zurueck -- nicht `null`, also faehrt `hinweisText` weiter --, aber jeder
42
+ Vergleich antwortete `false`. Kein Netz-Hinweis, kein Skill-Hinweis, keine
43
+ Fehlermeldung. *Ein Merkmal, das sich lautlos abschaltet, faellt niemandem
44
+ auf.*
45
+
46
+ πŸ”‘ Bewusst NICHT nach Semver sortiert (dort gilt `0.2.0-rc.1` < `0.2.0`):
47
+ Diese Rechnung beantwortet "soll ich jemandem ein Update anbieten", und da
48
+ ist eine Vorabversion nah genug an ihrer Fassung. Die Genauigkeit waere
49
+ Aufwand fuer einen Fall, den es hier noch nie gab -- YAGNI.
50
+ */
51
+ const stuecke = v.trim().split(/[-+]/)[0].split(".");
52
+ if (stuecke.length === 0)
53
+ return null;
54
+ const zahlen = [];
55
+ for (const s of stuecke) {
56
+ // ⚠️ Absichtlich streng: `Number("1a")` ist NaN, aber `parseInt("1a")` ist 1.
57
+ // Wer hier `parseInt` nimmt, macht aus dem Muellwert "bald" eine 0 und
58
+ // vergleicht dann munter weiter.
59
+ if (!/^\d+$/.test(s))
60
+ return null;
61
+ zahlen.push(Number(s));
62
+ }
63
+ return zahlen;
64
+ }
65
+ /**
66
+ * Ist `a` aelter als `b`? Zahl fuer Zahl, nicht als Text.
67
+ *
68
+ * πŸ”΄ DER FALL, DER DIESE FUNKTION RECHTFERTIGT: `"0.10.0" < "0.9.0"` ist als
69
+ * Textvergleich WAHR. Wer so vergleicht, haelt die neueste Fassung fuer die
70
+ * aeltere und drangsaliert genau die Kunden, die aktuell sind.
71
+ *
72
+ * Ist einer der beiden Werte kein Versionsstand, lautet die Antwort `false` --
73
+ * "im Zweifel schweigen" ist bei einem Nebenschauplatz die richtige Vorgabe.
74
+ */
75
+ export function istAelter(a, b) {
76
+ const x = teile(a);
77
+ const y = teile(b);
78
+ if (!x || !y)
79
+ return false;
80
+ const laenge = Math.max(x.length, y.length);
81
+ for (let i = 0; i < laenge; i++) {
82
+ const links = x[i] ?? 0;
83
+ const rechts = y[i] ?? 0;
84
+ if (links !== rechts)
85
+ return links < rechts;
86
+ }
87
+ return false;
88
+ }
89
+ /**
90
+ * Welcher Tag ist in Deutschland gerade?
91
+ *
92
+ * ⚠️ NICHT `toISOString().slice(0,10)` -- das ist UTC. Um 00:30 deutscher Zeit
93
+ * waere "heute" der Vortag, und die Nachricht erschiene an einem Abend zweimal.
94
+ * `sv-SE` liefert von sich aus `JJJJ-MM-TT`; dieselbe Technik wie
95
+ * `heuteInDeutschland` in der App. Importieren laesst sie sich nicht --
96
+ * `cli/tsconfig.json` steht auf `rootDir: src` (Begruendung steht schon in
97
+ * projekt-zeilen.ts).
98
+ */
99
+ export function heuteAus(d) {
100
+ return new Intl.DateTimeFormat("sv-SE", { timeZone: "Europe/Berlin" }).format(d);
101
+ }
102
+ /**
103
+ * Die ganze Entscheidung, als Funktion von vier Werten.
104
+ */
105
+ export function sollZeigen(h, eigeneVersion, gesehen, heute) {
106
+ if (!h.id)
107
+ return false;
108
+ if (!h.text || h.text.trim() === "")
109
+ return false;
110
+ // Leeres Feld = alle sehen es. Sonst nur, wer aelter ist als die Grenze.
111
+ if (h.gilt_unter_version && !istAelter(eigeneVersion, h.gilt_unter_version))
112
+ return false;
113
+ // Hoechstens einmal am Tag -- auch bei zwanzig Befehlen.
114
+ if (gesehen[h.id] === heute)
115
+ return false;
116
+ return true;
117
+ }
118
+ /**
119
+ * Die Zeilen fuers Terminal.
120
+ *
121
+ * Bewusst Klartext mit `β†’`, wie der Rest der CLI: `cli/` hat null
122
+ * Abhaengigkeiten und nirgends ANSI-Farben. Ein gezeichneter Kasten braeche
123
+ * ausserdem in einem schmalen Terminal auseinander -- ein eingerueckter Satz
124
+ * nicht.
125
+ */
126
+ export function formatiere(h) {
127
+ return `\n β†’ ${saeubere(h.text)}\n`;
128
+ }
129
+ /**
130
+ * Macht aus dem Nachrichtentext EINE harmlose Zeile.
131
+ *
132
+ * πŸ”΄ WARUM UEBERHAUPT (Befund der vierten Review-Runde): Der Text kommt aus
133
+ * der Datenbank und wird in ein TERMINAL geschrieben. Steuerzeichen sind dort
134
+ * keine Zeichen, sondern Befehle -- eine ANSI-Fluchtfolge kann faerben, den
135
+ * Bildschirm loeschen oder den Wagen zurueckholen und damit Zeilen
136
+ * ueberschreiben, die schon dastanden. Der Kanal wuerde dann nicht mehr nur
137
+ * etwas HINZUFUEGEN, sondern die Ausgabe des Befehls veraendern.
138
+ *
139
+ * ⚠️ Und Zeilenumbrueche muessen weg, weil die Ausgabe genau eine eingerueckte
140
+ * Zeile ist: Ein "\n" mitten im Text schoebe den Rest linksbuendig darunter --
141
+ * es saehe aus wie Ausgabe des Befehls, nicht wie ein Hinweis.
142
+ *
143
+ * πŸ“Œ Die Pruefung dazu stand vorher da und konnte nicht rot werden: Sie mass
144
+ * einen Vordruck ohne Steuerzeichen. *Eine gruene Pruefung ueber einen Wert,
145
+ * der nie anders aussieht, prueft den Vordruck, nicht den Code.*
146
+ */
147
+ function saeubere(text) {
148
+ return text
149
+ .replace(/[\u0000-\u001f\u007f-\u009f]/g, " ")
150
+ .replace(/\s+/g, " ")
151
+ .trim();
152
+ }
153
+ /**
154
+ * Zieht `0.1.6` aus `<!-- kimal3:erzeugt version=0.1.6 -->`.
155
+ *
156
+ * πŸ”΄ DER LETZTE TREFFER GILT, nicht der erste (Befund der fuenften
157
+ * Review-Runde). Der echte Stempel steht immer am DATEIENDE -- aber die
158
+ * SKILL.md ist ein Text ueber dieses Werkzeug und beschreibt inzwischen selbst,
159
+ * wie der Kanal funktioniert. Nennt sie eines Tages ein Beispiel wie
160
+ * `version=9.9.9`, gewaenne der Beispieltext gegen den echten Stempel:
161
+ * `skillZustand` antwortete fuer immer "still", und Weg 2 waere lautlos tot.
162
+ *
163
+ * *Ein Dokument, das seine eigene Maschinenlesbarkeit beschreibt, wird
164
+ * irgendwann versehentlich zu seinen eigenen Daten.*
165
+ */
166
+ export function stempelAus(inhalt) {
167
+ const treffer = [...inhalt.matchAll(/kimal3:erzeugt version=([0-9][0-9.]*)/g)];
168
+ return treffer.length > 0 ? treffer[treffer.length - 1][1] : null;
169
+ }
170
+ /**
171
+ * Die DREI Zustaende aus US-4/US-5 -- und sie sind ausdruecklich drei, nicht
172
+ * zwei:
173
+ *
174
+ * | Was liegt dort | Antwort |
175
+ * |-----------------------|------------|
176
+ * | Datei fehlt | still | <- nie installiert
177
+ * | Datei ohne Stempel | veraltet | <- 0.1.4 und aelter
178
+ * | Stempel aelter | veraltet |
179
+ * | Stempel gleich/neuer | still | <- niemand wird rueckwaerts gedraengt
180
+ *
181
+ * ⚠️ Die ersten beiden sehen im Dateisystem fast gleich aus und bedeuten das
182
+ * Gegenteil. `null` heisst hier "es gibt keine Datei", NICHT "leer".
183
+ */
184
+ export function skillZustand(inhalt, eigeneVersion) {
185
+ if (inhalt === null)
186
+ return "still";
187
+ const stempel = stempelAus(inhalt);
188
+ if (stempel === null)
189
+ return "veraltet";
190
+ return istAelter(stempel, eigeneVersion) ? "veraltet" : "still";
191
+ }
192
+ /**
193
+ * ⚠️ Sie nennt den Befehl, sie FUEHRT IHN NICHT AUS. Ein Befehl darf nicht
194
+ * mehr tun, als sein Name verspricht -- die Regel steht schon in kimal3.ts.
195
+ */
196
+ export function skillMeldung() {
197
+ return "\n β†’ Deine kimal3-Skilldateien sind aelter als die CLI. kimal3 skill install\n";
198
+ }
@@ -0,0 +1,37 @@
1
+ import { readFileSync } from "node:fs";
2
+ /**
3
+ * Die eigene Versionsnummer -- GELESEN, nie abgetippt.
4
+ *
5
+ * ⚠️ Die Regel dahinter ist gemessen (QA-Befund B-8, 2026-08-18): Stuende die
6
+ * Zahl als Text im Code, sagte die CLI nach dem naechsten `npm version` die
7
+ * alte Nummer. Dieselbe Zahl an zwei Tueren mit zwei Werten -- ein Muster, das
8
+ * in diesem Projekt schon mehrfach zugeschlagen hat.
9
+ *
10
+ * πŸ“Œ `../package.json` stimmt aus `dist/` UND aus `src/`: Beide liegen eine
11
+ * Ebene unter `cli/`. Deshalb steht hier kein fester Pfad.
12
+ *
13
+ * πŸ”΄ RUECKGABE `null`, NICHT "0.0.0" und auch nicht "unbekannt".
14
+ * Diese Funktion hat zwei Aufrufer mit gegensaetzlichem Bedarf:
15
+ * - `versionsZeilen()` ZEIGT den Wert einem Menschen. Dort ist "unbekannt"
16
+ * die ehrliche Antwort -- eine erfundene "0.0.0" waere eine Luege auf dem
17
+ * Bildschirm.
18
+ * - `hinweisAusgeben()` RECHNET damit. Dort ist "unbekannt" Muell, der jeden
19
+ * Versionsvergleich schweigen laesst.
20
+ * `null` zwingt beide, ihren eigenen Fall zu entscheiden, statt einen
21
+ * Ersatzwert zu erben, der nur fuer den anderen passt.
22
+ */
23
+ export function eigeneVersion() {
24
+ try {
25
+ const roh = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
26
+ if (roh && typeof roh === "object" && "version" in roh) {
27
+ const v = roh.version;
28
+ if (typeof v === "string")
29
+ return v;
30
+ }
31
+ }
32
+ catch {
33
+ // Kein Absturz, wenn die Datei fehlt oder Unsinn enthaelt. Der Aufrufer
34
+ // entscheidet, was "keine Nummer" bei ihm bedeutet.
35
+ }
36
+ return null;
37
+ }
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Wie eine Werkbank heisst β€” **eine** Regel fuer beide Auslieferungswege.
3
+ *
4
+ * πŸ”‘ **Warum das eine eigene Datei ist** (2026-08-20, bei der Umbenennung von
5
+ * `meine-werkbank` auf `werkbank-<name>`): Der Ordnername entsteht an ZWEI
6
+ * Stellen. Der Server setzt ihn beim Ausliefern (Zip-Download auf `/pro` UND
7
+ * `/api/cli/arbeitsplatz`), und die CLI setzt ihn noch einmal, wenn der Mensch
8
+ * einen Namen mitgegeben hat. Waeren das zwei Regeln, hiesse derselbe Mensch je
9
+ * nach Weg anders β€” genau die Sorte Unterschied, die man erst Tage spaeter
10
+ * bemerkt.
11
+ *
12
+ * ⚠️ **DIESE DATEI IMPORTIERT NICHTS**, aus demselben Grund wie
13
+ * `befehle-katalog.ts`: Die CLI baut mit NodeNext (Import-Pfade enden auf
14
+ * `.js`), die App mit dem Bundler. Eine Datei ohne eigene Importe liest sich aus
15
+ * beiden Welten ohne Uebersetzung.
16
+ *
17
+ * πŸ”΄ **Wozu das Ganze** (Entscheidung Dennis, 2026-08-20): *Besitz steht im
18
+ * Ordnernamen, nicht in einem Possessivpronomen.* Der Vorgaenger `meine-werkbank`
19
+ * hiess bei JEDEM gleich β€” liegen zwei davon nebeneinander oder gehen sie zu
20
+ * GitHub, ist nicht mehr erkennbar, wem welche gehoert. Wo ein Name steht
21
+ * (`werkbank-sabine`), gehoert der Ordner einer Person; wo keiner steht
22
+ * (`kontext/`, `KI-Arbeitsplatz/`), gehoert er allen. *Ein Nutzer muss diese
23
+ * Regel nicht lernen β€” er sieht sie im Baum.*
24
+ */
25
+ /** Das Praefix, an dem man eine Werkbank erkennt. Bewacht von `check:arbeitsplatz` (11b). */
26
+ export const WERKBANK_PRAEFIX = "werkbank-";
27
+ /**
28
+ * Wie lang der Namensteil hoechstens werden darf.
29
+ *
30
+ * Nicht aus Geschmack: Windows deckelt einen ganzen Pfad bei 260 Zeichen, und
31
+ * dieser Ordner traegt darunter noch `projekte/<projekt>/referenzen/…`. Ein
32
+ * ellenlanger Name aus einer Sammel-Adresse wuerde erst dort unten Fehler
33
+ * erzeugen β€” an einer Stelle, an der niemand mehr an den Ordnernamen denkt.
34
+ */
35
+ const MAX_LAENGE = 40;
36
+ /**
37
+ * Aus dem, was ein Mensch eingibt (oder aus seiner Adresse kommt), ein Ordnername.
38
+ *
39
+ * "Sabine" β†’ "werkbank-sabine"
40
+ * "Sabine MΓΌller" β†’ "werkbank-sabine-mueller"
41
+ * "werkbank-sabine" β†’ "werkbank-sabine" (nicht doppelt)
42
+ * "sabine@arno.de" β†’ "werkbank-sabine" (der Teil vor dem @)
43
+ * "!!!" β†’ null (nichts Brauchbares uebrig)
44
+ *
45
+ * πŸ”‘ **Warum umgeschrieben und nicht abgelehnt wird:** Der Name landet als
46
+ * Ordner auf einer echten Platte. Ein `/` darin waere ein Unterordner, ein `:`
47
+ * unter Windows ein Laufwerk, ein Leerzeichen ein zweites Argument im naechsten
48
+ * `cd`. Wer β€žSabine MΓΌller" eingibt, hat nichts falsch gemacht β€” er hat einen
49
+ * Namen genannt, und Namen duerfen Umlaute und Leerzeichen haben.
50
+ *
51
+ * ⚠️ **Umlaute werden AUSGESCHRIEBEN, nicht entfernt.** `ü` einfach zu streichen
52
+ * machte aus β€žMΓΌller" ein β€žMller"; die deutsche Ersatzschreibung `ue` ist die,
53
+ * die jeder Mensch hier erwartet. Der Weg ueber `normalize("NFD")` allein reicht
54
+ * dafuer NICHT β€” er zerlegt `ΓΌ` in `u` + Trema und liefert damit β€žMuller".
55
+ * Deshalb stehen die vier deutschen Zeichen ausdruecklich davor.
56
+ *
57
+ * πŸ“Œ Gibt `null` zurueck, wenn nichts uebrig bleibt. Das ist **kein** Fehlerfall
58
+ * dieser Funktion, sondern eine Auskunft: Der Aufrufer entscheidet, ob er
59
+ * absagt (die CLI, wo ein Mensch etwas getippt hat) oder still beim Vordruck
60
+ * bleibt (der Server, wo niemand gefragt wurde).
61
+ */
62
+ export function werkbankOrdnerAus(roh) {
63
+ /*
64
+ πŸ”΄ **Spitze Klammern sind eine ABSAGE, keine Zeichen zum Wegwerfen**
65
+ (Review-Runde 5 zu PR #25). Gemessen: `"<dein-vorname>"` wurde zu
66
+ `werkbank-dein-vorname` -- ein Ordner, der plausibel aussieht und den
67
+ niemand wollte.
68
+
69
+ Und die Zeichenkette kommt nicht aus dem Nichts: Beide Absagen dieser CLI
70
+ und die Tabelle in `SKILL.md` legen sie dem Menschen woertlich hin
71
+ (`kimal3 werkbank neu <dein-vorname>`). In einer rohen Shell rettet der
72
+ Zufall -- dort ist `<` eine Umleitung und der Aufruf scheitert vorher. In
73
+ Claude Code, wo der Aufruf als Zeichenkette weitergereicht wird, rettet
74
+ nichts.
75
+
76
+ *Genau der Fehler, gegen den `Anleitung.tsx` den Kopier-Kasten entworfen
77
+ hat: β€žEin Platzhalter darin wird mitkopiert und ergibt einen Ordner, den
78
+ niemand wollte."* Er stand hier eine Ebene tiefer noch offen.
79
+ */
80
+ if (roh.includes("<") || roh.includes(">"))
81
+ return null;
82
+ const vorDemAt = roh.split("@")[0] ?? "";
83
+ const grundform = vorDemAt
84
+ .toLowerCase()
85
+ .replaceAll("Γ€", "ae")
86
+ .replaceAll("ΓΆ", "oe")
87
+ .replaceAll("ΓΌ", "ue")
88
+ .replaceAll("ß", "ss")
89
+ // Alles andere mit Akzent auf den Grundbuchstaben zurueck (Γ© β†’ e, Γ§ β†’ c).
90
+ .normalize("NFD")
91
+ .replace(/[\u0300-\u036f]/g, "")
92
+ // Was jetzt noch kein a–z oder 0–9 ist, wird zum Trennstrich -- und
93
+ // mehrere Trennstriche werden einer.
94
+ .replace(/[^a-z0-9]+/g, "-")
95
+ .replace(/^-+|-+$/g, "");
96
+ // Ein bereits vollstaendiger Ordnername soll nicht `werkbank-werkbank-sabine`
97
+ // werden. Das ist kein Sonderfall fuer Feinschmecker: Die Hilfe von
98
+ // `werkbank neu` hat bis heute genau diese Schreibweise beigebracht
99
+ // (`kimal3 werkbank neu werkbank-sabine`), und alte Anleitungen ueberleben
100
+ // laenger als der Code, den sie beschreiben.
101
+ const ohnePraefix = grundform.startsWith(WERKBANK_PRAEFIX)
102
+ ? grundform.slice(WERKBANK_PRAEFIX.length)
103
+ : grundform;
104
+ // ⚠️ Erst kuerzen, DANN den Trennstrich am Ende wegnehmen: Faellt der Schnitt
105
+ // mitten in β€žsabine-mueller", bliebe sonst ein β€žwerkbank-sabine-" stehen.
106
+ const gekuerzt = ohnePraefix.slice(0, MAX_LAENGE).replace(/-+$/g, "");
107
+ if (gekuerzt === "")
108
+ return null;
109
+ return `${WERKBANK_PRAEFIX}${gekuerzt}`;
110
+ }
111
+ /**
112
+ * Die Vorlage auf einen anderen Werkbank-Namen umschreiben β€” **Pfade und Texte**.
113
+ *
114
+ * πŸ”‘ **Warum auch die Texte** β€” und warum die Begruendung dafuer sich am
115
+ * 2026-08-20 zweimal gedreht hat:
116
+ *
117
+ * Zuerst hiess sie: *β€žDie `README.md` sagt woertlich `cd werkbank-dein-name`,
118
+ * und `check:arbeitsplatz` (Pruefung 11) haelt das fest."* Das stimmte β€”
119
+ * bis Review-Runde 3 zeigte, dass ein Name im GEMEINSAMEN Firmentext den Namen
120
+ * EINES Menschen ist, und Pruefung 11 daraufhin umgedreht wurde: Sie verbietet
121
+ * ihn dort jetzt.
122
+ *
123
+ * πŸ”¬ **Heute nennt deshalb keine ausgelieferte Datei den Werkbank-Namen im
124
+ * Text** (nachgemessen: `grep` findet ihn nur im Ordnernamen). Die Ersetzung
125
+ * hat also aktuell **nichts zu tun** β€” und bleibt trotzdem drin, als Netz fuer
126
+ * den Fall, dass jemand in eine Werkbank-Datei `werkbank-sabine/projekte/`
127
+ * schreibt. Dort verbietet es keine Wache, und der Satz zeigte beim naechsten
128
+ * Menschen auf einen fremden Ordner.
129
+ *
130
+ * *Ein Netz, das heute niemand braucht, ist ehrlich zu beschreiben β€” nicht mit
131
+ * einer Zusicherung zu begruenden, die inzwischen das Gegenteil sagt.*
132
+ *
133
+ * ⚠️ Ersetzt wird der ganze Ordnername, nicht das Praefix β€” sonst traefe es
134
+ * jeden Text, der irgendeine Werkbank erwaehnt.
135
+ *
136
+ * πŸ”΄ **Und das reicht NICHT, um erklaerende Beispiele zu schuetzen**
137
+ * (Review-Runde 1 zu PR #25). Hier stand, die Beispiele `werkbank-sabine` in der
138
+ * Vorlage blieben stehen. Das stimmt nur, solange `alt` der Vordruck ist β€” bei
139
+ * der CLI ist `alt` aber der Name, den der SERVER gesetzt hat. Heisst ein Konto
140
+ * `werkbank-sabine` und ruft `arbeitsplatz neu jonas` auf, wird aus dem
141
+ * erklaerenden Beispiel ebenfalls `werkbank-jonas`, und der Satz liest sich
142
+ * *β€žtraegt seinen Namen: `werkbank-jonas`, `werkbank-jonas`"*.
143
+ *
144
+ * πŸ”‘ Geschuetzt sind Beispiele deshalb nicht hier, sondern in der VORLAGE: Sie
145
+ * heissen `werkbank-<vorname>`, und diese Form kann kein echter Ordner tragen β€”
146
+ * `<` und `>` ueberleben `werkbankOrdnerAus` nicht. `check:arbeitsplatz` (11d)
147
+ * haelt das fest. *Eine Zusicherung, die nur fuer einen der beiden Aufrufer
148
+ * gilt, ist keine Zusicherung dieser Funktion.*
149
+ *
150
+ * πŸ“Œ Ist `alt` gleich `neu`, kommt die Liste unveraendert zurueck. Der Aufrufer
151
+ * muss also nicht selbst pruefen, ob sich etwas aendert.
152
+ */
153
+ export function werkbankUmbenennen(dateien, alt, neu) {
154
+ if (alt === "" || neu === "" || alt === neu)
155
+ return [...dateien];
156
+ const altesPraefix = `${alt}/`;
157
+ const neuesPraefix = `${neu}/`;
158
+ /*
159
+ πŸ”΄ **Mit Wortgrenze, nicht mit `replaceAll`** (Review-Runde 5 zu PR #25).
160
+ Gemessen: Ein nacktes `replaceAll("werkbank-sabine", "werkbank-jonas")`
161
+ macht aus `werkbank-sabine-alt` ein `werkbank-jonas-alt` -- es zerlegt jedes
162
+ laengere Token, das mit dem Namen anfaengt.
163
+
164
+ Heute stolpert niemand darueber, weil `check:arbeitsplatz` (11d) solche
165
+ Token aus der Vorlage haelt. Aber der Doku-Block oben verkauft die
166
+ Inhalts-Ersetzung ausdruecklich als **Netz fuer kuenftige Texte** -- und ein
167
+ Netz mit einem Loch ist genau das, was man erst am Tag des Sturzes merkt.
168
+
169
+ Die Grenze gilt auf BEIDEN Seiten: `xwerkbank-sabine` ist ebenso wenig
170
+ gemeint wie `werkbank-sabine-alt`. Erlaubt sind nur Zeichen, die in keinem
171
+ Ordnernamen vorkommen koennen -- alles ausser `[a-z0-9-]`.
172
+ */
173
+ const grenze = new RegExp(`(?<![a-z0-9-])${maskiere(alt)}(?![a-z0-9-])`, "g");
174
+ return dateien.map((datei) => ({
175
+ ...datei,
176
+ pfad: datei.pfad.startsWith(altesPraefix)
177
+ ? `${neuesPraefix}${datei.pfad.slice(altesPraefix.length)}`
178
+ : datei.pfad,
179
+ inhalt: datei.inhalt.replace(grenze, neu),
180
+ }));
181
+ }
182
+ /**
183
+ * Welcher Unterordner die Werkbank ist β€” **abgeleitet, nicht getippt**.
184
+ *
185
+ * Erkennungsregel: der einzige Unterordner, der DIREKT eine eigene `CLAUDE.md`
186
+ * traegt (Pfad `<ordner>/CLAUDE.md`, genau zwei Teile). Dass es genau einen gibt,
187
+ * sichert `check:arbeitsplatz` (Pruefung 10) β€” ohne diese Wache waehlte die Regel
188
+ * bei mehreren stumm den ersten.
189
+ *
190
+ * πŸ”΄ Diese Regel stand vorher ZWEIMAL im Projekt, wortgleich: einmal in
191
+ * `cli/src/arbeitsplatz-dateien.ts` (Befund R2-3) und einmal in
192
+ * `src/lib/arbeitsplatz/ausliefern.ts` (Runde 3). Beide Male war die Begruendung
193
+ * β€žnicht austippen" β€” und trotzdem wurde die Ableitung selbst kopiert.
194
+ * *Zwei Kopien einer Ableitung sind dieselbe zweite Wahrheit wie zwei Kopien des
195
+ * Namens, nur besser getarnt.*
196
+ */
197
+ export function werkbankAusDateien(dateien) {
198
+ for (const datei of dateien) {
199
+ const teile = datei.pfad.split("/");
200
+ if (teile.length === 2 && teile[1] === "CLAUDE.md")
201
+ return teile[0];
202
+ }
203
+ return null;
204
+ }
205
+ /**
206
+ * Sonderzeichen eines regulaeren Ausdrucks entschaerfen.
207
+ *
208
+ * πŸ”‘ Der Name kommt aus einer Server-Antwort. `werkbankOrdnerAus` laesst zwar
209
+ * nur `[a-z0-9-]` durch β€” aber `werkbankUmbenennen` bekommt `alt` NICHT von
210
+ * dort, sondern aus der gelieferten Dateiliste (`werkbankAusDateien`). Was von
211
+ * aussen kommt, wird nie zum Muster. *Eine Regel, die nur fuer den einen
212
+ * Aufrufer gilt, ist keine Regel dieser Funktion* -- dieselbe Lehre wie bei der
213
+ * Zusicherung ueber die Beispiele.
214
+ */
215
+ function maskiere(text) {
216
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
217
+ }