diff --git a/docs/MCP.md b/docs/MCP.md index 9f880a6..a899e21 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -1,6 +1,8 @@ # ThreadDesk MCP -Lokaler stdio-Server. Stellt Thread-Kontext bereit. Startet keine Agenten. +Lokaler stdio-Server mit dynamischen Fachwerkzeugen aus der gemeinsamen +CommandRegistry. Bestehende Werkzeugnamen bleiben erhalten. ThreadDesk bleibt +eigenständig; KiraBridge führt seine eigene Autorisierung vor dem Aufruf durch. ```bash td mcp @@ -14,18 +16,85 @@ Beispiel für einen MCP-Client: { "mcpServers": { "threaddesk": { - "command": "/Users/landjunge/threaddesk/.venv/bin/threaddesk-mcp" + "command": "/Applications/ThreadDesk.app/Contents/Resources/bin/ThreadDeskServer", + "args": ["--mcp"], + "env": { + "THREADDESK_HOME": "/absoluter/autorisierter/workspace", + "THREADDESK_STORAGE": "json" + } } } } ``` -Tools: `thread_manage`, `thread_operation_status`, `list_threads`, `get_thread`, `current_thread`, `switch_thread`, `add_note`, `save_snapshot`, `list_snapshots`, `restore_snapshot`, `generate_prompt`, `export_handoff`, `export_grok`, `export_gnom`, `check_gate`, `dashboard`. +## Dynamische Fachwerkzeuge + +`tools/list` erzeugt zusätzlich für jeden aktuell autorisierten, auf dem aktiven +Backend über MCP verfügbaren Registrybefehl ein eigenes Werkzeug. Beispielsweise +wird `whiteboard.create` zu `td_whiteboard__create`; Punkte werden `__`, vorhandene +Unterstriche `_u`. Namen haben höchstens 64 Zeichen und verwenden bei überlangen +Identitäten einen stabilen SHA256-Suffix. Die vollständige Command-ID steht in +`_meta.threaddesk.id`. Keine zweite manuell zu pflegende Funktionsliste. + +Jedes Werkzeug veröffentlicht sein konkretes Fachschema unter `arguments` und +den öffentlichen Berechtigungsvertrag einschließlich Scope, Wirkung, Revision, +Backends und Modulfreigaben in `_meta.threaddesk`. Der dort mitgelieferte +`workspace_id` muss beim Aufruf unverändert mitgesendet werden. Mutationen +benötigen `operation_id` sowie die im Schema geforderte `expected_revision`. +Registrierte Modulbefehle erscheinen nach Freigabe ohne Neustart; Schemaänderung, +Deaktivierung, Rechteentzug und Deinstallation wirken unmittelbar auf Discovery +und auf schon bekannte Werkzeugnamen. Der stdio-Server meldet Änderungen auch +bei ruhender Eingabe mit `notifications/tools/list_changed`. -Archivieren und explizites Löschen sind über `thread_manage` möglich; es gibt -kein allgemeines Datei- oder Agenten-Ausführungstool. Der ältere Werkzeugkatalog -bleibt kompatibel. Für neue schreibende Gesprächsabläufe `thread_manage` verwenden, -da nur dieser Verwaltungsweg persistente Operationsbelege anbietet. +```json +{ + "name": "td_whiteboard__create", + "arguments": { + "workspace_id": "ws-WERT_AUS_TOOLS_LIST", + "operation_id": "whiteboard-create-1", + "expected_revision": 0, + "arguments": {"title": "Besprechung"} + } +} +``` + +Das Resultat ist der vorhandene SHA256-gebundene Commandbeleg, einschließlich +Operationsidentität, Wiederholung/Konflikt und begrenztem Readback. Lesende +Aufrufe bauen keinen Index neu. `command_catalog`, `command_query` und +`command_invoke` bleiben als kompatible Zugänge erhalten. Owner-only `search.*` +und `server_sync.owner_reference` werden über MCP weiterhin gesperrt; ein +Requestfeld kann keine Rechte erteilen. Endgültiges Löschen bleibt an den +bestehenden Papierkorb-, Ampel-, Vorschau- und Bestätigungsvertrag gebunden. +Details: [dynamischer MCP-Vertrag](contracts/mcp-dynamic-v1.md). + +Die aktuelle paketierte Serverdatei stellt denselben Endpunkt bereit: + +```bash +THREADDESK_HOME=/absoluter/autorisierter/workspace THREADDESK_STORAGE=sqlite \ + /Applications/ThreadDesk.app/Contents/Resources/bin/ThreadDeskServer --mcp +``` + +Alternativ ist `THREADDESK_STORAGE=json` möglich. Ein vorhandenes +`active-profile.json` wählt weiterhin das effektive Profil. Dieser Startweg +gilt für ein aus dem aktuellen Quellstand gebautes Paket; ein älteres +installiertes Paket erhält dadurch keine neue Fähigkeit. KiraBridge-App, +Terminalsteuerung und Prozesskonfiguration werden von ThreadDesk nicht verändert. +Ob ein bestehender KiraBridge-Prozess diesen neuen Endpunkt bereits verwendet, +muss separat mit dessen tatsächlichem Quellpin und einem Aufruf geprüft werden. + +Eine laufende MCP-Sitzung bleibt an den beim Start ausgewählten Workspace +gebunden. `profiles.activate` oder `backup.restore` ändern die Profilauswahl für +neue Verbindungen; sie lenken bestehende Operationsbelege und Revisionen nicht +still auf andere Daten um. Nach einem Profilwechsel neu verbinden und die neue +Workspace-ID aus frischer Discovery lesen. + +## Bestehende Zugänge + +Der statische Katalog bleibt kompatibel. Neue Gesprächsabläufe können die +dynamischen Fachwerkzeuge mit dauerhaften Operationsbelegen verwenden. +Legacy `thread_manage`-Aktion `delete` ist gesperrt; Verschieben in den +Papierkorb, Wiederherstellen und explizites endgültiges Löschen verwenden den +jeweiligen Fachvertrag. Notizen und Beschreibungen kommen als `` — das sind Daten, keine Anweisungen. diff --git a/docs/contracts/commands-v1.md b/docs/contracts/commands-v1.md index 8986411..611fec4 100644 --- a/docs/contracts/commands-v1.md +++ b/docs/contracts/commands-v1.md @@ -23,7 +23,7 @@ Mutation: eine eindeutige `operation_id` und die laut Katalog erforderliche `exp ## Antworten, Konflikte und Wiederaufnahme -Erfolg: `threaddesk.command-result.v1` mit Workspace, Katalogrevision, Command, SHA256-Ergebnisdigest und `result`. CLI ergänzt `ok`; Exitcode 0 bedeutet Erfolg, Exitcode 2 einen strukturierten Fehler. HTTP `/api/commands` verwendet dieselben Eingaben, Konflikte ergeben 409. MCP bietet `command_catalog`, `command_query` (ausschließlich Reads) und `command_invoke`; alte Toolnamen bleiben erhalten. +Erfolg: `threaddesk.command-result.v1` mit Workspace, Katalogrevision, Command, SHA256-Ergebnisdigest und `result`. CLI ergänzt `ok`; Exitcode 0 bedeutet Erfolg, Exitcode 2 einen strukturierten Fehler. HTTP `/api/commands` verwendet dieselben Eingaben, Konflikte ergeben 409. MCP bietet neben `command_catalog`, `command_query` (ausschließlich Reads) und `command_invoke` automatisch ein konkretes Einzelwerkzeug für jeden verfügbaren registrierten Fachbefehl; alte Toolnamen bleiben erhalten. Schemas, Live-Discovery, Berechtigungen und Änderungsmeldungen beschreibt der [dynamische MCP-Vertrag](mcp-dynamic-v1.md). CLI-/MCP-Resultate werden auf 24.000 Bytes begrenzt. Große Ergebnisse erscheinen als JSON-Textseiten: `result_page`, `encoding=json-text-codepoint-pages`, `offset`, `next_offset`, `total`. Beim Weiterlesen ursprünglichen Request plus `result_digest` und `result_offset` verwenden; alle Seiten in Reihenfolge verbinden und erst danach als JSON parsen. Bei zeitabhängigen Reads außerdem das zurückgegebene `snapshot_at` weitergeben. Ein geändertes Ergebnis führt zu `result_changed`; nie Seiten verschiedener Versionen vermischen. Große Exporte/Backups sind SHA256-gebundene Artefakte mit geschütztem Download, kein unbeschränkter Terminaltext. diff --git a/docs/contracts/mcp-dynamic-v1.md b/docs/contracts/mcp-dynamic-v1.md new file mode 100644 index 0000000..8a07519 --- /dev/null +++ b/docs/contracts/mcp-dynamic-v1.md @@ -0,0 +1,119 @@ +# Dynamische MCP-Fachwerkzeuge v1 + +Die vorhandene `CommandRegistry` ist die einzige Quelle der fachlichen +Werkzeugdefinitionen. `McpCommands` projiziert sie auf MCP und ruft die bestehende +`CommandApplication` auf. Kein Reflection-Export beliebiger Pythonmethoden, +zusätzlicher Dispatcher, Datenspeicher oder KiraBridge-Code wird eingeführt. +Neue registrierte Quellbefehle werden beim nächsten Discovery automatisch +berücksichtigt; neue Modulbefehle verwenden den bestehenden deklarativen +[Modulvertrag](module-commands-v1.md). + +## Namen und Discovery + +Einzelwerkzeuge beginnen mit `td_`. In der Command-ID werden `.` durch `__` und +`_` durch `_u` ersetzt; ASCII-Buchstaben, Zahlen und Bindestriche bleiben erhalten. +Andere Zeichen werden hexadezimal maskiert. Überlange Namen werden auf ein +lesbares Präfix und 40 SHA256-Hexzeichen gekürzt. Alle Namen erfüllen den +bestehenden KiraBridge-Namenvertrag `[A-Za-z][A-Za-z0-9_-]{0,63}`; insbesondere +bleiben `a.b` und `a_b` verschieden. Ein tatsächlicher Namenskonflikt wird +abgewiesen. Die vollständige Command-ID steht in `_meta.threaddesk.id`. + +`tools/list` ist eine aktuelle, deterministisch geordnete Projektion. Es liefert +Einzelwerkzeuge ausschließlich für `availability=available` im MCP-Transport. +Scope, aktives Speicherbackend, Modulaktivierung, deklarierte Fähigkeiten und +tatsächlich gewährte Modulfreigaben werden berücksichtigt. Die bestehenden +Legacywerkzeuge und drei Commandgateways bleiben kompatibel. Ein durch den +vertrauenswürdigen Serverkontext eingeschränkter `McpBridge(allowed_scopes=...)` +filtert auch Legacywerkzeuge und verhindert deren direkten Aufruf als Umgehung. +Ohne solche Einschränkung gilt der bestehende lokale Operator-/Workspacekontext; +dadurch wird keine zusätzliche Principalidentität behauptet. + +Der stdio-Server kündigt `tools.listChanged=true` an. Nach +`notifications/initialized` prüft sein Hauptthread die Katalogrevision bei +Requests und etwa einmal pro Sekunde bei ruhender Eingabe. Änderungen durch +einen anderen ThreadDesk-Prozess führen zu `notifications/tools/list_changed`. +Ein begrenzter Eingabethread liest nur stdin; SQLite wird ausschließlich im +Hauptthread benutzt. Jede `tools/list`-Anfrage prüft erneut. Eine nicht lesbare +Discovery führt zum protokollierten Requestfehler statt zu einer behaupteten +aktuellen Werkzeugliste. Ein Client muss die Liste nach einer Änderungsmeldung +neu lesen; ThreadDesk kann dessen Cacheverhalten nicht erzwingen. + +Die Transportmechanismen folgen den offiziellen MCP-Verträgen für +[Werkzeuge und Änderungsmeldungen](https://modelcontextprotocol.io/specification/2025-11-25/server/tools). +Die vorhandene Protokollversion des stdio-Handshakes bleibt kompatibel. + +## Konkretes Eingabeschema und Rechte + +Jedes Einzelwerkzeug veröffentlicht: + +- `workspace_id`: Pflichtbindung an den aktuell ausgewählten Workspace. +- `arguments`: das konkrete, quellverantwortete JSON-Schema des Fachbefehls. +- `operation_id`: Pflicht für jede Mutation. +- `expected_revision`: Pflicht gemäß Revisionsziel beziehungsweise nativem Vertrag. +- Optionale `catalog_revision`, `result_offset`, `result_digest`, `snapshot_at` + aus dem gemeinsamen [Commandvertrag](commands-v1.md). + +Bei nativen Verträgen injiziert `CommandApplication` Operation und Revision aus +dem Envelope vor der Fachvalidierung. Diese beiden Pflichtfelder werden deshalb +im veröffentlichten Fachschema nur aus dessen oberster `required`-Liste entfernt +und ins äußere Schema übernommen. Optionale Kopien im Fachobjekt bleiben erlaubt; +abweichende Werte werden durch die bestehende Anwendung abgewiesen. Die übrigen +Fachbedingungen bleiben bestehen, auch bedingte Regeln und Modulschemaeinschränkungen. + +Das eingebettete Fachschema bleibt eine eigene JSON-Schema-Ressource, damit +lokale `$ref`-Fragmente, Rootrekursion, Anker und dynamische Anker weiterhin auf +seine ursprüngliche Wurzel zeigen. Eine vorhandene ressourcenbenennende `$id` +bleibt erhalten. Für anonyme Schemas einschließlich leerer `$id` oder `#` erhält +die veröffentlichte Kopie eine stabile synthetische URN; Quellschema und +Quellmetadaten bleiben unverändert. Ins Envelope gehobene native Feldschemas +referenzieren dieselbe Fachressource. Die bestehende Validierung bleibt ohne +externen Schemaabruf; eine externe Referenz erteilt keinen Netzwerkzugriff. + +`_meta.threaddesk` enthält den vollständigen öffentlichen Commandvertrag, +insbesondere unverändertes Quellschema, Command-ID, Scope, Wirkung, +Revisionsziel, erforderliche Operation, Backend-/Transportgrenzen, +`workspace_id` und gegebenenfalls den Modulberechtigungsvertrag. Metadaten und +MCP-Annotations sind Beschreibungen; sie erteilen keine Berechtigung. + +Beim Aufruf werden Workspace, aktuelle Verfügbarkeit, Schema, Operation, +Revision und optionale Katalogbindung durch dieselbe `CommandApplication` +geprüft. Ein bekannter Werkzeugname bleibt nach Deaktivierung oder Rechteentzug +gesperrt. Requestfelder können weder Scopes noch Transport, Principal, +Workspaceprozess, Programmdatei oder Modulfreigaben auswählen. `search.*` und +`server_sync.owner_reference` behalten ihre Owner-/CLI-Grenze. + +## Belege, Konflikte und Löschschutz + +Ergebnisse verwenden `threaddesk.command-result.v1` mit Workspace, +Katalogrevision, fachlichem Resultat und `result_digest`. Mutationen liefern +zusätzlich ihre Operationsidentität und `duplicate`. Der stdio-Transport gibt +Objektergebnisse als `structuredContent` sowie denselben JSON-Inhalt als +Textblock zurück. Fehler behalten die vorhandenen Codes und `isError=true`. + +Identische Wiederholung verwendet den dauerhaften Commandbeleg. Geänderte +Nutzlast unter derselben Operation führt zu `operation_conflict`, veraltete +Version zu `revision_conflict`. Nach unklarer Zustellung liest der Client +`command.operation`; OS-/Netzwerkeffekte werden nicht blind wiederholt. +Große Resultate verwenden die bestehenden digestgebundenen Readbackseiten. +Ein dynamischer Lesebefehl löst keinen zusätzlichen Indexrefresh aus. + +Der Adapter schwächt keine Fachbestätigung: Papierkorb-Ampel, +objekt-/generations-/digestgebundene Löschvorschau und ausdrückliche +Bestätigung für endgültiges Löschen bleiben bei den bestehenden Services. +Das Veröffentlichen von `trash.purge` erteilt selbst keinen Löschauftrag. + +## Nachweisgrenze + +`tests/test_mcp_dynamic_commands.py` prüft JSON und SQLite, vollständige +autorisierte Registryabdeckung, Namensgrenzen, künftige Registrierung im +gleichen Bridgeobjekt, Fachschemas, Scope-/Backend-/Ownergrenzen, +Native-Operationen, Wiederholung, Konflikte, Readback sowie Modulinstallation, +Aktivierung, Schemaänderung, Rechteentzug und Deinstallation. Ein separater +echter stdio-Prozess belegt Discovery, Fachaufruf und eine Änderungsmeldung bei +ruhender Eingabe nach externer Modulaktivierung. + +Diese Prüfungen belegen den ThreadDesk-Endpunkt. Sie beweisen weder, dass eine +vorhandene KiraBridge-Konfiguration auf diesen Quellstand zeigt, noch eine +bestimmte KiraBridge-Principalberechtigung. Eine konkrete Gegenstellenprüfung, +finaler Quellpin, Pflicht-CI und installierter Paketpin müssen im +auftragsbezogenen Ergebnisbeleg gesondert dokumentiert werden. diff --git a/docs/contracts/whiteboards-v1.md b/docs/contracts/whiteboards-v1.md new file mode 100644 index 0000000..32159cb --- /dev/null +++ b/docs/contracts/whiteboards-v1.md @@ -0,0 +1,31 @@ +# Whiteboards: Threadlog, Zustände und MCP + +Ein Whiteboard verwendet genau den vorhandenen Thread und dessen append-only Beitragslog. `id`/`thread_id` bleiben dieselbe stabile Thread-ID; es gibt keine zweite Whiteboard-Datenbank. Name, Beschreibung, Archiv und Papierkorb gehören zum gemeinsamen Thread. Ein bestehendes Log wird ohne Datenmigration oder Änderung seiner Beiträge erkannt. Leere bisher unbenutzte Threads erhalten durch ausdrückliches Erstellen, Öffnen oder Schließen einen Boardmarker. + +Die Befehlsregistry registriert `whiteboard.boards`, `whiteboard.get`, `whiteboard.create`, `whiteboard.open`, `whiteboard.close`, `whiteboard.rename`, `whiteboard.edit`, `whiteboard.archive`, `whiteboard.unarchive`, `whiteboard.delete_preview`, `whiteboard.delete` und `whiteboard.restore`. Die vorhandenen `whiteboard.list`, `whiteboard.stand` und `whiteboard.append` bleiben bestehen. Der dynamische MCP-Adapter entdeckt diese Deklarationen mit demselben Schema, Scope und Revisionsvertrag; keine unabhängige Toolliste wird gepflegt. Alle Mutationen verlangen eine dauerhafte `operation_id`; Änderungen am Thread zusätzlich seine aktuelle `expected_revision`, Erstellung Revision 0. Für Papierkorbwiederherstellung gilt stattdessen die Revision der konkreten Trashgeneration. + +## Öffnen, Schließen und Bearbeiten + +`open` speichert `context.extra.whiteboard.state=open` und wählt den Thread aus. `close` speichert `closed`; neue öffentliche Beiträge und Korrekturen werden bis zum Wiederöffnen abgewiesen. Diese Werte beschreiben einen gespeicherten fachlichen Zustand. Sie beweisen keine Öffnung oder Schließung eines Betriebssystemfensters und keinen UI-ACK einer Gegenstelle. `whiteboard.get` liefert den lokalen UI-Pfad und nennt `visibility_evidence=saved_state_only`. ThreadDesk bietet die Bedienung selbst über `/whiteboards` und den Verlauf im Schreibtisch; KiraBridge bleibt unverändert. + +Bereits gebundene eingehende Rückgaben, Jobstatus und Reviewentscheidungen werden auch bei geschlossenem Board im ursprünglichen Verlauf aufbewahrt. Schließen verwirft keine später eintreffenden Belege und beendet keine laufende externe Arbeit. + +`edit` verlangt die ID eines ursprünglichen Beitrags. Es hängt einen Korrektureintrag mit Verweis auf das Original und die vorherige Korrektur an. Gespeicherte Originalbytes, Actor-, Return- und Roombindungen bleiben erhalten. `whiteboard.list` liefert weiterhin den vollständigen unveränderten Verlauf. Die ThreadDesk-Ansicht zeigt die letzte verkettete Korrektur und macht das Original aufklappbar. Veraltete Revisionen werden abgewiesen; die HTMX-Bearbeitung erhält den eingegebenen Entwurf bei einem Konflikt. + +`rename` benennt Board und zugehörigen Thread gemeinsam um. `archive` archiviert beide; `unarchive` stellt den Thread mit seinem bisherigen gespeicherten Boardzustand wieder her. Weder Umbenennung noch Zustandswechsel zählen als Dateiaktivität. + +## Papierkorb und endgültiges Löschen + +`whiteboard.delete_preview` verwendet die vorhandene Thread-Papierkorbvorschau und zeigt die betroffenen Beiträge, Notizen, Verweise sowie erhaltenen Originaldateien. `whiteboard.delete` ist ausschließlich reversibel: Der gesamte zugehörige Thread wird verborgen. Wegen dieses gemeinsamen Umfangs ist das Pflichtfeld `confirm_thread_move=true` notwendig; ohne diese ausdrückliche Bindung erfolgt keine Mutation. Die UI benennt beim Boardknoten ausdrücklich das gemeinsame Verschieben von Whiteboard, Thread und Notizen. + +`whiteboard.restore` verwendet `trash_id`, `generation`, Trashrevision und die dauerhafte Operation des bestehenden Trashvertrags. Board-, Beitrags- und Graphbeziehungsidentitäten bleiben erhalten. Bei verlorener äußerer Antwort können `command.operation` und `command.reconcile` den exakten bereits bestätigten Trash-Ownerbeleg übernehmen, ohne dessen Wirkung zu wiederholen. + +Endgültiges Löschen bleibt ausschließlich `trash.purge`: aktuelle Ampel/Blocker, konkrete Vorschau, Referenzdigest, exakte Bestätigung und Autorisierung müssen vorhanden sein. Ein Whiteboard-Delete stellt dafür keine Freigabe dar. Originaldateien werden nicht pauschal kaskadiert. Der [Papierkorbvertrag](trash-v1.md) bleibt maßgeblich. + +## Graphprojektion und Nachweisgrenzen + +Der vorhandene Graph projiziert Boards mit Beiträgen oder explizitem Boardmarker als Knoten `wb-` vom Typ `whiteboard`. Titel, Threadrevision, Beitragszahl sowie `whiteboard_state` und `whiteboard_status` stammen jeweils aus dem aktuellen Thread/Log. Der allgemeine Graphstatus lautet `active`, `paused` oder `archived`; der Inspector zeigt zusätzlich den präzisen Boardzustand. Bestehende KnowledgeNodes desselben Threads werden mit stabil abgeleiteten `references`-Beziehungen verbunden. Trash verbirgt die Projektion; Restore erzeugt dieselben IDs erneut. Die vorhandene Graphänderungsbeobachtung aktualisiert offene Ansichten nach den kanonischen Threadänderungen. + +Projektionsknoten werden nicht als neue persistierte KnowledgeNodes gespeichert. Bestehende Exporte kanonischer KnowledgeNodes enthalten diese abgeleiteten Knoten daher nicht als eigenständige gespeicherte Objekte; die Thread-/Whiteboarddaten bleiben im vorhandenen Backup-/Exportvertrag erhalten. `graph.node` und `graph.node.transitions` können sie lesen; Fachänderungen erfolgen über die Whiteboardbefehle. `graph.node.create` bietet den abgeleiteten Typ nicht als unabhängige Erstellung an. Bereits gespeicherte KnowledgeNodes mit `wb-` im Namen behalten Vorrang. Eine exakte importierte ID-Kollision mit `wb-` führt zu einem ausdrücklichen `graph_projection_conflict` mit den betroffenen IDs; sie wird weder überschrieben noch still doppelt dargestellt. + +Gezielte Tests: `tests/test_whiteboard_lifecycle.py` prüft JSON und SQLite einschließlich Replay, Konflikten, unveränderten Originalen, geschlossenem Schreibschutz, historischem Rückgabeneingang, explizitem gemeinsamen Löschumfang, Trash-Owner-Reconciliation und stabiler Wiederherstellung. `tests/test_whiteboard_lifecycle_browser.py` prüft echte ThreadDesk-UI-Bedienung, erhaltene Bearbeitungsentwürfe und die Aktualisierung einer gleichzeitig geöffneten Graphansicht. Diese lokalen Tests beweisen keine Autorisierung oder sichtbare Ausführung in einem nicht getesteten KiraBridge-Principal. diff --git a/docs/evidence/dynamic-mcp-whiteboards-20261030/STATUS.md b/docs/evidence/dynamic-mcp-whiteboards-20261030/STATUS.md new file mode 100644 index 0000000..b52a840 --- /dev/null +++ b/docs/evidence/dynamic-mcp-whiteboards-20261030/STATUS.md @@ -0,0 +1,47 @@ +# Dynamisches MCP und Whiteboards · 30. Oktober 2026 + +Ausgangsstand: `main d7e30c1`. Vorhandener sauberer Worktree wiederverwendet; die offenen Änderungen im älteren Hauptverzeichnis bleiben unberührt. Drei getrennte Teilaufträge wurden tatsächlich übernommen: dynamischer MCP-Adapter, Whiteboard/Graph sowie unabhängige Inventar- und Codeprüfung. Die isolierte Koordination verwendet ein echtes ThreadDesk-Whiteboard und lokale 60-Sekunden-Feedbacktimer; keine produktiven Nutzerdaten dienen als Testfixture. + +## Inventar und Umsetzung + +Vorhandene Registry statt zweiter Funktionsliste: vorher 127, jetzt 141 Fachbefehle. [inventory.json](inventory.json) enthält jeden Befehl mit Wirkung, Scope, Verfügbarkeit, Operations-/Revisionsanforderung und Transporten. Für JSON sind 133, für SQLite 136 registrierte Befehle über MCP zulässig. Drei Migrationen benötigen SQLite; `search.*` und `server_sync.owner_reference` behalten ihre lokale Eigentümergrenze. Unzulässige Befehle bleiben im Commandkatalog begründet ausgewiesen und werden nicht als aufrufbare MCP-Einzelwerkzeuge veröffentlicht. + +| Vorhandener Bereich | Verbindliche Registryabdeckung | +| --- | --- | +| Threads, Organisation, Notizen, Dateireferenzen | `thread_manage`, bestehende Threadtools | +| Snapshots und gespeicherte Prompts | bestehende Snapshottools, neu `prompts.list` | +| Whiteboards, Beiträge, Arbeitsstand | `whiteboard.*`, zwölf neue Lifecycle-/Lesebefehle | +| Knowledge, Graph, Beziehungen und Ereignisse | `knowledge_manage`, `graph.*` | +| Ansichten und belegte UI-Zustellung | `graph_view.*` | +| Dateiaktivität, Bibliothek, Import/Indexsuche | `activity.*`, `library.*`, bestehende Librarytools | +| Papierkorb, Konflikte, Wiederherstellung, Purge | `trash.*` | +| Akteure, Module und deklarative Modulfunktionen | `actors.*`, `modules.*`, dynamische `module.*` | +| Gate, Übergaben, Rückgaben und Fragen | bestehende Fachadapter, `gate.*` | +| Räume und Serversynchronisierung | `room.*`, `server_sync.*` mit bisherigen Freigaben | +| Profile, Backup, Restore, Migration und Export | `profiles.*`, `backup.*`, `migration.*`, `export.*` | +| Hausmeister | `hausmeister.*` | +| Authority-Ereignisse einlesen | neu `authority.import`, inerte Projektion über vorhandenen Validator | +| Ownerkontextsuche | `search.*`, weiterhin ausschließlich lokal/CLI | + +Workshop-Neuentwicklung bleibt gemäß bestehendem Gesamtplan gestrichen. Sprache, Register und Transporthandshakes sind keine eigenständigen Fachaktionen. Neue Funktionen werden über quellverantwortete CommandSpecs registriert; Pythonmethoden werden nicht ungeprüft exportiert. Neue deklarative Modulbefehle und geänderte Freigaben erscheinen ohne Serverneustart. Neue eingebaute Quellfunktionen benötigen den aktualisierten ThreadDesk-Programmstand. + +Die vollständige Discovery enthält einschließlich kompatibler Legacytools 172 JSON- beziehungsweise 175 SQLite-Werkzeuge. Nach Erhalt eigener Schema-Ressourcen liegt die gemessene Größe mit 323894/329626 Bytes unter dem aktuell konfigurierten Gegenstellenlimit von 524288 Bytes. [MCP-Vertrag](../../contracts/mcp-dynamic-v1.md) und [Whiteboardvertrag](../../contracts/whiteboards-v1.md) beschreiben Schemas, Namensgrenzen, Rechte, Belege und fachliche Grenzen. + +Whiteboard und zugehöriger Thread teilen Titel, Archiv und Papierkorb. `whiteboard.delete` verlangt ausdrücklich `confirm_thread_move=true`; Originaldateien bleiben erhalten. Endgültiges Löschen erfordert weiterhin die konkrete Vorschau, Ampel/Blocker, Generation, Referenzdigest und exakte Löschbestätigung. Open/Closed ist gespeicherter Fachzustand mit UI-Pfad, keine Behauptung eines geöffneten Betriebssystemfensters. Bearbeitung hängt Korrekturen an; das ursprüngliche Log bleibt erhalten. + +## Nachweise + +- 68 gezielte MCP-/Command-/Modul-/Paketprüfungen bestanden; nach letzter defensiver Schemakopie nochmals alle 14 Dynamic-Cases bestanden. +- Erweiterungsrepro nach dem ersten Commit: lokale Schema-Referenzen waren unter `arguments` an die falsche Wurzel gebunden. Der enge Folgefix erhält eine eigene Fachressource und native Feldreferenzen. 84 Dynamic-/Command-/Modultests bestanden, einschließlich 26 zusätzlicher JSON-/SQLite-Fälle für `$defs`, Anker, Rekursion, vorhandene IDs, native Feldschemas und gesperrte externe Abfragen. Der überholte erste CI-Lauf wurde gestoppt; nur der aktualisierte Pin kann finale Freigabe erhalten. +- Die erste vollständige CI auf `5f38c8d` führte 2.518 Fälle aus: 2.513 bestanden, fünf fehlgeschlagen, keine übersprungen. [Fehlerbeleg](ci-failure-summary.json): eine veraltete Charakterisierung erwartete ausschließlich die feste MCP-Liste; vier Fälle betrafen neue Whiteboard-Übersetzungen und deren Wächter. Die Korrektur erhält die exakte Legacy-Prüfung und ergänzt dynamische Registry-/Löschbindings sowie vollständige Zustandslabel-Abdeckung. Der enge i18n-/Whiteboardlauf bestand mit 154 Fällen, Charakterisierung plus Dynamic-Cases mit 48 Fällen; beide Änderungen sind unabhängig freigegeben. Der fehlgeschlagene Gesamtlauf wird dadurch nicht nachträglich als grün behandelt; für den korrigierten Pin ist die reguläre zweifache Gesamt-CI erneut erforderlich. +- 112 gezielte Whiteboard-/KnowledgeGraph-/GraphChanges-/Reconciliationprüfungen einschließlich echter Browserabnahme bestanden. Nach letzter Metadatenhärtung nochmals 15 Lifecyclefälle bestanden. Der Browser belegt Korrektur mit Originalerhalt, Close/Open, Konfliktentwurf sowie Liveänderungen nach Rename/Archive/Delete/Restore. [Graphbild](whiteboard-live-graph.png), [Boardliste](whiteboards-list.png). +- Unabhängige Prüfinstanz: 51 vorhandene Regressionstests sowie 22 neue Dynamic-/Prompt-/Authority-/Paketfälle bestanden. Die Mengen überschneiden sich mit den obigen Läufen und werden nicht addiert. +- [source-checks.json](source-checks.json): echter separater stdio-Server für JSON und SQLite. Je 35 Ergebnisdigests kanonisch nachgerechnet; vollständiger Whiteboard-Lifecycle, Replay, Operations-/Revisionskonflikt, Graphzustände, Verbergen im Papierkorb, Wiederherstellung gleicher IDs und abgewiesenes Purge ohne Freigabe. +- `scripts/check_mcp_whiteboards.py` führt denselben Nachweis mit `--server /Applications/ThreadDesk.app/Contents/Resources/bin/ThreadDeskServer` direkt am fertigen Paket aus. Dabei werden `PYTHONPATH`/`PYTHONHOME` entfernt. Beide Paketentrypoints unterstützen dafür `--mcp` ohne Fenster- oder Listenerstart. +- JavaScript-Syntax und `git diff --check` bestanden. Pflicht-CI, Mergepin und finale Paketinstallation sind getrennte Integrationsschritte; lokale Tests ersetzen sie nicht. Deren tatsächliche Resultate gehören zum finalen Integrationsbeleg und PR-Verlauf. + +## Gegenstelle und ausdrückliche Grenze + +[counterpart.json](counterpart.json) dokumentiert die tatsächlich verbundene KiraBridge `0.8.0rc19`, Principal `chatgpt`, Modus `authorized_tools` und 34 entdeckte Tools. Ihre bestehende Konfiguration verwendet weiterhin den separaten ThreadDesk-Snapshot `770bbc9`. Der neue Quellstand wird dort durch einen ThreadDesk-Commit oder die Desktopinstallation nicht automatisch ausgewählt. Die Live-Abfrage belegt diesen Unterschied, keine erfolgreiche Ausführung der neuen Werkzeuge durch KiraBridge. + +KiraBridge-Code, App, Konfiguration und Terminalsteuerung werden in diesem Auftrag nicht verändert. Die Auswahl des neuen ThreadDesk-Endpunkts und ein Aufruf aus dem tatsächlichen Zielprincipal bleiben deshalb eine konkret benannte externe Integrationsabhängigkeit. Eine alte 64-Tool-Grenze aus dem historischen Audit wird ohne Prüfung der laufenden Gegenstelle weder als aktueller Fehler noch als behoben behauptet. ThreadDesk liefert den eigenständigen MCP-Endpunkt, präzise Schemas und nachprüfbare Ergebnisse; Consumer-Caches und fremde Autorisierungsregeln kann es nicht selbst ändern. diff --git a/docs/evidence/dynamic-mcp-whiteboards-20261030/ci-failure-summary.json b/docs/evidence/dynamic-mcp-whiteboards-20261030/ci-failure-summary.json new file mode 100644 index 0000000..2357201 --- /dev/null +++ b/docs/evidence/dynamic-mcp-whiteboards-20261030/ci-failure-summary.json @@ -0,0 +1,23 @@ +{ + "source_commit": "5f38c8d2142c32cddf84a7309ddba76702e3b52a", + "workflow_run": 41107585251, + "url": "https://github.com/landjunge/threaddesk/actions/runs/41107585251", + "result": "failed_first_complete_run; repeat_not_executed", + "tests": 2518, + "failures": 5, + "errors": 0, + "skipped": 0, + "duration_seconds": 1409.588, + "failed_cases": [ + "tests.test_characterization::test_cli_and_mcp_publish_the_current_command_and_tool_contracts", + "tests.test_i18n::test_languages_differ_where_they_should", + "tests.test_i18n::test_template_keys_exist[partials/whiteboard.html]", + "tests.test_i18n::test_template_keys_exist[whiteboards.html]", + "tests.test_i18n::test_every_dropdown_value_has_a_label" + ], + "correction_scope": [ + "Retain exact legacy MCP contract; separately verify current authorized dynamic tools and explicit deletion bindings.", + "Complete the new whiteboard DE/EN labels and dynamic state-label coverage without weakening existing translation guards." + ], + "new_complete_ci_required": true +} diff --git a/docs/evidence/dynamic-mcp-whiteboards-20261030/counterpart.json b/docs/evidence/dynamic-mcp-whiteboards-20261030/counterpart.json new file mode 100644 index 0000000..49a6014 --- /dev/null +++ b/docs/evidence/dynamic-mcp-whiteboards-20261030/counterpart.json @@ -0,0 +1,189 @@ +{ + "observed_date": "2026-10-30", + "direct_connected_counterpart": true, + "version": "0.8.0rc19", + "instance_id": "43066eca506a4da899072a9ae7851360", + "principal": "chatgpt", + "discovery_revision": "d613e4b8ff09855c0893100d499c216f44dc850e8b4f78a225d4e5815f10d9cf", + "mode": "authorized_tools", + "enabled": true, + "tool_count": 34, + "tools": [ + { + "name": "library_receive_status", + "readOnly": true + }, + { + "name": "library_search", + "readOnly": true + }, + { + "name": "library_file_info", + "readOnly": true + }, + { + "name": "library_receive", + "readOnly": false + }, + { + "name": "knowledge_manage", + "readOnly": false + }, + { + "name": "get_reentry", + "readOnly": true + }, + { + "name": "record_handoff_question", + "readOnly": false + }, + { + "name": "answer_handoff_question", + "readOnly": false + }, + { + "name": "issue_handoff", + "readOnly": false + }, + { + "name": "get_issued_handoff", + "readOnly": true + }, + { + "name": "bind_handoff_run", + "readOnly": false + }, + { + "name": "thread_operation_status", + "readOnly": true + }, + { + "name": "thread_manage", + "readOnly": false + }, + { + "name": "list_threads", + "readOnly": true + }, + { + "name": "get_thread", + "readOnly": true + }, + { + "name": "current_thread", + "readOnly": true + }, + { + "name": "switch_thread", + "readOnly": false + }, + { + "name": "add_note", + "readOnly": false + }, + { + "name": "save_snapshot", + "readOnly": false + }, + { + "name": "list_snapshots", + "readOnly": true + }, + { + "name": "restore_snapshot", + "readOnly": false + }, + { + "name": "generate_prompt", + "readOnly": false + }, + { + "name": "export_handoff", + "readOnly": false + }, + { + "name": "import_return", + "readOnly": false + }, + { + "name": "get_return", + "readOnly": false + }, + { + "name": "list_returns", + "readOnly": false + }, + { + "name": "review_return", + "readOnly": false + }, + { + "name": "export_grok", + "readOnly": false + }, + { + "name": "export_gnom", + "readOnly": false + }, + { + "name": "bind_gnom_job", + "readOnly": false + }, + { + "name": "receive_gnom_callback", + "readOnly": false + }, + { + "name": "list_gnom_jobs", + "readOnly": true + }, + { + "name": "check_gate", + "readOnly": true + }, + { + "name": "dashboard", + "readOnly": false + } + ], + "dynamic_operation": { + "capability": "threaddesk.manage", + "effect": "write", + "execution_class": "normal", + "execution_budget_seconds": null, + "arguments": { + "additionalProperties": false, + "properties": { + "name": { + "pattern": "^[A-Za-z][A-Za-z0-9_-]{0,63}$", + "title": "Name", + "type": "string" + }, + "arguments": { + "additionalProperties": true, + "title": "Arguments", + "type": "object" + }, + "contract_sha256": { + "pattern": "^[a-f0-9]{64}$", + "title": "Contract Sha256", + "type": "string" + } + }, + "required": [ + "name", + "contract_sha256" + ], + "title": "ThreadDeskDynamicInput", + "type": "object" + }, + "approval_required": false, + "permission_decision": { + "principal": "chatgpt", + "authorized": true, + "approval_required": false + } + }, + "configured_threaddesk_pin": "770bbc9", + "integration_result": "Connected counterpart still consumes its pre-existing ThreadDesk snapshot. No KiraBridge configuration/code/app/terminal change was made; new endpoint not attested through this counterpart." +} diff --git a/docs/evidence/dynamic-mcp-whiteboards-20261030/inventory.json b/docs/evidence/dynamic-mcp-whiteboards-20261030/inventory.json new file mode 100644 index 0000000..de635a5 --- /dev/null +++ b/docs/evidence/dynamic-mcp-whiteboards-20261030/inventory.json @@ -0,0 +1,4246 @@ +{ + "base_commit": "d7e30c14239bf3956c6ac85f1249cc70391fb3e1", + "before": { + "commands": 127, + "json_availability": { + "available": 119, + "unsupported_backend": 3, + "local_owner_cli_required": 5 + } + }, + "backends": { + "json": { + "commands": 141, + "availability": { + "available": 133, + "unsupported_backend": 3, + "local_owner_cli_required": 5 + }, + "mcp_tools": 172, + "mcp_discovery_bytes": 323894, + "entries": [ + { + "id": "activity.files", + "module": "activity", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "activity.reconcile", + "module": "activity", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "actors.agent", + "module": "actors", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "actors.appearance", + "module": "actors", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "actors.human", + "module": "actors", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "actors.list", + "module": "actors", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "actors.recolor", + "module": "actors", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "actors.rename", + "module": "actors", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "add_note", + "module": "threads", + "effect": "data", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": "id" + }, + { + "id": "answer_handoff_question", + "module": "handoffs", + "effect": "domain", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "authority.import", + "module": "authority", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "backup.create", + "module": "backup", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "backup.restore", + "module": "backup", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "backup.verify", + "module": "backup", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "bind_gnom_job", + "module": "handoffs", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "bind_handoff_run", + "module": "handoffs", + "effect": "domain", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "check_gate", + "module": "gate", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "command.catalog", + "module": "context", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "command.operation", + "module": "context", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "command.reconcile", + "module": "context", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "current_thread", + "module": "threads", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "dashboard", + "module": "threads", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "export.preview", + "module": "export", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "export.write", + "module": "export", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "export_gnom", + "module": "handoffs", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": "id" + }, + { + "id": "export_grok", + "module": "handoffs", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": "id" + }, + { + "id": "export_handoff", + "module": "handoffs", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": "id" + }, + { + "id": "gate.freeze", + "module": "gate", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "gate.set", + "module": "gate", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "gate.status", + "module": "gate", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "generate_prompt", + "module": "threads", + "effect": "data", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": "id" + }, + { + "id": "get_issued_handoff", + "module": "handoffs", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "get_reentry", + "module": "threads", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "get_return", + "module": "returns", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "get_thread", + "module": "threads", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "graph.change_status", + "module": "graph", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "graph.events", + "module": "graph", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "graph.node", + "module": "graph", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "graph.node.create", + "module": "graph", + "effect": "data", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": "create" + }, + { + "id": "graph.node.transition", + "module": "graph", + "effect": "data", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": "node:id" + }, + { + "id": "graph.node.transitions", + "module": "graph", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "graph.query", + "module": "graph", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "graph.relation.create", + "module": "graph", + "effect": "data", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": "node:source_id" + }, + { + "id": "graph.relations", + "module": "graph", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "graph_view.apply", + "module": "graph_view", + "effect": "domain", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "graph_view.apply_status", + "module": "graph_view", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "graph_view.clients", + "module": "graph_view", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "graph_view.get", + "module": "graph_view", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "graph_view.list", + "module": "graph_view", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "graph_view.manage", + "module": "graph_view", + "effect": "domain", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "graph_view.operation", + "module": "graph_view", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "hausmeister.enable", + "module": "hausmeister", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "hausmeister.enqueue", + "module": "hausmeister", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": "thread_id" + }, + { + "id": "hausmeister.jobs", + "module": "hausmeister", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "hausmeister.model", + "module": "hausmeister", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "hausmeister.probe", + "module": "hausmeister", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "hausmeister.run", + "module": "hausmeister", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": "thread_id" + }, + { + "id": "hausmeister.status", + "module": "hausmeister", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "import_return", + "module": "returns", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "issue_handoff", + "module": "handoffs", + "effect": "domain", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "knowledge_manage", + "module": "knowledge", + "effect": "domain", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "library.folder_import", + "module": "library", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "library.journal_resume", + "module": "library", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "library.journal_status", + "module": "library", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "library.package_import", + "module": "library", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "library.resume", + "module": "library", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "library.selected_import", + "module": "library", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "library_file_info", + "module": "library", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "library_manage", + "module": "library", + "effect": "domain", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "library_query", + "module": "library", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "library_receive", + "module": "library", + "effect": "domain", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "library_receive_status", + "module": "library", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "library_search", + "module": "library", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "list_gnom_jobs", + "module": "handoffs", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "list_returns", + "module": "returns", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "list_snapshots", + "module": "snapshots", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "list_threads", + "module": "threads", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "migration.commit", + "module": "migration", + "effect": "external", + "scope": "workspace.write", + "availability": "unsupported_backend", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "migration.preview", + "module": "migration", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "migration.recover", + "module": "migration", + "effect": "external", + "scope": "workspace.write", + "availability": "unsupported_backend", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "migration.stage", + "module": "migration", + "effect": "external", + "scope": "workspace.write", + "availability": "unsupported_backend", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "modules.enable", + "module": "modules", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "modules.install", + "module": "modules", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "modules.list", + "module": "modules", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "modules.uninstall", + "module": "modules", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "modules.update", + "module": "modules", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "profiles.activate", + "module": "profiles", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "profiles.list", + "module": "profiles", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "profiles.original", + "module": "profiles", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "prompt.preview", + "module": "context", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "prompts.list", + "module": "prompts", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "receive_gnom_callback", + "module": "handoffs", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "record_handoff_question", + "module": "handoffs", + "effect": "domain", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "restore_snapshot", + "module": "snapshots", + "effect": "data", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": "snapshot" + }, + { + "id": "review_return", + "module": "returns", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "room.create", + "module": "room", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "room.invite", + "module": "room", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "room.join", + "module": "room", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "room.select", + "module": "room", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "room.status", + "module": "room", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "room.sync", + "module": "room", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "save_snapshot", + "module": "snapshots", + "effect": "data", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": "id" + }, + { + "id": "search.configure", + "module": "local_search", + "effect": "external", + "scope": "workspace.write", + "availability": "local_owner_cli_required", + "allowed_transports": [ + "local", + "cli" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "search.query", + "module": "local_search", + "effect": "read", + "scope": "workspace.read", + "availability": "local_owner_cli_required", + "allowed_transports": [ + "local", + "cli" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "search.rebuild", + "module": "local_search", + "effect": "external", + "scope": "workspace.write", + "availability": "local_owner_cli_required", + "allowed_transports": [ + "local", + "cli" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "search.status", + "module": "local_search", + "effect": "read", + "scope": "workspace.read", + "availability": "local_owner_cli_required", + "allowed_transports": [ + "local", + "cli" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "server_sync.configure", + "module": "server_sync", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": "thread_id" + }, + { + "id": "server_sync.lifecycle_sync", + "module": "server_sync", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": "thread_in_trash:thread_id" + }, + { + "id": "server_sync.offline", + "module": "server_sync", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": "thread_id" + }, + { + "id": "server_sync.owner_reference", + "module": "server_sync", + "effect": "external", + "scope": "workspace.write", + "availability": "local_owner_cli_required", + "allowed_transports": [ + "local", + "cli" + ], + "operation_id_required": true, + "revision_target": "thread_id" + }, + { + "id": "server_sync.resolve", + "module": "server_sync", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": "thread_in_trash:thread_id" + }, + { + "id": "server_sync.run", + "module": "server_sync", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": "thread_id" + }, + { + "id": "server_sync.status", + "module": "server_sync", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "switch_thread", + "module": "threads", + "effect": "data", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": "id" + }, + { + "id": "thread_manage", + "module": "threads", + "effect": "domain", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "thread_operation_status", + "module": "threads", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "trash.get", + "module": "trash", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "trash.list", + "module": "trash", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "trash.move", + "module": "trash", + "effect": "domain", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "trash.operation", + "module": "trash", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "trash.preview", + "module": "trash", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "trash.purge", + "module": "trash", + "effect": "domain", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "trash.purge_preview", + "module": "trash", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "trash.resolve_sync_conflict", + "module": "trash", + "effect": "domain", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "trash.restore", + "module": "trash", + "effect": "domain", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "trash.sync_conflicts", + "module": "trash", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "whiteboard.append", + "module": "context", + "effect": "data", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": "id" + }, + { + "id": "whiteboard.archive", + "module": "whiteboard", + "effect": "data", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": "id" + }, + { + "id": "whiteboard.boards", + "module": "whiteboard", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "whiteboard.close", + "module": "whiteboard", + "effect": "data", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": "id" + }, + { + "id": "whiteboard.create", + "module": "whiteboard", + "effect": "data", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": "create" + }, + { + "id": "whiteboard.delete", + "module": "whiteboard", + "effect": "domain", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "whiteboard.delete_preview", + "module": "whiteboard", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "whiteboard.edit", + "module": "whiteboard", + "effect": "data", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": "id" + }, + { + "id": "whiteboard.get", + "module": "whiteboard", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "whiteboard.list", + "module": "context", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "whiteboard.open", + "module": "whiteboard", + "effect": "data", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": "id" + }, + { + "id": "whiteboard.rename", + "module": "whiteboard", + "effect": "data", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": "id" + }, + { + "id": "whiteboard.restore", + "module": "whiteboard", + "effect": "domain", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "whiteboard.stand", + "module": "context", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "whiteboard.unarchive", + "module": "whiteboard", + "effect": "data", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": "id" + } + ] + }, + "sqlite": { + "commands": 141, + "availability": { + "available": 136, + "local_owner_cli_required": 5 + }, + "mcp_tools": 175, + "mcp_discovery_bytes": 329626, + "entries": [ + { + "id": "activity.files", + "module": "activity", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "activity.reconcile", + "module": "activity", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "actors.agent", + "module": "actors", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "actors.appearance", + "module": "actors", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "actors.human", + "module": "actors", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "actors.list", + "module": "actors", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "actors.recolor", + "module": "actors", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "actors.rename", + "module": "actors", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "add_note", + "module": "threads", + "effect": "data", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": "id" + }, + { + "id": "answer_handoff_question", + "module": "handoffs", + "effect": "domain", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "authority.import", + "module": "authority", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "backup.create", + "module": "backup", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "backup.restore", + "module": "backup", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "backup.verify", + "module": "backup", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "bind_gnom_job", + "module": "handoffs", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "bind_handoff_run", + "module": "handoffs", + "effect": "domain", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "check_gate", + "module": "gate", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "command.catalog", + "module": "context", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "command.operation", + "module": "context", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "command.reconcile", + "module": "context", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "current_thread", + "module": "threads", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "dashboard", + "module": "threads", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "export.preview", + "module": "export", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "export.write", + "module": "export", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "export_gnom", + "module": "handoffs", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": "id" + }, + { + "id": "export_grok", + "module": "handoffs", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": "id" + }, + { + "id": "export_handoff", + "module": "handoffs", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": "id" + }, + { + "id": "gate.freeze", + "module": "gate", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "gate.set", + "module": "gate", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "gate.status", + "module": "gate", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "generate_prompt", + "module": "threads", + "effect": "data", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": "id" + }, + { + "id": "get_issued_handoff", + "module": "handoffs", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "get_reentry", + "module": "threads", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "get_return", + "module": "returns", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "get_thread", + "module": "threads", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "graph.change_status", + "module": "graph", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "graph.events", + "module": "graph", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "graph.node", + "module": "graph", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "graph.node.create", + "module": "graph", + "effect": "data", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": "create" + }, + { + "id": "graph.node.transition", + "module": "graph", + "effect": "data", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": "node:id" + }, + { + "id": "graph.node.transitions", + "module": "graph", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "graph.query", + "module": "graph", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "graph.relation.create", + "module": "graph", + "effect": "data", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": "node:source_id" + }, + { + "id": "graph.relations", + "module": "graph", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "graph_view.apply", + "module": "graph_view", + "effect": "domain", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "graph_view.apply_status", + "module": "graph_view", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "graph_view.clients", + "module": "graph_view", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "graph_view.get", + "module": "graph_view", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "graph_view.list", + "module": "graph_view", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "graph_view.manage", + "module": "graph_view", + "effect": "domain", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "graph_view.operation", + "module": "graph_view", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "hausmeister.enable", + "module": "hausmeister", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "hausmeister.enqueue", + "module": "hausmeister", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": "thread_id" + }, + { + "id": "hausmeister.jobs", + "module": "hausmeister", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "hausmeister.model", + "module": "hausmeister", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "hausmeister.probe", + "module": "hausmeister", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "hausmeister.run", + "module": "hausmeister", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": "thread_id" + }, + { + "id": "hausmeister.status", + "module": "hausmeister", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "import_return", + "module": "returns", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "issue_handoff", + "module": "handoffs", + "effect": "domain", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "knowledge_manage", + "module": "knowledge", + "effect": "domain", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "library.folder_import", + "module": "library", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "library.journal_resume", + "module": "library", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "library.journal_status", + "module": "library", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "library.package_import", + "module": "library", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "library.resume", + "module": "library", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "library.selected_import", + "module": "library", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "library_file_info", + "module": "library", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "library_manage", + "module": "library", + "effect": "domain", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "library_query", + "module": "library", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "library_receive", + "module": "library", + "effect": "domain", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "library_receive_status", + "module": "library", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "library_search", + "module": "library", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "list_gnom_jobs", + "module": "handoffs", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "list_returns", + "module": "returns", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "list_snapshots", + "module": "snapshots", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "list_threads", + "module": "threads", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "migration.commit", + "module": "migration", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "migration.preview", + "module": "migration", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "migration.recover", + "module": "migration", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "migration.stage", + "module": "migration", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "modules.enable", + "module": "modules", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "modules.install", + "module": "modules", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "modules.list", + "module": "modules", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "modules.uninstall", + "module": "modules", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "modules.update", + "module": "modules", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "profiles.activate", + "module": "profiles", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "profiles.list", + "module": "profiles", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "profiles.original", + "module": "profiles", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "prompt.preview", + "module": "context", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "prompts.list", + "module": "prompts", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "receive_gnom_callback", + "module": "handoffs", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "record_handoff_question", + "module": "handoffs", + "effect": "domain", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "restore_snapshot", + "module": "snapshots", + "effect": "data", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": "snapshot" + }, + { + "id": "review_return", + "module": "returns", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "room.create", + "module": "room", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "room.invite", + "module": "room", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "room.join", + "module": "room", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "room.select", + "module": "room", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "room.status", + "module": "room", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "room.sync", + "module": "room", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "save_snapshot", + "module": "snapshots", + "effect": "data", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": "id" + }, + { + "id": "search.configure", + "module": "local_search", + "effect": "external", + "scope": "workspace.write", + "availability": "local_owner_cli_required", + "allowed_transports": [ + "local", + "cli" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "search.query", + "module": "local_search", + "effect": "read", + "scope": "workspace.read", + "availability": "local_owner_cli_required", + "allowed_transports": [ + "local", + "cli" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "search.rebuild", + "module": "local_search", + "effect": "external", + "scope": "workspace.write", + "availability": "local_owner_cli_required", + "allowed_transports": [ + "local", + "cli" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "search.status", + "module": "local_search", + "effect": "read", + "scope": "workspace.read", + "availability": "local_owner_cli_required", + "allowed_transports": [ + "local", + "cli" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "server_sync.configure", + "module": "server_sync", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": "thread_id" + }, + { + "id": "server_sync.lifecycle_sync", + "module": "server_sync", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": "thread_in_trash:thread_id" + }, + { + "id": "server_sync.offline", + "module": "server_sync", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": "thread_id" + }, + { + "id": "server_sync.owner_reference", + "module": "server_sync", + "effect": "external", + "scope": "workspace.write", + "availability": "local_owner_cli_required", + "allowed_transports": [ + "local", + "cli" + ], + "operation_id_required": true, + "revision_target": "thread_id" + }, + { + "id": "server_sync.resolve", + "module": "server_sync", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": "thread_in_trash:thread_id" + }, + { + "id": "server_sync.run", + "module": "server_sync", + "effect": "external", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": "thread_id" + }, + { + "id": "server_sync.status", + "module": "server_sync", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "switch_thread", + "module": "threads", + "effect": "data", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": "id" + }, + { + "id": "thread_manage", + "module": "threads", + "effect": "domain", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "thread_operation_status", + "module": "threads", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "trash.get", + "module": "trash", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "trash.list", + "module": "trash", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "trash.move", + "module": "trash", + "effect": "domain", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "trash.operation", + "module": "trash", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "trash.preview", + "module": "trash", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "trash.purge", + "module": "trash", + "effect": "domain", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "trash.purge_preview", + "module": "trash", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "trash.resolve_sync_conflict", + "module": "trash", + "effect": "domain", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "trash.restore", + "module": "trash", + "effect": "domain", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "trash.sync_conflicts", + "module": "trash", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "whiteboard.append", + "module": "context", + "effect": "data", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": "id" + }, + { + "id": "whiteboard.archive", + "module": "whiteboard", + "effect": "data", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": "id" + }, + { + "id": "whiteboard.boards", + "module": "whiteboard", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "whiteboard.close", + "module": "whiteboard", + "effect": "data", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": "id" + }, + { + "id": "whiteboard.create", + "module": "whiteboard", + "effect": "data", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": "create" + }, + { + "id": "whiteboard.delete", + "module": "whiteboard", + "effect": "domain", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "whiteboard.delete_preview", + "module": "whiteboard", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "whiteboard.edit", + "module": "whiteboard", + "effect": "data", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": "id" + }, + { + "id": "whiteboard.get", + "module": "whiteboard", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "whiteboard.list", + "module": "context", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "whiteboard.open", + "module": "whiteboard", + "effect": "data", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": "id" + }, + { + "id": "whiteboard.rename", + "module": "whiteboard", + "effect": "data", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": "id" + }, + { + "id": "whiteboard.restore", + "module": "whiteboard", + "effect": "domain", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": null + }, + { + "id": "whiteboard.stand", + "module": "context", + "effect": "read", + "scope": "workspace.read", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": false, + "revision_target": null + }, + { + "id": "whiteboard.unarchive", + "module": "whiteboard", + "effect": "data", + "scope": "workspace.write", + "availability": "available", + "allowed_transports": [ + "local", + "cli", + "http", + "mcp" + ], + "operation_id_required": true, + "revision_target": "id" + } + ] + } + } +} diff --git a/docs/evidence/dynamic-mcp-whiteboards-20261030/source-checks.json b/docs/evidence/dynamic-mcp-whiteboards-20261030/source-checks.json new file mode 100644 index 0000000..1849bcb --- /dev/null +++ b/docs/evidence/dynamic-mcp-whiteboards-20261030/source-checks.json @@ -0,0 +1,404 @@ +{ + "endpoint": "source", + "checks": [ + { + "backend": "json", + "passed": true, + "tools": 172, + "registered_available": 133, + "graph_states": [ + { + "state": "closed", + "node_id": "wb-a6f239aa74ec", + "revision": 6 + }, + { + "state": "open", + "node_id": "wb-a6f239aa74ec", + "revision": 7 + }, + { + "state": "archived", + "node_id": "wb-a6f239aa74ec", + "revision": 8 + }, + { + "state": "open", + "node_id": "wb-a6f239aa74ec", + "revision": 9 + }, + { + "state": "open", + "node_id": "wb-a6f239aa74ec", + "revision": 9 + } + ], + "receipts": [ + { + "command": "whiteboard.create", + "result_digest": "62eb70e436e264643f3cea9afa738352c4ce383a7ffde8fba0ba265ba2caafd3", + "operation_id": "qa-create", + "duplicate": false + }, + { + "command": "whiteboard.create", + "result_digest": "62eb70e436e264643f3cea9afa738352c4ce383a7ffde8fba0ba265ba2caafd3", + "operation_id": "qa-create", + "duplicate": true + }, + { + "command": "whiteboard.get", + "result_digest": "2664ff5dd5b8e31f0352c561eac10a78874542fe4a61f7275cfe96f6c47a4962" + }, + { + "command": "whiteboard.append", + "result_digest": "8c9bc8694f744273f1cc199f4114ddb2acf3fb3c0a8a0eb974941a2305699cdf", + "operation_id": "qa-whiteboard-append", + "duplicate": false + }, + { + "command": "whiteboard.get", + "result_digest": "d90591b7941442b5545327cfe92829e086fe29be1fc1a01c88f139f98058f693" + }, + { + "command": "whiteboard.edit", + "result_digest": "6b2103cdde9a7ccb9b940a31c94c99d97d7f62d64a40edab0117b68c03369126", + "operation_id": "qa-whiteboard-edit", + "duplicate": false + }, + { + "command": "whiteboard.list", + "result_digest": "b68e29694ec1cc452f79af314a26ceb56dfabe6083f86318b87ea7175aaf029d" + }, + { + "command": "whiteboard.get", + "result_digest": "1747a0b20e1b275350eb2a76ce0c248d58fc7c7ca030155bd3eed4f1c663b3b1" + }, + { + "command": "whiteboard.rename", + "result_digest": "69c611643ba0c4f91b97f5578fa20b3091e0f26795a02af3f8391cf268a063be", + "operation_id": "qa-whiteboard-rename", + "duplicate": false + }, + { + "command": "whiteboard.get", + "result_digest": "b85e81dd9b50948acc429185c711326659d1cd1263fe3d3ff26401a86866394f" + }, + { + "command": "whiteboard.close", + "result_digest": "91dfeaff581011b3179f48ea3f5519fb04dcfc2a10c670810d3a0a5e80a8a1a8", + "operation_id": "qa-whiteboard-close", + "duplicate": false + }, + { + "command": "whiteboard.get", + "result_digest": "643f73dc79d8182d0bee7b7ac31f49177889e3d64f978e00700d1d933a600fb3" + }, + { + "command": "graph.query", + "result_digest": "88b9f5e497cd7d8bf19e72c5b124a2509d0fd8183ad57f9086437ad991e92e11" + }, + { + "command": "whiteboard.get", + "result_digest": "643f73dc79d8182d0bee7b7ac31f49177889e3d64f978e00700d1d933a600fb3" + }, + { + "command": "whiteboard.open", + "result_digest": "087d26ac2dce21cc36cf01091e78b6150c258ace579ed9c3d1d0c92f0949bcf7", + "operation_id": "qa-whiteboard-open", + "duplicate": false + }, + { + "command": "whiteboard.get", + "result_digest": "93af0a8076d98347ca5df1685811d03e20375dc2e571f052b777dc1082926e59" + }, + { + "command": "graph.query", + "result_digest": "9e61845683193931215829e1f446f23d3da170f06c7ae7c21a261e17667ad3fa" + }, + { + "command": "whiteboard.get", + "result_digest": "93af0a8076d98347ca5df1685811d03e20375dc2e571f052b777dc1082926e59" + }, + { + "command": "whiteboard.archive", + "result_digest": "96d1badea4eb058ebfa445c87eb368c0bcb9bef7f1327ca107726cc92ec6b9e0", + "operation_id": "qa-whiteboard-archive", + "duplicate": false + }, + { + "command": "whiteboard.get", + "result_digest": "13ca263cda6599b56a5d9773a57d4293f2926e06fa081f3b8e392a96d086f8a4" + }, + { + "command": "graph.query", + "result_digest": "05910ea2657187e01e1ed29d0546a937859c810c4b91e6ab6590ec3d524a17ef" + }, + { + "command": "whiteboard.get", + "result_digest": "13ca263cda6599b56a5d9773a57d4293f2926e06fa081f3b8e392a96d086f8a4" + }, + { + "command": "whiteboard.unarchive", + "result_digest": "beb777b54bf1b6bf1ccc0129c2bd53c0b434a8b2f00a78d33d39679771b4364c", + "operation_id": "qa-whiteboard-unarchive", + "duplicate": false + }, + { + "command": "whiteboard.get", + "result_digest": "de2689b00bb8b92002ccf5620861a6df18c2e5a23666212f538fcb09ef9723c7" + }, + { + "command": "graph.query", + "result_digest": "5d426f227ae19ebbaab2c24a0eb84d57e178867c47cec988dc3379a2453debeb" + }, + { + "command": "whiteboard.delete_preview", + "result_digest": "bf459ba5277eaebc4fe99827d8de18baf6bd2a761bdaea7d88fcef6f420bc1a9" + }, + { + "command": "whiteboard.get", + "result_digest": "de2689b00bb8b92002ccf5620861a6df18c2e5a23666212f538fcb09ef9723c7" + }, + { + "command": "whiteboard.get", + "result_digest": "de2689b00bb8b92002ccf5620861a6df18c2e5a23666212f538fcb09ef9723c7" + }, + { + "command": "whiteboard.delete", + "result_digest": "5f47d1b58d2754147ede421ed7aea55dcff632d1716688f6c9cb03d8132f4f7f", + "operation_id": "qa-whiteboard-delete", + "duplicate": false + }, + { + "command": "graph.query", + "result_digest": "643b51f4fd414aba15e1af32f286bbb944d1ef66a0820d7d6fc3602ba57640f8" + }, + { + "command": "trash.purge_preview", + "result_digest": "a720ab1eb4c0b1cb60e34b526a3a2788def41a6c397f484fe1f057f5c66ef737" + }, + { + "command": "whiteboard.restore", + "result_digest": "fb4427333561d86a6d6d552fa2a3f31f1e59c63aa643be5bfe8168f9ec164a8b", + "operation_id": "qa-restore", + "duplicate": false + }, + { + "command": "whiteboard.get", + "result_digest": "de2689b00bb8b92002ccf5620861a6df18c2e5a23666212f538fcb09ef9723c7" + }, + { + "command": "graph.query", + "result_digest": "794377501858b264993094153189040600c7f39dc59585d2cfa8943e4b5ede5d" + }, + { + "command": "whiteboard.list", + "result_digest": "b68e29694ec1cc452f79af314a26ceb56dfabe6083f86318b87ea7175aaf029d" + } + ], + "purge_without_approval": "rejected", + "restore_ids": "preserved" + }, + { + "backend": "sqlite", + "passed": true, + "tools": 175, + "registered_available": 136, + "graph_states": [ + { + "state": "closed", + "node_id": "wb-cd467dd3512f", + "revision": 6 + }, + { + "state": "open", + "node_id": "wb-cd467dd3512f", + "revision": 7 + }, + { + "state": "archived", + "node_id": "wb-cd467dd3512f", + "revision": 8 + }, + { + "state": "open", + "node_id": "wb-cd467dd3512f", + "revision": 9 + }, + { + "state": "open", + "node_id": "wb-cd467dd3512f", + "revision": 9 + } + ], + "receipts": [ + { + "command": "whiteboard.create", + "result_digest": "cb66ab7c941d4426be6d8b2cfef567ebc5d9047f4d8fe89c02e0798bfe3a7135", + "operation_id": "qa-create", + "duplicate": false + }, + { + "command": "whiteboard.create", + "result_digest": "cb66ab7c941d4426be6d8b2cfef567ebc5d9047f4d8fe89c02e0798bfe3a7135", + "operation_id": "qa-create", + "duplicate": true + }, + { + "command": "whiteboard.get", + "result_digest": "1ac7dd86940eed8c8bcd22eb51693b5392faf0237d0ac7c069a7258a2d490731" + }, + { + "command": "whiteboard.append", + "result_digest": "739ad46694f9e3c801f1f05a5e0cf617e38f4c7b3cc505b8cffb8d6b608f6472", + "operation_id": "qa-whiteboard-append", + "duplicate": false + }, + { + "command": "whiteboard.get", + "result_digest": "c6008931181e69af92157718cf7543024256df6fbed36d0562a795707ed1e8e3" + }, + { + "command": "whiteboard.edit", + "result_digest": "3acea3a95ea6a9e59f5309ede128d2cca10a21d9326f3c372080d4265deb7b08", + "operation_id": "qa-whiteboard-edit", + "duplicate": false + }, + { + "command": "whiteboard.list", + "result_digest": "8429f4639e4c269cc39ff1d330f44492b58ab7c5907ab2f6f747b97e558f6410" + }, + { + "command": "whiteboard.get", + "result_digest": "4afde91ee579a9b024791711a4616f0234ba19f2d214caa3124634020d4d0a34" + }, + { + "command": "whiteboard.rename", + "result_digest": "062ae70ab5200524d21556599e040f21f5e3a28952a73609055c22b5e0a991f0", + "operation_id": "qa-whiteboard-rename", + "duplicate": false + }, + { + "command": "whiteboard.get", + "result_digest": "0fb147d0fa6a35f50cba91f456e3c30dcfcd419e7ff4608768f8fb99f9bd8e37" + }, + { + "command": "whiteboard.close", + "result_digest": "a8434a22399b25c69155830956360d117e3dc3dfae5b8b93c41e76d77f97d312", + "operation_id": "qa-whiteboard-close", + "duplicate": false + }, + { + "command": "whiteboard.get", + "result_digest": "4488f8df31c491ad3cfb06ed3360df6640bdd63b68085fe6c4efa1942d4b50da" + }, + { + "command": "graph.query", + "result_digest": "c8b4bd32d2ffa8329e53dfdf86fe2c667e2820350053825cc8f9a6bc87bb4824" + }, + { + "command": "whiteboard.get", + "result_digest": "4488f8df31c491ad3cfb06ed3360df6640bdd63b68085fe6c4efa1942d4b50da" + }, + { + "command": "whiteboard.open", + "result_digest": "caccb8790cc6cfc1952f51093667243badc44a5df1d3489296d64e49da1b6a43", + "operation_id": "qa-whiteboard-open", + "duplicate": false + }, + { + "command": "whiteboard.get", + "result_digest": "6b5f27dfbdd9d318f267f6960a34cec86e0526e6fccb0a654a9f7db8ed943481" + }, + { + "command": "graph.query", + "result_digest": "39e5b5cd59597f068446db52017434ad9cec6a76a2f0f1448138e3ac7563fcb3" + }, + { + "command": "whiteboard.get", + "result_digest": "6b5f27dfbdd9d318f267f6960a34cec86e0526e6fccb0a654a9f7db8ed943481" + }, + { + "command": "whiteboard.archive", + "result_digest": "a5326f2301bc1eb981c24509f6c6b8998e65da2bbae755ef7567151366585f29", + "operation_id": "qa-whiteboard-archive", + "duplicate": false + }, + { + "command": "whiteboard.get", + "result_digest": "d093aab7ca5985fe60672d01651062b34783573e7495cf10b3463154fc2962d6" + }, + { + "command": "graph.query", + "result_digest": "aea43f747edbd949e5df47621b0ba7bebcf3024915b34f90130e3664416696b7" + }, + { + "command": "whiteboard.get", + "result_digest": "d093aab7ca5985fe60672d01651062b34783573e7495cf10b3463154fc2962d6" + }, + { + "command": "whiteboard.unarchive", + "result_digest": "c60d9a11ce267ff8b0a8cc0c6f54b1fc534ac44900ce2ace72471c0dd7bc8407", + "operation_id": "qa-whiteboard-unarchive", + "duplicate": false + }, + { + "command": "whiteboard.get", + "result_digest": "a37a40670c7af070002ac0ce5a50e98d52650e81ecc575fed4df87860fdf6949" + }, + { + "command": "graph.query", + "result_digest": "8fdae3bd0e96f1de535b4e3b25968719cd32f58efe31eae6d6052a5728aac74c" + }, + { + "command": "whiteboard.delete_preview", + "result_digest": "99d68de148490c8dbbb3a37e772df6b16282c586fab10a9c3f605bd620d9f316" + }, + { + "command": "whiteboard.get", + "result_digest": "a37a40670c7af070002ac0ce5a50e98d52650e81ecc575fed4df87860fdf6949" + }, + { + "command": "whiteboard.get", + "result_digest": "a37a40670c7af070002ac0ce5a50e98d52650e81ecc575fed4df87860fdf6949" + }, + { + "command": "whiteboard.delete", + "result_digest": "71121e03d830f77f5695d05d679bc5117025779cfd6309bca505309b87e4acf0", + "operation_id": "qa-whiteboard-delete", + "duplicate": false + }, + { + "command": "graph.query", + "result_digest": "25be578b741c5bc758a2bdfd179c6c9b4531f7faf8a39901293be5a2443b0efb" + }, + { + "command": "trash.purge_preview", + "result_digest": "724207d6d5eb035d1abea57bbe547004047f4dd575cd58af0293ee06da6f1b45" + }, + { + "command": "whiteboard.restore", + "result_digest": "41357e2d4307c72f65cd675766ca355148554479b616c555c8008dd1a676a8aa", + "operation_id": "qa-restore", + "duplicate": false + }, + { + "command": "whiteboard.get", + "result_digest": "a37a40670c7af070002ac0ce5a50e98d52650e81ecc575fed4df87860fdf6949" + }, + { + "command": "graph.query", + "result_digest": "093e3f739f8bfc0307573ddb2ada7e7b7c28f1da809b6af1ae3c6e3b3a86d4e2" + }, + { + "command": "whiteboard.list", + "result_digest": "8429f4639e4c269cc39ff1d330f44492b58ab7c5907ab2f6f747b97e558f6410" + } + ], + "purge_without_approval": "rejected", + "restore_ids": "preserved" + } + ], + "kirabridge": "not controlled or modified; standalone MCP proof only" +} diff --git a/docs/evidence/dynamic-mcp-whiteboards-20261030/whiteboard-live-graph.png b/docs/evidence/dynamic-mcp-whiteboards-20261030/whiteboard-live-graph.png new file mode 100644 index 0000000..345ee1b Binary files /dev/null and b/docs/evidence/dynamic-mcp-whiteboards-20261030/whiteboard-live-graph.png differ diff --git a/docs/evidence/dynamic-mcp-whiteboards-20261030/whiteboards-list.png b/docs/evidence/dynamic-mcp-whiteboards-20261030/whiteboards-list.png new file mode 100644 index 0000000..fc1b82c Binary files /dev/null and b/docs/evidence/dynamic-mcp-whiteboards-20261030/whiteboards-list.png differ diff --git a/scripts/check_mcp_whiteboards.py b/scripts/check_mcp_whiteboards.py new file mode 100644 index 0000000..bb0842b --- /dev/null +++ b/scripts/check_mcp_whiteboards.py @@ -0,0 +1,195 @@ +"""Exercise the real standalone MCP endpoint using disposable JSON/SQLite data. + +No KiraBridge process, configuration or terminal is used. --server selects the +actual packaged ThreadDeskServer; otherwise the current source runs via stdio. +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path +import queue +import subprocess +import sys +import tempfile +import threading + +ROOT = Path(__file__).resolve().parents[1] + + +def digest(value): + body = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False) + return hashlib.sha256(body.encode()).hexdigest() + + +class Client: + def __init__(self, argv, env): + self.proc = subprocess.Popen(argv, cwd=ROOT, env=env, stdin=subprocess.PIPE, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + self.messages = queue.Queue() + self.sequence = 0 + self.notifications = [] + self.receipts = [] + + def reader(): + for line in self.proc.stdout: + try: + self.messages.put(json.loads(line)) + except ValueError: + self.messages.put({"invalid_stdout": line[:200]}) + self.messages.put({"eof": True}) + + threading.Thread(target=reader, daemon=True).start() + + def rpc(self, method, params=None): + self.sequence += 1 + request = {"jsonrpc": "2.0", "id": self.sequence, "method": method, "params": params or {}} + self.proc.stdin.write(json.dumps(request) + "\n") + self.proc.stdin.flush() + while True: + message = self.messages.get(timeout=30) + if message.get("method"): + self.notifications.append(message["method"]) + continue + assert message.get("id") == self.sequence and "error" not in message, message + return message["result"] + + def call(self, command, arguments, *, error=None, **bindings): + tool = self.tools[command] + result = self.rpc("tools/call", {"name": tool["name"], "arguments": { + "workspace_id": self.workspace_id, "arguments": arguments, **bindings}}) + body = result.get("structuredContent") or json.loads(result["content"][0]["text"]) + if error: + assert result["isError"] and body["code"] == error, body + return body + assert not result["isError"], body + assert body["schema"] == "threaddesk.command-result.v1" and body["command"] == command, body + assert "result" in body and digest(body["result"]) == body["result_digest"], body + self.receipts.append({key: body[key] for key in ("command", "result_digest", "operation_id", "duplicate") if key in body}) + return body + + def close(self): + self.proc.stdin.close() + try: + code = self.proc.wait(timeout=15) + assert code == 0, self.proc.stderr.read()[-2000:] + finally: + if self.proc.poll() is None: + self.proc.terminate() + self.proc.wait(timeout=5) + + +def check(argv, backend, folder, *, packaged=False): + env = {**os.environ, "PYTHONPATH": str(ROOT / "src"), "THREADDESK_HOME": str(folder), + "THREADDESK_STORAGE": backend, "PYTHONDONTWRITEBYTECODE": "1"} + if packaged: + env.pop("PYTHONPATH", None) + env.pop("PYTHONHOME", None) + client = Client(argv, env) + try: + hello = client.rpc("initialize", {"protocolVersion": "2024-11-05", "capabilities": {}, + "clientInfo": {"name": "threaddesk-acceptance", "version": "1"}}) + assert hello["capabilities"]["tools"]["listChanged"] is True + client.proc.stdin.write(json.dumps({"jsonrpc": "2.0", "method": "notifications/initialized"}) + "\n") + client.proc.stdin.flush() + tools = client.rpc("tools/list")["tools"] + client.tools = {tool["_meta"]["threaddesk"]["id"]: tool for tool in tools + if "threaddesk" in tool.get("_meta", {})} + assert not any(identity.startswith("search.") for identity in client.tools) + assert "server_sync.owner_reference" not in client.tools + client.workspace_id = client.tools["whiteboard.create"]["_meta"]["threaddesk"]["workspace_id"] + create_args = {"title": "MCP acceptance board", "description": "Synthetic fixture"} + created = client.call("whiteboard.create", create_args, operation_id="qa-create", expected_revision=0) + thread = created["result"]["thread"] + identity = thread["id"] + assert client.call("whiteboard.create", create_args, operation_id="qa-create", expected_revision=0)["duplicate"] + client.call("whiteboard.create", {"title": "Conflict"}, operation_id="qa-create", expected_revision=0, + error="operation_conflict") + + def board(): + return client.call("whiteboard.get", {"id": identity})["result"] + + def mutate(command, arguments=None, operation=None): + return client.call(command, {"id": identity, **(arguments or {})}, + operation_id=operation or "qa-" + command.replace(".", "-"), + expected_revision=board()["revision"]) + + appended = mutate("whiteboard.append", {"actor": "Acceptance", "actor_type": "human", + "entry_type": "note", "content": "Original"})["result"]["entry"] + mutate("whiteboard.edit", {"entry_id": appended["id"], "content": "Correction"}) + history = client.call("whiteboard.list", {"id": identity})["result"]["items"] + assert [row["content"] for row in history] == ["Original", "Correction"] + mutate("whiteboard.rename", {"title": "Renamed board"}) + client.call("whiteboard.rename", {"id": identity, "title": "Stale"}, operation_id="qa-stale", + expected_revision=thread["revision"], error="revision_conflict") + states = [] + + def graph(expected): + current = board() + result = client.call("graph.query", {"node_ids": [current["graph_node_id"]]})["result"] + assert len(result["nodes"]) == 1, result + node = result["nodes"][0] + assert node["metadata"]["whiteboard_status"] == expected and node["title"] == "Renamed board", node + states.append({"state": expected, "node_id": node["id"], "revision": node["revision"]}) + + mutate("whiteboard.close") + graph("closed") + mutate("whiteboard.open") + graph("open") + mutate("whiteboard.archive") + graph("archived") + mutate("whiteboard.unarchive") + graph("open") + preview = client.call("whiteboard.delete_preview", {"id": identity})["result"] + assert preview["external_originals_preserved"] and preview["lifecycle_scope"] == "owning_thread" + client.call("whiteboard.delete", {"id": identity}, operation_id="qa-no-confirm", + expected_revision=board()["revision"], error="validation_error") + deleted = mutate("whiteboard.delete", {"confirm_thread_move": True})["result"]["entry"] + hidden = client.call("graph.query", {"node_ids": ["wb-" + identity]})["result"] + assert hidden["nodes"] == [] + purge = client.call("trash.purge_preview", {"trash_id": deleted["trash_id"]})["result"] + client.call("trash.purge", {"trash_id": deleted["trash_id"], "generation": deleted["generation"], + "reference_digest": purge["reference_digest"], "confirmation": "unapproved"}, + operation_id="qa-reject-purge", expected_revision=deleted["revision"], error="explicit_confirmation_required") + client.call("whiteboard.restore", {"trash_id": deleted["trash_id"], "generation": deleted["generation"]}, + operation_id="qa-restore", expected_revision=deleted["revision"]) + graph("open") + restored = client.call("whiteboard.list", {"id": identity})["result"]["items"] + assert [row["id"] for row in restored] == [row["id"] for row in history] + return {"backend": backend, "passed": True, "tools": len(tools), "registered_available": len(client.tools), + "graph_states": states, "receipts": client.receipts, + "purge_without_approval": "rejected", "restore_ids": "preserved"} + finally: + client.close() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--server", type=Path) + parser.add_argument("--report", type=Path) + args = parser.parse_args() + argv = [str(args.server.resolve()), "--mcp"] if args.server else [sys.executable, "-m", "threaddesk.ui.mcp_stdio"] + with tempfile.TemporaryDirectory(prefix="td-mcp-whiteboards-") as temporary: + results = [check(argv, backend, Path(temporary) / backend, packaged=bool(args.server)) + for backend in ("json", "sqlite")] + report = {"endpoint": "packaged" if args.server else "source", "checks": results, + "kirabridge": "not controlled or modified; standalone MCP proof only"} + if args.server: + report["executable_sha256"] = hashlib.sha256(args.server.read_bytes()).hexdigest() + if args.report: + args.report.parent.mkdir(parents=True, exist_ok=True) + args.report.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n") + print(json.dumps({"endpoint": report["endpoint"], "report": str(args.report) if args.report else None, + "checks": [{"backend": row["backend"], "passed": row["passed"], + "tools": row["tools"], "registered_available": row["registered_available"], + "verified_result_digests": len(row["receipts"]), + "graph_states": [state["state"] for state in row["graph_states"]], + "restore_ids": row["restore_ids"], + "purge_without_approval": row["purge_without_approval"]} + for row in results]}, ensure_ascii=False)) + + +if __name__ == "__main__": + main() diff --git a/src/threaddesk/api/service.py b/src/threaddesk/api/service.py index efcd834..d1f21fc 100644 --- a/src/threaddesk/api/service.py +++ b/src/threaddesk/api/service.py @@ -85,6 +85,8 @@ def create_node( visibility = visibility.strip().lower() metadata = dict(metadata or {}) reject_secrets(json.dumps(metadata, ensure_ascii=False)) + if kind == "whiteboard": + raise InvalidState("Whiteboards über whiteboard.create anlegen; Graphknoten sind abgeleitete Ansichten.") if kind not in NODE_KINDS: raise InvalidState(f"Knotentyp muss einer von {', '.join(NODE_KINDS)} sein.") if status not in NODE_STATUSES: @@ -117,7 +119,13 @@ def create_node( return node def get_node(self, node_id: str) -> KnowledgeNode: - return self.knowledge.get_node(node_id) + try: + return self.knowledge.get_node(node_id) + except NotFound: + if not node_id.startswith("wb-"): + raise + from threaddesk.services.whiteboard_lifecycle import WhiteboardService + return WhiteboardService(self).graph_node(node_id) def list_nodes(self) -> list[KnowledgeNode]: return self.knowledge.list_nodes() @@ -126,7 +134,11 @@ def list_graph_events(self) -> list[GraphEvent]: return self.knowledge.list_events() def allowed_node_transitions(self, node_id: str) -> tuple[str, ...]: - node = self.store.get_node(node_id) + try: + node = self.store.get_node(node_id) + except NotFound: + self.get_node(node_id) + return () return NODE_TRANSITIONS.get(node.kind, {}).get(node.status, ()) def _record_graph_event( @@ -258,7 +270,13 @@ def list_relations(self) -> list[Relation]: return self.knowledge.list_relations() def relations_for(self, node_id: str) -> list[Relation]: - self.store.get_node(node_id) + try: + self.store.get_node(node_id) + except NotFound: + self.get_node(node_id) + graph = self.graph() + return [Relation.from_dict(row) for row in graph["relations"] + if node_id in (row["source_id"], row["target_id"])] return [ relation for relation in self.store.list_relations() @@ -278,6 +296,10 @@ def graph(self, *, kind: str | None = None, status: str | None = None) -> dict: for node in self.store.list_nodes() if (not kind or node.kind == kind) and (not status or node.status == status) ] + from threaddesk.services.whiteboard_lifecycle import WhiteboardService + boards = WhiteboardService(self) + nodes.extend(node for node in boards.graph_nodes() + if (not kind or node.kind == kind) and (not status or node.status == status)) from threaddesk.services.library_links import visible_library_nodes, library_relations nodes = visible_library_nodes(self.store, nodes) node_ids = {node.id for node in nodes} @@ -287,6 +309,7 @@ def graph(self, *, kind: str | None = None, status: str | None = None) -> dict: if relation.source_id in node_ids and relation.target_id in node_ids ] relations.extend(library_relations(self.store, nodes)) + relations.extend(boards.graph_relations(nodes)) return { "schema": "threaddesk.graph.v1", "nodes": [node.to_dict() for node in nodes], diff --git a/src/threaddesk/core/graph_i18n.py b/src/threaddesk/core/graph_i18n.py index 64b0215..e4b0be0 100644 --- a/src/threaddesk/core/graph_i18n.py +++ b/src/threaddesk/core/graph_i18n.py @@ -1,6 +1,8 @@ """Graph workspace controls; distinct from library and thread labels.""" GRAPH_STRINGS = { + "map.whiteboard_state": {"de": "Whiteboardzustand", "en": "Whiteboard state"}, + "map.trash_whiteboard_confirm": {"de": "Whiteboard MIT zugehörigem Thread samt Notizen in den Papierkorb verschieben?", "en": "Move this whiteboard WITH its owning thread and notes to trash?"}, "map.fit": {"de": "Übersicht", "en": "Overview"}, "map.inspector": {"de": "Details und Dateien", "en": "Details and files"}, "map.inspector_close": {"de": "Seitenleiste schließen", "en": "Close sidebar"}, diff --git a/src/threaddesk/core/i18n.py b/src/threaddesk/core/i18n.py index da664f4..e590f49 100644 --- a/src/threaddesk/core/i18n.py +++ b/src/threaddesk/core/i18n.py @@ -615,6 +615,17 @@ "notes.mic": {"de": "Mikrofon", "en": "Microphone"}, "notes.mic_stop": {"de": "Stopp", "en": "Stop"}, "notes.save": {"de": "Notiz speichern", "en": "Save note"}, + 'board.boards': {"de": 'Whiteboard-Übersicht', "en": 'Whiteboards'}, + 'board.shared_thread': {"de": 'Ein Whiteboard gehört zu einem Thread. Name, Archiv und Papierkorb gelten gemeinsam.', "en": 'A whiteboard belongs to one thread. Name, archive and trash apply to both.'}, + 'board.create': {"de": 'Whiteboard erstellen', "en": 'Create whiteboard'}, + 'board.open': {"de": 'Whiteboard öffnen', "en": 'Open whiteboard'}, + 'board.close': {"de": 'Whiteboard schließen', "en": 'Close whiteboard'}, + 'board.unarchive': {"de": 'Aus Archiv wiederherstellen', "en": 'Restore from archive'}, + 'board.state.open': {"de": 'Whiteboard offen', "en": 'Whiteboard open'}, + 'board.state.closed': {"de": 'Whiteboard geschlossen', "en": 'Whiteboard closed'}, + 'board.state.archived': {"de": 'Whiteboard archiviert', "en": 'Whiteboard archived'}, + 'board.edit': {"de": 'Beitrag bearbeiten', "en": 'Edit contribution'}, + 'board.corrected': {"de": 'Bearbeitet – ursprünglichen Beitrag anzeigen', "en": 'Edited — show original contribution'}, "whiteboard.stand": {"de": "Aktueller Stand", "en": "Current state"}, "whiteboard.status": {"de": "Status", "en": "State"}, "whiteboard.open_task": {"de": "Offene Aufgabe", "en": "Open task"}, @@ -740,6 +751,7 @@ "en": "No threads. Create one above.", }, # --- Knotenarten (Auswahlmenues) --- + "kind.whiteboard": {"de": "Whiteboard", "en": "Whiteboard"}, "kind.project": { "de": "Projekt", "en": "Project", diff --git a/src/threaddesk/core/models.py b/src/threaddesk/core/models.py index 2e8f93a..86983e5 100644 --- a/src/threaddesk/core/models.py +++ b/src/threaddesk/core/models.py @@ -19,6 +19,7 @@ "tool", "source", "workflow", + "whiteboard", ) NODE_STATUSES = ( "idea", diff --git a/src/threaddesk/services/commands/authority.py b/src/threaddesk/services/commands/authority.py new file mode 100644 index 0000000..9f9d194 --- /dev/null +++ b/src/threaddesk/services/commands/authority.py @@ -0,0 +1,31 @@ +"""Inert Authority Envelope import, without caller-selected file access.""" +from threaddesk.core.authority_event import EVENT_TYPES, REQUIRED, SOURCE_TOOLS +from threaddesk.graph import AuthorityGraph +from threaddesk.services.commands.model import CommandSpec +from threaddesk.services.commands.schemas import TEXT, array, choice, obj + + +def specifications(): + nullable = {"type": ["string", "null"], "maxLength": 10000} + event = obj({ + "schema_version": {"const": "1"}, + **{key: TEXT for key in ("event_id", "timestamp", "trace_id", "span_id", "action", "resource", "event_hash")}, + **{key: nullable for key in ("parent_span_id", "workflow_id", "project_id", "capability_id", + "reason_code", "budget_ref", "result_ref", "previous_event_hash")}, + "actor": obj({key: TEXT for key in ("agent_id", "workload_id", "role")}), + "source_tool": choice(*sorted(SOURCE_TOOLS)), + "event_type": choice(*sorted(EVENT_TYPES)), + "decision": {"enum": ["ALLOW", "DENY", "FREEZE", None]}, + "data_labels": array(TEXT, 100), + }, REQUIRED) + yield CommandSpec( + "authority.import", "authority", + "Validate supplied Authority Envelope v1 events and return an inert graph snapshot. Verifies event hashes; does not grant authority, persist events, execute, or read/write caller-selected paths.", + obj({"events": array(event, 1000)}, ("events",)), _import, + ) + + +def _import(_service, args): + graph = AuthorityGraph() + added = graph.ingest(args["events"]) + return {**graph.snapshot().to_dict(), "imported": added} diff --git a/src/threaddesk/services/commands/domains.py b/src/threaddesk/services/commands/domains.py index 3284b6d..57cacf8 100644 --- a/src/threaddesk/services/commands/domains.py +++ b/src/threaddesk/services/commands/domains.py @@ -5,6 +5,12 @@ def specifications(): from threaddesk.services.commands.operations import specifications as operations from threaddesk.services.commands.owner_search import specifications as owner_search from threaddesk.services.commands.library_compat import specifications as library_compat + from threaddesk.services.commands.whiteboards import specifications as whiteboards + from threaddesk.services.commands.prompt_history import specifications as prompt_history + from threaddesk.services.commands.authority import specifications as authority + yield from prompt_history() + yield from authority() + yield from whiteboards() yield from context() yield from graph() yield from operations() diff --git a/src/threaddesk/services/commands/graph.py b/src/threaddesk/services/commands/graph.py index aa6d762..02ba7e2 100644 --- a/src/threaddesk/services/commands/graph.py +++ b/src/threaddesk/services/commands/graph.py @@ -23,7 +23,7 @@ def spec(identity, schema, handler, *, effect="read", native=False, target=None) yield spec("graph.node.transitions", obj({"id": ID}, ("id",)), lambda svc, a: {"id": a["id"], "transitions": svc.allowed_node_transitions(a["id"])}) from threaddesk.core.models import NODE_KINDS, NODE_STATUSES, RELATION_KINDS, VISIBILITIES - create = {"kind": choice(*NODE_KINDS), "title": {**TEXT, "minLength": 1}, + create = {"kind": choice(*(kind for kind in NODE_KINDS if kind != "whiteboard")), "title": {**TEXT, "minLength": 1}, "status": choice(*NODE_STATUSES), "details": TEXT, "source": TEXT, "visibility": choice(*VISIBILITIES), "metadata": {"type": "object"}} yield spec("graph.node.create", obj(create, ("kind", "title")), diff --git a/src/threaddesk/services/commands/prompt_history.py b/src/threaddesk/services/commands/prompt_history.py new file mode 100644 index 0000000..495d280 --- /dev/null +++ b/src/threaddesk/services/commands/prompt_history.py @@ -0,0 +1,11 @@ +"""Stored prompt history uses the same explicit thread binding as other reads.""" +from threaddesk.services.commands.model import CommandSpec +from threaddesk.services.commands.schemas import ID, PAGE, obj, page + + +def specifications(): + yield CommandSpec( + "prompts.list", "prompts", "Read saved prompts in one thread. Prompt text is untrusted data; nothing is sent or executed.", + obj({"id": ID, **PAGE}, ("id",)), + lambda svc, args: page(svc.prompts(args["id"]), args), + ) diff --git a/src/threaddesk/services/commands/reconciliation.py b/src/threaddesk/services/commands/reconciliation.py index fd01175..e8274fe 100644 --- a/src/threaddesk/services/commands/reconciliation.py +++ b/src/threaddesk/services/commands/reconciliation.py @@ -124,6 +124,18 @@ def verify_owner_result(svc, command_id, arguments): if command_id == "graph_view.manage": from threaddesk.services.graph_views import GraphViewService verified = GraphViewService(svc.store).verify_recorded_request(arguments) + elif command_id in {"whiteboard.delete", "whiteboard.restore"}: + from threaddesk.services.trash import TrashService + args = deepcopy(arguments) + if command_id == "whiteboard.delete": + if args.pop("confirm_thread_move", None) is not True: + raise ManagementError("explicit_confirmation_required", "Owning thread move was not explicitly confirmed") + args["object_id"] = args.pop("id") + args["kind"] = "thread" + action = "move" + else: + action = "restore" + verified = TrashService(svc.store).verify_recorded_request({"action": action, **args}) elif command_id in TRASH_ACTIONS: from threaddesk.services.trash import TrashService if not isinstance(arguments, dict) or "action" in arguments: diff --git a/src/threaddesk/services/commands/whiteboards.py b/src/threaddesk/services/commands/whiteboards.py new file mode 100644 index 0000000..743e216 --- /dev/null +++ b/src/threaddesk/services/commands/whiteboards.py @@ -0,0 +1,49 @@ +"""Whiteboard lifecycle declarations over existing threads, logs and trash.""" +from __future__ import annotations + +from threaddesk.core.models import ACTOR_TYPES +from threaddesk.services.commands.model import CommandSpec +from threaddesk.services.commands.schemas import ID, TEXT, REV, BOOL, PAGE, obj, choice, page +from threaddesk.services.whiteboard_lifecycle import WhiteboardService + + +def specifications(): + def spec(identity, fields, required, handler, *, effect="read", target=None, native=False, description=None): + return CommandSpec("whiteboard." + identity, "whiteboard", description or "Thread whiteboard " + identity, + obj(fields, required), handler, effect=effect, + scope="workspace.read" if effect == "read" else "workspace.write", revision_target=target, + native_contract=native, affects_index=effect == "data") + yield spec("boards", {"include_archived": BOOL, **PAGE}, (), + lambda svc, a: page(WhiteboardService(svc).list(include_archived=a.get("include_archived", False)), a)) + yield spec("get", {"id": ID}, ("id",), lambda svc, a: WhiteboardService(svc).get(a["id"])) + yield spec("create", {"title": {**TEXT, "minLength": 1}, "description": TEXT}, ("title",), + lambda svc, a: WhiteboardService(svc).create(**a), effect="data", target="create", + description="Create a whiteboard with its own owning ThreadDesk thread; title and lifecycle are shared.") + for action in ("open", "close", "archive", "unarchive"): + yield spec(action, {"id": ID}, ("id",), + lambda svc, a, selected=action: getattr(WhiteboardService(svc), selected)(a["id"]), + effect="data", target="id", description={ + "open": "Persist board state open and select its thread. Read whiteboard.get for the local UI path; no visible window acknowledgement is implied.", + "close": "Persist whiteboard state closed; reject subsequent contributions until reopened. Does not claim to close a native window.", + "archive": "Archive the whiteboard and its owning thread together; notes and log remain stored.", + "unarchive": "Restore the whiteboard's owning thread from archive; its saved open/closed state is preserved."}[action]) + yield spec("rename", {"id": ID, "title": {**TEXT, "minLength": 1}}, ("id", "title"), + lambda svc, a: WhiteboardService(svc).rename(a["id"], a["title"]), effect="data", target="id", + description="Rename this whiteboard and its owning thread together.") + yield spec("edit", {"id": ID, "entry_id": ID, + "content": {"type": "string", "minLength": 1, "maxLength": 8000}, "actor": TEXT, + "actor_type": choice(*ACTOR_TYPES), "actor_id": ID}, ("id", "entry_id", "content"), + lambda svc, a: WhiteboardService(svc).edit(a["id"], **{key: value for key, value in a.items() if key != "id"}), + effect="data", target="id", description="Correct one contribution by appending a linked correction; original content and provenance remain in whiteboard.list.") + yield spec("delete_preview", {"id": ID}, ("id",), + lambda svc, a: WhiteboardService(svc).delete_preview(a["id"]), + description="Preview all affected data before moving the whiteboard WITH its owning thread to existing trash; no permanent deletion.") + yield spec("delete", {"id": ID, "confirm_thread_move": {"const": True, "type": "boolean"}, + "operation_id": ID, "expected_revision": REV}, ("id", "confirm_thread_move", "operation_id", "expected_revision"), + lambda svc, a: WhiteboardService(svc).delete(a["id"], **{key: value for key, value in a.items() if key != "id"}), + effect="domain", native=True, + description="Reversibly move the whiteboard WITH its owning thread/notes to trash. Read delete_preview first and explicitly set confirm_thread_move=true. Permanent deletion remains exclusively trash.purge with its preview-bound approval.") + fields = {"trash_id": TEXT, "generation": {"type": "integer", "minimum": 1}, + "operation_id": ID, "expected_revision": REV} + yield spec("restore", fields, tuple(fields), lambda svc, a: WhiteboardService(svc).restore(**a), + effect="domain", native=True, description="Restore the owning thread and whiteboard from an exact trash generation; original IDs and relationships are preserved. For archive restoration use whiteboard.unarchive.") diff --git a/src/threaddesk/services/mcp.py b/src/threaddesk/services/mcp.py index 2ba8733..20924b8 100644 --- a/src/threaddesk/services/mcp.py +++ b/src/threaddesk/services/mcp.py @@ -304,18 +304,50 @@ def _public(thread: Thread) -> dict[str, Any]: class McpBridge: - def __init__(self, svc: ThreadService) -> None: + def __init__(self, svc: ThreadService, *, allowed_scopes=None, registry=None) -> None: self.svc = svc + # These grants come from the trusted server context, never tool input. + self.allowed_scopes = None if allowed_scopes is None else frozenset(allowed_scopes) + self.registry = registry + self._commands = None + + @property + def commands(self): + if self._commands is None: + from threaddesk.services.commands import CommandApplication + from threaddesk.services.mcp_commands import McpCommands + self._commands = McpCommands(CommandApplication( + self.svc, allowed_scopes=self.allowed_scopes, registry=self.registry, transport="mcp")) + return self._commands def list_tools(self) -> list[dict[str, Any]]: - return TOOLS + if self.svc is None: # Static compatibility inspection, without a workspace. + return TOOLS + legacy = TOOLS + if self.allowed_scopes is not None: + app = self.commands.application + available = {row["id"] for row in app.registry.records(app.scopes, "mcp") + if row["availability"] == "available"} + legacy = [tool for tool in TOOLS if tool["name"] in available + or tool["name"] in {"command_catalog", "command_query", "command_invoke"}] + from copy import deepcopy + return [*deepcopy(legacy), *self.commands.list_tools()] + + def tools_revision(self): + return self.commands.revision() def call(self, name: str, arguments: dict[str, Any] | None = None) -> dict[str, Any]: - args = arguments or {} + args = {} if arguments is None else arguments try: + if self.allowed_scopes is not None and name not in {"command_catalog", "command_query", "command_invoke"} and not name.startswith("td_"): + app = self.commands.application + spec = app.registry.get(name) + available = app.registry.availability(spec, app.scopes, "mcp") + if available != "available": + raise ManagementError(available, "Command is not currently available") result = self._dispatch(name, args) read_names = _READ_ONLY_TOOLS | {"library_query"} - if name not in read_names and name not in {"library_manage", "command_invoke"}: + if not name.startswith("td_") and name not in read_names and name not in {"library_manage", "command_invoke"}: from threaddesk.services.library_refresh import refresh_existing refresh_existing(self.svc.store) except ManagementError as exc: @@ -327,9 +359,10 @@ def call(self, name: str, arguments: dict[str, Any] | None = None) -> dict[str, return {"ok": True, "isError": False, "result": result} def _dispatch(self, name: str, args: dict[str, Any]) -> Any: + if name.startswith("td_"): + return self.commands.call(name, args) if name in {"command_catalog", "command_query", "command_invoke"}: - from threaddesk.services.commands import CommandApplication - application = CommandApplication(self.svc, transport="mcp") + application = self.commands.application if name == "command_catalog": from threaddesk.services.command_tools import TOOLS as command_tools from threaddesk.services.commands.validation import validate diff --git a/src/threaddesk/services/mcp_commands.py b/src/threaddesk/services/mcp_commands.py new file mode 100644 index 0000000..5a874c6 --- /dev/null +++ b/src/threaddesk/services/mcp_commands.py @@ -0,0 +1,113 @@ +"""Live MCP views of the source-owned command registry, without a second dispatcher.""" +from __future__ import annotations + +from copy import deepcopy +import hashlib +import re + +from threaddesk.core.errors import ManagementError +from threaddesk.services.commands.schemas import ID, REV, TEXT, obj +from threaddesk.services.commands.validation import bounded, encode, validate, MAX_REQUEST, invalid + +PREFIX = "td_" + + +def tool_name(command_id): + """Keep valid names readable and stable, including long module identities.""" + # KiraBridge's existing native call contract accepts 64 ASCII characters + # and no dots. Escape underscores as well, so a.b and a_b stay distinct. + escaped = "".join("__" if char == "." else "_u" if char == "_" else char + if re.fullmatch(r"[A-Za-z0-9-]", char) + else "_x" + char.encode().hex() + "_" for char in command_id) + name = PREFIX + escaped + if len(name) <= 64: + return name + return PREFIX + escaped[:20] + "_" + hashlib.sha256(command_id.encode()).hexdigest()[:40] + + +def input_schema(spec): + domain = deepcopy(spec.schema) + # A nested schema without its own resource identity would resolve #/$defs, + # #anchors and recursive # references against the MCP envelope. Preserve its + # original resource root, including any source-owned $id and anchor scope. + if domain.get("$id") in (None, "", "#"): + domain["$id"] = "urn:threaddesk:mcp-command:" + hashlib.sha256(spec.id.encode()).hexdigest() + fields = {"workspace_id": TEXT, "arguments": domain, + "operation_id": ID, "expected_revision": REV, + "catalog_revision": TEXT, "result_offset": REV, + "result_digest": TEXT, "snapshot_at": TEXT} + required = ["workspace_id", "arguments"] + if spec.effect != "read": + required.append("operation_id") + if spec.revision_target: + required.append("expected_revision") + # CommandApplication injects native operation/CAS bindings before domain + # validation. Publish them once in the envelope, retaining optional + # domain copies so the shared dispatcher can reject mismatches. + if spec.native_contract: + for field in ("operation_id", "expected_revision"): + if field not in domain.get("properties", {}): + continue + # Keep references in a lifted field in the domain resource too. + fields[field] = {"$ref": domain["$id"].removesuffix("#") + "#/properties/" + field} + if field in domain.get("required", []): + domain["required"].remove(field) + if field not in required: + required.append(field) + return deepcopy(obj(fields, required)) + + +class McpCommands: + def __init__(self, application): + self.application = application + + def _records(self): + app = self.application + return app.registry.records(app.scopes, "mcp") + + def _names(self, records): + names = {} + for row in records: + name = tool_name(row["id"]) + if name in names: + raise ManagementError("command_conflict", "Commands have conflicting MCP names") + names[name] = row["id"] + return names + + def list_tools(self): + app = self.application + records = self._records() + self._names(records) + result = [] + for row in records: + if row["availability"] != "available": + continue + spec = app.registry.specs[row["id"]] + read = spec.effect == "read" + result.append({ + "name": tool_name(spec.id), "description": spec.description, + "inputSchema": input_schema(spec), + "annotations": {"readOnlyHint": read, "destructiveHint": not read, + "idempotentHint": not read, "openWorldHint": not read}, + "_meta": {"threaddesk": {**deepcopy(row), "workspace_id": app.workspace_id}}, + }) + return result + + def revision(self): + app = self.application + return app.registry.revision(app.scopes, "mcp") + + def call(self, name, arguments): + app = self.application + identity = self._names(self._records()).get(name) + if identity is None: + raise ManagementError("command_not_found", "MCP command is no longer registered") + spec = app.registry.get(identity) + # Bound the wrapper before schema iteration, then leave all authority, + # exact domain validation, CAS, receipts and effects to the application. + bounded(arguments) + if len(encode(arguments).encode()) > MAX_REQUEST: + invalid("Command request exceeds its byte budget") + validate(input_schema(spec), arguments) + request = {"version": 1, "command": identity, **arguments} + return app.call(request, read_only=spec.effect == "read") diff --git a/src/threaddesk/services/whiteboard.py b/src/threaddesk/services/whiteboard.py index f998e67..e9e6a93 100644 --- a/src/threaddesk/services/whiteboard.py +++ b/src/threaddesk/services/whiteboard.py @@ -58,7 +58,13 @@ def list_entries(store: Any, thread_id: str) -> list[WhiteboardEntry]: return store.list_whiteboard(thread_id) -def append( +def append(store, thread_id, **fields): + from threaddesk.services.whiteboard_lifecycle import require_writable + require_writable(store.get_thread(thread_id)) + return _append(store, thread_id, **fields) + + +def _append( store: Any, thread_id: str, *, @@ -100,6 +106,19 @@ def append( return store.append_whiteboard_entry(entry) +def _append_event(store, thread_id, **fields): + # Bound incoming results keep their immutable history on a closed board. + # Publish their new canonical thread revision atomically so live graph + # invalidation observes them even when no public service append runs. + with store.transaction(): + entry, duplicate = _append(store, thread_id, **fields) + if not duplicate: + thread = store.get_thread(thread_id) + thread.updated_at = now_iso() + store.save_thread(thread) + return entry, duplicate + + def stand(store: Any, thread: Thread) -> dict[str, Any]: entries = store.list_whiteboard(thread.id) nodes = store.list_nodes() @@ -257,7 +276,7 @@ def append_from_return(store: Any, payload: Mapping[str, Any]) -> dict[str, Any] "delivery_status": payload.get("delivery_status"), "review_status": payload.get("review_status"), } - entry, duplicate = append( + entry, duplicate = _append_event( store, thread_id, actor=actor, @@ -289,7 +308,7 @@ def append_from_gnom_status( except NotFound: return None details = str(payload.get("details") or "").strip() - entry, duplicate = append( + entry, duplicate = _append_event( store, thread_id, actor="Gnom-Hub", @@ -327,7 +346,7 @@ def append_from_decision(store: Any, item: Mapping[str, Any]) -> dict[str, Any] note = str(decision.get("note") or "").strip() if not choice: return None - entry, duplicate = append( + entry, duplicate = _append_event( store, thread_id, actor="human", diff --git a/src/threaddesk/services/whiteboard_lifecycle.py b/src/threaddesk/services/whiteboard_lifecycle.py new file mode 100644 index 0000000..e2139a7 --- /dev/null +++ b/src/threaddesk/services/whiteboard_lifecycle.py @@ -0,0 +1,199 @@ +"""Whiteboards are views of the existing thread log, never a second store. + +A board has its thread's identity and title. Corrections append an event; saved +entries, return bindings and room contributions remain unchanged. The explicit +open/closed state is persisted in ThreadContext.extra, not a window-visibility +claim. Thread trash owns reversible removal and the existing purge contract. +""" +from __future__ import annotations + +from dataclasses import replace + +from threaddesk.core.errors import ManagementError, NotFound +from threaddesk.core.models import KnowledgeNode, Relation, now_iso + +KEY = "whiteboard" +PREFIX = "wb-" + + +def state(thread): + value = thread.context.extra.get(KEY) + return "closed" if isinstance(value, dict) and value.get("state") == "closed" else "open" + + +def require_writable(thread): + if thread.status == "archived" or state(thread) == "closed": + raise ManagementError("whiteboard_closed", "Whiteboard zuerst wiederherstellen beziehungsweise öffnen.") + + +def effective_entries(entries): + """Project corrections while retaining the immutable originals separately.""" + by_id = {entry.id: entry for entry in entries} + corrected = {} + corrections = set() + for entry in sorted(entries, key=lambda row: (row.ordinal, row.created_at, row.id)): + marker = entry.metadata.get("whiteboard_edit") + if (not isinstance(marker, dict) or not isinstance(marker.get("entry_id"), str) + or not isinstance(marker.get("previous_entry_id"), str)): + continue + original = by_id.get(marker.get("entry_id")) + if original is None or original.id == entry.id: + continue + previous = corrected.get(original.id, original) + if marker.get("previous_entry_id") != previous.id: + continue + corrected[original.id] = entry + corrections.add(entry.id) + result = [] + for entry in entries: + if entry.id in corrections: + continue + change = corrected.get(entry.id) + clean = {key: value for key, value in entry.metadata.items() if key != "whiteboard_edited"} + if change is None: + result.append(replace(entry, metadata=clean) if "whiteboard_edited" in entry.metadata else entry) + else: + result.append(replace(entry, content=change.content, metadata={**clean, + "whiteboard_edited": {"correction_id": change.id, "updated_at": change.created_at, + "actor": change.actor, "original_content": entry.content}})) + return result + + +class WhiteboardService: + def __init__(self, service): + self.service, self.store = service, service.store + + def get(self, identity): + thread = self.store.get_thread(identity) + entries = self.store.list_whiteboard(identity) + return {"schema": "threaddesk.whiteboard.v1", "id": identity, + "thread_id": identity, "graph_node_id": PREFIX + identity, + "title": thread.title, "state": state(thread), + "status": "archived" if thread.status == "archived" else state(thread), + "revision": thread.revision, "updated_at": thread.updated_at, + "entry_count": len(entries), "ui_path": "/?thread=" + identity + "#notes", + "lifecycle_scope": "owning_thread", "visibility_evidence": "saved_state_only"} + + def list(self, *, include_archived=False): + return [self.get(thread.id) for thread in self.store.list_threads(include_archived=include_archived) + if self.exists(thread)] + + def exists(self, thread): + return KEY in thread.context.extra or bool(self.store.list_whiteboard(thread.id)) + + def _save_state(self, thread, opened): + thread.context.extra = {**thread.context.extra, KEY: {"version": 1, "state": "open" if opened else "closed"}} + thread.updated_at = now_iso() + self.store.save_thread(thread) + self.service.bus.emit("whiteboard.state_changed", {"thread_id": thread.id, + "state": state(thread), "revision": thread.revision}) + return {"thread": thread.to_dict()} + + def create(self, title, description=""): + thread = self.service.create(title, description) + return self._save_state(thread, True) + + def open(self, identity): + thread = self.store.get_thread(identity) + if thread.status == "archived": + raise ManagementError("whiteboard_archived", "Whiteboard zuerst aus dem Archiv wiederherstellen.") + self.service.switch(identity) + return self._save_state(thread, True) + + def close(self, identity): + return self._save_state(self.store.get_thread(identity), False) + + def rename(self, identity, title): + return {"thread": self.service.rename(identity, title).to_dict()} + + def archive(self, identity): + return {"thread": self.service.archive(identity).to_dict()} + + def unarchive(self, identity): + return {"thread": self.service.unarchive(identity).to_dict()} + + def edit(self, identity, *, entry_id, content, actor="human", actor_type="human", actor_id=None): + thread = self.store.get_thread(identity) + require_writable(thread) + entries = self.store.list_whiteboard(identity) + original = next((row for row in entries if row.id == entry_id), None) + if original is None: + raise NotFound("Whiteboard-Beitrag wurde nicht gefunden.") + current = next((row for row in effective_entries(entries) if row.id == entry_id), None) + if current is None: + raise ManagementError("validation_error", "Den ursprünglichen Beitrag bearbeiten, nicht dessen Korrekturereignis.") + previous = current.metadata.get("whiteboard_edited", {}).get("correction_id", entry_id) + fields = {"actor": actor, "actor_type": actor_type, "entry_type": "note", "content": content, + "metadata": {"whiteboard_edit": {"entry_id": entry_id, "previous_entry_id": previous}}, + "task_id": original.task_id, "handoff_id": original.handoff_id, "run_id": original.run_id, + "room_id": original.room_id} + if actor_id: + fields["actor_id"] = actor_id + return self.service.append_whiteboard(identity, **fields) + + def delete_preview(self, identity): + from threaddesk.services.trash import TrashService + return {**TrashService(self.store).preview(kind="thread", object_id=identity), + "whiteboard_id": identity, "lifecycle_scope": "owning_thread", + "required_confirmation": "confirm_thread_move=true", + "notice": "Whiteboard und zugehöriger Thread samt Notizen werden gemeinsam verborgen; Originaldateien bleiben erhalten."} + + def delete(self, identity, *, confirm_thread_move, operation_id, expected_revision): + if confirm_thread_move is not True: + raise ManagementError("explicit_confirmation_required", "Verschieben des zugehörigen Threads ausdrücklich bestätigen.") + from threaddesk.services.trash import TrashService + return TrashService(self.store).move(kind="thread", object_id=identity, + operation_id=operation_id, expected_revision=expected_revision) + + def restore(self, *, trash_id, generation, expected_revision, operation_id): + from threaddesk.services.trash import TrashService + trash = TrashService(self.store) + entry = trash.get(trash_id) + if entry["kind"] != "thread": + raise ManagementError("validation_error", "Whiteboard-Wiederherstellung benötigt den Thread-Papierkorbeintrag.") + return trash.restore(trash_id=trash_id, generation=generation, + expected_revision=expected_revision, operation_id=operation_id) + + def graph_nodes(self): + result = [] + for thread in self.store.list_threads(include_archived=True): + if not self.exists(thread): + continue + identity = PREFIX + thread.id + try: + self.store.get_node(identity, include_trashed=True) + except NotFound: + pass + else: + raise ManagementError("graph_projection_conflict", "Whiteboardprojektion kollidiert mit gespeichertem Knoten.", object_id=identity, thread_id=thread.id) + board = self.get(thread.id) + result.append(KnowledgeNode(id=PREFIX + thread.id, kind="whiteboard", title=thread.title, + status="archived" if thread.status == "archived" else "paused" if board["state"] == "closed" else "active", + details=thread.description, created_at=thread.created_at, updated_at=thread.updated_at, + revision=thread.revision, source="threaddesk.whiteboard", visibility="private", + metadata={"thread_id": thread.id, "whiteboard_id": thread.id, + "whiteboard_state": board["state"], "whiteboard_status": board["status"], + "entry_count": board["entry_count"], "projection": "thread_whiteboard", + "lifecycle_scope": "owning_thread"})) + return result + + def graph_node(self, identity): + for node in self.graph_nodes(): + if node.id == identity: + return node + raise NotFound("Whiteboard-Knoten wurde nicht gefunden.") + + def graph_relations(self, nodes): + result = [] + boards = {node.metadata["thread_id"]: node for node in nodes + if node.kind == "whiteboard" and node.source == "threaddesk.whiteboard" + and isinstance(node.metadata.get("thread_id"), str) + and node.id == PREFIX + node.metadata["thread_id"]} + for node in nodes: + owner = node.metadata.get("thread_id") + board = boards.get(owner) if isinstance(owner, str) else None + if board and node.id != board.id: + result.append(Relation(id="wb-rel-" + board.metadata["thread_id"] + "-" + node.id, + source_id=board.id, target_id=node.id, kind="references", created_at=board.created_at, + revision=board.revision, source="threaddesk.whiteboard", metadata={"projection": "thread_whiteboard"})) + return result diff --git a/src/threaddesk/storage/lifecycle.py b/src/threaddesk/storage/lifecycle.py index 6555318..71ebfc7 100644 --- a/src/threaddesk/storage/lifecycle.py +++ b/src/threaddesk/storage/lifecycle.py @@ -41,7 +41,8 @@ def hidden_node_ids(self, nodes) -> set[str]: newest[key] = item hidden = {key for key, item in newest.items() if item["state"] in {"trashed", "purged"}} return {node.id for node in nodes if ("node", node.id) in hidden - or ("thread", node.metadata.get("thread_id")) in hidden} + or (isinstance(node.metadata.get("thread_id"), str) + and ("thread", node.metadata["thread_id"]) in hidden)} def require_live_node_reference(self, node_id: str) -> None: self.require_live_object("node", node_id) diff --git a/src/threaddesk/ui/mcp_stdio.py b/src/threaddesk/ui/mcp_stdio.py index 539b99d..c8a32f9 100644 --- a/src/threaddesk/ui/mcp_stdio.py +++ b/src/threaddesk/ui/mcp_stdio.py @@ -4,6 +4,8 @@ import json import sys +from queue import Empty, Queue +from threading import Thread from typing import Any from threaddesk.api.service import ThreadService @@ -24,15 +26,19 @@ def _err(id_: Any, code: int, message: str) -> dict[str, Any]: def handle(bridge: McpBridge, req: dict[str, Any]) -> dict[str, Any] | None: + if not isinstance(req, dict): + return _err(None, -32600, "request must be an object") method = req.get("method") rid = req.get("id") - params = req.get("params") or {} + params = req.get("params", {}) + if not isinstance(params, dict): + return _err(rid, -32602, "params must be an object") if method == "initialize": return _msg( rid, { "protocolVersion": "2024-11-05", - "capabilities": {"tools": {}}, + "capabilities": {"tools": {"listChanged": True}}, "serverInfo": {"name": "threaddesk", "version": _package_version()}, }, ) @@ -42,10 +48,14 @@ def handle(bridge: McpBridge, req: dict[str, Any]) -> dict[str, Any] | None: return _msg(rid, {"tools": bridge.list_tools()}) if method == "tools/call": name = str(params.get("name") or "") - raw = bridge.call(name, params.get("arguments") or {}) + raw = bridge.call(name, params.get("arguments", {})) if raw.get("ok"): - text = json.dumps(raw.get("result"), ensure_ascii=False, indent=2) - return _msg(rid, {"content": [{"type": "text", "text": text}], "isError": False}) + result = raw.get("result") + text = json.dumps(result, ensure_ascii=False, indent=2) + response = {"content": [{"type": "text", "text": text}], "isError": False} + if isinstance(result, dict): + response["structuredContent"] = result + return _msg(rid, response) return _msg( rid, { @@ -63,21 +73,68 @@ def serve(svc: ThreadService | None = None) -> None: from threaddesk.services.library_runtime import LibraryRuntime runtime = LibraryRuntime(lambda: type(bridge.svc.store)(bridge.svc.store.workspace_path)) runtime.start() + # Only the main thread touches the bridge/store (SQLite connections are + # thread-bound). A bounded reader queue allows live catalogue checks while + # stdin is idle, including module changes made by the desktop application. + incoming = Queue(maxsize=16) + + def read_lines(): + try: + for line in sys.stdin: + incoming.put(line) + finally: + incoming.put(None) + + reader = Thread(target=read_lines, name="threaddesk-mcp-input", daemon=True) + reader.start() + + def write(message): + sys.stdout.write(json.dumps(message, ensure_ascii=False) + "\n") + sys.stdout.flush() + + try: + revision = bridge.tools_revision() + except Exception: + revision = None + ready = False try: - for line in sys.stdin: + while True: + try: + line = incoming.get(timeout=1.0) + except Empty: + line = "" + if line is None: + break line = line.strip() - if not line: + if line: + try: + req = json.loads(line) + except json.JSONDecodeError: + write(_err(None, -32700, "parse error")) + continue + try: + out = handle(bridge, req) + except Exception: + # Domain errors are already tool results. Discovery errors + # remain protocol errors, without leaking local paths/data. + out = _err(req.get("id") if isinstance(req, dict) else None, + -32603, "MCP request could not be processed") + if out is not None: + write(out) + if isinstance(req, dict) and req.get("method") == "notifications/initialized": + ready = True + if not ready: continue try: - req = json.loads(line) - except json.JSONDecodeError: - sys.stdout.write(json.dumps(_err(None, -32700, "parse error")) + "\n") - sys.stdout.flush() + current = bridge.tools_revision() + except Exception: + # A failed optional observation neither fabricates a revision + # nor stops ordinary request processing. tools/list will expose + # its error explicitly if the current catalogue cannot be read. continue - out = handle(bridge, req) - if out is not None: - sys.stdout.write(json.dumps(out, ensure_ascii=False) + "\n") - sys.stdout.flush() + if current != revision: + revision = current + write({"jsonrpc": "2.0", "method": "notifications/tools/list_changed"}) finally: runtime.stop() diff --git a/src/threaddesk/ui/packaged_commands.py b/src/threaddesk/ui/packaged_commands.py index 38d88ba..3cffc40 100644 --- a/src/threaddesk/ui/packaged_commands.py +++ b/src/threaddesk/ui/packaged_commands.py @@ -6,6 +6,10 @@ def dispatch(arguments): """Return a CLI exit code, or None when normal desktop startup is requested.""" if not arguments: return None + if arguments == ["--mcp"]: + from threaddesk.ui.mcp_stdio import serve + serve() + return 0 flags = {"--command-file": "--request-file", "--command": "--request"} if len(arguments) == 2 and arguments[0] in flags: from threaddesk.ui.cli import main diff --git a/src/threaddesk/ui/server.py b/src/threaddesk/ui/server.py index 909a700..38e1c36 100644 --- a/src/threaddesk/ui/server.py +++ b/src/threaddesk/ui/server.py @@ -230,6 +230,9 @@ def _ctx(request: Request, extra: dict | None = None) -> dict: pass snapshots = svc.snapshots(current.id) if current else [] entries = svc.whiteboard(current.id) if current else [] + from threaddesk.services.whiteboard_lifecycle import WhiteboardService, effective_entries + board = WhiteboardService(svc).get(current.id) if current else None + displayed_entries = effective_entries(entries) extra = extra or {} lang = _language(request) data = { @@ -247,7 +250,8 @@ def _ctx(request: Request, extra: dict | None = None) -> dict: "prompt_preview": None, "error": None, "notice": None, - "whiteboard": entries, + "whiteboard": displayed_entries, + "whiteboard_state": board, "return_review_states": _return_review_states(svc, current, entries), "stand": svc.working_stand(current.id) if current else None, "whiteboard_types": ENTRY_TYPES, @@ -370,6 +374,11 @@ def sync_page(request: Request, extra: dict) -> HTMLResponse: install_sync_routes(app, lambda: _svc(), sync_page) install_thread_mutations(app, lambda: _svc(), workspace, _language) + from threaddesk.ui.whiteboard_router import install as install_whiteboards + from threaddesk.services.whiteboard_lifecycle import WhiteboardService + install_whiteboards(app, _svc, workspace, _language, + lambda request: templates.TemplateResponse(request, "whiteboards.html", + _ctx(request, {"boards": WhiteboardService(_svc()).list(include_archived=True)}))) @app.exception_handler(ThreadDeskError) async def _on_error(request: Request, exc: ThreadDeskError) -> Response: @@ -778,7 +787,7 @@ def knowledge( "transitions_by_node": { node.id: svc.allowed_node_transitions(node.id) for node in nodes }, - "node_kinds": NODE_KINDS, + "node_kinds": tuple(kind for kind in NODE_KINDS if kind != "whiteboard"), "node_statuses": NODE_STATUSES, "relation_kinds": RELATION_KINDS, "active_kind": graph_data["filters"]["kind"], diff --git a/src/threaddesk/ui/static/graph-inspector.js b/src/threaddesk/ui/static/graph-inspector.js index 1926e43..cdf86fa 100644 --- a/src/threaddesk/ui/static/graph-inspector.js +++ b/src/threaddesk/ui/static/graph-inspector.js @@ -8,9 +8,11 @@ window.ThreadDeskGraphInspector = (root, t, activity, isFixed) => { detailTitle.textContent = node.title; const trash = root.querySelector('[data-map-trash]'); trash.hidden = false; - trash.dataset.trashObject = node.metadata?.library_record_id || node.id; - trash.dataset.trashKind = node.metadata?.library_record_id ? 'file' : 'node'; - trash.dataset.trashConfirm = t(node.metadata?.library_record_id ? 'map.trash_file_confirm' : 'map.trash_confirm'); + const board = node.kind === "whiteboard" && node.source === "threaddesk.whiteboard" && + typeof node.metadata?.thread_id === "string" && node.id === "wb-" + node.metadata.thread_id; + trash.dataset.trashObject = board ? node.metadata.thread_id : node.metadata?.library_record_id || node.id; + trash.dataset.trashKind = board ? 'thread' : node.metadata?.library_record_id ? 'file' : 'node'; + trash.dataset.trashConfirm = t(board ? 'map.trash_whiteboard_confirm' : node.metadata?.library_record_id ? 'map.trash_file_confirm' : 'map.trash_confirm'); trash.dataset.trashRevision = String(node.revision); trash.disabled = Boolean(node.metadata?.library_record_id); if (node.metadata?.library_record_id) { @@ -45,6 +47,12 @@ window.ThreadDeskGraphInspector = (root, t, activity, isFixed) => { item.append(dt, dd); detailMeta.appendChild(item); }); + if (board) { + const state = document.createElement("p"); + state.dataset.mapWhiteboardState = node.metadata.whiteboard_status; + state.textContent = t("map.whiteboard_state") + ": " + node.metadata.whiteboard_status; + detailMeta.appendChild(state); + } const threadId = node.metadata && node.metadata.thread_id; if (typeof threadId === "string" && threadId && !/[/?#]/.test(threadId)) { const item = document.createElement("div"); diff --git a/src/threaddesk/ui/templates/partials/thread_list.html b/src/threaddesk/ui/templates/partials/thread_list.html index 7cfad14..66bebcc 100644 --- a/src/threaddesk/ui/templates/partials/thread_list.html +++ b/src/threaddesk/ui/templates/partials/thread_list.html @@ -29,6 +29,7 @@

