kimal3 0.6.5 β†’ 0.8.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.
Files changed (47) hide show
  1. package/README.md +10 -4
  2. package/claude-plugins/.claude-plugin/marketplace.json +2 -1
  3. package/claude-plugins/README.md +2 -0
  4. package/claude-plugins/kimal3-logo/.claude-plugin/plugin.json +1 -1
  5. package/claude-plugins/kimal3-logo/hooks/app-oeffnen.ts +37 -9
  6. package/claude-plugins/kimal3-logo/hooks/register.tsx +1 -1
  7. package/claude-plugins/kimal3-plan/.claude-plugin/plugin.json +7 -0
  8. package/claude-plugins/kimal3-plan/hooks/erinnerung.ts +83 -0
  9. package/claude-plugins/kimal3-plan/hooks/fehlerzeile.ts +41 -0
  10. package/claude-plugins/kimal3-plan/hooks/hooks.json +1 -0
  11. package/claude-plugins/kimal3-plan/hooks/lauf.ts +143 -0
  12. package/claude-plugins/kimal3-plan/hooks/lebenszeichen.ts +197 -0
  13. package/claude-plugins/kimal3-plan/hooks/ordner.ts +141 -0
  14. package/claude-plugins/kimal3-plan/hooks/register.tsx +22 -0
  15. package/claude-plugins/kimal3-plan/hooks/spec-text.ts +45 -0
  16. package/claude-plugins/kimal3-plan/types/index.d.ts +10 -0
  17. package/coach/de/kimal3-coach.md +41 -1
  18. package/coach/en/kimal3-coach.md +41 -1
  19. package/dist/argumente.js +12 -0
  20. package/dist/atomar.js +22 -0
  21. package/dist/befehle/einrichten.js +4 -4
  22. package/dist/befehle/login.js +2 -2
  23. package/dist/befehle/plan-hoch.js +8 -0
  24. package/dist/befehle/plan-melde.js +105 -0
  25. package/dist/befehle/plan-senden.js +149 -0
  26. package/dist/befehle/plan.js +45 -10
  27. package/dist/befehle/projekt-verbinden.js +141 -0
  28. package/dist/befehle/skill-install.js +2 -2
  29. package/dist/befehle-katalog-en.js +6 -6
  30. package/dist/befehle-katalog.js +8 -7
  31. package/dist/befehlswoerter.js +9 -0
  32. package/dist/einstellungen-schreiben.js +1 -1
  33. package/dist/kimal3.js +22 -0
  34. package/dist/meldungen-speicher.js +189 -0
  35. package/dist/{logo-ablegen.js β†’ mods-ablegen.js} +29 -18
  36. package/dist/plugins-mischen.js +7 -2
  37. package/dist/projekt-ordner.js +189 -0
  38. package/dist/texte/einrichten.js +3 -1
  39. package/dist/texte/meldungen.js +127 -0
  40. package/dist/texte/plan.js +42 -22
  41. package/dist/texte/skill.js +12 -12
  42. package/package.json +1 -1
  43. package/skill/commands/k3feierabend.md +29 -0
  44. package/skill/commands/k3plan.md +167 -213
  45. package/skill/en/commands/k3plan.md +174 -222
  46. package/skill/en/commands/k3show.md +3 -1
  47. package/skill/en/commands/k3wrapup.md +28 -0
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Meldungen an die Projektseite und die gemerkten, die noch nicht ankamen (Spec 2026-10-23-brett-in-echtzeit-design.md,
3
+ * 1.1, E-6, S-10, US-4; Bauplan R-4).
4
+ *
5
+ * πŸ”‘ `Meldung` und `Umschlag` stehen hier Feld fuer Feld wie in `src/lib/planung/meldung.ts` -- `cli/` kann `src/` nicht
6
+ * importieren. `meldungFehler` urteilt wie `pruefeMeldung` dort; `check:cli` haelt beide an denselben Proben gegeneinander.
7
+ * Warum die CLI ueberhaupt selbst prueft: Die App lehnt eine Anfrage mit EINER unlesbaren Meldung ganz ab. Eine solche,
8
+ * offline gemerkt, hielte sonst jede spaetere Sendung auf -- fuer immer.
9
+ *
10
+ * Gemerkt wird unter `~/.kimal3/meldungen/` (ueberlebt das Schliessen der Sitzung, US-4), eine Datei je Meldung:
11
+ * `<entstanden ohne :>-<6 Zeichen>.json`. Mit drin: das Projekt, fuer das sie gedacht war, und der Ordner, aus dem sie
12
+ * kam -- sie wird auch aus einem anderen Ordner nachgeschickt, und eine Spec-Meldung nimmt ihren Text von dort mit.
13
+ */
14
+ import { randomBytes } from "node:crypto";
15
+ import { mkdirSync, readdirSync, readFileSync, rmSync } from "node:fs";
16
+ import { homedir } from "node:os";
17
+ import { join } from "node:path";
18
+ import { schreibeAtomar } from "./atomar.js";
19
+ /**
20
+ * Die Sitzung, aus der eine Meldung kommt: zuerst `KIMAL3_SITZUNG`, die der Mod `kimal3-plan` bei jedem Start setzt --
21
+ * auch nach `/clear`, wo ungemessen ist, ob `CLAUDE_CODE_SESSION_ID` neu ist (Ruling Final, Important 2). Ohne Mod
22
+ * Claudes eigene Variable. Nur fuer Meldungen: die Wache bleibt bei `sitzungJetzt`.
23
+ */
24
+ export function sitzungFuerMeldung(umgebung) {
25
+ for (const wert of [umgebung.KIMAL3_SITZUNG, umgebung.CLAUDE_CODE_SESSION_ID]) {
26
+ if (typeof wert === "string" && wert.trim() !== "")
27
+ return wert;
28
+ }
29
+ return null;
30
+ }
31
+ const istObjekt = (w) => typeof w === "object" && w !== null && !Array.isArray(w);
32
+ const istText = (w) => typeof w === "string" && w.length > 0;
33
+ const istTextOderNull = (w) => w === null || istText(w);
34
+ const STAENDE = ["offen", "in_arbeit", "gebaut", "weggelassen"];
35
+ /** `null` = lesbar; sonst das Feld, das die App nennen wuerde (`meldung` oder `art`). Spiegel von `pruefeInhalt`. */
36
+ export function meldungFehler(m) {
37
+ if (!istObjekt(m))
38
+ return "meldung";
39
+ switch (m.art) {
40
+ case "spec":
41
+ if (!istText(m.schluessel) || !istText(m.titel) || !istTextOderNull(m.datei) || !Array.isArray(m.stories))
42
+ return "meldung";
43
+ return m.stories.every((s) => istObjekt(s) && istText(s.schluessel) && istText(s.titel)) ? null : "meldung";
44
+ case "story":
45
+ if (!istText(m.spec) || !istText(m.story) || typeof m.stand !== "string" || !STAENDE.includes(m.stand))
46
+ return "meldung";
47
+ if (m.runde !== undefined && !Number.isInteger(m.runde))
48
+ return "meldung";
49
+ if (m.kriterien !== undefined && !Array.isArray(m.kriterien))
50
+ return "meldung";
51
+ if (m.befunde !== undefined && !Array.isArray(m.befunde))
52
+ return "meldung";
53
+ return (m.testen === undefined || istTextOderNull(m.testen)) && (m.grund === undefined || istTextOderNull(m.grund)) ? null : "meldung";
54
+ case "thema": return istText(m.schluessel) && istText(m.text) ? null : "meldung";
55
+ case "uebernommen": return Number.isInteger(m.rueckmeldung) && istText(m.antwort) ? null : "meldung";
56
+ case "eingeordnet": return istText(m.schluessel) && istText(m.text) && istText(m.antwort) ? null : "meldung";
57
+ default: return "art";
58
+ }
59
+ }
60
+ // ── gemerkte Meldungen ────────────────────────────────────────────────────────────────────────────────────────────
61
+ const ORDNER = join(homedir(), ".kimal3", "meldungen");
62
+ export const dateiNameFuer = (entstanden, zufall) => `${entstanden.replace(/:/g, "")}-${zufall}.json`; // kein-anzeigetext: Dateiname
63
+ /**
64
+ * In der Reihenfolge, in der sie entstanden -- als ZEIT verglichen, nicht als Text (ein Versatz wie `+02:00` sortiert
65
+ * als Text falsch); bei gleicher Zeit nach Dateiname, damit die Reihenfolge nie vom Zufall des Dateisystems abhaengt.
66
+ */
67
+ export function ordneGemerkte(liste) {
68
+ return [...liste].sort((a, b) => Date.parse(a.umschlag.entstanden) - Date.parse(b.umschlag.entstanden) || (a.datei < b.datei ? -1 : a.datei > b.datei ? 1 : 0));
69
+ }
70
+ /** Merkt eine Meldung fuer spaeter. `false`: nicht geschrieben -- dann ist sie verloren, und das muss man sagen. */
71
+ export function merke(projektId, ordner, u) {
72
+ try {
73
+ mkdirSync(ORDNER, { recursive: true });
74
+ const datei = dateiNameFuer(u.entstanden, randomBytes(3).toString("hex"));
75
+ schreibeAtomar(join(ORDNER, datei), `${JSON.stringify({ format: 1, projektId, ordner, umschlag: u }, null, 2)}\n`);
76
+ return true;
77
+ }
78
+ catch {
79
+ return false;
80
+ }
81
+ }
82
+ /**
83
+ * Alle gemerkten, in Sendereihenfolge -- und die Dateien, die sich nicht lesen lassen. Die bleiben liegen (geloescht
84
+ * wird nichts ungesehen), werden aber GENANNT: Still uebergangen saehe β€žnichts gemerktβ€œ aus wie Erfolg.
85
+ */
86
+ export function gemerkteMitUnlesbaren() {
87
+ let namen;
88
+ try {
89
+ namen = readdirSync(ORDNER).filter((n) => n.endsWith(".json"));
90
+ }
91
+ catch {
92
+ return { liste: [], unlesbar: [] };
93
+ }
94
+ const unlesbar = [];
95
+ const liste = namen.flatMap((datei) => {
96
+ try {
97
+ const o = JSON.parse(readFileSync(join(ORDNER, datei), "utf8"));
98
+ if (istObjekt(o) && istText(o.projektId) && istObjekt(o.umschlag)) {
99
+ const u = o.umschlag;
100
+ if (istText(u.entstanden) && !Number.isNaN(Date.parse(u.entstanden)) && meldungFehler(u.meldung) === null) {
101
+ const sitzung = istText(u.sitzung) ? u.sitzung : null;
102
+ return [{ datei, projektId: o.projektId, ordner: istText(o.ordner) ? o.ordner : null, umschlag: { meldung: u.meldung, entstanden: u.entstanden, sitzung } }];
103
+ }
104
+ }
105
+ }
106
+ catch {
107
+ /* unten gezaehlt */
108
+ }
109
+ unlesbar.push(datei);
110
+ return [];
111
+ });
112
+ return { liste: ordneGemerkte(liste), unlesbar: unlesbar.sort() };
113
+ }
114
+ export const gemerkte = () => gemerkteMitUnlesbaren().liste;
115
+ export const gemerktOrdner = () => ORDNER;
116
+ export function vergiss(datei) {
117
+ try {
118
+ rmSync(join(ORDNER, datei));
119
+ }
120
+ catch {
121
+ /* schon weg */
122
+ }
123
+ }
124
+ /** Absagen, die nur DIESE Anfrage betreffen (ihre Form) -- die naechste Anfrage desselben Projekts kann durchgehen. */
125
+ const NUR_DIESES_PAKET = ["rumpf", "zu-viele", "meldung-unlesbar"];
126
+ /**
127
+ * Was die Antwort fuer die Meldungen bedeutet. πŸ”‘ β€žmerkenβ€œ heisst: Ob sie ankamen, ist nicht sicher -- also nochmals
128
+ * schicken. Das ist harmlos: Dieselbe Meldung zweimal aendert nichts (E-6 vergleicht β€žspaeter alsβ€œ, nicht β€žgleichβ€œ).
129
+ * πŸ”΄ Endgueltig abgesagt ist eine Meldung NUR, wenn die App selbst mit einem `grund` antwortet (Ruling Fixrunde 1):
130
+ * Eine `413` vom Rumpf-Deckel von Vercel, eine `403`-Seite einer Firewall, eine `400` ohne JSON kamen nie bei der App an
131
+ * -- wer sie als Absage nimmt, loescht gemerkte Meldungen, die nie jemand geprueft hat. Darum: ohne `grund` β€žmerkenβ€œ.
132
+ * Ebenso Aussetzer (5xx, 408, 429) und eine `200`, deren Rumpf nicht die erwartete Form hat (WLAN-Anmeldeportal).
133
+ */
134
+ export function meldungenErgebnis(status, koerper) {
135
+ const k = istObjekt(koerper) ? koerper : null;
136
+ const grund = typeof k?.grund === "string" && k.grund !== "" ? k.grund : null;
137
+ const meldung = typeof k?.meldung === "string" ? k.meldung : "";
138
+ if (status === 200) {
139
+ const abgesagt = Array.isArray(k?.abgesagt) ? k.abgesagt : null;
140
+ const lesbar = abgesagt?.every((a) => istObjekt(a) && Number.isInteger(a.index) && typeof a.meldung === "string");
141
+ if (k === null || !Number.isInteger(k.angenommen) || abgesagt === null || !lesbar)
142
+ return { art: "merken" };
143
+ return { art: "ok", angenommen: k.angenommen, abgesagt: abgesagt };
144
+ }
145
+ if (status === 401)
146
+ return { art: "anmelden", meldung };
147
+ if (grund === null || status === 408 || status === 429 || status >= 500)
148
+ return { art: "merken" };
149
+ return { art: "absage", grund, meldung, umfang: NUR_DIESES_PAKET.includes(grund) ? "paket" : "projekt" };
150
+ }
151
+ // ── In Paketen schicken (reine Ablaufregel; das Netz kommt als Funktion herein) ──────────────────────────────────
152
+ export const JE_ANFRAGE = 50;
153
+ /**
154
+ * Schickt die Umschlaege eines Projekts in Paketen zu 50 (Ruling Task 7: eine Anfrage, nicht eine je Meldung).
155
+ * `je` hat dieselbe Laenge und Reihenfolge wie `umschlaege`; `offen` heisst: nicht sicher, ob sie ankam.
156
+ * Eine Absage, die das PROJEKT betrifft (geloescht, Planung unlesbar), gilt fuer alle restlichen Meldungen dieses
157
+ * Projekts; eine, die nur die Form DIESES Pakets betrifft, nur fuer dieses -- die naechsten Pakete gehen trotzdem.
158
+ */
159
+ export async function sendeInPaketen(umschlaege, schicke) {
160
+ const je = umschlaege.map(() => ({ art: "offen" }));
161
+ let problem = null;
162
+ for (let von = 0; von < umschlaege.length; von += JE_ANFRAGE) {
163
+ const paket = umschlaege.slice(von, von + JE_ANFRAGE);
164
+ const v = await schicke(paket);
165
+ if (v === "netz")
166
+ return { je, problem: { art: "nicht-erreichbar" } };
167
+ if (v === "nicht-angemeldet")
168
+ return { je, problem: { art: "anmelden", meldung: "" } };
169
+ const e = meldungenErgebnis(v.status, v.koerper);
170
+ if (e.art === "merken")
171
+ return { je, problem: { art: "nicht-erreichbar" } };
172
+ if (e.art === "anmelden")
173
+ return { je, problem: { art: "anmelden", meldung: e.meldung } };
174
+ if (e.art === "absage") {
175
+ const bis = e.umfang === "projekt" ? umschlaege.length : von + paket.length;
176
+ for (let i = von; i < bis; i++)
177
+ je[i] = { art: "abgesagt", meldung: e.meldung };
178
+ problem = { art: "absage", grund: e.grund, meldung: e.meldung };
179
+ if (e.umfang === "projekt")
180
+ return { je, problem };
181
+ continue;
182
+ }
183
+ paket.forEach((_, i) => { je[von + i] = { art: "angenommen" }; });
184
+ for (const a of e.abgesagt)
185
+ if (a.index >= 0 && a.index < paket.length)
186
+ je[von + a.index] = { art: "abgesagt", meldung: a.meldung };
187
+ }
188
+ return { je, problem };
189
+ }
@@ -5,8 +5,8 @@ import { fileURLToPath } from "node:url";
5
5
  import { EINRICHTEN_TEXTE } from "./texte/einrichten.js";
