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
package/README.md CHANGED
@@ -42,8 +42,11 @@ kimal3 kontext fragen Die Fragen über deine Firma beantworten — Claude f
42
42
  kimal3 kontext hoch Deine lokalen Änderungen an den fünf Firmen-Texten hochschicken
43
43
  kimal3 projekt neu … Ein Projekt ins Erfolgsprofil eintragen
44
44
  kimal3 projekt liste Deine eingetragenen Projekte anzeigen
45
- kimal3 plan Planung hochladen oder Rückmeldungen holen
46
- kimal3 plan hoch Die Planung aus plan/stand.json hochschicken (--probe: nur prüfen)
45
+ kimal3 plan Den Stand der Planung holen und Änderungen melden
46
+ kimal3 plan melde Eine Änderung an die Projektseite melden (Meldung als JSON von der Eingabe)
47
+ kimal3 plan senden Gemerkte Meldungen nachschicken, die offline nicht ankamen
48
+ kimal3 projekt verbinden Den Projektordner mit seinem Projekt in der App verbinden
49
+ kimal3 plan hoch Nur noch mit --ueberschreiben: den ganzen Stand durch plan/stand.json ersetzen
47
50
  kimal3 kurs Zeigen, wo du im Mini-Kurs stehst
48
51
  kimal3 firma daten Die Daten deiner Firma holen, damit Claude sie auswerten kann
49
52
  kimal3 zeigen start Eine Aufnahme starten, während du eine Aufgabe vormachst
@@ -184,8 +187,11 @@ kimal3 context questions Answer the questions about your company — Claude tak
184
187
  kimal3 context upload Upload your local changes to the five company texts
185
188
  kimal3 project new … Add a project to your success profile
186
189
  kimal3 project list Show the projects you've added
187
- kimal3 plan Upload planning or fetch responses
188
- kimal3 plan upload Upload the planning in plan/stand.json (--dry-run: only check)
190
+ kimal3 plan Fetch the planning state and report changes
191
+ kimal3 plan report Report a change to the project page (report as JSON from the input)
192
+ kimal3 plan send Send kept reports that didn't arrive while offline
193
+ kimal3 project connect Connect the project folder with its project in the app
194
+ kimal3 plan upload Only with --overwrite: replace the whole state with plan/stand.json
189
195
  kimal3 course Show where you are in the mini-course
190
196
  kimal3 company data Fetch your company's data so Claude can analyse it
191
197
  kimal3 show start Start a recording while you show a task
@@ -3,6 +3,7 @@
3
3
  "owner": { "name": "KImal3", "url": "https://kimal3.de" },
4
4
  "description": "Eingetragen vom Programm kimal3 · Added by the kimal3 program",
5
5
  "plugins": [
6
- { "name": "kimal3-logo", "source": "./kimal3-logo", "description": "KImal3-Logo über dem Eingabefeld · KImal3 logo above the prompt" }
6
+ { "name": "kimal3-logo", "source": "./kimal3-logo", "description": "KImal3-Logo über dem Eingabefeld · KImal3 logo above the prompt" },
7
+ { "name": "kimal3-plan", "source": "./kimal3-plan", "description": "Hält das Board der Projektseite aktuell · Keeps the project page board current" }
7
8
  ]
8
9
  }
@@ -2,6 +2,8 @@
2
2
 
3
3
  Diesen Ordner legt `kimal3 einrichten` an und ersetzt ihn bei jedem Lauf. Eigene Änderungen hier gehen dabei verloren.
4
4
  Das Logo abschalten: in Claude unter **Plugins** `kimal3-logo` ausschalten — `kimal3` schaltet es danach nicht wieder ein.
5
+ `kimal3-plan` hält das Board der Projektseite in der App aktuell (Punkt, solange die Sitzung arbeitet; Spec-Texte). Abschalten genauso: unter **Plugins** `kimal3-plan` ausschalten.
5
6
 
6
7
  `kimal3 setup` creates this folder and replaces it on every run; your own changes here are lost.
7
8
  To turn the logo off: switch off `kimal3-logo` under **Plugins** in Claude — `kimal3` won't switch it back on.
