nxsflow
flow (nxf)

Plugins

Ein Plugin bildet den universellen Kern auf die Sprache und Policy eines Konsumenten ab. Nichts in der CLI kodiert Vokabular fest — alles ergibt sich aus der aktiven Plugin-Konfiguration, einmal bei nxf init gewählt. Ein Plugin legt vier Dinge fest:

  • Vokabular: die Worte für die beiden Item-Typen und die drei Status.
  • Prioritätslabels: die Namen für die Prioritätsstufen 0–4.
  • Ranking: wie next die ready-Menge ordnet.
  • Darstellung: welche Felder list und show anzeigen.

Die zwei mitgelieferten Plugins

  • issue-tracker — Software-Issue-Tracking: Epics und Issues, P0–P4-Prioritäten, abhängigkeitsbewusstes Ranking.
  • personal-todo — Persönliche To-do-Liste: Listen und Todos, now/soon/later-Prioritäten für alltägliche Aufgaben.

Ihre Vokabulare unterscheiden sich:

  • issue-tracker: ein Projekt ist ein epic, ein Task ist ein issue; die Status sind open / in progress / closed; die Prioritäten sind P0–P4; next rankt nach Priorität, dann Fälligkeitsdatum, dann id.
  • personal-todo: ein Projekt ist ein project, ein Task ist ein todo; die Status sind todo / doing / done; die Prioritäten sind now / soon / later / someday / icebox; next rankt nach Priorität, dann id (kein Fälligkeitsdatum als Tiebreak).

Named Variants auflösen: types und priorities

type und priority sind beide named variants — eine kleine, plugin-definierte Menge mit fester Ordnung. nxf schema --json exponiert beide als gekeyte Map, sodass eine Regel jedes der beiden Felder auf jedem Item auflöst:

schema.types[item.type]           # "task" -> "issue"
schema.priorities[item.priority]  # "0"    -> "P0"

Der auf einem Item gespeicherte Wert ist der Handle, nicht das Label. Für priority ist dieser Handle ein Ordinal — "0" ist die höchste Priorität, aufsteigend gezählt — bewusst plugin-unabhängig, damit next numerisch ranken kann und gesyncte Items keine Fremd-Labels tragen. Die schema.priorities-Map übersetzt das Ordinal zurück in ein menschliches Label.

Ordinal-Stabilitätsvertrag. Innerhalb einer Schema-Version ist die Bindung Ordinal→Bedeutung stabil und append-only: priority.labels darf am Ende wachsen, aber Umsortieren oder Einfügen verschiebt die Bedeutung bereits gespeicherter Items und ist daher ein breaking Schema-Change — ein Schema-Version-Bump mit Migration, keine In-place-Änderung.

Gleiche Daten, nebeneinander

Die Naht ist am klarsten, wenn Sie die gleichen Befehle auf den gleichen Daten unter jedem Plugin ausführen. Beide Workspaces unten enthalten drei Tasks: zwei mit Priorität 1 (einer fällig 2026-02-01, einer fällig 2026-03-01) und einer mit Priorität 2.

next zeigt zwei Unterschiede auf einmal — das Vokabular und das Ranking. Unter issue-tracker gewinnt das früher fällige P1-Issue (Book venue) den Tiebreak:

$ nxf next

0002  P1  open  [feature]  Book venue
0001  P1  open  [feature]  Draft proposal
0003  P2  open  [feature]  Order badges

Unter personal-todo stehen dieselben beiden soon-Todos bei der Priorität gleich und fallen auf die id zurück, sodass das zuerst erstellte (Draft proposal) zuerst kommt — und die Labels sind nun now/soon/later, todo/doing/done:

$ nxf next

0001  soon  todo  [todo]  Draft proposal
0002  soon  todo  [todo]  Book venue
0003  later  todo  [todo]  Order badges

Dennoch ist der gespeicherte Datensatz derselbe — list --json ist id-geordnet und hängt nicht vom aktiven Plugin ab. Das Plugin legt Darstellung und Ranking darüber; es ändert nie die Daten. Das eine Feld, das ein Plugin besitzt, ist type: Das Typ-Set ist plugin-deklariert, also speichert issue-tracker feature, wo personal-todo todo speichert. Lässt man dieses eine Feld weg, sind die Datensätze byte-für-byte dieselben:

