hmgr 0.0.4 → 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +10 -0
  2. package/dist/adapter/checkout-pfad.js +10 -2
  3. package/dist/adapter/claude-code.js +251 -23
  4. package/dist/adapter/composer.js +4 -3
  5. package/dist/adapter/datei-text.js +18 -0
  6. package/dist/adapter/dateisystem.js +146 -8
  7. package/dist/adapter/git.js +129 -17
  8. package/dist/adapter/hermes.js +235 -10
  9. package/dist/adapter/marketplace-json.js +2 -1
  10. package/dist/adapter/mcp-katalog.js +124 -0
  11. package/dist/adapter/quelle-text.js +10 -1
  12. package/dist/adapter/skill-md.js +96 -19
  13. package/dist/adapter/terminal.js +1 -1
  14. package/dist/adapter/umgebung.js +43 -0
  15. package/dist/adapter/vergleich.js +20 -0
  16. package/dist/adapter/zustand-json.js +6 -1
  17. package/dist/cli/hilfe.js +227 -26
  18. package/dist/cli/main.js +362 -20
  19. package/dist/gui/beobachter.js +108 -0
  20. package/dist/gui/icons.js +114 -0
  21. package/dist/gui/login-pfad.js +120 -0
  22. package/dist/gui/main.js +823 -0
  23. package/dist/gui/mcp-ansicht.js +57 -0
  24. package/dist/gui/oeffnen.js +33 -0
  25. package/dist/gui/preload.cjs +70 -0
  26. package/dist/gui/renderer/fonts/Inter-LICENSE.txt +92 -0
  27. package/dist/gui/renderer/fonts/InterVariable.woff2 +0 -0
  28. package/dist/gui/renderer/fonts/JetBrainsMono-Medium.woff2 +0 -0
  29. package/dist/gui/renderer/fonts/JetBrainsMono-OFL.txt +93 -0
  30. package/dist/gui/renderer/fonts/JetBrainsMono-Regular.woff2 +0 -0
  31. package/dist/gui/renderer/index.html +581 -0
  32. package/dist/gui/renderer/renderer.js +3227 -0
  33. package/dist/gui/renderer/styles.css +701 -0
  34. package/dist/kern/anwendungsfaelle.js +859 -65
  35. package/dist/kern/modell.js +297 -0
  36. package/dist/kern/quelle-ort.js +14 -1
  37. package/dist/ports/harness.js +16 -0
  38. package/dist/ports/mcp-katalog.js +1 -0
  39. package/dist/ports/umgebung.js +1 -0
  40. package/dist/ports/zustand.js +1 -1
  41. package/package.json +47 -2
@@ -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, McpEbeneNichtUnterstuetzt, McpFaehigkeitFehlt, McpUebernahmeNichtMoeglich, McpUnbekannt, 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, kannMcp, 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,14 +12,34 @@ 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
  }
