hmgr 0.0.45 → 0.0.47

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.
@@ -659,7 +659,7 @@ export class Anwendungsfaelle {
659
659
  await this.p.ziele.linkEntfernen(ziel, verzeichnis, name);
660
660
  entfernteLinks.push(name);
661
661
  }
662
- if (Anwendungsfaelle.bundlesZerbrechen(nachZustand, ziel, entfernteLinks).length > 0)
662
+ if (Anwendungsfaelle.bundleVerweiseFallenLassen(nachZustand, ziel))
663
663
  bundlesGeaendert = true;
664
664
  const konfigurationZurueckgebaut = kannZurueckbauen(harness) ? await harness.zurueckbauen(ziel.ort) : false;
665
665
  zielErgebnisse.push({ ziel, entfernteLinks, konfigurationZurueckgebaut });
@@ -801,12 +801,12 @@ export class Anwendungsfaelle {
801
801
  throw new QuelleAufraeumenLokaleAenderungen(inventar.checkoutsMitLokalenAenderungen);
802
802
  for (const link of inventar.links)
803
803
  await this.p.ziele.linkEntfernen(link.ziel, link.verzeichnis, link.name);
804
- // Wo dabei ein Bundle-Mitglied fiel, zerbricht das Bundle in diesem Ziel (E69), wie beim
805
- // einzelnen `linkEntfernen`: die übrigen Links bleiben als Einzel-Installationen liegen.
804
+ // Wo dabei ein Bundle-Mitglied fiel, gilt es als herausgenommen (E114), wie beim einzelnen
805
+ // `linkEntfernen`: das Bundle bleibt, der Abgleich legt das Mitglied nicht wieder an.
806
806
  const zustand = await this.p.zustand.lesen();
807
807
  let bundlesGeaendert = false;
808
808
  for (const link of inventar.links)
809
- if (Anwendungsfaelle.bundlesZerbrechen(zustand, link.ziel, [link.name]).length > 0)
809
+ if (Anwendungsfaelle.bundleMitgliederHerausnehmen(zustand, link.ziel, [link.name]).length > 0)
810
810
  bundlesGeaendert = true;
811
811
  if (bundlesGeaendert)
812
812
  await this.p.zustand.schreiben(zustand);
@@ -1122,8 +1122,11 @@ export class Anwendungsfaelle {
1122
1122
  }
1123
1123
  return angelegt;
1124
1124
  }
1125
- /** Die Zugehörigkeit der Links eines Bundles im Ziel merken (E69), einen alten Eintrag ersetzend. */
1126
- async bundleInstallationMerken(ziel, bundle, skills) {
1125
+ /**
1126
+ * Die Zugehörigkeit der Links eines Bundles im Ziel merken (E69), einen alten Eintrag ersetzend.
1127
+ * `herausgenommen` (E114) wird nur geschrieben, wenn es etwas enthält.
1128
+ */
1129
+ async bundleInstallationMerken(ziel, bundle, skills, herausgenommen = []) {
1127
1130
  const zustand = await this.p.zustand.lesen();
1128
1131
  const schluessel = zielSchluessel(ziel);
1129
1132
  const eigener = bundleSchluessel(bundle);
@@ -1135,27 +1138,43 @@ export class Anwendungsfaelle {
1135
1138
  bundle: bundle.name,
1136
1139
  quelle: bundle.quelle,
1137
1140
  skills,
1141
+ ...(herausgenommen.length === 0 ? {} : { herausgenommen }),
1138
1142
  });
1139
1143
  await this.p.zustand.schreiben(zustand);
1140
1144
  }
1141
1145
  /**
1142
- * Zerbrechen (E69): Wird ein einzelner Link entfernt, der zu einem Bundle gehört, zerbricht das
1143
- * Bundle in diesem Ziel — die übrigen Links werden Einzel-Installationen, der Bundle-Verweis
1144
- * fällt weg. Ändert nur den übergebenen Zustand, geschrieben wird er vom Aufrufer, der ohnehin
1145
- * schreibt. Gibt die Namen der zerbrochenen Bundles zurück, damit die Oberfläche es sagen kann.
1146
+ * Herausnehmen (E114 L2, ersetzt das Zerbrechen aus E69): Wird ein einzelner Link entfernt, der
1147
+ * zu einem Bundle gehört, bleibt das Bundle im Ziel und merkt sich das Mitglied als herausgenommen
1148
+ * — auch wenn danach keines mehr verlinkt ist („0 von N", weiter entfernbar). Ändert nur den
1149
+ * übergebenen Zustand, geschrieben wird er vom Aufrufer. Gibt die Namen der betroffenen Bundles
1150
+ * zurück, damit die Oberfläche es sagen kann.
1146
1151
  */
