kimal3 0.5.0 → 0.6.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
@@ -42,6 +42,8 @@ kimal3 kontext fragen Die Fragen über deine Firma beantworten — Claude f
42
42
  kimal3 kontext hoch Deine lokalen Änderungen an den fünf Firmen-Texten hochschicken
43
43
  kimal3 projekt neu … Ein Projekt ins Erfolgsprofil eintragen
44
44
  kimal3 projekt liste Deine eingetragenen Projekte anzeigen
45
+ kimal3 plan Planung hochladen oder Rückmeldungen holen
46
+ kimal3 plan hoch Die Planung aus plan/stand.json hochschicken (--probe: nur prüfen)
45
47
  kimal3 kurs Zeigen, wo du im Mini-Kurs stehst
46
48
  kimal3 firma daten Die Daten deiner Firma holen, damit Claude sie auswerten kann
47
49
  kimal3 zeigen start Eine Aufnahme starten, während du eine Aufgabe vormachst
@@ -182,6 +184,8 @@ kimal3 context questions Answer the questions about your company — Claude tak
182
184
  kimal3 context upload Upload your local changes to the five company texts
183
185
  kimal3 project new … Add a project to your success profile
184
186
  kimal3 project list Show the projects you've added
187
+ kimal3 plan Upload planning or fetch responses
188
+ kimal3 plan upload Upload the planning in plan/stand.json (--dry-run: only check)
185
189
  kimal3 course Show where you are in the mini-course
186
190
  kimal3 company data Fetch your company's data so Claude can analyse it
187
191
  kimal3 show start Start a recording while you show a task
