hmgr 0.0.41 → 0.0.42

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.
@@ -1,6 +1,10 @@
1
1
  {
2
2
  "fassung": 1,
3
3
  "versionen": {
4
+ "0.0.42": [
5
+ "Der Verlauf meldet jetzt auch neue und entfernte Skills einer Quelle, nicht nur geänderte.",
6
+ "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."
7
+ ],
4
8
  "0.0.41": [
5
9
  "Im Verlauf steht unter „hmgr … installiert\" jetzt aufklappbar, was sich in der neuen Version geändert hat."
6
10
  ]
package/dist/cli/hilfe.js CHANGED
@@ -496,10 +496,16 @@ damit nichts verloren geht. Lokale Quellen haben keinen Checkout und bleiben unb
496
496
  Quelle ohne eigenen Checkout wird dabei aufgefrischt (E85): ihr Katalog zeigt danach, was neu im
497
497
  Remote liegt, auch ohne dass irgendwo ein Checkout von ihr existiert.
498
498
 
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.
499
+ Aktualisierte Checkouts erscheinen je Quelle als Gruppe (E109): der Name der Quelle aus
500
+ hmgr source list, darunter „Neu", „Entfernt" und „Geändert" mit den Skill-Namen, leere Zeilen
501
+ fallen weg. Neu und entfernt gelten für alle Skills der Quelle (der Vergleich von altem und neuem
502
+ Commit nach dem Namen aus der SKILL.md, eine Umbenennung zeigt beides), „Geändert" nur für Skills,
503
+ die in diesem Checkout ausgecheckt sind (E70). Liegt der Checkout auf einem anderen Ref als main
504
+ oder master, steht er in Klammern hinter dem Namen, etwa „cmux (nightly)". Quellen, bei denen sich
505
+ an Skills nichts getan hat (README, CI), nennt eine Zeile am Ende des Berichts: „Außerdem neuer
506
+ Stand bei cmux, ohne Änderung an deinen Skills." Commit-Kürzel zeigt update nicht mehr, sie stehen
507
+ in ~/.hmgr/verlauf.json. Ist eine Quelle nicht mehr in der Quellenliste, steht ihr Pfad
508
+ besitzer/repo statt des Namens.
503
509
 
504
510
  Im selben Lauf gleicht update die installierten Bundles gegen ihre Definitionen ab (E69): eine
505
511
  Definition, die inzwischen ein Mitglied mehr oder weniger nennt, wird in jedem Ziel nachgezogen, in
@@ -557,12 +563,18 @@ die hmgr update schreibt und die GUI bei jedem automatischen und angestoßenen L
557
563
  liest. Ein Lauf ohne Ereignis (nichts Neues) steht nicht drin, die Datei ist ein Changelog, keine
558
564
  vollständige Historie — sie hält die letzten 20 Einträge.
559
565
 
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).
566
+ Je Eintrag steht eine Kopfzeile mit Datum und Uhrzeit („Heute, 00:26", „Gestern, 22:58", sonst
567
+ „06.10.2026, 17:42"), darunter die Ereignisse. Aktualisierte Checkouts stehen je Quelle als Gruppe
568
+ (E109): der Name der Quelle, darunter „Neu", „Entfernt" und „Geändert" mit Skill-Namen, leere
569
+ Zeilen fallen weg; ein Ref außer main oder master steht in Klammern hinter dem Namen. Neu und
570
+ entfernt gelten für alle Skills der Quelle (eine Umbenennung zeigt beides), „Geändert" nur für
571
+ ausgecheckte Skills (E70). Quellen ohne Skill-Änderung sammelt eine Zeile am Ende des Laufs
572
+ („Außerdem neuer Stand bei cmux, ohne Änderung an deinen Skills."). Dazwischen die übrigen
573
+ Ereignisse, je eine Zeile: ein nachgezogenes Bundle mit gelegten und entfernten Links, ein
574
+ aufgeräumter (gelöschter) Checkout, ein Fehler zu einer Quelle oder einem Bundle, dazu
575
+ hmgr-Ereignisse: ein Versionssprung von hmgr selbst (GUI oder CLI, erkannt beim Start bzw. bei
576
+ jedem CLI-Aufruf) oder ein gefundenes Update, das noch nicht installiert ist. Einträge aus der
577
+ Zeit vor E109 zeigen nur „Geändert". Plugins stehen nicht im Verlauf (siehe hmgr help update).
566
578
 
567
579
  Unter einem „installiert"-Eintrag von hmgr stehen die Änderungen: je Version seit der zuvor
568
580
  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";
@@ -764,16 +765,27 @@ export async function ausfuehren(argv, umgebung, optionen = {}) {
764
765
  const { checkouts: liste, pluginFehler, bundles, aufraeumen, doctor, } = await anwendungsfaelle.aktualisieren({ ausloeser: 'cli', ...(nurQuelle === undefined ? {} : { quelle: nurQuelle }) });
765
766
  if (liste.length === 0)
766
767
  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')
768
+ const updateQuellen = liste.length === 0 ? [] : await anwendungsfaelle.quellen();
769
+ // Aktualisierte Checkouts als Quellengruppen, ohne Commit-Kürzel (E109, F125); Quellen ohne
770
+ // Skill-Änderung sammelt die Zeile am Ende. Die übrigen Zeilenarten nennen die Quelle beim Namen.
771
+ const { zeilen: updateGruppen, ohneAenderung: updateOhne } = checkoutGruppen(liste
772
+ .filter(({ aktualisierung }) => aktualisierung.ergebnis === 'aktualisiert')
773
+ .map(({ checkout, geaenderteSkills, neueSkills, entfernteSkills }) => ({
774
+ quelle: quelleAlsText(checkout.quelle),
775
+ ref: checkout.ref,
776
+ neu: neueSkills,
777
+ entfernt: entfernteSkills,
778
+ geaendert: geaenderteSkills,
779
+ })), updateQuellen);
780
+ for (const zeile of updateGruppen)
781
+ console.log(zeile);
782
+ let updateAndere = updateGruppen.length > 0;
783
+ for (const { checkout, aktualisierung } of liste) {
784
+ if (aktualisierung.ergebnis === 'aktualisiert')
785
+ continue;
786
+ updateAndere = true;
787
+ const kopf = quellenAnzeigeName({ quelle: quelleAlsText(checkout.quelle), ref: checkout.ref }, updateQuellen);
788
+ if (aktualisierung.ergebnis === 'unveraendert')
777
789
  console.log(` unverändert ${kopf}`);
778
790
  else if (aktualisierung.ergebnis === 'uebersprungen')
779
791
  console.log(` übersprungen ${kopf} lokale Änderung (E15)`);
@@ -807,6 +819,14 @@ export async function ausfuehren(argv, umgebung, optionen = {}) {
807
819
  else
808
820
  console.log(` liegen lassen Arbeitsverzeichnis ${pfad} (nicht mehr auf der Platte, ohne Bestätigung nicht vergessen)`);
809
821
  }
822
+ // Die Sammelzeile steht ganz am Ende des Berichts (E109); „Außerdem" nur, wenn sonst etwas im Lauf stand.
823
+ const updateSammel = sammelZeile(updateOhne, !updateAndere &&
824
+ pluginFehler.length === 0 &&
825
+ bundleBericht(bundles).length === 0 &&
826
+ aufraeumen.verwaiste.length === 0 &&
827
+ doctor.fehlendeArbeitsverzeichnisse.length === 0);
828
+ if (updateSammel !== undefined)
829
+ console.log(updateSammel);
810
830
  // Zuletzt hmgr selbst (E77): wer schon aktualisiert, will auch wissen, dass sein hmgr alt ist.
811
831
  // Verglichen wird die eigene Version (E72) gegen npm, gemeldet wird nur — installiert wird
812
832
  // nichts. Offline oder Registry unerreichbar heißt keine Zeile, still: der Lauf selbst ist
@@ -858,9 +878,28 @@ export async function ausfuehren(argv, umgebung, optionen = {}) {
858
878
  const eintraege = await anwendungsfaelle.verlauf();
859
879
  if (eintraege.length === 0)
860
880
  console.log('Noch keine Ereignisse.');
881
+ const quellenListe = eintraege.length === 0 ? [] : await anwendungsfaelle.quellen();
861
882
  for (const eintrag of eintraege) {
862
883
  console.log(verlaufZeitpunkt(eintrag.zeit));
884
+ // Erst die Quellengruppen, dann die übrigen Ereignisse, zuletzt die Sammelzeile (E109).
885
+ const vermerke = [];
886
+ for (const ereignis of eintrag.ereignisse)
887
+ if (ereignis.art === 'checkout')
888
+ vermerke.push({
889
+ quelle: ereignis.quelle,
890
+ ref: ereignis.ref,
891
+ neu: ereignis.neu ?? [],
892
+ entfernt: ereignis.entfernt ?? [],
893
+ geaendert: ereignis.skills,
894
+ });
895
+ const { zeilen: gruppenZeilen, ohneAenderung } = checkoutGruppen(vermerke, quellenListe);
896
+ for (const zeile of gruppenZeilen)
897
+ console.log(zeile);
898
+ let andere = gruppenZeilen.length > 0;
863
899
  for (const ereignis of eintrag.ereignisse) {
900
+ if (ereignis.art === 'checkout')
901
+ continue;
902
+ andere = true;
864
903
  console.log(` ${verlaufEreignisZeile(ereignis)}`);
865
904
  // Unter einem Versionssprung die Commits der neuen Version(en), eingerückt (E108).
866
905
  if (ereignis.art === 'hmgr-installiert') {
@@ -869,6 +908,9 @@ export async function ausfuehren(argv, umgebung, optionen = {}) {
869
908
  console.log(zeile);
870
909
  }
871
910
  }
911
+ const sammel = sammelZeile(ohneAenderung, !andere);
912
+ if (sammel !== undefined)
913
+ console.log(sammel);
872
914
  }
873
915
  return 0;
874
916
  }
@@ -1093,24 +1135,12 @@ function bundleBericht(eintraege) {
1093
1135
  }
1094
1136
  return zeilen;
1095
1137
  }
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
1138
  /** Je Version die Nummer und darunter ihre Commit-Betreffs, tiefer eingerückt als die Ereigniszeile (E108). */
1104
1139
  function aenderungsZeilen(aenderungen) {
1105
1140
  return aenderungen.flatMap((a) => [` ${a.version}`, ...a.commits.map((c) => ` ${c}`)]);
1106
1141
  }
1107
- /** Eine Ereigniszeile im Verlauf (E80), eingerückt unter der Kopfzeile ihres Laufs. */
1142
+ /** Eine Ereigniszeile im Verlauf (E80), eingerückt unter der Kopfzeile ihres Laufs. Checkouts stellt `checkoutGruppen` dar (E109). */
1108
1143
  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
1144
  if (ereignis.art === 'bundle') {
1115
1145
  const teile = [
1116
1146
  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
@@ -275,6 +275,8 @@ function registriereIpc() {
275
275
  art: quelle.art,
276
276
  text: quelleAlsText(quelle),
277
277
  name: quelleName(quelle),
278
+ // Nur der gespeicherte Name (E109): der Verlauf braucht den Unterschied zum abgeleiteten.
279
+ gespeichert: quelle.name,
278
280
  }))));
279
281
  // Quelle-Verwaltung in den Einstellungen (E54, E59): Text-zu-Quelle passiert im Main-Prozess, der
280
282
  // Renderer kennt nur Zeichenketten. Der Anzeigename kommt serverseitig mit, damit dem Renderer nie
@@ -286,6 +288,7 @@ function registriereIpc() {
286
288
  art: ergebnis.quelle.art,
287
289
  text: quelleAlsText(ergebnis.quelle),
288
290
  name: quelleName(ergebnis.quelle),
291
+ gespeichert: ergebnis.quelle.name,
289
292
  },
290
293
  skills: ergebnis.skills,
291
294
  marketplace: ergebnis.marketplace,
@@ -295,7 +298,7 @@ function registriereIpc() {
295
298
  ipcMain.handle('hmgr:quelleEntfernen', async (_event, text) => auffangen(async () => anwendungsfaelle.quelleEntfernen(quelleAusText(text))));
296
299
  ipcMain.handle('hmgr:quelleUmbenennen', async (_event, text, name) => auffangen(async () => {
297
300
  const umbenannt = await anwendungsfaelle.quelleUmbenennen(quelleAusText(text), name);
298
- return { art: umbenannt.art, text: quelleAlsText(umbenannt), name: quelleName(umbenannt) };
301
+ return { art: umbenannt.art, text: quelleAlsText(umbenannt), name: quelleName(umbenannt), gespeichert: umbenannt.name };
299
302
  }));
300
303
  // Aufräum-Angebot nach dem Entfernen einer Quelle (E58): Inventar vor dem eigentlichen Entfernen
301
304
  // erheben (der Renderer ruft das, bevor er quelleEntfernen aufruft), dann bei Zustimmung abräumen.
@@ -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'),
@@ -2894,17 +2908,11 @@
2894
2908
 
2895
2909
  /**
2896
2910
  * 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).
2911
+ * und die CLI-Ausgabe (verlaufEreignisZeile, src/cli/main.ts). Checkouts stellt
2912
+ * verlaufQuellenGruppen dar (E109).
2898
2913
  */
2899
2914
  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') {
2915
+ if (ereignis.art === 'bundle') {
2908
2916
  li.appendChild(e('span', 'marke-ok', 'Bundle'));
2909
2917
  var teile = [];
2910
2918
  if (ereignis.gelegt.length > 0) teile.push('gelegt ' + ereignis.gelegt.join(', '));
@@ -2942,6 +2950,89 @@
2942
2950
  }
2943
2951
  }
2944
2952
 
2953
+ /**
2954
+ * Anzeigename einer Quelle im Verlauf (E109). Baut quelleVerlaufName (src/kern/modell.ts) nach,
2955
+ * der Renderer kann die Kern-Datei nicht importieren: Quelle aus QUELLEN über die Identität
2956
+ * (Host/Besitzer/Repo, E36) suchen; fehlt sie oder hat sie keinen Namen, `besitzer/repo` aus der
2957
+ * URL, bei einer lokalen Quelle der letzte Pfadteil. Ein Ref außer main/master steht in Klammern.
2958
+ */
2959
+ function verlaufQuellenOrt(text) {
2960
+ var t = text.trim().replace(/\/+$/, '');
2961
+ var segmente = null;
2962
+ var host = '';
2963
+ var m;
2964
+ if (t.indexOf('://') >= 0) {
2965
+ try {
2966
+ var u = new URL(t);
2967
+ segmente = decodeURIComponent(u.pathname).split('/');
2968
+ host = u.protocol === 'file:' ? 'local' : u.hostname;
2969
+ } catch (x) { return null; }
2970
+ } else if (!/^[A-Za-z]:[\\/]/.test(t) && (m = /^(?:[^@/]+@)?([^@/:]+):(.+)$/.exec(t))) {
2971
+ host = m[1];
2972
+ segmente = m[2].split('/');
2973
+ } else {
2974
+ return null;
2975
+ }
2976
+ segmente = segmente.filter(function (x) { return x.length > 0; });
2977
+ var repo = (segmente[segmente.length - 1] || 'repo').replace(/\.git$/, '');
2978
+ return { host: host.toLowerCase(), besitzer: segmente[segmente.length - 2] || '_', repo: repo };
2979
+ }
2980
+ function verlaufQuellenName(text, ref) {
2981
+ var ort = verlaufQuellenOrt(text);
2982
+ var schluessel = ort === null ? text : ort.host + '/' + ort.besitzer + '/' + ort.repo;
2983
+ var gefunden = QUELLEN.filter(function (q) {
2984
+ var qt = q.text;
2985
+ var qo = verlaufQuellenOrt(qt);
2986
+ return (qo === null ? qt : qo.host + '/' + qo.besitzer + '/' + qo.repo) === schluessel;
2987
+ })[0];
2988
+ var name;
2989
+ if (gefunden && gefunden.gespeichert && gefunden.gespeichert.trim().length > 0) name = gefunden.gespeichert;
2990
+ else if (ort !== null && ort.besitzer !== '_') name = ort.besitzer + '/' + ort.repo;
2991
+ else if (ort !== null) name = ort.repo;
2992
+ else {
2993
+ var teile = text.replace(/[\/\\]+$/, '').split(/[\/\\]/).filter(function (x) { return x.length > 0; });
2994
+ name = teile[teile.length - 1] || text;
2995
+ }
2996
+ return ref === 'main' || ref === 'master' ? name : name + ' (' + ref + ')';
2997
+ }
2998
+
2999
+ /**
3000
+ * Die Checkout-Ereignisse eines Laufs (E109): je Quelle mit Skill-Änderung eine Gruppe (Name,
3001
+ * darunter Neu/Entfernt/Geändert, leere fallen weg), dieselbe Darstellung wie `hmgr log`
3002
+ * (src/cli/verlauf-anzeige.ts). Gibt die Namen der Quellen ohne Skill-Änderung zurück, aus
3003
+ * denen der Aufrufer die Sammelzeile baut; die Gruppen hängt sie an `ul` an.
3004
+ */
3005
+ function verlaufQuellenGruppen(ul, ereignisse) {
3006
+ var ohne = [];
3007
+ var gruppen = 0;
3008
+ ereignisse.forEach(function (ereignis) {
3009
+ if (ereignis.art !== 'checkout') return;
3010
+ var name = verlaufQuellenName(ereignis.quelle, ereignis.ref);
3011
+ var teile = [
3012
+ ['Neu', ereignis.neu || []],
3013
+ ['Entfernt', ereignis.entfernt || []],
3014
+ ['Geändert', ereignis.skills || []],
3015
+ ].filter(function (t) { return t[1].length > 0; });
3016
+ if (teile.length === 0) {
3017
+ if (ohne.indexOf(name) < 0) ohne.push(name);
3018
+ return;
3019
+ }
3020
+ gruppen += 1;
3021
+ var li = e('li', 'quellengruppe');
3022
+ li.appendChild(e('b', null, name));
3023
+ var innen = e('ul', 'gruppenzeilen');
3024
+ teile.forEach(function (t) {
3025
+ var zeile = e('li');
3026
+ zeile.appendChild(e('span', 'marke-ok', t[0]));
3027
+ zeile.appendChild(e('span', 'pfad', t[1].join(', ')));
3028
+ innen.appendChild(zeile);
3029
+ });
3030
+ li.appendChild(innen);
3031
+ ul.appendChild(li);
3032
+ });
3033
+ return { ohne: ohne, gruppen: gruppen };
3034
+ }
3035
+
2945
3036
  /**
2946
3037
  * Baut das Changelog in der Update-Ansicht aus ~/.hmgr/verlauf.json (E80): ein Block je Lauf,
2947
3038
  * neueste zuerst, das Etikett der lokale Zeitpunkt, darunter eine Zeile je Ereignis. Die Datei
@@ -2959,15 +3050,29 @@
2959
3050
  var block = e('div', 'block');
2960
3051
  var zeit = new Date(eintrag.zeit);
2961
3052
  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());
3053
+ // „Heute, 00:26", „Gestern, 22:58", sonst „06.10.2026, 17:42" (E109), lokale Zeit.
3054
+ var uhr = zwei(zeit.getHours()) + ':' + zwei(zeit.getMinutes());
3055
+ var jetzt = new Date();
3056
+ var tagStart = function (d) { return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); };
3057
+ var etikett = tagStart(zeit) === tagStart(jetzt) ? 'Heute, ' + uhr
3058
+ : tagStart(zeit) === new Date(jetzt.getFullYear(), jetzt.getMonth(), jetzt.getDate() - 1).getTime() ? 'Gestern, ' + uhr
3059
+ : zwei(zeit.getDate()) + '.' + zwei(zeit.getMonth() + 1) + '.' + zeit.getFullYear() + ', ' + uhr;
2964
3060
  block.appendChild(e('span', 'etikett', etikett));
2965
3061
  var ul = e('ul');
3062
+ // Erst die Quellengruppen, dann die übrigen Ereignisse, zuletzt die Sammelzeile (E109).
3063
+ var gruppen = verlaufQuellenGruppen(ul, eintrag.ereignisse);
3064
+ var andere = gruppen.gruppen > 0;
2966
3065
  eintrag.ereignisse.forEach(function (ereignis) {
3066
+ if (ereignis.art === 'checkout') return;
3067
+ andere = true;
2967
3068
  var li = e('li');
2968
3069
  verlaufEreignisZeile(li, ereignis);
2969
3070
  ul.appendChild(li);
2970
3071
  });
3072
+ if (gruppen.ohne.length > 0) {
3073
+ ul.appendChild(e('li', 'sammelzeile', (andere ? 'Außerdem neuer' : 'Neuer') + ' Stand bei '
3074
+ + gruppen.ohne.join(', ') + ', ohne Änderung an deinen Skills.'));
3075
+ }
2971
3076
  block.appendChild(ul);
2972
3077
  box.appendChild(block);
2973
3078
  });
@@ -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}
@@ -1931,10 +1931,14 @@ export class Anwendungsfaelle {
1931
1931
  // Der Quellen-Port baut Checkouts von der Platte (remote.origin.url), ohne den Zustand zu
1932
1932
  // befragen: der Anzeigename fehlt dort. Für den Bericht kommt er aus der geführten Quelle
1933
1933
  // (E59), sonst zeigte eine umbenannte Quelle hier wieder ihren abgeleiteten Namen.
1934
+ const { neu, entfernt } = await this.neueUndEntfernteSkills(checkout, aktualisierung);
1935
+ const geaendert = await this.geaenderteSkills(checkout, aktualisierung);
1934
1936
  checkouts.push({
1935
1937
  checkout: { ...checkout, quelle: quelleMitNamen(checkout.quelle, gefuehrt.name) },
1936
1938
  aktualisierung,
1937
- geaenderteSkills: await this.geaenderteSkills(checkout, aktualisierung),
1939
+ geaenderteSkills: geaendert.filter((name) => !neu.includes(name)),
1940
+ neueSkills: neu,
1941
+ entfernteSkills: entfernt,
1938
1942
  });
1939
1943
  // `aktualisieren` holt für einen Checkout dieselben Refs wie `refsNachziehen` (fetch auf dem
1940
1944
  // Basis-Clone). Ist das gelaufen, braucht der Bundle-Abgleich unten die Quelle nicht ein
@@ -2003,7 +2007,7 @@ export class Anwendungsfaelle {
2003
2007
  */
2004
2008
  aktualisierungsEreignisse(checkouts, pluginFehler, bundles, aufraeumen, doctorErgebnis) {
2005
2009
  const ereignisse = [];
2006
- for (const { checkout, aktualisierung, geaenderteSkills } of checkouts) {
2010
+ for (const { checkout, aktualisierung, geaenderteSkills, neueSkills, entfernteSkills } of checkouts) {
2007
2011
  if (aktualisierung.ergebnis === 'aktualisiert')
2008
2012
  ereignisse.push({
2009
2013
  art: 'checkout',
@@ -2012,6 +2016,8 @@ export class Anwendungsfaelle {
2012
2016
  von: aktualisierung.von,
2013
2017
  nach: aktualisierung.nach,
2014
2018
  skills: geaenderteSkills,
2019
+ neu: neueSkills,
2020
+ entfernt: entfernteSkills,
2015
2021
  });
2016
2022
  else if (aktualisierung.ergebnis === 'fehlgeschlagen')
2017
2023
  ereignisse.push({
@@ -2138,6 +2144,30 @@ export class Anwendungsfaelle {
2138
2144
  namen.add(skill.name);
2139
2145
  return [...namen].sort((a, b) => a.localeCompare(b, 'de'));
2140
2146
  }
2147
+ /**
2148
+ * Neue und entfernte Skills zwischen altem und neuem Commit (E109): Vergleich der Skill-Namen
2149
+ * (E32) von `skills(quelle, von)` und `skills(quelle, nach)`, für alle Skills der Quelle. Eine
2150
+ * Umbenennung erscheint als beides. Scheitert einer der beiden Aufrufe, bleiben beide Listen
2151
+ * leer: der Bericht wird ungenauer, der Lauf nicht kaputt.
2152
+ */
2153
+ async neueUndEntfernteSkills(checkout, aktualisierung) {
2154
+ const leer = { neu: [], entfernt: [] };
2155
+ if (aktualisierung.ergebnis !== 'aktualisiert')
2156
+ return leer;
2157
+ const [vorher, nachher] = await Promise.all([
2158
+ this.p.quellen.skills(checkout.quelle, aktualisierung.von).catch(() => undefined),
2159
+ this.p.quellen.skills(checkout.quelle, aktualisierung.nach).catch(() => undefined),
2160
+ ]);
2161
+ if (vorher === undefined || nachher === undefined)
2162
+ return leer;
2163
+ const alt = new Set(vorher.map((s) => s.name));
2164
+ const neuNamen = new Set(nachher.map((s) => s.name));
2165
+ const sortiert = (namen) => [...namen].sort((a, b) => a.localeCompare(b, 'de'));
2166
+ return {
2167
+ neu: sortiert([...neuNamen].filter((n) => !alt.has(n))),
2168
+ entfernt: sortiert([...alt].filter((n) => !neuNamen.has(n))),
2169
+ };
2170
+ }
2141
2171
  /**
2142
2172
  * Pfade, in die ein Link zeigt. Gelesen wird wie beim Ist-Zustand (E19): die Ziele der
2143
2173
  * 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.42",
4
4
  "description": "Harness Manager: Skills und Plugins für AI-Harnesses verwalten",
5
5
  "license": "MIT",
6
6
  "author": "Tim Kaufmann",