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.
package/README.md CHANGED
@@ -37,6 +37,9 @@ kimal3 login Diesen Rechner mit deinem Konto verbinden
37
37
  kimal3 skill install Claude beibringen, dieses Werkzeug zu benutzen — und dir die /k3-Befehle geben
38
38
  kimal3 arbeitsplatz neu Den ganzen KI-Arbeitsplatz anlegen — Firmenebene und Werkbank
39
39
  kimal3 werkbank neu Nur eine eigene Werkbank in einer bestehenden Firma anlegen
40
+ kimal3 kontext runter Den Firmenkontext holen — CLAUDE.md und die fünf Dateien in kontext/
41
+ kimal3 kontext fragen Zeigen, welche Fragen über deine Firma noch unbeantwortet sind
42
+ kimal3 kontext hoch Deine lokalen Änderungen an den fünf Kontext-Dateien hochschicken
40
43
  kimal3 projekt neu … Ein Projekt ins Erfolgsprofil eintragen
41
44
  kimal3 projekt liste Deine eingetragenen Projekte anzeigen
42
45
  kimal3 firma daten Die Daten deiner Firma holen, damit Claude sie auswerten kann
package/dist/api.js CHANGED
@@ -1,9 +1,62 @@
1
1
  import { BASIS_URL, leseAnmeldung } from "./anmeldung.js";
2
2
  import { hinweisAusAntwort } from "./nachricht.js";
3
3
  import { eigeneVersion } from "./version.js";
4
+ import { findeArbeitsplatz, leseMerkzettelDatei } from "./kontext-dateien.js";
5
+ import { bekannterStandAus, istKopfwertSicher } from "./kontext-stand.js";
4
6
  export class NichtAngemeldet extends Error {
5
7
  constructor() { super("nicht angemeldet"); }
6
8
  }
9
+ /**
10
+ * Der Wert fuer den Kopf `X-Kimal3-Kontext-Stand` -- `<firmen-id>:<stand>`
11
+ * aus dem lokalen Merkzettel, oder `null`, wenn er sich nicht bilden laesst
12
+ * (Task 10, Step 1).
13
+ *
14
+ * ⚠️ WIRFT NIE -- dieselbe Regel wie beim Update-Kanal insgesamt: Ein
15
+ * Nebenschauplatz darf keinen Befehl umwerfen. `findeArbeitsplatz` und
16
+ * `leseMerkzettelDatei` werfen zwar selbst schon nicht (s. ihre eigenen
17
+ * Kommentare), das `try` ist trotzdem das Netz fuer den Fall, dass sich das
18
+ * einmal aendert.
19
+ *
20
+ * 🔴 **Fix-Runde 1, Fund 1:** `firma_id` und jeder `stand` kommen aus dem
21
+ * Merkzettel auf der Platte des Kunden -- einer Datei, die ausdruecklich zum
22
+ * Von-Hand-Aufmachen gedacht ist (s. Kopf-Kommentar an `pruefsumme` in
23
+ * `kontext-stand.ts`). `leseMerkzettel` prueft nur `typeof === "string"`, nie
24
+ * den INHALT. Ohne `istKopfwertSicher` wuerde ein einziges Nicht-ASCII-
25
+ * Zeichen darin `headers.set(...)` in `ruf()` unten zum Werfen bringen --
26
+ * und zwar bei JEDEM folgenden Befehl, nicht nur bei `kontext`-Befehlen. Der
27
+ * Kunde saehe "kimal3 ist gerade nicht erreichbar" und suchte im Netz, waehrend
28
+ * der Fehler auf seiner eigenen Platte sitzt.
29
+ *
30
+ * 🔑 **Warum stillschweigend `null`, keine Warnung:** Dieselbe Antwort wie
31
+ * bei den drei anderen Wirklichkeiten, die diese Funktion schon zu `null`
32
+ * zusammenfaltet (kein Arbeitsplatz, keine bekannte `firma_id` in allen drei
33
+ * Lesarten von Task 10a). Eine Warnung liefe bei JEDEM Befehl erneut, bis der
34
+ * Merkzettel neu geschrieben wird (naechstes `kontext runter`/`kontext hoch`
35
+ * heilt das automatisch) -- und ein Nebenschauplatz, der ploetzlich auf jedem
36
+ * Befehl eine unerwartete Zeile ausgibt, ist selbst ein Verstoss gegen die
37
+ * Regel, die ihn ueberhaupt rechtfertigt ("darf keinen Befehl umwerfen" ist
38
+ * eng verwandt mit "darf keinen Befehl verunstalten"). *Wie viele
39
+ * verschiedene Wirklichkeiten enden auf `null` -- und ist jede ein sicherer
40
+ * Ausgang?* Vier, und JEDE fuehrt zum selben, harmlosen Ergebnis: kein Kopf,
41
+ * kein Hinweis, der Befehl laeuft unveraendert weiter.
42
+ */
43
+ function kontextStandKopf() {
44
+ try {
45
+ const ordner = findeArbeitsplatz(process.cwd());
46
+ if (ordner === null)
47
+ return null;
48
+ const merkzettel = leseMerkzettelDatei(ordner);
49
+ if (merkzettel.firma_id === null)
50
+ return null;
51
+ const wert = `${merkzettel.firma_id}:${bekannterStandAus(merkzettel)}`;
52
+ if (!istKopfwertSicher(wert))
53
+ return null;
54
+ return wert;
55
+ }
56
+ catch {
57
+ return null;
58
+ }
59
+ }
7
60
  /**
8
61
  * Ein Aufruf gegen die App. `mitAusweis` haengt den Schluessel an.
9
62
  *
@@ -40,6 +93,30 @@ export async function ruf(pfad, optionen = {}) {
40
93
  const version = eigeneVersion();
41
94
  if (version !== null)
42
95
  kopf["X-Kimal3-Version"] = version;
96
+ /*
97
+ 🔴 DER ZWEITE KOPF (Task 10) -- der lokal bekannte Firmenstand, damit der
98
+ Server einen firmenbezogenen Hinweis anhaengen kann ("deine Firma hat den
99
+ Kontext geaendert"). Der Server weiss nicht, was auf DIESER Platte liegt
100
+ -- deshalb schickt die CLI ihn mit, statt dass der Server raet.
101
+
102
+ ⚠️ ASCII PFLICHT, aus demselben Grund wie der Versions-Kopf oben: die
103
+ Firmen-ID ist eine UUID, der Stand ein ISO-Zeitstempel oder `"leer"" --
104
+ beides ASCII. Der Firmenname geht NIE hier mit, nur die ID; den Namen
105
+ loest der Server fuer den Hinweistext selbst auf (Step 4, US-8).
106
+
107
+ 🔴 GAR KEIN KOPF, wenn sich kein Arbeitsplatz findet ODER der lokale
108
+ Merkzettel keine Firmen-ID kennt -- letzteres kollabiert bewusst DREI
109
+ Wirklichkeiten in einen Wert (altes Merkzettel-Format ohne das Feld,
110
+ kaputtes JSON, ausdruecklich `null`, s. `leseMerkzettel` in
111
+ `kontext-stand.ts`). Diese Funktion unterscheidet sie nicht: Alle drei
112
+ heissen "wir wissen die Firma nicht sicher", und "kein Kopf" ist in
113
+ JEDEM der drei Faelle die richtige, sichere Antwort -- kein Hinweis
114
+ bleibt der Vorgabewert, auch wenn er einen der drei Faelle zu Unrecht
115
+ (aber folgenlos) mit erwischt.
116
+ */
117
+ const kontextKopf = kontextStandKopf();
118
+ if (kontextKopf !== null)
119
+ kopf["X-Kimal3-Kontext-Stand"] = kontextKopf;
43
120
  const methode = optionen.methode ?? "POST";
