kimal3 0.6.4 → 0.7.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 (51) 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/ablage.ts +8 -0
  6. package/claude-plugins/kimal3-logo/hooks/app-oeffnen.ts +37 -9
  7. package/claude-plugins/kimal3-logo/hooks/register.tsx +42 -18
  8. package/claude-plugins/kimal3-logo/hooks/update-rechnung.ts +175 -0
  9. package/claude-plugins/kimal3-logo/hooks/update.ts +244 -0
  10. package/claude-plugins/kimal3-logo/types/index.d.ts +4 -0
  11. package/claude-plugins/kimal3-plan/.claude-plugin/plugin.json +7 -0
  12. package/claude-plugins/kimal3-plan/hooks/erinnerung.ts +79 -0
  13. package/claude-plugins/kimal3-plan/hooks/fehlerzeile.ts +41 -0
  14. package/claude-plugins/kimal3-plan/hooks/hooks.json +1 -0
  15. package/claude-plugins/kimal3-plan/hooks/lauf.ts +143 -0
  16. package/claude-plugins/kimal3-plan/hooks/lebenszeichen.ts +197 -0
  17. package/claude-plugins/kimal3-plan/hooks/ordner.ts +141 -0
  18. package/claude-plugins/kimal3-plan/hooks/register.tsx +22 -0
  19. package/claude-plugins/kimal3-plan/hooks/spec-text.ts +45 -0
  20. package/claude-plugins/kimal3-plan/types/index.d.ts +10 -0
  21. package/coach/de/kimal3-coach.md +33 -0
  22. package/coach/en/kimal3-coach.md +33 -0
  23. package/dist/argumente.js +12 -0
  24. package/dist/atomar.js +22 -0
  25. package/dist/befehle/einrichten.js +4 -4
  26. package/dist/befehle/login.js +2 -2
  27. package/dist/befehle/plan-hoch.js +8 -0
  28. package/dist/befehle/plan-melde.js +105 -0
  29. package/dist/befehle/plan-senden.js +149 -0
  30. package/dist/befehle/plan.js +45 -10
  31. package/dist/befehle/projekt-verbinden.js +141 -0
  32. package/dist/befehle/skill-install.js +2 -2
  33. package/dist/befehle-katalog-en.js +6 -6
  34. package/dist/befehle-katalog.js +8 -7
  35. package/dist/befehlswoerter.js +9 -0
  36. package/dist/einstellungen-schreiben.js +1 -1
  37. package/dist/kimal3.js +22 -0
  38. package/dist/meldungen-speicher.js +189 -0
  39. package/dist/{logo-ablegen.js → mods-ablegen.js} +87 -20
  40. package/dist/plugins-mischen.js +7 -2
  41. package/dist/projekt-ordner.js +189 -0
  42. package/dist/texte/einrichten.js +3 -1
  43. package/dist/texte/meldungen.js +127 -0
  44. package/dist/texte/plan.js +42 -22
  45. package/dist/texte/wache.js +4 -4
  46. package/dist/wache-rechnung.js +3 -1
  47. package/package.json +1 -1
  48. package/skill/commands/k3feierabend.md +20 -0
  49. package/skill/commands/k3plan.md +151 -211
  50. package/skill/en/commands/k3plan.md +157 -219
  51. package/skill/en/commands/k3wrapup.md +19 -0
