hmgr 0.0.4 → 0.0.5

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,8 +1,8 @@
1
1
  // Anwendungsfälle. Pfade werden hier nur als Zeichenketten normalisiert, gelesen wird über die Ports.
2
2
  import path from 'node:path';
3
- import { AbhaengigkeitFehlt, EinschraenkungOhneZeiger, HarnessNichtVorhanden, KeinPassenderRef, OrtNichtUnterstuetzt, PluginMehrdeutig, PluginUnbekannt, SkillMehrdeutig, SkillUnbekannt, ZeigerMehrdeutig, marketplaceUrl, pluginSchluessel, pluginSchluesselZerlegen, quelleAlsText, quelleSchluessel, zielSchluessel, } from "./modell.js";
3
+ import { AbhaengigkeitFehlt, EinschraenkungOhneZeiger, FremderEintragUnbekannt, HarnessNichtVorhanden, HarnessTypUnbekannt, KeinPassenderRef, OrtNichtUnterstuetzt, PluginMehrdeutig, PluginUnbekannt, QuelleAufraeumenLokaleAenderungen, QuelleNameBelegt, QuelleUnbekannt, SkillMehrdeutig, SkillOrdnerBereitsVerwaltet, SkillOrdnerFaehigkeitFehlt, SkillOrdnerNichtVerwaltet, SkillUnbekannt, ZeigerMehrdeutig, ZeigerSchreibenGesperrt, marketplaceUrl, pluginSchluessel, pluginSchluesselZerlegen, quelleAlsText, quelleMitNamen, quelleName, quelleNameAbleiten, quelleNameFrei, quelleSchluessel, zielSchluessel, } from "./modell.js";
4
4
  import { passenderRef } from "./version.js";
5
- import { kannPlugins, kannProfile } from "../ports/harness.js";
5
+ import { kannIstRegistriert, kannPlugins, kannProfile, kannSkillOrdner, kannZurueckbauen, } from "../ports/harness.js";
6
6
  /**
7
7
  * Frage vor dem Löschen, eine für alle Kandidaten (E28). Der Kern formuliert sie fertig, der
8
8
  * Adapter stellt sie: Anzahl und Pfade, damit der Nutzer sieht, worüber er entscheidet.
@@ -12,11 +12,15 @@ export function aufraeumFrage(checkouts) {
12
12
  ? 'Ein Checkout ist verwaist, kein Link zeigt hinein:'
13
13
  : `${checkouts.length} Checkouts sind verwaist, kein Link zeigt hinein:`;
14
14
  const pfade = checkouts.map((c) => ` ${c.pfad}`).join('\n');
15
- return `${kopf}\n${pfade}\nLöschen? [j/N]`;
15
+ return `${kopf}\n${pfade}\nLöschen?`;
16
16
  }
17
17
  function arbeitsverzeichnisZiel(harness, pfad) {
18
18
  return { harness, ort: { art: 'arbeitsverzeichnis', pfad } };
19
19
  }
20
+ /** Gleichheit eines ignorierten Eintrags mit einem Ziel plus Name (E43): harness, Ort strukturell, Name. */
21
+ function ignorierterEintragGleich(i, args) {
22
+ return zielSchluessel({ harness: i.harness, ort: i.ort }) === zielSchluessel(args.ziel) && i.name === args.name;
23
+ }
20
24
  function fehlertext(fehler) {
21
25
  return fehler instanceof Error ? fehler.message : String(fehler);
22
26
  }
@@ -30,6 +34,17 @@ function quellePasst(quelle, hinweis) {
30
34
  return true;
31
35
  return quelleSchluessel(quelle).includes(hinweis) || quelleAlsText(quelle).includes(hinweis);
32
36
  }