$ nxf list --json
[{"archived":null,"assignee":null,"belongs_to":null,"closed_at":null,"closing_comment":null,"completion_criterion":null,"created_at":"2026-06-23T00:00:00Z","defer_until":null,"deleted":null,"description":"Outline the event proposal","design":null,"due":"2026-03-01","id":"ab12.0001","priority":"1","priority_label":"P1","status":"open","title":"Draft proposal","type":"feature","type_label":"feature","updated_at":"2026-06-23T00:00:00Z"},{"archived":null,"assignee":null,"belongs_to":null,"closed_at":null,"closing_comment":null,"completion_criterion":null,"created_at":"2026-06-23T00:00:00Z","defer_until":null,"deleted":null,"description":"Reserve the event space","design":null,"due":"2026-02-01","id":"ab12.0002","priority":"1","priority_label":"P1","status":"open","title":"Book venue","type":"feature","type_label":"feature","updated_at":"2026-06-23T00:00:00Z"},{"archived":null,"assignee":null,"belongs_to":null,"closed_at":null,"closing_comment":null,"completion_criterion":null,"created_at":"2026-06-23T00:00:00Z","defer_until":null,"deleted":null,"description":"Print attendee name badges","design":null,"due":null,"id":"ab12.0003","priority":"2","priority_label":"P2","status":"open","title":"Order badges","type":"feature","type_label":"feature","updated_at":"2026-06-23T00:00:00Z"}]

show macht den Vokabularunterschied an einem einzelnen Item konkret. Issue-tracker:

$ nxf show ab12.0002

0002 P1 Book venue
==================

TYPE: feature
STATUS: open

DESCRIPTION
-----------

Reserve the event space

DEFINITION OF DONE
------------------



DESIGN
------



NOTES
-----

personal-todo, dasselbe ab12.0002:

$ nxf show ab12.0002

0002 soon Book venue
====================

TYPE: todo
STATUS: todo

WHY
---

Reserve the event space

DONE WHEN
---------



PLAN
----



LOG
---

Diese Verdopplung ist keine Redundanz — sie ist die Erklärung. Das --json ist die Bedeutung; das Plugin ist, wie ein bestimmtes Publikum sie liest und rankt. Ein Plugin bei init zu wählen ist daher eine echte Entscheidung; führen Sie nxf init --help aus, um die Optionen zu sehen, bevor Sie sich festlegen.

Eigene Felder deklarieren

Ein Plugin kann seinen Typen unter [fields.<name>] eigene Felder geben. nxf schema --json listet sie mit "custom": true und ihrer kind; create und update setzen sie mit --set <name>=<wert>; show --json und die Listen geben die gesetzten unter custom zurück. Ein leerer Wert löscht ein Feld.

[fields.stage]
type = "enum"
values = ["backlog", "active", "done"]
on = ["project"]          # die Typen, für die es gilt; weglassen heißt: für jeden Typ

[fields.occurred_on]
type = "datetime"
label = "Occurred on"

Der type entscheidet, was ein Wert sein darf:

  • text und longtext: beliebiger Text.
  • date: ein Datum (2026-10-12) oder ein RFC-3339-Zeitstempel, gespeichert wie angegeben.
  • datetime: ein Zeitpunkt, als RFC-3339-Zeitstempel mit Zeitzone — Z oder ein Offset wie +02:00. Gespeichert wird derselbe Zeitpunkt in UTC auf ganze Sekunden: --set occurred_on=2026-10-12T10:30:00+02:00 speichert 2026-10-12T08:30:00Z. Ein reines Datum oder eine Uhrzeit ohne Zone wird abgelehnt. Weil jeder Wert dieselbe Form hat, sortieren datetime-Werte überall zeitlich, wo ein Feld gelesen wird, etwa im of = { field = "..." } eines Ranking-Schlüssels. Der eingegebene Offset bleibt nicht erhalten.
  • enum: einer der deklarierten values.

Listenfelder

list = true lässt ein Feld mehrere Einträge statt eines Werts halten — etwa die E-Mail-Adressen einer Person. Jeder Eintrag ist ein Wert des type des Felds (text, date, datetime oder enum, geprüft wie oben) und kann eine eigene Kennzeichnung tragen, etwa work oder private:

[fields.email]
type = "text"
list = true
on = ["person"]
nxf entry add <id> email ada@work.example --label work
nxf entry add <id> email ada@home.example
nxf entry list <id> email
nxf entry remove <id> email ada@home.example
nxf update <id> --set email=          # leert die Liste

show --json und die Listen geben die Einträge unter custom zurück, in der Reihenfolge, in der ihre Werte zuerst hinzugefügt wurden: "email": [{"value": "ada@work.example", "label": "work"}, ...], label nur, wo es eine gibt. nxf schema --json markiert das Feld mit "list": true. Über MCP heißen die Verben flow_entry_add, flow_entry_remove und flow_entry_list.

Der Wert ist der Eintrag: Wer einen Wert hinzufügt, den die Liste schon hat, gibt diesem Eintrag die neue Kennzeichnung (oder keine, ohne --label), statt ihn doppelt anzulegen; wer einen Wert entfernt, entfernt ihn mit jeder Kennzeichnung. Zwei Geräte dürfen gleichzeitig hinzufügen und entfernen: Eine Adresse, die auf dem einen hinzukommt, übersteht ein Entfernen auf dem anderen, das sie nie gesehen hat, und beide enden mit derselben Liste. --set leert ein Listenfeld nur; ein Listenfeld kann weder required noch longtext sein, kein merge haben und nicht ranken.

