Plugins
The core of wloop is a task system over opaque units: tasks, their views and gates, projects, the review, the turns, the workflows and the read model work on unit.Unit{Provider, Key} and never on a pull request or a Slack thread. Where units come from is a plugin’s business. The bundled plugins, github and slack, run as their own processes, through the plugin host below; the host keeps, in its composition root, what each still needs in the daemon’s process.
A plugin
Section titled “A plugin”A plugin is listed in internal/cli/sources.go, the one file that names a source, with its codec and how the composition root builds the module it runs in the daemon from the configuration and the shared handles (internal/cli/module.go). Its codec mints and parses the keys of its units and stays registered when the plugin is disabled, so stored units still render. No package outside the composition root registers a source: a plugin’s vocabulary, UI and profile layer come from its manifest and its directory.
A module must have its codec, an ingestion job with an interval and its rows; a module that derives items must have a card. A module that fails to build, or whose keys clash with the core’s, is left out with its error reported, and the daemon runs without it. Everything else is optional, and the core does without what a plugin leaves out:
| A plugin contributes | Through its module | What the core does with it |
|---|---|---|
| Units and their changes | Ingest (an ingest.Job over its port.Source and port.SourceStore), Slow, Run (a long-running stream, restarted after 30 s), Jobs | runs them as daemon jobs; a source never writes, ingest saves through the source’s store |
| Rows | Rows, a read model provider | serves them to every surface with the rest of the read model |
| Items and cards | Items (deterministic, no I/O), Card, Identity, UnitEvents | turns items into tasks; renders the card on every surface; resolves linked items from unit events |
| Workflow facts | Facts, an automation.Units | the engine evaluates workflows over the plugin’s units; a fact that names a signal, such as pr.head, is what a workflow’s mark records; <slot>.ended (builtin.EndedFact), such as pr.ended or slack.ended, is the plugin’s word that a unit has ended, which the tasks it is attached to read as task.link_ended |
| Marks | its planner’s own mark names, written through the store; those a workflow may read declared in the manifest’s [[source.marks]] | stores them per unit, retains them with the unit, and lets handled() read the declared ones |
| What a turn reads about a unit | Payload, a turn.PayloadReader: a source plugin’s Planner.Payload (GitHub’s wraps it in ghsource.TurnPayloads, which also completes a pull request a host round hands the turn and names a probe’s) | renders it into the turn’s instructions: the argument, the words its profile layer gives, the facts and the contents the plugin answers; a plugin whose manifest names its payload root, such as GitHub’s PR, has its data read by its intents’ templates under that root |
| Where its work runs | PlacementDefaults, PlacementHints | the placement service decides the repository and checkout |
| Publishing | Publisher, a port.Effects | sends an approved draft once, with no automatic retry; Verify settles a draft a crash left in sending |
| Following units | Track, Untrack | wloop track and untrack go through it; GitHub’s admit a pull request while fewer than 50 are tracked and it is open |
| UI | its manifest’s [source.ui]: views (a view’s default_sort words its own order), facets with their age buckets (the one marked strip colors its own rows’ strip and its legend, each value in the hue its ui.toml colors names), menu bar sections, keys (action = "nav" binds the chord that jumps to its views) and web, a directory of the plugin the host serves from disk under /plugins/<id>/; the words of each id in its profile layer’s ui.toml (views and a view’s search presets, filters and sorts, facets, values, buckets, situations, the reasons a situation names, event kinds and how an event’s data shows, the detail’s fields, menu bar sections), which a user profile’s plugins/<id>/ui.toml overrides key by key (psource.Catalog); CLI | serves the views, facets, situations, reason words, event words and detail fields in GET /api/catalog; the cockpit names none of a plugin’s event kinds or intents and the saved views, menu bar and keys in GET /api/plugins; every client renders a plugin view from its spec, and the cockpit mounts the plugin’s own web module for a custom view; View answers a custom view’s content, such as GitHub’s Explore |
| Health | Health | shows it with the daemon’s health |
| Its checkouts | Checkouts | a session on its unit runs in the unit’s own checkout, with no placer asked |
| Its briefs | Briefs, a brief.BriefSource: a source plugin’s Briefer calls | the brief queue reads the unit’s revision and staleness through it, and a pull request’s input through the GitHub plugin’s BriefInput (its facts and its diff in one call, which the queue’s facts and diff reads share); the revision’s Attempt names what an attempt is made from, and attempt_of stores the version and that attempt (brief.AttemptKey), as version 6 stored it. A brief another job writes reaches the queue through the broker’s PutBrief, which keeps it as the unit’s brief and wakes the turn waiting on it; a compiled-in module’s job tells the queue in process |
| Links to other plugins’ units | Links, a LinkResolver | resolves a link of its units to the unit another plugin names |
| A detail page | Detail | a client opens the unit’s detail in the cockpit, in place of its link |
| Addresses a person types | Resolve | the daemon answers GET /api/resolve with the unit an address lands in, such as a Slack message ref or permalink in its direct message segment or thread; a command line’s target asks it |
The module’s Capabilities() names what a plugin can do (checkouts, briefs, publishes, reacts, items, detail), served in api.Plugin.Capabilities. The daemon and the clients ask a plugin what it can do, never which one it is (api.PluginCan). A unit’s codec says the rest: Equal compares two keys of its provider, such as GitHub’s without case, for units and person keys alike (unit.Registry.Same, items.Identity.Codecs); URL and KeyOf name a unit by its web address, so a unit with one is typed by its address (unit.Registry.ParseURL); Target is how a command line names one of its units, such as <url>, in an intent’s usage line (unit.Registry.Target).
The UI comes in three tiers: the card, which every surface renders the same way and which is kept as a stored snapshot; the declarative contributions the core renders; and the plugin’s own views. Keys follow one map: the core owns every lowercase letter and digit, and a plugin or an intent gets one uppercase letter (internal/keys).
A disabled plugin
Section titled “A disabled plugin”[plugins.<id>] enabled = false (plugins.github.enabled, plugins.slack.enabled) leaves the module unbuilt: no ingestion, rows, jobs, facts, items, actions, notifications, MCP tools or publisher. Its codec stays, cards render from the stored snapshot, and every write to one of its units answers 409 {"error": "<id> is disabled"} before the handler runs. On a task, Dismiss still works: it detaches the disabled plugin’s units and their items, and an item’s link to such a unit is stored and left out while the plugin is off (Tasks and views). The core works with no plugin enabled.
A disabled plugin’s vocabulary still merges and its codec still parses its keys, so a configuration or a profile that names its facts, keys or units loads as it does while it is on; its profile layer does not load and its process does not run.
The vocabulary
Section titled “The vocabulary”internal/intent/builtin holds the core’s rows only: no fact, effect, role, step provider, slot, payload root, event kind, row kind, delivery or task kind of a plugin (TestBuiltinHasNoProviderRows), and it imports no plugin’s domain. Each plugin’s rows come from the [source] table of its plugin.toml: builtin.Merge maps every source table onto the tables, refusing one that takes a core slot, name or provider, or whose role names a mechanism the host does not have, and builtin.Install makes the result the tables. The bundled plugins’ manifests live in plugins/<id>/plugin.toml and internal/plugins/bundled installs their vocabulary when it initializes; the command’s main installs the bundled and the found plugins’ vocabulary once, before the configuration and the profile load. A fact of the manifest is a workflow fact of the plugin’s slot and, with slots, an intent fact its intents alone read. Its language_fact names the string fact that tells a unit’s language (builtin.LanguageFacts), which draft.propose matches against the profile’s locales to pick the template a draft’s text is written in; an effect’s silent says it applies without its draft’s post, so a person may close the conversation without the message. The task kind values a manifest declares join tasks.toml’s kind. make docs-ref reads the bundled manifests.
The codec of every plugin comes from its manifest (internal/plugins/codec), for an enabled plugin and a disabled one alike, and Unit.Label asks it how a person reads a key.
The plugin host
Section titled “The plugin host”internal/plugins/host runs plugin processes of any kind, and one adapter per kind connects a started process to the daemon (internal/plugins/source for sources):
- Discovery. A plugin is a directory with its
plugin.toml, its binary (<name>-plugin-<id>, named after the host’s binary) and its profile layer (profile/), in<config>/plugins/<id>/, in<state>/plugins/bundled/<id>/whereinstallplaces the bundled ones, or in the bundled directory beside the daemon’s binary,plugins/<id>/, where a release andmake buildkeep them; the first of these that holds an id wins it, so a user’s directory overrides a bundled one. - Packaging. A release (
.goreleaser.yaml) ships three binaries: the host, and each plugin’s inplugins/<id>/with its manifest, its profile layer and the SHA-256 of its binary (<binary>.sha256).installchecks each binary against that file, copies the plugins to<state>/plugins/bundled/and records each binary’s checksum in<state>/plugins/checksums, so the daemon finds the plugins whatever path it runs the binary by (Homebrew’s link) (TestInstallPlacesTheBundledPluginsWithChecksums).make buildplaces the plugins inbin/plugins/<id>/,make build-targetsbuilds them for every release target, andmake dev-installrunsinstallwith the build. - Trust. A bundled plugin runs only with the SHA-256 its install recorded (
<state>/plugins/checksums, which go-plugin checks). Any other binary, and a bundled one no install recorded, runs only with[plugins.<id>] trust = "path", which only you write; the daemon’s log names the path of each plugin it runs and how it is trusted, anddoctorshows each plugin’s path, the start of its hash, its trust, the protocol its binary negotiates when doctor starts it without its start call, the health the daemon reads and the manifests that do not load (TestDoctorShowsEachPluginsTrust,TestDoctorReportsAPluginWithTheWrongProtocol).scripts/isolated-daemon.sh setup --plugins <id,...>copies the plugins beside its binary into the isolatedbin/plugins/and writestrust = "path"for each it turns on, GitHub against the stubbedghand Slack with no socket (TestIsolatedDaemonRunsThePlugins);--slacktrusts Slack the same way with its socket on (TestIsolatedSetupSlackTrustsThePluginByItsPath), and--real-gh <owner/repo,...>runs GitHub against your owngh, scoped to those repositories.snapshotcopies theplugins/<id>/import.dbbeside a store, andstart --jobsserves such a copy with its jobs on when setup turned plugins on, so a version 6 snapshot migrated withrun <dir> <binary> store migrate --yesshows each plugin import and report (TestASnapshotsPluginsImportAndReport). - Brand. A plugin imports no host package: the host passes its brand name in the process’s environment (
plugin.BrandEnv), which the SDK reads asplugin.Brand(). - Start.
servestarts every enabled plugin it trusts, each the leader of a process group the daemon lists (procgroup);serve --no-jobsstarts none. The source adapter serves the broker to the plugin and starts its kind; until it runs, its units keep their last snapshot, marked stale. - Loading. A plugin the daemon starts is loading until its
Healthfirst says it serves: the host asks it every second after it starts. Until thenGET /api/rowsandGET /api/tasksname it inloading, with the reason its health gives (orstartingbefore it runs). A later crash does not make it loading again.GET /api/healthasks every plugin’s health and every source’s at once, in the background, one ask at a time; once they answered before, it waits at most 250ms and serves their last answer, so a busy plugin never holds it. - Restart. A plugin whose process ends is restarted after a backoff that doubles from a second to five minutes, and starts again from a second after a run longer than that; while it is down its units are stale: their rows and cards render from the last snapshot, marked
stale. - Notification. The host tells each running plugin of the changes to the host topics its manifest’s
notifylists (Notify(units, topics)), one change-log note at a time: with the plugin’s own units the note names, with none for a note of every unit or of no unit, and not at all for a note naming only other providers’ units. Each call is cut after ten seconds, so a plugin that hangs delays the others by no more. - Shutdown. When the daemon stops, each plugin’s kinds get
Stop, then its process is killed and go-plugin’s clients cleaned up. There is no reattach.
Snapshots and overlays
Section titled “Snapshots and overlays”A source plugin reports each unit as a snapshot (Reporter.Report), which replaces the unit’s last one whole. The host keeps it in the store, as JSON in unit_snapshots beside the plugin’s rev for it, and drops it when the plugin reports the unit gone. The host reads snapshots only; on the hot path it never calls a plugin:
- Rows.
serveserves the units of every plugin it runs and no built module serves throughreadmodel.Reported, with the views, facets and situations its manifest declares; a row’sviewsare the plugin’s views over units it is in, such as GitHub’s My PRs.readmodel.Reportedderives a unit’s row from its snapshot (its situation, card, views, facets, items and data; a facet that holds more than one value comes inFacetValues, overFacets) under the host’s own state, whichinternal/readmodel/overlaylays over it and which names no source: the run of the newest turn whose role drives the row (the loop working, waiting on you or stopped, with its question), your mute (the wake of the unit’s task: until a time, or until the wakes match its condition), and the automatic round an open plan gate on the unit holds (readmodel.Plans). The row carries no window of its own: a client counts the round down to its plan gate’s deadline, from/api/gates. An open intake shows in the row’s line. The row offers the intents’ actions, then each action the snapshot offers. The variant without the mute is an overlay too (readmodel.Unmuted). A unit’s card is its plugin’s, and when an overlay set the row’s situation the card says why and on whom as the row does. A turn that ends, a mute or a round changes the row with no new report. - Changes.
Reportnotes the unit data of the units it wrote in the change log.Changed(units, topics)notes the plugin’s own units as changed in ways no snapshot carries, as unit data and under the host topics behindtopics, so the read model and the sweep read them again. - Facts.
psource.UnitFactsserves the automation engine each unit’s reported facts and its rev. A fact the plugin reports as a temporal value (Since,At,After, in calendar or working days) is aworkflow.Temporal, which the engine resolves with its clock whenever it reads it; one whose time is zero resolves to its type’s zero. The engine’sDueis the earliest deadline among the temporal facts its last sweep read, anAfterin working days counting forward withworkday.Add. The engine reads a unit’s rev before its facts; an item whose unit was reported again after it was planned, whether a sweep’s run, a window’s end, a gate’s release or an apply by hand, runs only when its workflow’swhenstill matches on the current facts under the same key, and is dropped otherwise (automation.Revisioner). The GitHub module the composition root builds gives each pull request a rev that grows with every change to its stored data the change log notes, and the store notes a reported snapshot as changed only when its rev moved, and asks GitHub nothing before a run; the facts of a pull request that read it alone are the plugin’s report, and the module adds those that read the host’s state (its hold, mute, turns, the nudge, the situation under the overlays and the round planner’s). - Bookkeeping. What the host does of its own around a sweep, which no unit reports, is the engine’s
Keep, apart from the unit set (automation.Engine.Keep): the pass a sweep’s evaluation and its withdrawals read the facts through, one read transaction of the store and a pass over every task whose reads of each table the facts share (Preparer), the start blocks and the session provider’s writes before a sweep (Refresher), the planning settlement after it (Settler), what acting on a unit frees (Gainer), the units of work a subject belongs to (Owner), the workspace snapshot its facts came from (Seener), the facts a workflow scopes (Scoper), those an event names (EventFacts), and the workflows the user stopped on a unit, which plan nothing there and whose drafts there are withdrawn (Skipper, overinternal/core/skips, whose skips the daemon alone writes and keeps in memory). A plugin’s units read as snapshot fields, this bookkeeping, or aNotify.
Who the viewer is in a source, and their teams, is what its plugin’s Presenter.Identity says; the read model’s base holds both per provider (readmodel.Base.Identify), and the daemon serves the forge’s from its Deps.Identity. The unit a checkout holds, which a turn by hand runs on when it names none, is what a source’s Ingester.Locate says of the directory: the built-in modules’ locators first, then each source plugin’s.
A plugin’s profile layer loads while the plugin is enabled: the plugin’s profile/, after the bundled profile, then your plugins/<id>/ beside your profile’s own files, after it (profile.Chain). A bundled plugin’s profile/ is also embedded in the binary (plugins.Profiles, registered by internal/plugins/bundled), and loads from there when no plugin directory on disk holds one; the Slack plugin’s workflows, its extraction prompt and brief schema, its tools’ texts and its eval fixtures live in plugins/slack/profile/. The GitHub plugin’s review, follow-up, address and nudge workflows and the review, address and nudge intents live in plugins/github/profile/, with its ui.toml, its wording and its nudge’s texts; an intent of a plugin’s layer loads as a bundled one, and your profile’s intents/<name>/ overrides it file by file. Each plugin’s ui.toml words its manifest’s ids. A plugin’s workflow is overridden like a bundled one: a directory of its name in a higher layer replaces its files one by one. A plugin’s layer words its tools in its tools.toml, and a tool of a plugin whose layer does not load needs no table. Services.Profile reads a path through the plugin’s layer and your plugins/<id>/ over it. A plugin’s manifest declares its predicates in [[source.predicates]], each with the facts the plugin hands when it asks, which its layer’s predicates.toml sets by name; the host compiles each over those facts alone as the profile loads (a key the manifest does not declare, or one that does not compile, such as one that reads a fact the plugin does not hand, is a problem that names it and leaves the lower layer’s in force), and Services.Holds answers, from one loaded profile, whether it holds over each set of facts the plugin hands, its temporal facts at the host’s clock; a set it cannot evaluate gets its own error, and the plugin keeps its default for that unit alone. The plugin evaluates no predicate: it asks with its units’ facts and acts on the verdict.
pkg/plugin/store is a plugin’s own SQLite store in its state directory, <state>/plugins/<id>/: it applies the plugin’s migrations and imports the import.db the host exported for it once.
The boundary
Section titled “The boundary”The core never imports a plugin package, and no plugin imports another. The core here is internal/core, internal/domain, internal/readmodel, internal/api, internal/web, internal/client, internal/catalog, internal/keys, internal/daemon, internal/turn, internal/brief and internal/transport; each plugin owns its packages:
| Plugin | Packages |
|---|---|
github | internal/adapter/source/github, internal/adapter/facts/github, internal/readmodel/prrow, internal/situation, internal/domain/github, internal/domain/pr, internal/domain/inventory, internal/github |
slack | plugins/slack, a process of its own whose packages import only the SDK under pkg/ (TestPluginsImportOnlyTheSDK); the host’s other rules do not read them |
Test imports count too, except a plugin’s own test fake: a core test builds its units as unit.Unit values, and a test that exercises a plugin’s behavior lives with the plugin, such as the read model’s tests over GitHub rows in internal/readmodel/prrow. No client names a plugin either: the cockpit’s scripts and page quote no plugin’s id, and the terminal and the menu bar spell no plugin’s id or provider constant (TestClientsNameNoPlugin); they read the provider and its capabilities from the data the daemon serves. Outside the core, a provider id appears only in its plugin’s packages, the vocabulary tables of internal/intent/builtin, internal/domain/unit, the codecs of the providers the core keeps, internal/cli/root.go, sources.go and slack*.go, and an adapter spells only its own provider’s id. The places that still name one are listed in coreProviderAllowlist (TestTheCoreNamesNoProvider) by file, declaration and literal, each with how many times it spells it and its reason, so a new spelling, even in a listed declaration, fails the rule; the list only shrinks. Code that tells a pull request from another unit by asking the GitHub plugin’s package (pr.RefOf, pr.Codec{}.Provider()) is listed the same way in pluginIdentityAllowlist (TestProviderIdentityThroughAPluginPackageOnlyShrinks). An edge that breaks the rule is listed in pluginDebt in internal/archtest/rules_plugins_test.go, and a test debt lets only the importer’s tests pass: the daemon’s tests serve the GitHub plugin’s rows and presenter in process, and the brief queue still briefs GitHub’s pull requests in process. An entry that no longer matches an import fails the test (TestCoreImportsNoPluginDomain), and the daemon’s own code imports no plugin package under any debt (TestDaemonImportsNoPluginDomain). Loop code is keyed by unit.Unit and names no pull request (TestLoopCodeImportsNoPR).
session, workspace and task are providers the core keeps for its own units. They have no plugin: a session’s and a workspace’s facts come from the turn registry and the workspace managers, through internal/adapter/facts/session and internal/adapter/facts/workspace. A workspace’s facts read the unit of work it holds through factworkspace.Holdings, keyed by unit.Unit: which unit, whether the workspace reviews it, its state and when it ended, as its source matches them (GitHub’s ghsource.Holdings, by the manager’s link or the head branch).
Actions and views
Section titled “Actions and views”The daemon reaches a plugin’s source logic through its Presenter: Action and View, a running plugin’s over the protocol, a compiled-in module’s in process (daemon.Deps.Presenters, built with what the daemon serves it beside the broker: its read model, row settings, open intakes, turn books and viewer).
- Actions. A unit action the daemon does not run itself and no intent names is its plugin’s, when the catalog declares it or the unit’s row offers it: the row’s offer and basis are checked as for any action, then
Actionruns withapi.PluginActionArgs(the request and the unit’s row without the user’s mute and pending round) and answersapi.PluginActionResult(the lines to report). GitHub’smuteandunmuteare such actions:muteputs a wake on the pull request’s tasks through the broker’sSetWake, until a time or until who each task waits on changes: itsWhennamestask.link_owesalone, and the host holds each task to its own value of it at mute time,unmutedrops it throughClearWake, and each records its event.SetWakedefers every open task of the unit under the wake, andClearWakebrings each back where the wake said; a unit no open task holds has no wake to set. A refusal is anapi.PluginErrorwith its status in process, and over the protocolErrUnfitanswers 400,ErrNotFound404 andErrUnavailable503. - Prepares. A role’s
preparenames an action of its plugin,action:<name>(GitHub’s nudge:action:nudge.prepare); its result’s inputs, normalized to the intent’s, fill the turn’s, or its refusal refuses the turn. - Views.
GETandPOST /api/plugins/<id>/views/<view>serve a plugin’s view, its query the request’s parameters or body (GitHub’sexploreandexplore.recent). The daemon asks a unit’s plugin for the views it serves of the unit:unit.rowfor a unit the read model does not list (a lapsed pull request, or one GitHub reads for a track;keptreads nothing from the source),unit.detailfor the timeline and the brief of the detail,unit.checksfor the failing checks’ steps, andtimingsfor the forge’s timings report. A disabled plugin’s unit views still answer, read only; its actions and its own views do not. - Bookkeeping. A compiled-in presenter that is a
daemon.Keeperruns on each new snapshot of the rows and after each sweep: GitHub’s records the situations that changed. - Hold. The user’s pause of a unit’s rounds is the host’s
holdoverride; a plugin reads it, with the repository and global pauses, asRoundState.Paused, and never sets it.
Rounds and effects
Section titled “Rounds and effects”A round launches from its plugin’s plan. The sweep evaluates workflows over the facts of each unit’s snapshot and never asks for a plan; when a turn.start step checks, runs or skips a round, the host calls its plugin’s Plan(unit, roundState) and takes the round the step names (first, follow-up or address for GitHub).
- The plan is the plugin’s. GitHub’s planner (
plugins/github/plan) runs in its plugin over its own pull requests and the host’s round state, books, events and workspace listing; it reports the facts its rounds read (pr.review.*,pr.first.*,pr.address.*,pr.fingerprint,pr.has_workspace) and its rank and board data in each snapshot, settles what planning leaves behind (a fingerprint a review stored without its head, the first-round skip a review workspace clears, a first round’s block and unblock events) throughSetMarksandRecordEvents, and reports again when the host notifies it of rounds, marks, overrides, turns, events, workspaces or the configuration, and at the instant a decision changes with time alone. - Round state is the host’s.
RoundStatecarries the unit’s marks, its last sent round, the agent home that asked for its rounds, its automatic rounds today and every unit’s, its host overrides, whether its rounds are paused, and the start block holding it (Blocked).Reader.RoundStateserves the same bookkeeping to a plugin from the stored state. Caps and settling stay host bookkeeping. - Marks are the plugin’s. A planned round names the marks the host writes in its round state when it sends the round (
OnSent) or the user skips it (OnSkipped); a value""removes a mark, so mark names, such as GitHub’sreviewed,addressed.*andfirst.skipped, stay the plugin’s. A round the plan holds as not due (NotDue, why) is never launched, but skipping it still writes itsOnSkipped. A start block keeps the blocked round’sOnSkipped, which dismissing the block writes. - The round goes out with what the plan says: its workspace, which the host looks up by its manager’s id, its head, whether its cause was already observed, and the turn slot facts of its turn (
TurnFacts), which win over the snapshot’s. - Start blocks are host mechanism. A round its agent could not start because the agent’s question is unanswered is blocked by a
round_blockedevent on its unit. Before each sweep plans, the host asks the unit’s agent whether its home or checkout answered the question, and recordsround_unblockedwhen it did, or once a day passed in a terminal whose agent home the loop cannot read. - Effects run in the plugin. A turn effect a plugin declares runs through its
ApplyEffectonce the turn went out, with the turn’s id, head and inputs, the route it went out on (session,askedorlaunch) and whether its agent took it; a draft effect a plugin declares, with the sent draft. A turn whose plugin effects no running plugin applies is refused before it goes out. Once it is out, the turn stays sent whatever its effects answer: an effect that fails, or that the plugin answers unsupported, is a health problem the daemon lists until a later turn of the unit applies it. GitHub’spr.reviewedmarks the reviews a launched review round covered once its agent took it,pr.addressedthe reviews so far and the head’s checks and conflict, andpr.nudgedrecords whom a nudge turn asked; a draft’spr.nudgedits publisher recorded as it posted. The host’s own turn effects are core:turn.guidance_handedhands the turn the guidance queued on its unit before the turn is built, andturn.round_sentrecords the round-sent event of a round a person sent. - The round commands reach the forge through its plugin. The review, address, nudge and turn commands ask the forge’s plugin, the running one’s client or GitHub’s module in process, for the viewer (
Identity) and run its declared commands for what needs the forge’s own tool: GitHub’sclone <owner>/<name> <path>clones a repository the managers andprojects_dirlack (RunCLI; an exit other than 0 fails the round with what the command wrote to stderr). A source failure the plugin callsErrUnavailable, such as GitHub’s rate limit, is a round start tried again on the next sweep. - The inventory commands read the pull request through the plugin too. Take-over, the commands by hand, remove and a held round read a pull request’s basics through GitHub’s
pr.basicsaction (Action, its answer the pull request as JSON), and the viewer throughIdentity. A take-over is the plugin’spr.adoptaction, with the basics the host read: the plugin keeps the adoption, forgets the reviews its address rounds covered and reports it, and the host keeps the reported adoption. The marks a held review run by hand and the removal’s first-round skip write are GitHub’s names the host still writes in process (ghsource.Mark*,ReviewedMarks). - No command reaches GitHub but through its plugin. No production file of
internal/cliimports GitHub’s client orgh’s runner, and the host holds no GitHub client and no credential: nothing underinternal/names GitHub’s API.doctorandinitask the plugin whetherghis installed and logged in (auth, exit 127 whenghis missing), andinitreads its scope suggestions from the plugin’sreviewed-repos <yyyy-mm-dd>andteams; with no plugin running, a command runs the plugin’s binary in CLI mode, and what only the running plugin answers (the viewer, a pull request’s basics) is unavailable. Explore’s searches, the failed steps of a pull request’s checks, one pull request read for its row and the API’s budget are the plugin’s views (explore,explore.recent,checks,candidate,budget); the host’s presenter adds what reads its own state, such as the row’s actions and the brief. The daemon’s health and the brief queue read the budget the plugin last named after each of its jobs. The unit a checkout holds is the plugin’sLocate. The marks a review round that went out writes areReviewedMarks. - An answer in the source closes an asked turn. An intent whose role reconciles with
my_reviewcloses its asked turn as done once the user’s event of a kind the manifest marksanswers_asked_turn(GitHub’sreview) is recorded after the turn’s last record, with a state itsverdictsturn into the record’s verdict.
Plugin storage
Section titled “Plugin storage”The plugins’ tables live in their own stores. Before its transaction, the version 7 migration writes <state>/plugins/<id>/import.db for each plugin in a transaction of its own that writes that file alone: the plugin’s tables with their definitions, checked row for row against the core’s counts, synced, then renamed from import.db.tmp. A WAL store commits apart from the files attached to it, so only a complete export carries the name, and a migration run again keeps it. The migration’s last step (v7PluginStores) then drops the tables from the core; a failure between the two leaves both copies (TestAnExportIsDurableBeforeTheCoreDropsItsTables). Each plugin imports its file on its first start (TestV7PluginStoresExportsAndDrops, TestCoreSchemaHasNoPluginTables). GitHub’s export holds pull_requests, pr_seen, pr_details, fetches, GitHub’s meta rows and its overrides rows; Slack’s holds segment_map, extraction_*, slack_* and extraction_briefs, the brief each Slack unit uses. The core keeps fetches (the log of its workspaces’ fetches), overrides and GitHub’s viewer in meta, which it still reads. Host tests seed pull requests through inventorytest, which creates its own tables in the test’s store. No query names a plugin’s table and a core table, and no transaction does (TestNoQueryJoinsPluginAndCoreTables, which reads a function literal passed to Write and a function taking a *Tx with the functions it hands the transaction to). A plugin’s store reaches the host’s data through the broker and writes its own tables apart, and a reader that needs both reads each apart.
| Owner | Tables |
|---|---|
| core | round_state, round_days, round_day_prs, round_day_notes, events, unit_marks, view_applied, turn_instructions, and every other table but the plugins’; of overrides, next_guidance and the user’s hold, which the host reads and writes |
| plugins | pull_requests, pr_seen, pr_details, the GitHub meta rows and the other columns of overrides (taken_over, tracked) for GitHub; slack_*, segment_map and extraction_* for Slack |
A store backup carries the plugins’ stores: before a migration, the daemon or store migrate copies each <state>/plugins/<id>/ store (plugin.db with its log, and an import.db not imported yet) to <backup>.plugins/<id>/, and store restore puts back the stores of the backup it restores, removing those the backup did not hold, after keeping the current ones beside the pre-restore copy (internal/backup). Retention reads which units a plugin still reports from its snapshots and leaves a plugin’s units alone until it has reported any.
The workspace link of a pull request (its ws_* columns) is the plugin’s: it links each workspace the host lists (Reader.Workspaces) to its pull request, by the unit the manager links or by its branch, and reports the link on the pull request’s row. A listing a manager could not finish (Listing.Partial) links and unlinks nothing.
- Events and marks are host data. The GitHub plugin’s store (
plugins/github/store) records the events it observes through the broker’sRecorder.RecordEventsbefore its own write, derived from a read of its rows, so a write that fails afterwards leaves the rows that derive the same events on the next save, and an ingest refetches a pull request whose row the failed write left old: the timeline kindsopened,push,review_requested,review_request_removed,review,comment,check,ready,draft,mergedandclosed, and thefollowedandunfollowedof a scope flip. It reads back throughHistory.Eventsthepushandmergedevents a fresh snapshot is compared with, the lastfollowedevent that dates an unfollow, and the kind of the last flip, so a flip the host already took from a write that then failed is not recorded twice. The host keeps a keyed event once per unit, kind and key, and an unkeyed one once per unit, kind and time, filling the actor and data a stored one lacks, with the point of the unit it happened at (Event.Anchor, theevents.anchorcolumn); it notesTopicSourceEvents. - Snapshot reads. The read model’s pull requests and their overrides, the brief queue’s open pull requests and their brief states, and the activity feed’s events and their pull requests are separate reads.
- The extraction puts each unit’s brief in the host through
Recorder.PutBrief, which keeps one extraction brief run per unit per UTC day (sqlite.DB.PutUnitBrief), before it saves its cursors and hints in its own store, so a crash between them reads the same messages again. A first turn’sBriefWantasks the extraction to read the unit now, past its quiet time, and each brief carries the brief intent’s extra fields, which the broker’sServices.Confighands the plugin (BriefIntent). - Segmenting a direct message asks the host to move the drafts on the thread-shaped unit of each stored message the new segments hold onto its segment, through
Gates.RehomeDrafts. - Retention reads the ended pull requests, the stored ones’ keys and the extraction cursors first, deletes the core’s rows in one transaction, then the plugins’ in another.
GitHub’s rows, cards and items derive from the plugin’s reports, under the host’s overlays and the GitHub parts of the row that read the host’s state (internal/readmodel/prrow, ghsource.Cards). The host keeps no copy of the plugin’s pull requests: the facts, the workspace holdings, the placements, a round’s send, the presenter, the brief queue, the commands and retention read each pull request from its snapshot (prrow.ReportedPRs, ghsource.ReportedInputs: the data’s row and full read), and the viewer and teams the plugin names (ghsource.KeepIdentity), once the plugin loaded and whenever it reports the viewer changed. A workspace a round creates or removes reaches the plugin as a workspaces notification, and the plugin relinks it. Host tests seed pull requests through inventorytest, from which ghreporttest derives the plugin’s reports.
The GitHub plugin binary
Section titled “The GitHub plugin binary”plugins/github holds the GitHub plugin and imports only the SDK and its own packages (TestPluginsImportOnlyTheSDK). make build places its binary, <name>-plugin-github, in bin/plugins/github/, and serve runs it as GitHub’s source as it trusts it. It holds GitHub’s API client and budget (plugins/github/api), the scope (plugins/github/scope), the collector (plugins/github/collect) and its store, and gh for its token. It publishes approved drafts (plugins/github/publish): a draft is a comment on its own pull request, a mention of another provider’s person becomes their name from the host’s items (Ledger.QueryItems), a failure after the post may have left wraps src.ErrMayHavePosted, which crosses the protocol as its own status, and a Publish call the plugin never answered (canceled, timed out, connection lost: src.ErrNoAnswer) is one too, so the outbox asks a person to confirm a retry; and a draft declaring pr.nudged records the nudged event on the host as it posts, so its ApplyEffect of a draft’s pr.nudged has nothing left to do. Its github-nudge rule (plugins/github/nudge), whose draft and prepare texts are the templates of its profile layer’s prompts/github-nudge.tmpl read through Services.Profile, proposes, through Planner.Proposals, a nudge on each stalled open pull request in scope that is not a draft or approved, whose host overrides hold no hold or mute and whose book holds no running review or address turn, and whose nudge nudge.prepare would not refuse; nudge.prepare refuses a draft pull request. It withdraws its nudge on each open pull request that is no longer stalled, such as one that became a draft, got approved or left scope, or whose wait ended. nudge.prepare is the plugin’s action: the daemon hands it the unit’s row without the mute, and it fills whom the nudge asks from it, or refuses a nudge within a day of the last one on the same wait.
- Ingest is the plugin’s. The manifest’s jobs
fastandslow(every_fromsweep.fastandsweep.slow) are GitHub’s ingestion job and slow sweep in the daemon, each anIngestcall at background priority; the host then seeds its own client’s budget with the plugin’s (github.budget). A pass reads the scope, the stubs and what moved in full into the plugin’s store, links the workspaces, and reports each pull request whose row changed: its snapshot’s data is the row, its detail and whether it is tracked, with a rev the store keeps above every rev it reported, which moves only when the snapshot, but its time, differs from the pull request’s last report since the plugin started, so a report that changed nothing leaves the pull request’s rev in the engine where it was. On a start, and after each slow sweep, the plugin applies the host’s retention to its own store (every row of a pull request merged or closed 400 days ago, fetches older than 30 days) and reports what it dropped gone, so it never reports it again; then, on a start, it reports every stored pull request, and is loading until it has. - Tracking has two phases.
TrackwithFollowunset refuses a new track past 50 and reads the pull request in full, storing and reporting nothing; the host admits the read, thenTrackwithFollowset refuses a pull request no longer open, stores the read, tracks it and reports it. A track or untrack records itstrackedoruntrackedevent on the host before the plugin writes it, once across a retry.pr.refreshreads a pull request in full into the store and reports it, whichfetchPRwaits on before it reads the host’s row. - It reports what it derives. Each snapshot carries the pull request’s situation from the source alone (
plugins/github/situation: who it waits on, since when, why, and anAftervalue in working days for when it stalls), the source part of its row’s payload (author, repository, head, scope reasons, workspace, the overrides the plugin keeps, created at, changed files, checks, the last human), the views and facets that read the pull request (where, the repository, author, workspace status, checks, scope, opened), its card, its items, the link signalsstalled,in_loopandready, and its workflow facts that read it alone, a time-dependent one as a temporal value (plugins/github/row). A reviewer’s comment waits on the author’s reply until the author answers it, unless that reviewer approved after it, or the pull request has review threads, every one resolved, and someone approved the head after it; a dismissed review asks for nothing; your review requested by name with none of yours since waits on you before any reply owed to someone else. A situation reads the host’s events throughHistory.Events, the loop’s (a round sent, held or blocked, a review to address, a nudge) and the plugin’s, and throughReader.Bookswhether an address turn ran since a review to address and whether a turn asks the user something no answer reached since its record (its stateaskedand noAnsweredAtafter it; a withdrawn turn reads as never sent), which waits on you whatever the author owes. The plugin reports a pull request again when the host notifies it of its events or turns (Notify(events),Notify(turns)), of the viewer or the configuration, which it reads again, and every pull request again at the instant a report changes with time alone: a situation stalls, a held round’s local day ends, a day of silence passes. The host lays its own state over the report: the run, the mute and the pending round, the payload’s mute, brief, intake and turns, the views, flags and facets that read the row’s situation under them, and the actions; a card’s situation signal carries the row’s kind, and, while the row is stalled, astallednote that counts from when its situation began. The plugin’s card signals are codes:checks(passing,pending,failing,failing_namedwith the failing checks’names),review(approved; changes asked show only as thechangessituation, which no review signal repeats),merge(conflicts) andsituation(the kind), which its layer’sui.toml[signals]words; thesituationsignal reads its values as[situations]words them (values_from = "situations"). Every item of a pull request carries the pull request as its task key, so its items are the asks of its one task (items.Item.TaskKey,task.Reconcile); someone else’s pull request whose newest verdict of yours approves it, that does not need you and that you do not track, has no item, since merging it is its author’s; while the row’s situation is the host’s run overlay, its item is the host’sgh:follow, waiting on the loop or on you. A pending round, and a round the daily cap held (round.HeldSubject), is a plan gate, and a blocked one a trust gate, never an item; a pull request task’s notification is its row’s, keyed on the task and on the gate it stands for. - The rest it serves.
Resolvetakes a pull request’s page or key,Locateis the pull request of a checkout’s branch asgh pr viewreads it in that directory,Checkoutthe stored head branch, head commit, base and fork.Identityis the stored viewer, read from GitHub’s API once started, and the scope teams the viewer is in.Notify(workspaces)links the workspaces again. The plugin reads[plugins.github], which names the reviewers a row never waits on (automated_reviewers,address_skip_reviewers) and when a situation stalls (stalled), the planner’s settings (owners,trusted_authors, theaddress_*switches,ignore_checks,followups_without_workspace,address_without_workspace), and the host’ssweep,views,projectsandbriefkeys (config_reads), for the board columns that wait on the user or are done, the projects that override the planner’s settings, and the diff a brief reads. A turn’s wording is its profile layer’swording.toml, read throughServices.Profile. Before it derives the situations of a batch of pull requests, it asksServices.Holdswhether itsreviewer_requiredpredicate holds over each one’s facts (its layer’spredicates.tomlsets it totrue): where it does not, a pull request nobody was asked to review is neverno_reviewer, so it asks no one to pick a reviewer and draws no nudge for one, and with checks green or pending and no conflict it ismerge, as an approved one is; failing checks staychecksand a conflictconflicts. When the host cannot answer, every pull request requires a reviewer. - Its calls are metered under the host’s scope. A request to the plugin carries the name of the host’s meter scope (
psource.Meters.Call), and the plugin meters every GitHub request it makes throughServices.Meterunder it; a sample that reaches the host after its scope ended lands in the registry under its name, and a sample with points and no duration is the cost a response reported.
Its store (plugins/github/store, over pkg/plugin/store) is <state>/plugins/github/plugin.db: pull_requests, pr_seen, pr_details, fetches, meta and overrides, the last keyed by the unit key with the plugin’s columns only (taken_over, tracked). Its times use the host’s layout, so imported rows read and sort as its own. On its first start it imports import.db: the PR tables and meta whole, by the columns both share, and of overrides the GitHub rows that set a plugin column. The export decides which meta rows are GitHub’s (the viewer under viewer:github, the sweeps’ watermarks, the budget, the resolved scope, Explore’s recent list).
The Slack plugin
Section titled “The Slack plugin”Slack runs as its own process (plugins/slack, the binary <name>-plugin-slack), and its module in the daemon relays to it (internal/cli/serve_slack.go): the manifest’s first job is the ingestion job and the others daemon jobs named <job>:slack, each an Ingest call; rows, cards and workflow facts are its snapshots (readmodel.Reported, psource.SettledFacts); tracking, resolving an address, the viewer, the health view, publishing, payloads, placement, briefs and the unit.closed effect each call the running plugin, which answers ErrUnavailable while it is down. The links a snapshot reports are the unit’s facts links, resolved with every manifest’s codec. The broker serves it the host’s ledger, agent (<state>/plugins/slack/agent), profile layer, sessions, tasks, outbox and briefs (internal/cli/serve_hosted.go).
Its MCP tools are the manifest’s (mcp.Server.Plugins): the server lists each with its manifest input, describes it and words its answers from the layered tools.toml, words a fix_these answer’s problems one per line, and passes the loop’s session id. slack login, logout and status run the binary in CLI mode (RunCLI, no broker), as serve trusts it, with the configuration the broker would give it; the host reads the app token, hidden at a terminal. slack send, follow and unfollow ask the running plugin through POST /api/plugins/{id}/actions/{action}, which passes the plugin the actor of the request (ActionRequest.Actor: agent when it names a session, else person); the Slack plugin runs these actions for the person only. slack eval runs in the running plugin through POST /api/plugins/{id}/run: the daemon calls the plugin’s RunCLI and streams what it writes to the CLI as it writes it, one JSON line a chunk, the last its exit; a request that names a session runs none. The running Slack plugin runs only eval there, and the binary in CLI mode every command but eval.
A tool the manifest marks posts = "now" runs only for no session or a live one, which the host checks before the plugin runs (psource.Tools); each post is recorded, through the host’s RecordSent, as a sent draft whose origin is the session’s turn, or the session when the call names no session or the session has no turn, with a draft gate already approved by the agent, its rule the tool. slack_draft from a session with no turn drafts on its message target’s unit with the session as its origin, no items and no effects, and answers proposed, approved or dry_run from the delivery state its gate left the draft in (held, queued or dropped, GateRef.State).
The plugin protocol
Section titled “The plugin protocol”pkg/plugin is the SDK of plugins that run as their own processes, through hashicorp/go-plugin over gRPC; the daemon runs the Slack plugin this way. It holds what every plugin shares, with its protocol under proto/:
- the handshake and the protocol versions (
VersionedPlugins, protocol 1), negotiated when the host starts a plugin; - the lifecycle every kind answers:
Health,Start,Stop, andNotify(units, topics), which tells a kind that host state it reads changed, each topic one of the namesplugin.Topicdeclares (rounds,gates,marks, …); Serve, which serves one implementation per kind from one binary, the kind being go-plugin’s plugin name;- the logger, which writes JSON lines to stderr for the host to relay; a plugin writes nothing to stdout, which carries the handshake (
TestPluginWritesNothingToStdout); - the exit when the parent dies: the host passes its pid (
plugin.ParentEnv), and a plugin polls its parent process id and exits once it differs, even when the host died before the plugin served, with no reattach.
A plugin’s directory holds a plugin.toml (pkg/plugin/manifest) with its id, the kinds its binary serves, the protocol it speaks, and one table per kind, which the kind’s SDK package decodes. pkg/plugin/plugintest serves kinds in-process, for tests on either side. Nothing under pkg/ imports internal/ (TestTheSDKImportsNoInternal), and the host passes its product name through the environment (plugin.BrandEnv).
The source kind
Section titled “The source kind”pkg/plugin/source is the SDK of the source kind, with its protocol in proto/wloop/source/v1. Its SourceService is the union of capability interfaces of at most five methods each (TestEveryCapabilityHasAtMostFiveMethods); a plugin implements source.Source and the ones it has, source.New adapts it, and every other call answers ErrUnsupported:
| Interface | Methods |
|---|---|
Ingester | Ingest (one run of a manifest job, which may say when the job next has work: the host runs it then, and on its interval otherwise), Track (a read, then a follow once the host admits it), Untrack, Resolve, Locate (the unit a checkout holds) |
Presenter | Action, View, Identity |
Publisher | Validate, Where, Place, Publish, Verify |
Lander | Landed, Checkout, WriteState |
Briefer | BriefInput, BriefRevision, BriefDescribe, BriefStale, and BriefWant, which a first turn on the unit asks: true when the plugin writes a brief through PutBrief, which keeps one run per unit a day, false when nothing of the unit is left to read |
Planner | Plan(unit, roundState), which the host calls when it launches or skips a round and which names the rounds with the marks to write when one is sent or skipped; Payload; ApplyEffect; Proposals; Placement |
Tooler | CallTool(tool, args, session, turn), which names one of the tool’s manifest answers; the host words it from the layered tools.toml |
CLIRunner | RunCLI, which streams the command’s stdout and stderr to the host as it writes them, then its exit code |
The host serves the plugin’s calls back on the connection’s broker (source.ServeHost, whose id goes in StartRequest.HostBroker), through HostService, split the same way: Reporter (Report, Changed), Ledger (ApplyItems, QueryItems), Reader (Tasks, Books, RoundState, Workspaces, Session), Gates (OpenGate, RecordSent, RehomeDrafts, SetOverride), Recorder (RecordEvents, SetMarks, PutBrief), Wakes (SetWake, ClearWake), Services (Ask, Meter, Config, Profile, Holds), History (Events) and Jobs (WakeJob, which runs one of the plugin’s manifest jobs after its ingestion job at a time, keeping an earlier wake the host already holds). Config also carries the brief intent the host resolves for the plugin’s units from the whole loaded profile: its extra fields’ schema and versions and its prompts. No broker method takes the loop lock, which the host may hold while it calls the plugin.
A plugin proposes drafts through OpenGate, or through Planner.Proposals, which the proposals job reads for the plugin’s own rules (psource.Proposals); the host’s approval policy holds or releases them. Proposals also answers the withdrawals of the plugin’s rules (source.Withdrawal: a unit and a rule), and the job discards, as moot, the rule’s held auto draft on each unit a withdrawal names; a draft the answer only leaves out stays held, since a rule leaves one out for a while (a running turn, a hold) as well as for good. With an MCP session, the host sets the draft’s origin, task and default items from the session’s book and refuses effects the session’s intent does not allow. The host ignores the delivery a plugin’s draft names and derives every draft’s delivery from its kind through the manifest: the delivery of its [[source.draft_kinds]] entry, else default_delivery (psource.Publisher.Delivers). GitHub’s manifest posts every draft as a comment but a react (reaction) or an edit (edit), both of which its publisher refuses. OpenGate answers the draft’s state once the policy saw it (GateRef.State). For a live session with no turn, the draft is on one of the plugin’s own units, answers no items, declares no effects and has the session as its origin. RecordSent takes only a post through a tool the manifest marks posts = "now", of a live session or of no session. SetOverride is a compare-and-set over the unit’s host overrides, which RoundState carries back.
In CLI mode the host runs the plugin binary for one RunCLI without Start: the plugin gets its configuration in the request, has no broker and opens no store.
A plugin reports a unit as a Snapshot: its data as JSON the plugin’s profile layer reads, its situation (kind, who it waits on, since when, why, and when it stalls; why as a sentence agents and logs read, and as a reason key with its values, which its layer’s ui.toml [reasons] words for people in each locale and every client shows), its card (each signal a key and a value code with the params its words fill and notes in their own tone, in the SDK’s CardSignal.Detail, which the host’s card flattens into the signal’s params and notes; its layer’s ui.toml [signals] words them for people in each locale, served in the catalog’s card_signals, and a code with no words shows as itself; a note with a since reads, at each read of the card, its days param as the whole working days since then, at least one, Saturdays and Sundays counted in UTC, so a stored card’s count moves with the clock), items, link signals, who it owes, offered actions, extraction health, the contexts and due date it writes, the links its source holds (which the host resolves with every manifest’s codec), its facet values and views, its facts and a rev. The host overlays the situation and the card with its own state: a run on the unit, a mute, a pending round. A fact is a literal or a temporal value the host resolves with its clock when it reads it: Since(t) is a duration, At(t) is true from t on, and After(t, threshold, unit) is true once more than threshold has passed since t, counted in calendar time or working days. A temporal value of a zero time resolves to its type’s zero and has no deadline.
Nothing the host’s context carries crosses in a context: the call’s priority and meter scope travel as a Call field of every request (source.WithCall on the host, source.CallOf in the plugin), a tool call carries its MCP session and turn, Ask its tier, cost cap and meter scope, Reader.Session gives the session’s draft rules, and a payload request carries the last verdict, the planned round, the agent, why the brief is missing, the items and the task. pkg/plugin/source/sourcetest runs a source in-process against a host.
A source’s plugin.toml has a [source] table: the codec (key and address templates, so a disabled plugin’s keys still parse), the vocabulary it adds to the host’s tables (slot, facts with types, effects, event kinds and the link event each resolves, roles naming host mechanisms, brief subject, row kinds, deliveries and whether each posts to people, draft kinds, link signals, task kinds, ranked situations, the core steps its units take), the jobs the host runs through Ingest (with an interval read from the configuration), the configuration keys it reads, the host topics it is notified of, the CLI commands, the MCP tools with their argument schemas and answers, and its views, facets, menu bar, keys and web assets. Plugin manifest lists every key.
Adding a source
Section titled “Adding a source”A source plugin is one directory, and adding one edits nothing outside it:
- A
mainpackage that serves itssource.Sourcewithplugin.Serveand imports only the SDK underpkg/and its own packages (TestPluginsImportOnlyTheSDK); its store, if it keeps one, opens throughpkg/plugin/storein the state directoryStarthands it. - Its
plugin.toml, whose[source]table declares its codec, facts, deliveries, signals, task kinds, tools and the rest of its vocabulary, which the host merges into its tables before the configuration and the profile load. - Its profile layer under
profile/: the workflows and intents over its units, and the words of its tools and answers intools.toml. - Its binary,
<name>-plugin-<id>, beside the manifest. The user’splugins/<id>/directory under the configuration directory, or the bundled one a release installs, holds the directory, and[plugins.<id>] trustor an install’s recorded checksum lets the host run it.
A plugin’s rows are listed in the cockpit and the terminal through the lens (see Facets). Its rows emit the where facet for the place their unit lives (a repository, a channel), which the lens, a task holding the unit and the unit column all read; a view sorts its unit column by where. A facet id is one field across plugins and the profile, which must agree on its mechanics. The words of a view’s own order are [views.*] default_sort in the plugin’s ui.toml.
The host’s code never imports a plugin’s package (TestHostImportsNoPlugin): only a test-only package, whose path ends in test and which only tests reach, such as ghreporttest, runs a plugin’s code for the host’s tests. internal/archtest/testdata/standin is such a plugin: a fact, a temporal fact, a delivery, a signal, a task kind, a tool with its answers, its own store and a profile layer with a workflow. TestAStandInSourcePluginRunsWithNoEditOutsideItsDirectory checks that no other file names it, places it in an isolated daemon’s plugins directory, and runs it through the real launcher.
internal/plugins/source/fakesource, a plugin’s reports of chat threads that nothing registers, shows the minimum a reporting plugin’s rows need.