kimal3 0.4.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.
@@ -0,0 +1,226 @@
1
+ // Reine Rechnungen hinter `kimal3 zeigen` (Aufnahme von Bildschirm und Ton fuer Claude).
2
+ // Kein Dateisystem, kein Prozess, keine Anzeige-Texte: Die Befehle holen die Fakten und fragen hier.
3
+ // Gedeckt von `npm run check:zeigen` (Spec 2026-10-18-k3zeigen-design.md).
4
+ import { win32 } from "node:path";
5
+ const MAX_PFAD_WINDOWS = 170;
6
+ /** "2026-10-19-0912" in Ortszeit. */
7
+ export function ordnerName(jetzt) {
8
+ const z = (n) => String(n).padStart(2, "0");
9
+ return `${jetzt.getFullYear()}-${z(jetzt.getMonth() + 1)}-${z(jetzt.getDate())}-${z(jetzt.getHours())}${z(jetzt.getMinutes())}`;
10
+ }
11
+ /** Index des Projektordners im Segment-Array, -1 wenn keiner, -2 wenn der Pfad bei der Werkbank endet. */
12
+ function projektEnde(segmente) {
13
+ const i = segmente.findIndex((s) => /^(werkbank|workbench)-/i.test(s));
14
+ if (i < 0)
15
+ return -1;
16
+ const projekte = /^werkbank-/i.test(segmente[i]) ? /^projekte$/i : /^projects$/i;
17
+ if (i === segmente.length - 1)
18
+ return -2;
19
+ if (!projekte.test(segmente[i + 1]))
20
+ return -1;
21
+ return i + 2 < segmente.length ? i + 2 : -2;
22
+ }
23
+ /** Wohin die Aufnahme faellt: ins Projekt, in dem man steht -- nie in die Werkbank selbst. */
24
+ export function aufnahmeOrt(start, jetzt, plattform, gibtEs) {
25
+ const trenner = start.includes("\\") ? "\\" : "/";
26
+ const segmente = start.split(/[\\/]/);
27
+ while (segmente.length > 1 && segmente[segmente.length - 1] === "")
28
+ segmente.pop();
29
+ const ende = projektEnde(segmente);
30
+ if (ende === -2)
31
+ return { art: "werkbank" };
32
+ const projekt = (ende >= 0 ? segmente.slice(0, ende + 1) : segmente).join(trenner);
33
+ const aufnahmen = `${projekt}${trenner}material${trenner}aufnahmen`; // kein-anzeigetext: Ordnerpfad
34
+ const basis = `${aufnahmen}${trenner}${ordnerName(jetzt)}`;
35
+ let ordner = basis;
36
+ for (let n = 2; gibtEs(ordner); n++)
37
+ ordner = `${basis}-${n}`;
38
+ if (plattform === "win32" && ordner.length > MAX_PFAD_WINDOWS) {
39
+ return { art: "zu-lang", laenge: ordner.length, hoechstens: MAX_PFAD_WINDOWS };
40
+ }
41
+ return { art: "ort", ordner, aufnahmen };
42
+ }
43
+ /** 93 -> "1:33", 3725 -> "1:02:05". */
44
+ export function dauerText(sekunden) {
45
+ const s = Math.floor(sekunden);
46
+ const h = Math.floor(s / 3600);
47
+ const m = Math.floor((s % 3600) / 60);
48
+ const rest = String(s % 60).padStart(2, "0");
49
+ return h > 0 ? `${h}:${String(m).padStart(2, "0")}:${rest}` : `${m}:${rest}`;
50
+ }
51
+ /** "0:26" -> 26, "1:02:05" -> 3725, "26" -> 26; alles andere null. */
52
+ export function zeitAusText(text) {
53
+ const teile = text.trim().split(":");
54
+ if (teile.length > 3 || !teile.every((t) => /^\d+$/.test(t)))
55
+ return null;
56
+ const zahlen = teile.map(Number);
57
+ if (zahlen.slice(1).some((n) => n >= 60))
58
+ return null;
59
+ return zahlen.reduce((summe, n) => summe * 60 + n, 0);
60
+ }
61
+ /** 26 -> "0-26.png", 3725 -> "1-02-05.png" (Doppelpunkte duerfen nicht in Dateinamen). */
62
+ export function bildName(sekunden) {
63
+ return `${dauerText(sekunden).replaceAll(":", "-")}.png`; // kein-anzeigetext: Dateiname
64
+ }
65
+ // Reihenfolge zaehlt: das erste Muster, das trifft, gewinnt. Das Protokoll enthaelt Fenstertitel -- es wird nie
66
+ // zurueckgegeben oder angezeigt, nur nach diesen festen Mustern durchsucht.
67
+ const GRUENDE = [
68
+ [/FFmpegUnavailableError|ffmpeg.*not found/i, "ffmpeg-fehlt"],
69
+ [/returned success without a non-empty output/, "pfad-zu-lang"],
70
+ [/PortAudio|No Default Input Device|sounddevice/, "kein-mikrofon"],
71
+ [/DesktopCaptureError|screen layout/, "bildschirm-gesperrt"],
72
+ [/did not become ready|did not start/, "startet-nicht"],
73
+ ];
74
+ export function fehlerGrund(protokoll) {
75
+ return GRUENDE.find(([muster]) => muster.test(protokoll))?.[1] ?? "unbekannt";
76
+ }
77
+ /** Der Aufnehmer antwortet mit einer JSON-Zeile ganz am Ende; alles andere (auch ein Traceback) ist ein Fehlschlag. */
78
+ export function liesWerkzeugAntwort(rueckgabe, stdout) {
79
+ if (rueckgabe !== 0)
80
+ return { ok: false };
81
+ const zeilen = stdout.split(/\r?\n/).filter((z) => z.trim() !== "");
82
+ const letzte = zeilen[zeilen.length - 1];
83
+ if (letzte === undefined)
84
+ return { ok: false };
85
+ try {
86
+ const daten = JSON.parse(letzte);
87
+ if (typeof daten !== "object" || daten === null || Array.isArray(daten))
88
+ return { ok: false };
89
+ return { ok: true, daten: daten };
90
+ }
91
+ catch {
92
+ return { ok: false };
93
+ }
94
+ }
95
+ /** So alt darf das Lebenszeichen des Starters hoechstens sein, damit er als lebend gilt (er schreibt alle 2 s). */
96
+ const LEBENSZEICHEN_SEK = 10;
97
+ /** Lebt der Starter nachweislich -- meldet `startet`/`laeuft` UND sein Lebenszeichen ist hoechstens 10 s alt? (R-3) */
98
+ export function starterLebt(s, jetzt) {
99
+ if (s === null || (s.zustand !== "startet" && s.zustand !== "laeuft"))
100
+ return false;
101
+ return s.zuletzt !== undefined && jetzt - s.zuletzt <= LEBENSZEICHEN_SEK;
102
+ }
103
+ /**
104
+ * Die Wahrheit ueber "laeuft" kommt vom Aufnehmer; Notiz und Starter erzaehlen nur dazu.
105
+ * `jetzt` in Epochensekunden. Ausnahme Uebergangsfenster (R-3): Der Aufnehmer meldet noch/schon keine Sitzung, der
106
+ * Starter lebt aber nachweislich (frisches Lebenszeichen) -- dann laeuft etwas, sonst waere "abgebrochen" falsch.
107
+ */
108
+ export function aufnahmeZustand(f, jetzt) {
109
+ if (f.aktiveSitzungen === null)
110
+ return { art: "unbekannt" };
111
+ // Der Starter konnte den Aufnehmer nicht beenden: Solange er noch lebt (und es weiter versucht) oder der Aufnehmer
112
+ // eine Sitzung meldet, weiss niemand, ob aufgenommen wird. Ein laengst stiller Starter ohne aktive Sitzung blockiert
113
+ // dagegen nicht jeden weiteren Start im Projekt -- er zaehlt unten als abgebrochen (Endpruefung, Punkt 3).
114
+ const frisch = f.starter?.zuletzt !== undefined && jetzt - f.starter.zuletzt <= LEBENSZEICHEN_SEK;
115
+ if (f.starter?.grund === "nicht-beendet" && (frisch || f.aktiveSitzungen.length > 0))
116
+ return { art: "unbekannt" };
117
+ const n = f.notiz;
118
+ const ausNotiz = (sitzung, eigenerOrdner) => n !== null && ((sitzung !== null && n.sitzung === sitzung) || eigenerOrdner)
119
+ ? { art: "laeuft", sitzung, ordner: n.ordner, gestartet: n.gestartet, frist: n.frist, fremd: false }
120
+ : { art: "laeuft", sitzung, ordner: null, gestartet: null, frist: null, fremd: true };
121
+ if (f.aktiveSitzungen.length > 0) {
122
+ const eigene = n !== null && f.aktiveSitzungen.includes(n.sitzung) ? n.sitzung : null;
123
+ return ausNotiz(eigene ?? f.aktiveSitzungen[0], false);
124
+ }
125
+ const s = f.starter;
126
+ if (s === null)
127
+ return { art: "keine", letzte: null };
128
+ if (starterLebt(s, jetzt))
129
+ return ausNotiz(s.sitzung ?? null, f.starterImNotizOrdner);
130
+ if (s.zustand === "selbst-gestoppt" || s.zustand === "gestoppt")
131
+ return { art: "keine", letzte: s.zustand };
132
+ return { art: "keine", letzte: "abgebrochen" };
133
+ }
134
+ /**
135
+ * Das Python des Aufnehmers ist nicht zu finden (Ruling R-13). Ohne Notiz und ohne offenen Starter kann nichts laufen,
136
+ * was kimal3 gestartet hat -> wie "keine aktive Sitzung". Gibt es aber eine Notiz oder einen Starter in
137
+ * `startet`/`laeuft`, entscheidet allein sein Lebenszeichen: frisch -> laeuft, sonst weiss es niemand.
138
+ * Steht der Starter in einem ENDZUSTAND, ist die Aufnahme zu, auch mit Notiz (Ruling R-17): Er schreibt ihn erst, wenn
139
+ * sein Kind weg ist. Ausnahme `grund: nicht-beendet` mit frischem Lebenszeichen -- die faengt `aufnahmeZustand` als
140
+ * "weiss nicht" ab.
141
+ */
142
+ export function zustandOhneAufnehmer(f, jetzt) {
143
+ const mitListe = { ...f, aktiveSitzungen: [] };
144
+ const offen = f.starter?.zustand === "startet" || f.starter?.zustand === "laeuft";
145
+ const zu = f.starter !== null && !offen;
146
+ if (zu || f.notiz === null && !offen)
147
+ return aufnahmeZustand(mitListe, jetzt);
148
+ return starterLebt(f.starter, jetzt) ? aufnahmeZustand(mitListe, jetzt) : { art: "unbekannt" };
149
+ }
150
+ export const AUFNEHMER_FASSUNG = "1.3.0";
151
+ /** Die Fassung des Aufnehmers, wie `teile` sie meldet -- nur in der Form `1.3.0…`, alles andere ist keine Angabe (R-14, Punkt 5). */
152
+ export const gueltigeFassung = (w) => (typeof w === "string" && /^\d+\.\d+\.\d+\S*$/.test(w) ? w : null);
153
+ /**
154
+ * Die Antwort von `werkzeug.py teile` -> `Teile`. Ohne Antwort (kein Aufnehmer-Python) oder mit einer unlesbaren gilt
155
+ * alles ausser uv als fehlend: Einrichten holt es dann (wieder), statt einem stummen Aufnehmer zu glauben.
156
+ */
157
+ export function teileAus(uv, antwort) {
158
+ if (antwort === null || !antwort.ok)
159
+ return { uv, aufnehmer: null, ffmpeg: false, modell: false };
160
+ const d = antwort.daten;
161
+ return { uv, aufnehmer: gueltigeFassung(d.aufnehmer), ffmpeg: d.ffmpeg === true, modell: d.modell === true };
162
+ }
163
+ export const AUFNEHMER_PAKET = `openadapt-capture[transcribe-fast]==${AUFNEHMER_FASSUNG}`; // kein-anzeigetext: Paketname
164
+ /** `uv tool install …` -- `ersetzen`, sobald schon eine Umgebung des Aufnehmers liegt (andere Fassung, kaputt). */
165
+ export function aufnehmerArgumente(ersetzen) {
166
+ // kein-anzeigetext: Befehlszeile
167
+ return ["tool", "install", "--python", "3.12", ...(ersetzen ? ["--force"] : []), AUFNEHMER_PAKET];
168
+ }
169
+ /**
170
+ * Wie uv auf den Rechner kommt: winget zuerst (Coach-Regel: Paketmanager), sonst der offizielle Installer pro Benutzer.
171
+ * Ohne die beiden Zustimmungsschalter haengt winget auf einem frischen Rechner an einer Frage (Spec US-2).
172
+ */
173
+ export function uvEinrichtung(winget) {
174
+ // kein-anzeigetext: Befehlszeile
175
+ if (winget)
176
+ return { befehl: "winget", argumente: ["install", "--id", "astral-sh.uv", "-e", "--scope", "user", "--accept-source-agreements", "--accept-package-agreements"] };
177
+ // kein-anzeigetext: Befehlszeile
178
+ return { befehl: "powershell", argumente: ["-ExecutionPolicy", "ByPass", "-c", "irm https://astral.sh/uv/install.ps1 | iex"] };
179
+ }
180
+ /** Was beim Einrichten noch fehlt, in der Reihenfolge, in der es gebraucht wird. */
181
+ export function einrichtPlan(t) {
182
+ const fehlt = [
183
+ [t.uv === null, "uv"],
184
+ [t.aufnehmer !== AUFNEHMER_FASSUNG, "aufnehmer"],
185
+ [!t.ffmpeg, "ffmpeg"],
186
+ [!t.modell, "modell"],
187
+ ];
188
+ return fehlt.filter(([f]) => f).map(([, schritt]) => schritt);
189
+ }
190
+ /** Wo uv unter Windows liegen kann, wenn es nicht im PATH steht (frisch installiert, Terminal noch alt). */
191
+ export function uvKandidaten(env, home) {
192
+ const lokal = env.LOCALAPPDATA ?? win32.join(home, "AppData", "Local"); // kein-anzeigetext: Ordnerpfad
193
+ return [
194
+ // kein-anzeigetext: Ordnerpfad
195
+ win32.join(lokal, "Microsoft", "WinGet", "Packages", "astral-sh.uv_Microsoft.Winget.Source_8wekyb3d8bbwe", "uv.exe"),
196
+ win32.join(home, ".local", "bin", "uv.exe"),
197
+ win32.join(home, ".cargo", "bin", "uv.exe"),
198
+ ];
199
+ }
200
+ /**
201
+ * Welcher Ordner in `material/aufnahmen/` ist die juengste Aufnahme? Nur Namen von `ordnerName` (mit Endung `-2` …)
202
+ * zaehlen; `-10` kommt nach `-9` (als Text stuende es davor). `istAufnahme` sortiert Ordner aus, in denen keine
203
+ * Aufnahme liegt (ein gescheiterter Start hinterlaesst nur Protokoll und Starter-Bericht) -- sonst verdeckte er die
204
+ * letzte echte fuer auswerten und stopp (Endpruefung, Punkt 4). `null`, wenn keiner passt.
205
+ */
206
+ export function juengsteAufnahme(namen, istAufnahme) {
207
+ const schluessel = (n) => {
208
+ const m = /^(\d{4}-\d{2}-\d{2}-\d{4})(?:-(\d+))?$/.exec(n);
209
+ return m ? { name: n, basis: m[1], nr: m[2] === undefined ? 1 : Number(m[2]) } : null;
210
+ };
211
+ const passend = namen.map(schluessel).filter((k) => k !== null);
212
+ passend.sort((a, b) => (a.basis === b.basis ? b.nr - a.nr : a.basis < b.basis ? 1 : -1));
213
+ return passend.find((k) => istAufnahme(k.name))?.name ?? null;
214
+ }
215
+ /** `zahlenDa`: Dauer, Klicks, Tasten und Ton kamen in der erwarteten Form. Ohne sie ist auch "gestoppt" nicht bestaetigt. */
216
+ export function stoppAusgang(ergebnis, zahlenDa) {
217
+ if (ergebnis === "keine")
218
+ return { satz: "keine", code: 0, notizWeg: true };
219
+ if ((ergebnis === "gestoppt" || ergebnis === "selbst-gestoppt") && zahlenDa)
220
+ return { satz: ergebnis, code: 0, notizWeg: true };
221
+ if (ergebnis === "nicht-sauber" || ergebnis === "abgebrochen")
222
+ return { satz: ergebnis, code: 1, notizWeg: true };
223
+ if (ergebnis === "wird-abgeschlossen")
224
+ return { satz: ergebnis, code: 1, notizWeg: false };
225
+ return { satz: "unklar", code: 1, notizWeg: false };
226
+ }
@@ -0,0 +1,190 @@
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
3
+ import { homedir, tmpdir } from "node:os";
4
+ import { join, win32 } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { juengsteAufnahme, liesWerkzeugAntwort, starterLebt, uvKandidaten } from "./zeigen-rechnung.js";
7
+ /**
8
+ * Die Umgebung hinter `kimal3 zeigen`: wo der Aufnehmer und sein Python liegen, wie das Werkzeug gerufen wird, und
9
+ * die Notiz `~/.kimal3/zeigen.json` (welcher Ordner zu welcher Sitzung gehoert). Entscheidungen trifft
10
+ * `zeigen-rechnung.ts`; hier wird nur gesucht, gestartet, gelesen und geschrieben.
11
+ * 🔴 Ausgaben fremder Programme werden nie weitergereicht: vom Werkzeug zaehlt nur die eine JSON-Zeile (gelesen von
12
+ * `liesWerkzeugAntwort`), stderr wird verworfen.
13
+ */
14
+ /** `cli/dist/zeigen-umgebung.js` -> `cli/zeigen/werkzeug.py` (im Paket unter `files`, Task 7). */
15
+ const WERKZEUG = fileURLToPath(new URL("../zeigen/werkzeug.py", import.meta.url)); // kein-anzeigetext: Paketpfad
16
+ function laeuft(befehl, argumente) {
17
+ try {
18
+ const r = spawnSync(befehl, argumente, { encoding: "utf8", windowsHide: true, timeout: 20_000 });
19
+ return r.status === 0 ? r.stdout : null;
20
+ }
21
+ catch {
22
+ return null;
23
+ }
24
+ }
25
+ /** Das erste uv, das antwortet: Suchpfad, dann die bekannten Orte (frisch installiert, Terminal noch alt). */
26
+ export function findeUv() {
27
+ for (const uv of ["uv", ...uvKandidaten(process.env, homedir())]) {
28
+ const version = laeuft(uv, ["--version"]);
29
+ if (version !== null)
30
+ return { uv, version };
31
+ }
32
+ return null;
33
+ }
34
+ /** "uv 0.12.10 (…)" -> "0.12.10"; `null`, wenn es kein uv gibt. */
35
+ export function uvFassung() {
36
+ return /^uv (\d+\.\d+\.\d+\S*)/.exec(findeUv()?.version ?? "")?.[1] ?? null;
37
+ }
38
+ /** Python und pythonw des Aufnehmers: `<uv tool dir>/openadapt-capture/Scripts/`, sonst `%APPDATA%\uv\tools\…`. */
39
+ export function aufnehmerPython() {
40
+ const uv = findeUv();
41
+ const toolDir = uv === null ? null : laeuft(uv.uv, ["tool", "dir"])?.trim() || null;
42
+ const appdata = process.env.APPDATA ?? win32.join(homedir(), "AppData", "Roaming"); // kein-anzeigetext: Ordnerpfad
43
+ // Antwortet uv, gilt sein Ordner allein (so greift `UV_TOOL_DIR`); der Standardort nur, wenn uv selbst fehlt (Task 5:
44
+ // sonst faende ein nachgestellter frischer Rechner den echten Aufnehmer unter %APPDATA% doch).
45
+ const orte = toolDir !== null ? [toolDir] : [win32.join(appdata, "uv", "tools")]; // kein-anzeigetext: Ordnerpfad
46
+ for (const ort of orte) {
47
+ const scripts = win32.join(ort, "openadapt-capture", "Scripts"); // kein-anzeigetext: Ordnerpfad
48
+ const python = win32.join(scripts, "python.exe");
49
+ const pythonw = win32.join(scripts, "pythonw.exe");
50
+ if (existsSync(python) && existsSync(pythonw))
51
+ return { python, pythonw };
52
+ }
53
+ return null;
54
+ }
55
+ /** Wohin uv die Programme seiner Werkzeuge legt (`uv tool dir --bin`, beachtet `UV_TOOL_BIN_DIR`). */
56
+ export function uvBinOrdner(uv) {
57
+ return laeuft(uv, ["tool", "dir", "--bin"])?.trim() || null;
58
+ }
59
+ /**
60
+ * Ein fremdes Programm (winget, uv, capture) ohne Fenster und ohne dass eine Zeile davon irgendwo erscheint -- nicht
61
+ * einmal eingesammelt. `true` nur bei Rueckgabewert 0; ob die Sache danach WIRKLICH da ist, prueft der Aufrufer neu.
62
+ * Arbeitsordner ist der Temp-Ordner: nie das Projekt (eine `uv.toml`/`.env` dort gehoert nicht zum Einrichten).
63
+ */
64
+ export function stillAusfuehren(befehl, argumente, zeitgrenze) {
65
+ try {
66
+ const r = spawnSync(befehl, argumente, { stdio: "ignore", windowsHide: true, cwd: tmpdir(), env: UMGEBUNG(), timeout: zeitgrenze });
67
+ return r.status === 0;
68
+ }
69
+ catch {
70
+ return false;
71
+ }
72
+ }
73
+ /** Umgebung jedes Werkzeug-Aufrufs, auch des Starters (R-14, Punkt 6); den Rest setzt das Werkzeug selbst. */
74
+ const UMGEBUNG = () => ({ ...process.env, PYTHONUTF8: "1", PYTHONUNBUFFERED: "1" });
75
+ /** Lesende Aufrufe (`teile`, `zustand`) duerfen nicht haengen: nach 20 s gilt die Antwort als "weiss nicht" (R-14, Punkt 7). */
76
+ export const LESEN_MS = 20_000;
77
+ /**
78
+ * `stoppen` antwortet nach spaetestens 495 s selbst (werkzeug.py, STOPP_SEK + WARTEN_SEK). Diese Grenze ist nur das
79
+ * Netz, falls es haengt: Claude Code bricht einen Werkzeugaufruf nach 10 Minuten ab, und dann kaeme gar kein Satz.
80
+ */
81
+ export const STOPPEN_MS = 540_000;
82
+ /**
83
+ * Ein Unterbefehl des Werkzeugs, synchron. Arbeitsordner = Aufnahmeordner (nie das Projekt: `.env`-Falle).
84
+ * `zeitgrenze` in ms oder `null` (stoppen und auswerten duerfen dauern). Abgebrochen = `status` null = `{ ok: false }`.
85
+ */
86
+ export function rufeWerkzeug(p, argumente, arbeitsordner, zeitgrenze) {
87
+ try {
88
+ // stderr wird gar nicht erst eingesammelt: Er wird nie gelesen, und ein Fortschrittsbalken (Modell laden, 1,6 GB)
89
+ // koennte sonst `maxBuffer` sprengen -- dann bricht Node den Aufruf ab (Task 5).
90
+ const r = spawnSync(p.python, [WERKZEUG, ...argumente], {
91
+ encoding: "utf8", windowsHide: true, cwd: arbeitsordner ?? tmpdir(), maxBuffer: 1024 * 1024, env: UMGEBUNG(),
92
+ stdio: ["ignore", "pipe", "ignore"],
93
+ ...(zeitgrenze === null ? {} : { timeout: zeitgrenze }),
94
+ });
95
+ return liesWerkzeugAntwort(r.status, r.stdout ?? "");
96
+ }
97
+ catch {
98
+ return { ok: false };
99
+ }
100
+ }
101
+ /** Startet den Starter abgehaengt mit pythonw (kein Fenster, keine Ausgabe). `false`, wenn schon das Starten scheitert. */
102
+ export function starteStarter(p, konfig, ordner) {
103
+ try {
104
+ const kind = spawn(p.pythonw, [WERKZEUG, "starten", konfig], { cwd: ordner, detached: true, stdio: "ignore", windowsHide: true, env: UMGEBUNG() });
105
+ kind.on("error", () => { });
106
+ kind.unref();
107
+ return true;
108
+ }
109
+ catch {
110
+ return false;
111
+ }
112
+ }
113
+ // ── Die Notiz ─────────────────────────────────────────────────────────────────────────────────────────────────────
114
+ const ORDNER = join(homedir(), ".kimal3");
115
+ const NOTIZ = join(ORDNER, "zeigen.json"); // kein-anzeigetext: Dateiname
116
+ export function leseNotiz() {
117
+ try {
118
+ const n = JSON.parse(readFileSync(NOTIZ, "utf8"));
119
+ const zahl = (w) => typeof w === "number" && Number.isFinite(w);
120
+ if (typeof n.sitzung !== "string" || typeof n.ordner !== "string" || !zahl(n.gestartet) || !zahl(n.frist))
121
+ return null;
122
+ return { sitzung: n.sitzung, ordner: n.ordner, gestartet: n.gestartet, frist: n.frist };
123
+ }
124
+ catch {
125
+ return null;
126
+ }
127
+ }
128
+ /** `false`, wenn die Notiz nicht geschrieben werden konnte -- die Aufnahme laeuft trotzdem (status findet sie ueber den Ordner). */
129
+ export function schreibeNotiz(n) {
130
+ try {
131
+ mkdirSync(ORDNER, { recursive: true, mode: 0o700 }); // wie wache-schalter.ts: wer den Ordner anlegt, legt die Rechte fest
132
+ writeFileSync(NOTIZ, JSON.stringify(n, null, 2), "utf8");
133
+ return true;
134
+ }
135
+ catch {
136
+ return false;
137
+ }
138
+ }
139
+ export function loescheNotiz() {
140
+ try {
141
+ rmSync(NOTIZ, { force: true });
142
+ }
143
+ catch {
144
+ /* bleibt liegen -- status vergleicht sie ohnehin mit den Sitzungen des Aufnehmers */
145
+ }
146
+ }
147
+ // ── Ordner und Bericht des Starters ───────────────────────────────────────────────────────────────────────────────
148
+ /**
149
+ * Der juengste Aufnahmeordner unter `aufnahmen`, oder `null`. Zaehlt nur, wo eine Aufnahme liegt (`roh/recording.db`)
150
+ * oder ein Starter noch lebt (er startet gerade, die Datei kommt erst noch). `roh/` gehoert allein dem Aufnehmer
151
+ * (gemeinsam.py, ROH: sein Siegel bricht an jeder Datei, die wir dort schreiben).
152
+ */
153
+ export function juengsterOrdner(aufnahmen) {
154
+ try {
155
+ const namen = readdirSync(aufnahmen, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
156
+ const istAufnahme = (name) => existsSync(join(aufnahmen, name, "roh", "recording.db")) || // kein-anzeigetext: Dateiname
157
+ starterLebt(starterFakt(leseStarter(join(aufnahmen, name))), Date.now() / 1000);
158
+ const name = juengsteAufnahme(namen, istAufnahme);
159
+ return name === null ? null : join(aufnahmen, name);
160
+ }
161
+ catch {
162
+ return null;
163
+ }
164
+ }
165
+ /** Der Bericht des Starters (`starter.json`), roh; `null`, wenn er fehlt oder kaputt ist. */
166
+ export function leseStarter(ordner) {
167
+ try {
168
+ const d = JSON.parse(readFileSync(join(ordner, "starter.json"), "utf8")); // kein-anzeigetext: Dateiname
169
+ return d && typeof d === "object" && !Array.isArray(d) ? d : null;
170
+ }
171
+ catch {
172
+ return null;
173
+ }
174
+ }
175
+ const STARTER_ZUSTAENDE = ["startet", "laeuft", "gestoppt", "selbst-gestoppt", "abgebrochen", "fehlgeschlagen"];
176
+ /** Bericht des Starters -> `Fakten.starter`; alles, was nicht die erwartete Form hat, faellt weg. */
177
+ export function starterFakt(roh) {
178
+ if (!roh || typeof roh !== "object")
179
+ return null;
180
+ const r = roh;
181
+ const zustand = STARTER_ZUSTAENDE.find((z) => z === r.zustand);
182
+ if (zustand === undefined)
183
+ return null;
184
+ return {
185
+ zustand,
186
+ ...(typeof r.sitzung === "string" ? { sitzung: r.sitzung } : {}),
187
+ ...(typeof r.zuletzt === "number" && Number.isFinite(r.zuletzt) ? { zuletzt: r.zuletzt } : {}),
188
+ ...(typeof r.grund === "string" ? { grund: r.grund } : {}),
189
+ };
190
+ }
package/package.json CHANGED
@@ -1,29 +1,30 @@
1
- {
2
- "name": "kimal3",
3
- "version": "0.4.0",
4
- "description": "Trag deine KI-Projekte dort ein, wo sie entstehen: im Terminal. · Log your AI projects where they happen: in the terminal.",
5
- "type": "module",
6
- "bin": {
7
- "kimal3": "dist/kimal3.js"
8
- },
9
- "files": [
10
- "dist",
11
- "skill",
12
- "claude-plugins",
13
- "coach",
14
- "README.md"
15
- ],
16
- "engines": {
17
- "node": ">=20"
18
- },
19
- "scripts": {
20
- "build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json",
21
- "prepublishOnly": "npm run build"
22
- },
23
- "keywords": [
24
- "kimal3",
25
- "cli"
26
- ],
27
- "homepage": "https://app.kimal3.de/pro",
28
- "license": "SEE LICENSE IN README.md"
29
- }
1
+ {
2
+ "name": "kimal3",
3
+ "version": "0.6.0",
4
+ "description": "Trag deine KI-Projekte dort ein, wo sie entstehen: im Terminal. · Log your AI projects where they happen: in the terminal.",
5
+ "type": "module",
6
+ "bin": {
7
+ "kimal3": "dist/kimal3.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "skill",
12
+ "claude-plugins",
13
+ "coach",
14
+ "zeigen",
15
+ "README.md"
16
+ ],
17
+ "engines": {
18
+ "node": ">=20"
19
+ },
20
+ "scripts": {
21
+ "build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json",
22
+ "prepublishOnly": "npm run build"
23
+ },
24
+ "keywords": [
25
+ "kimal3",
26
+ "cli"
27
+ ],
28
+ "homepage": "https://app.kimal3.de/pro",
29
+ "license": "SEE LICENSE IN README.md"
30
+ }
package/skill/SKILL.md CHANGED
@@ -13,7 +13,7 @@ description: Projekte ins kimal3-Erfolgsprofil eintragen oder auflisten, Firmend
13
13
  Sobald der Mensch sagt, ein Projekt sei fertig, oder ausdrücklich bittet, es einzutragen.
14
14
  **Oder** wenn er seinen KI-Arbeitsplatz anlegen will — dann s. „Den Arbeitsplatz anlegen".
15
15
 
16
- ## Die acht Slash-Befehle, die der Mensch hat
16
+ ## Die zehn Slash-Befehle, die der Mensch hat
17
17
 
18
18
  `kimal3 skill install` legt sie global ab (`~/.claude/commands/`) — er kann sie also in
19
19
  **jedem** Ordner tippen. Sie sind der Weg, den er selbst auslöst; dieses Skill ist der Weg,
@@ -26,9 +26,11 @@ den du erkennst.
26
26
  | `/k3feierabend` | Sitzung sauber beenden: Stand sichern, aufräumen, zusammenfassen |
27
27
  | `/k3kurs` | Zurück in den Mini-Kurs — holt den Stand mit `kimal3 kurs` und macht da weiter, wo er aufgehört hat |
28
28
  | `/k3projekt` | Ein fertiges Projekt ins Erfolgsprofil eintragen |
29
+ | `/k3plan` | Die Specs eines Projekts gegen den Code prüfen und das Ergebnis mit `kimal3 plan` und `kimal3 plan hoch` auf die Projektseite laden |
29
30
  | `/k3liste` | Seine eingetragenen Projekte zeigen |
30
31
  | `/k3analyse` | Firmendaten holen und seine Frage dazu beantworten |
31
32
  | `/k3kontext` | Die offenen Fragen über seine Firma im Gespräch beantworten |
33
+ | `/k3zeigen` | Eine Aufgabe vormachen statt beschreiben — nimmt mit `kimal3 zeigen …` auf, wertet auf dem Rechner aus und schreibt den Plan; gelesen wird erst nach seinem Ja |
32
34
 
33
35
  🔴 **Nenne einen dieser Befehle nur, wenn du weißt, dass er ihn hat.** Sie kommen mit
34
36
  `kimal3 skill install`; wer das nie ausgeführt hat, sieht beim Tippen nichts, und ein Rat ins
@@ -37,7 +39,7 @@ einen Befehl zu zeigen.
37
39
 
38
40
  ⚠️ **`/k3start`, `/k3feierabend` und `/k3kurs` kamen bis 0.2.4 NUR im Kursordner mit** — Pro-
39
41
  Kunden ohne Mini-Kurs hatten sie nie, und aktualisieren ließen sie sich gar nicht. Seit 0.2.5
40
- liefert das Programm alle acht aus. *Triffst du jemanden mit einer älteren Fassung, ist ein
42
+ liefert das Programm sie alle aus. *Triffst du jemanden mit einer älteren Fassung, ist ein
41
43
  fehlender Befehl dort kein Fehler, sondern der alte Stand.*
42
44
 
43
45
  ## 🔴 Mehrere Firmen: DU WÄHLST NICHT
@@ -81,19 +81,21 @@ Stell die Liste in zwei Gruppen dar:
81
81
  | `/k3kurs` | Zurück in den Mini-Kurs — da weiter, wo du aufgehört hast |
82
82
  | `/k3kontext` | Durch die offenen Fragen über deine Firma führen |
83
83
  | `/k3projekt <was du gebaut hast>` | Ein fertiges Projekt ins Erfolgsprofil eintragen |
84
+ | `/k3plan` | Die Specs gegen den Code prüfen und die Planung in die App hochladen |
84
85
  | `/k3liste` | Deine eingetragenen Projekte zeigen |
85
86
  | `/k3analyse <deine Frage>` | Firmendaten holen und deine Frage dazu beantworten |
87
+ | `/k3zeigen` | Eine Aufgabe vormachen statt beschreiben — ich nehme auf und mache daraus einen Plan |
86
88
 
87
89
  Sag dazu: Die Slash-Befehle sind der **sichere** Weg — du musst dir keinen Satz merken. Ohne
88
90
  sie geht es auch: „das Projekt ist fertig" genügt, dann greife ich über den Skill.
89
91
 
90
- ## 6. Der neunte Eintrag im Menü: `/kimal3` ist KEIN Befehl
92
+ ## 6. Der elfte Eintrag im Menü: `/kimal3` ist KEIN Befehl
91
93
 
92
94
  In derselben `/`-Liste steht auch **`/kimal3`**. Erklär den Unterschied **ungefragt** — das Menü
93
95
  wirft zwei verschiedene Sorten in eine Liste, und genau daran hat sich der erste Mensch gestoßen,
94
96
  der die Familie gesehen hat (Dennis, 2026-08-19):
95
97
 
96
- - Die acht **`/k3…`** sind **Slash-Befehle** (Dateien in `~/.claude/commands/`) — **der Mensch**
98
+ - Die zehn **`/k3…`** sind **Slash-Befehle** (Dateien in `~/.claude/commands/`) — **der Mensch**
97
99
  tippt sie bewusst.
98
100
  - **`/kimal3`** ist der **Skill** (`~/.claude/skills/kimal3/SKILL.md`) — meine
99
101
  Bedienungsanleitung. Sie ist der Grund, warum „das Projekt ist fertig" genügt: Ich schlage sie
@@ -101,5 +103,5 @@ der die Familie gesehen hat (Dennis, 2026-08-19):
101
103
  sie mehrere Dinge auf einmal und liest sich nicht wie ein Menüpunkt.
102
104
 
103
105
  Sag auch, was `/kimal3` **nicht** ist: **nicht die Installation.** Die ist `kimal3 skill install`
104
- im Terminal — der eine Befehl, der beides ablegt, den Skill **und** die acht `/k3…`. Was im Menü
106
+ im Terminal — der eine Befehl, der beides ablegt, den Skill **und** die zehn `/k3…`. Was im Menü
105
107
  steht, ist sein Ergebnis.