hmgr 0.0.41 → 0.0.43

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,29 @@
1
+ // Signaldatei von der CLI an eine laufende GUI (E110): `<hmgrHome>/gui-signal.json`. Die CLI
2
+ // schreibt sie nach `dir add` und Verwandten, die GUI beobachtet das Verzeichnis (src/gui/signal.ts).
3
+ // Kein Port: der Kern kennt das Signal nicht, die Entscheidung „Signal ja/nein" fällt in der CLI.
4
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
5
+ import path from 'node:path';
6
+ import { ohneBom } from "./datei-text.js";
7
+ export const SIGNAL_DATEI = 'gui-signal.json';
8
+ /** Atomar: Temp-Datei im selben Verzeichnis, dann rename darüber. */
9
+ export async function signalSchreiben(hmgrHome, signal) {
10
+ await mkdir(hmgrHome, { recursive: true });
11
+ const ziel = path.join(hmgrHome, SIGNAL_DATEI);
12
+ const temp = path.join(hmgrHome, `${SIGNAL_DATEI}.${process.pid}.tmp`);
13
+ await writeFile(temp, `${JSON.stringify(signal)}\n`);
14
+ await rename(temp, ziel);
15
+ }
16
+ /** `undefined` bei fehlender, kaputter oder unbekannt geformter Datei. */
17
+ export async function signalLesen(hmgrHome) {
18
+ try {
19
+ const roh = await readFile(path.join(hmgrHome, SIGNAL_DATEI), 'utf8');
20
+ const daten = JSON.parse(ohneBom(roh));
21
+ if (daten.art !== 'verzeichnis-zeigen' || typeof daten.pfad !== 'string' || typeof daten.zeit !== 'number') {
22
+ return undefined;
23
+ }
24
+ return { art: daten.art, pfad: daten.pfad, zeit: daten.zeit };
25
+ }
26
+ catch {
27
+ return undefined;
28
+ }
29
+ }
@@ -1,6 +1,13 @@
1
1
  {
2
2
  "fassung": 1,
3
3
  "versionen": {
4
+ "0.0.43": [
5
+ "Fügst du im Terminal ein Arbeitsverzeichnis hinzu (hmgr dir add oder link mit einem neuen Verzeichnis), kommt ein offenes hmgr-Fenster nach vorn und zeigt das Verzeichnis sofort an."
6
+ ],
7
+ "0.0.42": [
8
+ "Der Verlauf meldet jetzt auch neue und entfernte Skills einer Quelle, nicht nur geänderte.",
9
+ "Verlauf und „hmgr update“ sind leichter zu lesen: Quellen erscheinen mit ihrem Namen, darunter Neu, Entfernt und Geändert, ohne Commit-Kürzel. Quellen ohne Änderung an deinen Skills stehen gesammelt in einer Zeile am Ende."
10
+ ],
4
11
  "0.0.41": [
5
12
  "Im Verlauf steht unter „hmgr … installiert\" jetzt aufklappbar, was sich in der neuen Version geändert hat."
6
13
  ]
package/dist/cli/hilfe.js CHANGED
@@ -104,6 +104,10 @@ Plugins des Vorhabens stehen. Ohne Zeiger bleibt das Verzeichnis gemerkt und jed
104
104
  (E26). dir forget löst Bindung und Eintrag, die gelegten Links bleiben liegen. Gebunden wird nur für
105
105
  Harnesses, die hmgr verwaltet: standardmäßig nur claude-code (E38).
106
106
 
107
+ Läuft die hmgr-GUI, kommt sie bei dir add nach vorn und zeigt das Verzeichnis (E110). Dasselbe
108
+ passiert bei link, unlink, adopt, bundle add und plugin add mit --ort dir:<pfad>, wenn das Verzeichnis
109
+ dabei neu gemerkt wird. Über SSH bleibt die GUI in Ruhe, und läuft keine GUI, passiert nichts.
110
+
107
111
  Ein Arbeitsverzeichnis unter Windows, das in einer WSL-Distro liegt (\\\\wsl$\\<distro>\\… oder
108
112
  \\\\wsl.localhost\\<distro>\\…, E96), bedient hmgr über die Bridge dieser Distro, wie ein Homedir in
109
113
  WSL: der Link liegt im Linux-Dateisystem und zeigt auf den Windows-Checkout (/mnt/c). MCP-Server
@@ -135,6 +139,9 @@ nur für ein Arbeitsverzeichnis mit composer.json, an Home und Profil bleibt jed
135
139
  Das Schreiben in einen Zeiger ist gesperrt, bis das Composer-Thema entschieden ist (E57): an einem
136
140
  Arbeitsverzeichnis mit Zeiger bricht link ohne --local ab, der Link wird nicht gelegt.
137
141
 
142
+ Mit --ort dir:<pfad> kommt eine laufende hmgr-GUI nach vorn und zeigt das Verzeichnis, wenn es dabei
143
+ neu gemerkt wird (nicht per SSH, E110).
144
+
138
145
  Schalter:
139
146
  --harness <name> claude-code, hermes oder agents, Standard claude-code
140
147
  --ort <ort> home, wsl:<distro>, dir:<pfad> oder profil:<name>, Standard home. profil
@@ -496,10 +503,16 @@ damit nichts verloren geht. Lokale Quellen haben keinen Checkout und bleiben unb
496
503
  Quelle ohne eigenen Checkout wird dabei aufgefrischt (E85): ihr Katalog zeigt danach, was neu im
497
504
  Remote liegt, auch ohne dass irgendwo ein Checkout von ihr existiert.
498
505
 
499
- Unter jeder „aktualisiert"-Zeile steht, welche Skills der neue Stand geändert hat (E70): die Namen
500
- kommen aus dem Vergleich von altem und neuem Commit, gezählt werden nur Skills, die in diesem
501
- Checkout auch ausgecheckt sind. Berührt ein Commit nur Dateien daneben (README, CI), bleibt die
502
- Zeile weg.
506
+ Aktualisierte Checkouts erscheinen je Quelle als Gruppe (E109): der Name der Quelle aus
507
+ hmgr source list, darunter „Neu", „Entfernt" und „Geändert" mit den Skill-Namen, leere Zeilen
508
+ fallen weg. Neu und entfernt gelten für alle Skills der Quelle (der Vergleich von altem und neuem
509
+ Commit nach dem Namen aus der SKILL.md, eine Umbenennung zeigt beides), „Geändert" nur für Skills,
510
+ die in diesem Checkout ausgecheckt sind (E70). Liegt der Checkout auf einem anderen Ref als main
511
+ oder master, steht er in Klammern hinter dem Namen, etwa „cmux (nightly)". Quellen, bei denen sich
512
+ an Skills nichts getan hat (README, CI), nennt eine Zeile am Ende des Berichts: „Außerdem neuer
513
+ Stand bei cmux, ohne Änderung an deinen Skills." Commit-Kürzel zeigt update nicht mehr, sie stehen
514
+ in ~/.hmgr/verlauf.json. Ist eine Quelle nicht mehr in der Quellenliste, steht ihr Pfad
515
+ besitzer/repo statt des Namens.
503
516
 
504
517
  Im selben Lauf gleicht update die installierten Bundles gegen ihre Definitionen ab (E69): eine
505
518
  Definition, die inzwischen ein Mitglied mehr oder weniger nennt, wird in jedem Ziel nachgezogen, in
@@ -557,12 +570,18 @@ die hmgr update schreibt und die GUI bei jedem automatischen und angestoßenen L
557
570
  liest. Ein Lauf ohne Ereignis (nichts Neues) steht nicht drin, die Datei ist ein Changelog, keine
558
571
  vollständige Historie — sie hält die letzten 20 Einträge.
559
572
 
560
- Je Eintrag steht eine Kopfzeile mit Datum und Uhrzeit, darunter je Ereignis eine Zeile: ein
561
- aktualisierter Checkout mit Quelle, Ref und Commit-Stand samt geänderten Skills (E70), ein
562
- nachgezogenes Bundle mit gelegten und entfernten Links, ein aufgeräumter (gelöschter) Checkout, ein
563
- Fehler zu einer Quelle oder einem Bundle, dazu hmgr-Ereignisse: ein Versionssprung von hmgr selbst
564
- (GUI oder CLI, erkannt beim Start bzw. bei jedem CLI-Aufruf) oder ein gefundenes Update, das noch
565
- nicht installiert ist. Plugins stehen nicht im Verlauf (siehe hmgr help update).
573
+ Je Eintrag steht eine Kopfzeile mit Datum und Uhrzeit („Heute, 00:26", „Gestern, 22:58", sonst
574
+ „06.10.2026, 17:42"), darunter die Ereignisse. Aktualisierte Checkouts stehen je Quelle als Gruppe
575
+ (E109): der Name der Quelle, darunter „Neu", „Entfernt" und „Geändert" mit Skill-Namen, leere
576
+ Zeilen fallen weg; ein Ref außer main oder master steht in Klammern hinter dem Namen. Neu und
577
+ entfernt gelten für alle Skills der Quelle (eine Umbenennung zeigt beides), „Geändert" nur für
578
+ ausgecheckte Skills (E70). Quellen ohne Skill-Änderung sammelt eine Zeile am Ende des Laufs
579
+ („Außerdem neuer Stand bei cmux, ohne Änderung an deinen Skills."). Dazwischen die übrigen
580
+ Ereignisse, je eine Zeile: ein nachgezogenes Bundle mit gelegten und entfernten Links, ein
581
+ aufgeräumter (gelöschter) Checkout, ein Fehler zu einer Quelle oder einem Bundle, dazu
582
+ hmgr-Ereignisse: ein Versionssprung von hmgr selbst (GUI oder CLI, erkannt beim Start bzw. bei
583
+ jedem CLI-Aufruf) oder ein gefundenes Update, das noch nicht installiert ist. Einträge aus der
584
+ Zeit vor E109 zeigen nur „Geändert". Plugins stehen nicht im Verlauf (siehe hmgr help update).
566
585
 
567
586
  Unter einem „installiert"-Eintrag von hmgr stehen die Änderungen: je Version seit der zuvor
568
587
  vermerkten die Nummer und darunter die Änderungen als kurze Sätze (E108). Einträge ohne bekannte Vorversion
package/dist/cli/main.js CHANGED
@@ -4,6 +4,7 @@ import path from 'node:path';
4
4
  import { realpathSync } from 'node:fs';
5
5
  import { homedir } from 'node:os';
6
6
  import { pathToFileURL } from 'node:url';
7
+ import { checkoutGruppen, quellenAnzeigeName, sammelZeile, verlaufZeitpunkt } from "./verlauf-anzeige.js";
7
8
  import { Anwendungsfaelle, pluginWeltText, } from "../kern/anwendungsfaelle.js";
8
9
  import { AbhaengigkeitFehlt, BundleDateiFehler, BundleMehrdeutig, BundleMitgliederFehlen, BundleNamenskonflikte, BundleNichtInstalliert, BundleUnbekannt, EinschraenkungOhneZeiger, HarnessInaktiv, HarnessNichtVorhanden, HarnessTypUnbekannt, HeimAdresseUngueltig, HeimBereitsVorhanden, HeimFaehigkeitFehlt, HeimNurUnterWindows, ClaudeCliNichtErreichbar, HeimUnbekannt, KeinPassenderRef, LinkAnlegenFehlgeschlagen, McpAbweichenderEintrag, McpEbeneNichtUnterstuetzt, McpFaehigkeitFehlt, McpKatalogFehler, McpNichtInGeteilterDatei, McpNurGeteilterFundort, McpOrtUnterGitWurzel, McpUebernahmeNichtMoeglich, McpUebernahmeUnvollstaendig, McpUnbekannt, NameBelegt, OrtNichtUnterstuetzt, PluginMehrdeutig, PluginUnbekannt, QuelleAufraeumenLokaleAenderungen, QuelleNameBelegt, QuelleUnbekannt, SkillMehrdeutig, SkillOrdnerBereitsVerwaltet, SkillOrdnerFaehigkeitFehlt, SkillOrdnerNichtVerwaltet, SkillOrdnerUngueltig, SkillUnbekannt, ZeigerMehrdeutig, WslBrueckeFehlgeschlagen, ZeigerSchreibenGesperrt, heimAdresseAlsText, heimAdresseZerlegen, wslDistroVonOrt, wslUncZerlegen, pluginSchluessel, quelleName, quelleSchluessel, } from "../kern/modell.js";
9
10
  import { Agents } from "../adapter/agents.js";
@@ -20,6 +21,7 @@ import { paketVersion } from "../adapter/paket-version.js";
20
21
  import { quelleAlsText, quelleAusText } from "../adapter/quelle-text.js";
21
22
  import { TerminalRueckfrage } from "../adapter/terminal.js";
22
23
  import { ProzessUmgebung } from "../adapter/umgebung.js";
24
+ import { signalSchreiben } from "../adapter/gui-signal.js";
23
25
  import { ZustandJson } from "../adapter/zustand-json.js";
24
26
  import { VerlaufJson } from "../adapter/verlauf-json.js";
25
27
  import { AenderungenJson } from "../adapter/aenderungen-json.js";
@@ -258,6 +260,21 @@ const UEBERNAHME_GRUND = {
258
260
  'lokale-aenderungen': 'Ordner weicht von der Quelle ab',
259
261
  'nicht-pruefbar': 'hmgr kann nicht prüfen, ob der Ordner der Quelle entspricht',
260
262
  };
263
+ /**
264
+ * E110: gibt einer laufenden GUI Bescheid, dass dieses Verzeichnis gezeigt werden soll. Nie bei
265
+ * SSH-Aufrufen, nie ein Grund zu scheitern.
266
+ */
267
+ async function guiBenachrichtigen(umgebung, ports, pfad) {
268
+ try {
269
+ const variablen = ports.umgebung.variablen();
270
+ if ((variablen.SSH_CONNECTION ?? '') !== '' || (variablen.SSH_TTY ?? '') !== '')
271
+ return;
272
+ await signalSchreiben(umgebung.hmgrHome, { art: 'verzeichnis-zeigen', pfad, zeit: Date.now() });
273
+ }
274
+ catch {
275
+ // Das Signal ist ein Zusatz; das Kommando selbst hat funktioniert.
276
+ }
277
+ }
261
278
  export async function ausfuehren(argv, umgebung, optionen = {}) {
262
279
  const { anwendungsfaelle, ports } = bauen(umgebung, optionen);
263
280
  const kommando = argv[0];
@@ -425,6 +442,7 @@ export async function ausfuehren(argv, umgebung, optionen = {}) {
425
442
  const ergebnis = composer === undefined
426
443
  ? await anwendungsfaelle.arbeitsverzeichnisHinzufuegen(pfad)
427
444
  : await anwendungsfaelle.arbeitsverzeichnisHinzufuegen(pfad, composer);
445
+ await guiBenachrichtigen(umgebung, ports, ergebnis.pfad);
428
446
  console.log(`Arbeitsverzeichnis ${ergebnis.pfad}`);
429
447
  if (ergebnis.zeigerPfad !== undefined)
430
448
  console.log(` Zeiger ${ergebnis.zeigerPfad}`);
@@ -464,10 +482,11 @@ export async function ausfuehren(argv, umgebung, optionen = {}) {
464
482
  if (ziel.ort.art === 'arbeitsverzeichnis') {
465
483
  // Verzeichnis merken und binden, damit status es wiederfindet und der Zeiger bekannt ist (E21).
466
484
  const composer = werte.get('composer');
467
- if (composer === undefined)
468
- await anwendungsfaelle.arbeitsverzeichnisHinzufuegen(ziel.ort.pfad);
469
- else
470
- await anwendungsfaelle.arbeitsverzeichnisHinzufuegen(ziel.ort.pfad, composer);
485
+ const verz = composer === undefined
486
+ ? await anwendungsfaelle.arbeitsverzeichnisHinzufuegen(ziel.ort.pfad)
487
+ : await anwendungsfaelle.arbeitsverzeichnisHinzufuegen(ziel.ort.pfad, composer);
488
+ if (verz.neu)
489
+ await guiBenachrichtigen(umgebung, ports, verz.pfad);
471
490
  }
472
491
  if (kommando === 'unlink') {
473
492
  const ergebnis = await anwendungsfaelle.linkEntfernen({ skill, ziel, lokal });
@@ -503,10 +522,11 @@ export async function ausfuehren(argv, umgebung, optionen = {}) {
503
522
  const name = rest[0];
504
523
  if (ziel.ort.art === 'arbeitsverzeichnis' && !probelauf) {
505
524
  const composer = werte.get('composer');
506
- if (composer === undefined)
507
- await anwendungsfaelle.arbeitsverzeichnisHinzufuegen(ziel.ort.pfad);
508
- else
509
- await anwendungsfaelle.arbeitsverzeichnisHinzufuegen(ziel.ort.pfad, composer);
525
+ const verz = composer === undefined
526
+ ? await anwendungsfaelle.arbeitsverzeichnisHinzufuegen(ziel.ort.pfad)
527
+ : await anwendungsfaelle.arbeitsverzeichnisHinzufuegen(ziel.ort.pfad, composer);
528
+ if (verz.neu)
529
+ await guiBenachrichtigen(umgebung, ports, verz.pfad);
510
530
  }
511
531
  const ergebnis = await anwendungsfaelle.uebernehmen({
512
532
  ziel,
@@ -567,10 +587,11 @@ export async function ausfuehren(argv, umgebung, optionen = {}) {
567
587
  const lokal = flaggen.has('local') || flaggen.has('lokal');
568
588
  if (ziel.ort.art === 'arbeitsverzeichnis') {
569
589
  const composer = werte.get('composer');
570
- if (composer === undefined)
571
- await anwendungsfaelle.arbeitsverzeichnisHinzufuegen(ziel.ort.pfad);
572
- else
573
- await anwendungsfaelle.arbeitsverzeichnisHinzufuegen(ziel.ort.pfad, composer);
590
+ const verz = composer === undefined
591
+ ? await anwendungsfaelle.arbeitsverzeichnisHinzufuegen(ziel.ort.pfad)
592
+ : await anwendungsfaelle.arbeitsverzeichnisHinzufuegen(ziel.ort.pfad, composer);
593
+ if (verz.neu)
594
+ await guiBenachrichtigen(umgebung, ports, verz.pfad);
574
595
  }
575
596
  const quelle = werte.get('quelle');
576
597
  const ergebnis = unterkommando === 'add'
@@ -626,10 +647,11 @@ export async function ausfuehren(argv, umgebung, optionen = {}) {
626
647
  if (ziel.ort.art === 'arbeitsverzeichnis') {
627
648
  // Wie bei link: Verzeichnis merken und binden, damit status es wiederfindet (E21).
628
649
  const composer = werte.get('composer');
629
- if (composer === undefined)
630
- await anwendungsfaelle.arbeitsverzeichnisHinzufuegen(ziel.ort.pfad);
631
- else
632
- await anwendungsfaelle.arbeitsverzeichnisHinzufuegen(ziel.ort.pfad, composer);
650
+ const verz = composer === undefined
651
+ ? await anwendungsfaelle.arbeitsverzeichnisHinzufuegen(ziel.ort.pfad)
652
+ : await anwendungsfaelle.arbeitsverzeichnisHinzufuegen(ziel.ort.pfad, composer);
653
+ if (verz.neu)
654
+ await guiBenachrichtigen(umgebung, ports, verz.pfad);
633
655
  }
634
656
  if (unterkommando === 'add') {
635
657
  const quelle = werte.get('quelle');
@@ -764,16 +786,27 @@ export async function ausfuehren(argv, umgebung, optionen = {}) {
764
786
  const { checkouts: liste, pluginFehler, bundles, aufraeumen, doctor, } = await anwendungsfaelle.aktualisieren({ ausloeser: 'cli', ...(nurQuelle === undefined ? {} : { quelle: nurQuelle }) });
765
787
  if (liste.length === 0)
766
788
  console.log('Keine Checkouts.');
767
- for (const { checkout, aktualisierung, geaenderteSkills } of liste) {
768
- const kopf = `${quelleAlsText(checkout.quelle)} @ ${checkout.ref}`;
769
- if (aktualisierung.ergebnis === 'aktualisiert') {
770
- console.log(` aktualisiert ${kopf} ${aktualisierung.von.slice(0, 8)} -> ${aktualisierung.nach.slice(0, 8)}`);
771
- // Auf Skill-Ebene berichten (E70): sonst steht da nur, dass die Quelle neue Commits hat.
772
- // Ohne Treffer bleibt die Zeile weg — der Commit hat dann nichts in einem Skill geändert.
773
- if (geaenderteSkills.length > 0)
774
- console.log(` ${zahlwort(geaenderteSkills.length, 'Skill', 'Skills')} geändert: ${geaenderteSkills.join(', ')}`);
775
- }
776
- else if (aktualisierung.ergebnis === 'unveraendert')
789
+ const updateQuellen = liste.length === 0 ? [] : await anwendungsfaelle.quellen();
790
+ // Aktualisierte Checkouts als Quellengruppen, ohne Commit-Kürzel (E109, F125); Quellen ohne
791
+ // Skill-Änderung sammelt die Zeile am Ende. Die übrigen Zeilenarten nennen die Quelle beim Namen.
792
+ const { zeilen: updateGruppen, ohneAenderung: updateOhne } = checkoutGruppen(liste
793
+ .filter(({ aktualisierung }) => aktualisierung.ergebnis === 'aktualisiert')
794
+ .map(({ checkout, geaenderteSkills, neueSkills, entfernteSkills }) => ({
795
+ quelle: quelleAlsText(checkout.quelle),
796
+ ref: checkout.ref,
797
+ neu: neueSkills,
798
+ entfernt: entfernteSkills,
799
+ geaendert: geaenderteSkills,
800
+ })), updateQuellen);
801
+ for (const zeile of updateGruppen)
802
+ console.log(zeile);
803
+ let updateAndere = updateGruppen.length > 0;
804
+ for (const { checkout, aktualisierung } of liste) {
805
+ if (aktualisierung.ergebnis === 'aktualisiert')
806
+ continue;
807
+ updateAndere = true;
808
+ const kopf = quellenAnzeigeName({ quelle: quelleAlsText(checkout.quelle), ref: checkout.ref }, updateQuellen);
809
+ if (aktualisierung.ergebnis === 'unveraendert')
777
810
  console.log(` unverändert ${kopf}`);
778
811
  else if (aktualisierung.ergebnis === 'uebersprungen')
779
812
  console.log(` übersprungen ${kopf} lokale Änderung (E15)`);
@@ -807,6 +840,14 @@ export async function ausfuehren(argv, umgebung, optionen = {}) {
807
840
  else
808
841
  console.log(` liegen lassen Arbeitsverzeichnis ${pfad} (nicht mehr auf der Platte, ohne Bestätigung nicht vergessen)`);
809
842
  }
843
+ // Die Sammelzeile steht ganz am Ende des Berichts (E109); „Außerdem" nur, wenn sonst etwas im Lauf stand.
844
+ const updateSammel = sammelZeile(updateOhne, !updateAndere &&
845
+ pluginFehler.length === 0 &&
846
+ bundleBericht(bundles).length === 0 &&
847
+ aufraeumen.verwaiste.length === 0 &&
848
+ doctor.fehlendeArbeitsverzeichnisse.length === 0);
849
+ if (updateSammel !== undefined)
850
+ console.log(updateSammel);
810
851
  // Zuletzt hmgr selbst (E77): wer schon aktualisiert, will auch wissen, dass sein hmgr alt ist.
811
852
  // Verglichen wird die eigene Version (E72) gegen npm, gemeldet wird nur — installiert wird
812
853
  // nichts. Offline oder Registry unerreichbar heißt keine Zeile, still: der Lauf selbst ist
@@ -858,9 +899,28 @@ export async function ausfuehren(argv, umgebung, optionen = {}) {
858
899
  const eintraege = await anwendungsfaelle.verlauf();
859
900
  if (eintraege.length === 0)
860
901
  console.log('Noch keine Ereignisse.');
902
+ const quellenListe = eintraege.length === 0 ? [] : await anwendungsfaelle.quellen();
861
903
  for (const eintrag of eintraege) {
862
904
  console.log(verlaufZeitpunkt(eintrag.zeit));
905
+ // Erst die Quellengruppen, dann die übrigen Ereignisse, zuletzt die Sammelzeile (E109).
906
+ const vermerke = [];
907
+ for (const ereignis of eintrag.ereignisse)
908
+ if (ereignis.art === 'checkout')
909
+ vermerke.push({
910
+ quelle: ereignis.quelle,
911
+ ref: ereignis.ref,
912
+ neu: ereignis.neu ?? [],
913
+ entfernt: ereignis.entfernt ?? [],
914
+ geaendert: ereignis.skills,
915
+ });
916
+ const { zeilen: gruppenZeilen, ohneAenderung } = checkoutGruppen(vermerke, quellenListe);
917
+ for (const zeile of gruppenZeilen)
918
+ console.log(zeile);
919
+ let andere = gruppenZeilen.length > 0;
863
920
  for (const ereignis of eintrag.ereignisse) {
921
+ if (ereignis.art === 'checkout')
922
+ continue;
923
+ andere = true;
864
924
  console.log(` ${verlaufEreignisZeile(ereignis)}`);
865
925
  // Unter einem Versionssprung die Commits der neuen Version(en), eingerückt (E108).
866
926
  if (ereignis.art === 'hmgr-installiert') {
@@ -869,6 +929,9 @@ export async function ausfuehren(argv, umgebung, optionen = {}) {
869
929
  console.log(zeile);
870
930
  }
871
931
  }
932
+ const sammel = sammelZeile(ohneAenderung, !andere);
933
+ if (sammel !== undefined)
934
+ console.log(sammel);
872
935
  }
873
936
  return 0;
874
937
  }
@@ -1093,24 +1156,12 @@ function bundleBericht(eintraege) {
1093
1156
  }
1094
1157
  return zeilen;
1095
1158
  }
1096
- /** Kopfzeile eines Verlaufseintrags (E80): lokale Zeit, damit sie zum Rechner des Lesers passt. */
1097
- function verlaufZeitpunkt(iso) {
1098
- const datum = new Date(iso);
1099
- const zwei = (n) => String(n).padStart(2, '0');
1100
- return (`${zwei(datum.getDate())}.${zwei(datum.getMonth() + 1)}.${datum.getFullYear()} ` +
1101
- `${zwei(datum.getHours())}:${zwei(datum.getMinutes())}`);
1102
- }
1103
1159
  /** Je Version die Nummer und darunter ihre Commit-Betreffs, tiefer eingerückt als die Ereigniszeile (E108). */
1104
1160
  function aenderungsZeilen(aenderungen) {
1105
1161
  return aenderungen.flatMap((a) => [` ${a.version}`, ...a.commits.map((c) => ` ${c}`)]);
1106
1162
  }
1107
- /** Eine Ereigniszeile im Verlauf (E80), eingerückt unter der Kopfzeile ihres Laufs. */
1163
+ /** Eine Ereigniszeile im Verlauf (E80), eingerückt unter der Kopfzeile ihres Laufs. Checkouts stellt `checkoutGruppen` dar (E109). */
1108
1164
  function verlaufEreignisZeile(ereignis) {
1109
- if (ereignis.art === 'checkout') {
1110
- const stand = `${ereignis.von.slice(0, 7)} → ${ereignis.nach.slice(0, 7)}`;
1111
- const skills = ereignis.skills.length > 0 ? `, Skills: ${ereignis.skills.join(', ')}` : '';
1112
- return `${ereignis.quelle} @ ${ereignis.ref}: ${stand}${skills}`;
1113
- }
1114
1165
  if (ereignis.art === 'bundle') {
1115
1166
  const teile = [
1116
1167
  ereignis.gelegt.length > 0 ? `gelegt ${ereignis.gelegt.join(', ')}` : undefined,
@@ -0,0 +1,61 @@
1
+ // Darstellung von Verlauf und `hmgr update` für Menschen (E109): Quellengruppen mit Neu, Entfernt,
2
+ // Geändert und eine Sammelzeile für Quellen ohne Skill-Änderung. Reine Funktionen, die GUI baut
3
+ // dieselbe Darstellung im Renderer nach (renderer.js, verlaufGruppen).
4
+ import { quelleVerlaufName } from "../kern/modell.js";
5
+ /** Name der Quelle, bei einem Ref außer main/master mit dem Ref in Klammern dahinter. */
6
+ export function quellenAnzeigeName(vermerk, quellen) {
7
+ const name = quelleVerlaufName(vermerk.quelle, quellen);
8
+ return vermerk.ref === 'main' || vermerk.ref === 'master' ? name : `${name} (${vermerk.ref})`;
9
+ }
10
+ const BESCHRIFTUNG_BREITE = 11;
11
+ /**
12
+ * Je Quelle mit Skill-Änderung eine Gruppe (Name, darunter Neu/Entfernt/Geändert, leere fallen weg),
13
+ * eingerückt für die Ausgabe unter einer Kopfzeile. `ohneAenderung` sind die Namen der übrigen.
14
+ */
15
+ export function checkoutGruppen(vermerke, quellen) {
16
+ const zeilen = [];
17
+ const ohneAenderung = [];
18
+ for (const v of vermerke) {
19
+ const name = quellenAnzeigeName(v, quellen);
20
+ const teile = [
21
+ ['Neu', v.neu],
22
+ ['Entfernt', v.entfernt],
23
+ ['Geändert', v.geaendert],
24
+ ];
25
+ const belegt = teile.filter(([, namen]) => namen.length > 0);
26
+ if (belegt.length === 0) {
27
+ if (!ohneAenderung.includes(name))
28
+ ohneAenderung.push(name);
29
+ continue;
30
+ }
31
+ zeilen.push(` ${name}`);
32
+ for (const [beschriftung, namen] of belegt)
33
+ zeilen.push(` ${beschriftung.padEnd(BESCHRIFTUNG_BREITE)}${namen.join(', ')}`);
34
+ }
35
+ return { zeilen, ohneAenderung };
36
+ }
37
+ /** Die Sammelzeile am Ende; `alleinig`, wenn sonst nichts im Lauf steht. */
38
+ export function sammelZeile(namen, alleinig) {
39
+ if (namen.length === 0)
40
+ return undefined;
41
+ const liste = namen.join(', ');
42
+ return alleinig
43
+ ? ` Neuer Stand bei ${liste}, ohne Änderung an deinen Skills.`
44
+ : ` Außerdem neuer Stand bei ${liste}, ohne Änderung an deinen Skills.`;
45
+ }
46
+ /**
47
+ * Kopfzeile eines Verlaufseintrags (E80, E109): „Heute, 00:26", „Gestern, 22:58", sonst
48
+ * „06.10.2026, 17:42". Lokale Zeit, damit sie zum Rechner des Lesers passt.
49
+ */
50
+ export function verlaufZeitpunkt(iso, jetzt = new Date()) {
51
+ const datum = new Date(iso);
52
+ const zwei = (n) => String(n).padStart(2, '0');
53
+ const uhr = `${zwei(datum.getHours())}:${zwei(datum.getMinutes())}`;
54
+ const tag = (d) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
55
+ const gestern = new Date(jetzt.getFullYear(), jetzt.getMonth(), jetzt.getDate() - 1).getTime();
56
+ if (tag(datum) === tag(jetzt))
57
+ return `Heute, ${uhr}`;
58
+ if (tag(datum) === gestern)
59
+ return `Gestern, ${uhr}`;
60
+ return `${zwei(datum.getDate())}.${zwei(datum.getMonth() + 1)}.${datum.getFullYear()}, ${uhr}`;
61
+ }
package/dist/gui/main.js CHANGED
@@ -21,6 +21,8 @@ import { iconFuerQuelle } from "./icons.js";
21
21
  import { entscheideOeffnen } from "./oeffnen.js";
22
22
  import { mcpUebernahmeSerialisieren, mcpUebersichtSerialisieren, mcpWirkungSerialisieren } from "./mcp-ansicht.js";
23
23
  import { beobachten } from "./beobachter.js";
24
+ import { signalSchreiben } from "../adapter/gui-signal.js";
25
+ import { signalBeobachten } from "./signal.js";
24
26
  import { loginPathUebernehmen } from "./login-pfad.js";
25
27
  import { hmgrCliVersion } from "./hmgr-cli.js";
26
28
  import { cliUpdateHinweis } from "../adapter/npm-registry.js";
@@ -275,6 +277,8 @@ function registriereIpc() {
275
277
  art: quelle.art,
276
278
  text: quelleAlsText(quelle),
277
279
  name: quelleName(quelle),
280
+ // Nur der gespeicherte Name (E109): der Verlauf braucht den Unterschied zum abgeleiteten.
281
+ gespeichert: quelle.name,
278
282
  }))));
279
283
  // Quelle-Verwaltung in den Einstellungen (E54, E59): Text-zu-Quelle passiert im Main-Prozess, der
280
284
  // Renderer kennt nur Zeichenketten. Der Anzeigename kommt serverseitig mit, damit dem Renderer nie
@@ -286,6 +290,7 @@ function registriereIpc() {
286
290
  art: ergebnis.quelle.art,
287
291
  text: quelleAlsText(ergebnis.quelle),
288
292
  name: quelleName(ergebnis.quelle),
293
+ gespeichert: ergebnis.quelle.name,
289
294
  },
290
295
  skills: ergebnis.skills,
291
296
  marketplace: ergebnis.marketplace,
@@ -295,7 +300,7 @@ function registriereIpc() {
295
300
  ipcMain.handle('hmgr:quelleEntfernen', async (_event, text) => auffangen(async () => anwendungsfaelle.quelleEntfernen(quelleAusText(text))));
296
301
  ipcMain.handle('hmgr:quelleUmbenennen', async (_event, text, name) => auffangen(async () => {
297
302
  const umbenannt = await anwendungsfaelle.quelleUmbenennen(quelleAusText(text), name);
298
- return { art: umbenannt.art, text: quelleAlsText(umbenannt), name: quelleName(umbenannt) };
303
+ return { art: umbenannt.art, text: quelleAlsText(umbenannt), name: quelleName(umbenannt), gespeichert: umbenannt.name };
299
304
  }));
300
305
  // Aufräum-Angebot nach dem Entfernen einer Quelle (E58): Inventar vor dem eigentlichen Entfernen
301
306
  // erheben (der Renderer ruft das, bevor er quelleEntfernen aufruft), dann bei Zustimmung abräumen.
@@ -502,6 +507,7 @@ function neuLadenAnstossenFabrik(minAbstandMs) {
502
507
  }
503
508
  const neuLadenAnstossen = neuLadenAnstossenFabrik(FOKUS_RELOAD_MIN_ABSTAND_MS);
504
509
  let beobachter;
510
+ let signalBeobachter;
505
511
  function fensterOeffnen() {
506
512
  hauptfenster = new BrowserWindow({
507
513
  width: 1280,
@@ -1364,6 +1370,63 @@ function fensterOeffnen() {
1364
1370
  await new Promise((r) => setTimeout(r, 800));
1365
1371
  const auszug = await fenster.webContents.executeJavaScript(NEUSTART_AUSZUG_SKRIPT);
1366
1372
  console.log(`[smoke] Neustart-Modal: ${String(auszug)}`);
1373
+ })
1374
+ // E110: CLI-Signal gegen die laufende GUI. Das Signal schreibt dieser Main-Prozess in das
1375
+ // Wegwerf-Home (der Renderer kann keine Dateien schreiben). Fall 1: ohne Dialog springt die
1376
+ // GUI aus einer anderen Ansicht zur Karte. Fall 2: mit offenem Dialog bleibt alles stehen,
1377
+ // erst nach dem Schließen folgt der Sprung. Läuft nach der Neustart-Modal-Prüfung, weil der Fall
1378
+ // offene Dialoge schließt.
1379
+ .then(async () => {
1380
+ const pfade = (process.env.HMGR_GUI_SMOKE_SIGNAL_PFADE ?? '').split('\n').filter((p) => p !== '');
1381
+ if (pfade.length < 2)
1382
+ return;
1383
+ const [pfad1, pfad2] = pfade;
1384
+ const js = (code) => fenster.webContents.executeJavaScript(code);
1385
+ const karteJs = (pfad) => `(function () {
1386
+ var schluessel = ${JSON.stringify(`dir:${pfad}`)};
1387
+ var karte = Array.from(document.querySelectorAll('#zielliste .ziel.verzeichnis'))
1388
+ .find(function (k) { return k.dataset.verzKey === schluessel; });
1389
+ return {
1390
+ karte: karte !== undefined,
1391
+ zieleSichtbar: document.getElementById('ansicht-ziele').hidden === false,
1392
+ aufgeklappt: karte?.querySelector('.zielkopf')?.getAttribute('aria-expanded') ?? null,
1393
+ };
1394
+ })()`;
1395
+ const warteAufSprung = async (pfad) => {
1396
+ let stand = null;
1397
+ for (let ms = 0; ms < 4000; ms += 100) {
1398
+ await new Promise((r) => setTimeout(r, 100));
1399
+ stand = await js(karteJs(pfad));
1400
+ const k = stand;
1401
+ if (k.zieleSichtbar && k.aufgeklappt === 'true')
1402
+ break;
1403
+ }
1404
+ return stand;
1405
+ };
1406
+ const ergebnis = {};
1407
+ try {
1408
+ await js(`new Promise(function (r) { var n = 0; (function w() { if (document.querySelector('dialog[open]') || ++n > 30) r(); else setTimeout(w, 50); })(); })`);
1409
+ await js(`document.querySelectorAll('dialog[open]').forEach(function (d) { d.close(); });
1410
+ document.querySelector('[data-ansicht="mcp"]').click(); true`);
1411
+ ergebnis.fall1Vorher = await js(karteJs(pfad1));
1412
+ await signalSchreiben(umgebung.hmgrHome, { art: 'verzeichnis-zeigen', pfad: pfad1, zeit: Date.now() });
1413
+ ergebnis.fall1 = await warteAufSprung(pfad1);
1414
+ // Fall 2: Dialog offen, andere Ansicht, Signal für ein anderes Verzeichnis.
1415
+ await js(`document.querySelector('[data-ansicht="mcp"]').click();
1416
+ document.getElementById('d-einstellungen').showModal(); true`);
1417
+ ergebnis.fall2Vorher = await js(karteJs(pfad2));
1418
+ await signalSchreiben(umgebung.hmgrHome, { art: 'verzeichnis-zeigen', pfad: pfad2, zeit: Date.now() });
1419
+ await new Promise((r) => setTimeout(r, 1500));
1420
+ ergebnis.fall2Offen = await js(`({ dialogOffen: document.getElementById('d-einstellungen').open === true, ` +
1421
+ `ansichtMcp: document.getElementById('ansicht-mcp').hidden === false })`);
1422
+ ergebnis.fall2NachWarten = await js(karteJs(pfad2));
1423
+ await js(`document.getElementById('d-einstellungen').close(); true`);
1424
+ ergebnis.fall2 = await warteAufSprung(pfad2);
1425
+ }
1426
+ catch (fehler) {
1427
+ ergebnis.fehler = String(fehler);
1428
+ }
1429
+ console.log(`[smoke] Signal: ${JSON.stringify(ergebnis)}`);
1367
1430
  })
1368
1431
  .finally(() => app.quit());
1369
1432
  }, 3000);
@@ -1404,6 +1467,26 @@ function fensterOeffnen() {
1404
1467
  beobachter = undefined;
1405
1468
  });
1406
1469
  }
1470
+ // CLI-Signal (E110): `hmgr dir add` und Verwandte schreiben `gui-signal.json` ins hmgr-Home. Anders
1471
+ // als der Watcher oben ohne Entprellen und ohne Drossel; läuft auch im Smoke-Test (mit dessen
1472
+ // Wegwerf-Home), dort aber ohne Fokusklau.
1473
+ {
1474
+ const fenster = hauptfenster;
1475
+ signalBeobachter = signalBeobachten(umgebung.hmgrHome, (pfad) => {
1476
+ if (fenster.isDestroyed())
1477
+ return;
1478
+ if (!smoketest) {
1479
+ fensterNachVornHolen();
1480
+ if (process.platform === 'darwin')
1481
+ app.focus({ steal: true });
1482
+ }
1483
+ fenster.webContents.send('hmgr:verzeichnisZeigen', pfad);
1484
+ });
1485
+ fenster.on('closed', () => {
1486
+ signalBeobachter?.close();
1487
+ signalBeobachter = undefined;
1488
+ });
1489
+ }
1407
1490
  void hauptfenster.loadFile(path.join(hierVerzeichnis, 'renderer', 'index.html'));
1408
1491
  }
1409
1492
  const { autoUpdater } = electronUpdater;
@@ -79,6 +79,11 @@ contextBridge.exposeInMainWorld('hmgr', {
79
79
  onNeuLadenAnstossen: (hoerer) => {
80
80
  ipcRenderer.on('hmgr:neuLadenAnstossen', () => hoerer());
81
81
  },
82
+ /** Signal der CLI (E110): ein Arbeitsverzeichnis soll gezeigt werden. Main hat das Fenster schon
83
+ nach vorn geholt; kein Entprellen, keine Drossel. */
84
+ onVerzeichnisZeigen: (hoerer) => {
85
+ ipcRenderer.on('hmgr:verzeichnisZeigen', (_event, pfad) => hoerer(pfad));
86
+ },
82
87
  /** Prüfung auf neue hmgr-Stände (E77), angestoßen bei jedem Aktualisieren-Lauf (E80: Start,
83
88
  Intervall, Klick). Antwortet nicht, meldet sich später über onSelbstUpdate — Netz und Spawn
84
89
  dauern länger als ein Knopfdruck. */
@@ -2707,7 +2707,7 @@
2707
2707
  if (standLaeuft) { el.textContent = 'Prüft …'; return; }
2708
2708
  if (standNeu !== null) {
2709
2709
  el.appendChild(e('b', null, standNeu.zahlwort));
2710
- el.appendChild(document.createTextNode(' ' + standNeu.anhang + ', ' + standZeit));
2710
+ el.appendChild(document.createTextNode((standNeu.anhang === '' ? '' : ' ' + standNeu.anhang) + ', ' + standZeit));
2711
2711
  return;
2712
2712
  }
2713
2713
  var text = 'Noch nicht geprüft';
@@ -2738,6 +2738,15 @@
2738
2738
  (c.geaenderteSkills || []).forEach(function (name) { geaenderteSkills[name] = true; });
2739
2739
  });
2740
2740
  var skillZahl = Object.keys(geaenderteSkills).length;
2741
+ // Neue und entfernte Skills (E109): über alle Checkouts, doppelte Namen zählen einmal.
2742
+ var neueSkills = {};
2743
+ var entfernteSkills = {};
2744
+ ergebnis.checkouts.forEach(function (c) {
2745
+ (c.neueSkills || []).forEach(function (name) { neueSkills[name] = true; });
2746
+ (c.entfernteSkills || []).forEach(function (name) { entfernteSkills[name] = true; });
2747
+ });
2748
+ var neueZahl = Object.keys(neueSkills).length;
2749
+ var entferntZahl = Object.keys(entfernteSkills).length;
2741
2750
  // Ein nachgezogenes Bundle ist Neues wie ein neuer Commit (E69, F64=O1): sonst bliebe die
2742
2751
  // Stand-Zeile still, obwohl sich am Ziel gerade Links geändert haben.
2743
2752
  var bundlesNeu = (ergebnis.bundles || []).filter(function (b) { return b.ergebnis === 'aktualisiert'; }).length;
@@ -2748,10 +2757,15 @@
2748
2757
  var automatisch = ausloeser !== 'klick';
2749
2758
  if ((neue === 0 && bundlesNeu === 0) || !automatisch || !$('ansicht-update').hidden) {
2750
2759
  standNeu = null;
2760
+ } else if (neueZahl > 0) {
2761
+ // Ein neuer Skill ist die größte Neuigkeit (E109), danach geänderte, danach entfernte.
2762
+ standNeu = { zahlwort: neueZahl === 1 ? '1 neuer Skill' : neueZahl + ' neue Skills', anhang: '' };
2751
2763
  } else if (skillZahl > 0) {
2752
2764
  // Ist die Skill-Zahl bekannt, sagt sie mehr als die Zahl der Quellen (E70). Sonst bleibt es
2753
2765
  // beim alten Signal: eine Quelle kann Neues haben, ohne dass ein Skill davon betroffen ist.
2754
2766
  standNeu = { zahlwort: zahlwort(skillZahl, 'Skill', 'Skills'), anhang: 'aktualisiert' };
2767
+ } else if (entferntZahl > 0) {
2768
+ standNeu = { zahlwort: zahlwort(entferntZahl, 'Skill', 'Skills'), anhang: 'entfernt' };
2755
2769
  } else {
2756
2770
  standNeu = {
2757
2771
  zahlwort: neue > 0 ? zahlwort(neue, 'Quelle', 'Quellen') : zahlwort(bundlesNeu, 'Bundle', 'Bundles'),
@@ -2790,6 +2804,7 @@
2790
2804
  knopf.classList.remove('laeuft');
2791
2805
  // Scheiterte der Lauf, bliebe sonst „Prüft …" stehen.
2792
2806
  if ($('standtext').textContent === 'Prüft …') standText();
2807
+ reloadWartendeFreigeben();
2793
2808
  }
2794
2809
  }
2795
2810
 
@@ -2869,6 +2884,21 @@
2869
2884
  // überholt. Fehler landen wie sonst über rufe() in der Meldungsleiste, kein stiller Absturz.
2870
2885
  var stillerReloadLaeuft = false;
2871
2886
  var stillerReloadVorgemerkt = false;
2887
+ // Wer auf das Ende des Neuladens wartet (E110): die Auflöser werden frei, sobald weder ein
2888
+ // stiller Reload noch ein Aktualisieren-Lauf (der mit ladeAlles endet) mehr läuft.
2889
+ var reloadWartende = [];
2890
+ function reloadWartendeFreigeben() {
2891
+ if (stillerReloadLaeuft || stillerReloadVorgemerkt || $('updatestart').disabled) return;
2892
+ var liste = reloadWartende;
2893
+ reloadWartende = [];
2894
+ liste.forEach(function (aufloeser) { aufloeser(); });
2895
+ }
2896
+ function stillerReloadAbwarten() {
2897
+ return new Promise(function (aufloeser) {
2898
+ reloadWartende.push(aufloeser);
2899
+ void stillerReload();
2900
+ });
2901
+ }
2872
2902
  async function stillerReload() {
2873
2903
  // Läuft gerade ein Aktualisieren-Lauf, wird nicht parallel geladen: er endet ohnehin mit
2874
2904
  // ladeAlles und nimmt die Änderung mit. Sonst bauten zwei Läufe gleichzeitig die Ansicht auf.
@@ -2887,24 +2917,43 @@
2887
2917
  if (stillerReloadVorgemerkt) {
2888
2918
  stillerReloadVorgemerkt = false;
2889
2919
  void stillerReload();
2920
+ } else {
2921
+ reloadWartendeFreigeben();
2890
2922
  }
2891
2923
  }
2892
2924
  }
2893
2925
  window.hmgr.onNeuLadenAnstossen(function () { void stillerReload(); });
2894
2926
 
2927
+ // CLI-Signal (E110, nach `hmgr dir add` und Verwandten): sofort neu laden, dann zur Karte des
2928
+ // Verzeichnisses springen, auch aus einer anderen Ansicht. Steht ein Dialog offen, bleibt er
2929
+ // unangetastet und der Sprung wartet, bis kein Dialog mehr offen ist; ein neueres Signal ersetzt
2930
+ // die Vormerkung. Die Meldungsleiste bleibt, wie sie ist.
2931
+ var signalPfadVorgemerkt = null;
2932
+ function signalSprungEinloesen() {
2933
+ if (signalPfadVorgemerkt === null) return;
2934
+ if (document.querySelector('dialog[open]')) return;
2935
+ var pfad = signalPfadVorgemerkt;
2936
+ signalPfadVorgemerkt = null;
2937
+ // Eine E84-Vormerkung auf denselben Pfad ist mit diesem Sprung erledigt.
2938
+ if (zielSprungVorgemerkt === pfad) zielSprungVorgemerkt = null;
2939
+ zeigeAnsicht('ziele');
2940
+ springeZuVerzeichnis(pfad);
2941
+ }
2942
+ window.hmgr.onVerzeichnisZeigen(function (pfad) {
2943
+ signalPfadVorgemerkt = pfad;
2944
+ void stillerReloadAbwarten().then(signalSprungEinloesen);
2945
+ });
2946
+ // `close` bubbelt nicht, der Capture-Listener am Dokument sieht aber jeden geschlossenen Dialog.
2947
+ // Der Aufschub um einen Takt lässt einen unmittelbar folgenden Dialog zuerst aufgehen.
2948
+ document.addEventListener('close', function () { setTimeout(signalSprungEinloesen, 0); }, true);
2949
+
2895
2950
  /**
2896
2951
  * Eine Ereigniszeile im Verlauf (E80): Marke + Text, Muster wie die frühere baueBericht-Zeile
2897
- * (Checkout/Bundle) und die CLI-Ausgabe (verlaufEreignisZeile, src/cli/main.ts).
2952
+ * und die CLI-Ausgabe (verlaufEreignisZeile, src/cli/main.ts). Checkouts stellt
2953
+ * verlaufQuellenGruppen dar (E109).
2898
2954
  */
2899
2955
  function verlaufEreignisZeile(li, ereignis) {
2900
- if (ereignis.art === 'checkout') {
2901
- li.appendChild(e('span', 'marke-ok', 'aktualisiert'));
2902
- var skills = ereignis.skills.length > 0
2903
- ? ' · ' + zahlwort(ereignis.skills.length, 'Skill', 'Skills') + ': ' + ereignis.skills.join(', ')
2904
- : '';
2905
- var stand = ' (' + ereignis.von.slice(0, 7) + ' → ' + ereignis.nach.slice(0, 7) + ')';
2906
- li.appendChild(e('span', 'pfad', ereignis.quelle + ' @ ' + ereignis.ref + skills + stand));
2907
- } else if (ereignis.art === 'bundle') {
2956
+ if (ereignis.art === 'bundle') {
2908
2957
  li.appendChild(e('span', 'marke-ok', 'Bundle'));
2909
2958
  var teile = [];
2910
2959
  if (ereignis.gelegt.length > 0) teile.push('gelegt ' + ereignis.gelegt.join(', '));
@@ -2942,6 +2991,89 @@
2942
2991
  }
2943
2992
  }
2944
2993
 
2994
+ /**
2995
+ * Anzeigename einer Quelle im Verlauf (E109). Baut quelleVerlaufName (src/kern/modell.ts) nach,
2996
+ * der Renderer kann die Kern-Datei nicht importieren: Quelle aus QUELLEN über die Identität
2997
+ * (Host/Besitzer/Repo, E36) suchen; fehlt sie oder hat sie keinen Namen, `besitzer/repo` aus der
2998
+ * URL, bei einer lokalen Quelle der letzte Pfadteil. Ein Ref außer main/master steht in Klammern.
2999
+ */
3000
+ function verlaufQuellenOrt(text) {
3001
+ var t = text.trim().replace(/\/+$/, '');
3002
+ var segmente = null;
3003
+ var host = '';
3004
+ var m;
3005
+ if (t.indexOf('://') >= 0) {
3006
+ try {
3007
+ var u = new URL(t);
3008
+ segmente = decodeURIComponent(u.pathname).split('/');
3009
+ host = u.protocol === 'file:' ? 'local' : u.hostname;
3010
+ } catch (x) { return null; }
3011
+ } else if (!/^[A-Za-z]:[\\/]/.test(t) && (m = /^(?:[^@/]+@)?([^@/:]+):(.+)$/.exec(t))) {
3012
+ host = m[1];
3013
+ segmente = m[2].split('/');
3014
+ } else {
3015
+ return null;
3016
+ }
3017
+ segmente = segmente.filter(function (x) { return x.length > 0; });
3018
+ var repo = (segmente[segmente.length - 1] || 'repo').replace(/\.git$/, '');
3019
+ return { host: host.toLowerCase(), besitzer: segmente[segmente.length - 2] || '_', repo: repo };
3020
+ }
3021
+ function verlaufQuellenName(text, ref) {
3022
+ var ort = verlaufQuellenOrt(text);
3023
+ var schluessel = ort === null ? text : ort.host + '/' + ort.besitzer + '/' + ort.repo;
3024
+ var gefunden = QUELLEN.filter(function (q) {
3025
+ var qt = q.text;
3026
+ var qo = verlaufQuellenOrt(qt);
3027
+ return (qo === null ? qt : qo.host + '/' + qo.besitzer + '/' + qo.repo) === schluessel;
3028
+ })[0];
3029
+ var name;
3030
+ if (gefunden && gefunden.gespeichert && gefunden.gespeichert.trim().length > 0) name = gefunden.gespeichert;
3031
+ else if (ort !== null && ort.besitzer !== '_') name = ort.besitzer + '/' + ort.repo;
3032
+ else if (ort !== null) name = ort.repo;
3033
+ else {
3034
+ var teile = text.replace(/[\/\\]+$/, '').split(/[\/\\]/).filter(function (x) { return x.length > 0; });
3035
+ name = teile[teile.length - 1] || text;
3036
+ }
3037
+ return ref === 'main' || ref === 'master' ? name : name + ' (' + ref + ')';
3038
+ }
3039
+
3040
+ /**
3041
+ * Die Checkout-Ereignisse eines Laufs (E109): je Quelle mit Skill-Änderung eine Gruppe (Name,
3042
+ * darunter Neu/Entfernt/Geändert, leere fallen weg), dieselbe Darstellung wie `hmgr log`
3043
+ * (src/cli/verlauf-anzeige.ts). Gibt die Namen der Quellen ohne Skill-Änderung zurück, aus
3044
+ * denen der Aufrufer die Sammelzeile baut; die Gruppen hängt sie an `ul` an.
3045
+ */
3046
+ function verlaufQuellenGruppen(ul, ereignisse) {
3047
+ var ohne = [];
3048
+ var gruppen = 0;
3049
+ ereignisse.forEach(function (ereignis) {
3050
+ if (ereignis.art !== 'checkout') return;
3051
+ var name = verlaufQuellenName(ereignis.quelle, ereignis.ref);
3052
+ var teile = [
3053
+ ['Neu', ereignis.neu || []],
3054
+ ['Entfernt', ereignis.entfernt || []],
3055
+ ['Geändert', ereignis.skills || []],
3056
+ ].filter(function (t) { return t[1].length > 0; });
3057
+ if (teile.length === 0) {
3058
+ if (ohne.indexOf(name) < 0) ohne.push(name);
3059
+ return;
3060
+ }
3061
+ gruppen += 1;
3062
+ var li = e('li', 'quellengruppe');
3063
+ li.appendChild(e('b', null, name));
3064
+ var innen = e('ul', 'gruppenzeilen');
3065
+ teile.forEach(function (t) {
3066
+ var zeile = e('li');
3067
+ zeile.appendChild(e('span', 'marke-ok', t[0]));
3068
+ zeile.appendChild(e('span', 'pfad', t[1].join(', ')));
3069
+ innen.appendChild(zeile);
3070
+ });
3071
+ li.appendChild(innen);
3072
+ ul.appendChild(li);
3073
+ });
3074
+ return { ohne: ohne, gruppen: gruppen };
3075
+ }
3076
+
2945
3077
  /**
2946
3078
  * Baut das Changelog in der Update-Ansicht aus ~/.hmgr/verlauf.json (E80): ein Block je Lauf,
2947
3079
  * neueste zuerst, das Etikett der lokale Zeitpunkt, darunter eine Zeile je Ereignis. Die Datei
@@ -2959,15 +3091,29 @@
2959
3091
  var block = e('div', 'block');
2960
3092
  var zeit = new Date(eintrag.zeit);
2961
3093
  var zwei = function (n) { return String(n).padStart(2, '0'); };
2962
- var etikett = zwei(zeit.getDate()) + '.' + zwei(zeit.getMonth() + 1) + '.' + zeit.getFullYear()
2963
- + ' ' + zwei(zeit.getHours()) + ':' + zwei(zeit.getMinutes());
3094
+ // „Heute, 00:26", „Gestern, 22:58", sonst „06.10.2026, 17:42" (E109), lokale Zeit.
3095
+ var uhr = zwei(zeit.getHours()) + ':' + zwei(zeit.getMinutes());
3096
+ var jetzt = new Date();
3097
+ var tagStart = function (d) { return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); };
3098
+ var etikett = tagStart(zeit) === tagStart(jetzt) ? 'Heute, ' + uhr
3099
+ : tagStart(zeit) === new Date(jetzt.getFullYear(), jetzt.getMonth(), jetzt.getDate() - 1).getTime() ? 'Gestern, ' + uhr
3100
+ : zwei(zeit.getDate()) + '.' + zwei(zeit.getMonth() + 1) + '.' + zeit.getFullYear() + ', ' + uhr;
2964
3101
  block.appendChild(e('span', 'etikett', etikett));
2965
3102
  var ul = e('ul');
3103
+ // Erst die Quellengruppen, dann die übrigen Ereignisse, zuletzt die Sammelzeile (E109).
3104
+ var gruppen = verlaufQuellenGruppen(ul, eintrag.ereignisse);
3105
+ var andere = gruppen.gruppen > 0;
2966
3106
  eintrag.ereignisse.forEach(function (ereignis) {
3107
+ if (ereignis.art === 'checkout') return;
3108
+ andere = true;
2967
3109
  var li = e('li');
2968
3110
  verlaufEreignisZeile(li, ereignis);
2969
3111
  ul.appendChild(li);
2970
3112
  });
3113
+ if (gruppen.ohne.length > 0) {
3114
+ ul.appendChild(e('li', 'sammelzeile', (andere ? 'Außerdem neuer' : 'Neuer') + ' Stand bei '
3115
+ + gruppen.ohne.join(', ') + ', ohne Änderung an deinen Skills.'));
3116
+ }
2971
3117
  block.appendChild(ul);
2972
3118
  box.appendChild(block);
2973
3119
  });
@@ -490,6 +490,11 @@ img.hsymbol{border-radius:3px}
490
490
  .block .marke-ok{color:var(--emaille-hoch);font-weight:700}
491
491
  .block .marke-aus{color:var(--messing-tief);font-weight:700}
492
492
  .block .pfad{color:var(--tinte-weich)}
493
+ /* E109: je Quelle eine Gruppe mit Neu/Entfernt/Geändert, Sammelzeile für Quellen ohne Skill-Änderung. */
494
+ .block li.quellengruppe{display:block}
495
+ .block .gruppenzeilen{margin-top:5px;gap:3px;padding-left:13px;border-left:2px solid var(--strich)}
496
+ .block .gruppenzeilen li{grid-template-columns:5.5em minmax(0,1fr);gap:10px}
497
+ .block li.sammelzeile{display:block;color:var(--tinte-weich)}
493
498
  /* E108: Änderungen unter „hmgr X installiert", aufklappbar wie die Notizen der MCP-Karte. */
494
499
  .block .aenderungen-platz{grid-column:1 / -1}
495
500
  .block .aenderungen-platz:empty{display:none}
@@ -0,0 +1,58 @@
1
+ // Beobachter für das CLI-Signal (E110), ohne Electron-Import (testbar wie beobachter.ts). Beobachtet
2
+ // wird das Verzeichnis hmgrHome mit Filter auf `gui-signal.json` (aus demselben Grund wie in
3
+ // beobachter.ts: die Datei wird per rename ersetzt, ein Watcher auf die Datei würde taub).
4
+ // Anders als beobachter.ts kein Entprellen und kein Inhaltsvergleich: jedes frische Signal zählt.
5
+ import { watch } from 'node:fs';
6
+ import { SIGNAL_DATEI, signalLesen } from "../adapter/gui-signal.js";
7
+ export function signalBeobachten(hmgrHome, callback, optionen = {}) {
8
+ const sammelnMs = optionen.sammelnMs ?? 30;
9
+ const maxAlterMs = optionen.maxAlterMs ?? 10_000;
10
+ let timer;
11
+ let geschlossen = false;
12
+ let zuletzt;
13
+ // Eine beim Start vorhandene Datei gilt als schon gesehen. Das Alter allein reicht nicht: auf macOS
14
+ // liefert fs.watch kurz nach dem Anlegen auch Events für Schreibvorgänge kurz davor.
15
+ const startStand = signalLesen(hmgrHome).then((signal) => {
16
+ if (signal !== undefined)
17
+ zuletzt = `${signal.zeit}\u0000${signal.pfad}`;
18
+ });
19
+ async function auswerten() {
20
+ await startStand;
21
+ const signal = await signalLesen(hmgrHome);
22
+ if (geschlossen || signal === undefined)
23
+ return;
24
+ if (Date.now() - signal.zeit > maxAlterMs)
25
+ return;
26
+ const schluessel = `${signal.zeit}\u0000${signal.pfad}`;
27
+ if (schluessel === zuletzt)
28
+ return;
29
+ zuletzt = schluessel;
30
+ callback(signal.pfad);
31
+ }
32
+ let watcher;
33
+ try {
34
+ watcher = watch(hmgrHome, (_ereignis, name) => {
35
+ if (geschlossen)
36
+ return;
37
+ if (name !== null && name !== SIGNAL_DATEI)
38
+ return;
39
+ if (timer !== undefined)
40
+ return; // schon ein Lesen eingeplant
41
+ timer = setTimeout(() => {
42
+ timer = undefined;
43
+ void auswerten();
44
+ }, sammelnMs);
45
+ });
46
+ }
47
+ catch {
48
+ // Verzeichnis fehlt: unbeobachtet.
49
+ }
50
+ return {
51
+ close() {
52
+ geschlossen = true;
53
+ if (timer !== undefined)
54
+ clearTimeout(timer);
55
+ watcher?.close();
56
+ },
57
+ };
58
+ }
@@ -1322,7 +1322,8 @@ export class Anwendungsfaelle {
1322
1322
  hinweis = `Kein Zeiger unter ${verzeichnis}, alles bleibt lokal (E26).`;
1323
1323
  }
1324
1324
  const zustand = await this.p.zustand.lesen();
1325
- if (!zustand.arbeitsverzeichnisse.includes(verzeichnis))
1325
+ const neu = !zustand.arbeitsverzeichnisse.includes(verzeichnis);
1326
+ if (neu)
1326
1327
  zustand.arbeitsverzeichnisse.push(verzeichnis);
1327
1328
  if (zeigerPfad !== undefined) {
1328
1329
  const gebunden = zeigerPfad;
@@ -1338,6 +1339,7 @@ export class Anwendungsfaelle {
1338
1339
  await this.p.zustand.schreiben(zustand);
1339
1340
  return {
1340
1341
  pfad: verzeichnis,
1342
+ neu,
1341
1343
  ...(zeigerPfad === undefined ? {} : { zeigerPfad }),
1342
1344
  ...(hinweis === undefined ? {} : { hinweis }),
1343
1345
  };
@@ -1931,10 +1933,14 @@ export class Anwendungsfaelle {
1931
1933
  // Der Quellen-Port baut Checkouts von der Platte (remote.origin.url), ohne den Zustand zu
1932
1934
  // befragen: der Anzeigename fehlt dort. Für den Bericht kommt er aus der geführten Quelle
1933
1935
  // (E59), sonst zeigte eine umbenannte Quelle hier wieder ihren abgeleiteten Namen.
1936
+ const { neu, entfernt } = await this.neueUndEntfernteSkills(checkout, aktualisierung);
1937
+ const geaendert = await this.geaenderteSkills(checkout, aktualisierung);
1934
1938
  checkouts.push({
1935
1939
  checkout: { ...checkout, quelle: quelleMitNamen(checkout.quelle, gefuehrt.name) },
1936
1940
  aktualisierung,
1937
- geaenderteSkills: await this.geaenderteSkills(checkout, aktualisierung),
1941
+ geaenderteSkills: geaendert.filter((name) => !neu.includes(name)),
1942
+ neueSkills: neu,
1943
+ entfernteSkills: entfernt,
1938
1944
  });
1939
1945
  // `aktualisieren` holt für einen Checkout dieselben Refs wie `refsNachziehen` (fetch auf dem
1940
1946
  // Basis-Clone). Ist das gelaufen, braucht der Bundle-Abgleich unten die Quelle nicht ein
@@ -2003,7 +2009,7 @@ export class Anwendungsfaelle {
2003
2009
  */
2004
2010
  aktualisierungsEreignisse(checkouts, pluginFehler, bundles, aufraeumen, doctorErgebnis) {
2005
2011
  const ereignisse = [];
2006
- for (const { checkout, aktualisierung, geaenderteSkills } of checkouts) {
2012
+ for (const { checkout, aktualisierung, geaenderteSkills, neueSkills, entfernteSkills } of checkouts) {
2007
2013
  if (aktualisierung.ergebnis === 'aktualisiert')
2008
2014
  ereignisse.push({
2009
2015
  art: 'checkout',
@@ -2012,6 +2018,8 @@ export class Anwendungsfaelle {
2012
2018
  von: aktualisierung.von,
2013
2019
  nach: aktualisierung.nach,
2014
2020
  skills: geaenderteSkills,
2021
+ neu: neueSkills,
2022
+ entfernt: entfernteSkills,
2015
2023
  });
2016
2024
  else if (aktualisierung.ergebnis === 'fehlgeschlagen')
2017
2025
  ereignisse.push({
@@ -2138,6 +2146,30 @@ export class Anwendungsfaelle {
2138
2146
  namen.add(skill.name);
2139
2147
  return [...namen].sort((a, b) => a.localeCompare(b, 'de'));
2140
2148
  }
2149
+ /**
2150
+ * Neue und entfernte Skills zwischen altem und neuem Commit (E109): Vergleich der Skill-Namen
2151
+ * (E32) von `skills(quelle, von)` und `skills(quelle, nach)`, für alle Skills der Quelle. Eine
2152
+ * Umbenennung erscheint als beides. Scheitert einer der beiden Aufrufe, bleiben beide Listen
2153
+ * leer: der Bericht wird ungenauer, der Lauf nicht kaputt.
2154
+ */
2155
+ async neueUndEntfernteSkills(checkout, aktualisierung) {
2156
+ const leer = { neu: [], entfernt: [] };
2157
+ if (aktualisierung.ergebnis !== 'aktualisiert')
2158
+ return leer;
2159
+ const [vorher, nachher] = await Promise.all([
2160
+ this.p.quellen.skills(checkout.quelle, aktualisierung.von).catch(() => undefined),
2161
+ this.p.quellen.skills(checkout.quelle, aktualisierung.nach).catch(() => undefined),
2162
+ ]);
2163
+ if (vorher === undefined || nachher === undefined)
2164
+ return leer;
2165
+ const alt = new Set(vorher.map((s) => s.name));
2166
+ const neuNamen = new Set(nachher.map((s) => s.name));
2167
+ const sortiert = (namen) => [...namen].sort((a, b) => a.localeCompare(b, 'de'));
2168
+ return {
2169
+ neu: sortiert([...neuNamen].filter((n) => !alt.has(n))),
2170
+ entfernt: sortiert([...alt].filter((n) => !neuNamen.has(n))),
2171
+ };
2172
+ }
2141
2173
  /**
2142
2174
  * Pfade, in die ein Link zeigt. Gelesen wird wie beim Ist-Zustand (E19): die Ziele der
2143
2175
  * vorhandenen Harnesses, die Arbeitsverzeichnisse aus dem Zustand. Ein Link, dessen Quelle nicht
@@ -270,6 +270,24 @@ export function quelleMitNamen(quelle, name) {
270
270
  return quelle;
271
271
  return quelle.art === 'git' ? { art: 'git', url: quelle.url, name } : { art: 'lokal', pfad: quelle.pfad, name };
272
272
  }
273
+ /**
274
+ * Anzeigename einer Quelle im Verlauf (E109), aus dem Text, den das Ereignis trägt (URL oder Pfad,
275
+ * `quelleAlsText`). Gefunden wird die Quelle in der Quellenliste über die Identität (E36), damit
276
+ * SSH- und HTTPS-Schreibweise derselben Quelle zusammenfallen; beide Quellenarten werden probiert,
277
+ * weil der Text nicht verrät, was er war. Fehlt die Quelle oder hat sie keinen Namen, steht
278
+ * `besitzer/repo` aus der URL, bei einer lokalen Quelle (oder einer URL ohne Besitzer) der letzte
279
+ * Pfadteil. GUI und CLI nutzen dieselbe Regel (der Renderer baut sie nach, renderer.js).
280
+ */
281
+ export function quelleVerlaufName(text, quellen) {
282
+ const schluessel = new Set([quelleSchluessel({ art: 'git', url: text }), quelleSchluessel({ art: 'lokal', pfad: text })]);
283
+ const gefunden = quellen.find((q) => schluessel.has(quelleSchluessel(q)));
284
+ if (gefunden?.name !== undefined && gefunden.name.trim().length > 0)
285
+ return gefunden.name;
286
+ const ort = urlOrt(text);
287
+ if (ort !== undefined && ort.besitzer !== '_')
288
+ return `${ort.besitzer}/${ort.repo}`;
289
+ return ort !== undefined ? ort.repo : letztesSegment(text);
290
+ }
273
291
  /** Ein Anzeigename, den schon eine andere geführte Quelle trägt (E59): Namen sind eindeutig. */
274
292
  export class QuelleNameBelegt extends Error {
275
293
  angefragterName;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hmgr",
3
- "version": "0.0.41",
3
+ "version": "0.0.43",
4
4
  "description": "Harness Manager: Skills und Plugins für AI-Harnesses verwalten",
5
5
  "license": "MIT",
6
6
  "author": "Tim Kaufmann",