27
+ /**
28
+ * `${VAR}`- und `${env:VAR}`-Verweise in einem MCP-Konfigblock, ohne Dubletten (E61, F39).
29
+ * Exportiert, damit die GUI dieselbe Prüfung für den Warn-Vermerk an der Katalog-Karte
30
+ * wiederverwenden kann (E61-Folgerunde), statt sie nachzubauen.
31
+ */
32
+ export function variablenVerweise(konfig) {
33
+ const texte = 'command' in konfig
34
+ ? [konfig.command, ...(konfig.args ?? []), ...Object.values(konfig.env ?? {})]
35
+ : [konfig.url, ...Object.values(konfig.headers ?? {})];
36
+ const namen = new Set();
37
+ const muster = /\$\{(?:env:)?([A-Za-z_][A-Za-z0-9_]*)\}/g;
38
+ for (const text of texte)
39
+ for (const treffer of text.matchAll(muster))
40
+ namen.add(treffer[1]);
41
+ return [...namen];
42
+ }
23
43
  /**
24
44
  * Quellenhinweis bei Mehrdeutigkeit (E12): Teilstring des Schlüssels oder der Schreibweise. Beides
25
45
  * zählt, damit `host/besitzer/repo` genauso greift wie ein Stück der URL, unter der die Quelle
@@ -30,6 +50,17 @@ function quellePasst(quelle, hinweis) {
30
50
  return true;
31
51
  return quelleSchluessel(quelle).includes(hinweis) || quelleAlsText(quelle).includes(hinweis);
32
52
  }
53
+ /** Ordnerpfad eines Skills relativ zur Quelle, aus `skillPath` des Lockfiles (E48 b): das `SKILL.md`-Suffix fällt weg. */
54
+ function skillPfadAusLock(skillPath) {
55
+ let pfad = skillPath.replace(/\\/g, '/');
56
+ if (pfad.endsWith('/SKILL.md'))
57
+ pfad = pfad.slice(0, -'/SKILL.md'.length);
58
+ else if (pfad.endsWith('SKILL.md'))
59
+ pfad = pfad.slice(0, -'SKILL.md'.length);
60
+ if (pfad.endsWith('/'))
61
+ pfad = pfad.slice(0, -1);
62
+ return pfad;
63
+ }
33
64
  export class Anwendungsfaelle {
34
65
  p;
35
66
  /** Schon beantwortete Orte, je Ziel der Hinweis, der davon übrig blieb (E34). */
@@ -42,7 +73,7 @@ export class Anwendungsfaelle {
42
73
  harness(name) {
43
74
  const h = this.p.harnesses.find((h) => h.name === name);
44
75
  if (!h)
45
- throw new Error(`Unbekannter Harness „${name}"`);
76
+ throw new HarnessTypUnbekannt(name);
46
77
  return h;
47
78
  }
48
79
  /** Ein namentlich genannter Harness muss auf dem Rechner liegen, sonst passiert nichts Stilles. */
@@ -68,25 +99,423 @@ export class Anwendungsfaelle {
68
99
  ergebnis.push(harness.name);
69
100
  return ergebnis;
70
101
  }
102
+ /** Namen aller Harness-Typen, die hmgr kennt, aktiv oder nicht. Reine Aufzählung, ohne Regel. */
103
+ harnessTypen() {
104
+ return this.p.harnesses.map((h) => h.name);
105
+ }
106
+ /**
107
+ * Harness-Typen, die hmgr verwaltet (E38, GRILL.md Runde 2). Nur für sie richtet hmgr beim
108
+ * Arbeitsverzeichnis-Hinzufügen und beim Abgleich etwas ein. Standard: nur claude-code.
109
+ */
110
+ async aktiveHarnesses() {
111
+ return (await this.p.zustand.lesen()).aktiveHarnesses;
112
+ }
113
+ /** Vorhandene Harnesses, gefiltert auf die aktiven (E38). Für Einrichten-Abläufe über alle Harnesses. */
114
+ async vorhandeneAktiveHarnesses() {
115
+ const aktiv = new Set(await this.aktiveHarnesses());
116
+ return (await this.vorhandeneHarnesses()).filter((h) => aktiv.has(h.name));
117
+ }
118
+ /** Aktiviert einen Harness-Typ (E38). Unbekannter Name ist ein Fehler, schon aktiv ist keiner. */
119
+ async harnessAktivieren(name) {
120
+ this.harness(name); // wirft HarnessTypUnbekannt bei unbekanntem Namen
121
+ const zustand = await this.p.zustand.lesen();
122
+ if (zustand.aktiveHarnesses.includes(name))
123
+ return;
124
+ zustand.aktiveHarnesses.push(name);
125
+ await this.p.zustand.schreiben(zustand);
126
+ }
127
+ /**
128
+ * Deaktiviert einen Harness-Typ (E38). Unbekannter Name ist ein Fehler, schon inaktiv ist keiner.
129
+ * Säubert nicht automatisch: liegende Konfiguration meldet die GUI über `istZustand`, entfernt
130
+ * wird sie erst mit `saeubern`.
131
+ */
132
+ async harnessDeaktivieren(name) {
133
+ this.harness(name); // wirft HarnessTypUnbekannt bei unbekanntem Namen
134
+ const zustand = await this.p.zustand.lesen();
135
+ if (!zustand.aktiveHarnesses.includes(name))
136
+ return;
137
+ zustand.aktiveHarnesses = zustand.aktiveHarnesses.filter((h) => h !== name);
138
+ await this.p.zustand.schreiben(zustand);
139
+ }
140
+ /** Ein Harness mit der Fähigkeit „verwalteter Skill-Ordner" (E56), sonst `SkillOrdnerFaehigkeitFehlt`. */
141
+ harnessMitSkillOrdner(name) {
142
+ const harness = this.harness(name);
143
+ if (!kannSkillOrdner(harness))
144
+ throw new SkillOrdnerFaehigkeitFehlt(name);
145
+ return harness;
146
+ }
147
+ /** Der verwaltete Skill-Ordner eines Ziels (E56), oder `undefined`, wenn keiner verwaltet wird. */
148
+ async verwalteterSkillOrdner(ziel) {
149
+ await this.harnessGeprueft(ziel.harness);
150
+ return this.harnessMitSkillOrdner(ziel.harness).verwalteterSkillOrdner(ziel.ort);
151
+ }
152
+ /**
153
+ * Skill-Ordner festlegen (E56): erlaubt nur, wenn für dieses Ziel noch keiner verwaltet wird
154
+ * (sonst `SkillOrdnerBereitsVerwaltet`). Den genannten Pfad deutet und prüft die Fähigkeit selbst
155
+ * (unterhalb des Hermes-Home, nicht der eigene Ordner), sie kennt den Aufbau; der Kern reicht ihn
156
+ * unverändert durch und gibt den aufgelösten Pfad zurück.
157
+ */
158
+ async skillOrdnerFestlegen(ziel, pfad) {
159
+ const harness = await this.harnessGeprueft(ziel.harness);
160
+ const faehig = this.harnessMitSkillOrdner(harness.name);
161
+ const vorhanden = await faehig.verwalteterSkillOrdner(ziel.ort);
162
+ if (vorhanden !== undefined)
163
+ throw new SkillOrdnerBereitsVerwaltet(ziel, vorhanden);
164
+ return await faehig.skillOrdnerFestlegen(ziel.ort, pfad);
165
+ }
166
+ /**
167
+ * Skill-Ordner entfernen (E56): nur der Eintrag in der Harness-Konfiguration verschwindet, der
168
+ * Ordner bleibt liegen. Erhebt vorher den Inhalt (hmgr-Links, fremde Einträge), wie `istZustand`
169
+ * ihn für dieses Ziel zeigen würde, fürs anschließende Lösch-Angebot des Aufrufers. Ohne
170
+ * verwalteten Eintrag: `SkillOrdnerNichtVerwaltet`.
171
+ */
172
+ async skillOrdnerEntfernen(ziel) {
173
+ const harness = await this.harnessGeprueft(ziel.harness);
174
+ const faehig = this.harnessMitSkillOrdner(harness.name);
175
+ const pfad = await faehig.verwalteterSkillOrdner(ziel.ort);
176
+ if (pfad === undefined)
177
+ throw new SkillOrdnerNichtVerwaltet(ziel);
178
+ const eintraege = await this.p.ziele.eintraege(ziel, pfad).catch(() => []);
179
+ const hmgrLinks = eintraege.filter((e) => e.art !== 'fremd').length;
180
+ const fremdeEintraege = eintraege.filter((e) => e.art === 'fremd').length;
181
+ await faehig.skillOrdnerEntfernen(ziel.ort);
182
+ return { ziel, pfad, hmgrLinks, fremdeEintraege };
183
+ }
184
+ /**
185
+ * Den zuvor verwalteten Skill-Ordner mit Inhalt löschen (E56), ein eigener, destruktiver Schritt
186
+ * nach `skillOrdnerEntfernen`. Die Bestätigung liegt beim Aufrufer (CLI über den Rückfrage-Port,
187
+ * GUI über einen eigenen Dialog), hier nur die Ausführung samt Sicherheitsnetz in der Fähigkeit.
188
+ */
189
+ async skillOrdnerLoeschen(ziel, pfad) {
190
+ const harness = await this.harnessGeprueft(ziel.harness);
191
+ await this.harnessMitSkillOrdner(harness.name).skillOrdnerLoeschen(ziel.ort, pfad);
192
+ }
193
+ /** Ein Harness mit der Fähigkeit MCP-Server (E61), sonst `McpFaehigkeitFehlt`. */
194
+ harnessMitMcp(name) {
195
+ const harness = this.harness(name);
196
+ if (!kannMcp(harness))
197
+ throw new McpFaehigkeitFehlt(name);
198
+ return harness;
199
+ }
200
+ /** MCP-Katalog, unverändert wie in ~/.hmgr/mcp.yaml gepflegt (E61). hmgr schreibt ihn nie. */
201
+ async mcpKatalog() {
202
+ return await this.p.mcpKatalog.lesen();
203
+ }
204
+ async mcpEintragFinden(mcpName) {
205
+ const eintrag = (await this.p.mcpKatalog.lesen()).find((e) => e.name === mcpName);
206
+ if (eintrag === undefined)
207
+ throw new McpUnbekannt(mcpName);
208
+ return eintrag;
209
+ }
210
+ /**
211
+ * F39: fehlende `${VAR}`-Verweise als Warnung, nie als Abbruch. Geprüft wird gegen die eigene
212
+ * Prozess-Umgebung und, wenn die Fähigkeit eine `.env`-Datei nennt (Hermes), zusätzlich gegen sie.
213
+ */
214
+ async mcpVariablenWarnung(faehig, eintrag, ort) {
215
+ const verweise = variablenVerweise(eintrag.konfig);
216
+ if (verweise.length === 0)
217
+ return undefined;
218
+ const prozessEnv = this.p.umgebung.variablen();
219
+ const envPfad = faehig.mcp.envDatei?.(ort);
220
+ const zusatzEnv = envPfad === undefined ? {} : await this.p.umgebung.envDatei(envPfad);
221
+ const fehlend = verweise.filter((v) => !(v in prozessEnv) && !(v in zusatzEnv));
222
+ if (fehlend.length === 0)
223
+ return undefined;
224
+ const mehrzahl = fehlend.length > 1;
225
+ return (`hmgr kann nicht wissen, was die Shell beim Start des Harness setzt: ` +
226
+ `${mehrzahl ? 'Variablen' : 'Variable'} ${fehlend.join(', ')} ${mehrzahl ? 'sind' : 'ist'} weder in der ` +
227
+ 'aktuellen Umgebung noch in einer bekannten .env-Datei zu finden.');
228
+ }
229
+ /**
230
+ * MCP einhängen (E61): Katalog-Eintrag an ein Ziel legen, Projekt oder global. Die Fähigkeit hängt
231
+ * am Adapter (E16): ein Harness ohne MCP-Fähigkeit oder ohne diese Ebene ist ein Fehler. Ein
232
+ * fehlender `${VAR}`-Verweis bricht nicht ab, sondern kommt als Warnung zurück (F39).
233
+ */
234
+ async mcpEinhaengen(args) {
235
+ const eintrag = await this.mcpEintragFinden(args.name);
236
+ const harness = await this.harnessGeprueft(args.ziel.harness);
237
+ const faehig = this.harnessMitMcp(harness.name);
238
+ if (!faehig.mcp.ebenen().includes(args.ziel.ort.art))
239
+ throw new McpEbeneNichtUnterstuetzt(harness.name, args.ziel.ort.art);
240
+ await faehig.mcp.einhaengen(eintrag.name, eintrag.konfig, args.ziel.ort);
241
+ const warnung = await this.mcpVariablenWarnung(faehig, eintrag, args.ziel.ort);
242
+ const hinweis = faehig.mcp.hinweisNachAenderung?.();
243
+ return {
244
+ eintrag,
245
+ ziel: args.ziel,
246
+ ...(hinweis === undefined ? {} : { hinweis }),
247
+ ...(warnung === undefined ? {} : { warnung }),
248
+ };
249
+ }
250
+ /** MCP aushängen (E61): Gegenstück zu `mcpEinhaengen`, ohne F39-Prüfung. */
251
+ async mcpAushaengen(args) {
252
+ const eintrag = await this.mcpEintragFinden(args.name);
253
+ const harness = await this.harnessGeprueft(args.ziel.harness);
254
+ const faehig = this.harnessMitMcp(harness.name);
255
+ if (!faehig.mcp.ebenen().includes(args.ziel.ort.art))
256
+ throw new McpEbeneNichtUnterstuetzt(harness.name, args.ziel.ort.art);
257
+ await faehig.mcp.aushaengen(eintrag.name, args.ziel.ort);
258
+ const hinweis = faehig.mcp.hinweisNachAenderung?.();
259
+ return { eintrag, ziel: args.ziel, ...(hinweis === undefined ? {} : { hinweis }) };
260
+ }
261
+ /**
262
+ * MCP übernehmen (E64, F48=O2): einen Eintrag aus der geteilten Konfigurationsdatei eines Ortes
263
+ * (bei Claude Code die `.mcp.json` der Projektwurzel) in den privaten Bereich desselben Ortes
264
+ * holen — eintragen und aus der geteilten Datei entfernen. Der Konfigblock kommt 1:1 aus der
265
+ * Datei, nicht aus dem Katalog: es gibt Einträge, die der Katalog nicht kennt, und beide Sorten
266
+ * müssen gehen. Die Fähigkeit hängt am Adapter (E16), nicht am Namen: fehlt sie, kennt dieser
267
+ * Harness-Typ keine geteilte Datei.
268
+ */
269
+ async mcpUebernehmen(args) {
270
+ const harness = await this.harnessGeprueft(args.ziel.harness);
271
+ const faehig = this.harnessMitMcp(harness.name);
272
+ const uebernehmen = faehig.mcp.geteiltenUebernehmen;
273
+ if (uebernehmen === undefined)
274
+ throw new McpUebernahmeNichtMoeglich(args.ziel);
275
+ const { datei } = await uebernehmen.call(faehig.mcp, args.name, args.ziel.ort);
276
+ const hinweis = faehig.mcp.hinweisNachAenderung?.();
277
+ return { name: args.name, ziel: args.ziel, datei, ...(hinweis === undefined ? {} : { hinweis }) };
278
+ }
279
+ /**
280
+ * Alle Orte, an denen MCP-Server grundsätzlich hängen können (E61): je Harness mit der Fähigkeit
281
+ * (E16, am Adapter abgefragt, nie am Namen) seine Ebenen aufgelöst zu tatsächlichen Zielen — Home,
282
+ * jedes bekannte Profil, jedes bekannte Arbeitsverzeichnis. Unabhängig davon, ob dort schon etwas
283
+ * hängt oder der Harness gerade aktiv ist (E38): das entscheidet der Aufrufer. Geteilt zwischen
284
+ * `mcpUebersicht` (dort mit `eingehaengte` abgeglichen) und `mcpPlaetze` (roh für den
285
+ * Einhängen-Dialog der GUI).
286
+ */
287
+ async mcpZiele() {
288
+ const ziele = [];
289
+ const zustand = await this.p.zustand.lesen();
290
+ for (const harness of await this.vorhandeneHarnesses()) {
291
+ if (!kannMcp(harness))
292
+ continue;
293
+ const ebenen = harness.mcp.ebenen();
294
+ if (ebenen.includes('home'))
295
+ ziele.push({ harness: harness.name, ort: { art: 'home' } });
296
+ if (ebenen.includes('profil') && kannProfile(harness))
297
+ for (const name of await harness.profile())
298
+ ziele.push({ harness: harness.name, ort: { art: 'profil', name } });
299
+ if (ebenen.includes('arbeitsverzeichnis'))
300
+ for (const pfad of zustand.arbeitsverzeichnisse)
301
+ ziele.push(arbeitsverzeichnisZiel(harness.name, pfad));
302
+ }
303
+ return ziele;
304
+ }
305
+ /**
306
+ * Die Plätze für den Einhängen-Dialog der GUI (E61-Folgerunde, F42): dieselbe Liste wie in
307
+ * `mcpUebersicht`, aber roh, ohne den Abgleich mit Katalog-Einträgen. Die Fähigkeit hängt am
308
+ * Adapter, nicht am Harness-Namen (E16): agents kommt nie vor, weil es `mcp` nicht implementiert.
309
+ */
310
+ async mcpPlaetze() {
311
+ return this.mcpZiele();
312
+ }
313
+ /**
314
+ * Übersicht für `hmgr mcp list` (E61): je Katalog-Eintrag, wo er eingehängt ist, dazu Einträge in
315
+ * Harness-Konfigurationen, die nicht im Katalog stehen (nur genannt, nicht angefasst).
316
+ */
317
+ async mcpUebersicht() {
318
+ const katalog = (await this.p.mcpKatalog.lesen()).map((eintrag) => ({
319
+ eintrag,
320
+ eingehaengt: [],
321
+ geteilteFundorte: [],
322
+ }));
323
+ const nachName = new Map(katalog.map((k) => [k.eintrag.name, k]));
324
+ const unbekannt = [];
325
+ const unbekannteGeteilteFundorte = [];
326
+ for (const ziel of await this.mcpZiele()) {
327
+ const faehig = this.harnessMitMcp(ziel.harness);
328
+ const namen = await faehig.mcp.eingehaengte(ziel.ort).catch(() => []);
329
+ for (const mcpName of namen) {
330
+ const eintrag = nachName.get(mcpName);
331
+ if (eintrag !== undefined)
332
+ eintrag.eingehaengt.push(ziel);
333
+ else
334
+ unbekannt.push({ ziel, name: mcpName });
335
+ }
336
+ const geteilt = (await faehig.mcp.geteilteFundorte?.(ziel.ort).catch(() => [])) ?? [];
337
+ for (const fundort of geteilt) {
338
+ const eintrag = nachName.get(fundort.name);
339
+ if (eintrag !== undefined)
340
+ eintrag.geteilteFundorte.push({ ziel, datei: fundort.datei, scope: fundort.scope });
341
+ else
342
+ unbekannteGeteilteFundorte.push({ ziel, name: fundort.name, datei: fundort.datei, scope: fundort.scope });
343
+ }
344
+ }
345
+ return { katalog, unbekannt, unbekannteGeteilteFundorte };
346
+ }
347
+ /**
348
+ * Säubern (E38): entfernt in allen Zielen dieses Harness-Typs (Home, Profile, bekannte
349
+ * Arbeitsverzeichnisse) die von hmgr angelegten Links und baut harness-eigene Einträge zurück, die
350
+ * hmgr angelegt hat (etwa Hermes' external_dirs-Eintrag). Fremde Einträge bleiben liegen. Läuft
351
+ * unabhängig davon, ob der Harness aktiv ist oder auf dem Rechner liegt, damit auch eine
352
+ * Deinstallation sauber aufgeräumt werden kann.
353
+ */
354
+ async saeubern(harnessName) {
355
+ const harness = this.harness(harnessName); // wirft HarnessTypUnbekannt bei unbekanntem Namen
356
+ const zustand = await this.p.zustand.lesen();
357
+ const ziele = [];
358
+ if (harness.orte().includes('home'))
359
+ ziele.push({ harness: harness.name, ort: { art: 'home' } });
360
+ if (harness.orte().includes('profil') && kannProfile(harness))
361
+ for (const name of await harness.profile())
362
+ ziele.push({ harness: harness.name, ort: { art: 'profil', name } });
363
+ if (harness.orte().includes('arbeitsverzeichnis'))
364
+ for (const pfad of zustand.arbeitsverzeichnisse)
365
+ ziele.push(arbeitsverzeichnisZiel(harness.name, pfad));
366
+ const zielErgebnisse = [];
367
+ for (const ziel of ziele) {
368
+ const verzeichnis = harness.zielVerzeichnis(ziel.ort);
369
+ const eintraege = await this.p.ziele.eintraege(ziel, verzeichnis).catch(() => []);
370
+ const entfernteLinks = [];
371
+ for (const eintrag of eintraege) {
372
+ if (eintrag.art === 'fremd')
373
+ continue; // fremde Einträge bleiben liegen
374
+ const name = eintrag.art === 'link' ? eintrag.link.name : eintrag.name;
375
+ await this.p.ziele.linkEntfernen(ziel, verzeichnis, name);
376
+ entfernteLinks.push(name);
377
+ }
378
+ const konfigurationZurueckgebaut = kannZurueckbauen(harness) ? await harness.zurueckbauen(ziel.ort) : false;
379
+ zielErgebnisse.push({ ziel, entfernteLinks, konfigurationZurueckgebaut });
380
+ }
381
+ return { harness: harness.name, ziele: zielErgebnisse };
382
+ }
71
383
  /**
72
384
  * Quelle hinzufügen: prüft, dass sie erreichbar ist, und liest Skills und Marketplace (E24).
73
385
  * Steht dieselbe Quelle schon in einer anderen Schreibweise im Zustand, kommt keine zweite dazu
74
386
  * (E36). Gelesen wird dann unter der Schreibweise, unter der sie geführt wird, denn mit ihr ist
75
387
  * der Basis-Clone angelegt. `bekannt` sagt, ob es so kam, `quelle` nennt die geführte Fassung.
388
+ *
389
+ * Anzeigename (E59): ohne `name` wird der abgeleitete Name gespeichert. Namen sind eindeutig,
390
+ * eine andere geführte Quelle mit demselben Anzeigenamen ist ein Fehler (`QuelleNameBelegt`).
391
+ * Bei einer schon bekannten Quelle wird ihr Name nicht überschrieben, `name` bleibt ohne Wirkung.
76
392
  */
77
- async quelleHinzufuegen(quelle) {
393
+ async quelleHinzufuegen(quelle, name) {
78
394
  const zustand = await this.p.zustand.lesen();
79
395
  const key = quelleSchluessel(quelle);
80
396
  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);
397
+ if (gefuehrt !== undefined) {
398
+ const ref = await this.p.quellen.standardRef(gefuehrt);
399
+ const skills = await this.p.quellen.skills(gefuehrt, ref);
400
+ const marketplace = await this.p.quellen.marketplace(gefuehrt, ref);
401
+ return { quelle: gefuehrt, skills, marketplace, bekannt: true };
402
+ }
403
+ const angefragterName = name?.trim();
404
+ const effektiverName = angefragterName !== undefined && angefragterName.length > 0 ? angefragterName : quelleNameAbleiten(quelle);
405
+ const kollision = zustand.quellen.find((q) => quelleName(q) === effektiverName);
406
+ if (kollision !== undefined)
407
+ throw new QuelleNameBelegt(effektiverName, kollision);
408
+ const neu = { ...quelle, name: effektiverName };
409
+ const ref = await this.p.quellen.standardRef(neu);
410
+ const skills = await this.p.quellen.skills(neu, ref);
411
+ const marketplace = await this.p.quellen.marketplace(neu, ref);
412
+ zustand.quellen.push(neu);
413
+ await this.p.zustand.schreiben(zustand);
414
+ return { quelle: neu, skills, marketplace, bekannt: false };
415
+ }
416
+ /**
417
+ * Quelle umbenennen (E59): ändert nur den Anzeigenamen, die Identität bleibt `quelleSchluessel`
418
+ * (E36). Referenz über URL/Pfad wie `quelleEntfernen`, eine unbekannte Quelle ist ein Fehler.
419
+ * Namen sind eindeutig, eine Kollision mit einer anderen geführten Quelle bricht ab, ohne zu
420
+ * schreiben. Gibt die geführte Quelle mit dem neuen Namen zurück.
421
+ */
422
+ async quelleUmbenennen(quelle, name) {
423
+ const zustand = await this.p.zustand.lesen();
424
+ const key = quelleSchluessel(quelle);
425
+ const gefuehrt = zustand.quellen.find((q) => quelleSchluessel(q) === key);
426
+ if (gefuehrt === undefined)
427
+ throw new QuelleUnbekannt(quelle);
428
+ const neuerName = name.trim();
429
+ // Ein leerer Name wäre kein Name: `quelleName` fiele still auf den abgeleiteten zurück, an der
430
+ // Eindeutigkeitsprüfung vorbei. Lieber ein Fehler als zwei Quellen mit derselben Anzeige.
431
+ if (neuerName.length === 0)
432
+ throw new Error('Der Anzeigename darf nicht leer sein.');
433
+ const kollision = zustand.quellen.find((q) => quelleSchluessel(q) !== key && quelleName(q) === neuerName);
434
+ if (kollision !== undefined)
435
+ throw new QuelleNameBelegt(neuerName, kollision);
436
+ const umbenannt = { ...gefuehrt, name: neuerName };
437
+ zustand.quellen = zustand.quellen.map((q) => (quelleSchluessel(q) === key ? umbenannt : q));
438
+ await this.p.zustand.schreiben(zustand);
439
+ return umbenannt;
440
+ }
441
+ /**
442
+ * Quelle entfernen: reines Vergessen aus dem Zustand (E54), wie beim Arbeitsverzeichnis (E52).
443
+ * Checkouts und Links bleiben liegen, bestehende Links gelten danach als „Link ohne Quelle"
444
+ * (das erkennt der Dateisystem-Adapter von selbst). Identität über `quelleSchluessel` (E36); eine
445
+ * unbekannte Quelle ist ein Fehler. Gibt die geführte Fassung zurück, für eine Meldung, die dieselbe
446
+ * Schreibweise nennt wie source list, auch wenn der Aufruf eine andere Schreibweise genannt hat.
447
+ */
448
+ async quelleEntfernen(quelle) {
449
+ const zustand = await this.p.zustand.lesen();
450
+ const key = quelleSchluessel(quelle);
451
+ const gefuehrt = zustand.quellen.find((q) => quelleSchluessel(q) === key);
452
+ if (gefuehrt === undefined)
453
+ throw new QuelleUnbekannt(quelle);
454
+ zustand.quellen = zustand.quellen.filter((q) => quelleSchluessel(q) !== key);
455
+ await this.p.zustand.schreiben(zustand);
456
+ return gefuehrt;
457
+ }
458
+ /**
459
+ * Inventar einer Quelle für das Aufräum-Angebot nach dem Entfernen (E58): alle von hmgr gelegten
460
+ * Links über alle Ziele (auch die, die ein Plugin für Harnesses ohne Plugin-Fähigkeit aufgelöst
461
+ * hat, sie stehen als gewöhnliche Links da), alle Checkouts der Quelle, dazu nur zum Nennen:
462
+ * installierte Plugins aus ihrem Marketplace und Zeiger, die sie führen. Muss VOR `quelleEntfernen`
463
+ * laufen: danach gelten ihre Links nur noch als „Link ohne Quelle" und die Zuordnung ist weg.
464
+ */
465
+ async quelleAufraeumInventar(quelle) {
466
+ const key = quelleSchluessel(quelle);
467
+ const zustand = await this.p.zustand.lesen();
468
+ const marktplatz = await this.marketplaceLesen(quelle).catch(() => null);
469
+ const links = [];
470
+ const plugins = new Set();
471
+ for (const zielZustand of await this.istZustand()) {
472
+ for (const eintrag of zielZustand.eintraege) {
473
+ if (eintrag.art !== 'link')
474
+ continue;
475
+ if (quelleSchluessel(eintrag.link.checkout.quelle) !== key)
476
+ continue;
477
+ links.push({ ziel: zielZustand.ziel, verzeichnis: zielZustand.verzeichnis, name: eintrag.link.name });
478
+ }
479
+ if (marktplatz === null || zielZustand.plugins === undefined)
480
+ continue;
481
+ for (const eintrag of zielZustand.plugins) {
482
+ if (pluginSchluesselZerlegen(eintrag).marketplace === marktplatz.name)
483
+ plugins.add(eintrag);
484
+ }
88
485
  }
89
- return { quelle: wirksam, skills, marketplace, bekannt: gefuehrt !== undefined };
486
+ const checkouts = (await this.p.quellen.checkouts()).filter((c) => quelleSchluessel(c.quelle) === key);
487
+ // Lokale Änderungen (E15, F31a): vorher erhoben, damit quelleAufraeumen VOR der ersten Löschung
488
+ // prüfen kann, statt einen Checkout mittendrin scheitern zu lassen.
489
+ const checkoutsMitLokalenAenderungen = [];
490
+ for (const checkout of checkouts) {
491
+ if (await this.p.quellen.lokaleAenderungen(checkout))
492
+ checkoutsMitLokalenAenderungen.push(checkout);
493
+ }
494
+ // Zeiger-Einträge (E57): nur zählen/nennen, welche Bindungen die Quelle in ihrem Zeiger führen.
495
+ const zeigerPfade = new Set();
496
+ for (const bindung of zustand.bindungen) {
497
+ const zeiger = await this.p.zeiger.lesen(bindung.zeigerPfad).catch(() => undefined);
498
+ if (zeiger !== undefined && zeiger.quellen.some((q) => quelleSchluessel(q) === key))
499
+ zeigerPfade.add(bindung.zeigerPfad);
500
+ }
501
+ return { quelle, links, checkouts, checkoutsMitLokalenAenderungen, plugins: [...plugins], zeigerPfade: [...zeigerPfade] };
502
+ }
503
+ /**
504
+ * Räumt das zuvor mit `quelleAufraeumInventar` erhobene Inventar ab (E58): jeden Link direkt über
505
+ * den Ziele-Port entfernen, nicht über den Anwendungsfall `linkEntfernen` (der Zeiger-Arbeit macht
506
+ * bzw. jetzt E57 würfe), danach jeden Checkout von der Platte nehmen. Zeiger und Plugins bleiben
507
+ * unangetastet (E57, E58): sie waren im Inventar nur genannt. Hat ein Checkout lokale Änderungen
508
+ * (E15), bricht der Aufruf VOR der ersten Löschung mit `QuelleAufraeumenLokaleAenderungen` ab,
509
+ * kein stilles Teil-Überspringen (F31a).
510
+ */
511
+ async quelleAufraeumen(inventar) {
512
+ if (inventar.checkoutsMitLokalenAenderungen.length > 0)
513
+ throw new QuelleAufraeumenLokaleAenderungen(inventar.checkoutsMitLokalenAenderungen);
514
+ for (const link of inventar.links)
515
+ await this.p.ziele.linkEntfernen(link.ziel, link.verzeichnis, link.name);
516
+ for (const checkout of inventar.checkouts)
517
+ await this.p.quellen.entfernen(checkout);
518
+ return { entfernteLinks: inventar.links.length, entfernteCheckouts: inventar.checkouts.length };
90
519
  }
91
520
  /** Marketplace einer Quelle am Standard-Ref, mit den Plugins als Modellwerte. */
92
521
  async marketplaceLesen(quelle) {
@@ -105,6 +534,31 @@ export class Anwendungsfaelle {
105
534
  })),
106
535
  };