@@ -0,0 +1,114 @@
1
+ import { feld, ruf } from "../api.js";
2
+ import { schalterOhneWert, zerlege } from "../argumente.js";
3
+ import { sprache } from "../sprache.js";
4
+ import { ALLGEMEIN_TEXTE } from "../texte/allgemein.js";
5
+ import { PLAN_TEXTE } from "../texte/plan.js";
6
+ import { leseSpecTexte, schreibeNachHochladen, specKoepfeAus } from "../plan-datei.js";
7
+ import { planAbsage, planDateiOderAbsage } from "./plan.js";
8
+ const istZahl = (w) => typeof w === "number" && Number.isInteger(w) && w >= 0;
9
+ function hochgeladenAus(koerper) {
10
+ if (!koerper || typeof koerper !== "object")
11
+ return null;
12
+ const k = koerper;
13
+ if (k.probe === true)
14
+ return null;
15
+ if (typeof k.projektId !== "string" || typeof k.titel !== "string" || typeof k.adresse !== "string" || typeof k.hochgeladenAm !== "string")
16
+ return null;
17
+ if (!istZahl(k.stories) || !istZahl(k.deinTest))
18
+ return null;
19
+ return { projektId: k.projektId, titel: k.titel, stories: k.stories, deinTest: k.deinTest, adresse: k.adresse, hochgeladenAm: k.hochgeladenAm, specTexte: istZahl(k.specTexte) ? k.specTexte : null };
20
+ }
21
+ function probeAus(koerper) {
22
+ if (!koerper || typeof koerper !== "object")
23
+ return null;
24
+ const k = koerper;
25
+ return k.probe === true && istZahl(k.stories) && istZahl(k.uebernommen) ? { stories: k.stories, uebernommen: k.uebernommen, specTexte: istZahl(k.specTexte) ? k.specTexte : null } : null;
26
+ }
27
+ /**
28
+ * Die Zeile aus US-16. `angekommen` kommt aus der Antwort des Servers (Eintraege mit Text), nicht aus dem, was
29
+ * die CLI geschickt hat: Eine alte App ohne das Feld antwortet ohne Zahl, und dann steht hier lieber keine Zeile
30
+ * als eine, die ein Hochladen behauptet, das nicht stattgefunden hat.
31
+ */
32
+ function specTexteZeile(angekommen, gesamt, ohne, s) {
33
+ if (angekommen === null)
34
+ return null;
35
+ const t = PLAN_TEXTE[s];
36
+ return t.specTexte(angekommen, gesamt, ohne.map((o) => t.specTextOhneEintrag(o.titel, t.specTextOhneGrund(o.grund, o.pfad))));
37
+ }
38
+ export async function planHoch(argv) {
39
+ let s = sprache();
40
+ if (argv.includes("--help") || argv.includes("-h")) {
41
+ console.log(PLAN_TEXTE[s].uploadHelp);
42
+ return 0;
43
+ }
44
+ // ⚠️ `zerlege` kennt keine Schalter ohne Wert -- beide VOR der Zerlegung heraus, wie `--ueberschreiben` in kontext-hoch.ts.
45
+ // Was danach uebrig bleibt, ist ein Tippfehler: `zerlege` ohne erlaubte Optionen sagt dazu den passenden Satz.
46
+ const ueber = schalterOhneWert(argv, "ueberschreiben");
47
+ const probe = schalterOhneWert(ueber.rest, "probe");
48
+ const zerlegt = zerlege(probe.rest, [], s);
49
+ if (!zerlegt.ok) {
50
+ console.error(zerlegt.meldung);
51
+ return 1;
52
+ }
53
+ const datei = planDateiOderAbsage(s);
54
+ if (datei === null)
55
+ return 1;
56
+ const hatteNummer = typeof datei.json.projekt_id === "string";
57
+ // Der Text der Specs geht im selben Aufruf mit (US-16); pro Spec liegt entweder der Text oder der Grund vor.
58
+ const koepfe = specKoepfeAus(datei.json);
59
+ // Eine Funktion baut den Rumpf: an ihm misst `leseSpecTexte` die 4 MB, und genau er geht hinaus (US-16).
60
+ const rumpf = (specTexte) => ({ stand: datei.text, ueberschreiben: ueber.gesetzt, probe: probe.gesetzt, specTexte });
61
+ const texte = leseSpecTexte(process.cwd(), koepfe, rumpf);
62
+ const antwort = await ruf("/api/cli/projekt/plan", {
63
+ methode: "POST", // kein-anzeigetext: HTTP-Verb
64
+ mitAusweis: true,
65
+ koerper: rumpf(texte.eintraege),
66
+ });
67
+ // 🌐 Die Antwort kann die Kontosprache umgestellt haben: Was ab hier gedruckt wird, in der neuen.
68
+ s = sprache();
69
+ const t = PLAN_TEXTE[s];
70
+ if (antwort.status !== 200) {
71
+ const code = planAbsage(antwort, s);
72
+ // Ruling 10: `503 teilweise` -- gespeichert, aber die Rueckmeldungen noch nicht markiert. `basis` muss trotzdem
73
+ // nachgetragen werden, sonst lehnt der zweite Lauf als „veraltet" ab; `uebernommen` bleibt fuer genau diesen Lauf stehen.
74
+ // Fehlen Nummer oder Zeitpunkt in der Antwort, wird nichts geschrieben (geraten wird hier nie).
75
+ const projektId = feld(antwort.koerper, "projektId"); // kein-anzeigetext: Feldname der Antwort
76
+ const hochgeladenAm = feld(antwort.koerper, "hochgeladenAm"); // kein-anzeigetext: Feldname der Antwort
77
+ if (antwort.status === 503 && feld(antwort.koerper, "grund") === "teilweise" && projektId !== null && hochgeladenAm !== null &&
78
+ !schreibeNachHochladen(process.cwd(), projektId, hochgeladenAm, false)) {
79
+ console.error(t.writeBackFailed(projektId, hochgeladenAm, false));
80
+ }
81
+ return code;
82
+ }
83
+ if (probe.gesetzt) {
84
+ const p = probeAus(antwort.koerper);
85
+ if (p === null) {
86
+ console.error(ALLGEMEIN_TEXTE[s].unreadableAnswer);
87
+ return 3;
88
+ }
89
+ console.log(t.dryRun(p.stories, p.uebernommen));
90
+ const zeile = specTexteZeile(p.specTexte, koepfe.length, texte.ohne, s);
91
+ if (zeile !== null)
92
+ console.log(zeile);
93
+ return 0;
94
+ }
95
+ const h = hochgeladenAus(antwort.koerper);
96
+ if (h === null) {
97
+ console.error(ALLGEMEIN_TEXTE[s].unreadableAnswer);
98
+ return 3;
99
+ }
100
+ // Erst sagen, was oben angekommen ist, dann, was daneben schiefging (dieselbe Reihenfolge wie in kontext-hoch.ts).
101
+ console.log(t.uploaded(h.titel, h.stories, h.deinTest));
102
+ const zeile = specTexteZeile(h.specTexte, koepfe.length, texte.ohne, s);
103
+ if (zeile !== null)
104
+ console.log(zeile);
105
+ console.log(t.view(h.adresse));
106
+ if (!schreibeNachHochladen(process.cwd(), h.projektId, h.hochgeladenAm, true)) {
107
+ // `1`, nicht `0`: Ohne `basis` in der Datei lehnt das naechste Hochladen als „veraltet" ab -- hier ist noch etwas zu tun.
108
+ console.error(t.writeBackFailed(h.projektId, h.hochgeladenAm, true));
109
+ return 1;
110
+ }
111
+ if (!hatteNummer)
112
+ console.log(t.idNowInFile);
113
+ return 0;
114
+ }
@@ -0,0 +1,110 @@
1
+ import { feld, ruf } from "../api.js";
2
+ import { sprache } from "../sprache.js";
3
+ import { ALLGEMEIN_TEXTE } from "../texte/allgemein.js";
4
+ import { PLAN_TEXTE } from "../texte/plan.js";
5
+ import { schlussFehler } from "../fehler.js";
6
+ import { lesePlanDatei } from "../plan-datei.js";
7
+ const istZahl = (w) => typeof w === "number" && Number.isInteger(w);
8
+ const textOderNull = (w) => typeof w === "string" || w === null;
9
+ function offenAus(w) {
10
+ if (!w || typeof w !== "object")
11
+ return null;
12
+ const r = w;
13
+ if (!istZahl(r.nummer) || (r.art !== "passt_nicht" && r.art !== "thema") || typeof r.notiz !== "string")
14
+ return null;
15
+ if (!textOderNull(r.spec) || !textOderNull(r.story) || !(istZahl(r.runde) || r.runde === null))
16
+ return null;
17
+ // Ein „passt nicht" ohne Story und Runde waere eine Zeile, aus der Claude nichts machen kann.
18
+ if (r.art === "passt_nicht" && (r.spec === null || r.story === null || r.runde === null))
19
+ return null;
20
+ return { nummer: r.nummer, art: r.art, spec: r.spec, story: r.story, runde: r.runde, notiz: r.notiz };
21
+ }
22
+ function bestaetigtAus(w) {
23
+ if (!w || typeof w !== "object")
24
+ return null;
25
+ const r = w;
26
+ return typeof r.spec === "string" && typeof r.story === "string" && istZahl(r.runde) ? { spec: r.spec, story: r.story, runde: r.runde } : null;
27
+ }
28
+ /** `null`, wenn die Antwort nicht die erwartete Form hat (z. B. ein WLAN-Anmeldeportal mit `200`). */
29
+ export function planAntwortAus(koerper) {
30
+ if (!koerper || typeof koerper !== "object")
31
+ return null;
32
+ const k = koerper;
33
+ if (typeof k.adresse !== "string" || !Array.isArray(k.offen) || !Array.isArray(k.bestaetigt))
34
+ return null;
35
+ const offen = k.offen.map((o) => offenAus(o));
36
+ const bestaetigt = k.bestaetigt.map((b) => bestaetigtAus(b));
37
+ if (offen.includes(null) || bestaetigt.includes(null))
38
+ return null;
39
+ return { adresse: k.adresse, offen: offen, bestaetigt: bestaetigt };
40
+ }
41
+ /** Reine Rechnung: die Zeilen, die Claude zu sehen bekommt. */
42
+ export function planZeilen(a, s) {
43
+ const t = PLAN_TEXTE[s];
44
+ const zeilen = a.offen.length === 0
45
+ ? [t.noOpen]
46
+ : a.offen.map((o) => (o.art === "thema" ? t.openTopic(o.nummer, o.notiz) : t.openVerdict(o.nummer, o.spec ?? "", o.story ?? "", o.runde ?? 0, o.notiz)));
47
+ if (a.bestaetigt.length > 0)
48
+ zeilen.push(t.confirmed(a.bestaetigt.map((b) => t.confirmedItem(b.spec, b.story, b.runde)).join(", ")));
49
+ zeilen.push(t.view(a.adresse));
50
+ return zeilen;
51
+ }
52
+ /** Die Datei fehlt oder ist kein JSON -- Satz auf stderr, `null`. Sonst die gelesene Datei. */
53
+ export function planDateiOderAbsage(s) {
54
+ const datei = lesePlanDatei(process.cwd());
55
+ if (datei.ok)
56
+ return datei;
57
+ console.error(datei.grund === "fehlt" ? PLAN_TEXTE[s].fileMissing : PLAN_TEXTE[s].noJson);
58
+ return null;
59
+ }
60
+ /**
61
+ * Was beide Befehle mit einer Absage tun. `409`/`422` tragen einen fertigen Satz in der Kontosprache
62
+ * (Projekt unbekannt oder mehrdeutig, Plan unlesbar, Runde kleiner, veraltet) -- woertlich ausgeben, `1`.
63
+ * `503 teilweise` heisst: gespeichert, aber nicht ganz -- `3`, denn ein zweiter Lauf hilft (Spec Abschnitt 5).
64
+ */
65
+ export function planAbsage(antwort, s) {
66
+ const meldung = feld(antwort.koerper, "meldung") ?? ALLGEMEIN_TEXTE[s].genericFailure;
67
+ if (antwort.status === 402) {
68
+ console.error(meldung);
69
+ return 4;
70
+ }
71
+ if (antwort.status === 401) {
72
+ console.error(`${meldung}${ALLGEMEIN_TEXTE[s].pleaseSignIn}`);
73
+ return 2;
74
+ }
75
+ if (antwort.status === 409 || antwort.status === 422) {
76
+ console.error(meldung);
77
+ return 1;
78
+ }
79
+ // `503 teilweise` landet ueber `schlussFehler` auf derselben `3` wie jeder andere Aussetzer -- mit dem Satz des Servers.
80
+ const schluss = schlussFehler(antwort.status, meldung, s);
81
+ console.error(schluss.text);
82
+ return schluss.wert;
83
+ }
84
+ export async function plan(argv) {
85
+ let s = sprache();
86
+ if (argv.includes("--help") || argv.includes("-h")) {
87
+ console.log(PLAN_TEXTE[s].listHelp);
88
+ return 0;
89
+ }
90
+ const datei = planDateiOderAbsage(s);
91
+ if (datei === null)
92
+ return 1;
93
+ const suche = new URLSearchParams();
94
+ if (typeof datei.json.projekt_id === "string")
95
+ suche.set("projekt-id", datei.json.projekt_id);
96
+ if (typeof datei.json.projekt === "string")
97
+ suche.set("projekt", datei.json.projekt);
98
+ const antwort = await ruf(`/api/cli/projekt/plan?${suche.toString()}`, { methode: "GET", mitAusweis: true }); // kein-anzeigetext: Pfad und HTTP-Methode
99
+ // 🌐 Die Antwort kann die Kontosprache umgestellt haben: Was ab hier gedruckt wird, in der neuen.
100
+ s = sprache();
101
+ if (antwort.status !== 200)
102
+ return planAbsage(antwort, s);
103
+ const a = planAntwortAus(antwort.koerper);
104
+ if (a === null) {
105
+ console.error(ALLGEMEIN_TEXTE[s].unreadableAnswer);
106
+ return 3;
107
+ }
108
+ planZeilen(a, s).forEach((z) => console.log(z));
109
+ return 0;
110
+ }
@@ -118,6 +118,20 @@ export const BEFEHLE_EN = {
118
118
  "What it's really for: Claude looks here BEFORE adding a project. Without that check, the same project would sooner or later appear twice on a public page that carries your name.",
119
119
  ],