entry add und entry remove geben den Eintrag so aus, wie er gespeichert ist: Ein datetime-Eintrag, eingegeben als 10:30:00+02:00, erscheint als 08:30:00Z, und ein datetime-Eintrag lässt sich mit demselben Zeitpunkt in jedem Offset entfernen. Jeder Eintrag, den du siehst, lässt sich entfernen: entry remove prüft weder den Wert gegen den Typ des Felds (einen inzwischen gestrichenen Enum-Wert) noch on (einen Typ, den das Plugin inzwischen ausnimmt). Der Name eines Listenfelds hat höchstens 64 Zeichen.

Verweisfelder

type = "ref" macht ein Feld zu einem typisierten Verweis auf ein anderes Item — die Organisatorin eines Meetings, die Firma, bei der eine Person arbeitet. Sein Wert ist eine Item-Id, und targets nennt die Item-Typen, auf die es zeigen darf. Ein Verweis ist ein einzelner Wert oder, mit list = true, eine Liste von Einträgen mit optionaler Kennzeichnung:

[fields.organizer]
type = "ref"
targets = ["person"]
on = ["meeting"]

[fields.participants]
type = "ref"
targets = ["person"]
list = true
on = ["meeting"]
nxf update <meeting> --set organizer=<person>
nxf entry add <meeting> participants <person> --label host
nxf entry remove <meeting> participants <person>
nxf update <meeting> --set organizer=      # leert ihn

--set und entry add nehmen eine volle Id (ab12.0007 oder eine fremde cd34.0001) oder eine nackte lokale (0007), die wie jede eingegebene Id aufgelöst wird. Sie prüfen das Ziel, in dieser Reihenfolge: Der Wert ist eine Id, das Item existiert und ist nicht gelöscht, sein Typ steht in targets, und es ist nicht das Item selbst. Ein geschlossenes oder archiviertes Item ist ein gültiges Ziel. entry remove prüft nichts, sodass sich auch ein Verweis auf ein inzwischen gelöschtes Item entfernen lässt. Typ und Selbstverweis werden beim Schreiben geprüft, nie danach: Wird das Ziel später umgetypt oder gelöscht, bleibt der Verweis, wie er ist.

show --json behält den Wert unter custom und ergänzt refs, das jedes Ziel auflöst: "refs": {"organizer": [{"id": "ab12.0007", "found": true, "type": "person", "title": "Ada", "status": "open"}]}, oder {"id": …, "found": false} für einen Wert, der kein Item auf diesem Board nennt. Das menschenlesbare show zeigt unter der Überschrift des Felds Id und Titel jedes Ziels, oder (not on this board). nxf schema --json meldet das Feld als "kind": "ref" mit seinen "targets".

Ein Verweis kann kein merge = "crdt-text" haben und nicht ranken; sein Name hat höchstens 64 Zeichen. Wer ein Textfeld als Verweis neu deklariert, behält dessen Werte: Die Ids darunter werden zu Verweisen, der Rest erscheint als nicht auf diesem Board. Ein Verweis wird mit den gewöhnlichen Feld-Ops geschrieben, sodass eine aus einem Snapshot gestartete Replik gültig bleibt.

Die Verweisenden finden

nxf list --ref <feld>=<id> listet die Items, deren Verweisfeld die Id hält: einen einzelnen Wert gleich ihr oder einen Eintrag gleich ihr. Es ist ein list-Filter und lässt sich daher mit --type, --status, --in, --label, --sort, --limit und --paginate kombinieren; eine nackte lokale Id wird aufgelöst. Mehrfach angegeben sind es Alternativen (höchstens 8). Mit einem Plugin, das Meetings, Stationen und Beziehungen deklariert:

nxf list --type meeting --ref participants=<person>              # die Meetings einer Person
nxf list --type station --ref person=<person>                    # die Stationen einer Person
nxf list --type relationship --ref a=<person> --ref b=<person>   # Beziehungen, von beiden Enden

Unter --sort rank grenzt der Filter die Zeilen ein, nie das Ranking: Die Items kommen in der Reihenfolge, die die ungefilterte gerankte Liste ihnen gibt. Die Id muss kein Item nennen — die Verweisenden eines gelöschten Items bleiben auffindbar, und eine Id, die nichts hält, listet nichts. Gelöschte Verweisende fallen weg, geschlossene werden gelistet; archivierte Verweisende werden nicht gelistet, weil list lebende, nicht archivierte Items listet (nxf archived nimmt kein --ref). Ein Item, dessen eigene Id länger als 128 Byte ist, ist nie ein Verweisender. Ein Feld, das kein Verweisfeld des Plugins ist, ein Wert, der keine Id ist, --ref ohne = und mehr als 8 Verweise werden abgelehnt. list --json bleibt unverändert; über MCP nimmt flow_list refs: [{"field": "participants", "id": "<person>"}].