107
536
  }
537
+ /** Die bekannten Quellen, ohne sie zu lesen. Für Listen, die nur die Herkunft nennen. */
538
+ async quellen() {
539
+ return (await this.p.zustand.lesen()).quellen;
540
+ }
541
+ /**
542
+ * Skill-Katalog aller bekannten Quellen (E40 R3): je Quelle ihre Skills, mit `beschreibung`, wo
543
+ * vorhanden (E39). Für einen künftigen CLI-Weg (etwa `hmgr skill list`) gedacht, heute schon von
544
+ * der GUI benutzt. Eine nicht erreichbare Quelle bekommt einen Fehlervermerk statt den Lauf
545
+ * abzubrechen, wie bei `istZustand` und `marketplaces`.
546
+ */
547
+ async katalog() {
548
+ const zustand = await this.p.zustand.lesen();
549
+ const ergebnis = [];
550
+ for (const quelle of zustand.quellen) {
551
+ try {
552
+ const ref = await this.p.quellen.standardRef(quelle);
553
+ const skills = await this.p.quellen.skills(quelle, ref);
554
+ ergebnis.push({ quelle, ref, skills });
555
+ }
556
+ catch (fehler) {
557
+ ergebnis.push({ quelle, skills: [], fehler: fehlertext(fehler) });
558
+ }
559
+ }
560
+ return ergebnis;
561
+ }
108
562
  /** Alle Marketplaces der bekannten Quellen, für `hmgr plugin list`. */
