Plugin manager — declarative sources, build-on-boot, use-package for WASM

Design fragment. Contracts, data model, rationale, rejected alternatives, paramount-goal alignment. Slice sequencing lives in the slice plan (../operations/slice-plans/archive/plugin-loader.md). Sibling fragments: plugin-host.md (the seam host + capability model), config-and-init.md (init.rs lifecycle + the on-plugin-loaded deferred-config pattern the enablement reuses), plugin-auto-pair.md (the first consumer).

Status: 📝 designed, not built. A redesign of the existing loader (lattice-plugin-loader) + :plugins view (lattice-plugin-manager) to add what they lack: where a plugin comes from (a git/local source) and how a missing artifact is produced (build-on-boot). The load / unload / reload / discovery machinery and the interactive :plugins view are unchanged — this adds a resolve→build→cache layer in front of them, plus a require declaration API (use-package for WASM).

1. Why

Today a plugin must already exist as a built .wasm under ~/.config/lattice/plugins/<name>/, placed there by hand. There is no notion of where it came from or how to (re)produce it. The user wants the emacs use-package / lazy.nvim model: declare a plugin and its source (a git repo or a local directory); the editor builds it on first boot if the compiled artifact is missing, and just loads it when it's present. Plugins ship and version independently of the editor binary.

Explicitly rejected: include_bytes! embedding. Compiling plugin bytes into the lattice binary couples plugin releases to editor releases and bloats the binary — plugins ship separately (§11). The artifact cache is the filesystem (~/.config/lattice/plugins/<name>/<name>.wasm), not the binary.

2. What exists vs. what's new