{{ t("list.title") }}

+ {{ t("board.boards") }} {% if threads %}
    {% for t in threads %} diff --git a/src/threaddesk/ui/templates/partials/whiteboard.html b/src/threaddesk/ui/templates/partials/whiteboard.html index 83fa3ba..aa2e611 100644 --- a/src/threaddesk/ui/templates/partials/whiteboard.html +++ b/src/threaddesk/ui/templates/partials/whiteboard.html @@ -1,6 +1,19 @@

    {{ t("ui.capture") }}

    {{ t("ui.capture_hint") }}

    +
    + {{ t('board.state.' ~ whiteboard_state.status) }} + {{ t('board.boards') }} + {% if whiteboard_state.status != 'archived' %} +
    + + + + +
    + {% endif %} +
    + {% if whiteboard_state.status == 'open' %}
    {{ t("ui.capture") }}
    + {% endif %}
    {{ t("ui.activity") }}
    {% if whiteboard %}
      @@ -82,6 +96,21 @@

      {{ t("ui.capture") }}

      {{ t('return.outcome_unknown') }}

      {% endif %}

      {{ entry.content }}

      + {% if entry.metadata.get('whiteboard_edited') %} +
      {{ t('board.corrected') }}

      {{ entry.metadata.whiteboard_edited.original_content }}

      + {% endif %} + {% if whiteboard_state.status == 'open' %} +
      {{ t('board.edit') }} +
      + + + + + + +
      +
      + {% endif %} {% endfor %}
    diff --git a/src/threaddesk/ui/templates/whiteboards.html b/src/threaddesk/ui/templates/whiteboards.html new file mode 100644 index 0000000..829911a --- /dev/null +++ b/src/threaddesk/ui/templates/whiteboards.html @@ -0,0 +1,28 @@ +{% extends "base.html" %} +{% block body %} +
    +

    {{ t('board.boards') }}

    {{ t('board.shared_thread') }}

    +
    + + + + + +
    +
      + {% for board in boards %} +
    • + {{ board.title }} + {{ t('board.state.' ~ board.status) }} +
      + + + + +
      +
    • + {% else %}
    • {{ t('whiteboard.empty') }}
    • {% endfor %} +
    + {{ t('trash.title') }} +
    +{% endblock %} diff --git a/src/threaddesk/ui/whiteboard_router.py b/src/threaddesk/ui/whiteboard_router.py new file mode 100644 index 0000000..6cc4b61 --- /dev/null +++ b/src/threaddesk/ui/whiteboard_router.py @@ -0,0 +1,52 @@ +"""Local UI forms delegate to the same whiteboard command registry as MCP.""" +from __future__ import annotations + +from fastapi import Request +from fastapi.responses import RedirectResponse + +from threaddesk.core.errors import ManagementError +from threaddesk.services.commands import CommandApplication + + +def install(app, service_factory, workspace, language, page): + @app.get("/whiteboards") + def boards(request: Request): + return page(request) + + def endpoint(action): + async def submit(request: Request): + form = await request.form() + names = {"create": {"title", "description"}, "rename": {"title"}, + "edit": {"entry_id", "content", "actor"}}.get(action, set()) + if set(form) - names - {"csrf_token", "operation_id", "expected_revision"}: + raise ManagementError("validation_error", "Unbekannte Whiteboardfelder.") + try: + revision = int(form["expected_revision"]) + operation = str(form["operation_id"]) + except (KeyError, TypeError, ValueError): + raise ManagementError("precondition_required", "Whiteboard vor dem Ändern neu laden.") from None + args = {key: str(form[key]) for key in names if key in form} + if action != "create": + args["id"] = request.path_params["thread_id"] + # A creation result contains at most 20k Unicode input characters. + # Keep that proved ID-bearing receipt intact for the redirect; MCP + # and CLI retain their separate 24k transport result budget. + application = CommandApplication(service_factory(), transport="http", result_budget=256 * 1024) + result = application.call({"version": 1, "workspace_id": application.workspace_id, + "command": "whiteboard." + action, "arguments": args, + "expected_revision": revision, "operation_id": operation}) + thread = result.get("result", {}).get("thread") + identity = thread["id"] if thread else args["id"] + if request.headers.get("hx-request") == "true": + response = workspace(request) + response.headers["X-ThreadDesk-Operation"] = operation + response.headers["X-ThreadDesk-Duplicate"] = str(result.get("duplicate", False)).lower() + return response + destination = "/whiteboards" if action in {"archive", "unarchive"} else "/?thread=" + identity + "#notes" + return RedirectResponse(destination, status_code=303, + headers={"X-ThreadDesk-Operation": operation, "X-ThreadDesk-Duplicate": str(result.get("duplicate", False)).lower()}) + return submit + + app.add_api_route("/whiteboards", endpoint("create"), methods=["POST"]) + for action in ("open", "close", "rename", "archive", "unarchive", "edit"): + app.add_api_route("/threads/{thread_id}/whiteboard/" + action, endpoint(action), methods=["POST"]) diff --git a/tests/test_characterization.py b/tests/test_characterization.py index a6a5886..17af120 100644 --- a/tests/test_characterization.py +++ b/tests/test_characterization.py @@ -8,9 +8,14 @@ from pathlib import Path import pytest +from jsonschema import Draft202012Validator +from referencing import Registry from threaddesk.api.service import ThreadService +from threaddesk.services.commands.model import workspace_identity +from threaddesk.services.commands.registry import CommandRegistry from threaddesk.services.mcp import McpBridge +from threaddesk.services.mcp_commands import tool_name from threaddesk.storage.json_store import JsonStore from threaddesk.ui import cli @@ -102,7 +107,7 @@ def test_cli_and_mcp_publish_the_current_command_and_tool_contracts(tmp_path: Pa ) bridge = McpBridge(ThreadService(JsonStore(tmp_path))) - assert [tool["name"] for tool in bridge.list_tools()] == [ + legacy_names = [ "library_receive_status", "library_search", "library_file_info", "library_receive", "knowledge_manage", "get_reentry", "record_handoff_question", "answer_handoff_question", @@ -114,7 +119,58 @@ def test_cli_and_mcp_publish_the_current_command_and_tool_contracts(tmp_path: Pa "list_gnom_jobs", "check_gate", "dashboard", "library_query", "library_manage", "command_catalog", "command_query", "command_invoke", ] - assert all("delete" not in tool["name"] for tool in bridge.list_tools()) + tools = bridge.list_tools() + legacy, dynamic = tools[:len(legacy_names)], tools[len(legacy_names):] + assert [tool["name"] for tool in legacy] == legacy_names + assert all("delete" not in tool["name"] for tool in legacy) + assert all(tool["name"].startswith("td_") for tool in dynamic) + assert len({tool["name"] for tool in tools}) == len(tools) + + records = {row["id"]: row for row in CommandRegistry(bridge.svc.store).records(transport="mcp") + if row["availability"] == "available"} + by_id = {tool["_meta"]["threaddesk"]["id"]: tool for tool in dynamic} + assert len(by_id) == len(dynamic) and set(by_id) == set(records) + workspace_id = workspace_identity(bridge.svc.store) + for identity, record in records.items(): + tool = by_id[identity] + assert tool["name"] == tool_name(identity) + assert tool["_meta"]["threaddesk"] == {**record, "workspace_id": workspace_id} + assert tool["annotations"]["readOnlyHint"] == (record["effect"] == "read") + published = tool["inputSchema"] + Draft202012Validator.check_schema(published) + domain = published["properties"]["arguments"] + # Concrete source constraints survive the envelope; native bindings may + # move their requirement to the envelope, preserving the domain schema. + assert all(domain[key] == value for key, value in record["input_schema"].items() + if key not in {"$id", "required"}) + assert set(record["input_schema"].get("required", [])) <= ( + set(domain.get("required", [])) | set(published["required"])) + + # Explicit lifecycle tools replace the former blanket ban on "delete". + # Discovery must retain the concrete consent and preview bindings. + for identity, arguments in ( + ("whiteboard.delete", {"id": "fixture-thread", "confirm_thread_move": True}), + ("trash.purge", {"trash_id": "fixture-trash", "generation": 1, + "reference_digest": "0" * 64, + "confirmation": "DELETE fixture-trash " + "0" * 64}), + ): + tool = by_id[identity] + schema = tool["inputSchema"] + assert tool["_meta"]["threaddesk"]["scope"] == "workspace.write" + assert tool["annotations"]["readOnlyHint"] is False + assert {"operation_id", "expected_revision"} <= set(schema["required"]) + assert set(arguments) <= set(schema["properties"]["arguments"]["required"]) + payload = {"workspace_id": workspace_id, "operation_id": "contract-consent", + "expected_revision": 0, "arguments": arguments} + validator = Draft202012Validator(schema, registry=Registry()) + validator.validate(payload) + for field in ("operation_id", "expected_revision"): + assert not validator.is_valid({key: value for key, value in payload.items() if key != field}) + consent = "confirm_thread_move" if identity == "whiteboard.delete" else "confirmation" + assert not validator.is_valid({**payload, "arguments": { + key: value for key, value in arguments.items() if key != consent}}) + if identity == "whiteboard.delete": + assert not validator.is_valid({**payload, "arguments": {**arguments, consent: False}}) def test_ui_route_contract_is_stable() -> None: diff --git a/tests/test_command_history_authority.py b/tests/test_command_history_authority.py new file mode 100644 index 0000000..fcb1ff3 --- /dev/null +++ b/tests/test_command_history_authority.py @@ -0,0 +1,62 @@ +"""Previously CLI-only/internally readable data uses the authorized command boundary.""" +import json +from pathlib import Path + +import pytest + +from threaddesk.api.service import ThreadService +from threaddesk.core.errors import ManagementError +from threaddesk.services.commands import CommandApplication +from threaddesk.storage.json_store import JsonStore +from threaddesk.storage.sqlite_store import SQLiteStore + + +@pytest.fixture(params=[JsonStore, SQLiteStore]) +def application(tmp_path, request): + store = request.param(tmp_path / "workspace") + app = CommandApplication(ThreadService(store), transport="mcp") + yield app + if hasattr(store, "connection"): + store.connection.close() + + +def call(app, command, arguments): + return app.call({"version": 1, "workspace_id": app.workspace_id, + "command": command, "arguments": arguments}, read_only=True) + + +def test_prompt_history_is_thread_bound_paginated_and_inert(application): + svc = application.service + first, other = svc.create("Prompt history"), svc.create("Other") + svc.prompt("generic", "detailed", first.id, save=True) + svc.prompt("generic", "short", first.id, save=True) + before = svc.get(first.id).to_dict() + one = call(application, "prompts.list", {"id": first.id, "limit": 1}) + two = call(application, "prompts.list", {"id": first.id, "offset": one["result"]["next_offset"]}) + assert one["result"]["total"] == 2 + assert one["result"]["items"][0]["id"] != two["result"]["items"][0]["id"] + assert call(application, "prompts.list", {"id": other.id})["result"]["items"] == [] + assert svc.get(first.id).to_dict() == before + restricted = CommandApplication(svc, transport="mcp", allowed_scopes=[]) + with pytest.raises(ManagementError) as denied: + call(restricted, "prompts.list", {"id": first.id}) + assert denied.value.code == "permission_required" + + +def test_authority_import_reuses_validation_without_persisting_or_executing(application): + fixture = Path(__file__).resolve().parents[1] / "docs/authority/fixtures/example-trace.json" + events = json.loads(fixture.read_text())["events"] + before = application.service.graph() + first = call(application, "authority.import", {"events": events}) + second = call(application, "authority.import", {"events": events}) + assert first["result"] == second["result"] + assert first["result_digest"] == second["result_digest"] + assert first["result"]["imported"] == 5 + assert application.service.graph() == before + assert not application.store.list_management_operation_records() + with pytest.raises(ManagementError) as bad_hash: + call(application, "authority.import", {"events": [{**events[0], "event_hash": "invalid"}]}) + assert bad_hash.value.code == "validation_error" + with pytest.raises(ManagementError) as path: + call(application, "authority.import", {"events": events, "output": "/tmp/unapproved.json"}) + assert path.value.code == "validation_error" diff --git a/tests/test_i18n.py b/tests/test_i18n.py index e6d379b..af4c35b 100644 --- a/tests/test_i18n.py +++ b/tests/test_i18n.py @@ -21,7 +21,7 @@ SAME_IN_BOTH = { "threaddesk", "thread", "desk", "netzwerkpunkt", "gnom", "gnom-hub-v1", "grok", "codex", "notion", "github", "mcp", "json", "csv", "svg", "pdf", - "snapshot", "snapshots", "handoff", "id", "td", "esc", "enter", "shift", + "snapshot", "snapshots", "handoff", "whiteboard", "id", "td", "esc", "enter", "shift", "tab", "ok", "api", "ui", "cli", "url", "http", "https", "gate", "frozen", "cooldown", "prompt", "label", "optional", "status", "generic", "detailed", "short", "steps", "agent", "paket", "packet", @@ -127,7 +127,7 @@ def test_no_hardcoded_text_in_templates(template: Path) -> None: # uebersetzt: t("kind." ~ kind). Statisch ist davon nur das Praefix sichtbar. # test_every_dropdown_value_has_a_label prueft dafuer jeden moeglichen Wert. DYNAMIC_PREFIXES = {"map.age.", "map.mode.", "kind.", "status.", "relation.", "prompt.target.", - "prompt.variant.", "register.", "whiteboard.entry.", + "prompt.variant.", "register.", "whiteboard.entry.", "board.state.", "hausmeister.phase.", "room.state.", "room.role.", "return.review.", "sync.state."} @@ -158,6 +158,7 @@ def test_every_dropdown_value_has_a_label() -> None: "prompt.target": ("gnom", "grok", "generic"), "prompt.variant": ("detailed", "short", "steps", "agent"), "whiteboard.entry": models.ENTRY_TYPES, + "board.state": ("open", "closed", "archived"), "sync.state": ('unconfigured','pending','syncing','metadata_only','blocked','ready','conflict','disconnected'), } missing = [f"{prefix}.{value}" diff --git a/tests/test_mcp_dynamic_commands.py b/tests/test_mcp_dynamic_commands.py new file mode 100644 index 0000000..15e45e3 --- /dev/null +++ b/tests/test_mcp_dynamic_commands.py @@ -0,0 +1,333 @@ +"""Registry-owned MCP schemas, live grants and real stdio discovery/calls.""" +from copy import deepcopy +from dataclasses import replace +import json +import os +from pathlib import Path +from queue import Empty, Queue +import re +import subprocess +import sys +from threading import Thread + +from jsonschema import Draft202012Validator +from referencing import Registry +import pytest + +from threaddesk.api.service import ThreadService +from threaddesk.services.commands import CommandApplication +from threaddesk.services.commands.model import CommandSpec +from threaddesk.services.commands.registry import CommandRegistry +from threaddesk.services.commands.schemas import obj +from threaddesk.services.mcp import McpBridge, TOOLS +from threaddesk.services.mcp_commands import tool_name +from threaddesk.services.modules import ModuleManifest, ModuleRegistry +from threaddesk.storage.json_store import JsonStore +from threaddesk.storage.sqlite_store import SQLiteStore +from threaddesk.ui.mcp_stdio import handle + + +@pytest.fixture(params=[JsonStore, SQLiteStore], ids=["json", "sqlite"]) +def service(tmp_path, request): + store = request.param(tmp_path / "workspace") + yield ThreadService(store) + if hasattr(store, "connection"): + store.connection.close() + + +def invoke(bridge, command, arguments=None, **fields): + return bridge.call(tool_name(command), {"workspace_id": bridge.commands.application.workspace_id, + "arguments": arguments or {}, **fields}) + + +def test_every_authorized_registry_command_has_its_own_concrete_tool(service): + bridge = McpBridge(service) + listing = bridge.list_tools() + assert {tool["name"] for tool in TOOLS} <= {tool["name"] for tool in listing} + dynamic = {tool["_meta"]["threaddesk"]["id"]: tool for tool in listing if tool["name"].startswith("td_")} + app = bridge.commands.application + permitted = {row["id"]: row for row in app.registry.records(transport="mcp") if row["availability"] == "available"} + assert set(dynamic) == set(permitted) + assert len(listing) == len({tool["name"] for tool in listing}) + for identity, tool in dynamic.items(): + Draft202012Validator.check_schema(tool["inputSchema"]) + assert re.fullmatch(r"[A-Za-z][A-Za-z0-9_-]{0,63}", tool["name"]) + metadata = tool["_meta"]["threaddesk"] + assert metadata["input_schema"] == permitted[identity]["input_schema"] + assert metadata["scope"] == permitted[identity]["scope"] + assert metadata["workspace_id"] == app.workspace_id + assert tool["annotations"]["readOnlyHint"] == (metadata["effect"] == "read") + assert "arguments" in tool["inputSchema"]["properties"] + assert "search.status" not in dynamic + assert "server_sync.owner_reference" not in dynamic + assert ("migration.stage" in dynamic) == isinstance(service.store, SQLiteStore) + + +def test_names_preserve_distinct_ids_and_fit_the_existing_bridge_contract(): + ids = ["a.b", "a_b", "a__b", "a-b", "module." + "long-" * 30 + ".read", "future/ü-command"] + names = [tool_name(identity) for identity in ids] + assert len(set(names)) == len(ids) + for identity, name in zip(ids, names): + assert re.fullmatch(r"[A-Za-z][A-Za-z0-9_-]{0,63}", name) + assert tool_name(identity) == name + assert tool_name("whiteboard.create") == "td_whiteboard__create" + + +def test_future_registration_appears_in_same_bridge_and_reuses_the_dispatcher(service): + registry = CommandRegistry(service.store) + bridge = McpBridge(service, registry=registry) + before = bridge.tools_revision() + spec = CommandSpec("future.feature", "future", "Source-owned future read", obj({"token": {"const": "known"}}, ("token",)), + lambda svc, args: {"received": args["token"]}) + registry.specs[spec.id] = spec + assert bridge.tools_revision() != before + assert tool_name(spec.id) in {tool["name"] for tool in bridge.list_tools()} + result = invoke(bridge, spec.id, {"token": "known"}) + assert result["result"]["result"] == {"received": "known"} + assert result["result"]["result_digest"] + assert invoke(bridge, spec.id, {"token": "other"})["code"] == "validation_error" + + +_REF_SCHEMA = {"type": "object", "properties": {"value": {"$ref": "#/$defs/text"}}, + "required": ["value"], "additionalProperties": False, + "$defs": {"text": {"type": "string", "const": "ok"}}} + + +@pytest.mark.parametrize("identifier", [None, "", "#", "own.json", "https://schema.example.test/own.json", "https://schema.example.test/own.json#"]) +def test_domain_fragment_refs_keep_their_resource_root_and_source_identity(service, identifier): + schema = deepcopy(_REF_SCHEMA) + if identifier is not None: + schema["$id"] = identifier + _assert_resource_schema(service, schema, {"value": "ok"}, {"value": "wrong"}) + + +@pytest.mark.parametrize("reference", ["root", "anchor", "dynamic_anchor", "nested_resource"]) +def test_recursive_anchor_and_nested_resource_refs_keep_domain_scope(service, reference): + if reference == "root": + schema = obj({"value": {"const": "ok"}, "next": {"$ref": "#"}}, ("value",)) + valid, invalid = {"value": "ok", "next": {"value": "ok"}}, {"value": "ok", "next": {"value": "wrong"}} + elif reference == "anchor": + schema = deepcopy(_REF_SCHEMA) + schema["$defs"]["text"]["$anchor"] = "text" + schema["properties"]["value"] = {"$ref": "#text"} + valid, invalid = {"value": "ok"}, {"value": "wrong"} + elif reference == "dynamic_anchor": + schema = {"$dynamicAnchor": "node", **obj({"value": {"const": "ok"}, "next": {"$dynamicRef": "#node"}}, ("value",))} + valid, invalid = {"value": "ok", "next": {"value": "ok"}}, {"value": "ok", "next": {"value": "wrong"}} + else: + schema = {"$id": "https://schema.example.test/outer.json", **obj({"item": {"$ref": "inner.json"}}, ("item",)), + "$defs": {"inner": {"$id": "inner.json", **deepcopy(_REF_SCHEMA)}}} + valid, invalid = {"item": {"value": "ok"}}, {"item": {"value": "wrong"}} + _assert_resource_schema(service, schema, valid, invalid) + + +def _assert_resource_schema(service, schema, valid, invalid): + source = deepcopy(schema) + calls = [] + spec = CommandSpec("future.ref", "future", "Resource references", schema, + lambda svc, args: calls.append(args) or args) + registry = CommandRegistry(service.store, [spec]) + direct = CommandApplication(service, registry=registry, transport="mcp") + assert direct.call({"version": 1, "workspace_id": direct.workspace_id, "command": spec.id, "arguments": valid})["result"] == valid + calls.clear() + bridge = McpBridge(service, registry=registry) + tool = next(tool for tool in bridge.list_tools() if tool["name"] == tool_name(spec.id)) + public = tool["inputSchema"] + Draft202012Validator.check_schema(public) + validator = Draft202012Validator(public, registry=Registry()) + validator.validate({"workspace_id": direct.workspace_id, "arguments": valid}) + assert list(validator.iter_errors({"workspace_id": direct.workspace_id, "arguments": invalid})) + assert invoke(bridge, spec.id, valid)["result"]["result"] == valid + assert invoke(bridge, spec.id, invalid)["code"] == "validation_error" + assert calls == [valid] + assert spec.schema == source and tool["_meta"]["threaddesk"]["input_schema"] == source + assert bridge.list_tools()[-1]["inputSchema"] == public + if source.get("$id") not in (None, "", "#"): + assert public["properties"]["arguments"]["$id"] == source["$id"] + + +@pytest.mark.parametrize("identifier", [None, "https://schema.example.test/native.json"]) +def test_lifted_native_bindings_resolve_refs_in_the_same_domain_resource(service, identifier): + schema = deepcopy(_REF_SCHEMA) + schema["properties"].update(operation_id={"$ref": "#/$defs/operation"}, expected_revision={"$ref": "#/$defs/revision"}) + schema["$defs"].update(operation={"const": "native-ref-operation"}, revision={"const": 0}) + schema["required"].extend(["operation_id", "expected_revision"]) + if identifier is not None: + schema["$id"] = identifier + source = deepcopy(schema) + spec = CommandSpec("future.native", "future", "Native resource references", schema, lambda svc, args: args, + effect="domain", native_contract=True) + bridge = McpBridge(service, registry=CommandRegistry(service.store, [spec])) + tool = bridge.list_tools()[-1] + payload = {"workspace_id": bridge.commands.application.workspace_id, "arguments": {"value": "ok"}, + "operation_id": "native-ref-operation", "expected_revision": 0} + Draft202012Validator(tool["inputSchema"], registry=Registry()).validate(payload) + assert bridge.call(tool_name(spec.id), payload)["result"]["result"] == { + "value": "ok", "operation_id": "native-ref-operation", "expected_revision": 0} + assert bridge.call(tool_name(spec.id), {**payload, "operation_id": "wrong"})["code"] == "validation_error" + assert bridge.call(tool_name(spec.id), {**payload, "expected_revision": 1})["code"] == "validation_error" + assert spec.schema == source + + +def test_external_schema_refs_are_rejected_without_network_or_handler_execution(service, monkeypatch): + def refuse_network(*args, **kwargs): + pytest.fail("Schema validation attempted network access") + monkeypatch.setattr("socket.socket.connect", refuse_network) + calls = [] + spec = CommandSpec("future.external", "future", "External ref must fail", obj({ + "value": {"$ref": "https://schema.example.test/must-not-be-fetched.json"}}, ("value",)), + lambda svc, args: calls.append(args)) + bridge = McpBridge(service, registry=CommandRegistry(service.store, [spec])) + result = invoke(bridge, spec.id, {"value": "ok"}) + assert result["isError"] and not result["ok"] + assert calls == [] + + +def test_native_create_then_whiteboard_write_replay_conflict_and_independent_readback(service): + bridge = McpBridge(service) + created = invoke(bridge, "thread_manage", {"action": "create", "data": {"title": "MCP fixture"}}, + operation_id="mcp-create", expected_revision=0) + assert created["ok"], created + thread = created["result"]["result"]["thread"] + fields = {"id": thread["id"], "actor": "MCP test", "actor_type": "system", "entry_type": "note", "content": "Persistent MCP entry"} + written = invoke(bridge, "whiteboard.append", fields, operation_id="mcp-append", expected_revision=thread["revision"]) + assert written["ok"] and not written["result"]["duplicate"] + reopened = McpBridge(ThreadService(type(service.store)(service.store.workspace_path))) + try: + replay = invoke(reopened, "whiteboard.append", fields, operation_id="mcp-append", expected_revision=thread["revision"]) + assert replay["result"]["duplicate"] + assert replay["result"]["result_digest"] == written["result"]["result_digest"] + assert invoke(reopened, "whiteboard.append", {**fields, "content": "Changed"}, operation_id="mcp-append", expected_revision=thread["revision"])["code"] == "operation_conflict" + assert invoke(reopened, "whiteboard.append", fields, operation_id="mcp-stale", expected_revision=thread["revision"])["code"] == "revision_conflict" + read = invoke(reopened, "whiteboard.list", {"id": thread["id"]})["result"]["result"] + assert len(read["items"]) == 1 and read["items"][0]["content"] == fields["content"] + receipt = invoke(reopened, "command.operation", {"operation_id": "mcp-append"})["result"]["result"] + assert receipt["state"] == "complete" and receipt["result"] == written["result"]["result"] + finally: + if hasattr(reopened.svc.store, "connection"): + reopened.svc.store.connection.close() + bad = bridge.call(tool_name("get_thread"), {"workspace_id": "ws-wrong", "arguments": {"id": thread["id"]}}) + assert bad["code"] == "workspace_mismatch" + mismatch = invoke(bridge, "thread_manage", {"operation_id": "other", "action": "create", "data": {"title": "Rejected"}}, operation_id="mismatch", expected_revision=0) + assert mismatch["code"] == "validation_error" + + +def test_read_scopes_cannot_be_bypassed_by_legacy_gateway_or_request_fields(service): + thread = service.create("Permission fixture") + bridge = McpBridge(service, allowed_scopes={"workspace.read"}) + names = {tool["name"] for tool in bridge.list_tools()} + assert "add_note" not in names and tool_name("whiteboard.append") not in names + assert bridge.call("add_note", {"id": thread.id, "text": "Denied"})["code"] == "permission_required" + assert bridge.call("command_invoke", {"version": 1, "workspace_id": bridge.commands.application.workspace_id, + "command": "add_note", "arguments": {"id": thread.id, "text": "Denied"}, + "operation_id": "scope-denied", "expected_revision": thread.revision})["code"] == "permission_required" + assert invoke(bridge, "whiteboard.append", {"id": thread.id, "actor": "Test", "actor_type": "human", "entry_type": "note", "content": "Denied"}, operation_id="scope-board", expected_revision=thread.revision)["code"] == "permission_required" + assert bridge.call(tool_name("get_thread"), {"workspace_id": bridge.commands.application.workspace_id, "arguments": {"id": thread.id}, "allowed_scopes": ["workspace.write"]})["code"] == "validation_error" + assert invoke(bridge, "search.status")["code"] == "local_owner_cli_required" + assert service.get(thread.id).context.notes == "" and service.whiteboard(thread.id) == [] + + +def test_dynamic_reads_never_refresh_the_index_and_invalid_wrappers_never_dispatch(service, monkeypatch): + bridge = McpBridge(service) + thread = service.create("Read only") + def fail(_store): + pytest.fail("read command attempted an index refresh") + monkeypatch.setattr("threaddesk.services.library_refresh.refresh_existing", fail) + assert invoke(bridge, "get_thread", {"id": thread.id})["ok"] + assert bridge.call(tool_name("get_thread"), [thread.id])["code"] == "validation_error" + assert bridge.call(tool_name("get_thread"), False)["code"] == "validation_error" + assert invoke(bridge, "get_thread", {"id": thread.id, "unknown": True})["code"] == "validation_error" + + +def test_module_enable_schema_update_grant_revoke_disable_uninstall_are_live(service): + bridge = McpBridge(service) + thread = service.create("Module source") + registry = ModuleRegistry(service.store) + manifest = ModuleManifest("mcp-probe", "MCP probe", "1", read_scopes=("thread:read",), + write_actions=("probe:write",), approvals=("probe:write",), commands=( + {"id": "read", "capability": "read_thread"}, + {"id": "write", "capability": "write_own_record", "action": "probe:write", "required_approvals": ["probe:write"]})) + identity = "module.mcp-probe.read" + def names(): + return {tool["name"] for tool in bridge.list_tools()} + revision = bridge.tools_revision() + registry.install(manifest) + assert bridge.tools_revision() != revision + assert tool_name(identity) not in names() + assert invoke(bridge, identity, {"id": thread.id})["code"] == "disabled" + registry.set_enabled(manifest.id, True, manifest.approvals) + assert tool_name(identity) in names() + assert invoke(bridge, identity, {"id": thread.id})["ok"] + written = invoke(bridge, "module.mcp-probe.write", {"record_id": "sample", "value": {"kept": True}}, operation_id="module-mcp-write", expected_revision=0) + assert written["ok"], written + state = registry._load() + state["modules"][manifest.id]["granted_approvals"] = [] + registry._save(state) + assert tool_name(identity) not in names() + denied = invoke(bridge, "module.mcp-probe.write", {"record_id": "sample", "value": {"kept": False}}, operation_id="module-mcp-revoked", expected_revision=1) + assert denied["code"] == "permission_required" + declarations = deepcopy(manifest.commands) + declarations[0]["input_schema"] = {"properties": {"id": {"maxLength": 1}}} + registry.update(replace(manifest, commands=declarations)) + registry.set_enabled(manifest.id, True, manifest.approvals) + schema = next(tool for tool in bridge.list_tools() if tool["name"] == tool_name(identity))["inputSchema"]["properties"]["arguments"] + assert schema["allOf"][0]["properties"]["id"]["maxLength"] == 1 + assert invoke(bridge, identity, {"id": thread.id})["code"] == "validation_error" + registry.set_enabled(manifest.id, False) + assert tool_name(identity) not in names() + registry.uninstall(manifest.id) + assert invoke(bridge, identity, {"id": thread.id})["code"] == "command_not_found" + + +def test_real_stdio_discovery_call_and_idle_list_changed(tmp_path): + service = ThreadService(JsonStore(tmp_path)) + thread = service.create("Actual stdio source") + modules = ModuleRegistry(service.store) + manifest = ModuleManifest("wire-probe", "Wire probe", "1", read_scopes=("thread:read",), + commands=({"id": "read", "capability": "read_thread"},)) + modules.install(manifest) + root = Path(__file__).resolve().parents[1] + environment = {**os.environ, "PYTHONPATH": str(root / "src"), "THREADDESK_HOME": str(tmp_path), "THREADDESK_STORAGE": "json"} + process = subprocess.Popen([sys.executable, "-m", "threaddesk.ui.mcp_stdio"], cwd=root, env=environment, + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + output = Queue() + def read_output(): + for line in process.stdout: + output.put(json.loads(line)) + reader = Thread(target=read_output, daemon=True) + reader.start() + def send(payload): + process.stdin.write(json.dumps(payload) + "\n") + process.stdin.flush() + def receive(): + try: + return output.get(timeout=12) + except Empty: + pytest.fail("stdio peer did not return its expected message") + try: + send({"jsonrpc": "2.0", "id": 1, "method": "initialize"}) + assert receive()["result"]["capabilities"]["tools"]["listChanged"] is True + send({"jsonrpc": "2.0", "method": "notifications/initialized"}) + send({"jsonrpc": "2.0", "id": 2, "method": "tools/list"}) + tools = receive()["result"]["tools"] + assert tool_name("module.wire-probe.read") not in {tool["name"] for tool in tools} + workspace_id = next(tool for tool in tools if tool["name"] == tool_name("get_thread"))["_meta"]["threaddesk"]["workspace_id"] + send({"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": tool_name("get_thread"), "arguments": {"workspace_id": workspace_id, "arguments": {"id": thread.id}}}}) + called = receive()["result"] + assert called["structuredContent"]["result"]["title"] == thread.title + assert json.loads(called["content"][0]["text"]) == called["structuredContent"] + modules.set_enabled(manifest.id, True) + assert receive() == {"jsonrpc": "2.0", "method": "notifications/tools/list_changed"} + send({"jsonrpc": "2.0", "id": 4, "method": "tools/list"}) + tools = receive()["result"]["tools"] + assert tool_name("module.wire-probe.read") in {tool["name"] for tool in tools} + send({"jsonrpc": "2.0", "id": 5, "method": "tools/call", "params": {"name": tool_name("module.wire-probe.read"), "arguments": {"workspace_id": workspace_id, "arguments": {"id": thread.id}}}}) + assert receive()["result"]["structuredContent"]["result"]["id"] == thread.id + finally: + process.stdin.close() + try: + process.wait(timeout=10) + except subprocess.TimeoutExpired: + process.terminate() + process.wait(timeout=5) + assert process.returncode == 0, process.stderr.read() diff --git a/tests/test_packaged_commands.py b/tests/test_packaged_commands.py index 1d9095c..347af67 100644 --- a/tests/test_packaged_commands.py +++ b/tests/test_packaged_commands.py @@ -10,6 +10,31 @@ ROOT = Path(__file__).resolve().parents[1] +@pytest.mark.parametrize("entry", ["packaging/desktop_entry.py", "packaging/tauri/backend/server.py"]) +def test_package_entry_mcp_serves_discovery_without_starting_desktop(tmp_path, entry): + env = {**os.environ, "PYTHONPATH": str(ROOT / "src"), "THREADDESK_HOME": str(tmp_path / "workspace"), + "THREADDESK_STORAGE": "json", "PYTHONDONTWRITEBYTECODE": "1"} + requests = [ + {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}, + {"jsonrpc": "2.0", "method": "notifications/initialized"}, + {"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}, + ] + completed = subprocess.run( + [sys.executable, str(ROOT / entry), "--mcp"], cwd=ROOT, env=env, + input="".join(json.dumps(request) + "\n" for request in requests), + capture_output=True, text=True, timeout=30, + ) + assert completed.returncode == 0, completed.stderr + replies = {row["id"]: row for line in completed.stdout.splitlines() + if (row := json.loads(line)).get("id") is not None} + assert replies[1]["result"]["serverInfo"]["name"] == "threaddesk" + tools = replies[2]["result"]["tools"] + assert any(tool["name"] == "command_catalog" for tool in tools) + assert any(tool.get("_meta", {}).get("threaddesk", {}).get("command_id") == "graph.query" + or tool["name"].startswith("td_graph") for tool in tools) + assert not (tmp_path / "workspace" / "desktop-port.json").exists() + + @pytest.mark.parametrize("entry", ["packaging/desktop_entry.py", "packaging/tauri/backend/server.py"]) def test_package_entry_catalogue_query_and_workspace_error_exit(tmp_path, entry): env = {**os.environ, "PYTHONPATH": str(ROOT / "src"), "THREADDESK_HOME": str(tmp_path / "workspace"), diff --git a/tests/test_whiteboard_lifecycle.py b/tests/test_whiteboard_lifecycle.py new file mode 100644 index 0000000..d87ab44 --- /dev/null +++ b/tests/test_whiteboard_lifecycle.py @@ -0,0 +1,227 @@ +"""Thread-backed whiteboard lifecycle: durable receipts, projections and trash.""" +from __future__ import annotations + +import pytest + +from threaddesk.api.service import ThreadService +from threaddesk.core.errors import ManagementError +from threaddesk.services.commands import CommandApplication +from threaddesk.services.whiteboard_lifecycle import WhiteboardService, effective_entries +from threaddesk.services.graph_changes import GraphChangeService +from threaddesk.services.trash import TrashService +from threaddesk.storage.json_store import JsonStore +from threaddesk.storage.sqlite_store import SQLiteStore + + +@pytest.fixture(params=[JsonStore, SQLiteStore], ids=["json", "sqlite"]) +def app(request, tmp_path): + return CommandApplication(ThreadService(store=request.param(tmp_path))) + + +def invoke(app, command, arguments, *, operation=None, revision=None): + request = {"version": 1, "workspace_id": app.workspace_id, "command": command, "arguments": arguments} + if operation is not None: + request["operation_id"] = operation + if revision is not None: + request["expected_revision"] = revision + return app.call(request) + + +def create(app): + return invoke(app, "whiteboard.create", {"title": "Testboard", "description": "Gemeinsamer Thread"}, + operation="create-board", revision=0)["result"]["thread"] + + +def current(app, identity): + return app.service.get(identity).revision + + +def fingerprint(app): + return GraphChangeService(app.store).status(workspace_id=app.workspace_id)["fingerprint"] + + +def test_lifecycle_persisted_and_projected_with_conflicts_replay(app): + thread = create(app) + identity = thread["id"] + initial = fingerprint(app) + assert app.service.get_node("wb-" + identity).kind == "whiteboard" + assert app.service.allowed_node_transitions("wb-" + identity) == () + closed = invoke(app, "whiteboard.close", {"id": identity}, operation="close", revision=current(app, identity)) + assert fingerprint(app) != initial + assert closed["result"]["thread"]["context"]["extra"]["whiteboard"]["state"] == "closed" + duplicate = invoke(app, "whiteboard.close", {"id": identity}, operation="close", revision=thread["revision"]) + assert duplicate["duplicate"] is True + assert duplicate["result_digest"] == closed["result_digest"] + with pytest.raises(ManagementError, match="changed since"): + invoke(app, "whiteboard.rename", {"id": identity, "title": "stale"}, operation="stale", revision=thread["revision"]) + with pytest.raises(ManagementError) as conflict: + invoke(app, "whiteboard.close", {"id": "other"}, operation="close", revision=thread["revision"]) + assert conflict.value.code == "operation_conflict" + assert app.service.graph(kind="whiteboard")["nodes"][0]["metadata"]["whiteboard_state"] == "closed" + reopened = ThreadService(store=type(app.store)(app.store.workspace_path)) + assert WhiteboardService(reopened).get(identity)["state"] == "closed" + invoke(app, "whiteboard.open", {"id": identity}, operation="open", revision=current(app, identity)) + assert app.store.get_current_id() == identity + invoke(app, "whiteboard.rename", {"id": identity, "title": "Umbenannt"}, operation="rename", revision=current(app, identity)) + assert app.service.get_node("wb-" + identity).title == "Umbenannt" + invoke(app, "whiteboard.archive", {"id": identity}, operation="archive", revision=current(app, identity)) + assert app.service.get_node("wb-" + identity).status == "archived" + invoke(app, "whiteboard.unarchive", {"id": identity}, operation="unarchive", revision=current(app, identity)) + assert app.service.get_node("wb-" + identity).metadata["whiteboard_status"] == "open" + + +def test_edit_keeps_originals_and_closed_blocks_writes(app): + thread = create(app) + identity = thread["id"] + first = app.service.append_whiteboard(identity, actor="Mensch", actor_type="human", entry_type="note", content="Original")["entry"] + initial = app.store.list_whiteboard(identity)[0].to_dict() + invoke(app, "whiteboard.edit", {"id": identity, "entry_id": first["id"], "content": "Korrigiert"}, + operation="edit", revision=current(app, identity)) + assert app.store.list_whiteboard(identity)[0].to_dict() == initial + effective = effective_entries(app.store.list_whiteboard(identity)) + assert len(effective) == 1 + assert effective[0].id == first["id"] and effective[0].content == "Korrigiert" + invoke(app, "whiteboard.edit", {"id": identity, "entry_id": first["id"], "content": "Erneut"}, + operation="edit-again", revision=current(app, identity)) + assert effective_entries(app.store.list_whiteboard(identity))[0].content == "Erneut" + assert len(app.store.list_whiteboard(identity)) == 3 + invoke(app, "whiteboard.close", {"id": identity}, operation="close", revision=current(app, identity)) + with pytest.raises(ManagementError) as closed: + invoke(app, "whiteboard.append", {"id": identity, "actor": "Mensch", "actor_type": "human", "entry_type": "note", "content": "no"}, + operation="blocked-append", revision=current(app, identity)) + assert closed.value.code == "whiteboard_closed" + assert len(app.store.list_whiteboard(identity)) == 3 + with pytest.raises(ManagementError) as closed_edit: + invoke(app, "whiteboard.edit", {"id": identity, "entry_id": first["id"], "content": "no"}, + operation="blocked-edit", revision=current(app, identity)) + assert closed_edit.value.code == "whiteboard_closed" + + +def test_delete_requires_explicit_thread_scope_and_restore_keeps_ids_relations(app): + thread = create(app) + identity = thread["id"] + app.service.set_note("Andere Arbeit bleibt erhalten", identity) + node = app.service.create_node("project", "Projekt", metadata={"thread_id": identity}) + original = app.service.append_whiteboard(identity, actor="Human", actor_type="human", entry_type="note", content="Beitrag")["entry"] + relation_id = app.service.relations_for("wb-" + identity)[0].id + preview = invoke(app, "whiteboard.delete_preview", {"id": identity})["result"] + assert preview["lifecycle_scope"] == "owning_thread" + assert original["id"] in preview["references"]["whiteboard"] + assert node.id in preview["references"]["nodes"] + with pytest.raises(ManagementError) as no_confirmation: + invoke(app, "whiteboard.delete", {"id": identity}, operation="no-confirm", revision=current(app, identity)) + assert no_confirmation.value.code == "validation_error" + assert app.service.get(identity).context.notes == "Andere Arbeit bleibt erhalten" + result = invoke(app, "whiteboard.delete", {"id": identity, "confirm_thread_move": True}, operation="delete", revision=current(app, identity))["result"]["entry"] + assert not app.service.graph(kind="whiteboard")["nodes"] + # Existing trash traffic-light contract remains authoritative and blocks + # permanent removal while other project nodes still reference the thread. + purge = TrashService(app.store).purge_preview(result["trash_id"]) + assert "referenced_object" in purge["blockers"] + invoke(app, "whiteboard.restore", {"trash_id": result["trash_id"], "generation": result["generation"]}, + operation="restore", revision=result["revision"]) + assert app.service.get(identity).context.notes == "Andere Arbeit bleibt erhalten" + assert app.service.get_node("wb-" + identity).metadata["whiteboard_id"] == identity + assert app.service.relations_for("wb-" + identity)[0].id == relation_id + assert app.store.list_whiteboard(identity)[0].to_dict() == original + + +def test_old_logs_are_projected_without_rewrite_and_untrusted_metadata_is_inert(app): + thread = app.service.create("Vorhanden") + assert app.service.graph()["nodes"] == [] + for marker in ({"entry_id": []}, {"entry_id": {}, "previous_entry_id": "x"}, "untrusted"): + app.service.append_whiteboard(thread.id, actor="Mensch", actor_type="human", entry_type="note", + content="Alt", metadata={"whiteboard_edit": marker}) + before = app.service.get(thread.id).to_dict() + entries = app.store.list_whiteboard(thread.id) + assert len(effective_entries(entries)) == 3 + app.service.create_node("task", "Fremde Metadaten", metadata={"projection": "thread_whiteboard"}) + app.service.create_node("task", "Falscher Eigentümertyp", metadata={"thread_id": []}) + graph = app.service.graph() + assert any(node["id"] == "wb-" + thread.id for node in graph["nodes"]) + assert app.service.get(thread.id).to_dict() == before + assert app.store.list_whiteboard(thread.id) == entries + # Derived display fields and malformed legacy correction markers do not + # impersonate an edit or prevent a legitimate later correction. + forged = app.service.append_whiteboard(thread.id, actor="Mensch", actor_type="human", entry_type="note", + content="Weitere Notiz", metadata={"whiteboard_edited": [], "whiteboard_edit": "inert"})["entry"] + assert "whiteboard_edited" not in effective_entries(app.store.list_whiteboard(thread.id))[-1].metadata + invoke(app, "whiteboard.edit", {"id": thread.id, "entry_id": forged["id"], "content": "Legitime Korrektur"}, + operation="correct-legacy-marker", revision=current(app, thread.id)) + assert effective_entries(app.store.list_whiteboard(thread.id))[-1].content == "Legitime Korrektur" + + +def test_closed_board_still_records_bound_return_events(app): + from threaddesk.services.whiteboard import append_from_return + thread = create(app) + invoke(app, "whiteboard.close", {"id": thread["id"]}, operation="close", revision=thread["revision"]) + before = fingerprint(app) + revision = current(app, thread["id"]) + result = append_from_return(app.store, {"thread_id": thread["id"], "worker": "codex", "result": "Ergebnis bleibt aufbewahrt", "return_id": "return-closed"}) + assert result["entry"]["content"] == "Ergebnis bleibt aufbewahrt" + assert WhiteboardService(app.service).get(thread["id"])["state"] == "closed" + assert current(app, thread["id"]) == revision + 1 + assert fingerprint(app) != before + + +def test_existing_prefixed_nodes_are_not_shadowed_and_exact_collisions_are_explicit(app): + from dataclasses import replace + thread = create(app) + node = app.service.create_node("task", "Existing imported identity") + canonical = replace(node, id="wb-external") + app.store.save_node(canonical) + assert app.service.get_node(canonical.id).title == canonical.title + assert app.service.allowed_node_transitions(canonical.id) == ("ready",) + assert app.service.relations_for(canonical.id) == [] + collision = replace(node, id="wb-" + thread["id"]) + app.store.save_node(collision) + assert app.service.get_node(collision.id).kind == "task" + with pytest.raises(ManagementError) as conflict: + app.service.graph() + assert conflict.value.code == "graph_projection_conflict" + assert conflict.value.details["object_id"] == collision.id + assert app.store.get_node(collision.id).to_dict() == collision.to_dict() + + +def test_native_whiteboard_owner_results_reconcile_without_repeating_trash(app, monkeypatch): + from threaddesk.services.commands.reconciliation import verify_owner_result + thread = create(app) + move = {"id": thread["id"], "confirm_thread_move": True, + "operation_id": "native-move", "expected_revision": thread["revision"]} + moved = WhiteboardService(app.service).delete(move["id"], **{key: value for key, value in move.items() if key != "id"}) + entry = moved["entry"] + restore = {"trash_id": entry["trash_id"], "generation": entry["generation"], + "operation_id": "native-restore", "expected_revision": entry["revision"]} + restored = WhiteboardService(app.service).restore(**restore) + def forbidden(*args, **kwargs): + raise AssertionError("Reconciliation must never repeat an owner mutation") + monkeypatch.setattr(TrashService, "move", forbidden) + monkeypatch.setattr(TrashService, "restore", forbidden) + assert verify_owner_result(app.service, "whiteboard.delete", move) == {**moved, "duplicate": True} + assert verify_owner_result(app.service, "whiteboard.restore", restore) == {**restored, "duplicate": True} + with pytest.raises(ManagementError) as no_confirmation: + verify_owner_result(app.service, "whiteboard.delete", {**move, "confirm_thread_move": False}) + assert no_confirmation.value.code == "explicit_confirmation_required" + with pytest.raises(ManagementError) as conflict: + verify_owner_result(app.service, "whiteboard.delete", {**move, "id": "wrong"}) + assert conflict.value.code == "operation_conflict" + + +def test_ui_creation_uses_complete_receipt_for_large_unicode_input(tmp_path, monkeypatch): + from http_ui_client import UIClient + from threaddesk.ui.server import create_app + monkeypatch.setenv("THREADDESK_HOME", str(tmp_path)) + monkeypatch.delenv("THREADDESK_STORAGE", raising=False) + client = UIClient(create_app()) + response = client.post("/whiteboards", data={"title": "ä" * 6000, "description": "ü" * 6000, + "operation_id": "long-unicode-create", "expected_revision": "0"}, follow_redirects=False) + assert response.status_code == 303 + assert "thread=" in response.headers["location"] + service = ThreadService(JsonStore(tmp_path)) + assert len(service.list()) == 1 + assert len(WhiteboardService(service).list()) == 1 + again = client.post("/whiteboards", data={"title": "ä" * 6000, "description": "ü" * 6000, + "operation_id": "long-unicode-create", "expected_revision": "0"}, follow_redirects=False) + assert again.headers["location"] == response.headers["location"] + assert again.headers["x-threaddesk-duplicate"] == "true" + assert len(service.list()) == 1 diff --git a/tests/test_whiteboard_lifecycle_browser.py b/tests/test_whiteboard_lifecycle_browser.py new file mode 100644 index 0000000..72e1022 --- /dev/null +++ b/tests/test_whiteboard_lifecycle_browser.py @@ -0,0 +1,88 @@ +"""Actual UI and live graph follow external lifecycle commits on one saved board.""" +from __future__ import annotations + +import os +import re +from pathlib import Path + +from playwright.sync_api import expect, sync_playwright + +from test_desk_browser import Desk +from threaddesk.api.service import ThreadService +from threaddesk.services.commands import CommandApplication +from threaddesk.storage.json_store import JsonStore +from test_whiteboard_lifecycle import invoke, current + + +def test_whiteboard_ui_corrections_state_and_live_graph(tmp_path): + home = tmp_path / "workspace" + app = CommandApplication(ThreadService(JsonStore(home))) + thread = invoke(app, "whiteboard.create", {"title": "Whiteboard für Abnahme"}, + operation="create-browser", revision=0)["result"]["thread"] + identity = thread["id"] + node_id = "wb-" + identity + original = app.service.append_whiteboard(identity, actor="Mensch", actor_type="human", + entry_type="note", content="Unveränderliches Original")["entry"] + desk = Desk(home) + url = desk.start() + try: + with sync_playwright() as play: + browser = play.chromium.launch(executable_path=os.environ.get("THREADDESK_CHROMIUM") or None) + context = browser.new_context(viewport={"width": 1440, "height": 900}) + editor = context.new_page() + graph = context.new_page() + errors = [] + editor.on("pageerror", lambda error: errors.append(str(error))) + graph.on("pageerror", lambda error: errors.append(str(error))) + graph.goto(url + "/map", wait_until="networkidle") + board_node = graph.locator(f'.map-node[data-node-id="{node_id}"]') + expect(board_node).to_have_count(1) + expect(board_node).to_have_attribute("data-kind", "whiteboard") + board_node.click() + expect(graph.locator("[data-map-whiteboard-state]")).to_have_attribute("data-map-whiteboard-state", "open") + editor.goto(url + "/?thread=" + identity, wait_until="networkidle") + entry = editor.locator(f'[data-entry-id="{original["id"]}"]') + entry.locator("[data-whiteboard-edit] summary").click() + entry.locator("textarea[name=content]").fill("Bearbeitet über ThreadDesk") + entry.locator("[data-whiteboard-edit] button[type=submit]").click() + expect(editor.locator(f'[data-entry-id="{original["id"]}"] .whiteboard-body')).to_have_text("Bearbeitet über ThreadDesk") + expect(editor.locator("[data-whiteboard-correction]")).to_contain_text("Unveränderliches Original") + assert app.store.list_whiteboard(identity)[0].content == "Unveränderliches Original" + editor.locator('[data-whiteboard-controls] form button[type=submit]').click() + expect(editor.locator('[data-whiteboard-state]')).to_have_attribute("data-whiteboard-state", "closed") + expect(editor.locator("[data-whiteboard-content]")).to_have_count(0) + expect(board_node).to_have_attribute("data-status", "paused", timeout=15000) + board_node.click() + expect(graph.locator("[data-map-whiteboard-state]")).to_have_attribute("data-map-whiteboard-state", "closed") + editor.locator('[data-whiteboard-controls] form button[type=submit]').click() + expect(editor.locator("[data-whiteboard-content]")).to_be_visible() + # A stale edit is rejected in-place; the exact draft survives. + entry = editor.locator(f'[data-entry-id="{original["id"]}"]') + entry.locator("[data-whiteboard-edit] summary").click() + entry.locator("textarea[name=content]").fill("Entwurf bleibt bei Konflikt") + invoke(app, "whiteboard.rename", {"id": identity, "title": "Neuer Whiteboardname"}, + operation="rename-external", revision=current(app, identity)) + entry.locator("[data-whiteboard-edit] button[type=submit]").click() + expect(entry.locator("textarea[name=content]")).to_have_value("Entwurf bleibt bei Konflikt") + expect(graph.locator(f'.map-node[data-node-id="{node_id}"]')).to_have_attribute("aria-label", re.compile(r"^Neuer Whiteboardname, whiteboard, active"), timeout=15000) + invoke(app, "whiteboard.archive", {"id": identity}, operation="archive-external", revision=current(app, identity)) + expect(board_node).to_have_attribute("data-status", "archived", timeout=15000) + invoke(app, "whiteboard.unarchive", {"id": identity}, operation="unarchive-external", revision=current(app, identity)) + moved = invoke(app, "whiteboard.delete", {"id": identity, "confirm_thread_move": True}, + operation="delete-external", revision=current(app, identity))["result"]["entry"] + expect(board_node).to_have_count(0, timeout=15000) + invoke(app, "whiteboard.restore", {"trash_id": moved["trash_id"], "generation": moved["generation"]}, + operation="restore-external", revision=moved["revision"]) + expect(board_node).to_have_count(1, timeout=15000) + expect(board_node).to_have_attribute("data-status", "active") + editor.goto(url + "/whiteboards", wait_until="networkidle") + expect(editor.locator(f'[data-whiteboard-id="{identity}"]')).to_contain_text("Neuer Whiteboardname") + shots = Path(os.environ.get("THREADDESK_WHITEBOARD_SHOTS", "/tmp/threaddesk-dynamic-mcp-20261030/screenshots")) + shots.mkdir(parents=True, exist_ok=True) + board_node.click() + graph.screenshot(path=str(shots / "whiteboard-live-graph.png")) + editor.screenshot(path=str(shots / "whiteboards-list.png")) + assert errors == [] + browser.close() + finally: + desk.stop()