6
6
  import { eigeneVersion } from "./version.js";
7
7
  /**
8
- * Legt den Plugin-Katalog mit dem Logo-Mod in `~/.kimal3/claude-plugins` ab und setzt dort die
9
- * Sprache des Kontos und die Ablage (`mitAblage`). Claude Code liest den Mod aus genau diesem Ordner (Katalog `kimal3`,
8
+ * Legt den Plugin-Katalog mit den beiden Mods (Logo und Board) in `~/.kimal3/claude-plugins` ab und setzt dort die
9
+ * Sprache des Kontos, im Logo-Mod dazu die Ablage (`mitAblage`). Claude Code liest die Mods aus genau diesem Ordner (Katalog `kimal3`,
10
10
  * `plugins-mischen.ts`).
11
11
  *
12
12
  * πŸ”‘ Warum ein fester Ordner im Benutzerordner und nicht der Ordner im installierten npm-Paket:
@@ -17,6 +17,9 @@ import { eigeneVersion } from "./version.js";
17
17
  export function katalogOrdner() {
18
18
  return join(homedir(), ".kimal3", "claude-plugins"); // kein-anzeigetext: Ordnername
19
19
  }
20
+ /** Die Dateien der Mods, die die Sprach-Zeile tragen (relativ zum Katalog); nur der Logo-Mod trΓ€gt auch die Ablage-Zeile. */
21
+ const LOGO_MOD = join("kimal3-logo", "hooks", "register.tsx"); // kein-anzeigetext: Dateipfad
22
+ const MODS = [LOGO_MOD, join("kimal3-plan", "hooks", "register.tsx")]; // kein-anzeigetext: Dateipfade
20
23
  /** Die Zeile im Mod, die die Sprache festlegt -- `check:logo` prΓΌft, dass es sie genau einmal gibt. */