Reused, unchanged (lattice-plugin-loader): discover (scan the plugins tree), discover_one (read one dir's plugin.toml + sole .wasm), load_discovered / load_path (compile + instantiate the seams), unload, reload, the health/quarantine plumbing, and the whole :plugins interactive view (lattice-plugin-manager: reload/unload/describe chords). These operate on a resolved directory containing a built .wasm — that contract is preserved.

New — the manager layer that produces that directory:

  1. A plugin sourceLocal(path) or Git{url, rev} (or Prebuilt{url}, §7) — resolved to a source directory on disk.
  2. A build service — a source dir (a wasm32-wasip2 component cargo project) → the cached <plugins>/<name>/<name>.wasm. Builds only when the cache is missing or stale (§5); off the boot thread; graceful when the toolchain is absent (§7).
  3. A require declaration API — init.rs (or a TOML list, §3) declares (name, source, …); the host resolves → builds → loads → optionally enables.

The pipeline is a prefix on the existing load path:

declare(name, source)  →  resolve source dir  →  build if <name>.wasm missing/stale
                          →  [existing]  discover_one(cache dir)  →  load_discovered  →  enable?

Recommendation: programmatic, in init.rs, via an imported host function — use-package is programmatic (conditional loading, per-plugin config), and it fits the standing principle logic stays code; static settings stay declarative (the user's init.rs-is-config model). The bootstrapping subtlety (§6) is real but resolvable.

// A new `plugin-manager` seam, imported by the init world (and any config plugin).
interface plugin-manager {
    require: func(spec: plugin-spec);

    record plugin-spec {
        name: string,
        source: plugin-source,
        // use-package sugar: enable this minor mode once the plugin loads
        // (desugars to the CI.5 on-plugin-loaded → enable-mode handler; the host
        // never learns the mode-id statically — feedback_mode_owns_its_surface).
        enable-mode: option<string>,
        // pin a build: skip the rebuild-on-change check, only build if absent.
        pinned: bool,
    }
    variant plugin-source {
        local(string),          // a directory path (built in place, not copied)
        git(git-source),
        prebuilt(string),       // a URL to a ready .wasm component (§7)
    }
    record git-source { url: string, rev: option<string> }
}

init.rs body (the shipped default, §9):

require(PluginSpec {
    name: "auto-pair".into(),
    source: Source::Local(bundled_plugin_dir("auto-pair")), // → Git/Prebuilt when shipped
    enable_mode: Some("auto-pair-mode".into()),
    pinned: false,
});

The host records each require during the init.rs register export (the register-grammar / register-events precedent — no work on the export), then drains the recorded specs after init.rs returns and runs the pipeline (§2) off-thread. A plugin's contributions appear a frame or two after boot — the eventual-consistency the UX contract already permits for plugin cold-start.

require is the user-plugin surface. Core plugins (the ones that ship with lattice) are NOT required — they are discovered from a runtime root and enabled by a config gate (§7). require exists for plugins the user declares with a source; a fresh editor with no user init.rs still gets its core plugins.

Rejected: a TOML plugin list. [[plugin]] name=… source=… is simpler (no bootstrapping) but not programmable — no when(cfg), no per-plugin setup. It loses the use-package expressiveness the user asked for. A TOML list may land later as declarative sugar over the same require for the trivial case, but the require API is canonical.

4. The source model + cache layout

  • Local(path) — the source dir is path (a cargo project). Built in place (the artifact still caches under <plugins>/<name>/; the source tree is not copied). The dev + monorepo case; auto-pair uses this today (§9).
  • Git{url, rev} — cloned/fetched into a source cache ~/.config/lattice/cache/sources/<name>/ (checked out at rev, default the remote head), then built like Local. A re-require fetches + rebuilds only when the resolved rev differs from the cached one (or pinned skips the fetch).

Who may touch the network — RefreshPolicy

Resolving a git source is asked for by two callers that want opposite things, so the resolver takes which one is asking:

policycallersan already-cloned, unpinned checkout
UseCacheboot, rebuildused as it stands — no git at all
Updateupdate / update-all, and nothing elsefetch, then reset --hard FETCH_HEAD

A pinned rev ignores the policy: the pin is the answer to "which commit", so update on a pinned plugin does nothing and costs nothing, while a CHANGED pin (the user edited init.rs) still fetches and moves under either.

reset --hard rather than merge or pull, because the checkout is a cache the editor owns and never a tree the user edits: the tracked head is simply what it should contain, and a merge could conflict with nobody there to resolve it.

Corrected 2026-09-14. Every re-resolve used to fetch an unpinned checkout and then stop — the checkout step ran only under a pin — so the objects arrived and local HEAD never moved. An unpinned plugin was frozen at the commit it was first cloned at for the life of the checkout, while paying a network round trip on every boot to stay that way. Both halves are wrong and they are each other's fix: UseCache drops the pointless fetch, Update follows its fetch with the move that makes it mean something.

  • Prebuilt{url} — download the .wasm straight into the cache; no build, no toolchain (§7).

There are two plugin roots (§7): the user root below (the require+build cache) and the runtime root that ships with lattice (core plugins, prebuilt).

<runtime>/plugins/<name>/          # CORE root — ships with lattice, prebuilt, read-only (§7)
    plugin.toml
    <name>.wasm

~/.config/lattice/plugins/<name>/  # USER root — the require/build cache
    plugin.toml           # the manifest (from source, or synthesized for Prebuilt)
    <name>.wasm           # the built/downloaded component  ← discover_one reads this
    .build-stamp          # source rev / mtime the artifact was built from (§5)
~/.config/lattice/cache/sources/<name>/   # git checkouts (Git sources only)

Once the artifact + manifest are in either root, the existing discovery / load path takes over unchanged. A plugin placed there by hand (no require) still loads exactly as today — the manager layer is additive.

5. The build service — build only when stale, off-thread, never a boot stall

  • Input: a source dir. Output: the cached <name>.wasm.
  • Build: invoke the component toolchain (cargo build --target wasm32-wasip2 --release + the component step the plugin build scripts already use) via spawn_blockingnever on the boot or actor thread (a cold build is seconds-to-minutes; boot must not wait). The plugin loads when the build completes (eventual consistency); a build in flight shows in the :plugins view (§8).
  • Staleness: the .build-stamp records the source rev (Git) or a content hash / max-mtime (Local) the artifact was built from. Build iff the artifact is missing OR the stamp differs (and not pinned). So a warm boot with an unchanged source is a pure load — no rebuild, the user's core requirement.
  • Graceful failure (four-artefact clause): a build failure (no toolchain, compile error, clone failure) is a logged skip that surfaces in :plugins and *messages* — never a failed boot, never a panic. If a stale rebuild fails but a previous artifact exists, the old artifact keeps loading (a broken new revision doesn't take the plugin down).

5b. The gap: the on-disk scan never asks whether its artifact is current

Status: designed, unbuilt. Slice plan: ../operations/slice-plans/scan-staleness.md.

§5's staleness check is real, but only two callers reach it: init.rs (build_init_if_needed) and the plugins init.rs requires (install_all). The third load path — the boot scan of ~/.config/lattice/plugins/ (discover_and_load(dir, UserInstalled)) — loads whatever <name>.wasm is staged beside the manifest and never consults the stamp. Core plugins skip the check too, but correctly: they ship prebuilt and SourceRecord::Bundled reports is_buildable() == false, so there is nothing to build from.

Who lands in the gap: a plugin installed by an earlier boot's require and since dropped from the require list; one placed by hand; and — the case that matters — every plugin on disk when init.rs itself fails to load. require never runs then, so nothing is checked, and the scan loads each stale artifact in turn.

That is exactly the reported failure's shape. Had the scan checked stamps, org would have rebuilt itself against the current ABI on the next boot even with a dead init.wasm, and the whole knot would have untied without lattice --wit-sync. WT.3 already put the ABI fingerprint in the stamp; this section is about a third caller learning to read it.

The constraint that shapes the answer

resolve_git reaches the network unless a rev is pinned and already checked out (resolve.rs:183) — an unpinned Git plugin fetches on every install_all. That cost is defensible for plugins the user declared; paying it for every directory that happens to be on disk would turn a pure-load boot into a network-dependent one, and an offline boot into a slow one. Any option that re-resolves sources at scan time is disqualified by that alone.

(a) Run the full resolve+build pipeline from the scan

UX (higher court): offline or slow-network boots get slower, and a plugin the user never declared can now trigger a fetch. That is a visible regression on the common path to fix an uncommon one. Paramount goals: protects #2; sacrifices #1 in the sense that matters here — not per-keystroke, but boot-to-usable under a bad network. Heuristic #1: rejected on merit, not size: it makes boot depend on the network for plugins that today need nothing.

(b) Extend install_all's spec list with the on-disk plugins

Same network cost as (a), by the same code path, plus it conflates declared with foundRequiredSpec means "init.rs asked for this", and manufacturing specs for directories nobody declared makes that type lie. Heuristic #1: reuse for its own sake, buying the disqualifying cost.

(c) Check the stamp against the source already on disk; build without resolving ✅

Read the .source marker and .build-stamp beside the staged artifact; if the recorded source is buildable and its directory is present locally, compare Stamp::current(source_dir) and call build_plugin directly on a mismatch. No resolver, no network, no clone. When the source is not local (a cleaned git cache), fall through to (d): report it, load what is there, build nothing.

UX (higher court): the silent-stale case disappears for every plugin whose source is on the machine, including when init.rs is dead — the case that cost a debugging session. No new boot-time network, so nothing regresses for users who have no stale plugins, which is nearly everyone nearly always. Paramount goals: protects #2 (a plugin that should be here is here, and current). #1 untouched — the check is two small file reads per plugin on a task that is already off the boot thread, and the build it may trigger is the spawn_blocking one §5 already describes. Heuristic #1 (long-term fit): the genuinely-better design because it separates two things (a) and (b) conflate — resolving a source (network, declared intent) and building one (local, mechanical). Only the second is needed to answer "is this artifact current", and doing only that is what keeps boot offline-clean. Heuristic #2: anchored on #2 and on the reported failure, not on how another editor refreshes extensions. Heuristic #3: (d) is the honest floor and is retained as this option's fallback rather than as a rival. Heuristic #6: no new crate — lattice-plugin-loader already owns the scan, the stamp and the build service.

(d) Report only — mark it stale, never build

UX: the user sees stale in :plugins and presses b. Better than silence, worse than working. Keeps loading code the stamp says is wrong. Heuristic #1: correct as (c)'s fallback for the no-local-source case, insufficient as the whole answer — "we told you" is not the bar the reported failure sets.

Costs and open questions, stated rather than discovered later

  • A boot may now build where it previously just loaded. Off-thread, and :plugins already shows building… (PM.8b) — but the plugin's contributions land seconds-to-minutes later than they used to on the boot after a source edit or an editor upgrade. That is the intended trade and should be said out loud in the release notes.
  • Pinning has no expression here. pinned is a RequiredSpec field, and a scanned plugin has no spec. Either a marker file beside the artifact carries it or pinning stays a require-only feature. Undecided; the slice must pick one before it ships, because "the editor rebuilt the artifact I deliberately froze" is a worse failure than the one being fixed.
  • Interaction with --wit-sync is additive, not redundant. (c) repairs a plugin whose source is present. --wit-sync repairs the wit/ inside that source. A dead init.wasm with no source directory still needs the command.

6. Bootstrapping — init.rs is built by the same service

init.rs is itself a wasm32-wasip2 component. Today it must be pre-built. Under this design the same build service builds init.rs first (its source dir is ~/.config/lattice/init/, a cargo project), then loads it, then drains its require specs. One build primitive, two callers (init + every plugin). This removes the "user must run cargo by hand" step for init.rs too — the editor rebuilds init.rs on change (the PL8.D.4 init-watcher already re-loads a rebuilt init.wasm; here the rebuild becomes the editor's job).

Ordering (extends config-and-init.md §3): discover + load core plugins (runtime root, §7) → build+load init.rs → drain its requires → resolve/build/load each user plugin → fire plugin-loaded → the <plugin>.enabled gate (§7) enables declared modes, and init.rs's on-plugin-loaded handlers run. init.rs's subscriptions are live before any user plugin loads, so a deferred handler can't miss its plugin; a core plugin's mode enables from its own gate independent of any init.rs.

7. Core plugins — how they ship (the runtime root, prebuilt, discovered)

Core plugins (auto-pair, and future bundled ones) ship with lattice yet must work on a fresh machine with no toolchain and no network on first boot. The model — the batteries-included pattern every editor uses (VSCode's built-in extensions, Helix's runtime/, nvim's $VIMRUNTIME) — is two plugin roots:

  • Runtime root — core plugins, prebuilt, read-only. Ships with the distribution as .wasm files (NOT embedded — §11), discovered at boot at the Bundled tier (pre-granted). No build, no network, no toolchain: the artifacts are already there.
  • User root — ~/.config/lattice/plugins/. The require+build cache (§2–§5): Git/Local source builds and Prebuilt downloads land here, UserInstalled tier.

Finding the runtime root — a search path, not one fragile exe-relative path (the reason a naive core-plugins/-next-to-the-exe was rejected, §11; a search path is the standard fix):

$LATTICE_RUNTIME                             (explicit override)
  → <compile-time install prefix>/share/lattice   (distro packagers set the prefix at build)
  → <exe-dir>/../share/lattice                (relocatable tarball / .app bundle)
  → <workspace>/runtime                       (dev, running from target/)

First hit wins. Boot discovers <runtime>/plugins/ in addition to the user root — the existing discovery path, pointed at a second dir.

How the prebuilt core wasm is made — at lattice-build time, not boot, not embedded. A packaging step (cargo xtask build-core-plugins, or release CI) runs the same wasm32-wasip2 component build the plugin build.rs already does for each plugins/<name>/, staging the components into the runtime root. Dev points the search path at the workspace's built artifacts. So a core plugin is a file that ships beside the binary — never include_bytes!, never built on the user's first boot.

Enablement — a per-plugin config gate (decision (i), 2026-07-20). A discovered core plugin's minor mode does not self-activate (the CI.3 available-but-off rule stands — a mode never forces itself on). Instead the plugin declares its default mode in its manifest (default_mode = "auto-pair-mode"), and the manager auto-registers a bool option <plugin-id>.enabled (default true) that gates it: on load (and on any change to the option) the manager enables / disables the declared mode via the CI.4 ModeEnablementRequested path. So a fresh editor auto-pairs out of the box, :set auto-pair.enabled=false turns it off, and the host never learns a mode-id statically — the plugin's manifest names it (the mode-ownership rule holds). This is the general mechanism for any plugin (core or user) that declares a default mode; the init.rs on-plugin-loaded → enable-mode handler (CI.5) remains for programmatic / conditional enablement of modes a plugin did not declare as default.

The toolchain reality — only user Git/Local sources need it. Build-on-boot needs rustup + wasm32-wasip2, but that's only for a user who declares a source build. Core plugins are prebuilt (above), so a no-Rust user still gets them. A user Git/Local spec with no toolchain degrades to "unavailable — install the toolchain, or the plugin offers a Prebuilt" (logged, surfaced in :plugins), never a hard failure; Prebuilt{url} (a released .wasm, downloaded

  • cached, no build) is the no-toolchain path for user plugins too.

8. The :plugins view — source + build status

The existing view (lattice-plugin-manager) gains columns/state for the new lifecycle: source (local / git@rev / prebuilt), build state (cached / building… / build-failed / stale), and a build/rebuild chord (force a rebuild of the plugin under the cursor). Async-build progress surfaces via the buffer's headerline (the async-buffer-status-in-headerline rule), not a status line. Reload already re-instantiates; a new "rebuild" is reload + a forced build.

8.1 Scope — lowercase is the row, uppercase is every row

Three verbs do strictly increasing amounts of work, and each has both scopes:

rowallwhat it does
reloadrRre-instantiate the artifact already on disk
rebuildbBcompile that artifact from the source you have
updateuUfetch a newer source first, then rebuild
unload / cleanxXdrop the instance / remove unclaimed staged dirs

The lowercase-row / uppercase-all split is the idiom the view already taught with t / T; reusing it beats inventing a second convention inside one buffer. The all-scope keys shadow vim's u, R and U here, which costs nothing — the buffer is read-only, so there is no edit for undo to reverse and no text for Replace to overwrite — and a mode-layer binding is scoped to plugins-mode-active buffers, so vim's meanings are untouched elsewhere.

The ex-command peers are :plugin-update <name>, :plugin-rebuild-all, :plugin-reload-all, :plugin-update-all and :plugin-clean[!]. The bulk forms are spelled out rather than reached by omitting :plugin-update's argument: a command that rebuilds the whole editor should not be one typo away.

8.2 Bulk runs are sequential, and report per leg

Sequential is the design, not a simplification. Concurrency reads as the obvious win and is wrong three times over: cargo already saturates the machine, so N of them contend rather than parallelise (and can exhaust the disk — a full build tree is tens of gigabytes); every leg finishes by reloading, which mutates the shared registries by copy-on-write RCU, so overlapping legs race to publish; and a user watching the view wants to read which plugin is building now, not six rows all claiming to be. One leg's failure never stops the next — the rule install_all already follows at boot.

Progress repaints between legs, because a bulk rebuild is minutes of cargo and a view that only updated at the end would sit still for exactly the time it mattered. The note rides the title line (# Plugins (7 loaded) — updating 3/7 (org)…) rather than taking a line of its own: the interactivity layer maps cursor.line - HEADER_LINES into the plugin list, so an extra header row would put every chord on the wrong plugin — and only while a run was in flight, which is the worst kind of bug to be handed a report about.

A leg is Done, Skipped or Failed, and skipped is not failed. "Pinned, so there was nothing to update" and "the build broke" both leave the plugin as it was, but only one is worth investigating: 4 updated, 2 pinned reads as success where 4 updated, 2 failed sends someone hunting for a problem that does not exist.

8.3 clean — the only verb that deletes

A staged directory under the user root is removable only when all hold, and each clause is there because dropping it destroys something wanted:

  1. nothing loads it — the obvious one;
  2. it did not FAIL to load this session — a broken plugin is still one the user asked for, and cleaning it turns "my plugin is failing" into "my plugin is gone", taking the error the view was showing with it;
  3. it is not init — that is the user's configuration, and it never appears in the loaded set under that name;
  4. it carries a .source marker — provenance is what makes removal recoverable. With it the directory can be re-resolved and rebuilt; without it the bytes are the only copy, which is exactly the hand-staged case.

:plugin-clean lists and :plugin-clean! removes — vim's own "yes, I mean it", costing no new mechanism. The X chord uses Effect::Confirm instead, and the confirmation carries the names (packed into one Args::String, since a confirm truncates its payload to the action's declared arity) so that what the user agreed to is what gets deleted: between the prompt appearing and y being pressed a plugin can finish loading, and re-deriving the list afterwards would delete the one that just arrived. The removal re-checks each name regardless.

9. auto-pair as the first consumer (AP.4, reframed)

AP.4 stops being "compile auto-pair into the binary" and becomes "auto-pair is the first core plugin (§7)." It ships as a prebuilt .wasm in the runtime root (staged there by the xtask/release plugin build), is discovered at boot at the Bundled tier, and its plugin.toml declares default_mode = "auto-pair-mode". The manager auto-registers auto-pair.enabled (default true), so a fresh editor auto-pairs out of the box with no user init.rs, no toolchain, no network. No require is involved — auto-pair is core.

Exit (updated from the AP.4 sketch): a fresh editor auto-pairs out of the box; :plugins lists auto-pair with source = bundled, build = cached; :set auto-pair.enabled=false turns it off (its mode deactivates, the plugin stays loaded); :set auto-pair.style=manual flips the style live. The require+build path (§2–§5) is proven separately by a user plugin from a Local/Git source.

Because auto-pair is a discovered core plugin, the default-init-delivery question disappears for AP.4 — no default init.rs is needed to enable it (that was the (i) vs (ii) choice; (i) won). A shipped default init.rs remains a separate, optional config-and-init.md concern for other defaults, not a dependency of core-plugin enablement.

10. Paramount-goal alignment

  • #2 Extensibility. The distribution story — a plugin is a git URL, built or downloaded on demand, versioned independently of the editor. use-package for WASM. The seam host (plugin-host.md) is unchanged; this is the layer above it.
  • #1 Performance. Builds + clones run off the boot/actor thread (spawn_blocking); a warm boot with unchanged sources is a pure load (no rebuild). Nothing on the keystroke path.
  • #4 Asynchronicity. Resolve/build/load is an async pipeline; a plugin appears when ready (eventual consistency), boot never blocks on a cold build.
  • UX (higher court). No embedded-binary bloat; core plugins work offline with no toolchain (prebuilt in the runtime root); a no-toolchain user still gets user plugins via Prebuilt; a broken rebuild never takes a working plugin down; all failures are logged skips surfaced in :plugins, never a failed boot.

11. Rejected alternatives

  • include_bytes! (compiled-in). Rejected by the user: couples plugin releases to editor releases, bloats the binary, and plugins should ship separately. The FS cache (user root) + the runtime root (core) are the artifact stores.
  • A single hardcoded core-plugins/-next-to-the-exe path. Rejected — resolving one fixed path relative to current_exe() is fragile across dev / installed / .app / packaged layouts. The runtime root (§7) is the right idea done right: a search path ($LATTICE_RUNTIME → build-time prefix → exe-relative → dev workspace), the standard editor mechanism, not one brittle path.
  • A TOML-only plugin list. Rejected as the canonical surface (not programmable); may return as sugar over require (§3).
  • Build on the boot/actor thread. Rejected: a cold build is seconds-to-minutes; it must be spawn_blocking, boot loads what's already cached and picks up the fresh build when it lands.
  • require-driven core plugins with a Bundled source (option (ii)). Rejected in favour of discovery + a <plugin>.enabled gate (option (i), §7): it dragged in the default-init-delivery question and put shipped config in the loop for something the editor already knows shipped. Core = discovered; require = user.

12. Settled decisions

  1. Declaration surface — init.rs require for user plugins (§3); core plugins are discovered, not required (§7). TOML list rejected as canonical.
  2. Toolchain model — core plugins prebuilt (no toolchain); user Git/Local build-from-source plus Prebuilt download for the no-toolchain path (§7).
  3. Core-plugin shipping — prebuilt .wasm in a runtime root found via a search path, staged by an xtask/release build; discovered at Bundled tier; mode enabled by a manifest default_mode + auto-registered <plugin>.enabled option (default true). (§7, §9)
  4. auto-pair — the first core plugin: Local in-repo build for dev → the runtime root for shipping; auto-pair.enabled gates auto-pair-mode. (§9)

13. Slices

See the slice plan (../operations/slice-plans/archive/plugin-loader.md) once sequenced. Two tracks — the core-plugin track ships auto-pair out of the box first (no build service needed), then the user require+build track:

Core track (delivers AP.4):

  • PM.1 runtime-root search path ($LATTICE_RUNTIME → prefix → exe-relative → workspace) + boot discovery of <runtime>/plugins/ at Bundled tier.
  • PM.2 the xtask build-core-plugins staging step (dev + release) that produces the prebuilt core .wasm into the runtime root.
  • PM.3 manifest default_mode + auto-registered <plugin>.enabled gate → enable/disable the declared mode via ModeEnablementRequested.
  • PM.4 auto-pair as the first core plugin (AP.4): manifest default_mode, auto-pair.enabled default true, discovered + enabled out of the box.

User track (use-package):

  • PM.5 build service (source dir → cached wasm, .build-stamp, spawn_blocking, graceful) → PM.6 source resolver (LocalGitPrebuilt) → PM.7 the require seam + init.rs require-drain + init.rs-built-by-the-service bootstrapping → PM.8 the :plugins source/build columns + rebuild chord.

Bulk track (PM.9, 2026-09-14) — scope for verbs that only ever had one row: see ../operations/slice-plans/archive/plugin-bulk-verbs.md.

  • PM.9a RefreshPolicy — an unpinned git checkout is fetched AND moved, or not touched at all (§4).
  • PM.9b update + :plugin-update <name> — the arm table by source kind.
  • PM.9c the bulk engine + clean (§8.2, §8.3).
  • PM.9d the view's scope chords R / B / u / U / X + per-leg progress (§8.1).
  • PM.9e docs. PM.9f the slice plan + workspace verification.