@@ -0,0 +1,244 @@
1
+ import { atom, read, update, type Engine } from 'claude-code'
2
+ import type { Ablage } from './ablage'
3
+ import {
4
+ befehle, deutscherTag, fertigGilt, istFassung, istNeuer, sollFragen, sperreGilt, TEXTE, versionStimmt,
5
+ type Fertig, type Merkzettel, type Sperre, type Zustand,
6
+ } from './update-rechnung'
7
+
8
+ // Der Ablauf hinter „Update installieren“ in der Leiste (Bauplan docs/superpowers/plans/2026-10-23-update-in-der-leiste.md,
9
+ // Abschnitte 1.2 und 1.3, Szenarien U-1 bis U-20). Die Entscheidungen selbst stehen rein in update-rechnung.ts; hier
10
+ // steht, wann was gefragt, gemerkt und ausgeführt wird. Gezeichnet wird in register.tsx.
11
+
12
+ const MINUTE = 60_000
13
+ const NPM = 'https://registry.npmjs.org/kimal3/latest'
14
+ // `HttpInit` kennt kein Zeitlimit: Was npm nach 5 s nicht beantwortet hat, zählt als Fehlschlag (Bauplan 1.2, A-6).
15
+ const NPM_GEDULD = 5000
16
+ // Was der Mod über Sitzungen und Fenster hinweg weiß (`$.store`, eine Datei je Mod): wann npm gefragt wurde, ob ein
17
+ // Update läuft, und dass eines geklappt hat, aber Claude noch nicht neu gestartet ist.
18
+ const MERKZETTEL = 'updateMerkzettel'
19
+ const SPERRE = 'updateSperre'
20
+ const FERTIG = 'updateFertig'
21
+ // Die zwei Befehle, die der Klick ausführt, so wie Claude sie in seinen Erlaubnis-Regeln sähe (Bauplan 1.3 Punkt 2).
22
+ // Beide Werkzeuge: Eine Firma, die `npm install` nur für PowerShell verbietet, verbietet es trotzdem (A-3).
23
+ const WERKZEUGE = ['Bash', 'PowerShell']
24
+
25
+ // Was die Leiste neu zeichnen soll, sobald es sich ändert (`$.state`, Vertrag in types/index.d.ts).
26
+ const neueste = atom({ plugin: 'kimal3-logo', key: 'updateNeueste' } as const, '')
27
+ const lauf = atom({ plugin: 'kimal3-logo', key: 'updateLauf' } as const, null)
28
+
29
+ /**
30
+ * Die Fehlermeldung für die Bitte an Claude: die letzten 10 Zeilen, höchstens 1500 Zeichen (Bauplan 1.3 Punkt 6).
31
+ * npm schreibt vorne Fortschritt und hinten den Grund; der Grund ist, was Claude braucht.
32
+ */
33
+ export function meldungKurz(text: string): string {
34
+ return text.trim().split(/\r?\n/).slice(-10).join('\n').slice(-1500)
35
+ }
36
+
37
+ // Was im Store steht, ist Daten von gestern — vielleicht von einer älteren Fassung des Mods. Nur Felder mit dem
38
+ // richtigen Typ zählen; ein `fehlerAm` als Text ergäbe sonst `NaN`, und npm würde nie wieder gefragt.
39
+ function merkzettelAus(roh: unknown): Merkzettel {
40
+ const m = (roh ?? {}) as Record<string, unknown>
41
+ return {
42
+ ...(typeof m.tag === 'string' ? { tag: m.tag } : {}),
43
+ ...(istFassung(m.neueste) ? { neueste: m.neueste } : {}),
44
+ ...(typeof m.fehlerAm === 'number' ? { fehlerAm: m.fehlerAm } : {}),
45
+ }
46
+ }
47
+
48
+ function versionAus(text: string): string | null {
49
+ try {
50
+ const version: unknown = (JSON.parse(text) as { version?: unknown } | null)?.version
51
+ return istFassung(version) ? version : null
52
+ } catch {
53
+ return null
54
+ }
55
+ }
56
+
57
+ function sperreAus(roh: unknown): Sperre | undefined {
58
+ const s = (roh ?? {}) as Record<string, unknown>
59
+ return istFassung(s.ziel) && typeof s.seit === 'number' ? { ziel: s.ziel, seit: s.seit } : undefined
60
+ }
61
+
62
+ // Ein Fehlschlag im Hintergrund gehört ins Debug-Protokoll, nie in den Chat (ohne `to: 'debug'` stünde er dort).
63
+ function protokoll($: Engine, text: string): void {
64
+ $.ui.log(`kimal3-logo: ${text.slice(0, 300)}`, { to: 'debug' })
65
+ }
66
+
67
+ type Sprache = 'de' | 'en'
68
+ // Gegen einen zweiten Klick, solange die Rückfrage noch offen ist oder der Lauf läuft.
69
+ let beschaeftigt = false
70
+ // Der Blick auf npm startet mit dem ersten Zeichnen der Leiste, einmal je Laden des Mods.
71
+ let angestossen = false
72
+
73
+ /** Nach dem Laden: Aufräumen, Wache-Schalter, Merkzettel — und npm fragen, wenn es heute noch nicht geschah. */
74
+ async function beimLaden($: Engine, ablage: Ablage): Promise<void> {
75
+ const fassung = ablage.fassung
76
+ const jetzt = await $.clock.now()
77
+ // U-6b: Ein Mod, der die neue Fassung schon trägt, hat das Update hinter sich; nach 24 Stunden gilt der Haken nicht mehr.
78
+ const fertig = await $.store.get(FERTIG)
79
+ if (fertig !== undefined && !fertigGilt(fertig, jetzt, fassung)) await $.store.delete(FERTIG)
80
+ const sperre = sperreAus(await $.store.get(SPERRE))
81
+ if (sperre !== undefined && !istNeuer(sperre.ziel, fassung)) await $.store.delete(SPERRE)
82
+
83
+ if (await wacheAus($, ablage)) return
84
+ const merkzettel = merkzettelAus(await $.store.get(MERKZETTEL))
85
+ // Ein zweites Fenster am selben Tag zeigt den Knopf aus dem Merkzettel, ohne zu fragen (U-11).
86
+ if (merkzettel.tag === deutscherTag(jetzt) && merkzettel.neueste !== undefined) await update($, neueste, () => merkzettel.neueste ?? '')
87
+ if (sollFragen(merkzettel, jetzt)) await npmFragen($, jetzt)
88
+ }
89
+
90
+ /** `kimal3 wache aus` schaltet auch den Blick auf npm ab (E-8, U-8). Keine oder kaputte Datei: an. */
91
+ async function wacheAus($: Engine, ablage: Ablage): Promise<boolean> {
92
+ try {
93
+ const inhalt: unknown = JSON.parse(await $.fs.read(`${ablage.kimal3Ordner}/wache.json`))
94
+ return (inhalt as { aus?: unknown } | null)?.aus === true
95
+ } catch {
96
+ return false
97
+ }
98
+ }
99
+
100
+ async function npmFragen($: Engine, jetzt: number): Promise<void> {
101
+ // Ein Zeitgeber statt `$.clock.sleep`: Ein `sleep` liefe auf das Zeitbudget des Hakens, aus dem `$` stammt — hier das
102
+ // Zeichnen der Leiste.
103
+ let zuEnde: (wert: null) => void = () => {}
104
+ const zuLangsam = new Promise<null>(aufloesen => { zuEnde = aufloesen })
105
+ const zeitgeber = $.clock.after(NPM_GEDULD, () => zuEnde(null))
106
+ const antwort = await Promise.race([$.http.fetch(NPM).catch(() => null), zuLangsam])
107
+ zeitgeber.cancel()
108
+ const version = versionAus(antwort?.ok ? antwort.text : '')
109
+ // Nur `1.2.3` zählt als Antwort; Vorabfassung, Unsinn, Fehler und Schweigen sind ein Fehlschlag (U-2, U-12).
110
+ if (version === null) {
111
+ protokoll($, `keine gültige Antwort von npm: ${antwort === null ? 'keine Antwort' : `${antwort.status} ${antwort.text}`}`)
112
+ await $.store.set(MERKZETTEL, { fehlerAm: jetzt } satisfies Merkzettel)
113
+ return
114
+ }
115
+ await $.store.set(MERKZETTEL, { tag: deutscherTag(jetzt), neueste: version } satisfies Merkzettel)
116
+ await update($, neueste, () => version)
117
+ }
118
+
119
+ /** Was die Leiste hinter der Fassung zeigt; `null` ohne Ablage (U-9). Schreibt nichts: Es wird gerade gezeichnet. */
120
+ export async function updateZustand($: Engine, ablage: Ablage, claudeArbeitet: boolean): Promise<Zustand | null> {
121
+ const fassung = ablage.fassung
122
+ if (fassung === '') return null
123
+ // Erst danach, nie in diesem Zeichnen (es soll nicht auf npm warten, und schreiben darf es nicht). Am ersten Zeichnen
124
+ // statt an `session.start`: Den Start hört schon hooks/app-oeffnen.ts (je Ereignis ein Haken ohne Auswahl), und
125
+ // ohne Leiste — `claude -p`, Skripte — braucht niemand die Antwort von npm.
126
+ if (!angestossen) {
127
+ angestossen = true
128
+ $.clock.after(0, () => void beimLaden($, ablage).catch(f => protokoll($, `Update-Blick: ${String(f)}`)))
129
+ }
130
+ const jetzt = await $.clock.now()
131
+ const sperre = sperreAus(await $.store.get(SPERRE))
132
+ const laeuft = sperreGilt(sperre, jetzt, fassung)
133
+ const hier = await read($, lauf)
134
+ // „läuft“ aus diesem Fenster nur, solange die Sperre gilt: Sonst ist es liegen geblieben (der Lauf kam nie zu Ende,
135
+ // etwa weil der Mod mittendrin neu geladen wurde), und die Leiste zeigte für immer „wird installiert“.
136
+ if (hier !== null && (hier.art !== 'laeuft' || laeuft)) {
137
+ return hier.art === 'laeuft' || hier.art === 'geklappt' ? { fassung, art: hier.art, ziel: hier.ziel } : { fassung, art: hier.art }
138
+ }
139
+ // Aus einem anderen Fenster (oder vor dem Schließen): geklappt, aber noch nicht neu gestartet (U-6b) — oder es läuft (U-5).
140
+ const fertig = await $.store.get(FERTIG)
141
+ if (fertigGilt(fertig, jetzt, fassung)) return { fassung, art: 'geklappt', ziel: fertig.ziel }
142
+ if (sperre !== undefined && laeuft) return { fassung, art: 'laeuft', ziel: sperre.ziel }
143
+ // Kein Knopf, solange Claude antwortet (U-18): ein Klick mitten in einer Antwort stellte eine Frage quer dazu.
144
+ return istNeuer(await read($, neueste), fassung) && !claudeArbeitet ? { fassung, art: 'knopf' } : { fassung, art: 'still' }
145
+ }
146
+
147
+ /** Der Klick auf „Update installieren“ (Bauplan 1.3). */
148
+ export async function updateInstallieren($: Engine, ablage: Ablage, sprache: Sprache): Promise<void> {
149
+ const t = TEXTE[sprache]
150
+ const fassung = ablage.fassung
151
+ const ziel = await read($, neueste)
152
+ if (beschaeftigt || !istNeuer(ziel, fassung)) return
153
+ beschaeftigt = true
154
+ try {
155
+ // K-4, U-10: Ohne npm-Pfad kein Direktbefehl — Claude bekommt die Bitte und fragt selbst um Erlaubnis.
156
+ const liste = befehle(ablage, ziel)
157
+ if (liste === null) {
158
+ await $.prompt.submit({ text: t.bitteOhneNpm(ziel) })
159
+ return
160
+ }
161
+ // U-3: Abbrechen, „Other“, eine Chat-Nachricht statt einer Antwort und das Wegklicken (Wurf): nichts passiert.
162
+ const antwort = await $.ui.ask(t.frage(fassung, ziel), [t.ja, t.nein]).catch(() => null)
163
+ if (antwort !== t.ja) return
164
+ // U-5: Während der Rückfrage kann ein anderes Fenster angefangen haben — dann zeigt auch dieses „wird installiert“.
165
+ const sperre = sperreAus(await $.store.get(SPERRE))
166
+ if (sperre !== undefined && sperreGilt(sperre, await $.clock.now(), fassung)) {
167
+ await update($, lauf, () => ({ art: 'laeuft', ziel: sperre.ziel, meldung: '' }))
168
+ return
169
+ }
170
+ if (await verboten($, ziel)) {
171
+ await update($, lauf, () => ({ art: 'gesperrt', ziel, meldung: '' }))
172
+ return
173
+ }
174
+ await ausfuehren($, ablage, liste, ziel)
175
+ } catch (fehler) {
176
+ // Etwa `$.store` scheitert (die Sperre lässt sich nicht schreiben): kein Erfolg und kein „gesperrt“, sondern ein Fehler mit Grund.
177
+ await update($, lauf, () => ({ art: 'fehler', ziel, meldung: meldungKurz(String(fehler)) }))
178
+ } finally {
179
+ beschaeftigt = false
180
+ }
181
+ }
182
+
183
+ /**
184
+ * U-4: Verbietet eine Erlaubnis-Regel einen der zwei Befehle in einem der zwei Werkzeuge? `ask` (der Normalfall im
185
+ * Auto-Modus, M-16) ist kein Verbot. ⚠️ Die Abfrage kennt nur die Regeln, nicht die PreToolUse-Hooks einer Firma und
186
+ * nicht den Prüfer des Auto-Modus (Typen von `ToolCheckResult.hook`; Bauplan Abschnitt 4).
187
+ * Gefragt werden nur Werkzeuge, die es hier gibt (ohne PowerShell etwa unter macOS); wirft die Liste, nur `Bash`. Eine
188
+ * Abfrage, die wirft, ist kein Verbot — nur ein ausgesprochenes `deny` sperrt; sonst hielte ein fehlendes Werkzeug
189
+ * jedes Update auf.
190
+ */
191
+ async function verboten($: Engine, ziel: string): Promise<boolean> {
192
+ const da = await $.tool.list().then(liste => liste.map(w => w.name), () => ['Bash'])
193
+ for (const tool of WERKZEUGE.filter(w => da.includes(w))) {
194
+ for (const command of [`npm install -g kimal3@${ziel}`, 'kimal3 skill install']) {
195
+ const urteil = await $.tool.check({ tool, input: { command } }).catch((fehler: unknown) => {
196
+ protokoll($, `Regel-Abfrage ${tool}: ${String(fehler)}`)
197
+ return null
198
+ })
199
+ if (urteil?.decision === 'deny') return true
200
+ }
201
+ }
202
+ return false
203
+ }
204
+
205
+ /**
206
+ * Die drei Befehle nacheinander, jeder ohne Kommandozeile, im Ordner `~/.kimal3` (nie im Projekt: ein `.npmrc` dort
207
+ * gälte sonst mit). Scheitert eine Stufe, läuft die nächste nicht (U-6, U-7, U-7b). Die Sperre hält andere Fenster ab
208
+ * (U-5) und geht in `finally` weg — auch wenn ein Befehl wirft.
209
+ */
210
+ async function ausfuehren($: Engine, ablage: Ablage, liste: string[][], ziel: string): Promise<void> {
211
+ await $.store.set(SPERRE, { ziel, seit: await $.clock.now() } satisfies Sperre)
212
+ await update($, lauf, () => ({ art: 'laeuft', ziel, meldung: '' }))
213
+ try {
214
+ const meldung = await stufen($, ablage.kimal3Ordner, liste, ziel).catch((fehler: unknown) => String(fehler))
215
+ if (meldung === null) {
216
+ // Bleibt stehen, bis ein Mod mit dieser Fassung lädt oder 24 Stunden um sind (beimLaden, `fertigGilt`): Erst der
217
+ // Neustart bringt Coach und Befehle.
218
+ await $.store.set(FERTIG, { ziel, am: await $.clock.now() } satisfies Fertig)
219
+ await update($, lauf, () => ({ art: 'geklappt', ziel, meldung: '' }))
220
+ } else {
221
+ await update($, lauf, () => ({ art: 'fehler', ziel, meldung: meldungKurz(meldung) }))
222
+ }
223
+ } finally {
224
+ await $.store.delete(SPERRE)
225
+ }
226
+ }
227
+
228
+ /** `null`: alle drei Stufen geklappt; sonst die Meldung der gescheiterten. */
229
+ async function stufen($: Engine, ordner: string, [npm, version, skill]: string[][], ziel: string): Promise<string | null> {
230
+ const init = (minuten: number) => ({ cwd: ordner, timeoutMs: minuten * MINUTE })
231
+ const a = await $.process.run(npm, init(10))
232
+ if (a.exitCode !== 0) return `${a.stdout}\n${a.stderr}`
233
+ // Hat das npm dieses Node die Fassung wirklich dorthin gelegt, wo `kimal3` liegt? (Homebrew, Volta, A-8)
234
+ const b = await $.process.run(version, init(2))
235
+ if (b.exitCode !== 0 || !versionStimmt(b.stdout, ziel)) return `kimal3 --version:\n${b.stdout}\n${b.stderr}`
236
+ const c = await $.process.run(skill, init(2))
237
+ return c.exitCode !== 0 ? `${c.stdout}\n${c.stderr}` : null
238
+ }
239
+
240
+ /** „Claude fragen“ nach einem Fehlschlag: die Zielfassung und das Ende der Meldung (Bauplan 1.3 Punkt 6). */
241
+ export async function updateClaudeFragen($: Engine, sprache: Sprache): Promise<void> {
242
+ const stand = await read($, lauf)
243
+ if (stand?.art === 'fehler') await $.prompt.submit({ text: TEXTE[sprache].bitteFehler(stand.ziel, stand.meldung) })
244
+ }
@@ -7,6 +7,10 @@ declare module 'claude-code' {
7
7
  chatBenannt: boolean
8
8
  // Wie oft das Modell in dieser Sitzung schon gefragt wurde, ob das Thema klar ist (höchstens 8, B-8).
9
9
  benennenFragen: number
10
+ // Die neueste Fassung, die npm heute genannt hat ('' = keine bekannt); daraus zeichnet die Leiste den Knopf (hooks/update.ts).
11
+ updateNeueste: string
12
+ // Was der Klick in diesem Fenster angestoßen hat; `meldung` = Ende der Fehlermeldung für „Claude fragen“ (hooks/update.ts).
13
+ updateLauf: { art: 'laeuft' | 'geklappt' | 'fehler' | 'gesperrt'; ziel: string; meldung: string } | null
10
14
  }