9
+ `kimal3-plan` keeps the board on the app's project page current (dot while the session works; spec texts). Turn it off the same way: switch off `kimal3-plan` under **Plugins**.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kimal3-logo",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "KImal3-Logo über dem Eingabefeld, öffnet beim Start das Projekt in der App, benennt den Chat nach seinem Thema · KImal3 logo above the prompt, opens the project in the app on start, names the chat after its topic",
5
5
  "author": { "name": "KImal3", "url": "https://kimal3.de" },
6
6
  "types": "./types/index.d.ts"
@@ -16,7 +16,10 @@ const ANSICHT = 'ccd_view'
16
16
  const EINBLENDEN = 'mcp__ccd_view__show_pane'
17
17
  const geoeffnet = atom({ plugin: 'kimal3-logo', key: 'appGeoeffnet' } as const, false)
18
18
 
19
- /** Die Adresse für den Inhalt von `plan/stand.json` (`null`: keine Datei). Ohne gültige Nummer der Kurs, wie der Link in der Leiste (S-3, S-4). */
19
+ /**
20
+ * Die Adresse für den Inhalt von `plan/projekt.json` oder `plan/stand.json` (`null`: keine Datei) -- beide tragen die
21
+ * Nummer als `projekt_id`. Ohne gültige Nummer der Kurs, wie der Link in der Leiste (S-3, S-4).
22
+ */
20
23
  export function adresseFuer(standJson: string | null): string {
21
24
  if (standJson !== null) {
22
25
  try {
@@ -30,6 +33,21 @@ export function adresseFuer(standJson: string | null): string {
30
33
  return `${APP}/kurs`
31
34
  }
32
35
 
36
+ /**
37
+ * Ist das ein gueltiger Ordnerzettel (`plan/projekt.json`: `format: 1`, Projektnummer, Titel)? Dieselbe Pruefung wie
38
+ * `zettelAus` im Mod `kimal3-plan` und in `cli/src/projekt-ordner.ts` -- ein ungueltiger zaehlt nicht, dann gilt die
39
+ * alte `plan/stand.json`.
40
+ */
41
+ export function istZettel(text: string | null): boolean {
42
+ if (text === null) return false
43
+ try {
44
+ const z = JSON.parse(text.replace(/^\uFEFF/, '')) as { format?: unknown; projekt_id?: unknown; titel?: unknown } | null
45
+ return z?.format === 1 && typeof z.projekt_id === 'string' && PROJEKTNUMMER.test(z.projekt_id) && typeof z.titel === 'string' && z.titel.trim() !== ''
46
+ } catch {
47
+ return false
48
+ }
49
+ }
50
+
33
51
  /** Die eigene Erlaubnis (unten): nur der Aufruf dieses Mods, nur eine Adresse der App. Herausgelöst, damit `check:logo` die Grenze prüft. */
34
52
  export function istEigenerAufruf(plugin: string, input: unknown): boolean {
35
53
  const url = (input as { url?: unknown } | null)?.url
@@ -83,29 +101,39 @@ async function liesDatei($: Engine, pfad: string): Promise<string | null> {
83
101
  }
84
102
 
85
103
  /**
86
- * `plan/stand.json` des Projekts, in dem Claude läuft (S-5, S-24, S-25): vom Startordner nach oben, bis zur Wurzel des
104
+ * Eine Datei des Projekts, in dem Claude läuft (S-5, S-24, S-25): vom Startordner nach oben, bis zur Wurzel des
87
105
  * eigenen Git-Projekts und nie darüber hinaus — sonst bekäme ein Projekt ohne Plan die Seite eines anderen. Ist die
88
- * Wurzel eine Arbeitskopie (`.git` ist dort eine Datei), zählt danach die Projektdatei ihres Hauptprojekts.
106
+ * Wurzel eine Arbeitskopie (`.git` ist dort eine Datei), zählt danach die Datei ihres Hauptprojekts.
89
107
  */
90
- async function findeStand($: Engine, cwd: string): Promise<string | null> {
108
+ async function findePlanDatei($: Engine, cwd: string, datei: string): Promise<string | null> {
91
109
  for (const ordner of ordnerNachOben(cwd)) {
92
- const stand = await liesDatei($, `${ordner}/plan/stand.json`)
93
- if (stand !== null) return stand
110
+ const text = await liesDatei($, `${ordner}/${datei}`)
111
+ if (text !== null) return text
94
112
  if (!(await $.fs.exists(`${ordner}/.git`))) continue
95
113
  // Ein Ordner `.git` lässt sich nicht lesen: echte Projektwurzel, hier ist Schluss.
96
114
  const gitdatei = await liesDatei($, `${ordner}/.git`)
97
115
  const haupt = gitdatei === null ? null : hauptordnerAusGitdatei(gitdatei)
98
- return haupt === null ? null : liesDatei($, `${haupt}/plan/stand.json`)
116
+ return haupt === null ? null : liesDatei($, `${haupt}/${datei}`)
99
117
  }
100
118
  return null
101
119
  }
102
120
 
121
+ /**
122
+ * Erst der Ordnerzettel `plan/projekt.json`, dann `plan/stand.json` für alte Ordner (Spec 2026-10-23-brett-in-echtzeit,
123
+ * 1.4, US-8) — dieselbe Reihenfolge wie `kimal3` (`cli/src/projekt-ordner.ts`). Ein ungültiger Zettel zählt nicht.
124
+ */
125
+ async function adresseJetzt($: Engine): Promise<string> {
126
+ const cwd = await $.session.cwd()
127
+ const zettel = await findePlanDatei($, cwd, 'plan/projekt.json')
128
+ return adresseFuer(istZettel(zettel) ? zettel : await findePlanDatei($, cwd, 'plan/stand.json'))
129
+ }
130
+
103
131
  async function oeffne($: Engine): Promise<boolean> {
104
- const stand = await findeStand($, await $.session.cwd())
132
+ const adresse = await adresseJetzt($)
105
133
  // Scheitern meldet sich als Scheitern, nie im Chat (S-13): eine Zeile nur im Debug-Protokoll (`to: 'debug'` —
106
134
  // ohne steht sie im Chatverlauf, Befund der Durchsicht 23.10.).
107
135
  try {
108
- const antwort = await $.mcp.call(BROWSER, 'navigate', { url: adresseFuer(stand) })
136
+ const antwort = await $.mcp.call(BROWSER, 'navigate', { url: adresse })
109
137
  if (!antwort.isError) {
110
138
  await einblenden($)
111
139
  return true
@@ -6,7 +6,7 @@ import { updateClaudeFragen, updateInstallieren, updateZustand } from './update'
6
6
  import { satzWeicht, TEXTE, type Zustand } from './update-rechnung'
7
7
 
8
8
  // Die Sprache des Kontos. `kimal3 einrichten` setzt diese Zeile beim Ablegen in `~/.kimal3/claude-plugins` und prüft
9
- // danach, dass sie gesetzt ist (`cli/src/logo-ablegen.ts`).
9
+ // danach, dass sie gesetzt ist (`cli/src/mods-ablegen.ts`).
10
10
  const SPRACHE: 'de' | 'en' = 'de'
11
11
  // Fassung und Orte von `kimal3` auf diesem Rechner; beim Ablegen gesetzt wie die Sprache (hooks/ablage.ts).
12
12
  const ABLAGE: Ablage = KEINE_ABLAGE
@@ -0,0 +1,7 @@
1
+ {
2
+ "name": "kimal3-plan",
3
+ "version": "0.1.1",
4
+ "description": "Hält das Board der Projektseite in der App aktuell: Punkt solange die Sitzung arbeitet, Spec-Texte, hält Claude vor dem ersten Bau-Schritt an · Keeps the board on the app's project page current: dot while the session works, spec texts, stops Claude before the first build step",
5
+ "author": { "name": "KImal3", "url": "https://kimal3.de" },
6
+ "types": "./types/index.d.ts"
7
+ }
@@ -0,0 +1,83 @@
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
+ * - **Sperre vor dem ersten Bau-Schritt** (US-6, Dennis 25.10.: „Ich will das Problem gleich gefixt haben“): Will Claude
10
+ * eine Datei aendern, ohne dass diese Sitzung eine Story „in Arbeit“ hat, lehnt der Mod die Aenderung VORHER ab
11
+ * (`deny`), und Claude liest den Satz als Grund. Bis zum 25.10. kam der Satz erst NACH der Aenderung als `context` --
12
+ * gemessen in einer echten Sitzung: Claude schrieb zuerst die Pruefung und meldete danach. Der zweite Versuch geht
13
+ * durch (30-Minuten-Sperre): Eine Aenderung ohne Story (Statuszettel, neue Spec) haengt so nie fest. Nur die
14
+ * Hauptsitzung: Ein Subagent (`e.agentId`) baut im Auftrag und meldet nicht. Nur in einem Projektordner mit
15
+ * Ordnerzettel (S-30), und nur, wenn die App zuletzt „nichts in Arbeit“ gesagt hat -- weiss der Mod es nicht (noch
16
+ * keine Antwort, App weg), haelt er nichts an. 🔴 Scheitert der Hook selbst, ueberspringt ihn die Maschine und die
17
+ * Aenderung laeuft (`tool.call`, Typen: „A guard that fails is skipped and the tool runs“) -- so gewollt: Ein Fehler
18
+ * im Mod darf die Arbeit nie aufhalten.
19
+ * - **Anstoss „Projekt anlegen“** (US-8) und **„gemerkte Meldungen senden“** (R-4) sind eine Zeile im Gespraech
20
+ * (`$.session.append`): Claude liest sie sofort und handelt.
21
+ */
22
+ const SPERRE_MS = 30 * 60_000
23
+
24
+ // In `$.state`, weil ein Neuladen des Mods die Variablen des Moduls zuruecksetzt -- die Sperre und der einmalige Anstoss
25
+ // sollen das ueberstehen.
26
+ const letzteErinnerung = atom({ plugin: 'kimal3-plan', key: 'letzteErinnerung' } as const, null)
27
+ const angestossen = atom({ plugin: 'kimal3-plan', key: 'anstossGegeben' } as const, false)
28
+
29
+ /** Die Datei eines Schreib-Werkzeugs (`file_path`, bei Notebooks `notebook_path`). */
30
+ export function dateiAus(e: unknown): string | null {
31
+ const o = e as { file_path?: unknown; notebook_path?: unknown }
32
+ const datei = o.file_path ?? o.notebook_path
33
+ return typeof datei === 'string' && datei !== '' ? datei : null
34
+ }
35
+
36
+ export function erinnerung(on: On, lauf: Lauf): void {
37
+ on('tool.call', { tool: ['Write', 'Edit', 'NotebookEdit'] }, async ($, e, next) => {
38
+ const satz = e.agentId === undefined ? await satzFuer($, lauf, dateiAus(e)) : null
39
+ return satz === null ? next(e) : { deny: satz }
40
+ })
41
+ }
42
+
43
+ async function satzFuer($: EngineInterface, lauf: Lauf, datei: string | null): Promise<string | null> {
44
+ const lage = lauf.lage
45
+ if (lauf.inArbeit !== false || lage.art !== 'projekt' || lage.zettel === null || datei === null) return null
46
+ // Markdown ist Planung (Spec, Bauplan, Statuszettel), kein Bau-Schritt: Eine neue Spec entsteht, bevor es eine Story gibt.
47
+ if (datei.toLowerCase().endsWith('.md')) return null
48
+ if (relativImOrdner(datei, lage.ordner, await $.session.cwd()) === null) return null
49
+ const jetzt = await $.clock.now()
50
+ const zuletzt = await read($, letzteErinnerung)
51
+ if (zuletzt !== null && jetzt - zuletzt < SPERRE_MS) return null
52
+ await update($, letzteErinnerung, () => jetzt)
53
+ return TEXTE[lauf.sprache].erinnerung(lage.zettel.titel)
54
+ }
55
+
56
+ /** Projektordner ohne Ordnerzettel: einmal je Sitzung „leg das Projekt an“ (US-8). */
57
+ export async function anstossOhneZettel($: EngineInterface, lauf: Lauf): Promise<void> {
58
+ if (lauf.lage.art !== 'projekt' || lauf.lage.zettel !== null || (await read($, angestossen))) return
59
+ if (await anClaude($, TEXTE[lauf.sprache].anstoss)) await update($, angestossen, () => true)
60
+ }
61
+
62
+ /** Liegen gemerkte Meldungen (`~/.kimal3/meldungen/*.json`; `.tmp` sind Reste) und ist die App erreichbar: senden lassen. */
63
+ export async function gemerkteMeldungenAnstossen($: EngineInterface, lauf: Lauf): Promise<void> {
64
+ if (lauf.heim === null) return
65
+ const eintraege = await $.fs.list(`${lauf.heim}/.kimal3/meldungen`).catch(() => [])
66
+ if (eintraege.some((d: { kind: string; name: string }) => d.kind === 'file' && d.name.endsWith('.json'))) await anClaude($, TEXTE[lauf.sprache].gemerkt)
67
+ }
68
+
69
+ /** Nach `/clear` beginnt eine neue Sitzung: ihre eigene Sperre, ihr eigener Anstoss. */
70
+ export async function sitzungNeu($: EngineInterface): Promise<void> {
71
+ await update($, letzteErinnerung, () => null)
72
+ await update($, angestossen, () => false)
73
+ }
74
+
75
+ async function anClaude($: EngineInterface, text: string): Promise<boolean> {
76
+ try {
77
+ await $.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } })
78
+ return true
79
+ } catch (fehler) {
80
+ $.ui.log(`kimal3-plan: Satz an Claude nicht angehaengt: ${String(fehler).slice(0, 200)}`, { to: 'debug' })
81
+ return false
82
+ }
83
+ }
@@ -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) => `Halt, die Datei ist noch nicht geändert: Du arbeitest im Projekt ${titel}, aber diese Sitzung hat keine Story ‚in Arbeit‘ gemeldet. Melde zuerst die Story, an der du arbeitest, und ändere die Datei danach. Gehört die Änderung zu keiner Story, notiere sie als Thema oder versuch es einfach noch einmal.`,
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) => `Stop, the file has not been changed: You are working in the project ${titel}, but this session hasn't reported a story as ‘in progress’. Report the story you're working on first, then change the file. If the change belongs to no story, note it as a topic or simply try again.`,
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
+ }
@@ -0,0 +1,197 @@
1
+ import type { EngineInterface, On } from 'claude-code'
2
+ import { anstossOhneZettel, gemerkteMeldungenAnstossen, sitzungNeu } from './erinnerung'
3
+ import { fehlerSind } from './fehlerzeile'
4
+ import { anApp, liesAusweis, umgebungLesen, type Lauf } from './lauf'
5
+ import { lageJetzt } from './ordner'
6
+
7
+ /**
8
+ * Den Punkt am Leben halten (Spec 1.3 Aufgabe 2, E-4, US-2; Bauplan R-5, Review Focus 5). Solange die Sitzung in einem
9
+ * Projektordner mit Ordnerzettel offen ist, meldet sich der Mod alle 10 Sekunden bei der App (`POST
10
+ * /api/mod/lebenszeichen`, gemessen M-2: der Zeitgeber laeuft auch, waehrend Claude arbeitet) und merkt sich, ob diese
11
+ * Sitzung eine Story „in Arbeit“ hat. Beim Ende der Sitzung meldet er sich ab, damit der Punkt in unter 1 Sekunde weg
12
+ * ist; ein abgeschossener Prozess meldet sich nie ab, dafuer gibt es die 30-Sekunden-Regel der App.
13
+ *
14
+ * Jeder Takt liest Ordner, Zettel und Ausweis neu: So kommt ein Zettel, den Claude nach dem Anstoss anlegt (US-8), und ein
15
+ * `kimal3 login` beim naechsten Takt an, ohne Neustart.
16
+ */
17
+ const TAKT_MS = 10_000
18
+ const MELDUNG = /\bplan\s+(melde|report|senden|send)\b/
19
+
20
+ /**
21
+ * Die Nummer dieser Sitzung fuer `kimal3 plan melde` (Ruling Final, Important 2): Ob `CLAUDE_CODE_SESSION_ID` nach
22
+ * `/clear` neu ist, ist ungemessen -- der Mod setzt deshalb bei jedem Start, auch nach `/clear`, `KIMAL3_SITZUNG`, und
23
+ * das Programm nimmt sie vor Claudes eigener Variable (die bleibt unberuehrt). Gemessen am 23.10.: die Variable kommt
24
+ * bei einem Bash-Aufruf an (docs/qa/2026-10-23-brett-echtzeit-messungen.md, „Final — Sitzungskennung“). Scheitert das
25
+ * Setzen, laeuft der Mod weiter; das Programm faellt dann auf `CLAUDE_CODE_SESSION_ID` zurueck.
26
+ */
27
+ async function sitzungFuerProgramm($: EngineInterface): Promise<void> {
28
+ await $.env.set('KIMAL3_SITZUNG', await $.session.id()).catch(() => undefined)
29
+ }
30
+
31
+ export function lebenszeichen(on: On, lauf: Lauf): void {
32
+ on('session.start', async ($, e, next) => {
33
+ const ergebnis = await next(e)
34
+ await sitzungFuerProgramm($)
35
+ await starte($, lauf)
36
+ return ergebnis
37
+ })
38
+ // Haengt sich eine Oberflaeche an, ohne dass es lief (etwa nach einem frueheren Fehlstart): noch einmal versuchen.
39
+ on('session.attach', async ($, e, next) => {
40
+ const ergebnis = await next(e)
41
+ await starte($, lauf)
42
+ return ergebnis
43
+ })
44
+ // `/clear` beendet die Sitzung (abgemeldet unten), der Prozess laeuft unter einer neuen Kennung weiter -- ohne
45
+ // `session.start`. Die neue Sitzung meldet sich deshalb hier wieder an.
46
+ on('classic.SessionStart', async ($, e, next) => {
47
+ const ergebnis = await next(e)
48
+ if (e.source === 'clear') {
49
+ Object.assign(lauf, { beendet: false, inArbeit: null, specDateien: [], abgelehnt: null, gemeldetFuer: null, meldungenPruefen: true })
50
+ await sitzungNeu($)
51
+ await sitzungFuerProgramm($)
52
+ await starte($, lauf)
53
+ }
54
+ return ergebnis
55
+ })
56
+ // Claude hat gerade gemeldet (`kimal3 plan melde`/`plan senden`): Was der Mod ueber „in Arbeit“ weiss, ist ab jetzt
57
+ // veraltet -- bis zu 10 s lang kaeme sonst die Erinnerung, obwohl die Meldung schon raus ist (Final-Review, Important 1).
58
+ // Also gilt es als unbekannt (keine Erinnerung), und ein Lebenszeichen holt sofort den neuen Stand. Ein Fehlalarm des
59
+ // Musters kostet nur ein Lebenszeichen mehr.
60
+ on('tool.call', { tool: ['Bash', 'PowerShell'] }, async ($, e, next) => {
61
+ const ergebnis = await next(e)
62
+ if (!MELDUNG.test(e.command)) return ergebnis
63
+ lauf.inArbeit = null
64
+ lauf.meldungen++
65
+ // Nur, wenn der Zeitgeber laeuft (Projektordner, nicht beendet): kein zweiter Zeitgeber, und `takt` faellt aus, wenn
66
+ // schon eines unterwegs ist -- dessen Antwort zaehlt dann nicht (`meldungen`), bis der naechste Takt fragt.
67
+ if (lauf.takt !== null) $.clock.after(0, () => void takt($, lauf))
68
+ return ergebnis
69
+ })
70
+ on('session.end', async ($, e, next) => {
71
+ // 🔴 ZUERST den Zeitgeber anhalten, DANN abmelden (Review Focus 5): sonst kann ein letzter Takt nach dem Abmelden
72
+ // ankommen. Die App lehnt ihn zwar ab (`410`), aber `beendet` haelt auch einen Takt an, der schon unterwegs ist.
73
+ lauf.beendet = true
74
+ lauf.generation++
75
+ lauf.takt?.cancel()
76
+ lauf.takt = null
77
+ lauf.inArbeit = null
78
+ // Die ganze Kette von `session.end` hat ~1,5 s (gemessen M-2); der Abruf endet vorher, damit Claude Code nicht
79
+ // mitten in ihm abschneidet und die Hooks nach uns ihre Zeit behalten.
80
+ const grenze = next.budget.remainingMs - 100
81
+ if (lauf.gemeldetFuer !== null) await abmelden($, lauf, lauf.gemeldetFuer, e.sessionId, grenze)
82
+ lauf.gemeldetFuer = null
83
+ return next(e)
84
+ })
85
+ }
86
+
87
+ async function starte($: EngineInterface, lauf: Lauf): Promise<void> {
88
+ // 🔴 `startet` steht VOR dem ersten `await`: `session.start` und `session.attach` kommen fast gleichzeitig, und
89
+ // beide bestanden sonst die Pruefung auf `takt` -- zwei Zeitgeber, einer davon nie angehalten (Befund der Durchsicht).
90
+ if (lauf.takt !== null || lauf.startet || lauf.beendet) return
91
+ lauf.startet = true
92
+ try {
93
+ await umgebungLesen($, lauf)
94
+ lauf.lage = await lageJetzt($)
95
+ // Kein Projektordner (Werkbank, Beispielordner, ausserhalb): der Mod tut nichts (S-30).
96
+ if (lauf.lage.art !== 'projekt') return
97
+ await anstossOhneZettel($, lauf)
98
+ lauf.takt = $.clock.every(TAKT_MS, () => void takt($, lauf))
99
+ // Das erste Lebenszeichen sofort (Punkt in unter 1 s, E-3), aber ohne den Sitzungsstart darauf warten zu lassen.
100
+ $.clock.after(0, () => void takt($, lauf))
101
+ } finally {
102
+ lauf.startet = false
103
+ }
104
+ }
105
+
106
+ /** Die Sitzung (`generation`), fuer die gerade ein Lebenszeichen unterwegs ist -- je Sitzung hoechstens eines. */
107
+ let unterwegs: number | null = null
108
+
109
+ async function takt($: EngineInterface, lauf: Lauf): Promise<void> {
110
+ // Ein Takt, dessen Vorgaenger noch auf die App wartet (langsames Netz), faellt aus, statt sich zu stapeln.
111
+ const generation = lauf.generation
112
+ if (lauf.beendet || unterwegs === generation) return
113
+ unterwegs = generation
114
+ try {
115
+ await einTakt($, lauf, generation)
116
+ } finally {
117
+ if (unterwegs === generation) unterwegs = null
118
+ }
119
+ }
120
+
121
+ async function einTakt($: EngineInterface, lauf: Lauf, generation: number): Promise<void> {
122
+ // Nach jedem `await`: Ist die Sitzung, fuer die dieser Takt lief, inzwischen vorbei (Ende, `/clear`), wirkt er nicht
123
+ // mehr -- sonst haelt ein spaetes `410` der alten Sitzung den Zeitgeber der neuen an.
124
+ const vorbei = () => lauf.beendet || lauf.generation !== generation
125
+ const lage = await lageJetzt($)
126
+ if (vorbei()) return
127
+ lauf.lage = lage
128
+ if (lage.art !== 'projekt') return
129
+ const ausweis = await liesAusweis($, lauf)
130
+ if (vorbei()) return
131
+ if ('fehler' in ausweis) {
132
+ lauf.inArbeit = null
133
+ return fehlerSind($, lauf, [ausweis.fehler])
134
+ }
135
+ const zettel = lage.zettel
136
+ // Ohne Zettel gibt es noch kein Projekt (den Anstoss bekam Claude beim Start); nach `403` fragt der Mod nicht mehr.
137
+ if (zettel === null || zettel.projekt_id === lauf.abgelehnt) return fehlerSind($, lauf, [])
138
+
139
+ const sitzung = await $.session.id()
140
+ if (vorbei()) return
141
+ // Kam waehrend des Abrufs eine Meldung, ist seine Antwort zu „in Arbeit“ womoeglich von davor: sie zaehlt nicht.
142
+ const meldungen = lauf.meldungen
143
+ lauf.gemeldetFuer = zettel.projekt_id
144
+ const antwort = await anApp($, lauf, '/api/mod/lebenszeichen', ausweis.schluessel, { projektId: zettel.projekt_id, sitzung })
145
+ if (vorbei()) return
146
+
147
+ if (antwort?.status === 200) {
148
+ const gelesen = antwortAus(antwort.text)
149
+ // Kann der Mod nicht feststellen, ob es geklappt hat, gilt es als Fehler (US-4).
150
+ if (gelesen === null) return fehlerSind($, lauf, ['nicht-erreichbar'])
151
+ if (lauf.meldungen === meldungen) lauf.inArbeit = gelesen.inArbeit
152
+ lauf.specDateien = gelesen.specDateien
153
+ fehlerSind($, lauf, [])
154
+ if (lauf.meldungenPruefen) {
155
+ lauf.meldungenPruefen = false
156
+ await gemerkteMeldungenAnstossen($, lauf)
157
+ }
158
+ return
159
+ }
160
+ if (antwort?.status === 401) return fehlerSind($, lauf, ['nicht-angemeldet'])
161
+ // `403`: das Projekt gehoert diesem Konto nicht (mehr) -- etwa ein Kollege mit demselben Repo, oder in der App
162
+ // geloescht (das klaert `kimal3 projekt verbinden`, S-31). Kein Fehler fuer den Menschen, kein weiterer Versuch.
163
+ if (antwort?.status === 403) {
164
+ lauf.abgelehnt = zettel.projekt_id
165
+ lauf.inArbeit = null
166
+ return fehlerSind($, lauf, [])
167
+ }
168
+ // `410`: diese Sitzung ist schon abgemeldet -- nie wiederbeleben, sonst kaeme der Punkt zurueck (Review Focus 5).
169
+ if (antwort?.status === 410) {
170
+ lauf.takt?.cancel()
171
+ lauf.takt = null
172
+ lauf.inArbeit = null
173
+ return fehlerSind($, lauf, [])
174
+ }
175
+ fehlerSind($, lauf, ['nicht-erreichbar'])
176
+ }
177
+
178
+ /** `{ inArbeit, specDateien }` aus der Antwort; ohne gueltiges `inArbeit` `null`. Fehlt `specDateien` (aeltere App): leer. */
179
+ function antwortAus(text: string): { inArbeit: boolean; specDateien: string[] } | null {
180
+ try {
181
+ const o: unknown = JSON.parse(text)
182
+ const inArbeit = (o as { inArbeit?: unknown } | null)?.inArbeit
183
+ const dateien = (o as { specDateien?: unknown } | null)?.specDateien
184
+ if (typeof inArbeit !== 'boolean') return null
185
+ return { inArbeit, specDateien: Array.isArray(dateien) ? dateien.filter((d): d is string => typeof d === 'string') : [] }
186
+ } catch {
187
+ return null
188
+ }
189
+ }
190
+
191
+ async function abmelden($: EngineInterface, lauf: Lauf, projektId: string, sitzung: string, grenzeMs: number): Promise<void> {
192
+ const ausweis = await liesAusweis($, lauf)
193
+ if ('fehler' in ausweis) return
194
+ // Der Abruf brauchte gemessen 4 ms (M-2). Reicht die Zeit nicht, kostet das hoechstens den Punkt fuer 30 Sekunden.
195
+ const antwort = await anApp($, lauf, '/api/mod/abmelden', ausweis.schluessel, { projektId, sitzung }, grenzeMs)
196
+ if (antwort?.status !== 204) $.ui.log(`kimal3-plan: abmelden ${antwort === null ? 'nicht erreichbar' : antwort.status}`, { to: 'debug' })
197
+ }