Ein Ranking deklarieren

Ein Plugin deklariert unter [ranking.next], wie next rankt, und unter [ranking.list], wie list --sort rank rankt. Beide nehmen dieselben Schlüssel und dieselben Regeln:

  • order = [...] ist die Standardreihenfolge: eine Liste von Schlüsseln, der Reihe nach verglichen, mit der Id als letztem Entscheider.
  • [ranking.next.<typ>] (oder [ranking.list.<typ>]) gibt einem Typ eine eigene order.
  • types = [...] sagt, wie die Typen zueinander stehen, sobald einige eine eigene Reihenfolge haben.

Ein Schlüssel ist ein Feldschlüssel, etwa { field = "priority", dir = "asc" }. Mit einer precedence-Liste rankt ein Feldschlüssel stattdessen nach der Position des Werts in dieser Liste. In einer Reihenfolge pro Typ, oder in der Standard-order von [ranking.list], kann ein Schlüssel auch ein abgeleiteter Schlüssel sein, der ein Item nach seinen Nachbarn im Graphen rankt:

[ranking.next]
types = ["project", "action"]
order = [ { field = "priority" } ]

# related: ein Wert, gelesen an den Nachbarn über eine ausgehende Kante (parent, contributes_to,
# dep, mentions). `of = "rank"` liest die Position des Nachbarn in der Reihenfolge seines eigenen
# Typs; `of = { field = "..." }` liest ein Feld, auch ein eigenes Feld. `pick` ist min (Standard)
# oder max.
[ranking.next.action]
order = [
  { related = "parent", type = "project", of = "rank", nulls = "last" },
  { field = "priority" },
]

# count: wie viele Nachbarn passen. children, contributors, dependents und mentioned_by zählen die
# eingehenden Kanten parent, contributes_to, dep und mentions; eine ausgehende Art zählt in die
# andere Richtung. `type`, `status` (eine Liste) und `label` filtern die gezählten Nachbarn.
[ranking.next.project]
order = [
  { related = "contributes_to", type = "goal", of = { field = "order" }, nulls = "last" },
  { count = "children", type = "action", status = ["open", "in_progress"], dir = "desc" },
]

# sum: mehrere Zählungen, mit gleichem Gewicht addiert. `dir` gehört zur Summe.
[ranking.list]
types = ["person"]
[ranking.list.person]
order = [
  { sum = [ { count = "mentioned_by", type = "project" },
            { count = "mentioned_by", type = "action", label = "waiting" } ], dir = "desc" },
]

Abgeleitete Schlüssel zählen nur lebende Nachbarn: ein gelöschter fällt weg, ein geschlossener oder archivierter zählt. Ein Rang wird über die Kandidaten des Typs unter dem Verb berechnet, das ihn rankt, und Items, die in jedem Schlüssel gleich sind, teilen sich einen Rang. Für next sind diese Kandidaten seine umsetzbaren Items, für list die lebenden, nicht archivierten Items. Ein Nachbar außerhalb dieser Menge, etwa ein geschlossenes Projekt unter next, hat keinen Rang und liest sich als null. Ein list-Schlüssel darf den Rang eines Typs lesen, den nur next rankt; die Kandidaten von next werden dann zur aktuellen Zeit genommen (NXF_NOW legt sie fest). Nur list --sort rank liest diese Zeit.

Das Plugin wird beim Laden abgelehnt, mit dem Schlüssel benannt, wenn:

  • ein Schlüssel Arten mischt oder keine nennt;
  • ein Schlüssel eine unbekannte Kantenart, einen unbekannten Typ, Status oder ein unbekanntes Feld nennt;
  • ein Schlüssel einen Teil trägt, den seine Art nicht nimmt, etwa pick an einer Zählung oder nulls an einer Zählung oder Summe (eine Zählung ist nie null);
  • of = "rank" einen Typ ohne eigene Reihenfolge nennt;
  • die Typen sich im Kreis gegenseitig ranken.

Ein Schlüssel related ... of = "rank" ohne type liest den Rang jedes Typs mit einer Reihenfolge, auch des eigenen. Das ist ein Kreis, also nennen Sie den type des Nachbarn; die Standard-order von [ranking.list] lehnt einen solchen Schlüssel ebenfalls ab. Abgeleitete Schlüssel sind in der Standard-order von [ranking.next] nicht erlaubt, weil diese Reihenfolge auch blocked und --sort rank der Lanes rankt.

Tippen, um zu suchen. Gefunden wird nach Wortlaut und nach Bedeutung — ein Abschnitt kann also eine Frage beantworten, die er nicht ausspricht.