1147
- static bundlesZerbrechen(zustand, ziel, namen) {
1152
+ static bundleMitgliederHerausnehmen(zustand, ziel, namen) {
1148
1153
  const schluessel = zielSchluessel(ziel);
1149
- const zerbrochen = [];
1150
- zustand.bundleInstallationen = zustand.bundleInstallationen.filter((installation) => {
1154
+ const betroffen = [];
1155
+ for (const installation of zustand.bundleInstallationen) {
1151
1156
  if (zielSchluessel(Anwendungsfaelle.installationZiel(installation)) !== schluessel)
1152
- return true;
1153
- if (!installation.skills.some((name) => namen.includes(name)))
1154
- return true;
1155
- zerbrochen.push(installation.bundle);
1156
- return false;
1157
- });
1158
- return zerbrochen;
1157
+ continue;
1158
+ const raus = installation.skills.filter((name) => namen.includes(name));
1159
+ if (raus.length === 0)
1160
+ continue;
1161
+ installation.skills = installation.skills.filter((name) => !raus.includes(name));
1162
+ installation.herausgenommen = [...(installation.herausgenommen ?? []), ...raus];
1163
+ betroffen.push(installation.bundle);
1164
+ }
1165
+ return betroffen;
1166
+ }
1167
+ /**
1168
+ * Bundle-Verweise eines Ziels fallen lassen, wenn „Säubern" dort alle Links abbaut (E69). Kein
1169
+ * Herausnehmen: Säubern räumt den Harness ab, ein Bundle mit „0 von N" bliebe ein Rest, den der
1170
+ * Nutzer gerade loswerden wollte. Darum fallen alle Verweise des Ziels, auch die von Bundles, von
1171
+ * denen gerade kein Link mehr lag.
1172
+ */
1173
+ static bundleVerweiseFallenLassen(zustand, ziel) {
1174
+ const schluessel = zielSchluessel(ziel);
1175
+ const vorher = zustand.bundleInstallationen.length;
1176
+ zustand.bundleInstallationen = zustand.bundleInstallationen.filter((installation) => zielSchluessel(Anwendungsfaelle.installationZiel(installation)) !== schluessel);
1177
+ return zustand.bundleInstallationen.length !== vorher;
1159
1178
  }
1160
1179
  /**
1161
1180
  * Bundle installieren (E69): das Bundle als Ganzes in ein Ziel. Erst wird alles geprüft — jedes
@@ -1205,9 +1224,11 @@ export class Anwendungsfaelle {
1205
1224
  return treffer[0];
1206
1225
  }
1207
1226
  /**
1208
- * Bundle entfernen (E69): alle seine Links im Ziel und den Bundle-Verweis. Entfernt wird direkt
1209
- * über den Ziele-Port, nicht über den Anwendungsfall `linkEntfernen` — der macht Zeiger-Arbeit
1210
- * (E57) und ließe das Bundle bei jedem einzelnen Link zerbrechen, das es hier gerade verlässt.
1227
+ * Bundle entfernen (E69): alle seine noch verlinkten Mitglieder im Ziel und den Bundle-Verweis,
1228
+ * auch nach Teil-Löschung (E114 L2): herausgenommene stehen nicht in `skills` und bleiben
1229
+ * unberührt, ein Link gleichen Namens an ihrer Stelle gehört nicht dem Bundle. Entfernt wird
1230
+ * direkt über den Ziele-Port, nicht über den Anwendungsfall `linkEntfernen` — der macht
1231
+ * Zeiger-Arbeit (E57) und nähme jedes Mitglied einzeln heraus.
1211
1232
  */
1212
1233
  async bundleEntfernen(args) {
1213
1234
  const harness = await this.harnessGeprueft(args.ziel.harness);
@@ -1237,6 +1258,11 @@ export class Anwendungsfaelle {
1237
1258
  * Wirkt auf das Bundle als Ganzes: Lässt sich eine Definitionsänderung nicht vollständig nachziehen
1238
1259
  * (Quelle weg, Pfad kein Skill mehr, Namenskonflikt), bleibt das Bundle im betroffenen Ziel auf dem
1239
1260
  * alten Stand und der Lauf meldet es, statt es halb nachzuziehen (F66b).
1261
+ *
1262
+ * Herausgenommene Mitglieder (E114 L5) lässt der Abgleich aus: kein Link, keine Konfliktprüfung
1263
+ * (ein anderer Link gleichen Namens an ihrer Stelle ist dann kein Konflikt). Fällt eines aus der
1264
+ * Definition, fällt es auch aus der Liste. `aufnehmen` holt herausgenommene zurück: 'alle' ist
1265
+ * „Vervollständigen" (L4), eine Namensliste „Wieder verlinken" einzelner Mitglieder (L3).
1240
1266
  */
1241
1267
  async bundleAbgleich(args) {
1242
1268
  const zustand = await this.p.zustand.lesen();
@@ -1256,7 +1282,10 @@ export class Anwendungsfaelle {
1256
1282
  // ein Fehlschlag (kein Netz) arbeitet mit dem vorhandenen Stand weiter.
1257
1283
  await this.refsNachziehen(installation.quelle);
1258
1284
  const bundle = await this.bundleAusQuelle(installation.bundle, installation.quelle);
1259
- const { funde, neue } = await this.bundleAufloesen(bundle);
1285
+ const { funde: alle, neue } = await this.bundleAufloesen(bundle);
1286
+ const aufnehmen = args?.aufnehmen;
1287
+ const herausgenommen = (installation.herausgenommen ?? []).filter((name) => aufnehmen !== 'alle' && !(aufnehmen ?? []).includes(name) && alle.some((f) => f.skill.name === name));
1288
+ const funde = alle.filter((f) => !herausgenommen.includes(f.skill.name));
1260
1289
  const verzeichnis = harness.zielVerzeichnis(ziel.ort);
1261
1290
  const { konflikte } = await this.bundleKonflikte(bundle, ziel, verzeichnis, funde);
1262
1291
  if (konflikte.length > 0)
@@ -1272,7 +1301,7 @@ export class Anwendungsfaelle {
1272
1301
  await this.p.ziele.linkEntfernen(ziel, verzeichnis, name);
1273
1302
  entfernt.push(name);
1274
1303
  }
1275
- await this.bundleInstallationMerken(ziel, bundle, namen);
1304
+ await this.bundleInstallationMerken(ziel, bundle, namen, herausgenommen);
1276
1305
  const gelegt = namen.filter((n) => !installation.skills.includes(n));
1277
1306
  ergebnis.push({
1278
1307
  ...kopf,
@@ -1358,19 +1387,101 @@ export class Anwendungsfaelle {
1358
1387
  * dieselbe Weiche wie bei `verwaisteAufraeumen`.
1359
1388
  */
1360
1389
  async doctor(args) {
1361
- const rueckfragen = args?.rueckfragen ?? true;
1390
+ const ergebnis = await this.doctorPruefen(args?.rueckfragen ?? true);
1391
+ // Nur die SOPS-Befunde kommen in den Verlauf (E115); fehlende Arbeitsverzeichnisse meldet der
1392
+ // alleinstehende doctor wie bisher nur auf der Konsole.
1393
+ await this.sopsVermerken(args?.ausloeser ?? 'cli', ergebnis.sops, true);
1394
+ return ergebnis;
1395
+ }
1396
+ /**
1397
+ * doctor ohne Verlauf, damit der Aktualisieren-Lauf die Befunde in seinen eigenen Eintrag nimmt.
1398
+ * Dazu die SOPS-Prüfung (E115): Checkouts geführter Quellen, deren SOPS-Datei noch verschlüsselt
1399
+ * im Arbeitsbaum liegt, schreibt der Quellen-Port neu, wo die Probe klappt. Sie ist keine
1400
+ * Rückfrage wert: die Datei entspricht dem Commit, nur entschlüsselt.
1401
+ */
1402
+ async doctorPruefen(rueckfragen) {
1362
1403
  const zustand = await this.p.zustand.lesen();
1363
1404
  const fehlend = [];
1364
1405
  for (const pfad of zustand.arbeitsverzeichnisse)
1365
1406
  if (!(await this.p.zeiger.verzeichnisExistiert(pfad)))
1366
1407
  fehlend.push(pfad);
1367
- if (fehlend.length === 0 || !rueckfragen)
1368
- return { fehlendeArbeitsverzeichnisse: fehlend, vergessen: [] };
1369
- if (!(await this.p.rueckfrage.bestaetigen(doctorFrage(fehlend))))
1370
- return { fehlendeArbeitsverzeichnisse: fehlend, vergessen: [] };
1371
- for (const pfad of fehlend)
1372
- await this.arbeitsverzeichnisVergessen(pfad);
1373
- return { fehlendeArbeitsverzeichnisse: fehlend, vergessen: [...fehlend] };
1408
+ let vergessen = [];
1409
+ if (fehlend.length > 0 && rueckfragen && (await this.p.rueckfrage.bestaetigen(doctorFrage(fehlend)))) {
1410
+ for (const pfad of fehlend)
1411
+ await this.arbeitsverzeichnisVergessen(pfad);
1412
+ vergessen = [...fehlend];
1413
+ }
1414
+ const sops = [];
1415
+ for (const checkout of await this.gefuehrteCheckouts(zustand.quellen)) {
1416
+ const reparaturen = await this.p.quellen.sopsReparieren(checkout).catch(() => []);
1417
+ for (const reparatur of reparaturen)
1418
+ sops.push({ checkout, ...reparatur });
1419
+ }
1420
+ return { fehlendeArbeitsverzeichnisse: fehlend, vergessen, sops };
1421
+ }
1422
+ /** Checkouts, deren Quelle der Zustand führt (E54), mit dem Anzeigenamen der geführten Quelle. */
1423
+ async gefuehrteCheckouts(quellen) {
1424
+ const bekannt = new Map(quellen.map((q) => [quelleSchluessel(q), q]));
1425
+ const ergebnis = [];
1426
+ for (const checkout of await this.p.quellen.checkouts()) {
1427
+ const gefuehrt = bekannt.get(quelleSchluessel(checkout.quelle));
1428
+ if (gefuehrt !== undefined)
1429
+ ergebnis.push({ ...checkout, quelle: quelleMitNamen(checkout.quelle, gefuehrt.name) });
1430
+ }
1431
+ return ergebnis;
1432
+ }
1433
+ /**
1434
+ * SOPS-Dateien, die im Arbeitsbaum eines Checkouts noch verschlüsselt liegen (E115), für
1435
+ * `hmgr status`. Liest nur, ruft kein sops auf und repariert nichts.
1436
+ */
1437
+ async sopsVerschluesselteDateien() {
1438
+ const ergebnis = [];
1439
+ for (const checkout of await this.gefuehrteCheckouts((await this.p.zustand.lesen()).quellen)) {
1440
+ const dateien = await this.p.quellen.sopsDateien(checkout).catch(() => []);
1441
+ for (const d of dateien)
1442
+ if (d.verschluesselt)
1443
+ ergebnis.push({ checkout, datei: d.datei });
1444
+ }
1445
+ return ergebnis;
1446
+ }
1447
+ /**
1448
+ * SOPS-Befunde als Verlaufsereignisse (E115). „Verschlüsselt" und ein gescheitertes Neuschreiben
1449
+ * kommen einmal je Datei und Grund (`gemerkt.sops`), „repariert" jedes Mal, wenn es geschieht.
1450
+ * `vollstaendig`: die Befunde decken alle Checkouts ab, dann ersetzen sie die gemerkte Liste;
1451
+ * sonst (ein einzelner Checkout beim Verlinken) kommen sie dazu.
1452
+ */
1453
+ async sopsEreignisse(befunde, vollstaendig) {
1454
+ const bisher = (await this.p.verlauf.lesen()).gemerkt.sops ?? [];
1455
+ const aktuell = [];
1456
+ const ereignisse = [];
1457
+ for (const befund of befunde) {
1458
+ const quelle = quelleAlsText(befund.checkout.quelle);
1459
+ const { ref } = befund.checkout;
1460
+ if (befund.ergebnis === 'repariert') {
1461
+ ereignisse.push({ art: 'sops-repariert', quelle, ref, datei: befund.datei });
1462
+ continue;
1463
+ }
1464
+ const art = befund.ergebnis === 'verschluesselt' ? befund.grund : 'fehlgeschlagen';
1465
+ const schluessel = `${quelle} @ ${ref}: ${befund.datei} | ${art}`;
1466
+ if (aktuell.includes(schluessel))
1467
+ continue;
1468
+ aktuell.push(schluessel);
1469
+ if (bisher.includes(schluessel))
1470
+ continue;
1471
+ if (befund.ergebnis === 'verschluesselt')
1472
+ ereignisse.push({ art: 'sops-verschluesselt', quelle, ref, datei: befund.datei, grund: befund.grund });
1473
+ else
1474
+ ereignisse.push({
1475
+ art: 'fehler',
1476
+ betrifft: `${quelle} @ ${ref}: ${befund.datei}`,
1477
+ fehler: `verschlüsselte Datei nicht neu geschrieben: ${befund.fehler}`,
1478
+ });
1479
+ }
1480
+ return { ereignisse, gemerkt: vollstaendig ? aktuell : [...new Set([...bisher, ...aktuell])] };
1481
+ }
1482
+ async sopsVermerken(ausloeser, befunde, vollstaendig) {
1483
+ const { ereignisse, gemerkt } = await this.sopsEreignisse(befunde, vollstaendig);
1484
+ await this.verlaufVermerken(ausloeser, ereignisse, gemerkt);
1374
1485
  }
1375
1486
  /**
1376
1487
  * Mehrere composer.json unter einem Verzeichnis (E26): hmgr rät nicht, es fragt. Bleibt die Frage
@@ -1451,7 +1562,11 @@ export class Anwendungsfaelle {
1451
1562
  throw new OrtNichtUnterstuetzt(harness.name, ziel.ort.art);
1452
1563
  const verzeichnis = harness.zielVerzeichnis(ziel.ort);
1453
1564
  // Die Prüfung auf einen fremden Eintrag macht der Ziele-Port unmittelbar vor dem Anlegen (E12).
1454
- const checkout = await this.p.quellen.checkout(fund.quelle, fund.ref, [fund.skill]);
1565
+ // Nur Quelle, Ref und Pfad gehen in den Link, die SOPS-Befunde nicht.
1566
+ const { sopsVerschluesselt, ...checkout } = await this.p.quellen.checkout(fund.quelle, fund.ref, [fund.skill]);
1567
+ // Verschlüsselt angekommene SOPS-Dateien (E115) gleich melden, nicht erst beim nächsten Lauf.
1568
+ if (sopsVerschluesselt.length > 0)
1569
+ await this.sopsVermerken('aktion', sopsVerschluesselt.map((v) => ({ checkout, datei: v.datei, ergebnis: 'verschluesselt', grund: v.grund })), false);
1455
1570
  await harness.vorbereiten(ziel.ort);
1456
1571
  await this.p.ziele.linkLegen(ziel, verzeichnis, fund.skill.name, checkout, fund.skill, ersetzen);
1457
1572
  return { ziel, name: fund.skill.name, checkout, skill: fund.skill };
@@ -1486,7 +1601,8 @@ export class Anwendungsfaelle {
1486
1601
  const version = args.version;
1487
1602
  if (version === undefined) {
1488
1603
  const fund = await this.skillFinden(args.skill, args.quelle);
1489
- return await this.linkAusFund(harness, args.ziel, fund, args.ersetzen, args.lokal);
1604
+ const ergebnis = await this.linkAusFund(harness, args.ziel, fund, args.ersetzen, args.lokal);
1605
+ return Anwendungsfaelle.hinweisAnhaengen(ergebnis, await this.bundleMitgliedZurueckholen(args.ziel, fund));
1490
1606
  }
1491
1607
  if (args.lokal === true || args.ziel.ort.art !== 'arbeitsverzeichnis')
1492
1608
  throw new EinschraenkungOhneZeiger(args.ziel, version);
@@ -1500,7 +1616,57 @@ export class Anwendungsfaelle {
1500
1616
  const link = await this.linkAnlegen(harness, args.ziel, fund, args.ersetzen);
1501
1617
  await this.zeigerErgaenzen(zeigerPfad, fund, version);
1502
1618
  const hinweis = await this.harnessHinweis(harness, args.ziel.ort);
1503
- return hinweis === undefined ? link : { ...link, hinweis };
1619
+ return Anwendungsfaelle.hinweisAnhaengen(hinweis === undefined ? link : { ...link, hinweis }, await this.bundleMitgliedZurueckholen(args.ziel, fund));
1620
+ }
1621
+ /** Weitere Hinweiszeilen an ein Link-Ergebnis hängen, ohne einen vorhandenen Hinweis zu verdrängen. */
1622
+ static hinweisAnhaengen(ergebnis, zeilen) {
1623
+ if (zeilen.length === 0)
1624
+ return ergebnis;
1625
+ const alle = ergebnis.hinweis === undefined ? zeilen : [ergebnis.hinweis, ...zeilen];
1626
+ return { ...ergebnis, hinweis: alle.join('\n') };
1627
+ }
1628
+ /**
1629
+ * `link` eines herausgenommenen Bundle-Mitglieds (E114 L6): Ist es derselbe Skill aus derselben
1630
+ * Quelle, wie ihn die Definition nennt (Quelle und Pfad, dieselbe Probe wie die Übernahme in
1631
+ * F67), gehört er wieder zum Bundle. Die Definition wird nur gelesen, wenn der Name in diesem
1632
+ * Ziel herausgenommen ist; lässt sie sich nicht lesen, bleibt es beim Einzel-Link. Gibt je
1633
+ * zurückgeholtem Bundle eine Hinweiszeile zurück.
1634
+ */
1635
+ async bundleMitgliedZurueckholen(ziel, fund) {
1636
+ const name = fund.skill.name;
1637
+ const kandidaten = Anwendungsfaelle.installationenAn(await this.p.zustand.lesen(), ziel).filter((i) => (i.herausgenommen ?? []).includes(name));
1638
+ if (kandidaten.length === 0)
1639
+ return [];
1640
+ const passend = [];
1641
+ for (const installation of kandidaten) {
1642
+ try {
1643
+ const bundle = await this.bundleAusQuelle(installation.bundle, installation.quelle);
1644
+ const gleich = bundle.mitglieder.some((m) => quelleSchluessel(m.quelle) === quelleSchluessel(fund.quelle) && m.pfad === fund.skill.pfad);
1645
+ if (gleich)
1646
+ passend.push(bundleSchluessel(bundle));
1647
+ }
1648
+ catch {
1649
+ // Definition nicht lesbar: der Link bleibt ein Einzel-Link, das Mitglied herausgenommen.
1650
+ }
1651
+ }
1652
+ if (passend.length === 0)
1653
+ return [];
1654
+ const zustand = await this.p.zustand.lesen();
1655
+ const zeilen = [];
1656
+ for (const installation of Anwendungsfaelle.installationenAn(zustand, ziel)) {
1657
+ if (!passend.includes(bundleSchluessel({ name: installation.bundle, quelle: installation.quelle })))
1658
+ continue;
1659
+ const rest = (installation.herausgenommen ?? []).filter((n) => n !== name);
1660
+ if (rest.length === 0)
1661
+ delete installation.herausgenommen;
1662
+ else
1663
+ installation.herausgenommen = rest;
1664
+ if (!installation.skills.includes(name))
1665
+ installation.skills.push(name);
1666
+ zeilen.push(`„${name}" gehört wieder zum Bundle „${installation.bundle}".`);
1667
+ }
1668
+ await this.p.zustand.schreiben(zustand);
1669
+ return zeilen;
1504
1670
  }
1505
1671
  /** Link legen, wenn Quelle, Ref und Skill schon feststehen. Die Zeiger-Arbeit ist dieselbe. */
1506
1672
  async linkAusFund(harness, ziel, fund, ersetzen, lokal) {
@@ -1553,9 +1719,9 @@ export class Anwendungsfaelle {
1553
1719
  * Link entfernen. Ohne `lokal` verschwindet der Skill auch aus dem Zeiger (E17). Mit `lokal` an
1554
1720
  * einem gebundenen Ziel bleibt der Zeiger unangetastet, ein Hinweis sagt das (E57, F32).
1555
1721
  *
1556
- * Gehörte der Link zu einem Bundle, zerbricht es in diesem Ziel (E69): die übrigen Links bleiben
1557
- * als Einzel-Installationen liegen, der Bundle-Verweis fällt weg. `zerbrochen` nennt die Bundles,
1558
- * damit die Oberfläche es sagen kann.
1722
+ * Gehörte der Link zu einem Bundle, bleibt das Bundle im Ziel und merkt sich den Skill als
1723
+ * herausgenommen (E114 L2). `herausgenommenAus` nennt die Bundles, damit die Oberfläche es sagen
1724
+ * kann.
1559
1725
  */
1560
1726
  async linkEntfernen(args) {
1561
1727
  const harness = await this.harnessGeprueft(args.ziel.harness);
@@ -1563,25 +1729,25 @@ export class Anwendungsfaelle {
1563
1729
  await this.zeigerSchreibenPruefen(args.ziel, args.lokal);
1564
1730
  await this.p.ziele.linkEntfernen(args.ziel, harness.zielVerzeichnis(args.ziel.ort), args.skill);
1565
1731
  const zustand = await this.p.zustand.lesen();
1566
- const zerbrochen = Anwendungsfaelle.bundlesZerbrechen(zustand, args.ziel, [args.skill]);
1567
- if (zerbrochen.length > 0)
1732
+ const herausgenommenAus = Anwendungsfaelle.bundleMitgliederHerausnehmen(zustand, args.ziel, [args.skill]);
1733
+ if (herausgenommenAus.length > 0)
1568
1734
  await this.p.zustand.schreiben(zustand);
1569
1735
  if (args.lokal === true && args.ziel.ort.art === 'arbeitsverzeichnis') {
1570
1736
  const zeigerPfad = await this.bindungFinden(args.ziel);
1571
1737
  if (zeigerPfad !== undefined)
1572
- return { hinweis: 'Der Link ist nur lokal entfernt, der Zeiger bleibt unangetastet (F32).', zerbrochen };
1573
- return { zerbrochen };
1738
+ return { hinweis: 'Der Link ist nur lokal entfernt, der Zeiger bleibt unangetastet (F32).', herausgenommenAus };
1739
+ return { herausgenommenAus };
1574
1740
  }
1575
1741
  if (args.lokal === true || args.ziel.ort.art !== 'arbeitsverzeichnis')
1576
- return { zerbrochen };
1742
+ return { herausgenommenAus };
1577
1743
  const zeigerPfad = await this.bindungFinden(args.ziel);
1578
1744
  if (zeigerPfad === undefined)
1579
- return { zerbrochen };
1745
+ return { herausgenommenAus };
1580
1746
  const zeiger = await this.p.zeiger.lesen(zeigerPfad);
1581
1747
  if (!zeiger.skills.delete(args.skill))
1582
- return { zerbrochen };
1748
+ return { herausgenommenAus };
1583
1749
  await this.p.zeiger.schreiben(zeigerPfad, zeiger);
1584
- return { zerbrochen };
1750
+ return { herausgenommenAus };
1585
1751
  }
1586
1752
  /**
1587
1753
  * Ignoriert einen fremden Eintrag (E43): idempotent, kein Duplikat. Gleichheit über harness, Ort
@@ -1996,9 +2162,15 @@ export class Anwendungsfaelle {
1996
2162
  ? { verwaiste: [], gefragt: false }
1997
2163
  : await this.verwaisteAufraeumen(checkouts, rueckfragen);
1998
2164
  const doctorErgebnis = nurSchluessel !== undefined
1999
- ? { fehlendeArbeitsverzeichnisse: [], vergessen: [] }
2000
- : await this.doctor({ rueckfragen });
2001
- await this.verlaufVermerken(ausloeser, this.aktualisierungsEreignisse(checkouts, pluginFehler, bundles, aufraeumen, doctorErgebnis));
2165
+ ? { fehlendeArbeitsverzeichnisse: [], vergessen: [], sops: [] }
2166
+ : await this.doctorPruefen(rueckfragen);
2167
+ // Die SOPS-Befunde (E115) kommen in denselben Eintrag; ein eingeschränkter Lauf prüft sie nicht
2168
+ // und lässt die gemerkte Liste stehen.
2169
+ const sops = nurSchluessel !== undefined ? undefined : await this.sopsEreignisse(doctorErgebnis.sops, true);
2170
+ await this.verlaufVermerken(ausloeser, [
2171
+ ...this.aktualisierungsEreignisse(checkouts, pluginFehler, bundles, aufraeumen, doctorErgebnis),
2172
+ ...(sops?.ereignisse ?? []),
2173
+ ], sops?.gemerkt);
2002
2174
  return { checkouts, pluginHarnesses, pluginFehler, bundles, aufraeumen, doctor: doctorErgebnis };
2003
2175
  }
2004
2176
  /**
@@ -2049,13 +2221,23 @@ export class Anwendungsfaelle {
2049
2221
  ereignisse.push({ art: 'arbeitsverzeichnis-vergessen', pfad });
2050
2222
  return ereignisse;
2051
2223
  }
2052
- /** Neuen Eintrag vorn anfügen, nur wenn Ereignisse da sind (E80). Kappen übernimmt der Adapter. */
2053
- async verlaufVermerken(ausloeser, ereignisse) {
2054
- if (ereignisse.length === 0)
2055
- return;
2224
+ /**
2225
+ * Neuen Eintrag vorn anfügen, nur wenn Ereignisse da sind (E80). Kappen übernimmt der Adapter.
2226
+ * `sopsGemerkt` (E115) ersetzt die gemerkten SOPS-Meldungen; hat sich nur diese Liste geändert,
2227
+ * wird sie ohne neuen Eintrag geschrieben.
2228
+ */
2229
+ async verlaufVermerken(ausloeser, ereignisse, sopsGemerkt) {
2056
2230
  const daten = await this.p.verlauf.lesen();
2231
+ const sopsGeaendert = sopsGemerkt !== undefined && JSON.stringify(sopsGemerkt) !== JSON.stringify(daten.gemerkt.sops ?? []);
2232
+ if (ereignisse.length === 0 && !sopsGeaendert)
2233
+ return;
2234
+ const gemerkt = sopsGeaendert ? { ...daten.gemerkt, sops: sopsGemerkt } : daten.gemerkt;
2235
+ if (ereignisse.length === 0) {
2236
+ await this.p.verlauf.schreiben({ gemerkt, eintraege: daten.eintraege });
2237
+ return;
2238
+ }
2057
2239
  const eintrag = { zeit: new Date().toISOString(), ausloeser, ereignisse };
2058
- await this.p.verlauf.schreiben({ gemerkt: daten.gemerkt, eintraege: [eintrag, ...daten.eintraege] });
2240
+ await this.p.verlauf.schreiben({ gemerkt, eintraege: [eintrag, ...daten.eintraege] });
2059
2241
  }
2060
2242
  /** Der Verlauf, neueste zuerst (E80). */
2061
2243
  async verlauf() {
@@ -2580,14 +2762,20 @@ export class Anwendungsfaelle {
2580
2762
  for (const ei of eintraege)
2581
2763
  if (ei.art === 'fremd')
2582
2764
  uebernahme.set(ei.name, await this.erkennenFremd(harness, verzeichnis, ei, bekannt, true));
2583
- // Bundles dieses Ziels (E69), rein aus dem Zustand: nur die Mitglieder, die dort auch
2584
- // wirklich noch liegen — ein von Hand gelöschter Link soll die Gruppe nicht länger führen.
2765
+ // Bundles dieses Ziels (E69), rein aus dem Zustand: verlinkt sind die Mitglieder, die dort
2766
+ // auch wirklich noch liegen; herausgenommene und von Hand gelöschte stehen als nicht
2767
+ // verlinkt daneben (E114 L3), die Gruppe zeigt alle.
2585
2768
  const eigeneNamen = new Set(eintraege.filter((ei) => ei.art !== 'fremd').map((ei) => (ei.art === 'link' ? ei.link.name : ei.name)));
2586
- const bundles = Anwendungsfaelle.installationenAn(zustand, ziel).map((i) => ({
2587
- name: i.bundle,
2588
- quelle: i.quelle,
2589
- skills: i.skills.filter((name) => eigeneNamen.has(name)),
2590
- }));
2769
+ const bundles = Anwendungsfaelle.installationenAn(zustand, ziel).map((i) => {
2770
+ const herausgenommen = i.herausgenommen ?? [];
2771
+ return {
2772
+ name: i.bundle,
2773
+ quelle: i.quelle,
2774
+ skills: i.skills.filter((name) => eigeneNamen.has(name)),
2775
+ nichtVerlinkt: [...i.skills.filter((name) => !eigeneNamen.has(name)), ...herausgenommen],
2776
+ herausgenommen,
2777
+ };
2778
+ });
2591
2779
  ergebnis.push({
2592
2780
  ziel,
2593
2781
  verzeichnis,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hmgr",
3
- "version": "0.0.45",
3
+ "version": "0.0.47",
4
4
  "description": "Harness Manager: Skills und Plugins für AI-Harnesses verwalten",
5
5
  "license": "MIT",
6
6
  "author": "Tim Kaufmann",