21
24
  export const SPRACH_ZEILE = "const SPRACHE: 'de' | 'en' = 'de'"; // kein-anzeigetext: Quelltext des Mods
22
25
  /** Reine Rechnung: der Mod-Text in der Sprache des Kontos, oder `null`, wenn die Zeile fehlt. */
@@ -94,18 +97,23 @@ export function ablageWerte() {
94
97
  * den der Aufrufer in einer Zeile nennt. Unter Windows hΓ€lt ihn ein Programm fest, dessen Arbeitsordner darin liegt;
95
98
  * Claude selbst tut es nicht (gemessen 18.10. bei acht laufenden Claude-Prozessen, Spec 2026-10-18, Abschnitt 1.2).
96
99
  */
97
- export function logoAblegen(sprache) {
98
- // `dist/logo-ablegen.js` -> `../claude-plugins` im installierten Paket (`files` in package.json).
100
+ export function modsAblegen(sprache) {
101
+ // `dist/mods-ablegen.js` -> `../claude-plugins` im installierten Paket (`files` in package.json).
99
102
  const quelle = fileURLToPath(new URL("../claude-plugins", import.meta.url));
100
103
  const ziel = katalogOrdner();
101
104
  const neu = `${ziel}.neu`; // kein-anzeigetext: Ordnername
102
- const modDatei = (ordner) => join(ordner, "kimal3-logo", "hooks", "register.tsx");
103
105
  try {
104
- const mitSpr = mitSprache(readFileSync(modDatei(quelle), "utf8"), sprache);
105
- const text = mitSpr === null ? null : mitAblage(mitSpr, ablageWerte());
106
- if (text === null)
107
- return { ok: false, detail: modDatei(quelle) };
108
- if (stehtSchonDa(quelle, ziel, text)) {
106
+ // Beide Mods bekommen die Sprache des Kontos, der Logo-Mod dazu die Ablage fΓΌr den Update-Knopf (nur er trΓ€gt die
107
+ // Zeile); fehlt eine Zeile, bleibt alles, wie es war.
108
+ const texte = new Map();
109
+ for (const mod of MODS) {
110
+ const mitSpr = mitSprache(readFileSync(join(quelle, mod), "utf8"), sprache);
111
+ const text = mitSpr === null ? null : mod === LOGO_MOD ? mitAblage(mitSpr, ablageWerte()) : mitSpr;
112
+ if (text === null)
113
+ return { ok: false, detail: join(quelle, mod) };
114
+ texte.set(mod, text);
115
+ }
116
+ if (stehtSchonDa(quelle, ziel, texte)) {
109
117
  // Auch im Normalfall aufrΓ€umen, samt einem halb gebauten `….neu`: sonst blieben Reste fΓΌr immer (Durchsicht 3/4).
110
118
  rmSync(neu, { recursive: true, force: true });
111
119
  resteWegraeumen(ziel);
@@ -113,7 +121,8 @@ export function logoAblegen(sprache) {
113
121
  }
114
122
  rmSync(neu, { recursive: true, force: true });
115
123
  cpSync(quelle, neu, { recursive: true });
116
- writeFileSync(modDatei(neu), text, "utf8");
124
+ for (const [mod, text] of texte)
125
+ writeFileSync(join(neu, mod), text, "utf8");
117
126
  if (!existsSync(ziel)) {
118
127
  renameSync(neu, ziel);
119
128
  return { ok: true, ordner: ziel, stand: "neu" };
@@ -141,15 +150,15 @@ export function logoAblegen(sprache) {
141
150
  }
142
151
  }
143
152
  /**
144
- * FΓΌr `login` und `skill install` (Spec 2026-10-18, Abschnitt 1.2): ein VORHANDENES Logo auffrischen (Update,
153
+ * FΓΌr `login` und `skill install` (Spec 2026-10-18, Abschnitt 1.2): VORHANDENE Mods auffrischen (Update,
145
154
  * Sprachwechsel), nie eines anlegen -- das darf nur `einrichten`, wie beim Coach (`frischeCoachAuf`). Wer das Logo nie
146
155
  * eingerichtet hat, hΓΆrt nichts und bekommt keinen Ordner (L-2). Eine Zeile nur, wenn getauscht wurde, das alte bleiben
147
156
  * musste oder etwas scheiterte; sonst Stille. Wirft nie und Γ€ndert den RΓΌckgabewert des Befehls nicht (L-6).
148
157
  */
149
- export function frischeLogoAuf(s) {
158
+ export function frischeModsAuf(s) {
150
159
  if (!existsSync(katalogOrdner()))
151
160
  return;
152
- const ablage = logoAblegen(s);
161
+ const ablage = modsAblegen(s);
153
162
  const t = EINRICHTEN_TEXTE[s];
154
163
  if (!ablage.ok)
155
164
  console.log(t.logoNotWritten(ablage.detail));
@@ -158,16 +167,18 @@ export function frischeLogoAuf(s) {
158
167
  else if (ablage.stand === "alt")
159
168
  console.log(t.logoNotRefreshed);
160
169
  }
161
- /** Gleiche Dateien, gleicher Inhalt -- der Mod mit Sprache und Ablage, alles andere wie im Paket. */
162
- function stehtSchonDa(quelle, ziel, modText) {
170
+ /** Gleiche Dateien, gleicher Inhalt -- die Mods mit Sprache (und im Logo-Mod Ablage), alles andere wie im Paket. */
171
+ function stehtSchonDa(quelle, ziel, modTexte) {
163
172
  if (!existsSync(ziel))
164
173
  return false;
165
174
  const dateien = (ordner) => alleDateien(ordner).sort();
166
175
  const soll = dateien(quelle);
167
176
  if (JSON.stringify(soll) !== JSON.stringify(dateien(ziel)))
168
177
  return false;
169
- const mod = join("kimal3-logo", "hooks", "register.tsx");
170
- return soll.every((d) => (d === mod ? Buffer.from(modText, "utf8") : readFileSync(join(quelle, d))).equals(readFileSync(join(ziel, d))));
178
+ return soll.every((d) => {
179
+ const modText = modTexte.get(d);
180
+ return (modText === undefined ? readFileSync(join(quelle, d)) : Buffer.from(modText, "utf8")).equals(readFileSync(join(ziel, d)));
181
+ });
171
182
  }
172
183
  /**
173
184
  * Alle Dateien unter `ordner`, als Pfade relativ dazu. πŸ”‘ Von Hand statt `readdirSync(…, { recursive: true })`: Das kann
@@ -1,6 +1,6 @@
1
1
  import { istObjekt, leseEinstellungen } from "./einstellungen-mischen.js";
2
2
  /**
3
- * Traegt superpowers und das KImal3-Logo in Claudes Einstellungen ein -- als Katalog plus
3
+ * Traegt superpowers, das KImal3-Logo und das Board (kimal3-plan) in Claudes Einstellungen ein -- als Katalog plus
4
4
  * Einschalter, so wie Anthropic Firmen-Mods auf jeden Rechner verteilt (Bauplan 2026-10-15,
5
5
  * β€žWarum anders als die Spec"). Beim naechsten Start laedt Claude Code beides selbst.
6
6
  *
@@ -10,6 +10,7 @@ import { istObjekt, leseEinstellungen } from "./einstellungen-mischen.js";
10
10
  */
11
11
  export const KATALOG = "kimal3"; // kein-anzeigetext: Name des Plugin-Katalogs in Claudes Einstellungen
12
12
  export const LOGO = `kimal3-logo@${KATALOG}`; // kein-anzeigetext: Plugin-Kennung
13
+ export const PLAN = `kimal3-plan@${KATALOG}`; // kein-anzeigetext: Plugin-Kennung
13
14
  const OFFIZIELL = "claude-plugins-official"; // kein-anzeigetext: Anthropics Katalog
14
15
  export const SUPERPOWERS = `superpowers@${OFFIZIELL}`; // kein-anzeigetext: Plugin-Kennung
15
16
  /**
@@ -47,6 +48,9 @@ export function mischePlugins(alt, katalogPfad, mitSuperpowers = true) {
47
48
  neueKataloge[KATALOG] = zeigtSchonHin ? alterEintrag : { ...alterEintrag, source: { source: "directory", path: katalogPfad } };
48
49
  if (!(LOGO in plugins))
49
50
  neuePlugins[LOGO] = true;
51
+ // Wie beim Logo: ein Eintrag -- auch `false` -- heißt, er hat entschieden (US-7); nur was fehlt, wird eingeschaltet.
52
+ if (!(PLAN in plugins))
53
+ neuePlugins[PLAN] = true;
50
54
  }
51
55
  if (mitSuperpowers && !Object.keys(plugins).some((k) => k.startsWith("superpowers@"))) {
52
56
  neuePlugins[SUPERPOWERS] = true;
@@ -69,10 +73,11 @@ export function pluginStand(inhalt) {
69
73
  const lesen = leseEinstellungen(inhalt);
70
74
  const plugins = lesen.ok ? lesen.objekt.enabledPlugins : undefined;
71
75
  if (!istObjekt(plugins))
72
- return { superpowers: "fehlt", logo: "fehlt" };
76
+ return { superpowers: "fehlt", logo: "fehlt", plan: "fehlt" };
73
77
  const superpowers = Object.entries(plugins).filter(([k]) => k.startsWith("superpowers@")).map(([, v]) => v);
74
78
  return {
75
79
  superpowers: superpowers.includes(true) ? "an" : superpowers.length > 0 ? "aus" : "fehlt",
76
80
  logo: plugins[LOGO] === true ? "an" : LOGO in plugins ? "aus" : "fehlt",
81
+ plan: plugins[PLAN] === true ? "an" : PLAN in plugins ? "aus" : "fehlt",
77
82
  };
78
83
  }
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Zu welchem Projekt in der App gehoert dieser Ordner? (Spec 2026-10-23-brett-in-echtzeit-design.md, 1.4, 1.7, S-27
3
+ * bis S-31, US-8). Die Regel ist eine reine Rechnung (`ordnerlage`, `hauptordnerFuer`): Sie bekommt, was es auf der
4
+ * Platte gibt, als zwei Funktionen -- so prueft `check:cli` sie gegen die Tabelle in `scripts/projektordner-faelle.mts`,
5
+ * dieselbe, die der Mod (Task 8) in seine Tests uebernimmt.
6
+ *
7
+ * Projektordner: ein Ordner DIREKT in `werkbank-…/projekte/` (englisch `workbench-…/projects/`, auch gemischt), wie
8
+ * bei `/k3start`; der Beispielordner zaehlt nicht. Eine Arbeitskopie gehoert zum Projekt ihres Hauptordners -- auch
9
+ * eine, die NEBEN ihm in `projekte/` liegt (sonst waere `kimal3-app-board` ein eigenes Projekt).
10
+ *
11
+ * Der Ordnerzettel `plan/projekt.json` traegt Nummer und Titel des Projekts und kommt mit Git mit. Fehlt er, gilt
12
+ * hoechstens noch `projekt_id` aus `plan/stand.json` -- nur, um den Zettel daraus anzulegen (1.4).
13
+ */
14
+ import { readFileSync, statSync, mkdirSync } from "node:fs";
15
+ import { schreibeAtomar } from "./atomar.js";
16
+ import { werkbankSpracheAus } from "./werkbank-name.js";
17
+ export const ZETTEL_PFAD = "plan/projekt.json"; // kein-anzeigetext: Dateipfad
18
+ const STAND_PFAD = "plan/stand.json"; // kein-anzeigetext: Dateipfad
19
+ const BEISPIELE = ["beispiel-rechnungen-erfassen", "example-capture-invoices"];
20
+ const PROJEKTORDNER = ["projekte", "projects"];
21
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
22
+ const normiert = (pfad) => pfad.replace(/\\/g, "/").replace(/\/+$/, "");
23
+ /** Der Ordner und alle darueber, naechster zuerst (wie `ordnerNachOben` im Logo-Mod). */
24
+ const nachOben = (pfad) => {
25
+ const teile = normiert(pfad).split("/");
26
+ return teile.map((_, i) => teile.slice(0, teile.length - i).join("/")).filter((p) => p !== "");
27
+ };
28
+ /** `werkbank-x/projekte/<name>` irgendwo im Pfad -- der aeusserste Treffer, damit `.claude/worktrees/x` nicht zaehlt. */
29
+ function projektordnerIn(pfad) {
30
+ const teile = normiert(pfad).split("/");
31
+ for (let i = 0; i + 2 < teile.length; i++) {
32
+ if (werkbankSpracheAus(teile[i]) === null || !PROJEKTORDNER.includes(teile[i + 1]))
33
+ continue;
34
+ const name = teile[i + 2];
35
+ return name === "" || BEISPIELE.includes(name) ? null : { ordner: teile.slice(0, i + 3).join("/"), name };
36
+ }
37
+ return null;
38
+ }
39
+ const ohneBom = (text) => (text.startsWith("ο»Ώ") ? text.slice(1) : text);
40
+ function objektAus(text) {
41
+ if (text === null)
42
+ return null;
43
+ try {
44
+ const j = JSON.parse(ohneBom(text));
45
+ return j !== null && typeof j === "object" && !Array.isArray(j) ? j : null;
46
+ }
47
+ catch {
48
+ return null;
49
+ }
50
+ }
51
+ /** Der Zettel, wenn er gueltig ist -- sonst `null` (kein JSON, andere Formatnummer, keine Projektnummer). */
52
+ export function zettelAus(text) {
53
+ const o = objektAus(text);
54
+ if (o === null || o.format !== 1 || typeof o.projekt_id !== "string" || !UUID.test(o.projekt_id))
55
+ return null;
56
+ if (typeof o.titel !== "string" || o.titel.trim() === "")
57
+ return null;
58
+ return { format: 1, projekt_id: o.projekt_id, titel: o.titel };
59
+ }
60
+ export function zettelText(z) {
61
+ return `${JSON.stringify({ format: z.format, projekt_id: z.projekt_id, titel: z.titel }, null, 2)}\n`;
62
+ }
63
+ /** Aus einer alten `plan/stand.json` nur die Nummer (und der Titel, falls er dasteht). */
64
+ function zettelAusStand(text, name) {
65
+ const o = objektAus(text);
66
+ if (o === null || typeof o.projekt_id !== "string" || !UUID.test(o.projekt_id))
67
+ return null;
68
+ const titel = typeof o.projekt === "string" && o.projekt.trim() !== "" ? o.projekt : name;
69
+ return { format: 1, projekt_id: o.projekt_id, titel };
70
+ }
71
+ /**
72
+ * Der Hauptordner einer Git-Arbeitskopie: die naechste `.git` ueber `cwd`. Ist sie eine Datei mit
73
+ * `gitdir: …/.git/worktrees/<name>`, ist der Ordner davor der Hauptordner; sonst `null` (normales Projekt,
74
+ * Untermodul, kein Git). Dieselbe Erkennung wie `hauptordnerAusGitdatei` im Logo-Mod.
75
+ * Ein RELATIVER Pfad (`git worktree add` mit `worktree.useRelativePaths`: `gitdir: ../kimal3-app/.git/worktrees/x`)
76
+ * gilt ab der Wurzel der Arbeitskopie, nicht ab dem Startordner (Fixrunde 1).
77
+ */
78
+ export function hauptordnerFuer(cwd, gibt, lies) {
79
+ const wurzel = nachOben(cwd).find((o) => gibt(`${o}/.git`)); // kein-anzeigetext: Dateipfad
80
+ if (wurzel === undefined)
81
+ return null;
82
+ const text = lies(`${wurzel}/.git`); // kein-anzeigetext: Dateipfad
83
+ const treffer = text === null ? null : /^gitdir:\s*(.+?)[\\/]\.git[\\/]worktrees[\\/][^\\/]+\s*$/.exec(text.trim());
84
+ if (treffer === null)
85
+ return null;
86
+ const ziel = normiert(treffer[1]);
87
+ return /^([A-Za-z]:)?\//.test(ziel) ? ziel : aufgeloest(`${wurzel}/${ziel}`);
88
+ }
89
+ /** `.` und `..` in einem Pfad mit `/` aufloesen, ohne die Platte zu fragen (die Wurzel bleibt stehen). */
90
+ function aufgeloest(pfad) {
91
+ const teile = [];
92
+ for (const t of pfad.split("/")) {
93
+ if (t === "." || (t === "" && teile.length > 0))
94
+ continue;
95
+ if (t === ".." && teile.length > 1)
96
+ teile.pop();
97
+ else if (t !== "..")
98
+ teile.push(t);
99
+ }
100
+ return teile.join("/");
101
+ }
102
+ export function ordnerlage(cwd, gibt, lies, hauptordner) {
103
+ // In einer Arbeitskopie zaehlt das Projekt des Hauptordners; der Zettel gehoert in ihre eigene Wurzel.
104
+ const projekt = projektordnerIn(hauptordner ?? cwd);
105
+ if (projekt === null)
106
+ return { art: "kein-projektordner" };
107
+ const ordner = hauptordner === null ? projekt.ordner : nachOben(cwd).find((o) => gibt(`${o}/.git`)) ?? projekt.ordner; // kein-anzeigetext: Dateipfad
108
+ const orte = ordner === projekt.ordner ? [ordner] : [ordner, projekt.ordner];
109
+ for (const o of orte) {
110
+ const zettel = zettelAus(lies(`${o}/${ZETTEL_PFAD}`));
111
+ if (zettel !== null)
112
+ return { art: "projekt", ordner, name: projekt.name, zettel, ausStandJson: false };
113
+ }
114
+ for (const o of orte) {
115
+ const zettel = zettelAusStand(lies(`${o}/${STAND_PFAD}`), projekt.name);
116
+ if (zettel !== null)
117
+ return { art: "projekt", ordner, name: projekt.name, zettel, ausStandJson: true };
118
+ }
119
+ return { art: "projekt", ordner, name: projekt.name, zettel: null, ausStandJson: false };
120
+ }
121
+ // ── Platte ──────────────────────────────────────────────────────────────────────────────────────────────────────────
122
+ const gibtWirklich = (pfad) => {
123
+ try {
124
+ statSync(pfad);
125
+ return true;
126
+ }
127
+ catch {
128
+ return false;
129
+ }
130
+ };
131
+ const liesWirklich = (pfad) => {
132
+ try {
133
+ return readFileSync(pfad, "utf8");
134
+ }
135
+ catch {
136
+ return null; // fehlt, oder ein Ordner (`.git` eines normalen Projekts)
137
+ }
138
+ };
139
+ /** Die Lage des Ordners, in dem `kimal3` gerade laeuft. */
140
+ export function ordnerlageJetzt() {
141
+ const cwd = process.cwd();
142
+ return ordnerlage(cwd, gibtWirklich, liesWirklich, hauptordnerFuer(cwd, gibtWirklich, liesWirklich));
143
+ }
144
+ /** Schreibt den Zettel. `false` heisst: nicht geschrieben (Schreibschutz, Ordner weg). */
145
+ export function schreibeZettel(ordner, z) {
146
+ try {
147
+ mkdirSync(`${ordner}/plan`, { recursive: true }); // kein-anzeigetext: Dateipfad
148
+ schreibeAtomar(`${ordner}/${ZETTEL_PFAD}`, zettelText(z));
149
+ return true;
150
+ }
151
+ catch {
152
+ return false;
153
+ }
154
+ }
155
+ const textFeld = (k, name) => k !== null && typeof k === "object" && typeof k[name] === "string" ? k[name] : null;
156
+ /**
157
+ * Was die Antwort bedeutet. πŸ”‘ Eine `404` ist nur mit dem Grund `projekt-geloescht` ein geloeschtes Projekt -- ohne
158
+ * Grund kennt die App die Route noch nicht (B-7), und dann darf Claude nicht β€žSoll ich ein neues anlegen?β€œ fragen.
159
+ */
160
+ export function verbindenErgebnis(status, koerper) {
161
+ const grund = textFeld(koerper, "grund");
162
+ if (status === 200) {
163
+ const projektId = textFeld(koerper, "projektId"); // kein-anzeigetext: Feldname der Antwort
164
+ const titel = textFeld(koerper, "titel");
165
+ const angelegt = koerper?.angelegt;
166
+ return projektId !== null && UUID.test(projektId) && titel !== null && typeof angelegt === "boolean"
167
+ ? { art: "verbunden", projektId, titel, angelegt } : { art: "unlesbar" };
168
+ }
169
+ if (status === 404 && grund === "projekt-geloescht")
170
+ return { art: "geloescht" };
171
+ if (status === 409 && grund === "projekt-mehrdeutig") {
172
+ const roh = koerper.kandidaten;
173
+ const kandidaten = Array.isArray(roh)
174
+ ? roh.flatMap((x) => {
175
+ const projektId = textFeld(x, "projektId"); // kein-anzeigetext: Feldname der Antwort
176
+ const titel = textFeld(x, "titel");
177
+ return projektId !== null && titel !== null ? [{ projektId, titel }] : [];
178
+ })
179
+ : [];
180
+ return { art: "mehrdeutig", meldung: textFeld(koerper, "meldung") ?? "", kandidaten };
181
+ }
182
+ if (status === 409 && grund === "kein-profil")
183
+ return { art: "kein-profil", meldung: textFeld(koerper, "meldung") ?? "" };
184
+ // Die Firmenwahl kommt in der Form von `projekt neu` (mit `firmen`/`auswahl`); jede andere 409 ist ein fertiger Satz.
185
+ const k = koerper;
186
+ if (status === 409 && (Array.isArray(k?.firmen) || Array.isArray(k?.auswahl)))
187
+ return { art: "firma" };
188
+ return { art: "sonst" };
189
+ }
@@ -42,6 +42,7 @@ const de = {
42
42
  noWorkbank: "In der Lieferung steckt keine Werkbank. Bitte melde das an kimal3.",
43
43
  // Plugins (Bauplan 2026-10-15): Claude liest die Zeile und sagt der Kundin, was nach dem Neustart erscheint.
44
44
  logoName: "KImal3-Logo",
45
+ planName: "KImal3-Board",
45
46
  // `an`: eingeschaltet Β· `aus`: von ihr selbst ausgeschaltet (bleibt so, die Anleitung schickt sie dann nicht ins MenΓΌ)
46
47
  // Β· `neu`: gerade eingetragen (kommt mit dem Neustart) oder stand schon da (Durchsicht-Funde 1 und 7).
47
48
  pluginsLine: (an, aus, neu) => ` ${M.de.plugins} ${an.length > 0 ? `${an.join(", ")} β€” ${neu ? "erscheinen, sobald Claude ganz beendet und neu geΓΆffnet ist" : "sind schon eingetragen"}.` : "nichts eingeschaltet."}` +
@@ -57,7 +58,7 @@ const de = {
57
58
  // Die Sperre setzt ein anderes Programm mit Arbeitsordner im Logo-Ordner, nicht Claude -- deshalb kein Rat, Claude zu
58
59
  // beenden (fΓΌr App-Nutzer ein Kreis; Spec 2026-10-18, Abschnitt 1.2).
59
60
  logoOldVersion: " Das Logo konnte gerade nicht aufgefrischt werden, weil ein anderes Programm seinen Ordner benutzt. Es kommt beim nΓ€chsten `kimal3 einrichten`.",
60
- // Auffrischen bei `login` und `skill install` (`frischeLogoAuf`); scheitert es, sagt es `logoNotWritten`.
61
+ // Auffrischen bei `login` und `skill install` (`frischeModsAuf`); scheitert es, sagt es `logoNotWritten`.
61
62
  logoRefreshed: " Das Logo ΓΌber dem Eingabefeld ist aufgefrischt. Ist es eingeschaltet, siehst du es nach dem nΓ€chsten Neustart von Claude.",
62
63
  logoNotRefreshed: " Das Logo konnte gerade nicht aufgefrischt werden, weil ein anderes Programm seinen Ordner benutzt. Es kommt beim nΓ€chsten `kimal3 skill install`.",
63
64
  };
@@ -98,6 +99,7 @@ const en = {
98
99
  unsafeFolder: (name) => `The server sent a folder name I won't create: ${name}. Please report this to kimal3.`,
99
100
  noWorkbank: "The delivery contains no workbench. Please report this to kimal3.",
100
101
  logoName: "KImal3 logo",
102
+ planName: "KImal3 board",
101
103
  pluginsLine: (an, aus, neu) => ` ${M.en.plugins} ${an.length > 0 ? `${an.join(", ")} β€” ${neu ? "will appear once Claude is fully quit and reopened" : "already added"}.` : "nothing switched on."}` +
102
104
  (aus.length > 0 ? ` Switched off by you, stays off: ${aus.join(", ")}.` : ""),
103
105
  pluginsNotWritten: (grund) => ` ${M.en.plugins} not added β€” ${grund}`,