109
563
  async marketplaces() {
110
564
  const zustand = await this.p.zustand.lesen();
@@ -207,7 +661,7 @@ export class Anwendungsfaelle {
207
661
  zustand.arbeitsverzeichnisse.push(verzeichnis);
208
662
  if (zeigerPfad !== undefined) {
209
663
  const gebunden = zeigerPfad;
210
- for (const harness of await this.vorhandeneHarnesses()) {
664
+ for (const harness of await this.vorhandeneAktiveHarnesses()) {
211
665
  if (!harness.orte().includes('arbeitsverzeichnis'))
212
666
  continue;
213
667
  const ziel = arbeitsverzeichnisZiel(harness.name, verzeichnis);
@@ -256,6 +710,19 @@ export class Anwendungsfaelle {
256
710
  const zustand = await this.p.zustand.lesen();
257
711
  return zustand.bindungen.find((b) => zielSchluessel(b.ziel) === schluessel)?.zeigerPfad;
258
712
  }
713
+ /**
714
+ * Wächter vor jeder Wirkung, die ohne `--local` tatsächlich in einen Zeiger schreiben würde
715
+ * (E57): Ziel ist ein Arbeitsverzeichnis, dafür existiert eine Bindung, und `lokal` ist nicht
716
+ * gesetzt. Dann wirft er `ZeigerSchreibenGesperrt`, bevor irgendein Link gelegt, entfernt oder
717
+ * ein Plugin installiert bzw. deinstalliert wird, also nie eine halbe Wirkung. Ohne Bindung oder
718
+ * mit `lokal: true` bleibt das Verhalten unverändert (E26).
719
+ */
720
+ async zeigerSchreibenPruefen(ziel, lokal) {
721
+ if (lokal === true || ziel.ort.art !== 'arbeitsverzeichnis')
722
+ return;
723
+ if ((await this.bindungFinden(ziel)) !== undefined)
724
+ throw new ZeigerSchreibenGesperrt(ziel);
725
+ }
259
726
  /** Skill über alle Quellen suchen. Mehrere Treffer sind nur mit Quellenhinweis erlaubt. */
260
727
  async skillFinden(name, quelleHinweis) {
261
728
  const zustand = await this.p.zustand.lesen();
@@ -323,6 +790,9 @@ export class Anwendungsfaelle {
323
790
  const zeigerPfad = await this.bindungFinden(args.ziel);
324
791
  if (zeigerPfad === undefined)
325
792
  throw new EinschraenkungOhneZeiger(args.ziel, version);
793
+ // Eine Versionseinschränkung schreibt immer geteilt (siehe EinschraenkungOhneZeiger oben), das
794
+ // trifft die Sperre also ausnahmslos, solange sie steht (E57).
795
+ await this.zeigerSchreibenPruefen(args.ziel, args.lokal);
326
796
  const fund = await this.fundAusZeiger(args.skill, version, args.quelle);
327
797
  const link = await this.linkAnlegen(harness, args.ziel, fund, args.ersetzen);
328
798
  await this.zeigerErgaenzen(zeigerPfad, fund, version);
@@ -331,6 +801,8 @@ export class Anwendungsfaelle {
331
801
  }
332
802
  /** Link legen, wenn Quelle, Ref und Skill schon feststehen. Die Zeiger-Arbeit ist dieselbe. */
333
803
  async linkAusFund(harness, ziel, fund, ersetzen, lokal) {
804
+ // Vor jeder Wirkung: kein Link, wenn danach das Schreiben in den Zeiger scheitern würde (E57).
805
+ await this.zeigerSchreibenPruefen(ziel, lokal);
334
806
  const link = await this.linkAnlegen(harness, ziel, fund, ersetzen);
335
807
  const hinweise = [];
336
808
  if (lokal !== true && ziel.ort.art === 'arbeitsverzeichnis') {
@@ -341,6 +813,12 @@ export class Anwendungsfaelle {
341
813
  else
342
814
  await this.zeigerErgaenzen(zeigerPfad, fund);
343
815
  }
816
+ else if (lokal === true && ziel.ort.art === 'arbeitsverzeichnis' && (await this.bindungFinden(ziel)) !== undefined) {
817
+ // Zeiger-Bindung vorhanden, aber ausdrücklich lokal (E57, F32): der Zeiger bleibt unangetastet,
818
+ // ein Hinweis sagt das, damit es niemand für einen geteilten Link hält. Ohne Skill-Namen, damit
819
+ // die GUI ihn nach einem Lauf über mehrere Skills einmal statt N-mal zeigen kann.
820
+ hinweise.push('Der Link liegt nur lokal, der Zeiger bleibt unangetastet (F32).');
821
+ }
344
822
  const vomHarness = await this.harnessHinweis(harness, ziel.ort);
345
823
  if (vomHarness !== undefined)
346
824
  hinweise.push(vomHarness);
@@ -368,19 +846,68 @@ export class Anwendungsfaelle {
368
846
  this.beantwortet.set(schluessel, hinweis);
369
847
  return hinweis;
370
848
  }
371
- /** Link entfernen. Ohne `lokal` verschwindet der Skill auch aus dem Zeiger (E17). */
849
+ /**
850
+ * Link entfernen. Ohne `lokal` verschwindet der Skill auch aus dem Zeiger (E17). Mit `lokal` an
851
+ * einem gebundenen Ziel bleibt der Zeiger unangetastet, ein Hinweis sagt das (E57, F32).
852
+ */
372
853
  async linkEntfernen(args) {
373
854
  const harness = await this.harnessGeprueft(args.ziel.harness);
855
+ // Vor jeder Wirkung: kein Entfernen, wenn danach das Schreiben in den Zeiger scheitern würde (E57).
856
+ await this.zeigerSchreibenPruefen(args.ziel, args.lokal);
374
857
  await this.p.ziele.linkEntfernen(args.ziel, harness.zielVerzeichnis(args.ziel.ort), args.skill);
858
+ if (args.lokal === true && args.ziel.ort.art === 'arbeitsverzeichnis') {
859
+ const zeigerPfad = await this.bindungFinden(args.ziel);
860
+ if (zeigerPfad !== undefined)
861
+ return { hinweis: 'Der Link ist nur lokal entfernt, der Zeiger bleibt unangetastet (F32).' };
862
+ return {};
863
+ }
375
864
  if (args.lokal === true || args.ziel.ort.art !== 'arbeitsverzeichnis')
376
- return;
865
+ return {};
377
866
  const zeigerPfad = await this.bindungFinden(args.ziel);
378
867
  if (zeigerPfad === undefined)
379
- return;
868
+ return {};
380
869
  const zeiger = await this.p.zeiger.lesen(zeigerPfad);
381
870
  if (!zeiger.skills.delete(args.skill))
382
- return;
871
+ return {};
383
872
  await this.p.zeiger.schreiben(zeigerPfad, zeiger);
873
+ return {};
874
+ }
875
+ /**
876
+ * Ignoriert einen fremden Eintrag (E43): idempotent, kein Duplikat. Gleichheit über harness, Ort
877
+ * (strukturell, über `zielSchluessel`) und Name.
878
+ */
879
+ async eintragIgnorieren(args) {
880
+ const zustand = await this.p.zustand.lesen();
881
+ if (zustand.ignorierteEintraege.some((i) => ignorierterEintragGleich(i, args)))
882
+ return;
883
+ zustand.ignorierteEintraege.push({ harness: args.ziel.harness, ort: args.ziel.ort, name: args.name });
884
+ await this.p.zustand.schreiben(zustand);
885
+ }
886
+ /** Beachtet einen zuvor ignorierten Eintrag wieder: entfernt ihn aus der Ignorier-Liste (E43). */
887
+ async eintragBeachten(args) {
888
+ const zustand = await this.p.zustand.lesen();
889
+ const vorher = zustand.ignorierteEintraege.length;
890
+ zustand.ignorierteEintraege = zustand.ignorierteEintraege.filter((i) => !ignorierterEintragGleich(i, args));
891
+ if (zustand.ignorierteEintraege.length === vorher)
892
+ return;
893
+ await this.p.zustand.schreiben(zustand);
894
+ }
895
+ /**
896
+ * Löscht einen fremden Eintrag vom Ziel auf der Platte (E46): ein Verzeichnis rekursiv, einen
897
+ * fremden Symlink als Link, ohne ihm zu folgen, die Arbeitskopie dahinter bleibt unangetastet.
898
+ * Nur Einträge der Art 'fremd', ein hmgr-eigener Link ist ein Fehler, dafür gibt es
899
+ * `linkEntfernen`. Ein nicht vorhandener Eintrag ist ebenso ein Fehler. War der Eintrag ignoriert
900
+ * (E43), wird die Marke mitentfernt, sie zeigte sonst auf Gelöschtes.
901
+ */
902
+ async eintragLoeschen(args) {
903
+ const harness = await this.harnessGeprueft(args.ziel.harness);
904
+ const verzeichnis = harness.zielVerzeichnis(args.ziel.ort);
905
+ const eintraege = await this.p.ziele.eintraege(args.ziel, verzeichnis);
906
+ const eintrag = eintraege.find((e) => (e.art === 'link' ? e.link.name : e.name) === args.name);
907
+ if (eintrag === undefined || eintrag.art !== 'fremd')
908
+ throw new FremderEintragUnbekannt(args.ziel, args.name);
909
+ await this.p.ziele.eintragLoeschen(args.ziel, verzeichnis, args.name);
910
+ await this.eintragBeachten({ ziel: args.ziel, name: args.name });
384
911
  }
385
912
  /**
386
913
  * Plugin an einem Ziel herstellen: mit Plugin-Fähigkeit über die CLI des Harness, sonst als
@@ -416,6 +943,8 @@ export class Anwendungsfaelle {
416
943
  async pluginLegen(args) {
417
944
  const harness = await this.harnessGeprueft(args.ziel.harness);
418
945
  const fund = await this.pluginFinden(args.plugin, args.quelle);
946
+ // Vor jeder Wirkung: kein Plugin, wenn danach das Schreiben in den Zeiger scheitern würde (E57).
947
+ await this.zeigerSchreibenPruefen(args.ziel, args.lokal);
419
948
  const ergebnis = await this.pluginHerstellen(harness, args.ziel, fund);
420
949
  if (args.lokal === true || args.ziel.ort.art !== 'arbeitsverzeichnis')
421
950
  return ergebnis;
@@ -466,6 +995,8 @@ export class Anwendungsfaelle {
466
995
  async pluginEntfernen(args) {
467
996
  const harness = await this.harnessGeprueft(args.ziel.harness);
468
997
  const fund = await this.pluginFinden(args.plugin);
998
+ // Vor jeder Wirkung: kein Entfernen, wenn danach das Schreiben in den Zeiger scheitern würde (E57).
999
+ await this.zeigerSchreibenPruefen(args.ziel, args.lokal);
469
1000
  const schluessel = pluginSchluessel(fund.plugin);
470
1001
  const entfernt = [];
471
1002
  if (kannPlugins(harness)) {
@@ -567,7 +1098,7 @@ export class Anwendungsfaelle {
567
1098
  return ergebnis;
568
1099
  }
569
1100
  ergebnis.zeigerPfad = zeigerPfad;
570
- const harnesses = (await this.vorhandeneHarnesses()).filter((h) => h.orte().includes('arbeitsverzeichnis'));
1101
+ const harnesses = (await this.vorhandeneAktiveHarnesses()).filter((h) => h.orte().includes('arbeitsverzeichnis'));
571
1102
  if (harnesses.length === 0) {
572
1103
  ergebnis.hinweise.push('Kein Harness auf diesem Rechner, nichts zu tun (E20).');
573
1104
  return ergebnis;
@@ -660,11 +1191,28 @@ export class Anwendungsfaelle {
660
1191
  * Aktualisieren (E14, E15): jeden Checkout unter der Wurzel auf den Stand seines Refs bringen.
661
1192
  * Lokale Quellen haben keinen Checkout und bleiben unberührt (B11). Links zeigen auf die
662
1193
  * Worktree-Pfade, die dabei stehen bleiben, also müssen sie nicht erneuert werden.
1194
+ *
1195
+ * Checkouts, deren Quelle der Zustand nicht führt, bleiben ganz außen vor (E54): sie werden weder
1196
+ * geholt noch als verwaist zum Löschen angeboten. Eine vergessene Quelle ist vergessen, ihre
1197
+ * Checkouts und Links liegen unangetastet, bis die Quelle wieder hinzukommt.
663
1198
  */
664
- async aktualisieren() {
1199
+ async aktualisieren(args) {
1200
+ const rueckfragen = args?.rueckfragen ?? true;
1201
+ const bekannt = new Map((await this.p.zustand.lesen()).quellen.map((q) => [quelleSchluessel(q), q]));
665
1202
  const checkouts = [];
666
- for (const checkout of await this.p.quellen.checkouts())
667
- checkouts.push({ checkout, aktualisierung: await this.p.quellen.aktualisieren(checkout) });
1203
+ for (const checkout of await this.p.quellen.checkouts()) {
1204
+ const gefuehrt = bekannt.get(quelleSchluessel(checkout.quelle));
1205
+ if (gefuehrt === undefined)
1206
+ continue;
1207
+ const aktualisierung = await this.p.quellen.aktualisieren(checkout);
1208
+ // Der Quellen-Port baut Checkouts von der Platte (remote.origin.url), ohne den Zustand zu
1209
+ // befragen: der Anzeigename fehlt dort. Für den Bericht kommt er aus der geführten Quelle
1210
+ // (E59), sonst zeigte eine umbenannte Quelle hier wieder ihren abgeleiteten Namen.
1211
+ checkouts.push({
1212
+ checkout: { ...checkout, quelle: quelleMitNamen(checkout.quelle, gefuehrt.name) },
1213
+ aktualisierung,
1214
+ });
1215
+ }
668
1216
  // Nach den Checkouts die Plugins nachziehen (E14).
669
1217
  const pluginHarnesses = [];
670
1218
  for (const harness of await this.vorhandeneHarnesses()) {
@@ -673,7 +1221,7 @@ export class Anwendungsfaelle {
673
1221
  await harness.plugins.aktualisieren();
674
1222
  pluginHarnesses.push(harness.name);
675
1223
  }
676
- return { checkouts, pluginHarnesses, aufraeumen: await this.verwaisteAufraeumen(checkouts) };
1224
+ return { checkouts, pluginHarnesses, aufraeumen: await this.verwaisteAufraeumen(checkouts, rueckfragen) };
677
1225
  }
678
1226
  /**
679
1227
  * Pfade, in die ein Link zeigt. Gelesen wird wie beim Ist-Zustand (E19): die Ziele der
@@ -697,8 +1245,11 @@ export class Anwendungsfaelle {
697
1245
  * mit lokaler Änderung bleibt liegen und wird nur gemeldet (E15), er kommt gar nicht erst in die
698
1246
  * Frage. Gefragt wird einmal für alle Kandidaten (E28); wo niemand antwortet, gilt Nein und es
699
1247
  * wird nichts gelöscht.
1248
+ *
1249
+ * Bei `rueckfragen: false` (E44, automatischer GUI-Lauf) werden Verwaiste wie sonst ermittelt und
1250
+ * gemeldet, aber weder gefragt noch gelöscht.
700
1251
  */
701
- async verwaisteAufraeumen(checkouts) {
1252
+ async verwaisteAufraeumen(checkouts, rueckfragen) {
702
1253
  const belegt = await this.belegteCheckoutPfade();
703
1254
  const verwaiste = [];
704
1255
  for (const { checkout, aktualisierung } of checkouts) {
@@ -708,6 +1259,8 @@ export class Anwendungsfaelle {
708
1259
  const lokaleAenderung = aktualisierung.ergebnis === 'uebersprungen';
709
1260
  verwaiste.push({ checkout, lokaleAenderung, geloescht: false });
710
1261
  }
1262
+ if (!rueckfragen)
1263
+ return { verwaiste, gefragt: false };
711
1264
  const kandidaten = verwaiste.filter((v) => !v.lokaleAenderung);
712
1265
  if (kandidaten.length === 0)
713
1266
  return { verwaiste, gefragt: false };
@@ -720,14 +1273,124 @@ export class Anwendungsfaelle {
720
1273
  return { verwaiste, gefragt: true };
721
1274
  }
722
1275
  /**
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.
1276
+ * Erkennungskette eines fremden Eintrags (E31, E48), rein lesend, ohne etwas zu verändern. Sie
1277
+ * bedient Prognose (`istZustand`) und Übernahme gleichermaßen:
1278
+ * - Ein Symlink führt in eine Arbeitskopie (B12): Quelle und Ref aus `remote.origin.url` und dem
1279
+ * ausgecheckten Branch, der Skill über den relativen Pfad in der Quelle.
1280
+ * - Sonst gilt ein Ordner mit eigenem `.git` an Ort und Stelle als Arbeitskopie (E48 a): erkannt
1281
+ * nur, wenn sie sauber ist und ihr HEAD im lokalen Stand des Remote-Branchs enthalten ist,
1282
+ * sonst „lokale-aenderungen". Erkannt daran, dass `arbeitskopie` auf den Eintrag selbst
1283
+ * angewandt dessen eigene Wurzel liefert (`relativerPfad === ''`).
1284
+ * - Sonst greift bei einem agents-Ziel das Lockfile der skills-CLI (E48 b, HARNESS-FAKTEN):
1285
+ * `sourceUrl` und `ref` liefern die Quelle, ersetzt wird nur, wenn der Ordnerinhalt dem
1286
+ * festgehaltenen `skillFolderHash` entspricht. Das Hash-Verfahren der skills-CLI ist nur für
1287
+ * Nicht-GitHub-Quellen als SHA-256 über Dateien reproduzierbar (`computeSkillFolderHash`);
1288
+ * für GitHub-Quellen benutzt sie eine Baum-SHA aus der GitHub-API (40 Zeichen statt 64), die
1289
+ * sich ohne Netzzugriff nicht nachbilden lässt. Ein `skillFolderHash` mit 40 Zeichen ergibt
1290
+ * deshalb ehrlich „nicht-pruefbar" statt fälschlich „lokale-aenderungen" (E50); nur ein
1291
+ * nachrechenbarer (64-stelliger) Hash, der abweicht, ergibt „lokale-aenderungen".
1292
+ * - Sonst „kein-link": nichts davon trifft zu.
1293
+ * Ein kaputter Teil (unlesbares Lockfile, Git-Fehler) bricht die Erhebung nie ab, er ergibt einen
1294
+ * Grund. `bekannt` normalisiert die erkannte Quelle auf die schon geführte Schreibweise (E36),
1295
+ * ohne sie selbst zu verändern.
727
1296
  *
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.
1297
+ * `nurLesend` trennt Prognose von Übernahme an genau einer Stelle: der Skill-Katalog einer
1298
+ * Quelle, die hmgr noch nicht führt, bleibt ungefragt. `skills()` legte sonst deren Basis-Clone
1299
+ * an, und die Erhebung würde für jeden fremden Symlink ein fremdes Repo aus dem Netz holen und
1300
+ * auf die Platte schreiben. Bekannte Quellen haben ihren Clone schon, sie werden wie bisher
1301
+ * gefragt. Die Prognose nennt dann Quelle und Ref ohne Skill (E48), „skill-unbekannt" fällt in
1302
+ * diesem Fall erst in der Vorschau der Übernahme auf.
1303
+ */
1304
+ async erkennenFremd(harness, verzeichnis, eintrag, bekannt, nurLesend) {
1305
+ const unbekannt = (grund) => ({ art: 'unbekannt', grund });
1306
+ const normalisiert = (kandidat) => bekannt.get(quelleSchluessel(kandidat)) ?? kandidat;
1307
+ /** Skill aus dem Katalog: `null` heißt „nicht darin", `undefined` heißt „nicht gefragt". */
1308
+ const ausKatalog = async (quelle, ref, pfad) => {
1309
+ if (nurLesend && !bekannt.has(quelleSchluessel(quelle)))
1310
+ return undefined;
1311
+ const skills = await this.p.quellen.skills(quelle, ref).catch(() => []);
1312
+ return skills.find((s) => s.pfad === pfad) ?? null;
1313
+ };
1314
+ if (eintrag.linkZiel !== undefined) {
1315
+ const kopie = await this.p.quellen.arbeitskopie(eintrag.linkZiel).catch(() => null);
1316
+ if (kopie === null)
1317
+ return unbekannt('keine-arbeitskopie');
1318
+ if (kopie.url === null)
1319
+ return unbekannt('keine-url');
1320
+ if (kopie.ref === null)
1321
+ return unbekannt('kein-branch');
1322
+ const quelle = normalisiert({ art: 'git', url: kopie.url });
1323
+ const skill = await ausKatalog(quelle, kopie.ref, kopie.relativerPfad);
1324
+ if (skill === null)
1325
+ return unbekannt('skill-unbekannt');
1326
+ return { art: 'erkannt', quelle, ref: kopie.ref, ...(skill === undefined ? {} : { skill }) };
1327
+ }
1328
+ const entryPfad = path.join(verzeichnis, eintrag.name);
1329
+ // (a) Ordner mit eigenem .git an Ort und Stelle (E48).
1330
+ const eigeneKopie = await this.p.quellen.arbeitskopie(entryPfad).catch(() => null);
1331
+ if (eigeneKopie !== null && eigeneKopie.relativerPfad === '') {
1332
+ if (eigeneKopie.url === null)
1333
+ return unbekannt('keine-url');
1334
+ if (eigeneKopie.ref === null)
1335
+ return unbekannt('kein-branch');
1336
+ const quelleRoh = { art: 'git', url: eigeneKopie.url };
1337
+ const sauber = !(await this.p.quellen
1338
+ .lokaleAenderungen({ quelle: quelleRoh, ref: eigeneKopie.ref, pfad: entryPfad })
1339
+ .catch(() => true));
1340
+ const enthalten = sauber && (await this.p.quellen.imRemoteEnthalten(entryPfad, eigeneKopie.ref).catch(() => false));
1341
+ if (!enthalten)
1342
+ return unbekannt('lokale-aenderungen');
1343
+ const quelle = normalisiert(quelleRoh);
1344
+ // Der Katalog (skills()) kennt einen Wurzel-Skill zwar seit dem E11-Nachtrag, aber er gilt
1345
+ // für die geführte Quelle am gefragten Ref. Hier liegt eine fremde Arbeitskopie an Ort und
1346
+ // Stelle: ihr Wurzel-Skill wird direkt von der Platte gelesen, ohne Clone (E48 a).
1347
+ const skill = await this.p.quellen.wurzelSkill(entryPfad).catch(() => null);
1348
+ if (skill === null)
1349
+ return unbekannt('skill-unbekannt');
1350
+ return { art: 'erkannt', quelle, ref: eigeneKopie.ref, skill };
1351
+ }
1352
+ // (b) Lockfile der skills-CLI, nur für agents-Ziele (E48 b).
1353
+ if (harness.name === 'agents') {
1354
+ const skillsHeim = harness.zielVerzeichnis({ art: 'home' });
1355
+ const lockEintrag = await this.p.ziele
1356
+ .skillLockEintrag(skillsHeim, eintrag.name)
1357
+ .catch(() => undefined);
1358
+ if (lockEintrag !== undefined) {
1359
+ if (lockEintrag.ref === undefined)
1360
+ return unbekannt('kein-branch');
1361
+ if (lockEintrag.skillPath === undefined)
1362
+ return unbekannt('skill-unbekannt');
1363
+ // Eine GitHub-Baum-SHA (40 Hex-Zeichen) statt eines SHA-256 (64) lässt sich lokal nicht
1364
+ // nachrechnen (E50): ehrlich „nicht prüfbar" statt fälschlich „lokale Änderungen".
1365
+ if (lockEintrag.skillFolderHash.length === 40)
1366
+ return unbekannt('nicht-pruefbar');
1367
+ const echterHash = await this.p.ziele.ordnerHash(verzeichnis, eintrag.name).catch(() => undefined);
1368
+ if (echterHash === undefined || echterHash.toLowerCase() !== lockEintrag.skillFolderHash.toLowerCase())
1369
+ return unbekannt('lokale-aenderungen');
1370
+ const quelle = normalisiert({ art: 'git', url: lockEintrag.sourceUrl });
1371
+ const skill = await ausKatalog(quelle, lockEintrag.ref, skillPfadAusLock(lockEintrag.skillPath));
1372
+ if (skill === null)
1373
+ return unbekannt('skill-unbekannt');
1374
+ return { art: 'erkannt', quelle, ref: lockEintrag.ref, ...(skill === undefined ? {} : { skill }) };
1375
+ }
1376
+ }
1377
+ return unbekannt('kein-link');
1378
+ }
1379
+ /**
1380
+ * Übernahme (B12, E31): fremde Einträge eines Ziels durch eigene Links auf einen hmgr-Checkout
1381
+ * derselben Quelle am selben Ref ersetzen. Eine Arbeitskopie, in die ein Symlink führt, bleibt
1382
+ * unberührt und wird nicht als Quelle registriert, das ist B11. Ein Ordner mit eigenem `.git`
1383
+ * oder ein Lockfile-Treffer wird dagegen selbst gelöscht und durch den Link ersetzt (E48 a/b). Ein
1384
+ * Eintrag, der nicht auflösbar ist, bleibt liegen und wird gemeldet, damit ein Lauf über viele
1385
+ * Einträge nicht an einem einzelnen abbricht.
1386
+ *
1387
+ * `probelauf` schreibt weder Zustand noch Checkout noch Link. Die Erkennungskette wird trotzdem
1388
+ * gelesen, sonst wären die Gründe nicht zu haben. Der Git-Adapter legt dabei den Basis-Clone der
1389
+ * Quelle an, das Ziel und der Zustand bleiben unangetastet.
1390
+ *
1391
+ * Mit `name` (E46) betrachtet der Lauf nur diesen einen fremden Eintrag, ohne `name` bleibt es
1392
+ * bei allen fremden Einträgen des Ziels (CLI unverändert). Ein genannter Name, der am Ziel nicht
1393
+ * als fremder Eintrag liegt, ist ein Fehler, gilt für Probelauf wie echten Lauf gleichermaßen.
731
1394
  */
732
1395
  async uebernehmen(args) {
733
1396
  const harness = await this.harnessGeprueft(args.ziel.harness);
@@ -740,60 +1403,93 @@ export class Anwendungsfaelle {
740
1403
  for (const q of (await this.p.zustand.lesen()).quellen)
741
1404
  bekannt.set(quelleSchluessel(q), q);
742
1405
  const eintraege = [];
743
- for (const eintrag of await this.p.ziele.eintraege(args.ziel, verzeichnis)) {
1406
+ const alleEintraege = await this.p.ziele.eintraege(args.ziel, verzeichnis);
1407
+ if (args.name !== undefined) {
1408
+ const gefunden = alleEintraege.find((e) => (e.art === 'link' ? e.link.name : e.name) === args.name);
1409
+ if (gefunden === undefined || gefunden.art !== 'fremd')
1410
+ throw new FremderEintragUnbekannt(args.ziel, args.name);
1411
+ }
1412
+ for (const eintrag of alleEintraege) {
744
1413
  // Eigene Links sind schon fertig, sie kommen nicht in die Meldung.
745
1414
  if (eintrag.art !== 'fremd')
746
1415
  continue;
747
1416
  if (args.name !== undefined && eintrag.name !== args.name)
748
1417
  continue;
749
1418
  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');
758
- continue;
759
- }
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');
1419
+ const prognose = await this.erkennenFremd(harness, verzeichnis, eintrag, bekannt, false);
1420
+ if (prognose.art === 'unbekannt') {
1421
+ eintraege.push({ ergebnis: 'uebersprungen', name, grund: prognose.grund });
767
1422
  continue;
768
1423
  }
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);
1424
+ const { quelle, ref, skill } = prognose;
1425
+ // Ohne `nurLesend` fragt die Kette den Katalog immer, ein fehlender Skill kommt hier nie an.
775
1426
  if (skill === undefined) {
776
- liegenLassen('skill-unbekannt');
1427
+ eintraege.push({ ergebnis: 'uebersprungen', name, grund: 'skill-unbekannt' });
777
1428
  continue;
778
1429
  }
779
- const quelleNeu = gefuehrt === undefined;
1430
+ const quelleNeu = !bekannt.has(quelleSchluessel(quelle));
780
1431
  bekannt.set(quelleSchluessel(quelle), quelle);
1432
+ const ordnerErsetzt = eintrag.linkZiel === undefined;
781
1433
  if (!probelauf) {
1434
+ // Vor jeder Wirkung (E57): die Übernahme schreibt ohne `lokal` denselben Zeiger wie
1435
+ // `link`. Die Sperre muss vor `quelleHinzufuegen` greifen, sonst bliebe die Quelle im
1436
+ // Zustand stehen, während der Link nie entsteht. Ein früherer Durchlauf kann nichts
1437
+ // geschrieben haben: er wäre an derselben Stelle abgebrochen.
1438
+ await this.zeigerSchreibenPruefen(args.ziel, args.lokal);
1439
+ // Der abgeleitete Anzeigename kann schon vergeben sein (E59). Die Übernahme hat keinen Weg,
1440
+ // den Nutzer nach einem anderen zu fragen, darum weicht sie auf einen freien aus statt an
1441
+ // QuelleNameBelegt zu scheitern; umbenennen lässt er sich hinterher (source rename).
782
1442
  if (quelleNeu)
783
- await this.quelleHinzufuegen(quelle);
1443
+ await this.quelleHinzufuegen(quelle, quelleNameFrei(quelleNameAbleiten(quelle), (await this.p.zustand.lesen()).quellen));
784
1444
  await this.linkAusFund(harness, args.ziel, { quelle, ref, skill }, true, args.lokal);
785
1445
  }
786
- eintraege.push({ ergebnis: 'uebernommen', name, quelle, ref, skill, quelleNeu });
1446
+ // Nur in der Vorschau (E50): welche gitignorierten Dateien beim Ersetzen verloren gingen.
1447
+ // Blockiert nichts, ist reine Sichtbarkeit vor dem echten Lauf.
1448
+ let gitignoriert;
1449
+ if (probelauf && ordnerErsetzt) {
1450
+ const liste = await this.p.quellen
1451
+ .gitignorierteDateien(path.join(verzeichnis, name))
1452
+ .catch(() => []);
1453
+ if (liste.length > 0)
1454
+ gitignoriert = liste;
1455
+ }
1456
+ eintraege.push({
1457
+ ergebnis: 'uebernommen',
1458
+ name,
1459
+ quelle,
1460
+ ref,
1461
+ skill,
1462
+ quelleNeu,
1463
+ ordnerErsetzt,
1464
+ ...(gitignoriert === undefined ? {} : { gitignoriert }),
1465
+ });
787
1466
  }
1467
+ // Ausdrücklich lokal an einem gebundenen Arbeitsverzeichnis (E57, F32): der Zeiger bleibt
1468
+ // unangetastet, ein Hinweis sagt das. Nur wenn wirklich etwas übernommen wurde und nur bei
1469
+ // bestehender Bindung, sonst wäre es Rauschen.
1470
+ if (!probelauf &&
1471
+ args.lokal === true &&
1472
+ args.ziel.ort.art === 'arbeitsverzeichnis' &&
1473
+ eintraege.some((e) => e.ergebnis === 'uebernommen') &&
1474
+ (await this.bindungFinden(args.ziel)) !== undefined)
1475
+ return {
1476
+ ziel: args.ziel,
1477
+ probelauf,
1478
+ eintraege,
1479
+ hinweis: 'Die übernommenen Links liegen nur lokal, der Zeiger bleibt unangetastet (F32).',
1480
+ };
788
1481
  return { ziel: args.ziel, probelauf, eintraege };
789
1482
  }
790
- /** Versionseinschränkungen des Zeigers, an den ein Ziel gebunden ist (E33). Ohne Zeiger leer. */
791
- async einschraenkungen(ziel) {
792
- const ergebnis = new Map();
1483
+ /** Zeiger, an den ein Ziel gebunden ist. Ohne Bindung oder mit unlesbarer Datei: undefined. */
1484
+ async zeigerFuerZiel(ziel) {
793
1485
  const zeigerPfad = await this.bindungFinden(ziel);
794
1486
  if (zeigerPfad === undefined)
795
- return ergebnis;
796
- const zeiger = await this.p.zeiger.lesen(zeigerPfad).catch(() => undefined);
1487
+ return undefined;
1488
+ return await this.p.zeiger.lesen(zeigerPfad).catch(() => undefined);
1489
+ }
1490
+ /** Versionseinschränkungen aus dem Zeiger eines Ziels (E33). Ohne Zeiger leer. */
1491
+ einschraenkungen(zeiger) {
1492
+ const ergebnis = new Map();
797
1493
  if (zeiger === undefined)
798
1494
  return ergebnis;
799
1495
  for (const [name, einschraenkung] of zeiger.skills)
@@ -801,9 +1497,80 @@ export class Anwendungsfaelle {
801
1497
  ergebnis.set(name, einschraenkung);
802
1498
  return ergebnis;
803
1499
  }
1500
+ /**
1501
+ * Plugins, die für ein Ziel als Grund in Frage kommen: bei einem Arbeitsverzeichnis die des
1502
+ * gebundenen Zeigers, bei Home und Profil die aller Marketplaces der bekannten Quellen.
1503
+ */
1504
+ async pluginKandidaten(ziel, zeiger, marktplatzFunde) {
1505
+ const alle = await marktplatzFunde();
1506
+ if (ziel.ort.art !== 'arbeitsverzeichnis')
1507
+ return alle;
1508
+ if (zeiger === undefined)
1509
+ return [];
1510
+ const funde = [];
1511
+ for (const eintrag of zeiger.plugins) {
1512
+ const zerlegt = pluginSchluesselZerlegen(eintrag);
1513
+ for (const fund of alle)
1514
+ if (fund.plugin.name === zerlegt.name &&
1515
+ (zerlegt.marketplace === undefined || fund.plugin.marketplace === zerlegt.marketplace))
1516
+ funde.push(fund);
1517
+ }
1518
+ return funde;
1519
+ }
1520
+ /**
1521
+ * Grund je eigenem Link (E37). Priorität: Plugin vor Zeiger vor manuell. Die Plugin-Zugehörigkeit
1522
+ * steht vorn, weil sie die Einschränkung trägt, dass der Link nicht einzeln entfernt werden darf,
1523
+ * ohne das Plugin zu verletzen. Damit gilt auch ein von Hand gelegter Link, der eine flache
1524
+ * Skill-Liste komplettiert, als Plugin-Link. Diese Unschärfe ist bekannt und akzeptiert.
1525
+ * Bei einem Harness mit Plugin-Fähigkeit sind Plugin-Skills keine hmgr-Links (E16), dort entsteht
1526
+ * der Plugin-Grund nicht. Ist eine Quelle nicht erreichbar, fällt ihr Plugin als Kandidat aus.
1527
+ */
1528
+ async gruende(harness, ziel, eintraege, zeiger, marktplatzFunde) {
1529
+ const gruende = new Map();
1530
+ const eigene = eintraege.filter((e) => e.art !== 'fremd').map((e) => (e.art === 'link' ? e.link.name : e.name));
1531
+ if (eigene.length === 0)
1532
+ return gruende;
1533
+ const eigeneNamen = new Set(eigene);
1534
+ if (!kannPlugins(harness))
1535
+ for (const fund of await this.pluginKandidaten(ziel, zeiger, marktplatzFunde)) {
1536
+ if (!(await this.pluginLiegt(harness, ziel, fund, eintraege).catch(() => false)))
1537
+ continue;
1538
+ const skills = await this.flacheSkillListe(fund.plugin.name, fund.quelle).catch(() => []);
1539
+ const schluessel = pluginSchluessel(fund.plugin);
1540
+ for (const skill of skills)
1541
+ if (eigeneNamen.has(skill.name) && !gruende.has(skill.name))
1542
+ gruende.set(skill.name, { art: 'plugin', plugin: schluessel });
1543
+ }
1544
+ for (const name of eigene)
1545
+ if (!gruende.has(name))
1546
+ gruende.set(name, zeiger?.skills.has(name) === true ? { art: 'zeiger' } : { art: 'manuell' });
1547
+ return gruende;
1548
+ }
804
1549
  /** Ist-Zustand: alle Ziele der vorhandenen Harnesses, aus dem Dateisystem gelesen. */
805
1550
  async istZustand() {
806
1551
  const zustand = await this.p.zustand.lesen();
1552
+ // Je Identität die Quelle, unter deren Schreibweise sie geführt wird (E36, E48), für die
1553
+ // Übernahme-Prognose. Bleibt über den ganzen Lauf gleich, die Prognose verändert nichts.
1554
+ const bekannt = new Map();
1555
+ for (const q of zustand.quellen)
1556
+ bekannt.set(quelleSchluessel(q), q);
1557
+ // Marketplaces der bekannten Quellen, höchstens einmal je Lauf gelesen. Eine unerreichbare
1558
+ // Quelle fällt aus, statt die Erhebung abzubrechen.
1559
+ let gelesene;
1560
+ const marktplatzFunde = async () => {
1561
+ if (gelesene !== undefined)
1562
+ return gelesene;
1563
+ const funde = [];
1564
+ for (const quelle of zustand.quellen) {
1565
+ const marktplatz = await this.marketplaceLesen(quelle).catch(() => null);
1566
+ if (marktplatz === null)
1567
+ continue;
1568
+ for (const plugin of marktplatz.plugins)
1569
+ funde.push({ quelle, ref: marktplatz.ref, plugin });
1570
+ }
1571
+ gelesene = funde;
1572
+ return funde;
1573
+ };
807
1574
  const ergebnis = [];
808
1575
  for (const harness of await this.vorhandeneHarnesses()) {
809
1576
  const ziele = [];
@@ -821,12 +1588,39 @@ export class Anwendungsfaelle {
821
1588
  const plugins = kannPlugins(harness)
822
1589
  ? await harness.plugins.installierte(ziel).catch(() => [])
823
1590
  : undefined;
1591
+ const zeiger = await this.zeigerFuerZiel(ziel);
1592
+ const istRegistriert = kannIstRegistriert(harness) ? await harness.istRegistriert(ziel.ort) : false;
1593
+ // Fähigkeit am Typwächter, nicht am Namen (E16), und nur an Orten, die sie kennt.
1594
+ let skillOrdnerFaehig = false;
1595
+ let verwalteterSkillOrdner;
1596
+ if (kannSkillOrdner(harness) && harness.skillOrdnerOrte().includes(ziel.ort.art)) {
1597
+ skillOrdnerFaehig = true;
1598
+ verwalteterSkillOrdner = await harness.verwalteterSkillOrdner(ziel.ort);
1599
+ }
1600
+ // Ignoriert (E43): nur Namen fremder Einträge, die tatsächlich noch auf der Platte liegen.
1601
+ // Die Ignorier-Liste selbst wird dabei nicht bereinigt.
1602
+ const fremdeNamen = new Set(eintraege.filter((ei) => ei.art === 'fremd').map((ei) => ei.name));
1603
+ const zielSchl = zielSchluessel(ziel);
1604
+ const ignoriert = zustand.ignorierteEintraege
1605
+ .filter((i) => zielSchluessel({ harness: i.harness, ort: i.ort }) === zielSchl && fremdeNamen.has(i.name))
1606
+ .map((i) => i.name);
1607
+ // Übernahme-Prognose je fremdem Eintrag (E48), dieselbe Kette wie `uebernehmen`, nur lesend.
1608
+ const uebernahme = new Map();
1609
+ for (const ei of eintraege)
1610
+ if (ei.art === 'fremd')
1611
+ uebernahme.set(ei.name, await this.erkennenFremd(harness, verzeichnis, ei, bekannt, true));
824
1612
  ergebnis.push({
825
1613
  ziel,
826
1614
  verzeichnis,
827
1615
  eintraege,
828
- einschraenkungen: await this.einschraenkungen(ziel),
1616
+ einschraenkungen: this.einschraenkungen(zeiger),
1617
+ gruende: await this.gruende(harness, ziel, eintraege, zeiger, marktplatzFunde),
1618
+ ignoriert,
1619
+ uebernahme,
1620
+ istRegistriert,
1621
+ kannSkillOrdner: skillOrdnerFaehig,
829
1622
  ...(plugins === undefined ? {} : { plugins }),
1623
+ ...(verwalteterSkillOrdner === undefined ? {} : { verwalteterSkillOrdner }),
830
1624
  });
831
1625
  }
832
1626
  }