120
120
  },
121
+ "kimal3 plan": {
122
+ stempel: "5a366449",
123
+ befehl: "kimal3 plan",
124
+ tippen: "/k3plan",
125
+ terminal: "kimal3 plan",
126
+ beschreibung: "Upload planning or fetch responses",
127
+ rhythmusOderSatz: "check my planning",
128
+ details: [
129
+ "kimal3 plan fetches what you've said in the app since the last upload: every “doesn't work” and every noted topic with its number, plus the confirmed stories. Claude sorts them and enters them in plan/stand.json.",
130
+ "kimal3 plan upload sends plan/stand.json to your project and then enters projekt_id and basis in the file itself. With --dry-run it only checks and saves nothing.",
131
+ "If the plan can't be read, a round goes down or the app already holds a newer plan, it refuses and saves nothing at all — the message says what to do. Use --overwrite only if you really mean it.",
132
+ "You'll know it worked when it reports with ✓ how many stories were uploaded and how many are waiting for your test, and gives the link to the planning.",
133
+ ],
134
+ },
121
135
  "kimal3 kurs": {
122
136
  stempel: "3a5c7600",
123
137
  befehl: "kimal3 course",
@@ -167,6 +167,24 @@ export const BEFEHLE = [
167
167
  "Wofür es wirklich da ist: Claude schaut hier VOR dem Eintragen nach. Ohne diesen Blick stünde dasselbe Projekt irgendwann zweimal auf einer öffentlichen Seite, die deinen Namen trägt.",
168
168
  ],
169
169
  },