37
+ /** Ordnerpfad eines Skills relativ zur Quelle, aus `skillPath` des Lockfiles (E48 b): das `SKILL.md`-Suffix fällt weg. */
38
+ function skillPfadAusLock(skillPath) {
39
+ let pfad = skillPath.replace(/\\/g, '/');
40
+ if (pfad.endsWith('/SKILL.md'))
41
+ pfad = pfad.slice(0, -'/SKILL.md'.length);
42
+ else if (pfad.endsWith('SKILL.md'))
43
+ pfad = pfad.slice(0, -'SKILL.md'.length);
44
+ if (pfad.endsWith('/'))
45
+ pfad = pfad.slice(0, -1);
46
+ return pfad;
47
+ }
33
48
  export class Anwendungsfaelle {
34
49
  p;
35
50
  /** Schon beantwortete Orte, je Ziel der Hinweis, der davon übrig blieb (E34). */
@@ -42,7 +57,7 @@ export class Anwendungsfaelle {
42
57
  harness(name) {
43
58
  const h = this.p.harnesses.find((h) => h.name === name);
44
59
  if (!h)
45
- throw new Error(`Unbekannter Harness „${name}"`);
60
+ throw new HarnessTypUnbekannt(name);
46
61
  return h;
47
62
  }
48
63
  /** Ein namentlich genannter Harness muss auf dem Rechner liegen, sonst passiert nichts Stilles. */
@@ -68,25 +83,269 @@ export class Anwendungsfaelle {
68
83
  ergebnis.push(harness.name);
69
84
  return ergebnis;
70
85
  }
86
+ /** Namen aller Harness-Typen, die hmgr kennt, aktiv oder nicht. Reine Aufzählung, ohne Regel. */
87
+ harnessTypen() {
88
+ return this.p.harnesses.map((h) => h.name);
89
+ }
90
+ /**
91
+ * Harness-Typen, die hmgr verwaltet (E38, GRILL.md Runde 2). Nur für sie richtet hmgr beim
92
+ * Arbeitsverzeichnis-Hinzufügen und beim Abgleich etwas ein. Standard: nur claude-code.
93
+ */
94
+ async aktiveHarnesses() {
95
+ return (await this.p.zustand.lesen()).aktiveHarnesses;
96
+ }
97
+ /** Vorhandene Harnesses, gefiltert auf die aktiven (E38). Für Einrichten-Abläufe über alle Harnesses. */
98
+ async vorhandeneAktiveHarnesses() {
99
+ const aktiv = new Set(await this.aktiveHarnesses());
100
+ return (await this.vorhandeneHarnesses()).filter((h) => aktiv.has(h.name));
101
+ }
102
+ /** Aktiviert einen Harness-Typ (E38). Unbekannter Name ist ein Fehler, schon aktiv ist keiner. */
103
+ async harnessAktivieren(name) {
104
+ this.harness(name); // wirft HarnessTypUnbekannt bei unbekanntem Namen
105
+ const zustand = await this.p.zustand.lesen();
106
+ if (zustand.aktiveHarnesses.includes(name))
107
+ return;
108
+ zustand.aktiveHarnesses.push(name);
109
+ await this.p.zustand.schreiben(zustand);
110
+ }
111
+ /**
112
+ * Deaktiviert einen Harness-Typ (E38). Unbekannter Name ist ein Fehler, schon inaktiv ist keiner.
113
+ * Säubert nicht automatisch: liegende Konfiguration meldet die GUI über `istZustand`, entfernt
114
+ * wird sie erst mit `saeubern`.
115
+ */
116
+ async harnessDeaktivieren(name) {
117
+ this.harness(name); // wirft HarnessTypUnbekannt bei unbekanntem Namen
118
+ const zustand = await this.p.zustand.lesen();
119
+ if (!zustand.aktiveHarnesses.includes(name))
120
+ return;
121
+ zustand.aktiveHarnesses = zustand.aktiveHarnesses.filter((h) => h !== name);
122
+ await this.p.zustand.schreiben(zustand);
123
+ }
124
+ /** Ein Harness mit der Fähigkeit „verwalteter Skill-Ordner" (E56), sonst `SkillOrdnerFaehigkeitFehlt`. */
125
+ harnessMitSkillOrdner(name) {
126
+ const harness = this.harness(name);
127
+ if (!kannSkillOrdner(harness))
128
+ throw new SkillOrdnerFaehigkeitFehlt(name);
129
+ return harness;
130
+ }
131
+ /** Der verwaltete Skill-Ordner eines Ziels (E56), oder `undefined`, wenn keiner verwaltet wird. */
132
+ async verwalteterSkillOrdner(ziel) {
133
+ await this.harnessGeprueft(ziel.harness);
134
+ return this.harnessMitSkillOrdner(ziel.harness).verwalteterSkillOrdner(ziel.ort);
135
+ }
136
+ /**
137
+ * Skill-Ordner festlegen (E56): erlaubt nur, wenn für dieses Ziel noch keiner verwaltet wird
138
+ * (sonst `SkillOrdnerBereitsVerwaltet`). Den genannten Pfad deutet und prüft die Fähigkeit selbst
139
+ * (unterhalb des Hermes-Home, nicht der eigene Ordner), sie kennt den Aufbau; der Kern reicht ihn
140
+ * unverändert durch und gibt den aufgelösten Pfad zurück.
141
+ */
142
+ async skillOrdnerFestlegen(ziel, pfad) {
143
+ const harness = await this.harnessGeprueft(ziel.harness);
144
+ const faehig = this.harnessMitSkillOrdner(harness.name);
145
+ const vorhanden = await faehig.verwalteterSkillOrdner(ziel.ort);
146
+ if (vorhanden !== undefined)
147
+ throw new SkillOrdnerBereitsVerwaltet(ziel, vorhanden);
148
+ return await faehig.skillOrdnerFestlegen(ziel.ort, pfad);
149
+ }
150
+ /**
151
+ * Skill-Ordner entfernen (E56): nur der Eintrag in der Harness-Konfiguration verschwindet, der
152
+ * Ordner bleibt liegen. Erhebt vorher den Inhalt (hmgr-Links, fremde Einträge), wie `istZustand`
153
+ * ihn für dieses Ziel zeigen würde, fürs anschließende Lösch-Angebot des Aufrufers. Ohne
154
+ * verwalteten Eintrag: `SkillOrdnerNichtVerwaltet`.
155
+ */
156
+ async skillOrdnerEntfernen(ziel) {
157
+ const harness = await this.harnessGeprueft(ziel.harness);
158
+ const faehig = this.harnessMitSkillOrdner(harness.name);
159
+ const pfad = await faehig.verwalteterSkillOrdner(ziel.ort);
160
+ if (pfad === undefined)
161
+ throw new SkillOrdnerNichtVerwaltet(ziel);
162
+ const eintraege = await this.p.ziele.eintraege(ziel, pfad).catch(() => []);
163
+ const hmgrLinks = eintraege.filter((e) => e.art !== 'fremd').length;
164
+ const fremdeEintraege = eintraege.filter((e) => e.art === 'fremd').length;
165
+ await faehig.skillOrdnerEntfernen(ziel.ort);
166
+ return { ziel, pfad, hmgrLinks, fremdeEintraege };
167
+ }
168
+ /**
169
+ * Den zuvor verwalteten Skill-Ordner mit Inhalt löschen (E56), ein eigener, destruktiver Schritt
170
+ * nach `skillOrdnerEntfernen`. Die Bestätigung liegt beim Aufrufer (CLI über den Rückfrage-Port,
171
+ * GUI über einen eigenen Dialog), hier nur die Ausführung samt Sicherheitsnetz in der Fähigkeit.
172
+ */
173
+ async skillOrdnerLoeschen(ziel, pfad) {
174
+ const harness = await this.harnessGeprueft(ziel.harness);
175
+ await this.harnessMitSkillOrdner(harness.name).skillOrdnerLoeschen(ziel.ort, pfad);
176
+ }
177
+ /**
178
+ * Säubern (E38): entfernt in allen Zielen dieses Harness-Typs (Home, Profile, bekannte
179
+ * Arbeitsverzeichnisse) die von hmgr angelegten Links und baut harness-eigene Einträge zurück, die
180
+ * hmgr angelegt hat (etwa Hermes' external_dirs-Eintrag). Fremde Einträge bleiben liegen. Läuft
181
+ * unabhängig davon, ob der Harness aktiv ist oder auf dem Rechner liegt, damit auch eine
182
+ * Deinstallation sauber aufgeräumt werden kann.
183
+ */
184
+ async saeubern(harnessName) {
185
+ const harness = this.harness(harnessName); // wirft HarnessTypUnbekannt bei unbekanntem Namen
186
+ const zustand = await this.p.zustand.lesen();
187
+ const ziele = [];
188
+ if (harness.orte().includes('home'))
189
+ ziele.push({ harness: harness.name, ort: { art: 'home' } });
190
+ if (harness.orte().includes('profil') && kannProfile(harness))
191
+ for (const name of await harness.profile())
192
+ ziele.push({ harness: harness.name, ort: { art: 'profil', name } });
193
+ if (harness.orte().includes('arbeitsverzeichnis'))
194
+ for (const pfad of zustand.arbeitsverzeichnisse)
195
+ ziele.push(arbeitsverzeichnisZiel(harness.name, pfad));
196
+ const zielErgebnisse = [];
197
+ for (const ziel of ziele) {
198
+ const verzeichnis = harness.zielVerzeichnis(ziel.ort);
199
+ const eintraege = await this.p.ziele.eintraege(ziel, verzeichnis).catch(() => []);
200
+ const entfernteLinks = [];
201
+ for (const eintrag of eintraege) {
202
+ if (eintrag.art === 'fremd')
203
+ continue; // fremde Einträge bleiben liegen
204
+ const name = eintrag.art === 'link' ? eintrag.link.name : eintrag.name;
205
+ await this.p.ziele.linkEntfernen(ziel, verzeichnis, name);
206
+ entfernteLinks.push(name);
207
+ }
208
+ const konfigurationZurueckgebaut = kannZurueckbauen(harness) ? await harness.zurueckbauen(ziel.ort) : false;
209
+ zielErgebnisse.push({ ziel, entfernteLinks, konfigurationZurueckgebaut });
210
+ }
211
+ return { harness: harness.name, ziele: zielErgebnisse };
212
+ }
71
213
  /**
72
214
  * Quelle hinzufügen: prüft, dass sie erreichbar ist, und liest Skills und Marketplace (E24).
73
215
  * Steht dieselbe Quelle schon in einer anderen Schreibweise im Zustand, kommt keine zweite dazu
74
216
  * (E36). Gelesen wird dann unter der Schreibweise, unter der sie geführt wird, denn mit ihr ist
75
217
  * der Basis-Clone angelegt. `bekannt` sagt, ob es so kam, `quelle` nennt die geführte Fassung.
218
+ *
219
+ * Anzeigename (E59): ohne `name` wird der abgeleitete Name gespeichert. Namen sind eindeutig,
220
+ * eine andere geführte Quelle mit demselben Anzeigenamen ist ein Fehler (`QuelleNameBelegt`).
221
+ * Bei einer schon bekannten Quelle wird ihr Name nicht überschrieben, `name` bleibt ohne Wirkung.
76
222
  */
77
- async quelleHinzufuegen(quelle) {
223
+ async quelleHinzufuegen(quelle, name) {
78
224
  const zustand = await this.p.zustand.lesen();
79
225
  const key = quelleSchluessel(quelle);
80
226
  const gefuehrt = zustand.quellen.find((q) => quelleSchluessel(q) === key);
81
- const wirksam = gefuehrt ?? quelle;
82
- const ref = await this.p.quellen.standardRef(wirksam);
83
- const skills = await this.p.quellen.skills(wirksam, ref);
84
- const marketplace = await this.p.quellen.marketplace(wirksam, ref);
85
- if (gefuehrt === undefined) {
86
- zustand.quellen.push(quelle);
87
- await this.p.zustand.schreiben(zustand);
227
+ if (gefuehrt !== undefined) {
228
+ const ref = await this.p.quellen.standardRef(gefuehrt);
229
+ const skills = await this.p.quellen.skills(gefuehrt, ref);
230
+ const marketplace = await this.p.quellen.marketplace(gefuehrt, ref);
231
+ return { quelle: gefuehrt, skills, marketplace, bekannt: true };
88
232
  }
89
- return { quelle: wirksam, skills, marketplace, bekannt: gefuehrt !== undefined };
233
+ const angefragterName = name?.trim();
234
+ const effektiverName = angefragterName !== undefined && angefragterName.length > 0 ? angefragterName : quelleNameAbleiten(quelle);
235
+ const kollision = zustand.quellen.find((q) => quelleName(q) === effektiverName);
236
+ if (kollision !== undefined)
237
+ throw new QuelleNameBelegt(effektiverName, kollision);
238
+ const neu = { ...quelle, name: effektiverName };
239
+ const ref = await this.p.quellen.standardRef(neu);
240
+ const skills = await this.p.quellen.skills(neu, ref);
241
+ const marketplace = await this.p.quellen.marketplace(neu, ref);
242
+ zustand.quellen.push(neu);
243
+ await this.p.zustand.schreiben(zustand);
244
+ return { quelle: neu, skills, marketplace, bekannt: false };
245
+ }
246
+ /**
247
+ * Quelle umbenennen (E59): ändert nur den Anzeigenamen, die Identität bleibt `quelleSchluessel`
248
+ * (E36). Referenz über URL/Pfad wie `quelleEntfernen`, eine unbekannte Quelle ist ein Fehler.
249
+ * Namen sind eindeutig, eine Kollision mit einer anderen geführten Quelle bricht ab, ohne zu
250
+ * schreiben. Gibt die geführte Quelle mit dem neuen Namen zurück.
251
+ */
252
+ async quelleUmbenennen(quelle, name) {
253
+ const zustand = await this.p.zustand.lesen();
254
+ const key = quelleSchluessel(quelle);
255
+ const gefuehrt = zustand.quellen.find((q) => quelleSchluessel(q) === key);
256
+ if (gefuehrt === undefined)
257
+ throw new QuelleUnbekannt(quelle);
258
+ const neuerName = name.trim();
259
+ // Ein leerer Name wäre kein Name: `quelleName` fiele still auf den abgeleiteten zurück, an der
260
+ // Eindeutigkeitsprüfung vorbei. Lieber ein Fehler als zwei Quellen mit derselben Anzeige.
261
+ if (neuerName.length === 0)
262
+ throw new Error('Der Anzeigename darf nicht leer sein.');
263
+ const kollision = zustand.quellen.find((q) => quelleSchluessel(q) !== key && quelleName(q) === neuerName);
264
+ if (kollision !== undefined)
265
+ throw new QuelleNameBelegt(neuerName, kollision);
266
+ const umbenannt = { ...gefuehrt, name: neuerName };
267
+ zustand.quellen = zustand.quellen.map((q) => (quelleSchluessel(q) === key ? umbenannt : q));
268
+ await this.p.zustand.schreiben(zustand);
269
+ return umbenannt;
270
+ }
271
+ /**
272
+ * Quelle entfernen: reines Vergessen aus dem Zustand (E54), wie beim Arbeitsverzeichnis (E52).
273
+ * Checkouts und Links bleiben liegen, bestehende Links gelten danach als „Link ohne Quelle"
274
+ * (das erkennt der Dateisystem-Adapter von selbst). Identität über `quelleSchluessel` (E36); eine
275
+ * unbekannte Quelle ist ein Fehler. Gibt die geführte Fassung zurück, für eine Meldung, die dieselbe
276
+ * Schreibweise nennt wie source list, auch wenn der Aufruf eine andere Schreibweise genannt hat.
277
+ */
278
+ async quelleEntfernen(quelle) {
279
+ const zustand = await this.p.zustand.lesen();
280
+ const key = quelleSchluessel(quelle);
281
+ const gefuehrt = zustand.quellen.find((q) => quelleSchluessel(q) === key);
282
+ if (gefuehrt === undefined)
283
+ throw new QuelleUnbekannt(quelle);
284
+ zustand.quellen = zustand.quellen.filter((q) => quelleSchluessel(q) !== key);
285
+ await this.p.zustand.schreiben(zustand);
286
+ return gefuehrt;
287
+ }
288
+ /**
289
+ * Inventar einer Quelle für das Aufräum-Angebot nach dem Entfernen (E58): alle von hmgr gelegten
290
+ * Links über alle Ziele (auch die, die ein Plugin für Harnesses ohne Plugin-Fähigkeit aufgelöst
291
+ * hat, sie stehen als gewöhnliche Links da), alle Checkouts der Quelle, dazu nur zum Nennen:
292
+ * installierte Plugins aus ihrem Marketplace und Zeiger, die sie führen. Muss VOR `quelleEntfernen`
293
+ * laufen: danach gelten ihre Links nur noch als „Link ohne Quelle" und die Zuordnung ist weg.
294
+ */
295
+ async quelleAufraeumInventar(quelle) {
296
+ const key = quelleSchluessel(quelle);
297
+ const zustand = await this.p.zustand.lesen();
298
+ const marktplatz = await this.marketplaceLesen(quelle).catch(() => null);
299
+ const links = [];
300
+ const plugins = new Set();
301
+ for (const zielZustand of await this.istZustand()) {
302
+ for (const eintrag of zielZustand.eintraege) {
303
+ if (eintrag.art !== 'link')
304
+ continue;
305
+ if (quelleSchluessel(eintrag.link.checkout.quelle) !== key)
306
+ continue;
307
+ links.push({ ziel: zielZustand.ziel, verzeichnis: zielZustand.verzeichnis, name: eintrag.link.name });
308
+ }
309
+ if (marktplatz === null || zielZustand.plugins === undefined)
310
+ continue;
311
+ for (const eintrag of zielZustand.plugins) {
312
+ if (pluginSchluesselZerlegen(eintrag).marketplace === marktplatz.name)
313
+ plugins.add(eintrag);
314
+ }
315
+ }
316
+ const checkouts = (await this.p.quellen.checkouts()).filter((c) => quelleSchluessel(c.quelle) === key);
317
+ // Lokale Änderungen (E15, F31a): vorher erhoben, damit quelleAufraeumen VOR der ersten Löschung
318
+ // prüfen kann, statt einen Checkout mittendrin scheitern zu lassen.
319
+ const checkoutsMitLokalenAenderungen = [];
320
+ for (const checkout of checkouts) {
321
+ if (await this.p.quellen.lokaleAenderungen(checkout))
322
+ checkoutsMitLokalenAenderungen.push(checkout);
323
+ }
324
+ // Zeiger-Einträge (E57): nur zählen/nennen, welche Bindungen die Quelle in ihrem Zeiger führen.
325
+ const zeigerPfade = new Set();
326
+ for (const bindung of zustand.bindungen) {
327
+ const zeiger = await this.p.zeiger.lesen(bindung.zeigerPfad).catch(() => undefined);
328
+ if (zeiger !== undefined && zeiger.quellen.some((q) => quelleSchluessel(q) === key))
329
+ zeigerPfade.add(bindung.zeigerPfad);
330
+ }
331
+ return { quelle, links, checkouts, checkoutsMitLokalenAenderungen, plugins: [...plugins], zeigerPfade: [...zeigerPfade] };
332
+ }
333
+ /**
334
+ * Räumt das zuvor mit `quelleAufraeumInventar` erhobene Inventar ab (E58): jeden Link direkt über
335
+ * den Ziele-Port entfernen, nicht über den Anwendungsfall `linkEntfernen` (der Zeiger-Arbeit macht
336
+ * bzw. jetzt E57 würfe), danach jeden Checkout von der Platte nehmen. Zeiger und Plugins bleiben
337
+ * unangetastet (E57, E58): sie waren im Inventar nur genannt. Hat ein Checkout lokale Änderungen
338
+ * (E15), bricht der Aufruf VOR der ersten Löschung mit `QuelleAufraeumenLokaleAenderungen` ab,
339
+ * kein stilles Teil-Überspringen (F31a).
340
+ */
341
+ async quelleAufraeumen(inventar) {
342
+ if (inventar.checkoutsMitLokalenAenderungen.length > 0)
343
+ throw new QuelleAufraeumenLokaleAenderungen(inventar.checkoutsMitLokalenAenderungen);
344
+ for (const link of inventar.links)
345
+ await this.p.ziele.linkEntfernen(link.ziel, link.verzeichnis, link.name);
346
+ for (const checkout of inventar.checkouts)
347
+ await this.p.quellen.entfernen(checkout);
348
+ return { entfernteLinks: inventar.links.length, entfernteCheckouts: inventar.checkouts.length };
90
349
  }
91
350
  /** Marketplace einer Quelle am Standard-Ref, mit den Plugins als Modellwerte. */
92
351
  async marketplaceLesen(quelle) {
@@ -105,6 +364,31 @@ export class Anwendungsfaelle {
105
364
  })),
106
365
  };
107
366
  }
367
+ /** Die bekannten Quellen, ohne sie zu lesen. Für Listen, die nur die Herkunft nennen. */
368
+ async quellen() {
369
+ return (await this.p.zustand.lesen()).quellen;
370
+ }
371
+ /**
372
+ * Skill-Katalog aller bekannten Quellen (E40 R3): je Quelle ihre Skills, mit `beschreibung`, wo
373
+ * vorhanden (E39). Für einen künftigen CLI-Weg (etwa `hmgr skill list`) gedacht, heute schon von
374
+ * der GUI benutzt. Eine nicht erreichbare Quelle bekommt einen Fehlervermerk statt den Lauf
375
+ * abzubrechen, wie bei `istZustand` und `marketplaces`.
376
+ */
377
+ async katalog() {
378
+ const zustand = await this.p.zustand.lesen();
379
+ const ergebnis = [];
380
+ for (const quelle of zustand.quellen) {
381
+ try {
382
+ const ref = await this.p.quellen.standardRef(quelle);
383
+ const skills = await this.p.quellen.skills(quelle, ref);
384
+ ergebnis.push({ quelle, ref, skills });
385
+ }
386
+ catch (fehler) {
387
+ ergebnis.push({ quelle, skills: [], fehler: fehlertext(fehler) });
388
+ }
389
+ }
390
+ return ergebnis;
391
+ }
108
392
  /** Alle Marketplaces der bekannten Quellen, für `hmgr plugin list`. */
109
393
  async marketplaces() {
110
394
  const zustand = await this.p.zustand.lesen();
@@ -207,7 +491,7 @@ export class Anwendungsfaelle {
207
491
  zustand.arbeitsverzeichnisse.push(verzeichnis);
208
492
  if (zeigerPfad !== undefined) {
209
493
  const gebunden = zeigerPfad;
210
- for (const harness of await this.vorhandeneHarnesses()) {
494
+ for (const harness of await this.vorhandeneAktiveHarnesses()) {
211
495
  if (!harness.orte().includes('arbeitsverzeichnis'))
212
496
  continue;
213
497
  const ziel = arbeitsverzeichnisZiel(harness.name, verzeichnis);
@@ -256,6 +540,19 @@ export class Anwendungsfaelle {
256
540
  const zustand = await this.p.zustand.lesen();
257
541
  return zustand.bindungen.find((b) => zielSchluessel(b.ziel) === schluessel)?.zeigerPfad;
258
542
  }
543
+ /**
544
+ * Wächter vor jeder Wirkung, die ohne `--local` tatsächlich in einen Zeiger schreiben würde
545
+ * (E57): Ziel ist ein Arbeitsverzeichnis, dafür existiert eine Bindung, und `lokal` ist nicht
546
+ * gesetzt. Dann wirft er `ZeigerSchreibenGesperrt`, bevor irgendein Link gelegt, entfernt oder
547
+ * ein Plugin installiert bzw. deinstalliert wird, also nie eine halbe Wirkung. Ohne Bindung oder
548
+ * mit `lokal: true` bleibt das Verhalten unverändert (E26).
549
+ */
550
+ async zeigerSchreibenPruefen(ziel, lokal) {
551
+ if (lokal === true || ziel.ort.art !== 'arbeitsverzeichnis')
552
+ return;
553
+ if ((await this.bindungFinden(ziel)) !== undefined)
554
+ throw new ZeigerSchreibenGesperrt(ziel);
555
+ }
259
556
  /** Skill über alle Quellen suchen. Mehrere Treffer sind nur mit Quellenhinweis erlaubt. */
260
557
  async skillFinden(name, quelleHinweis) {
261
558
  const zustand = await this.p.zustand.lesen();
@@ -323,6 +620,9 @@ export class Anwendungsfaelle {
323
620
  const zeigerPfad = await this.bindungFinden(args.ziel);
324
621
  if (zeigerPfad === undefined)
325
622
  throw new EinschraenkungOhneZeiger(args.ziel, version);
623
+ // Eine Versionseinschränkung schreibt immer geteilt (siehe EinschraenkungOhneZeiger oben), das
624
+ // trifft die Sperre also ausnahmslos, solange sie steht (E57).
625
+ await this.zeigerSchreibenPruefen(args.ziel, args.lokal);
326
626
  const fund = await this.fundAusZeiger(args.skill, version, args.quelle);
327
627
  const link = await this.linkAnlegen(harness, args.ziel, fund, args.ersetzen);
328
628
  await this.zeigerErgaenzen(zeigerPfad, fund, version);
@@ -331,6 +631,8 @@ export class Anwendungsfaelle {
331
631
  }
332
632
  /** Link legen, wenn Quelle, Ref und Skill schon feststehen. Die Zeiger-Arbeit ist dieselbe. */
333
633
  async linkAusFund(harness, ziel, fund, ersetzen, lokal) {
634
+ // Vor jeder Wirkung: kein Link, wenn danach das Schreiben in den Zeiger scheitern würde (E57).
635
+ await this.zeigerSchreibenPruefen(ziel, lokal);
334
636
  const link = await this.linkAnlegen(harness, ziel, fund, ersetzen);
335
637
  const hinweise = [];
336
638
  if (lokal !== true && ziel.ort.art === 'arbeitsverzeichnis') {
@@ -341,6 +643,12 @@ export class Anwendungsfaelle {
341
643
  else
342
644
  await this.zeigerErgaenzen(zeigerPfad, fund);
343
645
  }
646
+ else if (lokal === true && ziel.ort.art === 'arbeitsverzeichnis' && (await this.bindungFinden(ziel)) !== undefined) {
647
+ // Zeiger-Bindung vorhanden, aber ausdrücklich lokal (E57, F32): der Zeiger bleibt unangetastet,
648
+ // ein Hinweis sagt das, damit es niemand für einen geteilten Link hält. Ohne Skill-Namen, damit
649
+ // die GUI ihn nach einem Lauf über mehrere Skills einmal statt N-mal zeigen kann.
650
+ hinweise.push('Der Link liegt nur lokal, der Zeiger bleibt unangetastet (F32).');
651
+ }
344
652
  const vomHarness = await this.harnessHinweis(harness, ziel.ort);
345
653
  if (vomHarness !== undefined)
346
654
  hinweise.push(vomHarness);
@@ -368,19 +676,68 @@ export class Anwendungsfaelle {
368
676
  this.beantwortet.set(schluessel, hinweis);
369
677
  return hinweis;
370
678
  }
371
- /** Link entfernen. Ohne `lokal` verschwindet der Skill auch aus dem Zeiger (E17). */
679
+ /**
680
+ * Link entfernen. Ohne `lokal` verschwindet der Skill auch aus dem Zeiger (E17). Mit `lokal` an
681
+ * einem gebundenen Ziel bleibt der Zeiger unangetastet, ein Hinweis sagt das (E57, F32).
682
+ */
372
683
  async linkEntfernen(args) {
373
684
  const harness = await this.harnessGeprueft(args.ziel.harness);
685
+ // Vor jeder Wirkung: kein Entfernen, wenn danach das Schreiben in den Zeiger scheitern würde (E57).
686
+ await this.zeigerSchreibenPruefen(args.ziel, args.lokal);
374
687
  await this.p.ziele.linkEntfernen(args.ziel, harness.zielVerzeichnis(args.ziel.ort), args.skill);
688
+ if (args.lokal === true && args.ziel.ort.art === 'arbeitsverzeichnis') {
689
+ const zeigerPfad = await this.bindungFinden(args.ziel);
690
+ if (zeigerPfad !== undefined)
691
+ return { hinweis: 'Der Link ist nur lokal entfernt, der Zeiger bleibt unangetastet (F32).' };
692
+ return {};
693
+ }
375
694
  if (args.lokal === true || args.ziel.ort.art !== 'arbeitsverzeichnis')
376
- return;
695
+ return {};
377
696
  const zeigerPfad = await this.bindungFinden(args.ziel);
378
697
  if (zeigerPfad === undefined)
379
- return;
698
+ return {};
380
699
  const zeiger = await this.p.zeiger.lesen(zeigerPfad);
381
700
  if (!zeiger.skills.delete(args.skill))
382
- return;
701
+ return {};
383
702
  await this.p.zeiger.schreiben(zeigerPfad, zeiger);
703
+ return {};
704
+ }
705
+ /**
706
+ * Ignoriert einen fremden Eintrag (E43): idempotent, kein Duplikat. Gleichheit über harness, Ort
707
+ * (strukturell, über `zielSchluessel`) und Name.
708
+ */
709
+ async eintragIgnorieren(args) {
710
+ const zustand = await this.p.zustand.lesen();
711
+ if (zustand.ignorierteEintraege.some((i) => ignorierterEintragGleich(i, args)))
712
+ return;
713
+ zustand.ignorierteEintraege.push({ harness: args.ziel.harness, ort: args.ziel.ort, name: args.name });
714
+ await this.p.zustand.schreiben(zustand);
715
+ }
716
+ /** Beachtet einen zuvor ignorierten Eintrag wieder: entfernt ihn aus der Ignorier-Liste (E43). */
717
+ async eintragBeachten(args) {
718
+ const zustand = await this.p.zustand.lesen();
719
+ const vorher = zustand.ignorierteEintraege.length;
720
+ zustand.ignorierteEintraege = zustand.ignorierteEintraege.filter((i) => !ignorierterEintragGleich(i, args));
721
+ if (zustand.ignorierteEintraege.length === vorher)
722
+ return;
723
+ await this.p.zustand.schreiben(zustand);
724
+ }
725
+ /**
726
+ * Löscht einen fremden Eintrag vom Ziel auf der Platte (E46): ein Verzeichnis rekursiv, einen
727
+ * fremden Symlink als Link, ohne ihm zu folgen, die Arbeitskopie dahinter bleibt unangetastet.
728
+ * Nur Einträge der Art 'fremd', ein hmgr-eigener Link ist ein Fehler, dafür gibt es
729
+ * `linkEntfernen`. Ein nicht vorhandener Eintrag ist ebenso ein Fehler. War der Eintrag ignoriert
730
+ * (E43), wird die Marke mitentfernt, sie zeigte sonst auf Gelöschtes.
731
+ */
732
+ async eintragLoeschen(args) {
733
+ const harness = await this.harnessGeprueft(args.ziel.harness);
734
+ const verzeichnis = harness.zielVerzeichnis(args.ziel.ort);
735
+ const eintraege = await this.p.ziele.eintraege(args.ziel, verzeichnis);
736
+ const eintrag = eintraege.find((e) => (e.art === 'link' ? e.link.name : e.name) === args.name);
737
+ if (eintrag === undefined || eintrag.art !== 'fremd')
738
+ throw new FremderEintragUnbekannt(args.ziel, args.name);
739
+ await this.p.ziele.eintragLoeschen(args.ziel, verzeichnis, args.name);
740
+ await this.eintragBeachten({ ziel: args.ziel, name: args.name });
384
741
  }
385
742
  /**
386
743
  * Plugin an einem Ziel herstellen: mit Plugin-Fähigkeit über die CLI des Harness, sonst als
@@ -416,6 +773,8 @@ export class Anwendungsfaelle {
416
773
  async pluginLegen(args) {
417
774
  const harness = await this.harnessGeprueft(args.ziel.harness);
418
775
  const fund = await this.pluginFinden(args.plugin, args.quelle);
776
+ // Vor jeder Wirkung: kein Plugin, wenn danach das Schreiben in den Zeiger scheitern würde (E57).
777
+ await this.zeigerSchreibenPruefen(args.ziel, args.lokal);
419
778
  const ergebnis = await this.pluginHerstellen(harness, args.ziel, fund);
420
779
  if (args.lokal === true || args.ziel.ort.art !== 'arbeitsverzeichnis')
421
780
  return ergebnis;
@@ -466,6 +825,8 @@ export class Anwendungsfaelle {
466
825
  async pluginEntfernen(args) {
467
826
  const harness = await this.harnessGeprueft(args.ziel.harness);
468
827
  const fund = await this.pluginFinden(args.plugin);
828
+ // Vor jeder Wirkung: kein Entfernen, wenn danach das Schreiben in den Zeiger scheitern würde (E57).
829
+ await this.zeigerSchreibenPruefen(args.ziel, args.lokal);
469
830
  const schluessel = pluginSchluessel(fund.plugin);
470
831
  const entfernt = [];
471
832
  if (kannPlugins(harness)) {
@@ -567,7 +928,7 @@ export class Anwendungsfaelle {
567
928
  return ergebnis;
568
929
  }
569
930
  ergebnis.zeigerPfad = zeigerPfad;
570
- const harnesses = (await this.vorhandeneHarnesses()).filter((h) => h.orte().includes('arbeitsverzeichnis'));
931
+ const harnesses = (await this.vorhandeneAktiveHarnesses()).filter((h) => h.orte().includes('arbeitsverzeichnis'));
571
932
  if (harnesses.length === 0) {
572
933
  ergebnis.hinweise.push('Kein Harness auf diesem Rechner, nichts zu tun (E20).');
573
934
  return ergebnis;
@@ -660,11 +1021,28 @@ export class Anwendungsfaelle {
660
1021
  * Aktualisieren (E14, E15): jeden Checkout unter der Wurzel auf den Stand seines Refs bringen.
661
1022
  * Lokale Quellen haben keinen Checkout und bleiben unberührt (B11). Links zeigen auf die
662
1023
  * Worktree-Pfade, die dabei stehen bleiben, also müssen sie nicht erneuert werden.
1024
+ *
1025
+ * Checkouts, deren Quelle der Zustand nicht führt, bleiben ganz außen vor (E54): sie werden weder
1026
+ * geholt noch als verwaist zum Löschen angeboten. Eine vergessene Quelle ist vergessen, ihre
1027
+ * Checkouts und Links liegen unangetastet, bis die Quelle wieder hinzukommt.
663
1028
  */
664
- async aktualisieren() {
1029
+ async aktualisieren(args) {
1030
+ const rueckfragen = args?.rueckfragen ?? true;
1031
+ const bekannt = new Map((await this.p.zustand.lesen()).quellen.map((q) => [quelleSchluessel(q), q]));
665
1032
  const checkouts = [];
666
- for (const checkout of await this.p.quellen.checkouts())
667
- checkouts.push({ checkout, aktualisierung: await this.p.quellen.aktualisieren(checkout) });
1033
+ for (const checkout of await this.p.quellen.checkouts()) {
1034
+ const gefuehrt = bekannt.get(quelleSchluessel(checkout.quelle));
1035
+ if (gefuehrt === undefined)
1036
+ continue;
1037
+ const aktualisierung = await this.p.quellen.aktualisieren(checkout);
1038
+ // Der Quellen-Port baut Checkouts von der Platte (remote.origin.url), ohne den Zustand zu
1039
+ // befragen: der Anzeigename fehlt dort. Für den Bericht kommt er aus der geführten Quelle
1040
+ // (E59), sonst zeigte eine umbenannte Quelle hier wieder ihren abgeleiteten Namen.
1041
+ checkouts.push({
1042
+ checkout: { ...checkout, quelle: quelleMitNamen(checkout.quelle, gefuehrt.name) },
1043
+ aktualisierung,
1044
+ });
1045
+ }
668
1046
  // Nach den Checkouts die Plugins nachziehen (E14).
669
1047
  const pluginHarnesses = [];
670
1048
  for (const harness of await this.vorhandeneHarnesses()) {
@@ -673,7 +1051,7 @@ export class Anwendungsfaelle {
673
1051
  await harness.plugins.aktualisieren();
674
1052
  pluginHarnesses.push(harness.name);
675
1053
  }
676
- return { checkouts, pluginHarnesses, aufraeumen: await this.verwaisteAufraeumen(checkouts) };
1054
+ return { checkouts, pluginHarnesses, aufraeumen: await this.verwaisteAufraeumen(checkouts, rueckfragen) };
677
1055
  }
678
1056
  /**
679
1057
  * Pfade, in die ein Link zeigt. Gelesen wird wie beim Ist-Zustand (E19): die Ziele der
@@ -697,8 +1075,11 @@ export class Anwendungsfaelle {
697
1075
  * mit lokaler Änderung bleibt liegen und wird nur gemeldet (E15), er kommt gar nicht erst in die
698
1076
  * Frage. Gefragt wird einmal für alle Kandidaten (E28); wo niemand antwortet, gilt Nein und es
699
1077
  * wird nichts gelöscht.
1078
+ *
1079
+ * Bei `rueckfragen: false` (E44, automatischer GUI-Lauf) werden Verwaiste wie sonst ermittelt und
1080
+ * gemeldet, aber weder gefragt noch gelöscht.
700
1081
  */
701
- async verwaisteAufraeumen(checkouts) {
1082
+ async verwaisteAufraeumen(checkouts, rueckfragen) {
702
1083
  const belegt = await this.belegteCheckoutPfade();
703
1084
  const verwaiste = [];
704
1085
  for (const { checkout, aktualisierung } of checkouts) {
@@ -708,6 +1089,8 @@ export class Anwendungsfaelle {
708
1089
  const lokaleAenderung = aktualisierung.ergebnis === 'uebersprungen';
709
1090
  verwaiste.push({ checkout, lokaleAenderung, geloescht: false });
710
1091
  }
1092
+ if (!rueckfragen)
1093
+ return { verwaiste, gefragt: false };
711
1094
  const kandidaten = verwaiste.filter((v) => !v.lokaleAenderung);
712
1095
  if (kandidaten.length === 0)
713
1096
  return { verwaiste, gefragt: false };
@@ -720,14 +1103,123 @@ export class Anwendungsfaelle {
720
1103
  return { verwaiste, gefragt: true };
721
1104
  }
722
1105
  /**
723
- * Übernahme (B12, E31): fremde Symlinks eines Ziels durch eigene Links auf einen hmgr-Checkout
724
- * derselben Quelle am selben Ref ersetzen. Die Arbeitskopie bleibt unberührt und wird nicht als
725
- * Quelle registriert, das ist B11. Ein Eintrag, der nicht auflösbar ist, bleibt liegen und wird
726
- * gemeldet, damit ein Lauf über viele Einträge nicht an einem einzelnen abbricht.
1106
+ * Erkennungskette eines fremden Eintrags (E31, E48), rein lesend, ohne etwas zu verändern. Sie
1107
+ * bedient Prognose (`istZustand`) und Übernahme gleichermaßen:
1108
+ * - Ein Symlink führt in eine Arbeitskopie (B12): Quelle und Ref aus `remote.origin.url` und dem
1109
+ * ausgecheckten Branch, der Skill über den relativen Pfad in der Quelle.
1110
+ * - Sonst gilt ein Ordner mit eigenem `.git` an Ort und Stelle als Arbeitskopie (E48 a): erkannt
1111
+ * nur, wenn sie sauber ist und ihr HEAD im lokalen Stand des Remote-Branchs enthalten ist,
1112
+ * sonst „lokale-aenderungen". Erkannt daran, dass `arbeitskopie` auf den Eintrag selbst
1113
+ * angewandt dessen eigene Wurzel liefert (`relativerPfad === ''`).
1114
+ * - Sonst greift bei einem agents-Ziel das Lockfile der skills-CLI (E48 b, HARNESS-FAKTEN):
1115
+ * `sourceUrl` und `ref` liefern die Quelle, ersetzt wird nur, wenn der Ordnerinhalt dem
1116
+ * festgehaltenen `skillFolderHash` entspricht. Das Hash-Verfahren der skills-CLI ist nur für
1117
+ * Nicht-GitHub-Quellen als SHA-256 über Dateien reproduzierbar (`computeSkillFolderHash`);
1118
+ * für GitHub-Quellen benutzt sie eine Baum-SHA aus der GitHub-API (40 Zeichen statt 64), die
1119
+ * sich ohne Netzzugriff nicht nachbilden lässt. Ein `skillFolderHash` mit 40 Zeichen ergibt
1120
+ * deshalb ehrlich „nicht-pruefbar" statt fälschlich „lokale-aenderungen" (E50); nur ein
1121
+ * nachrechenbarer (64-stelliger) Hash, der abweicht, ergibt „lokale-aenderungen".
1122
+ * - Sonst „kein-link": nichts davon trifft zu.
1123
+ * Ein kaputter Teil (unlesbares Lockfile, Git-Fehler) bricht die Erhebung nie ab, er ergibt einen
1124
+ * Grund. `bekannt` normalisiert die erkannte Quelle auf die schon geführte Schreibweise (E36),
1125
+ * ohne sie selbst zu verändern.
1126
+ *
1127
+ * `nurLesend` trennt Prognose von Übernahme an genau einer Stelle: der Skill-Katalog einer
1128
+ * Quelle, die hmgr noch nicht führt, bleibt ungefragt. `skills()` legte sonst deren Basis-Clone
1129
+ * an, und die Erhebung würde für jeden fremden Symlink ein fremdes Repo aus dem Netz holen und
1130
+ * auf die Platte schreiben. Bekannte Quellen haben ihren Clone schon, sie werden wie bisher
1131
+ * gefragt. Die Prognose nennt dann Quelle und Ref ohne Skill (E48), „skill-unbekannt" fällt in
1132
+ * diesem Fall erst in der Vorschau der Übernahme auf.
1133
+ */
1134
+ async erkennenFremd(harness, verzeichnis, eintrag, bekannt, nurLesend) {
1135
+ const unbekannt = (grund) => ({ art: 'unbekannt', grund });
1136
+ const normalisiert = (kandidat) => bekannt.get(quelleSchluessel(kandidat)) ?? kandidat;
1137
+ /** Skill aus dem Katalog: `null` heißt „nicht darin", `undefined` heißt „nicht gefragt". */
1138
+ const ausKatalog = async (quelle, ref, pfad) => {
1139
+ if (nurLesend && !bekannt.has(quelleSchluessel(quelle)))
1140
+ return undefined;
1141
+ const skills = await this.p.quellen.skills(quelle, ref).catch(() => []);
1142
+ return skills.find((s) => s.pfad === pfad) ?? null;
1143
+ };
1144
+ if (eintrag.linkZiel !== undefined) {
1145
+ const kopie = await this.p.quellen.arbeitskopie(eintrag.linkZiel).catch(() => null);
1146
+ if (kopie === null)
1147
+ return unbekannt('keine-arbeitskopie');
1148
+ if (kopie.url === null)
1149
+ return unbekannt('keine-url');
1150
+ if (kopie.ref === null)
1151
+ return unbekannt('kein-branch');
1152
+ const quelle = normalisiert({ art: 'git', url: kopie.url });
1153
+ const skill = await ausKatalog(quelle, kopie.ref, kopie.relativerPfad);
1154
+ if (skill === null)
1155
+ return unbekannt('skill-unbekannt');
1156
+ return { art: 'erkannt', quelle, ref: kopie.ref, ...(skill === undefined ? {} : { skill }) };
1157
+ }
1158
+ const entryPfad = path.join(verzeichnis, eintrag.name);
1159
+ // (a) Ordner mit eigenem .git an Ort und Stelle (E48).
1160
+ const eigeneKopie = await this.p.quellen.arbeitskopie(entryPfad).catch(() => null);
1161
+ if (eigeneKopie !== null && eigeneKopie.relativerPfad === '') {
1162
+ if (eigeneKopie.url === null)
1163
+ return unbekannt('keine-url');
1164
+ if (eigeneKopie.ref === null)
1165
+ return unbekannt('kein-branch');
1166
+ const quelleRoh = { art: 'git', url: eigeneKopie.url };
1167
+ const sauber = !(await this.p.quellen
1168
+ .lokaleAenderungen({ quelle: quelleRoh, ref: eigeneKopie.ref, pfad: entryPfad })
1169
+ .catch(() => true));
1170
+ const enthalten = sauber && (await this.p.quellen.imRemoteEnthalten(entryPfad, eigeneKopie.ref).catch(() => false));
1171
+ if (!enthalten)
1172
+ return unbekannt('lokale-aenderungen');
1173
+ const quelle = normalisiert(quelleRoh);
1174
+ // Der übliche Katalog (skills()) kennt nur Skills unter einem Unterverzeichnis (E11), ein
1175
+ // Wurzel-Skill der ganzen Arbeitskopie kommt darin nicht vor. Er wird direkt gelesen (E48 a).
1176
+ const skill = await this.p.quellen.wurzelSkill(entryPfad).catch(() => null);
1177
+ if (skill === null)
1178
+ return unbekannt('skill-unbekannt');
1179
+ return { art: 'erkannt', quelle, ref: eigeneKopie.ref, skill };
1180
+ }
1181
+ // (b) Lockfile der skills-CLI, nur für agents-Ziele (E48 b).
1182
+ if (harness.name === 'agents') {
1183
+ const skillsHeim = harness.zielVerzeichnis({ art: 'home' });
1184
+ const lockEintrag = await this.p.ziele
1185
+ .skillLockEintrag(skillsHeim, eintrag.name)
1186
+ .catch(() => undefined);
1187
+ if (lockEintrag !== undefined) {
1188
+ if (lockEintrag.ref === undefined)
1189
+ return unbekannt('kein-branch');
1190
+ if (lockEintrag.skillPath === undefined)
1191
+ return unbekannt('skill-unbekannt');
1192
+ // Eine GitHub-Baum-SHA (40 Hex-Zeichen) statt eines SHA-256 (64) lässt sich lokal nicht
1193
+ // nachrechnen (E50): ehrlich „nicht prüfbar" statt fälschlich „lokale Änderungen".
1194
+ if (lockEintrag.skillFolderHash.length === 40)
1195
+ return unbekannt('nicht-pruefbar');
1196
+ const echterHash = await this.p.ziele.ordnerHash(verzeichnis, eintrag.name).catch(() => undefined);
1197
+ if (echterHash === undefined || echterHash.toLowerCase() !== lockEintrag.skillFolderHash.toLowerCase())
1198
+ return unbekannt('lokale-aenderungen');
1199
+ const quelle = normalisiert({ art: 'git', url: lockEintrag.sourceUrl });
1200
+ const skill = await ausKatalog(quelle, lockEintrag.ref, skillPfadAusLock(lockEintrag.skillPath));
1201
+ if (skill === null)
1202
+ return unbekannt('skill-unbekannt');
1203
+ return { art: 'erkannt', quelle, ref: lockEintrag.ref, ...(skill === undefined ? {} : { skill }) };
1204
+ }
1205
+ }
1206
+ return unbekannt('kein-link');
1207
+ }
1208
+ /**
1209
+ * Übernahme (B12, E31): fremde Einträge eines Ziels durch eigene Links auf einen hmgr-Checkout
1210
+ * derselben Quelle am selben Ref ersetzen. Eine Arbeitskopie, in die ein Symlink führt, bleibt
1211
+ * unberührt und wird nicht als Quelle registriert, das ist B11. Ein Ordner mit eigenem `.git`
1212
+ * oder ein Lockfile-Treffer wird dagegen selbst gelöscht und durch den Link ersetzt (E48 a/b). Ein
1213
+ * Eintrag, der nicht auflösbar ist, bleibt liegen und wird gemeldet, damit ein Lauf über viele
1214
+ * Einträge nicht an einem einzelnen abbricht.
1215
+ *
1216
+ * `probelauf` schreibt weder Zustand noch Checkout noch Link. Die Erkennungskette wird trotzdem
1217
+ * gelesen, sonst wären die Gründe nicht zu haben. Der Git-Adapter legt dabei den Basis-Clone der
1218
+ * Quelle an, das Ziel und der Zustand bleiben unangetastet.
727
1219
  *
728
- * `probelauf` schreibt weder Zustand noch Checkout noch Link. Die Skill-Liste der Quelle wird
729
- * trotzdem gelesen, sonst wäre der Grund „skill-unbekannt" nicht zu haben. Der Git-Adapter legt
730
- * dabei den Basis-Clone der Quelle an, das Ziel und der Zustand bleiben unangetastet.
1220
+ * Mit `name` (E46) betrachtet der Lauf nur diesen einen fremden Eintrag, ohne `name` bleibt es
1221
+ * bei allen fremden Einträgen des Ziels (CLI unverändert). Ein genannter Name, der am Ziel nicht
1222
+ * als fremder Eintrag liegt, ist ein Fehler, gilt für Probelauf wie echten Lauf gleichermaßen.
731
1223
  */
732
1224
  async uebernehmen(args) {
733
1225
  const harness = await this.harnessGeprueft(args.ziel.harness);
@@ -740,60 +1232,93 @@ export class Anwendungsfaelle {
740
1232
  for (const q of (await this.p.zustand.lesen()).quellen)
741
1233
  bekannt.set(quelleSchluessel(q), q);
742
1234
  const eintraege = [];
743
- for (const eintrag of await this.p.ziele.eintraege(args.ziel, verzeichnis)) {
1235
+ const alleEintraege = await this.p.ziele.eintraege(args.ziel, verzeichnis);
1236
+ if (args.name !== undefined) {
1237
+ const gefunden = alleEintraege.find((e) => (e.art === 'link' ? e.link.name : e.name) === args.name);
1238
+ if (gefunden === undefined || gefunden.art !== 'fremd')
1239
+ throw new FremderEintragUnbekannt(args.ziel, args.name);
1240
+ }
1241
+ for (const eintrag of alleEintraege) {
744
1242
  // Eigene Links sind schon fertig, sie kommen nicht in die Meldung.
745
1243
  if (eintrag.art !== 'fremd')
746
1244
  continue;
747
1245
  if (args.name !== undefined && eintrag.name !== args.name)
748
1246
  continue;
749
1247
  const name = eintrag.name;
750
- const liegenLassen = (grund) => void eintraege.push({ ergebnis: 'uebersprungen', name, grund });
751
- if (eintrag.linkZiel === undefined) {
752
- liegenLassen('kein-link');
753
- continue;
754
- }
755
- const kopie = await this.p.quellen.arbeitskopie(eintrag.linkZiel);
756
- if (kopie === null) {
757
- liegenLassen('keine-arbeitskopie');
1248
+ const prognose = await this.erkennenFremd(harness, verzeichnis, eintrag, bekannt, false);
1249
+ if (prognose.art === 'unbekannt') {
1250
+ eintraege.push({ ergebnis: 'uebersprungen', name, grund: prognose.grund });
758
1251
  continue;
759
1252
  }
760
- if (kopie.url === null) {
761
- liegenLassen('keine-url');
762
- continue;
763
- }
764
- // Ohne Branch gibt es keinen Ref, auf den ein Checkout zeigen könnte (detached HEAD).
765
- if (kopie.ref === null) {
766
- liegenLassen('kein-branch');
767
- continue;
768
- }
769
- // Kennt hmgr das Repo schon in anderer Schreibweise, zählt die geführte Fassung (E36).
770
- const gefuehrt = bekannt.get(quelleSchluessel({ art: 'git', url: kopie.url }));
771
- const quelle = gefuehrt ?? { art: 'git', url: kopie.url };
772
- const ref = kopie.ref;
773
- const skills = await this.p.quellen.skills(quelle, ref);
774
- const skill = skills.find((s) => s.pfad === kopie.relativerPfad);
1253
+ const { quelle, ref, skill } = prognose;
1254
+ // Ohne `nurLesend` fragt die Kette den Katalog immer, ein fehlender Skill kommt hier nie an.
775
1255
  if (skill === undefined) {
776
- liegenLassen('skill-unbekannt');
1256
+ eintraege.push({ ergebnis: 'uebersprungen', name, grund: 'skill-unbekannt' });
777
1257
  continue;
778
1258
  }
779
- const quelleNeu = gefuehrt === undefined;
1259
+ const quelleNeu = !bekannt.has(quelleSchluessel(quelle));
780
1260
  bekannt.set(quelleSchluessel(quelle), quelle);
1261
+ const ordnerErsetzt = eintrag.linkZiel === undefined;
781
1262
  if (!probelauf) {
1263
+ // Vor jeder Wirkung (E57): die Übernahme schreibt ohne `lokal` denselben Zeiger wie
1264
+ // `link`. Die Sperre muss vor `quelleHinzufuegen` greifen, sonst bliebe die Quelle im
1265
+ // Zustand stehen, während der Link nie entsteht. Ein früherer Durchlauf kann nichts
1266
+ // geschrieben haben: er wäre an derselben Stelle abgebrochen.
1267
+ await this.zeigerSchreibenPruefen(args.ziel, args.lokal);
1268
+ // Der abgeleitete Anzeigename kann schon vergeben sein (E59). Die Übernahme hat keinen Weg,
1269
+ // den Nutzer nach einem anderen zu fragen, darum weicht sie auf einen freien aus statt an
1270
+ // QuelleNameBelegt zu scheitern; umbenennen lässt er sich hinterher (source rename).
782
1271
  if (quelleNeu)
783
- await this.quelleHinzufuegen(quelle);
1272
+ await this.quelleHinzufuegen(quelle, quelleNameFrei(quelleNameAbleiten(quelle), (await this.p.zustand.lesen()).quellen));
784
1273
  await this.linkAusFund(harness, args.ziel, { quelle, ref, skill }, true, args.lokal);
785
1274
  }
786
- eintraege.push({ ergebnis: 'uebernommen', name, quelle, ref, skill, quelleNeu });
1275
+ // Nur in der Vorschau (E50): welche gitignorierten Dateien beim Ersetzen verloren gingen.
1276
+ // Blockiert nichts, ist reine Sichtbarkeit vor dem echten Lauf.
1277
+ let gitignoriert;
1278
+ if (probelauf && ordnerErsetzt) {
1279
+ const liste = await this.p.quellen
1280
+ .gitignorierteDateien(path.join(verzeichnis, name))
1281
+ .catch(() => []);
1282
+ if (liste.length > 0)
1283
+ gitignoriert = liste;
1284
+ }
1285
+ eintraege.push({
1286
+ ergebnis: 'uebernommen',
1287
+ name,
1288
+ quelle,
1289
+ ref,
1290
+ skill,
1291
+ quelleNeu,
1292
+ ordnerErsetzt,
1293
+ ...(gitignoriert === undefined ? {} : { gitignoriert }),
1294
+ });
787
1295
  }
1296
+ // Ausdrücklich lokal an einem gebundenen Arbeitsverzeichnis (E57, F32): der Zeiger bleibt
1297
+ // unangetastet, ein Hinweis sagt das. Nur wenn wirklich etwas übernommen wurde und nur bei
1298
+ // bestehender Bindung, sonst wäre es Rauschen.
1299
+ if (!probelauf &&
1300
+ args.lokal === true &&
1301
+ args.ziel.ort.art === 'arbeitsverzeichnis' &&
1302
+ eintraege.some((e) => e.ergebnis === 'uebernommen') &&
1303
+ (await this.bindungFinden(args.ziel)) !== undefined)
1304
+ return {
1305
+ ziel: args.ziel,
1306
+ probelauf,
1307
+ eintraege,
1308
+ hinweis: 'Die übernommenen Links liegen nur lokal, der Zeiger bleibt unangetastet (F32).',
1309
+ };
788
1310
  return { ziel: args.ziel, probelauf, eintraege };
789
1311
  }
790
- /** Versionseinschränkungen des Zeigers, an den ein Ziel gebunden ist (E33). Ohne Zeiger leer. */
791
- async einschraenkungen(ziel) {
792
- const ergebnis = new Map();
1312
+ /** Zeiger, an den ein Ziel gebunden ist. Ohne Bindung oder mit unlesbarer Datei: undefined. */
1313
+ async zeigerFuerZiel(ziel) {
793
1314
  const zeigerPfad = await this.bindungFinden(ziel);
794
1315
  if (zeigerPfad === undefined)
795
- return ergebnis;
796
- const zeiger = await this.p.zeiger.lesen(zeigerPfad).catch(() => undefined);
1316
+ return undefined;
1317
+ return await this.p.zeiger.lesen(zeigerPfad).catch(() => undefined);
1318
+ }
1319
+ /** Versionseinschränkungen aus dem Zeiger eines Ziels (E33). Ohne Zeiger leer. */
1320
+ einschraenkungen(zeiger) {
1321
+ const ergebnis = new Map();
797
1322
  if (zeiger === undefined)
798
1323
  return ergebnis;
799
1324
  for (const [name, einschraenkung] of zeiger.skills)
@@ -801,9 +1326,80 @@ export class Anwendungsfaelle {
801
1326
  ergebnis.set(name, einschraenkung);
802
1327
  return ergebnis;
803
1328
  }
1329
+ /**
1330
+ * Plugins, die für ein Ziel als Grund in Frage kommen: bei einem Arbeitsverzeichnis die des
1331
+ * gebundenen Zeigers, bei Home und Profil die aller Marketplaces der bekannten Quellen.
1332
+ */
1333
+ async pluginKandidaten(ziel, zeiger, marktplatzFunde) {
1334
+ const alle = await marktplatzFunde();
1335
+ if (ziel.ort.art !== 'arbeitsverzeichnis')
1336
+ return alle;
1337
+ if (zeiger === undefined)
1338
+ return [];
1339
+ const funde = [];
1340
+ for (const eintrag of zeiger.plugins) {
1341
+ const zerlegt = pluginSchluesselZerlegen(eintrag);
1342
+ for (const fund of alle)
1343
+ if (fund.plugin.name === zerlegt.name &&
1344
+ (zerlegt.marketplace === undefined || fund.plugin.marketplace === zerlegt.marketplace))
1345
+ funde.push(fund);
1346
+ }
1347
+ return funde;
1348
+ }
1349
+ /**
1350
+ * Grund je eigenem Link (E37). Priorität: Plugin vor Zeiger vor manuell. Die Plugin-Zugehörigkeit
1351
+ * steht vorn, weil sie die Einschränkung trägt, dass der Link nicht einzeln entfernt werden darf,
1352
+ * ohne das Plugin zu verletzen. Damit gilt auch ein von Hand gelegter Link, der eine flache
1353
+ * Skill-Liste komplettiert, als Plugin-Link. Diese Unschärfe ist bekannt und akzeptiert.
1354
+ * Bei einem Harness mit Plugin-Fähigkeit sind Plugin-Skills keine hmgr-Links (E16), dort entsteht
1355
+ * der Plugin-Grund nicht. Ist eine Quelle nicht erreichbar, fällt ihr Plugin als Kandidat aus.
1356
+ */
1357
+ async gruende(harness, ziel, eintraege, zeiger, marktplatzFunde) {
1358
+ const gruende = new Map();
1359
+ const eigene = eintraege.filter((e) => e.art !== 'fremd').map((e) => (e.art === 'link' ? e.link.name : e.name));
1360
+ if (eigene.length === 0)
1361
+ return gruende;
1362
+ const eigeneNamen = new Set(eigene);
1363
+ if (!kannPlugins(harness))
1364
+ for (const fund of await this.pluginKandidaten(ziel, zeiger, marktplatzFunde)) {
1365
+ if (!(await this.pluginLiegt(harness, ziel, fund, eintraege).catch(() => false)))
1366
+ continue;
1367
+ const skills = await this.flacheSkillListe(fund.plugin.name, fund.quelle).catch(() => []);
1368
+ const schluessel = pluginSchluessel(fund.plugin);
1369
+ for (const skill of skills)
1370
+ if (eigeneNamen.has(skill.name) && !gruende.has(skill.name))
1371
+ gruende.set(skill.name, { art: 'plugin', plugin: schluessel });
1372
+ }
1373
+ for (const name of eigene)
1374
+ if (!gruende.has(name))
1375
+ gruende.set(name, zeiger?.skills.has(name) === true ? { art: 'zeiger' } : { art: 'manuell' });
1376
+ return gruende;
1377
+ }
804
1378
  /** Ist-Zustand: alle Ziele der vorhandenen Harnesses, aus dem Dateisystem gelesen. */
805
1379
  async istZustand() {
806
1380
  const zustand = await this.p.zustand.lesen();
1381
+ // Je Identität die Quelle, unter deren Schreibweise sie geführt wird (E36, E48), für die
1382
+ // Übernahme-Prognose. Bleibt über den ganzen Lauf gleich, die Prognose verändert nichts.
1383
+ const bekannt = new Map();
1384
+ for (const q of zustand.quellen)
1385
+ bekannt.set(quelleSchluessel(q), q);
1386
+ // Marketplaces der bekannten Quellen, höchstens einmal je Lauf gelesen. Eine unerreichbare
1387
+ // Quelle fällt aus, statt die Erhebung abzubrechen.
1388
+ let gelesene;
1389
+ const marktplatzFunde = async () => {
1390
+ if (gelesene !== undefined)
1391
+ return gelesene;
1392
+ const funde = [];
1393
+ for (const quelle of zustand.quellen) {
1394
+ const marktplatz = await this.marketplaceLesen(quelle).catch(() => null);
1395
+ if (marktplatz === null)
1396
+ continue;
1397
+ for (const plugin of marktplatz.plugins)
1398
+ funde.push({ quelle, ref: marktplatz.ref, plugin });
1399
+ }
1400
+ gelesene = funde;
1401
+ return funde;
1402
+ };
807
1403
  const ergebnis = [];
808
1404
  for (const harness of await this.vorhandeneHarnesses()) {
809
1405
  const ziele = [];
@@ -821,12 +1417,39 @@ export class Anwendungsfaelle {
821
1417
  const plugins = kannPlugins(harness)
822
1418
  ? await harness.plugins.installierte(ziel).catch(() => [])
823
1419
  : undefined;
1420
+ const zeiger = await this.zeigerFuerZiel(ziel);
1421
+ const istRegistriert = kannIstRegistriert(harness) ? await harness.istRegistriert(ziel.ort) : false;
1422
+ // Fähigkeit am Typwächter, nicht am Namen (E16), und nur an Orten, die sie kennt.
1423
+ let skillOrdnerFaehig = false;
1424
+ let verwalteterSkillOrdner;
1425
+ if (kannSkillOrdner(harness) && harness.skillOrdnerOrte().includes(ziel.ort.art)) {
1426
+ skillOrdnerFaehig = true;
1427
+ verwalteterSkillOrdner = await harness.verwalteterSkillOrdner(ziel.ort);
1428
+ }
1429
+ // Ignoriert (E43): nur Namen fremder Einträge, die tatsächlich noch auf der Platte liegen.
1430
+ // Die Ignorier-Liste selbst wird dabei nicht bereinigt.
1431
+ const fremdeNamen = new Set(eintraege.filter((ei) => ei.art === 'fremd').map((ei) => ei.name));
1432
+ const zielSchl = zielSchluessel(ziel);
1433
+ const ignoriert = zustand.ignorierteEintraege
1434
+ .filter((i) => zielSchluessel({ harness: i.harness, ort: i.ort }) === zielSchl && fremdeNamen.has(i.name))
1435
+ .map((i) => i.name);
1436
+ // Übernahme-Prognose je fremdem Eintrag (E48), dieselbe Kette wie `uebernehmen`, nur lesend.
1437
+ const uebernahme = new Map();
1438
+ for (const ei of eintraege)
1439
+ if (ei.art === 'fremd')
1440
+ uebernahme.set(ei.name, await this.erkennenFremd(harness, verzeichnis, ei, bekannt, true));
824
1441
  ergebnis.push({
825
1442
  ziel,
826
1443
  verzeichnis,
827
1444
  eintraege,
828
- einschraenkungen: await this.einschraenkungen(ziel),
1445
+ einschraenkungen: this.einschraenkungen(zeiger),
1446
+ gruende: await this.gruende(harness, ziel, eintraege, zeiger, marktplatzFunde),
1447
+ ignoriert,
1448
+ uebernahme,
1449
+ istRegistriert,
1450
+ kannSkillOrdner: skillOrdnerFaehig,
829
1451
  ...(plugins === undefined ? {} : { plugins }),
1452
+ ...(verwalteterSkillOrdner === undefined ? {} : { verwalteterSkillOrdner }),
830
1453
  });
831
1454
  }
832
1455
  }