44
121
  // Content-Type nur, wenn wirklich etwas im Rumpf steht. Ein GET mit
45
122
  // "Content-Type: application/json" und ohne Rumpf ist eine Luege ueber die
@@ -0,0 +1,165 @@
1
+ import { FIRMEN_TEXTE } from "../kontext-stand.js";
2
+ import { fragenAus, traegtFragenVon } from "../fragen-lesen.js";
3
+ import { leseEineDatei } from "../kontext-dateien.js";
4
+ import { arbeitsplatzOderAbsage, fremdeDateiAbsage } from "./kontext-gemeinsam.js";
5
+ /**
6
+ * `kimal3 kontext fragen` -- zeigt, welche Fragen ueber die Firma noch offen
7
+ * sind.
8
+ *
9
+ * 🔴 **DIESER BEFEHL FUEHRT KEIN GESPRAECH.** Er gibt Daten aus: Datei, Anker,
10
+ * Frage, Hinweis. Das Gespraech fuehrt Claude, angeleitet vom Skill. *Ein
11
+ * Werkzeug, das selbst ein Gespraech fuehren will, baut eine zweite,
12
+ * schlechtere Fassung von dem, wofuer der Kunde Claude ohnehin schon hat.*
13
+ *
14
+ * 🔑 **Er braucht kein Netz und keine Anmeldung.** Alles, was er wissen muss,
15
+ * steht in den fuenf Dateien auf der Platte -- die Fragen samt Ankern und
16
+ * Hinweisen sind Teil des ausgelieferten Textes. Deshalb laeuft er auch dann,
17
+ * wenn der Ausweis abgelaufen ist, und deshalb kann er nichts kaputt machen:
18
+ * **er liest, er schreibt nirgends.**
19
+ *
20
+ * ⚠️ **Die Reihenfolge ist die von `FIRMEN_TEXTE`** -- dieselbe wie die der
21
+ * Felder im Formular auf `/firma/kontext`. Wer beides nebeneinander hat, findet
22
+ * dieselbe Frage an derselben Stelle.
23
+ */
24
+ /*
25
+ 🔴 **ECHTE UMLAUTE, auch hier** (Review-Befund J2). Diese Zeilen liest ein
26
+ Kunde im Terminal -- die ASCII-Umschrift dieses Ordners gilt ausschliesslich
27
+ fuer Kommentare. Hier stand eine Fassung in Umschrift, waehrend die
28
+ Erfolgsausgabe derselben Datei Umlaute setzte: *eine Datei, zwei Schreibweisen,
29
+ und der Unterschied war nicht zu begruenden.*
30
+ */
31
+ const HILFE = `
32
+ kimal3 kontext fragen
33
+
34
+ Zeigt, welche Fragen über deine Firma in kontext/ noch unbeantwortet sind —
35
+ mit Datei, Anker, Frage und Hinweis.
36
+
37
+ Der Befehl liest nur. Er braucht weder Netz noch Anmeldung und ändert nichts.
38
+
39
+ Antworten trägst du in die genannte Datei unter ihre Frage ein; danach
40
+ schickt "kimal3 kontext hoch" sie an die Firma (nur als Admin).
41
+ `;
42
+ /** `[optional]` hinter einer Frage, die keine Pflicht ist -- sonst nichts. */
43
+ function zusatz(frage) {
44
+ return frage.pflicht ? "" : " [optional]";
45
+ }
46
+ /*
47
+ ⚠️ **Bewusst NICHT `async`, anders als die Nachbarn `kontextRunter` und
48
+ `kontextHoch`.** Die beiden warten auf den Server; dieser Befehl nicht. Ein
49
+ `async` ohne `await` waere ein Versprechen auf Arbeit, die nicht stattfindet
50
+ -- und beim Lesen die falsche Erwartung, dass hier etwas ueber das Netz geht.
51
+ Der Verteiler in `kimal3.ts` ist selbst `async` und nimmt beides.
52
+ */
53
+ export function kontextFragen(argv) {
54
+ if (argv.includes("--help") || argv.includes("-h")) {
55
+ console.log(HILFE);
56
+ return 0;
57
+ }
58
+ /*
59
+ ⚠️ **Der Befehl nimmt GAR KEIN Argument -- auch kein `--firma`.** Er fragt
60
+ den Server nicht, also gibt es keine Firma zu waehlen; und er liest fest die
61
+ fuenf Dateien neben sich, also auch keinen Dateinamen.
62
+
63
+ 🔴 Geprueft wird auf JEDES Argument, nicht nur auf solche mit `-` davor. Ein
64
+ stillschweigend geschlucktes `--firma "Nordwind"` waere schlimmer als eine
65
+ Absage -- der Mensch glaubte dann, er habe eine Firma ausgewaehlt -- und ein
66
+ verschlucktes `marke` genauso: Er saehe alle 17 Fragen und hielte sie fuer
67
+ die einer Datei.
68
+ */
69
+ if (argv.length > 0) {
70
+ console.error(`„${argv[0]}" kenne ich hier nicht — dieser Befehl nimmt keine Angaben.`);
71
+ console.error(" Er liest die fünf Dateien in kontext/ neben dir und gibt aus, was offen ist.");
72
+ return 1;
73
+ }
74
+ const ordner = arbeitsplatzOderAbsage();
75
+ if (ordner === null)
76
+ return 1;
77
+ /*
78
+ 🔴 **EINE FEHLENDE DATEI IST EIN ABBRUCH, KEINE LUECKE IN DER LISTE.**
79
+ Wer aus vier von fuenf Dateien eine Liste offener Fragen baut und sie als
80
+ die Antwort hinlegt, sagt einem Menschen, er sei fertig, waehrend drei
81
+ Fragen nirgends stehen. Dieselbe Regel wie bei `kontext hoch`: lieber ein
82
+ Abbruch, der sagt warum, als ein halber Stand, den niemand benennen kann.
83
+ */
84
+ const gelesen = [];
85
+ for (const datei of FIRMEN_TEXTE) {
86
+ const antwort = leseEineDatei(ordner, datei.pfad);
87
+ if (!antwort.ok) {
88
+ console.error(antwort.meldung);
89
+ return 1;
90
+ }
91
+ if (antwort.inhalt === null) {
92
+ console.error(`„${datei.pfad}" gibt es hier nicht — dann fehlen mir ihre Fragen.`);
93
+ console.error(" `kimal3 kontext runter` holt die Datei; danach noch einmal.");
94
+ return 1;
95
+ }
96
+ /*
97
+ 🔴 **EINE DATEI OHNE IHRE FRAGEN IST DERSELBE FALL WIE EINE FEHLENDE**
98
+ (Review-Befund J1). Ohne diese Zeilen lieferte `fragenAus` eine leere
99
+ Liste, und die Datei fiel aus BEIDEN Summen -- gemessen mit der
100
+ ausgelieferten `README.md` als `kontext/marke.md`: „12 von 12 Fragen",
101
+ „Die Marke" nirgends genannt. Nach zwoelf Antworten haette der Befehl
102
+ `✓ Alle 12 Fragen … sind beantwortet` gemeldet, und `kontext hoch` waere
103
+ einen Schritt spaeter -- im Ablauf, den unser eigener Skill vorgibt --
104
+ auf genau dieser Datei mit Rueckgabewert 1 abgebrochen.
105
+
106
+ 🔑 Woertlich dieselbe Beschreibung des Ordners wie dort
107
+ (`fremdeDateiAbsage`): Zwei Befehle, die dieselbe Lage verschieden
108
+ benennen, sind zwei Wahrheiten.
109
+ */
110
+ if (!traegtFragenVon(antwort.inhalt, datei.ankerPraefix)) {
111
+ fremdeDateiAbsage(datei.pfad, datei.titel, datei.ankerPraefix, "Ich kann ihre Fragen deshalb nicht zählen — und zähle dann gar nicht.");
112
+ return 1;
113
+ }
114
+ gelesen.push({ pfad: datei.pfad, titel: datei.titel, fragen: fragenAus(antwort.inhalt) });
115
+ }
116
+ const gesamt = gelesen.reduce((summe, d) => summe + d.fragen.length, 0);
117
+ const offen = gelesen.reduce((summe, d) => summe + d.fragen.filter((f) => f.antwort === "").length, 0);
118
+ /*
119
+ 🔑 **Alles beantwortet -> EIN Satz, keine Liste** (Auftrag, Step 1). Eine
120
+ leere Liste mit Ueberschriften sieht aus wie ein Fehler; ein Satz ist eine
121
+ Antwort.
122
+
123
+ ⚠️ Er sagt „in deinen Kontext-Dateien" und nicht „alle Fragen deiner
124
+ Firma". Gemessen wurde, was in diesen fuenf Dateien steht -- wer einen
125
+ Anker geloescht hat, hat eine Frage, die hier nicht auftaucht (s.
126
+ `fragen-lesen.ts`). *Ein Satz darf nur behaupten, was seine Messung
127
+ deckt.*
128
+ */
129
+ if (offen === 0) {
130
+ console.log(`✓ Alle ${gesamt} Fragen, die in deinen Dateien stehen, sind beantwortet.`);
131
+ /*
132
+ 🔴 **DIE ZWEITE ZEILE BENENNT DIE LUECKE, STATT SIE ZU VERSTECKEN**
133
+ (Review-Befund J5). Gezaehlt wird, was in den fuenf Dateien steht. Wer den
134
+ Ankerkommentar EINER Frage loescht und sie unbeantwortet laesst, hat sie
135
+ hier nicht mehr -- `fragenAus` erkennt eine Frage am Anker (Ruling K-14),
136
+ die App zusaetzlich am Wortlaut. Dann stuende hier `Alle 16 …`, waehrend
137
+ die App weiter „16 von 17" fuehrt.
138
+
139
+ 🔑 Der Ausweg gehoert in dieselbe Ausgabe wie die Zahl. *Eine Zahl, deren
140
+ Grenze man kennt, gehoert nicht ohne ihre Grenze hingeschrieben* -- sonst
141
+ liest ein Mensch „fertig" und die App widerspricht ihm morgen.
142
+ */
143
+ console.log(" Gezählt ist, was in kontext/ steht. Nennt die App unter Firma → Kontext");
144
+ console.log(" eine höhere Zahl, fehlt in einer Datei eine Frage — `kimal3 kontext runter`");
145
+ console.log(" holt sie zurück.");
146
+ return 0;
147
+ }
148
+ console.log(`${offen} von ${gesamt} Fragen über deine Firma sind noch offen.\n`);
149
+ for (const datei of gelesen) {
150
+ const offeneDieserDatei = datei.fragen.filter((f) => f.antwort === "");
151
+ if (offeneDieserDatei.length === 0)
152
+ continue;
153
+ console.log(`${datei.pfad} — ${datei.titel}`);
154
+ for (const frage of offeneDieserDatei) {
155
+ console.log(` <!-- k3:${frage.anker} -->${zusatz(frage)}`);
156
+ console.log(` ## ${frage.ueberschrift}`);
157
+ if (frage.hinweis !== "")
158
+ console.log(` ${frage.hinweis}`);
159
+ console.log("");
160
+ }
161
+ }
162
+ console.log(" Die Antwort kommt in der genannten Datei unter ihre Überschrift.");
163
+ console.log(" Danach als Admin: kimal3 kontext hoch");
164
+ return 0;
165
+ }
@@ -0,0 +1,171 @@
1
+ import { feld } from "../api.js";
2
+ import { schlussFehler } from "../fehler.js";
3
+ import { BASIS_URL } from "../anmeldung.js";
4
+ import { findeArbeitsplatz } from "../kontext-dateien.js";
5
+ /**
6
+ * Was `kontext runter` (`kontext-runter.ts`) und `kontext hoch`
7
+ * (`kontext-hoch.ts`) TEILEN -- die `--firma`/`--firma-id`-Optionen und die
8
+ * Absage-Logik fuer `/api/cli/kontext`.
9
+ *
10
+ * 🔴 **Ruling T9-2 (Split von `kontext.ts`, ehemals 400 Code-Zeilen mit zwei
11
+ * Befehlen).** Diese Datei existiert, damit die 409-Firmenwahl-Logik nicht
12
+ * zweimal existiert: zwei Kopien einer Entscheidung ueber Sichern/
13
+ * Ueberschreiben sind genau das Fehlerbild, das dieses Projekt schon mehrfach
14
+ * teuer bezahlt hat.
15
+ *
16
+ * 🔑 Was hier NICHT steht: alles, das nur EINE der beiden Richtungen betrifft.
17
+ * Die Fundregel-Implementierung (`findeArbeitsplatz`) bleibt in
18
+ * `kontext-dateien.ts` -- sie war schon vorher gemeinsam genutzt, aber importiert
19
+ * (Ruling T8-1), nicht hier definiert. Die Antwort-Formen `KontextAntwort` und
20
+ * `HochAntwort` bleiben bei ihrem jeweiligen Befehl, weil sie unterschiedliche
21
+ * Felder pruefen.
22
+ */
23
+ /**
24
+ * Den Arbeitsplatz suchen -- und, wenn keiner da ist, die Absage GLEICH
25
+ * ausgeben.
26
+ *
27
+ * 🔴 **Sie steht hier, weil es jetzt DREI Aufrufer gibt** (`kontext runter`,
28
+ * `kontext hoch`, `kontext fragen`). Bis Task 7 waren es zwei, und beide
29
+ * trugen dieselben vier Zeilen abgetippt. Bei der dritten Kopie ist es keine
30
+ * Wiederholung mehr, sondern eine Falle: Wer eine Zeile schaerft, schaerft
31
+ * eine von dreien, und zwei Befehle erklaeren dieselbe Lage danach anders.
32
+ *
33
+ * `null` heisst "kein Arbeitsplatz" -- die Meldung steht dann schon auf
34
+ * stderr, der Aufrufer gibt nur noch `1` zurueck.
35
+ */
36
+ export function arbeitsplatzOderAbsage() {
37
+ const ordner = findeArbeitsplatz(process.cwd());
38
+ if (ordner !== null)
39
+ return ordner;
40
+ console.error("Hier steht kein KI-Arbeitsplatz -- ich suche von hier aufwaerts nach einem");
41
+ console.error("Ordner, der `kontext/` UND `CLAUDE.md` enthaelt.");
42
+ console.error("");
43
+ console.error(" Wechsle in deinen Arbeitsplatz-Ordner und versuch es dort noch einmal.");
44
+ return null;
45
+ }
46
+ /**
47
+ * Was ein Mensch liest, wenn eine der fuenf Dateien keine einzige ihrer Fragen
48
+ * mehr traegt -- **woertlich dieselben Zeilen fuer `kontext hoch` und
49
+ * `kontext fragen`** (Review-Befund J1).
50
+ *
51
+ * 🔴 **WARUM SIE GETEILT WIRD.** `kontext fragen` zaehlte eine solche Datei
52
+ * vorher stillschweigend mit NULL Fragen und liess sie aus beiden Summen
53
+ * fallen. Gemessen mit der ausgelieferten `README.md` als `kontext/marke.md`:
54
+ * „12 von 12 Fragen" statt 17, „Die Marke" nirgends genannt -- und nach zwoelf
55
+ * Antworten meldete der Befehl `✓ Alle 12 Fragen … sind beantwortet`, waehrend
56
+ * `kontext hoch` einen Schritt spaeter auf genau dieser Datei abbrach.
57
+ * *Der lesende Befehl gab die beruhigende Auskunft, der schreibende
58
+ * verweigerte sich.* Das ist die Form von Fund F, und es ist die Regel, gegen
59
+ * die diese Aufgabe gebaut ist: **Ein Fehlerfall darf nie denselben Namen
60
+ * tragen wie ein Erfolgsfall.**
61
+ *
62
+ * ⚠️ `folge` ist die EINE Zeile, die sich unterscheiden darf -- was der
63
+ * jeweilige Befehl gerade nicht getan hat. Die Beschreibung des Ordners
64
+ * (Zeile 1 und 2) ist bei beiden Zeichen fuer Zeichen dieselbe; zwei Fassungen
65
+ * davon wuerden auseinanderlaufen, und dann beschriebe jeder Befehl eine
66
+ * andere Lage.
67
+ *
68
+ * 🔑 ECHTE UMLAUTE -- das hier liest ein Kunde im Terminal. Die ASCII-Umschrift
69
+ * dieses Ordners gilt nur fuer Kommentare.
70
+ */
71
+ export function fremdeDateiAbsage(pfad, titel, ankerPraefix, folge) {
72
+ console.error(`„${pfad}" sieht nicht aus wie „${titel}". ${folge}`);
73
+ console.error(` Ich finde darin keine einzige Frage dieser Datei (Zeilen wie \`<!-- k3:${ankerPraefix}… -->\`).`);
74
+ console.error(" Hol sie mit `kimal3 kontext runter` zurück und trag deine Antworten dort ein.");
75
+ }
76
+ /** `--firma`/`--firma-id` -- die einzigen Optionen, die beide Richtungen kennen. */
77
+ export const ERLAUBT = ["firma", "firma-id"];
78
+ /**
79
+ * Alles, was `/api/cli/kontext` NICHT mit `200` beantwortet -- fuer GET
80
+ * (`kontextRunter`) und POST (`kontextHoch`) gleichermassen. `befehl` sagt,
81
+ * welcher der beiden fuer die kopierfertigen Zeilen richtig ist.
82
+ *
83
+ * 🔑 Dieselbe Struktur wie `firmaDaten` in `firma-daten.ts` -- 402 vor 401
84
+ * (verlangen Gegenteiliges), 409 nennt die eigenen Firmen bzw. die IDs bei
85
+ * gleichnamigen, der Rest geht an `schlussFehler`. Die kopierfertigen Zeilen
86
+ * unten sind eine BEWUSSTE Kopie und keine Wiederverwendung von
87
+ * `firmen-liste.ts`: Dessen `firmenZeilen`/`firmenIdZeilen` schreiben den
88
+ * Befehl `kimal3 firma daten …` fest in die Zeile -- fuer `kontext *`
89
+ * waere das ein falscher, nicht ausfuehrbarer Rat.
90
+ *
91
+ * 🔴 **`409` heisst hier ZWEI verschiedene Dinge** (Task 9): Bei GET und bei
92
+ * POST kommt ein `409` mit `grund` von `waehleFirma` (mehrdeutige Firmenwahl,
93
+ * unten weiterbehandelt) -- POST kann ZUSAETZLICH `grund: "veraltet"` senden
94
+ * (§7d, `postRoh` in `route.ts`): Die Firma wurde inzwischen von jemand
95
+ * anderem geaendert. Die `meldung` dafuer nennt bereits WER und WANN sowie
96
+ * den Ausweg (`--ueberschreiben`) -- sie kommt woertlich vom Server und wird
97
+ * hier nur ausgegeben, nicht neu formuliert.
98
+ */
99
+ export function absage(antwort, befehl) {
100
+ const meldung = feld(antwort.koerper, "meldung") ?? "Das hat nicht geklappt.";
101
+ if (antwort.status === 402) {
102
+ console.error(meldung);
103
+ return 4;
104
+ }
105
+ if (antwort.status === 401) {
106
+ console.error(`${meldung} Bitte \`kimal3 login\` ausführen.`);
107
+ return 2;
108
+ }
109
+ if (antwort.status === 409) {
110
+ const grund = feld(antwort.koerper, "grund");
111
+ if (grund === "veraltet") {
112
+ console.error(meldung);
113
+ return 1;
114
+ }
115
+ const koerper = antwort.koerper;
116
+ const namen = Array.isArray(koerper?.firmen)
117
+ ? koerper.firmen.filter((x) => typeof x === "string")
118
+ : [];
119
+ console.error(meldung);
120
+ const auswahl = Array.isArray(koerper.auswahl)
121
+ ? koerper.auswahl.filter((x) => !!x &&
122
+ typeof x === "object" &&
123
+ typeof x.id === "string" &&
124
+ typeof x.name === "string")
125
+ : [];
126
+ if (auswahl.length > 0) {
127
+ console.error("\n Diese beiden heissen gleich — nimm die ID:");
128
+ firmenIdZeilenKontext(auswahl, befehl).forEach((z) => console.error(z));
129
+ }
130
+ else {
131
+ const zeilen = firmenZeilenKontext(namen, befehl);
132
+ if (zeilen.length > 0) {
133
+ console.error("\n Deine Firmen:");
134
+ zeilen.forEach((z) => console.error(z));
135
+ }
136
+ }
137
+ if (grund === "keine-firma") {
138
+ console.error(`\n Eine Firma anlegen: ${BASIS_URL}/firma/neu`);
139
+ }
140
+ return 1;
141
+ }
142
+ const schluss = schlussFehler(antwort.status, meldung);
143
+ console.error(schluss.text);
144
+ return schluss.wert;
145
+ }
146
+ /**
147
+ * `firmenZeilen` aus `firmen-liste.ts`, nur mit dem richtigen Befehl in der
148
+ * Zeile -- s. Kommentar an `absage`.
149
+ */
150
+ function firmenZeilenKontext(namen, befehl) {
151
+ const anzahl = (name) => namen.filter((n) => n === name).length;
152
+ const gesehen = new Set();
153
+ const zeilen = [];
154
+ for (const name of namen) {
155
+ if (gesehen.has(name))
156
+ continue;
157
+ gesehen.add(name);
158
+ const wieOft = anzahl(name);
159
+ if (wieOft === 1) {
160
+ zeilen.push(` kimal3 ${befehl} --firma "${name}"`);
161
+ }
162
+ else {
163
+ zeilen.push(` „${name}" — kommt ${wieOft}× vor und ist über den Namen nicht wählbar.`);
164
+ }
165
+ }
166
+ return zeilen;
167
+ }
168
+ /** `firmenIdZeilen` aus `firmen-liste.ts`, nur mit dem richtigen Befehl. */
169
+ function firmenIdZeilenKontext(auswahl, befehl) {
170
+ return auswahl.map((f, i) => ` kimal3 ${befehl} --firma-id "${f.id}" # ${f.name} (${i + 1}.)`);
171
+ }
@@ -0,0 +1,203 @@
1
+ import { zerlege } from "../argumente.js";
2
+ import { ruf } from "../api.js";
3
+ import { FIRMEN_TEXTE, alsMerkzettel, bekannterStandAus, merkzettelNachHoch, } from "../kontext-stand.js";
4
+ import { traegtFragenVon } from "../fragen-lesen.js";
5
+ import { leseEineDatei, leseMerkzettelDatei, schreibeMerkzettelDatei } from "../kontext-dateien.js";
6
+ import { ERLAUBT, absage, arbeitsplatzOderAbsage, fremdeDateiAbsage } from "./kontext-gemeinsam.js";
7
+ /*
8
+ 🪦 **Hier stand `feldSchluessel`, gestrichen mit Ruling K-6 (Task 7).**
9
+
10
+ Sie leitete den Feldnamen der Route aus dem Dateinamen ab (`marke.md` ->
11
+ `marke_md`). Fuer vier der fuenf Dateien war das richtig; die fuenfte,
12
+ `kontext/kurzprofil.md`, fuellt `drei_saetze` und haette daraus `kurzprofil_md`
13
+ bekommen -- ein Feld, das die Route nicht kennt. Die Zuordnung steht jetzt als
14
+ Tabelle in `FIRMEN_TEXTE` (`../kontext-stand.js`), und dass sie mit `FELDER`
15
+ in der App uebereinstimmt, prueft `check:kontext`.
16
+ */
17
+ // 📌 `bekannterStandAus` wohnte hier (Fix-Runde 1, Review-Fund 1) und ist seit
18
+ // Task 10 nach `../kontext-stand.js` umgezogen -- `cli/src/api.ts` braucht
19
+ // dieselbe Rechnung fuer den Kopf `X-Kimal3-Kontext-Stand`, und ein Import
20
+ // von HIER haette `api.ts` in einen Kreisimport mit sich selbst gezwungen
21
+ // (diese Datei importiert `ruf` aus `../api.js`). Die volle Begruendung der
22
+ // Rechnung selbst steht jetzt dort.
23
+ const HILFE_HOCH = `
24
+ kimal3 kontext hoch [--firma "Name" | --firma-id "…"] [--ueberschreiben]
25
+
26
+ Schickt deine lokalen Aenderungen an allen fuenf Firmen-Texten nach oben --
27
+ fuer den Firmen-Admin. Nur wer Admin ist, darf das; die App entscheidet.
28
+
29
+ Die fuenf sind die Dateien in kontext/: kurzprofil.md, firma.md, marke.md,
30
+ systeme.md, sicherheit.md.
31
+
32
+ Welche Fragen darin noch offen sind, zeigt "kimal3 kontext fragen".
33
+
34
+ --firma "Name" Noetig, wenn du zu mehreren Firmen gehoerst.
35
+ Die Absage nennt dir die Namen als fertige Befehle.
36
+
37
+ --firma-id "…" Fuer den Fall, dass zwei deiner Firmen GLEICH heissen.
38
+
39
+ --ueberschreiben Speichert auch dann, wenn die Firma inzwischen von
40
+ jemand anderem geaendert wurde (§7d).
41
+ `;
42
+ /**
43
+ * Schickt die fuenf Firmen-Texte des gefundenen Arbeitsplatzes nach oben --
44
+ * die Gegenrichtung zu `kontextRunter` (`kontext-runter.ts`). Fundregel,
45
+ * Firmenwahl-Absage und die `--firma`/`--firma-id`-Optionen teilt sie sich mit
46
+ * `kontextRunter` ueber `kontext-gemeinsam.ts` (Ruling T9-2).
47
+ *
48
+ * 🔴 **FUENF GLEICHRANGIGE DATEIEN IN `kontext/` -- kein Sonderfall mehr
49
+ * (Ruling K-6, Task 7).** Bis hierher las dieser Befehl die drei Saetze aus der
50
+ * Firmen-`CLAUDE.md` heraus. Seit Task 6 stehen dort nur noch die ANTWORTEN,
51
+ * ohne Anker und ohne Ueberschriften -- zurueckgeschickt haetten sie die Spalte
52
+ * mit einem Text gefuellt, in dem die App keine einzige beantwortete Frage
53
+ * findet. Das Formular haette danach wieder das leere Fragengeruest gezeigt,
54
+ * mit den Saetzen der Firma unsichtbar dahinter. Gepflegt wird stattdessen
55
+ * `kontext/kurzprofil.md`, wie die vier anderen auch.
56
+ *
57
+ * 🔴 **Importiert bewusst KEIN `FELDER` aus `src/app/firma/kontext-felder.ts`.**
58
+ * `cli/src/` ist ein eigenes Paket mit eigenem `tsconfig` und darf nichts aus
59
+ * `src/` importieren (s. Kopf von `kontext-stand.ts`). Welche Datei welches
60
+ * Feld fuellt, steht deshalb in `FIRMEN_TEXTE` -- und dass diese Tabelle mit
61
+ * `FELDER` uebereinstimmt, ist eine Pruefung in `check:kontext`.
62
+ */
63
+ export async function kontextHoch(argv) {
64
+ if (argv.includes("--help") || argv.includes("-h")) {
65
+ console.log(HILFE_HOCH);
66
+ return 0;
67
+ }
68
+ const ueberschreiben = argv.includes("--ueberschreiben");
69
+ // ⚠️ `zerlege` kennt keine Optionen ohne Wert -- `--ueberschreiben` wird
70
+ // deshalb VOR der Zerlegung herausgefiltert, genau wie `--help`/`-h` oben
71
+ // nie an `zerlege` gereicht werden.
72
+ const ohneFlag = argv.filter((a) => a !== "--ueberschreiben");
73
+ const zerlegt = zerlege(ohneFlag, ERLAUBT);
74
+ if (!zerlegt.ok) {
75
+ console.error(zerlegt.meldung);
76
+ return 1;
77
+ }
78
+ const ordner = arbeitsplatzOderAbsage();
79
+ if (ordner === null)
80
+ return 1;
81
+ // 🔴 Fix-Runde 1: der bekannte Stand kommt aus ALLEN Merkzettel-Eintraegen
82
+ // (`bekannterStandAus`), nicht nur aus den Dateien, die gleich gelesen
83
+ // werden. Die Firmenzeile traegt genau EIN `geaendert_am` fuer alle fuenf
84
+ // Felder -- jeder nicht-leere Eintrag kennt es also, auch der der `CLAUDE.md`.
85
+ const lokalerMerkzettel = leseMerkzettelDatei(ordner);
86
+ const bekannterStand = bekannterStandAus(lokalerMerkzettel);
87
+ /*
88
+ ---- die fuenf Texte lesen ---------------------------------------------
89
+
90
+ 🔴 JEDER ABBRUCH HIER PASSIERT VOR DEM ERSTEN `ruf` -- es wird nichts
91
+ gesendet, auch nichts Halbes. Ein Lauf, der vier von fuenf Texten schickt
92
+ und dabei einen Fehler meldet, laesst den Admin ratlos zurueck: Er weiss
93
+ dann nicht, was jetzt in der App steht. *Ein halber Stand, den niemand
94
+ benennen kann, ist teurer als ein Abbruch, der klar sagt, warum.*
95
+ */
96
+ const werte = {};
97
+ for (const datei of FIRMEN_TEXTE) {
98
+ const gelesen = leseEineDatei(ordner, datei.pfad);
99
+ if (!gelesen.ok) {
100
+ console.error(gelesen.meldung);
101
+ return 1;
102
+ }
103
+ if (gelesen.inhalt === null) {
104
+ console.error(`„${datei.pfad}" gibt es hier nicht -- ich schicke nichts Halbes hoch.`);
105
+ console.error(" Erst `kimal3 kontext runter` ausfuehren, dann bearbeiten, dann noch einmal.");
106
+ return 1;
107
+ }
108
+ /*
109
+ 🔴 **DIE FALSCHE DATEI ERWISCHT -- DER ABBRUCH (Spec, Abschnitt 11).**
110
+
111
+ Ohne diese Pruefung schickt der Befehl jeden Inhalt hoch, der an dieser
112
+ Stelle liegt. Die App zerlegt ihn gegen den Fragenkatalog GENAU DIESER
113
+ Datei, findet keine einzige Antwort -- und legt den kompletten Text als
114
+ „Zusatz" ab. Die Firma haette danach einen Zusatz statt eines
115
+ Firmentextes, und die CLI haette dazu „✓ hochgeschickt" gemeldet.
116
+ *Ein Fehlerfall darf nie denselben Namen tragen wie ein Erfolgsfall.*
117
+
118
+ Der Fall ist nicht ausgedacht: Er entsteht durch ein verrutschtes
119
+ `cp`/`Copy-Item` ebenso wie durch eine Datei, die jemand von Hand
120
+ angelegt hat, weil `kontext runter` noch nie lief.
121
+ */
122
+ if (!traegtFragenVon(gelesen.inhalt, datei.ankerPraefix)) {
123
+ fremdeDateiAbsage(datei.pfad, datei.titel, datei.ankerPraefix, "Nichts wurde gespeichert.");
124
+ return 1;
125
+ }
126
+ werte[datei.feld] = gelesen.inhalt;
127
+ }
128
+ // ---- senden -------------------------------------------------------------
129
+ const wunsch = zerlegt.werte.optionen.firma;
130
+ const wunschId = zerlegt.werte.optionen["firma-id"];
131
+ const suche = new URLSearchParams();
132
+ if (wunsch !== undefined)
133
+ suche.set("firma", wunsch);
134
+ if (wunschId !== undefined)
135
+ suche.set("firma-id", wunschId);
136
+ const pfad = suche.toString() ? `/api/cli/kontext?${suche.toString()}` : "/api/cli/kontext";
137
+ const koerper = { stand: bekannterStand, ...werte };
138
+ if (ueberschreiben)
139
+ koerper.ueberschreiben = true;
140
+ const antwort = await ruf(pfad, { methode: "POST", mitAusweis: true, koerper });
141
+ if (antwort.status !== 200) {
142
+ return absage(antwort, "kontext hoch");
143
+ }
144
+ // 🔴 Ein `200` mit unlesbarem Rumpf ist KEIN Erfolg -- dasselbe Muster wie
145
+ // in `kontextRunter` und in `firma-daten.ts`.
146
+ if (antwort.koerper === null || typeof antwort.koerper !== "object") {
147
+ console.error("Die Antwort war nicht lesbar. Bitte gleich noch einmal.");
148
+ return 3;
149
+ }
150
+ const ergebnis = alsHochAntwort(antwort.koerper);
151
+ if (ergebnis === null) {
152
+ console.error("Die Antwort war nicht lesbar. Bitte gleich noch einmal.");
153
+ return 3;
154
+ }
155
+ /*
156
+ 🔴 §6b: Der Merkzettel aus der ANTWORT wird JETZT geschrieben -- im selben
157
+ Zug wie der Erfolg, nicht danach. Ohne ihn saehe das naechste
158
+ `kontext runter` „Server neuer" UND „lokal geaendert", wo keiner ist, und
159
+ sicherte die eigene, gerade erst hochgeladene Fassung unnoetig weg.
160
+
161
+ Schlaegt das Schreiben fehl, ist die Firma trotzdem gespeichert -- das
162
+ Scheitern betrifft nur die lokale Buchfuehrung. Ein Fehlerfall darf nie
163
+ denselben Namen tragen wie ein Erfolgsfall (§8), deshalb sagt die Meldung
164
+ das ausdruecklich, statt einen missglueckten Datei-Schreibvorgang wie ein
165
+ gescheitertes Hochladen aussehen zu lassen.
166
+ */
167
+ /*
168
+ 🔴 **NICHT die Antwort des Servers eins zu eins** -- s. `merkzettelNachHoch`.
169
+ Ueber die Firmen-`CLAUDE.md` weiss dieser Befehl nichts: Er hat sie nicht
170
+ gesendet und schreibt sie nicht. Traege ihr Eintrag die Pruefsumme aus der
171
+ Antwort, meldete der naechste `kontext runter` „du hast lokal geaendert:
172
+ CLAUDE.md" und riete zu `kontext hoch` -- dem Befehl, der sie gar nicht
173
+ mitnimmt. Gemessen auf der Kundenstrecke am 2026-08-25.
174
+ */
175
+ const zuSchreiben = merkzettelNachHoch(ergebnis.merkzettel, lokalerMerkzettel, FIRMEN_TEXTE.map((d) => d.pfad));
176
+ const merkzettelSchreiben = schreibeMerkzettelDatei(ordner, zuSchreiben);
177
+ if (!merkzettelSchreiben.ok) {
178
+ console.error("Die Firma hat deine Aenderungen gespeichert -- nur der lokale Merkzettel liess sich nicht schreiben:");
179
+ console.error(merkzettelSchreiben.meldung);
180
+ return 1;
181
+ }
182
+ // US-3: WAS hochgeladen wurde, nicht nur "fertig".
183
+ //
184
+ // 🔑 Der Erklaersatz darunter ist mit Fund F ersatzlos WEGGEFALLEN. Er
185
+ // benannte eine Ausnahme, die es nicht mehr gibt -- fuenf Texte, zwei
186
+ // Richtungen, kein Sonderfall zu lernen. Die Namen kommen aus der Antwort
187
+ // der Route (`uebernommen`), nicht aus einer zweiten Liste hier.
188
+ console.log(`✓ hochgeschickt: ${ergebnis.uebernommen.join(", ")}`);
189
+ return 0;
190
+ }
191
+ /** Prueft die Antwort-Form von POST, ohne `any`. */
192
+ function alsHochAntwort(koerper) {
193
+ if (!koerper || typeof koerper !== "object")
194
+ return null;
195
+ const k = koerper;
196
+ const merkzettel = alsMerkzettel(k.merkzettel);
197
+ if (merkzettel === null)
198
+ return null;
199
+ const uebernommenRoh = k.uebernommen;
200
+ if (!Array.isArray(uebernommenRoh) || !uebernommenRoh.every((x) => typeof x === "string"))
201
+ return null;
202
+ return { merkzettel, uebernommen: uebernommenRoh };
203
+ }