170
+ /*
171
+ `kimal3 plan` und `kimal3 plan hoch` (Spec 2026-10-21-projektseite-planung) -- EIN Eintrag fuer beide: Claude
172
+ ruft sie nacheinander auf, und ein Mensch sucht hier nach der Planung, nicht nach zwei Befehlen.
173
+ */
174
+ {
175
+ befehl: "kimal3 plan",
176
+ beschreibung: "Planung hochladen oder Rückmeldungen holen",
177
+ wer: "claude",
178
+ ausloeser: "prüf meine Planung",
179
+ tippen: "/k3plan",
180
+ terminal: "kimal3 plan",
181
+ details: [
182
+ "kimal3 plan holt aus der App, was du seit dem letzten Hochladen gesagt hast: jedes „passt nicht“ und jedes notierte Thema mit seiner Nummer, dazu die bestätigten Stories. Claude ordnet sie ein und trägt sie in plan/stand.json ein.",
183
+ "kimal3 plan hoch schickt plan/stand.json an dein Projekt und trägt danach projekt_id und basis selbst in die Datei ein. Mit --probe prüft er nur und speichert nichts.",
184
+ "Ist der Plan nicht lesbar, wird eine Runde kleiner oder liegt in der App schon ein neuerer Plan, lehnt er ab und speichert gar nichts — die Meldung sagt, was zu tun ist. --ueberschreiben nur, wenn du es wirklich willst.",
185
+ "Geklappt hat es, wenn er mit ✓ meldet, wie viele Stories hochgeladen sind und wie viele auf deinen Test warten, und den Link zur Planung nennt.",
186
+ ],
187
+ },
170
188
  {
171
189
  befehl: "kimal3 kurs",
172
190
  beschreibung: "Zeigen, wo du im Mini-Kurs stehst",
@@ -10,6 +10,9 @@ export const BEFEHLSWOERTER = {
10
10
  "kontext fragen": { de: "kontext fragen", en: "context questions" }, // kein-anzeigetext: Befehlswörter (Spec Abschnitt 6)
11
11
  "projekt neu": { de: "projekt neu", en: "project new" }, // kein-anzeigetext: Befehlswörter (Spec Abschnitt 6)
12
12
  "projekt liste": { de: "projekt liste", en: "project list" }, // kein-anzeigetext: Befehlswörter (Spec Abschnitt 6)
13
+ // `kimal3 plan` (Spec 2026-10-21-projektseite-planung, US-9): `plan` heisst in beiden Sprachen gleich.
14
+ plan: { de: "plan", en: "plan" },
15
+ "plan hoch": { de: "plan hoch", en: "plan upload" }, // kein-anzeigetext: Befehlswörter (Spec projektseite-planung)
13
16
  "firma daten": { de: "firma daten", en: "company data" }, // kein-anzeigetext: Befehlswörter (Spec Abschnitt 6)
14
17
  kurs: { de: "kurs", en: "course" }, // kein-anzeigetext: Befehlswörter (Spec Abschnitt 6)
15
18
  wache: { de: "wache", en: "watch" },
@@ -71,6 +74,8 @@ export const SCHALTER_EN = {
71
74
  "at-most": "hoechstens",
72
75
  language: "sprache",
73
76
  "without-typed": "ohne-getipptes",
77
+ // `kimal3 plan upload` (Spec 2026-10-21-projektseite-planung, US-11); `--overwrite` steht schon oben.
78
+ "dry-run": "probe",
74
79
  };
75
80
  /** `company` -> `firma`; ein deutscher (oder unbekannter) Name bleibt, wie er ist. */
76
81
  export function deutscherSchalter(getippt) {
package/dist/kimal3.js CHANGED
@@ -36,6 +36,8 @@ import { logout } from "./befehle/logout.js";
36
36
  import { kurs } from "./befehle/kurs.js";
37
37
  import { projektListe } from "./befehle/projekt-liste.js";
38
38
  import { projektNeu } from "./befehle/projekt-neu.js";
39
+ import { plan } from "./befehle/plan.js";
40
+ import { planHoch } from "./befehle/plan-hoch.js";
39
41
  import { skillDatei, skillInstallBefehl } from "./befehle/skill-install.js";
40
42
  import { wache, wacheSchalten } from "./befehle/wache.js";
41
43
  import { zeigenAuswerten, zeigenBild, zeigenStatus, zeigenStopp } from "./befehle/zeigen.js";
@@ -487,6 +489,9 @@ async function fuehreAus(name, rest) {
487
489
  case "skill install": return allein ? skillInstallBefehl() : null;
488
490
  case "projekt neu": return projektNeu(rest);
489
491
  case "projekt liste": return allein ? projektListe() : null;
492
+ // `plan` nimmt nur `--help`; ein Wort dahinter (`plan muell`) bleibt unbekannt wie bei `login extra`.
493
+ case "plan": return allein || (rest.length === 1 && (rest[0] === "--help" || rest[0] === "-h")) ? plan(rest) : null;
494
+ case "plan hoch": return planHoch(rest);
490
495
  case "kurs": return allein ? kurs() : null;
491
496
  // ⚠️ Rest wird durchgereicht, nicht auf Gleichheit geprueft: `firma daten --firma X` gehoert dazu.
492
497
  case "firma daten": return firmaDaten(rest);
@@ -0,0 +1,172 @@
1
+ /**
2
+ * `plan/stand.json` lesen und nach dem Hochladen nachtragen (Spec 2026-10-21-projektseite-planung, Abschnitt 3).
3
+ *
4
+ * 🔑 Die Datei gehoert Claude und liegt unter Git -- diese Datei prueft sie NICHT. Ob ein Plan stimmt,
5
+ * entscheidet allein der Server (`lesePlanText`); hier geht es nur darum, ob es ueberhaupt ein JSON-Objekt
6
+ * ist, damit ein Tippfehler im Klammerwerk ohne Netz und ohne Anmeldung auffaellt.
7
+ *
8
+ * ⚠️ Ein BOM am Anfang (Windows-Editoren setzen eines) wird beim Lesen entfernt: `JSON.parse` lehnt es ab,
9
+ * und der Server meldete sonst „kein JSON" fuer eine Datei, die im Editor einwandfrei aussieht.
10
+ */
11
+ import { readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
12
+ import { isAbsolute, join, relative, resolve } from "node:path";
13
+ export const PLAN_PFAD = "plan/stand.json"; // kein-anzeigetext: Dateipfad
14
+ const ohneBom = (text) => (text.startsWith("\uFEFF") ? text.slice(1) : text);
15
+ function alsObjekt(text) {
16
+ try {
17
+ const json = JSON.parse(text);
18
+ return json !== null && typeof json === "object" && !Array.isArray(json) ? json : null;
19
+ }
20
+ catch {
21
+ return null;
22
+ }
23
+ }
24
+ export function lesePlanDatei(ordner) {
25
+ let text;
26
+ try {
27
+ text = ohneBom(readFileSync(join(ordner, PLAN_PFAD), "utf8"));
28
+ }
29
+ catch {
30
+ return { ok: false, grund: "fehlt" };
31
+ }
32
+ const json = alsObjekt(text);
33
+ return json === null ? { ok: false, grund: "kein-json" } : { ok: true, text, json };
34
+ }
35
+ /**
36
+ * Traegt `projekt_id` und `basis` ein und leert `uebernommen` -- alles andere bleibt, wie es ist.
37
+ * 🔑 `uebernommenLeeren` ist PFLICHT, nicht optional: Nach `503 teilweise` ist der Plan gespeichert, die
38
+ * Rueckmeldungen aber noch nicht markiert (Ruling 10). Dann muss `basis` neu, `uebernommen` aber stehen
39
+ * bleiben -- sonst gingen sie beim zweiten Lauf verloren, und mit altem `basis` lehnte er als „veraltet" ab.
40
+ * Fehlen die beiden Felder, kommen sie hinter `projekt` (dort stehen sie im Beispiel der Spec), nicht ans Ende.
41
+ * Eingerueckt mit 2 Leerzeichen; Windows-Zeilenenden bleiben Windows-Zeilenenden.
42
+ * `false` heisst: nicht geschrieben (Datei weg, kein JSON mehr, Schreibschutz).
43
+ */
44
+ export function schreibeNachHochladen(ordner, projektId, hochgeladenAm, uebernommenLeeren) {
45
+ const pfad = join(ordner, PLAN_PFAD);
46
+ try {
47
+ const roh = readFileSync(pfad, "utf8");
48
+ const alt = alsObjekt(ohneBom(roh));
49
+ if (alt === null)
50
+ return false;
51
+ const neu = {};
52
+ for (const [schluessel, wert] of Object.entries(alt)) {
53
+ neu[schluessel] = wert;
54
+ if (schluessel === "projekt") {
55
+ neu.projekt_id = projektId;
56
+ neu.basis = hochgeladenAm;
57
+ }
58
+ }
59
+ neu.projekt_id = projektId;
60
+ neu.basis = hochgeladenAm;
61
+ if (uebernommenLeeren)
62
+ neu.uebernommen = [];
63
+ const text = `${JSON.stringify(neu, null, 2)}\n`;
64
+ writeFileSync(pfad, roh.includes("\r\n") ? text.replace(/\n/g, "\r\n") : text, "utf8");
65
+ return true;
66
+ }
67
+ catch {
68
+ return false;
69
+ }
70
+ }
71
+ export const SPEC_TEXT_GRENZE_JE = 300 * 1024; // kein-anzeigetext: Bytes
72
+ export const SPEC_TEXT_GRENZE_ZUSAMMEN = 4 * 1024 * 1024; // kein-anzeigetext: Bytes
73
+ /**
74
+ * Darf diese Datei gelesen werden? Nur ein relativer Pfad, der nach dem Aufloesen (auch einer Verknuepfung) im
75
+ * Projektordner bleibt und auf `.md` endet. 🔴 Positivliste: erst alles ausser dem Erlaubten abweisen -- die
76
+ * Schreibweisen fuer „woanders" (Laufwerk, Netzwerkpfad, Wurzel, `..`) sind nicht zu Ende aufzaehlbar.
77
+ * `wirklich` ist `realpathSync` (im Test ersetzt) und liefert `null`, wo es die Datei nicht gibt.
78
+ */
79
+ export function specPfadPruefen(ordner, datei, wirklich) {
80
+ if (datei === null || datei.trim() === "")
81
+ return { ok: false, grund: "keine_datei" };
82
+ if (!/\.md$/i.test(datei))
83
+ return { ok: false, grund: "ausserhalb" };
84
+ // Laufwerk, Wurzel und Netzwerkpfad gelten auf JEDEM System als „woanders", auch wo `isAbsolute` sie nicht kennt.
85
+ if (isAbsolute(datei) || /^[A-Za-z]:/.test(datei) || /^[\\/]/.test(datei))
86
+ return { ok: false, grund: "ausserhalb" };
87
+ const innen = (wurzel, ziel) => {
88
+ const r = relative(wurzel, ziel);
89
+ return r !== "" && r.split(/[\\/]/)[0] !== ".." && !isAbsolute(r);
90
+ };
91
+ const ziel = resolve(ordner, datei);
92
+ if (!innen(resolve(ordner), ziel))
93
+ return { ok: false, grund: "ausserhalb" };
94
+ const echt = wirklich(ziel);
95
+ if (echt !== null && !innen(wirklich(resolve(ordner)) ?? resolve(ordner), echt))
96
+ return { ok: false, grund: "ausserhalb" };
97
+ return { ok: true, pfad: echt ?? ziel };
98
+ }
99
+ const wirklichOderNull = (p) => {
100
+ try {
101
+ return realpathSync(p);
102
+ }
103
+ catch {
104
+ return null;
105
+ }
106
+ };
107
+ /** `stand.json` -> die Koepfe der Specs; Unlesbares wird uebersprungen (ob der Plan stimmt, sagt allein der Server). */
108
+ export function specKoepfeAus(json) {
109
+ if (!Array.isArray(json.specs))
110
+ return [];
111
+ return json.specs.flatMap((s) => {
112
+ if (!s || typeof s !== "object")
113
+ return [];
114
+ const r = s;
115
+ if (typeof r.schluessel !== "string")
116
+ return [];
117
+ return [{ schluessel: r.schluessel, titel: typeof r.titel === "string" ? r.titel : r.schluessel, datei: typeof r.datei === "string" ? r.datei : null }];
118
+ });
119
+ }
120
+ /** Wie gross der Text im JSON-Rumpf wird (mit Maskierung) -- danach wird der groesste gewaehlt, der herausfaellt. */
121
+ const rumpfBytes = (text) => Buffer.byteLength(JSON.stringify(text), "utf8");
122
+ /**
123
+ * Liest den Text jeder Spec. Jede Spec bekommt GENAU einen Eintrag: den Text oder den Grund, warum keiner da ist.
124
+ * Passt der fertige Anfrage-Rumpf nicht in 4 MB, werden die groessten Texte zu `zu_gross`, bis er passt (US-16:
125
+ * „am fertigen Anfrage-Rumpf"). `rumpf` baut genau den Rumpf, den `plan hoch` schickt (Stand, Schalter, Texte) --
126
+ * dieselbe Funktion baut ihn dort auch, damit gemessen wird, was hinausgeht (Befund M-3).
127
+ */
128
+ export function leseSpecTexte(ordner, specs, rumpf) {
129
+ const keineTextdatei = new Set();
130
+ const eintraege = specs.map((s, i) => {
131
+ const ohne = (grund) => ({ schluessel: s.schluessel, grund, pfad: s.datei });
132
+ const pruefung = specPfadPruefen(ordner, s.datei, wirklichOderNull);
133
+ if (!pruefung.ok)
134
+ return ohne(pruefung.grund);
135
+ try {
136
+ const status = statSync(pruefung.pfad);
137
+ if (!status.isFile())
138
+ return ohne("fehlt");
139
+ if (status.size > SPEC_TEXT_GRENZE_JE)
140
+ return ohne("zu_gross");
141
+ const text = readFileSync(pruefung.pfad, "utf8");
142
+ if (Buffer.byteLength(text, "utf8") > SPEC_TEXT_GRENZE_JE)
143
+ return ohne("zu_gross");
144
+ // Ein Nullzeichen nimmt Postgres in `text` nicht an -- der Server lehnte den ganzen Aufruf ab (Befund M-1).
145
+ if (text.includes("\u0000")) {
146
+ keineTextdatei.add(i);
147
+ return ohne("fehlt");
148
+ }
149
+ return { schluessel: s.schluessel, text: ohneBom(text) };
150
+ }
151
+ catch {
152
+ return ohne("fehlt");
153
+ }
154
+ });
155
+ const groesse = () => Buffer.byteLength(JSON.stringify(rumpf(eintraege)), "utf8");
156
+ while (groesse() > SPEC_TEXT_GRENZE_ZUSAMMEN) {
157
+ let groesster = -1;
158
+ let groessterBytes = -1;
159
+ eintraege.forEach((e, i) => {
160
+ const b = "text" in e ? rumpfBytes(e.text) : -1;
161
+ if (b > groessterBytes) {
162
+ groesster = i;
163
+ groessterBytes = b;
164
+ }
165
+ });
166
+ if (groesster < 0)
167
+ break;
168
+ eintraege[groesster] = { schluessel: specs[groesster].schluessel, grund: "zu_gross", pfad: specs[groesster].datei };
169
+ }
170
+ const ohne = eintraege.flatMap((e, i) => "grund" in e ? [{ titel: specs[i].titel, grund: keineTextdatei.has(i) ? "keine_textdatei" : e.grund, pfad: e.pfad }] : []);
171
+ return { eintraege, ohne };
172
+ }
@@ -5,6 +5,7 @@ export const SLASH_PAARE = [
5
5
  { de: "k3feierabend", en: "k3wrapup" }, // kein-anzeigetext: Befehlsname (Spec Teil 4, Abschnitt 6)
6
6
  { de: "k3kontext", en: "k3context" }, // kein-anzeigetext: Befehlsname
7
7
  { de: "k3projekt", en: "k3project" }, // kein-anzeigetext: Befehlsname
8
+ { de: "k3plan", en: "k3plan" }, // kein-anzeigetext: Befehlsname, in beiden Sprachen gleich (Spec 2026-10-21-projektseite-planung, C-7)
8
9
  { de: "k3liste", en: "k3list" }, // kein-anzeigetext: Befehlsname
9
10
  { de: "k3analyse", en: "k3analyse" }, // britisch, in beiden Sprachen gleich
10
11
  { de: "k3kurs", en: "k3course" }, // kein-anzeigetext: Befehlsname
@@ -0,0 +1,93 @@
1
+ const de = {
2
+ // ── plan.ts ──
3
+ listHelp: `
4
+ kimal3 plan
5
+
6
+ Holt die offenen Rückmeldungen zu deiner Planung aus der App: jedes
7
+ „passt nicht“ und jedes notierte Thema mit seiner Nummer, dazu die
8
+ bestätigten Stories der aktuellen Runden.
9
+
10
+ Liest plan/stand.json im Ordner, um das Projekt zu finden. Ändert nichts.
11
+ `,
12
+ fileMissing: "Im Ordner liegt keine plan/stand.json. `/k3plan` legt sie an.",
13
+ noJson: "Der Plan ist nicht lesbar: plan/stand.json enthält kein gültiges JSON-Objekt.",
14
+ openVerdict: (nummer, spec, story, runde, notiz) => `#${nummer} · passt nicht · ${spec} / ${story} (Runde ${runde}): ${notiz}`,
15
+ openTopic: (nummer, notiz) => `#${nummer} · Thema: ${notiz}`,
16
+ noOpen: "Keine offenen Rückmeldungen.",
17
+ confirmed: (liste) => `Bestätigt: ${liste}`,
18
+ confirmedItem: (spec, story, runde) => `${spec} / ${story} (Runde ${runde})`,
19
+ view: (adresse) => `Ansehen: ${adresse}`,
20
+ // ── plan-hoch.ts ──
21
+ uploadHelp: `
22
+ kimal3 plan hoch [--ueberschreiben] [--probe]
23
+
24
+ Schickt plan/stand.json an dein Projekt in der App. Danach trägt es
25
+ projekt_id und basis in die Datei ein und leert uebernommen.
26
+
27
+ --probe Prüft alles wie beim Hochladen und speichert nichts.
28
+
29
+ --ueberschreiben Speichert auch dann, wenn in der App inzwischen ein
30
+ neuerer Plan liegt (zwei Arbeitskopien).
31
+ `,
32
+ uploaded: (titel, stories, deinTest) => `✓ Planung für „${titel}“ hochgeladen: ${stories === 1 ? "1 Story" : `${stories} Stories`}, ` +
33
+ `${deinTest === 1 ? "1 wartet" : `${deinTest} warten`} auf deinen Test.`,
34
+ dryRun: (stories, uebernommen) => `Probelauf: würde ${stories === 1 ? "1 Story" : `${stories} Stories`} hochladen, ` +
35
+ `${uebernommen === 1 ? "1 Rückmeldung" : `${uebernommen} Rückmeldungen`} als übernommen melden. Gespeichert wurde nichts.`,
36
+ // US-16: Eine Spec ohne Text ist ein Hinweis, kein Fehlschlag -- darum steht der Grund in Klammern hinter ihrem Namen.
37
+ specTextOhneGrund: (grund, pfad) => grund === "fehlt" ? `${pfad ?? ""} nicht gefunden`
38
+ : grund === "keine_datei" ? "keine Datei angegeben"
39
+ : grund === "ausserhalb" ? "Pfad außerhalb des Projekts"
40
+ : grund === "keine_textdatei" ? "keine Textdatei"
41
+ : "größer als 300 KB",
42
+ specTexte: (angekommen, gesamt, ohne) => `Spec-Texte hochgeladen: ${angekommen} von ${gesamt}.${ohne.length === 0 ? "" : ` Ohne Text: ${ohne.join(", ")}.`}`,
43
+ specTextOhneEintrag: (titel, grundText) => `„${titel}“ (${grundText})`,
44
+ idNowInFile: "projekt_id und basis stehen jetzt in plan/stand.json.",
45
+ writeBackFailed: (projektId, hochgeladenAm, leeren) => "⚠ Die Planung ist gespeichert, aber plan/stand.json ließ sich nicht nachtragen. Trag selbst ein: " +
46
+ `"projekt_id": "${projektId}", "basis": "${hochgeladenAm}"${leeren ? ', "uebernommen": []' : ""}`,
47
+ };
48
+ const en = {
49
+ listHelp: `
50
+ kimal3 plan
51
+
52
+ Fetches the open responses to your planning from the app: every
53
+ “doesn't work” and every noted topic with its number, plus the
54
+ confirmed stories of the current rounds.
55
+
56
+ Reads plan/stand.json in this folder to find the project. Changes nothing.
57
+ `,
58
+ fileMissing: "There's no plan/stand.json in this folder. `/k3plan` creates it.",
59
+ noJson: "The plan can't be read: plan/stand.json doesn't contain a valid JSON object.",
60
+ openVerdict: (nummer, spec, story, runde, notiz) => `#${nummer} · doesn't work · ${spec} / ${story} (round ${runde}): ${notiz}`,
61
+ openTopic: (nummer, notiz) => `#${nummer} · Topic: ${notiz}`,
62
+ noOpen: "No open responses.",
63
+ confirmed: (liste) => `Confirmed: ${liste}`,
64
+ confirmedItem: (spec, story, runde) => `${spec} / ${story} (round ${runde})`,
65
+ view: (adresse) => `View: ${adresse}`,
66
+ uploadHelp: `
67
+ kimal3 plan upload [--overwrite] [--dry-run]
68
+
69
+ Sends plan/stand.json to your project in the app. After that it enters
70
+ projekt_id and basis in the file and empties uebernommen.
71
+
72
+ --dry-run Checks everything like an upload and saves nothing.
73
+
74
+ --overwrite Saves even if the app already holds a newer plan
75
+ (two working copies).
76
+
77
+ The German option names (--ueberschreiben, --probe) work too.
78
+ `,
79
+ uploaded: (titel, stories, deinTest) => `✓ Planning for "${titel}" uploaded: ${stories === 1 ? "1 story" : `${stories} stories`}, ${deinTest} waiting for your test.`,
80
+ dryRun: (stories, uebernommen) => `Dry run: would upload ${stories === 1 ? "1 story" : `${stories} stories`} and mark ` +
81
+ `${uebernommen === 1 ? "1 response" : `${uebernommen} responses`} as handled. Nothing was saved.`,
82
+ specTextOhneGrund: (grund, pfad) => grund === "fehlt" ? `${pfad ?? ""} not found`
83
+ : grund === "keine_datei" ? "no file given"
84
+ : grund === "ausserhalb" ? "path outside the project"
85
+ : grund === "keine_textdatei" ? "not a text file"
86
+ : "larger than 300 KB",
87
+ specTexte: (angekommen, gesamt, ohne) => `Spec texts uploaded: ${angekommen} of ${gesamt}.${ohne.length === 0 ? "" : ` Without text: ${ohne.join(", ")}.`}`,
88
+ specTextOhneEintrag: (titel, grundText) => `"${titel}" (${grundText})`,
89
+ idNowInFile: "projekt_id and basis are now in plan/stand.json.",
90
+ writeBackFailed: (projektId, hochgeladenAm, leeren) => "⚠ The planning is saved, but plan/stand.json couldn't be updated. Enter it yourself: " +
91
+ `"projekt_id": "${projektId}", "basis": "${hochgeladenAm}"${leeren ? ', "uebernommen": []' : ""}`,
92
+ };
93
+ export const PLAN_TEXTE = { de, en };