11
15
  }
12
16
  }
@@ -0,0 +1,7 @@
1
+ {
2
+ "name": "kimal3-plan",
3
+ "version": "0.1.0",
4
+ "description": "Hält das Board der Projektseite in der App aktuell: Punkt solange die Sitzung arbeitet, Spec-Texte, Erinnerung an Claude · Keeps the board on the app's project page current: dot while the session works, spec texts, reminder to Claude",
5
+ "author": { "name": "KImal3", "url": "https://kimal3.de" },
6
+ "types": "./types/index.d.ts"
7
+ }
@@ -0,0 +1,79 @@
1
+ import { atom, read, update, type EngineInterface, type On } from 'claude-code'
2
+ import { TEXTE, type Lauf } from './lauf'
3
+ import { relativImOrdner } from './ordner'
4
+
5
+ /**
6
+ * Die drei Saetze, die NUR Claude bekommt (Spec 1.3 Aufgabe 3, 1.5, 1.7, S-25, S-26, US-6, US-8; Bauplan R-4).
7
+ * Der Mensch sieht keinen davon (gemessen M-3, Claude-App). Zwei Wege, je nachdem, ob der Satz eine Runde starten soll:
8
+ *
9
+ * - **Erinnerung ans Melden** haengt am Aendern einer Datei (`tool.call` mit `context`): keine eigene Runde, Claude liest
10
+ * ihn direkt nach dem Werkzeug. Nur die Hauptsitzung bekommt ihn: Ein Subagent (`e.agentId`) baut im Auftrag und
11
+ * meldet nicht; er wuerde die 30-Minuten-Sperre verbrauchen, ohne dass die Hauptsitzung je erinnert wird (Ruling). Hoechstens einmal je 30 Minuten je Sitzung, nur in einem Projektordner mit
12
+ * Ordnerzettel (S-30), und nur, wenn die App zuletzt „nichts in Arbeit“ gesagt hat -- weiss der Mod es nicht (noch
13
+ * keine Antwort, App weg), schweigt er.
14
+ * - **Anstoss „Projekt anlegen“** (US-8) und **„gemerkte Meldungen senden“** (R-4) sind eine Zeile im Gespraech
15
+ * (`$.session.append`): Claude liest sie sofort und handelt.
16
+ */
17
+ const SPERRE_MS = 30 * 60_000
18
+
19
+ // In `$.state`, weil ein Neuladen des Mods die Variablen des Moduls zuruecksetzt -- die Sperre und der einmalige Anstoss
20
+ // sollen das ueberstehen.
21
+ const letzteErinnerung = atom({ plugin: 'kimal3-plan', key: 'letzteErinnerung' } as const, null)
22
+ const angestossen = atom({ plugin: 'kimal3-plan', key: 'anstossGegeben' } as const, false)
23
+
24
+ /** Die Datei eines Schreib-Werkzeugs (`file_path`, bei Notebooks `notebook_path`). */
25
+ export function dateiAus(e: unknown): string | null {
26
+ const o = e as { file_path?: unknown; notebook_path?: unknown }
27
+ const datei = o.file_path ?? o.notebook_path
28
+ return typeof datei === 'string' && datei !== '' ? datei : null
29
+ }
30
+
31
+ export function erinnerung(on: On, lauf: Lauf): void {
32
+ on('tool.call', { tool: ['Write', 'Edit', 'NotebookEdit'] }, async ($, e, next) => {
33
+ const ergebnis = await next(e)
34
+ // Abgelehnt oder gescheitert: Es hat sich nichts geaendert. Ein Subagent: nur die Hauptsitzung wird erinnert.
35
+ if (ergebnis.deny !== undefined || ergebnis.isError === true || e.agentId !== undefined) return ergebnis
36
+ const satz = await satzFuer($, lauf, dateiAus(e))
37
+ return satz === null ? ergebnis : { ...ergebnis, context: [...(ergebnis.context ?? []), satz] }
38
+ })
39
+ }
40
+
41
+ async function satzFuer($: EngineInterface, lauf: Lauf, datei: string | null): Promise<string | null> {
42
+ const lage = lauf.lage
43
+ if (lauf.inArbeit !== false || lage.art !== 'projekt' || lage.zettel === null || datei === null) return null
44
+ if (relativImOrdner(datei, lage.ordner, await $.session.cwd()) === null) return null
45
+ const jetzt = await $.clock.now()
46
+ const zuletzt = await read($, letzteErinnerung)
47
+ if (zuletzt !== null && jetzt - zuletzt < SPERRE_MS) return null
48
+ await update($, letzteErinnerung, () => jetzt)
49
+ return TEXTE[lauf.sprache].erinnerung(lage.zettel.titel)
50
+ }
51
+
52
+ /** Projektordner ohne Ordnerzettel: einmal je Sitzung „leg das Projekt an“ (US-8). */
53
+ export async function anstossOhneZettel($: EngineInterface, lauf: Lauf): Promise<void> {
54
+ if (lauf.lage.art !== 'projekt' || lauf.lage.zettel !== null || (await read($, angestossen))) return
55
+ if (await anClaude($, TEXTE[lauf.sprache].anstoss)) await update($, angestossen, () => true)
56
+ }
57
+
58
+ /** Liegen gemerkte Meldungen (`~/.kimal3/meldungen/*.json`; `.tmp` sind Reste) und ist die App erreichbar: senden lassen. */
59
+ export async function gemerkteMeldungenAnstossen($: EngineInterface, lauf: Lauf): Promise<void> {
60
+ if (lauf.heim === null) return
61
+ const eintraege = await $.fs.list(`${lauf.heim}/.kimal3/meldungen`).catch(() => [])
62
+ if (eintraege.some((d: { kind: string; name: string }) => d.kind === 'file' && d.name.endsWith('.json'))) await anClaude($, TEXTE[lauf.sprache].gemerkt)
63
+ }
64
+
65
+ /** Nach `/clear` beginnt eine neue Sitzung: ihre eigene Sperre, ihr eigener Anstoss. */
66
+ export async function sitzungNeu($: EngineInterface): Promise<void> {
67
+ await update($, letzteErinnerung, () => null)
68
+ await update($, angestossen, () => false)
69
+ }
70
+
71
+ async function anClaude($: EngineInterface, text: string): Promise<boolean> {
72
+ try {
73
+ await $.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } })
74
+ return true
75
+ } catch (fehler) {
76
+ $.ui.log(`kimal3-plan: Satz an Claude nicht angehaengt: ${String(fehler).slice(0, 200)}`, { to: 'debug' })
77
+ return false
78
+ }
79
+ }
@@ -0,0 +1,41 @@
1
+ import type { EngineInterface } from 'claude-code'
2
+ import { TEXTE, type Fehler, type Lauf } from './lauf'
3
+
4
+ /**
5
+ * Die eine Fehlerzeile des Mods (Spec 1.3, 1.5, US-4, S-11, S-22; Bauplan R-8): in der Statuszeile unter dem
6
+ * Eingabefeld, weil eine farbige Zeile dort in der Claude-App gar nicht erscheint (gemessen M-4). Sie steht, solange der
7
+ * Fehler besteht, und verschwindet danach -- `$.ui.status` wird nur gerufen, wenn sich der Satz AENDERT, so bleibt es
8
+ * bei zehn gescheiterten Versuchen bei EINER Zeile. Bei mehreren Fehlern zeigt sie den ersten nach diesem Vorrang:
9
+ * Wer nicht angemeldet ist, dem hilft „nicht erreichbar“ nichts.
10
+ */
11
+ const VORRANG: readonly Fehler[] = ['nicht-angemeldet', 'kimal3-fehlt', 'nicht-erreichbar']
12
+
13
+ function zeige($: EngineInterface, lauf: Lauf): void {
14
+ const art = VORRANG.find(f => lauf.fehler.has(f))
15
+ const satz = art === undefined ? undefined : TEXTE[lauf.sprache].fehler[art]
16
+ if (satz === lauf.zeile) return
17
+ lauf.zeile = satz
18
+ $.ui.status(satz)
19
+ }
20
+
21
+ /** Die Fehler nach einem Lebenszeichen: Es prueft Ausweis UND App, sein Ergebnis ersetzt alles Bisherige. */
22
+ export function fehlerSind($: EngineInterface, lauf: Lauf, fehler: readonly Fehler[]): void {
23
+ lauf.fehler = new Set(fehler)
24
+ merkeAusfall(lauf)
25
+ zeige($, lauf)
26
+ }
27
+
28
+ /** Einzelne Fehler kommen hinzu (`true`) oder sind behoben (`false`), etwa nach dem Hochladen eines Spec-Texts. */
29
+ export function fehlerSetzen($: EngineInterface, lauf: Lauf, aenderung: Partial<Record<Fehler, boolean>>): void {
30
+ for (const [art, besteht] of Object.entries(aenderung) as [Fehler, boolean][]) {
31
+ if (besteht) lauf.fehler.add(art)
32
+ else lauf.fehler.delete(art)
33
+ }
34
+ merkeAusfall(lauf)
35
+ zeige($, lauf)
36
+ }
37
+
38
+ /** War die App weg, koennen sich in der Zeit Meldungen angesammelt haben: nach der Rueckkehr nachsehen (R-4). */
39
+ function merkeAusfall(lauf: Lauf): void {
40
+ if (lauf.fehler.has('nicht-erreichbar')) lauf.meldungenPruefen = true
41
+ }
@@ -0,0 +1 @@
1
+ { "modules": ["./register.tsx"] }
@@ -0,0 +1,143 @@
1
+ import type { EngineInterface, Timer } from 'claude-code'
2
+ import { liesDatei, normiert, type Ordnerlage } from './ordner'
3
+
4
+ /**
5
+ * Was die Teile des Mods gemeinsam wissen (ein Objekt je Laden des Mods, angelegt in `register.tsx`), dazu die Saetze in
6
+ * beiden Sprachen und der eine Weg zur App.
7
+ */
8
+
9
+ export type Sprache = 'de' | 'en'
10
+ export type Fehler = 'nicht-angemeldet' | 'kimal3-fehlt' | 'nicht-erreichbar'
11
+
12
+ export type Lauf = {
13
+ /** Die SPRACHE-Zeile aus `register.tsx` -- gilt, wenn `~/.kimal3/sprache.json` fehlt (S-23). */
14
+ vorgabe: Sprache
15
+ sprache: Sprache
16
+ /** Der Benutzerordner mit `/` (`USERPROFILE`, sonst `HOME`) -- dort liegt `.kimal3/` (M-5). */
17
+ heim: string | null
18
+ basis: string
19
+ lage: Ordnerlage
20
+ /** Die letzte Antwort der App auf ein Lebenszeichen: `null`, solange keine gekommen ist (R-5). */
21
+ inArbeit: boolean | null
22
+ /** Zaehlt jede Meldung, die Claude gerade abgeschickt hat: Ein Lebenszeichen, das davor losging, setzt `inArbeit` nicht. */
23
+ meldungen: number
24
+ /** Die Spec-Dateien des Projekts aus dem letzten Lebenszeichen -- nur deren Texte gehen hoch (Datensparsamkeit). */
25
+ specDateien: readonly string[]
26
+ takt: Timer | null
27
+ /** Ein Start laeuft gerade (zwischen erstem `await` und dem Zeitgeber): ein zweiter wartet nicht, er unterbleibt. */
28
+ startet: boolean
29
+ /** Zaehlt jedes Sitzungsende hoch: Eine Antwort, die fuer eine fruehere Sitzung unterwegs war, wirkt nicht mehr. */
30
+ generation: number
31
+ /** `session.end` ist gelaufen: Ein Lebenszeichen, das noch unterwegs war, darf danach nichts mehr tun. */
32
+ beendet: boolean
33
+ /** Fuer diese Projektnummer hat die App `403` gesagt (nicht Eigentuemer, geloescht): nicht weiter fragen. */
34
+ abgelehnt: string | null
35
+ /** Fuer dieses Projekt ging ein Lebenszeichen raus -- nur dann gibt es etwas abzumelden. */
36
+ gemeldetFuer: string | null
37
+ /** Beim naechsten erfolgreichen Lebenszeichen nach gemerkten Meldungen sehen (R-4): beim Start und nach jedem Ausfall. */
38
+ meldungenPruefen: boolean
39
+ fehler: Set<Fehler>
40
+ /** Was gerade in der Statuszeile steht (`undefined`: nichts; `null`: noch nie gesetzt -- die erste Auswertung nach dem
41
+ * Laden ruft `$.ui.status` in jedem Fall und raeumt so eine Zeile ab, die ein frueherer Lauf stehen liess). */
42
+ zeile: string | undefined | null
43
+ }
44
+
45
+ export function neuerLauf(vorgabe: Sprache): Lauf {
46
+ return {
47
+ vorgabe, sprache: vorgabe, heim: null, basis: APP, lage: { art: 'kein-projektordner' }, inArbeit: null, meldungen: 0, specDateien: [],
48
+ takt: null, startet: false, generation: 0, beendet: false, abgelehnt: null, gemeldetFuer: null, meldungenPruefen: true,
49
+ fehler: new Set(), zeile: null,
50
+ }
51
+ }
52
+
53
+ const APP = 'https://app.kimal3.de'
54
+
55
+ // Wortlaut: Spec 1.3 (Erinnerung), 1.7 (Anstoss), S-11, S-22, US-4; Bauplan R-4 (gemerkte Meldungen).
56
+ // Englische Befehlswoerter wie in `cli/src/befehlswoerter.ts` (`project connect`, `plan send`).
57
+ export const TEXTE = {
58
+ de: {
59
+ erinnerung: (titel: string) => `Du arbeitest im Projekt ${titel}, aber diese Sitzung hat keine Story ‚in Arbeit‘ gemeldet. Welche ist dran? Gehört die Änderung zu keiner Spec, notiere sie als Thema oder arbeite weiter.`,
60
+ anstoss: 'Für diesen Projektordner gibt es noch kein Projekt in der App — leg es mit `kimal3 projekt verbinden` an.',
61
+ gemerkt: 'Gemerkte Meldungen warten — führe `kimal3 plan senden` aus.',
62
+ fehler: {
63
+ 'nicht-erreichbar': 'Die Projektseite ist gerade nicht erreichbar — ich versuche es gleich wieder.',
64
+ 'nicht-angemeldet': 'Du bist bei kimal3 nicht mehr angemeldet — `kimal3 login`.',
65
+ 'kimal3-fehlt': 'kimal3 fehlt oder ist zu alt — `npm install -g kimal3@latest`.',
66
+ },
67
+ },
68
+ en: {
69
+ erinnerung: (titel: string) => `You are working in the project ${titel}, but this session hasn't reported a story as ‘in progress’. Which one is it? If the change belongs to no spec, note it as a topic or keep working.`,
70
+ anstoss: 'There is no project in the app for this project folder yet — create it with `kimal3 project connect`.',
71
+ gemerkt: 'Saved reports are waiting — run `kimal3 plan send`.',
72
+ fehler: {
73
+ 'nicht-erreichbar': "The project page can't be reached right now — I'll try again shortly.",
74
+ 'nicht-angemeldet': "You're no longer signed in to kimal3 — `kimal3 login`.",
75
+ 'kimal3-fehlt': 'kimal3 is missing or too old — `npm install -g kimal3@latest`.',
76
+ },
77
+ },
78
+ } as const
79
+
80
+ /**
81
+ * Benutzerordner, Sprache und App-Adresse -- bei jedem Sitzungsstart neu, damit eine umgestellte Kontosprache ohne neues
82
+ * `kimal3 einrichten` gilt (S-23). `KIMAL3_URL` wie in `cli/src/anmeldung.ts` (Messen gegen den lokalen Server).
83
+ */
84
+ export async function umgebungLesen($: EngineInterface, lauf: Lauf): Promise<void> {
85
+ const heim = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
86
+ lauf.heim = heim === undefined || heim === '' ? null : normiert(heim)
87
+ lauf.basis = ((await $.env.get('KIMAL3_URL')) ?? APP).replace(/\/+$/, '')
88
+ lauf.sprache = lauf.vorgabe
89
+ const text = lauf.heim === null ? null : await liesDatei($, `${lauf.heim}/.kimal3/sprache.json`)
90
+ try {
91
+ const s: unknown = text === null ? null : JSON.parse(text.replace(/^\uFEFF/, ''))?.sprache
92
+ if (s === 'de' || s === 'en') lauf.sprache = s
93
+ } catch {
94
+ // kaputte Datei: die SPRACHE-Zeile gilt
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Der Geraeteschluessel aus `~/.kimal3/anmeldung.json` (M-5), bei jedem Gebrauch frisch gelesen -- so wirkt ein
100
+ * `kimal3 login` sofort. 🔴 Der Schluessel wird nur benutzt, nie ausgegeben oder protokolliert.
101
+ * Ohne Schluessel: Gibt es `~/.kimal3/`, ist `kimal3` da und nur abgemeldet (S-11, `kimal3 login`); fehlt der Ordner ganz,
102
+ * fehlt `kimal3` (S-22, installieren) -- Ruling der Fixrunde.
103
+ */
104
+ export async function liesAusweis($: EngineInterface, lauf: Lauf): Promise<{ schluessel: string } | { fehler: Fehler }> {
105
+ if (lauf.heim === null) return { fehler: 'kimal3-fehlt' }
106
+ const text = await liesDatei($, `${lauf.heim}/.kimal3/anmeldung.json`)
107
+ try {
108
+ const s: unknown = text === null ? null : JSON.parse(text.replace(/^\uFEFF/, ''))?.schluessel
109
+ if (typeof s === 'string' && s !== '') return { schluessel: s }
110
+ } catch {
111
+ // kaputte Datei: wie keine
112
+ }
113
+ const ordnerDa = await $.fs.exists(`${lauf.heim}/.kimal3`).catch(() => false)
114
+ return { fehler: ordnerDa ? 'nicht-angemeldet' : 'kimal3-fehlt' }
115
+ }
116
+
117
+ /** Wie lange ein Abruf an die App hoechstens dauern darf: `$.http.fetch` kennt keine eigene Zeitgrenze. */
118
+ const ZEITGRENZE_MS = 8_000
119
+
120
+ /**
121
+ * Ein Abruf an `/api/mod/*`. `null`: die App war nicht zu erreichen (kein Netz, abgelehnt, oder keine Antwort innerhalb
122
+ * von `grenzeMs`). 🔴 Ohne die Zeitgrenze haengt ein Abruf, der nie antwortet, das Lebenszeichen fuer immer auf --
123
+ * still, ohne Statuszeile (Befund der Durchsicht).
124
+ */
125
+ export function anApp($: EngineInterface, lauf: Lauf, pfad: string, schluessel: string, rumpf: unknown, grenzeMs = ZEITGRENZE_MS): Promise<{ status: number; text: string } | null> {
126
+ return new Promise(fertig => {
127
+ const frist = $.clock.after(Math.max(0, Math.min(grenzeMs, ZEITGRENZE_MS)), () => fertig(null))
128
+ $.http.fetch(`${lauf.basis}${pfad}`, {
129
+ method: 'POST',
130
+ headers: { Authorization: `Bearer ${schluessel}`, 'Content-Type': 'application/json' },
131
+ body: JSON.stringify(rumpf),
132
+ }).then(
133
+ antwort => {
134
+ frist.cancel()
135
+ fertig({ status: antwort.status, text: antwort.text })
136
+ },
137
+ () => {
138
+ frist.cancel()
139
+ fertig(null)
140
+ },
141
+ )
142
+ })
143
+ }