@skill-map/spec 0.62.1 → 0.64.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,37 @@
1
1
  # Spec changelog
2
2
 
3
+ ## 0.64.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Fix the OpenAI Codex connector model, which cloned Claude's grammar and was wrong per the official docs. Under the codex lens, skills are now invoked with `$name` (new `dollar-skill` extractor) not `/name`, `@` is a path-resolved file reference (new `at-file` extractor) not an agent mention, and codex plus the neutral `agent-skills` lens no longer flag skill names as reserved (a `$`-skill cannot shadow a `/` command). Claude and Antigravity are unchanged.
8
+
9
+ ## User-facing
10
+
11
+ Codex projects: a skill now connects via `$name` (not `/name`), `@file.md` references a file, and a skill named like a built-in (e.g. `model`) is no longer wrongly flagged as a reserved-name collision. `/` is left to Codex's own built-in commands.
12
+
13
+ - Lens auto-detection now gives a vendor marker precedence over the open-standard `agent-skills` fallback. The `agent-skills` provider declares `detect.fallback`, so its `.agents/` marker resolves a lens only when no vendor marker is present. A project carrying `.codex/` (or `.agent/workflows/`) alongside the shared `.agents/skills/` home now resolves to that vendor outright instead of prompting `codex` vs `agent-skills`. Several vendor markers together still surface an ambiguous prompt.
14
+
15
+ ## User-facing
16
+
17
+ Codex and Antigravity projects no longer hit a spurious "which lens?" prompt on first scan: a `.codex/` (or `.agent/workflows/`) project is detected as that lens even though it also uses the shared `.agents/skills/` folder. `/` is left to the vendor's own behavior.
18
+
19
+ - Add an optional `presentation.invocationSigil` to the Provider manifest: the single glyph a lens's runtime uses to invoke a skill (`/` for Claude and Antigravity, `$` for Codex). The BFF projects it into `providerRegistry`, and the link-kind palette now paints the `invokes` edge-kind glyph (and its tooltip example) for the active lens instead of a hardcoded `/`. Lenses with no `/`/`$` invocation channel (`agent-skills`, `markdown`) omit it.
20
+
21
+ ## User-facing
22
+
23
+ Under the Codex lens, the Invokes connector filter on the graph now shows a `$` glyph, matching how Codex invokes skills, instead of a `/`.
24
+
25
+ ## 0.63.0
26
+
27
+ ### Minor Changes
28
+
29
+ - Split plugin enable (operational) from import trust (security). Enable/disable now persist to the config layers, not the DB; `config_plugins` becomes a per-plugin local trust store. New `sm plugins trust / untrust` verbs, a trust PATCH route, a Settings UI Trust control, and a `pluginTrust.projectEnabled` opt-in grant or revoke consent to run a project-local plugin. It runs only when enabled AND trusted, so disabling one no longer re-reads as untrusted.
30
+
31
+ ## User-facing
32
+
33
+ Plugins now have two separate switches: enable (is it part of the project, shared) and trust (may its code run on your machine). New `sm plugins trust` / `untrust` plus a Trust button in Settings. A plugin you disabled stays disabled instead of nagging that it is untrusted.
34
+
3
35
  ## 0.62.1
4
36
 
5
37
  ### Patch Changes
package/architecture.md CHANGED
@@ -70,7 +70,7 @@ The UI is **not** a driving adapter; it is an HTTP/WS client of the Server. Exac
70
70
 
71
71
  A skill-map project sees its filesystem through exactly one **active provider lens** at any time: the provider whose extractors, classifiers, and resolution rules apply to the whole project during a scan. All other enabled providers stay registered but their provider-specific extractors are skipped.
72
72
 
73
- The lens is project-scope state, living in `.skill-map/settings.json` as the `activeProvider` key (see [`project-config.schema.json`](./schemas/project-config.schema.json#/properties/activeProvider)). When absent, the kernel auto-detects on first scan from filesystem markers and persists the result; if the heuristic is ambiguous (several markers), the CLI and UI prompt the user to pick one enabled provider. There is no unlensed state: when no vendor marker is present at all, the lens resolves to the open-standard `agent-skills` view, the universal default lens, which is NOT persisted, so a vendor marker added later still auto-detects on the next scan. The non-gated `core/markdown` base still classifies every unclaimed `.md` underneath, but it is not itself a selectable lens (see below). **The marker set is provider-owned**: each Provider declares its detection markers in its manifest `detect.markers` block (see [`provider.schema.json`](./schemas/extensions/provider.schema.json#/properties/detect)), e.g. `claude` → `.claude/`, `codex` → `.codex/`, `agent-skills` → `.agents/`, `antigravity` → `.agent/workflows/` (`AGENTS.md` is deliberately NOT an `codex` marker: it is the open agents.md standard, common in non-Codex repos and alongside `.claude/`, so keying detection off it would mis-route plain-markdown repos and force ambiguous prompts; a genuine Codex project is identified by `.codex/`). No central hardcoded detection table; the detectable set derives from registered Providers, so adding a Provider with a marker makes it auto-detectable without touching the resolver. When several markers match, the resolver returns the full candidate list in Provider iteration order, first match the default suggestion. A Provider with no `detect` block is never auto-suggested but can be selected manually. Google's Antigravity CLI (which replaced the retired Gemini CLI on 2026-05-19) uses the open-standard `.agents/skills/` for skills but its OWN `.agent/workflows/` (singular `.agent`) for workflows; the latter is its `detect` marker. `antigravity` ships `beta` (enabled by default), so its `.agent/workflows/` marker auto-detects the antigravity lens; a project that ALSO carries `.agents/` surfaces an ambiguous prompt (antigravity vs the `agent-skills` open default). `agent-skills` is `stable` (the locked open default lens), so a project's shared `.agents/` marker auto-detects it (a project with no vendor marker falls back to it), and a Google project's `.agents/skills/` files are owned by `agent-skills` for auto-detect, not by antigravity.
73
+ The lens is project-scope state, living in `.skill-map/settings.json` as the `activeProvider` key (see [`project-config.schema.json`](./schemas/project-config.schema.json#/properties/activeProvider)). When absent, the kernel auto-detects on first scan from filesystem markers and persists the result; if the heuristic is ambiguous (several VENDOR markers; the open `agent-skills` fallback never competes with a vendor, see Fallback precedence below), the CLI and UI prompt the user to pick one enabled provider. There is no unlensed state: when no vendor marker is present at all, the lens resolves to the open-standard `agent-skills` view, the universal default lens, which is NOT persisted, so a vendor marker added later still auto-detects on the next scan. The non-gated `core/markdown` base still classifies every unclaimed `.md` underneath, but it is not itself a selectable lens (see below). **The marker set is provider-owned**: each Provider declares its detection markers in its manifest `detect.markers` block (see [`provider.schema.json`](./schemas/extensions/provider.schema.json#/properties/detect)), e.g. `claude` → `.claude/`, `codex` → `.codex/`, `agent-skills` → `.agents/`, `antigravity` → `.agent/workflows/` (`AGENTS.md` is deliberately NOT an `codex` marker: it is the open agents.md standard, common in non-Codex repos and alongside `.claude/`, so keying detection off it would mis-route plain-markdown repos and force ambiguous prompts; a genuine Codex project is identified by `.codex/`). No central hardcoded detection table; the detectable set derives from registered Providers, so adding a Provider with a marker makes it auto-detectable without touching the resolver. When several markers match, the resolver returns the full candidate list in Provider iteration order, first match the default suggestion. **Fallback precedence**: the open default lens `agent-skills` declares `detect.fallback: true`, so its `.agents/` marker produces a candidate ONLY when no vendor marker is present; a project carrying a vendor marker alongside the shared `.agents/` home resolves to that vendor outright (the `.agents/skills/` directory is just where the vendor stores its skills, not a sign the project is a generic open-standard one). This is exactly what the scaffold `marker` field promises (`provider.schema.json#/properties/scaffold/properties/marker`): `sm tutorial --for codex` drops `.codex/` so the project resolves `codex`, never an ambiguous `codex` vs `agent-skills` pair. Several VENDOR markers together still surface a genuine ambiguous prompt. A Provider with no `detect` block is never auto-suggested but can be selected manually. Google's Antigravity CLI (which replaced the retired Gemini CLI on 2026-05-19) uses the open-standard `.agents/skills/` for skills but its OWN `.agent/workflows/` (singular `.agent`) for workflows; the latter is its `detect` marker. `antigravity` ships `beta` (enabled by default), so its `.agent/workflows/` marker auto-detects the antigravity lens; a project that ALSO carries `.agents/` still resolves to `antigravity` (its vendor marker outranks the `agent-skills` fallback, no ambiguous prompt). `agent-skills` is `stable` (the locked open default lens) and the sole `detect.fallback` Provider, so its shared `.agents/` marker auto-detects it only when no vendor marker is present (a project with no vendor marker falls back to it), and a Google project's `.agents/skills/` files are owned by `agent-skills` for auto-detect, not by antigravity.
74
74
 
75
75
  **Not-ready Providers ship disabled.** A Provider that is registered but not yet ready for end users declares `stability: 'experimental'` (see [`base.schema.json`](./schemas/extensions/base.schema.json#/properties/stability)), which ships it **disabled by default**: it does not classify, does not register, is never auto-detected, and is absent from the `selectable` set served by `GET /api/active-provider` until the operator opts in (`sm plugins enable <id>`, the Settings toggle, or a config override). There is no separate `comingSoon` flag: enabled/disabled is the single availability axis, and `stability: 'experimental'` is just the installed default flipped off. Today all four lenses ship enabled and selectable, `claude` (stable), `antigravity` (beta), `codex` (beta), and `agent-skills` (stable, the locked open default); no built-in Provider currently ships `experimental` (the flag's live built-in examples are extractors / analyzers, e.g. `core/mcp-tools` and `core/annotation-stale`). The non-gated `core/markdown` base is locked-enabled but is NOT a selectable lens, it is the substrate beneath whatever lens is active (see §Active-lens scope for providers). `stability: 'beta'` ships ENABLED like `stable` but renders a maturity badge (it is NOT a disabled state); this is distinct from `hideChip`, which only suppresses the per-card badge.
76
76
 
@@ -90,7 +90,7 @@ A provider plugin MAY declare it reads source files belonging to ANOTHER provide
90
90
 
91
91
  ### Universal extractors and per-provider extractors
92
92
 
93
- The lens does NOT gate the universal extractors under `core/` (markdown links, code-region file paths, external URLs, sidecar annotations); their semantics are provider-agnostic, so they run regardless of the active provider. Provider-specific extractors (Claude's `@`-directive parser, Cursor's picker-derived references, the future Codex AGENTS.md walker, future Antigravity parsers) declare `precondition: { provider: '<id>' }` on their manifest; the orchestrator invokes them on every node visited during the scan as long as the **active lens** is in the declared provider list, regardless of which provider's `classify()` claimed the node. The declared list MAY name more than one lens: the claude `@`-directive extractor runs under `claude` and `codex` (`precondition: { provider: ['claude', 'codex'] }`), because OpenAI Codex sub-agents share the same `@<name>` mention grammar; the `/command` (slash) extractor additionally runs under `antigravity` (`precondition: { provider: ['claude', 'codex', 'antigravity'] }`), since Antigravity invokes both skills and workflows by the same `/<name>` slash. codex declares `invokes: ['skill']`, so under the codex lens a `/name` slash signal resolves to the provider's open-standard `.agents/skills/<name>/SKILL.md` skills (Codex reads its skills from the open `.agents/skills/` layout); antigravity declares `invokes: ['skill', 'workflow']`, so under its lens a `/name` resolves to either a `.agents/skills/<name>/SKILL.md` skill or a `.agent/workflows/<name>.md` workflow. A body-scoped extractor reads whatever the walker yielded as the node body: for most providers the text after the frontmatter fence, but for a provider declaring `read.bodyField` (see [`provider.schema.json`](./schemas/extensions/provider.schema.json#/properties/read)) the named frontmatter field instead, since Codex sub-agents are pure TOML whose markdown prompt is the `developer_instructions` field, the codex provider sets `bodyField: 'developer_instructions'` so that prompt flows through the same body pipeline (body hash, markdown-link / backtick-path / external-url, and the lens-gated `@` / `/`).
93
+ The lens does NOT gate the universal extractors under `core/` (markdown links, code-region file paths, external URLs, sidecar annotations); their semantics are provider-agnostic, so they run regardless of the active provider. Provider-specific extractors (Claude's `@`-directive and `/command` parsers, OpenAI Codex's `$skill` and `@`-file parsers, future Antigravity parsers) declare `precondition: { provider: '<id>' }` on their manifest; the orchestrator invokes them on every node visited during the scan as long as the **active lens** is in the declared provider list, regardless of which provider's `classify()` claimed the node. The declared list MAY name more than one lens ONLY when the runtimes genuinely share a grammar: the claude `/command` (slash) extractor runs under `claude` AND `antigravity` (`precondition: { provider: ['claude', 'antigravity'] }`), since both invoke by `/<name>` (Antigravity declares `invokes: ['skill', 'workflow']`, so a `/name` resolves to either a `.agents/skills/<name>/SKILL.md` skill or a `.agent/workflows/<name>.md` workflow). But a runtime whose grammar DIFFERS owns its OWN extractor rather than borrowing one: OpenAI Codex reserves `/` for its built-in commands and invokes a user skill with `$`, so it ships a codex-only `dollar-skill` extractor (`$name` `invokes`, resolved via codex's `invokes: ['skill']` to a `.agents/skills/<name>/SKILL.md` skill) instead of the claude slash parser; and Codex's `@` is a file picker, not an agent-mention grammar, so it ships a codex-only `at-file` extractor (a path- or extension-shaped `@foo.md` → a path-resolved `references` link) instead of claude's `@`-directive (whose bare-`@handle` → `mentions` grammar does not apply to Codex). The claude `@`-directive thus stays `claude`-only. A body-scoped extractor reads whatever the walker yielded as the node body: for most providers the text after the frontmatter fence, but for a provider declaring `read.bodyField` (see [`provider.schema.json`](./schemas/extensions/provider.schema.json#/properties/read)) the named frontmatter field instead, since Codex sub-agents are pure TOML whose markdown prompt is the `developer_instructions` field, the codex provider sets `bodyField: 'developer_instructions'` so that prompt flows through the same body pipeline (body hash, markdown-link / backtick-path / external-url, and the lens-gated grammar extractors: `@` / `/` under claude, `$` / `@`-file under codex).
94
94
 
95
95
  The gate is the active lens, not the node's provider. A `@handle` token in `CLAUDE.md` or `notes/todo.md` (files the `claude` provider disclaims to `core/markdown`) still gets parsed by `claude/at-directive` under the `claude` lens, because the lens represents the runtime grammar and the runtime reads markdown across the whole project, not only files it owns. The earlier double-check ("node's provider matches AND the lens") silently dropped that surface; dropping the node side restores it. Cross-lens isolation holds via the lens half alone: under `codex`, claude extractors are silent on every node (including `.claude/*`) because lens authorisation is missing. Under the open-standard `agent-skills` default lens (a project with no vendor marker), the `claude` / `codex`-gated extractors stay silent because `agent-skills` is not in their declared provider allowlist; only the universal extractors run, alongside the open-standard `skill` classifier.
96
96
 
@@ -321,7 +321,7 @@ The `ui` block is required (not optional) by design: making it optional would fo
321
321
 
322
322
  The kernel ships every Provider's per-kind `ui` block to the BFF at boot; the BFF aggregates them into a `kindRegistry` map embedded in every payload-bearing REST envelope (see [`cli-contract.md` §Server](./cli-contract.md#server)). The UI consumes `kindRegistry` directly; built-in and user-plugin kinds render identically.
323
323
 
324
- Each Provider ALSO declares a top-level `presentation` block (`provider.schema.json#/properties/presentation`: `label`, `color`, optional `colorDark` / `icon` / `emoji` / `hideChip`) describing the Provider's own identity, distinct from its kinds' visuals. (Named `presentation`, not `ui`, because the shared extension `ui` key is the view-contributions map declared only by extractor / analyzer kinds.) The BFF aggregates these into a sibling `providerRegistry` map (keyed by Provider id) on the same envelopes. The UI consumes `providerRegistry` to render the active-lens dropdown, topbar lens chip, and per-node provider chip on cards from the real registered-Provider set, never a hardcoded list. Each entry carries an `isLens` flag projected from the Provider's `gatedByActiveLens`: the dropdown lists only lens entries (gated Providers), so the non-gated `core/markdown` base never appears there even though it keeps a registry entry for chip lookups. `hideChip: true` (set by the universal `markdown` base) suppresses the per-card chip; combined with `isLens: false` the base shows on no lens surface at all. Unlike kind colors (normalised across Providers so every `agent` paints the same), Provider colors are deliberately distinct so the chip tells the user which platform a node came from.
324
+ Each Provider ALSO declares a top-level `presentation` block (`provider.schema.json#/properties/presentation`: `label`, `color`, optional `colorDark` / `icon` / `emoji` / `hideChip` / `invocationSigil`) describing the Provider's own identity, distinct from its kinds' visuals. (Named `presentation`, not `ui`, because the shared extension `ui` key is the view-contributions map declared only by extractor / analyzer kinds.) The BFF aggregates these into a sibling `providerRegistry` map (keyed by Provider id) on the same envelopes. The UI consumes `providerRegistry` to render the active-lens dropdown, topbar lens chip, and per-node provider chip on cards from the real registered-Provider set, never a hardcoded list. Each entry carries an `isLens` flag projected from the Provider's `gatedByActiveLens`: the dropdown lists only lens entries (gated Providers), so the non-gated `core/markdown` base never appears there even though it keeps a registry entry for chip lookups. `hideChip: true` (set by the universal `markdown` base) suppresses the per-card chip; combined with `isLens: false` the base shows on no lens surface at all. Unlike kind colors (normalised across Providers so every `agent` paints the same), Provider colors are deliberately distinct so the chip tells the user which platform a node came from. The optional `invocationSigil` is the single glyph the lens's runtime uses to invoke a skill / command (`/` for the slash-invoking `claude` / `antigravity`, `$` for `codex`); the UI's link-kind palette joins it against the active lens to paint the `invokes` edge-kind glyph (and its tooltip example) so the toggle mirrors the lens's source syntax instead of a hardcoded `/`. Omitted for lenses with no `/`/`$` invocation channel (`agent-skills`, `core/markdown`), under which no `invokes` edge arises, so the glyph is never painted.
325
325
 
326
326
  ### Provider · dispatch order and the universal markdown fallback
327
327
 
@@ -381,7 +381,7 @@ Each Provider MAY declare an optional `reservedNames: Record<kind, string[]>` ma
381
381
 
382
382
  The kernel intersects each Provider's `reservedNames[kind]` catalog with the scanned graph at orchestrator time. For every node the post-walk pipeline derives its normalised identifiers via the §Provider · kind identifiers contract, then tests them against the reserved set of the node's OWN Provider (**self scope**): `reservedNames[node.kind]` of `node.provider`. Claude classifies `.claude/commands/help.md` as `claude`/`command` and reserves `help` under `command`, so the file is flagged.
383
383
 
384
- A runtime that adopts the open `.agents/skills/` standard instead of a vendor directory **reuses the `agent-skills` classifier + `skill` kind in its OWN manifest** (plain manifest composition, no kernel rule), and with them the **base reserved-name catalog the neutral `agent-skills` Provider owns** under `skill`: the universal slash commands an agent CLI ships built-in regardless of vendor (`help`, `config`, `mcp`, `model`, `clear`, `exit`, …). The open standard itself documents no reserved names, so this base is skill-map's curated cross-vendor common subset, not a clause of the standard; the neutral `agent-skills` lens enforces exactly this base, so a user `.agents/skills/help/SKILL.md` is flagged under it. A vendor that adopts the standard **spreads the base and appends its OWN runtime-specific verbs**: Google's Antigravity reuses the classifier and, on top of the inherited base, reserves the rest of `agy`'s built-in slash commands under `skill`, so when `activeProvider === 'antigravity'` a user `.agents/skills/goal/SKILL.md` (classified as `antigravity`/`skill`) is flagged because `/goal` is a built-in, while the neutral base never carries `agy`-specific verbs (a future Codex lens that adopts the standard inherits the same base, not Antigravity's extras). There is no cross-provider "lens scope": each lens classifies its own territory and self scope tests it against that Provider's own (base + extras) catalog.
384
+ A runtime that adopts the open `.agents/skills/` standard **reuses the `agent-skills` classifier + `skill` kind in its OWN manifest** (plain manifest composition, no kernel rule). Whether it ALSO reserves skill names depends on its **invocation channel**: a reserved name is the name of a built-in the runtime consumes through a particular sigil, so reserving a `skill` name only makes sense for a runtime that can invoke a skill through the **`/` command channel**, where a user skill could shadow a built-in `/` command. The `agent-skills` Provider exports a shared `COMMONS_RESERVED_NAMES` catalog (the universal cross-vendor slash commands an agent CLI ships built-in: `help`, `config`, `model`, `clear`, …), but it is applied ONLY by such `/`-invoking lenses. Google's Antigravity is one (its skills + workflows are `/`-invoked): it spreads the base and appends its own verbs (`goal`, …) under `skill` AND `workflow`, so when `activeProvider === 'antigravity'` a user `.agents/skills/goal/SKILL.md` is flagged because `/goal` is a built-in. The neutral `agent-skills` lens reserves **nothing**: the open Agent Skills standard documents no `/`-invocation (a skill activates by its `description` and connects by markdown links), so a skill name cannot shadow a `/` command and a `.agents/skills/help/SKILL.md` is NOT flagged under it. **OpenAI Codex likewise reserves no skill names**: it invokes skills with `$` (`$skill`, parsed by the codex `dollar-skill` extractor), a namespace disjoint from its built-in `/` commands, so a `$`-skill named `model` cannot shadow `/model`. There is no cross-provider "lens scope": each lens classifies its own territory and self scope tests it against that Provider's OWN catalog (empty for `agent-skills` and `codex`, base + extras for `antigravity`).
385
385
 
386
386
  A node landing in the reserved set joins a per-scan `Set<nodePath>` consumed by the score-phase `core/name-reserved` analyzer, which co-locates two effects in one pass (detection still lives in the orchestrator, so the same set drives both):
387
387
 
@@ -389,7 +389,7 @@ A node landing in the reserved set joins a per-scan `Set<nodePath>` consumed by
389
389
 
390
390
  2. **It downgrades any link resolving to a reserved target** (by path OR name match) by subtracting `RESERVED_PENALTY = 0.9` (a `delta` op) from the 1.0 baseline, folding it to `RESERVED_TARGET = 0.1`, emitting the `delta -0.9` in the same score-phase pass as its reserved warns. The reserved-target set is computed by the post-walk lift and surfaced via `ctx.reservedNodePaths`. The visual weight drops well below the broken floor (`0.5`) so the operator sees the edge resolves to a file the runtime ignores. When the trigger has multiple candidates (name index collision) and the strict-kind filter accepts more than one, the resolver picks the first allowed candidate; if non-reserved, the link keeps the 1.0 baseline, and only when EVERY accepted candidate is reserved does the penalty apply. With `core/name-reserved` disabled, a reserved-resolving link gets no `delta -0.9` and no warn, falling back to the 1.0 baseline (symmetric disable).
391
391
 
392
- The lookup normalises both sides through the §Extractor · trigger normalization pipeline, so a literal `Init-Project` in the manifest still matches a user `name: init project` or filename `Init-Project.md`. The catalog is intentionally per-kind, not global: a name reserved for commands (`/help`) MAY legitimately appear as a skill (a "help" skill triggered through a non-command channel). Lens scope respects the same per-kind boundary: Antigravity declares its reserved names under `skill` AND `workflow` (not `command`) because the invocables they shadow are skill files (`.agents/skills/`) and workflow files (`.agent/workflows/`), both invoked by `/<name>`, so skill- and workflow-kind nodes are tested.
392
+ The lookup normalises both sides through the §Extractor · trigger normalization pipeline, so a literal `Init-Project` in the manifest still matches a user `name: init project` or filename `Init-Project.md`. The catalog is intentionally per-kind AND per-channel, not global: a name is reserved only for a kind the active runtime invokes through the channel that owns that name. `/help` is reserved for a claude `command` or an antigravity `/`-invoked `skill` / `workflow`, but the same `help` is free as an OpenAI Codex `$`-skill or an `agent-skills` description-activated skill, because neither is reachable through the `/` channel (the "help skill triggered through a non-command channel" case). Antigravity declares its reserved names under `skill` AND `workflow` (both invoked by `/<name>`), not `command`, because the invocables they shadow are skill files (`.agents/skills/`) and workflow files (`.agent/workflows/`).
393
393
 
394
394
  **Update policy.** Built-in catalogs drift as vendor runtimes evolve. Each catalog change ships as a kernel patch with a changeset entry; the catalog is API surface users rely on the analyzer to reflect. User-installed Providers MAY declare their own `reservedNames` with the same shape; the analyzer and penalty run uniformly across built-in and user-installed Providers.
395
395
 
@@ -411,7 +411,7 @@ In addition to the `emitLink` path, Extractors MAY emit **Signals** via `ctx.emi
411
411
 
412
412
  The kernel's **resolver phase** runs after extraction completes and before analysis starts. For each Signal, the resolver:
413
413
 
414
- 1. (Phase 4+, not yet wired) Filters candidates whose `extractorId` is disabled by a per-extension enable filter. The config surface that toggles individual extensions is not defined yet (the earlier `plugins.<id>.extensions.<extId>.enabled` placeholder was removed); it will be specified when this filter lands. When the filter empties every candidate, the Signal carries `resolution.outcome = 'rejected'` with `extractorDisabled = { extractorId }`.
414
+ 1. (Signal-resolver candidate filter not yet wired) Filters candidates whose `extractorId` is disabled by a per-extension enable filter. The per-extension enable config surface now exists (`plugins.<id>.extensions.<ext>.enabled`, see [`project-config.schema.json`](./schemas/project-config.schema.json) and §Plugin enable vs import trust) and gates extension registration at load time; the Signal-resolver candidate filter that consults it per detection is not wired yet. When the filter empties every candidate, the Signal carries `resolution.outcome = 'rejected'` with `extractorDisabled = { extractorId }`.
415
415
  2. Ranks surviving candidates inside the Signal by the active Provider's `resolverRules.kindPriority` (when declared), then `confidence` DESC, then `range` length (`end - start`) DESC, then `extractorId` declaration order. The chosen index is recorded as `resolution.winnerIndex` and (provisionally) `resolution.outcome = 'materialised'`.
416
416
  3. For body-scoped Signals with a `range`, the resolver builds overlap clusters per source (transitive closure of range intersection). Size-1 clusters keep their winner. For size 2+ clusters, the resolver re-applies the same four-step tiebreak to each Signal's winning candidate to pick a cluster winner. Losers flip to `resolution.outcome = 'rejected'` with `rejectedBy = { source, range, extractorId, reason }`, where `reason` names the deciding tiebreak step: `kind-priority`, `higher-confidence`, `longer-range`, or `earlier-declaration`. External pseudo-link clusters (every member targets `http://` / `https://`) skip cross-cluster ranking, every member materialises (URL-targeted Signals never conflict with internal-target Signals or each other because they leave the local graph).
417
417
  4. Materialises every Signal whose final `outcome === 'materialised'` as a Link, identical in shape to one emitted directly via `emitLink`. The materialised Link's `sources[]` carries the winning candidate's `extractorId` so attribution survives.
@@ -558,7 +558,11 @@ Hooks introduce no new persisted state and do NOT participate in the determinist
558
558
  ### Locality
559
559
 
560
560
  - **Drop-in**: extensions live inside plugins, discovered at boot from `<cwd>/.skill-map/plugins/<id>/` only. The `--plugin-dir <path>` escape hatch on the `sm plugins …` verb family loads a custom directory per invocation when the user explicitly opts in.
561
- - **Import trust (security boundary).** A drop-in plugin discovered under the project-local `<cwd>/.skill-map/plugins/` is parsed (manifest read + surfaced in `sm plugins list`) but its extension CODE is NOT imported or executed until the operator grants LOCAL trust: a `config_plugins` (DB) override enabling the plugin (`<id>`) or any of its extensions (`<id>/<ext>`), as written by `sm plugins enable <id>` or the Settings toggle. The committed `settings.json` baseline does NOT grant import trust: a cloned repo controls its own `.skill-map/settings.json`, so honouring it would let a hostile repo auto-execute its plugins on the victim's first `sm scan`. A fresh clone therefore ships its project-local plugins discovered-but-unexecuted; the runtime emits a one-time notice naming how many were found and pointing at `sm plugins enable`. Built-in extensions (compiled into the CLI) and an explicit `--plugin-dir <path>` (the operator pointed the loader at the code on purpose) are NOT gated; `--no-plugins` skips discovery entirely. The `sm plugins` management family (`list` / `show` / `enable` / `disable` / `doctor`) still imports discovered plugin code to enumerate extensions, running those verbs is itself the operator's explicit choice to work with the project's plugins.
561
+ - **Plugin enable vs import trust (security boundary, two orthogonal axes).** A drop-in plugin discovered under the project-local `<cwd>/.skill-map/plugins/` is parsed (manifest read + surfaced in `sm plugins list`) but its extension CODE is imported and executed only when BOTH axes allow it:
562
+ - **Enabled (operational, shareable).** Whether the plugin / extension is part of the project. Lives in the config layers (`plugins.<id>.enabled`, `plugins.<id>.extensions.<ext>.enabled`), `settings.json` (committed team baseline) overlaid by `settings.local.json` (per-checkout override). Written by `sm plugins enable / disable` (defaults to the shared `settings.json`; `--local` targets `settings.local.json`) and the Settings toggle. Default: the installed default (`true` for `stable` / `beta`, `false` for `experimental` / `deprecated`).
563
+ - **Trusted (security, LOCAL, per-machine).** Whether THIS machine's operator consents to importing the plugin's code. A per-plugin boolean in the `config_plugins` (DB) trust store, written by `sm plugins trust / untrust <id>` and the per-plugin Trust control in the UI. The DB is structurally local (never committed, not a config layer), so trust cannot travel in a clone. A committed `settings.json` does NOT and CANNOT grant import trust: honouring a shared file would let a hostile repo auto-execute its plugins on the victim's first `sm scan`.
564
+
565
+ A plugin's code is imported iff it is **enabled** AND (it carries a local **trust** grant OR the local opt-in `pluginTrust.projectEnabled` is set). Per-extension enable is applied after import, at registration. A fresh clone has no DB trust row and no local opt-in, so its project-local plugins are discovered-but-unexecuted (`status: 'disabled'`, `untrusted: true`); the runtime emits a one-time notice naming how many were found and pointing at `sm plugins trust <id>`. The local escape hatch `pluginTrust.projectEnabled` (in `settings.local.json` only, stripped from the committed layer, gated behind a confirm because it expands the local execution surface) trusts every plugin the project enables, for teams that vet plugins in code review. The loader keeps the two not-loaded reasons distinct: `disabledByConfig` (the operator turned it off) vs `untrustedNotLoaded` (no local trust grant), so an explicit disable never re-reads as untrusted across a restart. Built-in extensions (compiled into the CLI) and an explicit `--plugin-dir <path>` (the operator pointed the loader at the code on purpose) are NOT trust-gated; `--no-plugins` skips discovery entirely. The `sm plugins` management family (`list` / `show` / `enable` / `disable` / `trust` / `untrust` / `doctor`) still imports discovered plugin code to enumerate extensions, running those verbs is itself the operator's explicit choice to work with the project's plugins.
562
566
  - **Built-in**: the reference impl bundles a default extension set (one Provider, four extractors, five analyzers, one formatter, one hook). The fifth analyzer, `core/schema-violation`, replays every scanned node and link through the authoritative spec schemas via AJV, the kernel-side guard against persisting non-conforming graph rows. The first built-in Hook is `core/update-check`, subscribing to `shutdown` to run the once-per-day "update available" probe + banner that lived on the CLI entry path before the Hook kind had concrete consumers. Loaded from `src/extensions/`, these are indistinguishable from plugin-supplied extensions to the kernel.
563
567
 
564
568
  ---
@@ -654,8 +658,9 @@ One locality class constrains which layers a given key MAY live in. Enforced in
654
658
  Members:
655
659
  - `allowEditSmFiles`, per-project consent to create / modify `.sm` sidecars.
656
660
  - `scan.referencePaths`, additional link-validation paths.
661
+ - `pluginTrust.projectEnabled`, the local opt-in that trusts every plugin the project enables (the import-trust escape hatch).
657
662
 
658
- Both describe disk access the local operator opted into; sharing them via the repo would silently expand every collaborator's scan surface to paths that only make sense on the original author's machine.
663
+ The first two describe disk access the local operator opted into, the third the local code-execution surface; sharing any of them via the repo would silently expand every collaborator's surface (scan paths, or auto-running the repo's plugins) in a way only the original author consented to. `pluginTrust.projectEnabled` in particular MUST stay local: honouring a committed `true` would let a cloned repo auto-execute its own plugins, the exact supply-chain attack the import-trust gate prevents.
659
664
 
660
665
  Adding a new entry is a behaviour change for older installs that wrote the key into a committed file: the value gets stripped at read time. The changeset adding the entry MUST document the migration.
661
666
 
package/cli-contract.md CHANGED
@@ -52,9 +52,13 @@ external location MAY be loaded via the `--plugin-dir <path>` escape
52
52
  hatch on the `sm plugins …` verb family, user-explicit per invocation.
53
53
  Project-local plugins are discovered but their code is NOT executed by
54
54
  `sm scan` / `sm serve` (and the other runtime verbs) until the operator
55
- grants local trust via `sm plugins enable <id>`; the committed
56
- `settings.json` does not grant it. `--plugin-dir` and built-ins are not
57
- gated. See [`architecture.md` §Locality](./architecture.md) (import
55
+ grants LOCAL trust via `sm plugins trust <id>` (a per-plugin row in the
56
+ `config_plugins` DB trust store, or the local opt-in
57
+ `pluginTrust.projectEnabled`); the committed `settings.json` does not and
58
+ cannot grant it. Enable / disable (`sm plugins enable / disable`, persisted
59
+ in the config layers) is the separate OPERATIONAL axis and grants no trust.
60
+ `--plugin-dir` and built-ins are not gated. See
61
+ [`architecture.md` §Locality](./architecture.md) (plugin enable vs import
58
62
  trust) for the normative model.
59
63
 
60
64
  ### User-settings file (narrow, documented exception)
@@ -115,7 +119,7 @@ The project sees its filesystem through exactly one **active provider lens** at
115
119
 
116
120
  CLI surfaces:
117
121
 
118
- - **Auto-detect on first scan**: when `activeProvider` is absent, `sm scan` and `sm watch` run a filesystem heuristic driven by each Provider's manifest `detect.markers` (e.g. `.claude/` → `claude`, `.codex/` → `codex`, `.agents/` → `agent-skills`; `AGENTS.md` is deliberately not a marker, it is the vendor-neutral agents.md standard). The marker set is provider-owned, not hardcoded. On unambiguous match, the result is persisted to `settings.json` and the scan proceeds; on no match, the lens defaults to the open-standard `agent-skills` view (the universal default lens) without persisting it, and the scan proceeds silently (a vendor marker added later still auto-detects on the next scan); on ambiguous match (multiple detected), it prompts interactively (or fails with exit code 2 under `--yes` if no default is configured). Google's Antigravity CLI declares no vendor-specific marker (it adopted the open-standard `.agents/` layout, auto-detected as `agent-skills`); the `antigravity` lens is set manually via `sm config set activeProvider antigravity`.
122
+ - **Auto-detect on first scan**: when `activeProvider` is absent, `sm scan` and `sm watch` run a filesystem heuristic driven by each Provider's manifest `detect.markers` (e.g. `.claude/` → `claude`, `.codex/` → `codex`, `.agents/` → `agent-skills`; `AGENTS.md` is deliberately not a marker, it is the vendor-neutral agents.md standard). The marker set is provider-owned, not hardcoded. On unambiguous match, the result is persisted to `settings.json` and the scan proceeds; on no match, the lens defaults to the open-standard `agent-skills` view (the universal default lens) without persisting it, and the scan proceeds silently (a vendor marker added later still auto-detects on the next scan); on ambiguous match (multiple VENDOR markers detected), it prompts interactively (or fails with exit code 2 under `--yes` if no default is configured). **Fallback precedence**: the open default `agent-skills` declares `detect.fallback`, so its `.agents/` marker never competes with a vendor; a project carrying `.codex/` (or `.agent/workflows/`) alongside the shared `.agents/` skill home resolves to that vendor outright, no prompt. Google's Antigravity CLI auto-detects from its own `.agent/workflows/` marker (it stores skills under the open-standard `.agents/skills/` but keeps workflows under `.agent/workflows/`); a project with only `.agents/` and no vendor marker auto-detects as `agent-skills`.
119
123
  - **Manual override**: `sm config set activeProvider <id>` switches the lens, drops the `scan_*` zone atomically (see [`db-schema.md`](./db-schema.md#zones)), and triggers an immediate rescan under the new lens. `state_*` and `config_*` zones survive.
120
124
  - **No per-scan flag**: there is no `sm scan --provider=<id>` flag. The lens is a project-level decision; the drop+rescan cost makes per-invocation switching the wrong default UX.
121
125
 
@@ -295,9 +299,9 @@ Keys are dot-paths (`jobs.minimumTtlSeconds`, `scan.tokenize`). Unknown keys →
295
299
 
296
300
  #### Privacy-sensitive config
297
301
 
298
- Keys whose value opens disk access OUTSIDE the project root (today: `scan.extraFolders`, `scan.referencePaths`) are gated behind `--yes` so the user never expands the scan surface by accident. The analyzer:
302
+ Keys whose value expands the project's surface, either disk access OUTSIDE the project root (today: `scan.extraFolders`, `scan.referencePaths`) or the local code-execution surface (`pluginTrust.projectEnabled`, which locally trusts every plugin the project enables), are gated behind `--yes` so the user never expands the surface by accident. The analyzer:
299
303
 
300
- - `sm config set <privacy-key> <value>` (without `--yes`), when the new value would expand the surface (adding `extraFolders` / `referencePaths` paths resolving outside the project root), exits with code `2` and prints the full list of exposed paths to stderr, suggesting `--yes` to confirm.
304
+ - `sm config set <privacy-key> <value>` (without `--yes`), when the new value would expand the surface (adding `extraFolders` / `referencePaths` paths resolving outside the project root, or setting `pluginTrust.projectEnabled` to `true`), exits with code `2` and prints the affected detail to stderr (the exposed paths, or the list of currently-untrusted plugins it would trust), suggesting `--yes` to confirm.
301
305
  - `sm config set <privacy-key> <value> --yes`, proceeds and prints the same list as a confirmation receipt.
302
306
  - Writes that NARROW the surface (removing paths) do not require `--yes`.
303
307
 
@@ -305,7 +309,7 @@ The Settings UI's Project section enforces the same analyzer via a confirm dialo
305
309
 
306
310
  #### Project-local-only config
307
311
 
308
- The three privacy-sensitive keys above PLUS `allowEditSmFiles` are members of `PROJECT_LOCAL_ONLY_KEYS` (see [`architecture.md` §Config layering · Per-key locality](./architecture.md#per-key-locality)). The values are per-user-per-project and MUST NOT travel via the committed repo:
312
+ The three privacy-sensitive keys above PLUS `allowEditSmFiles` and `pluginTrust.projectEnabled` are members of `PROJECT_LOCAL_ONLY_KEYS` (see [`architecture.md` §Config layering · Per-key locality](./architecture.md#per-key-locality)). The values are per-user-per-project and MUST NOT travel via the committed repo:
309
313
 
310
314
  - `sm config set` writes them to `<cwd>/.skill-map/settings.local.json` (gitignored).
311
315
  - The loader strips them (with a warning) when found in the committed `project` layer (`settings.json`). An older install that wrote one to `settings.json` keeps validating against the schema, but the value is ignored at read time and `sm config show --source` surfaces the warning.
@@ -561,8 +565,10 @@ Authentication: the nonce is the sole credential. An implementation MUST reject
561
565
  |---|---|
562
566
  | `sm plugins list [<id>]` | No id: auto-discovered plugins with status, one row per plugin (`--json` emits the aggregate discovered-plugin registry). With a bare plugin id: that plugin's manifest plus its extension detail (kind / version / per-extension status; `--json` emits the single `DiscoveredPlugin`). A qualified `<plugin>/<ext>` id is rejected with a redirect to `sm plugins show`. |
563
567
  | `sm plugins show <plugin>/<ext>` | Single-extension detail (Kind / Version / Stability / Description / Preconditions / Entry; `--json` emits the single extension object). Accepts only a qualified `<plugin>/<ext>` id; a bare plugin id is rejected with a redirect to `sm plugins list <id>`. |
564
- | `sm plugins enable <id>... \| --all` | Toggle on. Persists in `config_plugins`. Accepts one or more ids; batches are all-or-nothing (any unknown / mismatched id aborts before any write) and repeated ids are deduped. `--all` applies to every discovered plugin. |
565
- | `sm plugins disable <id>... \| --all` | Toggle off; does not delete the plugin directory. Eagerly purges each id's rows from `scan_contributions` so its UI chips disappear before the next scan (plugin-managed state in `state_plugin_kvs` / dedicated tables is preserved, see `plugin-kv-api.md`). Accepts one or more ids; batches are all-or-nothing and repeated ids are deduped. `--all` applies to every discovered plugin. |
568
+ | `sm plugins enable <id>... \| --all [--local]` | Operational toggle ON. Persists the per-extension `enabled` in the config layers (`plugins.<id>.extensions.<ext>.enabled`), defaulting to the shared `settings.json`; `--local` writes `settings.local.json` instead. Does NOT grant import trust (use `sm plugins trust`). Accepts one or more ids; batches are all-or-nothing (any unknown / mismatched id aborts before any write) and repeated ids are deduped. `--all` applies to every discovered plugin. |
569
+ | `sm plugins disable <id>... \| --all [--local]` | Operational toggle OFF; does not delete the plugin directory and does not revoke trust. Persists `enabled: false` in the config layers (`--local` targets `settings.local.json`). Eagerly purges each id's rows from `scan_contributions` so its UI chips disappear before the next scan (plugin-managed state in `state_plugin_kvs` / dedicated tables is preserved, see `plugin-kv-api.md`). Accepts one or more ids; batches are all-or-nothing and repeated ids are deduped. `--all` applies to every discovered plugin. |
570
+ | `sm plugins trust <id>... \| --all` | Grant LOCAL import trust: the operator's security consent to import and run this plugin's code on this machine. Persists a per-plugin row in the `config_plugins` (DB) trust store, keyed by the bare plugin id (a qualified `<plugin>/<ext>` collapses to its plugin). Local only, never committed, so it cannot travel in a clone. Distinct from `enable`: a plugin runs only when it is both enabled (config) and trusted. Accepts one or more ids; batches are all-or-nothing and repeated ids are deduped. `--all` applies to every discovered plugin. |
571
+ | `sm plugins untrust <id>... \| --all` | Revoke local import trust: drops the plugin's `config_plugins` trust row, so it reverts to discovered-but-unexecuted on the next scan / restart. Does NOT change the enable state and does NOT delete the plugin directory. Same id / batch semantics as `trust`. |
566
572
  | `sm plugins config <plugin>/<ext> [<settingId> [<value>]] [--reset]` | Read or write the operator-supplied values for an extension's declared `settings`. No `settingId`: table of each declared setting with its effective value and the layer that set it (`--json` emits the resolved set). With `<settingId> <value>`: coerce the shell string to the setting's input-type, validate, then write under `plugins.<pluginId>.extensions.<extId>.settings.<settingId>` (a normal setting lands in `settings.json`; a `secret`-typed one is forced into `settings.local.json`, gitignored, never committed); prints a "re-scan to apply" reminder. `--reset` drops the override back to the manifest default. Requires a qualified `<plugin>/<ext>` id; a bare plugin id is rejected with a redirect to `sm plugins list <id>`. `secret` values are redacted as `<redacted>` in output. |
567
573
  | `sm plugins doctor` | Revalidate all plugins against current spec version; update `status` fields. `--json` emits the report shape declared by [`plugins-doctor.schema.json`](./schemas/plugins-doctor.schema.json): `{ ok: true, kind: 'plugins.doctor', counts, issues[], warnings[], elapsedMs }`. |
568
574
 
@@ -630,10 +636,11 @@ The reference implementation ships a Hono BFF rooted at `src/server/`. One Node
630
636
  | `GET /api/branch?path=<prefix>&path=<prefix>&limit=<n>` | implemented | Branch projection for the map. `path` is **repeatable**: the response is the UNION of the subtrees under every given prefix (forward-slash; a node matches a prefix when its path equals it or starts with `<prefix>/`). No `path` (or a single empty one) = the whole corpus. The union is capped at `limit` nodes (default and effective max = the scan's `maxRenderNodes`), so the response stays bounded regardless of how many prefixes are sent. Direct shape (no envelope wrap, like `/api/scan`): `{ schemaVersion, kind: 'branch', branch: { paths, total, rendered, truncated, cap }, nodes: Node[], links: Link[], issues: Issue[] }`, where `paths` echoes the requested prefixes. `nodes` is the first `rendered` nodes of the union in stable path order; `links` carries only edges whose source AND **resolved target** are in `nodes` (the resolved target is `resolvedTarget`, the node a trigger-style `invokes` / `mentions` link points to, falling back to the raw `target` for path-style links; a genuinely-broken link whose target resolves to no node is excluded); `issues` carries those touching `nodes`. `truncated` is `total > cap`. Lets the SPA render a multi-folder selection without hydrating the full `ScanResult`. DB absent → empty branch (zero nodes). Validation: `limit` integer ≥ 1 else 400 `bad-query`. |
631
637
  | `GET /api/graph?format=ascii\|json\|md` | implemented | formatter-rendered graph. `Content-Type` per format: `text/plain` (ascii), `application/json` (json), `text/markdown` (md / mermaid). Default `format=ascii`. Unknown format → 400 `bad-query`. |
632
638
  | `GET /api/config` | implemented | `RestEnvelope` (`kind: 'config'`), merged effective config (defaults → user → user-local → project → project-local → override). |
633
- | `GET /api/plugins` | implemented | `RestEnvelope` (`kind: 'plugins'`), list of installed plugins (built-in + drop-in) with status. Item shape: `{ id, version, kinds, status, reason, source: 'built-in'\|'project', description?: string, locked?: boolean, startsAsDisabled?: boolean, extensions?: Array<{ id, kind, version, enabled, description?: string, stability?: 'experimental'\|'beta'\|'stable'\|'deprecated', locked?: boolean }> }`. The plugin row has no granular toggle axis; its `status` aggregates the children (`'enabled'` when at least one extension is enabled, else `'disabled'`). The `description` carries the manifest-declared description (built-ins: hardcoded on `IBuiltInPlugin`; drop-ins: `plugin.json#/description`); each `extensions[]` entry carries its manifest's `description` per `IExtensionBase` (`extensions/base.schema.json#/properties/description`), plus the optional `stability` lifecycle label per `extensions/base.schema.json#/properties/stability` (omitted when undeclared; missing means `stable`. The SPA badges only non-default values, `experimental` / `beta` / `deprecated`, next to the extension row; `stable` renders nothing. Presentation-only EXCEPT `experimental` and `deprecated`, which each flip the extension's installed default to disabled). The SPA's Settings list renders descriptions as muted secondary text and indexes them for substring search alongside the ids. The `extensions` array is present whenever the plugin declares any extension AND loaded successfully. Each entry's `enabled` reflects the per-extension override resolution (DB > settings.json > installed default, where the default is `false` for `experimental` and `deprecated` extensions and `true` otherwise). The optional `locked: true` flag is stamped when the plugin id (or qualified extension id) appears in the host's lock-list (`src/server/locked-plugins.ts`); locked items render the toggle disabled in the SPA and any `PATCH` returns `403 locked`. Omitted when false. The optional `startsAsDisabled: true` flag is stamped on drop-in plugins (never built-ins) whose discovery-time `status` was `'disabled'`, i.e. every extension was disabled in `config_plugins` / `settings.json` at `sm serve` boot, so the handlers were never bucketed into the runtime. The SPA renders a per-row hint when this flag is set AND the user re-enables at least one of the plugin's extensions in the buffered state, since re-enabling requires `sm serve` restart (the rest of the toggle pipeline applies live). Omitted when false. |
634
- | `PATCH /api/plugins/:id` | implemented | **Bundle (aggregate) macro endpoint**: fans the toggle out across every extension inside the plugin. `:id` MUST be a top-level plugin id (no slash); qualified-id form is the sibling route below. Body `{ enabled: boolean }` (JSON). Persists one `config_plugins` row per child extension (`<plugin>/<ext>`) in a single transaction; locked children are silently dropped, mirroring the CLI's bulk-mode lock semantics. Response is the canonical `RestEnvelope` (`kind: 'plugins'`) reflecting the post-write state. **Lock**, rejected with 403 `locked` when the plugin id itself is in the host lock-list (`src/server/locked-plugins.ts`). **Apply window**, the override applies on the next scan; both the BFF and the watcher build a fresh resolver from `config_plugins` before composing extensions, so the toggle is honoured without restarting `sm serve`. The endpoint purges `scan_contributions` rows for each disabled extension immediately so the UI stops rendering its chips before the next scan. **Exception**, drop-in plugins whose discovery-time `status` was `'disabled'` (carried as `startsAsDisabled: true`) are NOT in the runtime extension buckets; re-enabling them via PATCH persists the override but requires `sm serve` restart for the handlers to load. The SPA surfaces this per-row. The endpoint does NOT broadcast a WS event today. |
639
+ | `GET /api/plugins` | implemented | `RestEnvelope` (`kind: 'plugins'`), list of installed plugins (built-in + drop-in) with status. Item shape: `{ id, version, kinds, status, reason, source: 'built-in'\|'project', description?: string, locked?: boolean, startsAsDisabled?: boolean, trusted?: boolean, extensions?: Array<{ id, kind, version, enabled, description?: string, stability?: 'experimental'\|'beta'\|'stable'\|'deprecated', locked?: boolean }> }`. The plugin row has no granular toggle axis; its `status` aggregates the children (`'enabled'` when at least one extension is enabled, else `'disabled'`). The `description` carries the manifest-declared description (built-ins: hardcoded on `IBuiltInPlugin`; drop-ins: `plugin.json#/description`); each `extensions[]` entry carries its manifest's `description` per `IExtensionBase` (`extensions/base.schema.json#/properties/description`), plus the optional `stability` lifecycle label per `extensions/base.schema.json#/properties/stability` (omitted when undeclared; missing means `stable`. The SPA badges only non-default values, `experimental` / `beta` / `deprecated`, next to the extension row; `stable` renders nothing. Presentation-only EXCEPT `experimental` and `deprecated`, which each flip the extension's installed default to disabled). The SPA's Settings list renders descriptions as muted secondary text and indexes them for substring search alongside the ids. The `extensions` array is present whenever the plugin declares any extension AND loaded successfully. Each entry's `enabled` reflects the per-extension config resolution (`settings.local.json` over `settings.json` over installed default, where the default is `false` for `experimental` and `deprecated` extensions and `true` otherwise); enable no longer reads from the DB. The optional `locked: true` flag is stamped when the plugin id (or qualified extension id) appears in the host's lock-list (`src/server/locked-plugins.ts`); locked items render the toggle disabled in the SPA and any `PATCH` returns `403 locked`. Omitted when false. The optional `trusted: true` flag is stamped on a drop-in plugin that carries a local import-trust grant (a `config_plugins` trust row or the `pluginTrust.projectEnabled` opt-in); omitted when false, so an untrusted project-local plugin reads `trusted` absent (built-ins omit it, they are never trust-gated). The optional `startsAsDisabled: true` flag is stamped on a drop-in plugin (never built-ins) that was config-disabled at `sm serve` boot (discovery-time `status: 'disabled'` for a reason OTHER than untrust), so the handlers were never bucketed into the runtime; an untrusted plugin carries the one-time untrusted boot notice instead and does NOT get `startsAsDisabled`. The SPA renders a per-row hint when `startsAsDisabled` is set AND the user re-enables at least one of the plugin's extensions in the buffered state, since re-enabling requires `sm serve` restart (the rest of the toggle pipeline applies live). Omitted when false. |
640
+ | `PATCH /api/plugins/:id` | implemented | **Bundle (aggregate) macro endpoint**: fans the toggle out across every extension inside the plugin. `:id` MUST be a top-level plugin id (no slash); qualified-id form is the sibling route below. Body `{ enabled: boolean }` (JSON). Writes the per-extension `enabled` (`plugins.<id>.extensions.<ext>.enabled`) for every child to the config layers (the shared `settings.json`); locked children are silently dropped, mirroring the CLI's bulk-mode lock semantics. Response is the canonical `RestEnvelope` (`kind: 'plugins'`) reflecting the post-write state. **Lock**, rejected with 403 `locked` when the plugin id itself is in the host lock-list (`src/server/locked-plugins.ts`). **Apply window**, the override applies on the next scan; both the BFF and the watcher build a fresh resolver from the config layers before composing extensions, so the toggle is honoured without restarting `sm serve`. The endpoint purges `scan_contributions` rows for each disabled extension immediately so the UI stops rendering its chips before the next scan. **Exception**, drop-in plugins whose discovery-time `status` was `'disabled'` (carried as `startsAsDisabled: true`) are NOT in the runtime extension buckets; re-enabling them via PATCH persists the override but requires `sm serve` restart for the handlers to load. The SPA surfaces this per-row. The endpoint does NOT broadcast a WS event today. |
635
641
  | `PATCH /api/plugins/:pluginId/extensions/:extensionId` | implemented | Canonical per-extension toggle. Body `{ enabled: boolean }`. Both segments are URL-path-segment-encoded (no slash inside `:pluginId` or `:extensionId`). 404 `not-found` when the plugin id is unknown or the extension id does not belong to that plugin. **Lock**, rejected with 403 `locked` when either the plugin id or the qualified `pluginId/extensionId` appears in the host lock-list. Same persistence + apply-window semantics as the bundle macro form (including the `startsAsDisabled` exception). The SPA's buffered Settings modal posts here for every per-row flip. |
636
- | `PATCH /api/plugins` | implemented | Bulk toggle. Body `{ "changes": Array<{ "id": string, "enabled": boolean }> }` where each `id` is either a bare plugin id (bundle cascade macro, same semantics as `PATCH /api/plugins/:id`) or a qualified `<plugin>/<extension>` id. Empty `changes` is a no-op (returns the current `GET /api/plugins` envelope). The route validates the **entire batch** before writing; any invalid entry (`unknown-plugin` / `locked`) rejects the whole request with the offending id in `error.details.id`, DB untouched. Valid batches apply in **one SQLite transaction**: bare plugin ids expand to child qualified ids before persistence; `IConfigPluginsPort.set` is called per resulting key, then one grouped `scan_contributions` purge per disabled extension (enables skip the purge). Response is the same `RestEnvelope` (`kind: 'plugins'`) shape as `GET /api/plugins`, reflecting the post-write state; the SPA replaces its modal state from it. Apply-window and `startsAsDisabled` exception semantics match the per-id routes. The single-id `PATCH /api/plugins/:id` and qualified-id sibling stay available for CLI / external automation; the bulk variant lets the SPA stage edits in a buffered modal and ship the final delta atomically. |
642
+ | `PATCH /api/plugins` | implemented | Bulk toggle. Body `{ "changes": Array<{ "id": string, "enabled": boolean }> }` where each `id` is either a bare plugin id (bundle cascade macro, same semantics as `PATCH /api/plugins/:id`) or a qualified `<plugin>/<extension>` id. Empty `changes` is a no-op (returns the current `GET /api/plugins` envelope). The route validates the **entire batch** before writing; any invalid entry (`unknown-plugin` / `locked`) rejects the whole request with the offending id in `error.details.id`, DB untouched. Valid batches apply in **one SQLite transaction**: bare plugin ids expand to child qualified ids before persistence; the per-extension `enabled` is written to the config layers per resulting key, then one grouped `scan_contributions` purge per disabled extension (enables skip the purge). Response is the same `RestEnvelope` (`kind: 'plugins'`) shape as `GET /api/plugins`, reflecting the post-write state; the SPA replaces its modal state from it. Apply-window and `startsAsDisabled` exception semantics match the per-id routes. The single-id `PATCH /api/plugins/:id` and qualified-id sibling stay available for CLI / external automation; the bulk variant lets the SPA stage edits in a buffered modal and ship the final delta atomically. |
643
+ | `PATCH /api/plugins/:id/trust` | implemented | Plugin-level LOCAL import-trust toggle (the security axis, separate from enable). `:id` MUST be a bare plugin id (no slash). Body `{ trusted: boolean }` (JSON). Writes (true) or clears (false) the plugin's row in the `config_plugins` (DB) trust store. Built-ins and locked ids are rejected with 403 `locked` (they are never trust-gated). Response is the canonical `RestEnvelope` (`kind: 'plugins'`) reflecting the post-write `trusted` projection. Granting trust lets an enabled plugin's code import on the next scan / `sm serve` restart (handlers load on restart, like the `startsAsDisabled` case); revoking reverts the plugin to discovered-but-unexecuted. Does NOT touch the enable axis. The local opt-in `pluginTrust.projectEnabled` (a project-local-only config key, surface-expanding, gated by the `confirm-required` flow) is the bulk alternative, set through the project-preferences route, not here. |
637
644
  | `ALL /api/*` (other) | reserved | structured 404 envelope (see below); future endpoints land in subsequent sub-steps. |
638
645
  | `GET /ws` | implemented (v14.4.a) | accepts WebSocket upgrade and registers the client with the BFF broadcaster. Server-push only, the server fans `scan.*` (and forthcoming `issue.*`) events to every connected client. See **WebSocket protocol** below. |
639
646
  | `GET *` | implemented | static asset from the resolved UI bundle, falling back to `index.html` for SPA deep links. |
package/db-schema.md CHANGED
@@ -431,22 +431,22 @@ The BFF's `/api/nodes` route loads the full set of favorited paths once per requ
431
431
 
432
432
  ### `config_plugins`
433
433
 
434
- Persists user-toggled enable/disable overrides. Discovery is still filesystem-based; this table records user intent.
434
+ Records the operator's LOCAL import-trust grants for project-local drop-in plugins. This is the **security** axis (may THIS machine import and run the plugin's code), NOT the operational enable/disable toggle, which lives in the config layers (`plugins.<id>.enabled` / `plugins.<id>.extensions.<ext>.enabled` in `settings.json` / `settings.local.json`). Discovery is still filesystem-based; this table records per-machine consent. The table name is retained for continuity.
435
435
 
436
436
  | Column | Type | Constraint |
437
437
  |---|---|---|
438
438
  | `plugin_id` | TEXT | PRIMARY KEY |
439
- | `enabled` | INTEGER | NOT NULL DEFAULT 1 |
440
- | `config_json` | TEXT | NULL |
439
+ | `trusted` | INTEGER | NOT NULL DEFAULT 0, CHECK (`trusted` IN (0,1)) |
441
440
  | `updated_at` | INTEGER | NOT NULL |
442
441
 
443
- **Effective enable/disable resolution.** A plugin is enabled iff the highest-precedence layer that mentions it says so. Order from highest to lowest:
442
+ **Effective trust resolution.** A project-local plugin's code is imported iff it is **enabled** (config layers) AND it is **trusted** locally. A plugin is trusted iff either:
444
443
 
445
- 1. `config_plugins.enabled` for the row whose `plugin_id` matches, written by `sm plugins enable/disable`. Local-machine user override; never committed (the DB is gitignored unless the team removes the `.gitignore` entry).
446
- 2. `.skill-map/settings.json#/plugins/<id>/enabled`, committed team-shared baseline.
447
- 3. Installed default, every discovered plugin is enabled until told otherwise.
444
+ 1. A `config_plugins` row with `trusted = 1` exists for its `plugin_id`, written by `sm plugins trust` (cleared by `sm plugins untrust`). Keyed by the **bare plugin id** (trust is per-plugin; a qualified `<plugin>/<ext>` collapses to its plugin), OR
445
+ 2. the local opt-in `pluginTrust.projectEnabled` (project-local-only config, honoured only from `settings.local.json`) is set, which trusts every plugin the project enables.
448
446
 
449
- The DB takes precedence over `settings.json` so a developer can locally disable a misbehaving plugin without committing the toggle to the team's config. Conversely, a team baseline that explicitly enables a plugin is overridable per-machine, no agreement required to experiment.
447
+ The store is structurally LOCAL: the DB never travels in a commit and is not a config layer, so a cloned repo's committed `settings.json` can never grant import trust to its own plugins (the supply-chain guard). Built-ins and `--plugin-dir` are not trust-gated. See [`architecture.md` §Locality](./architecture.md) (plugin enable vs import trust).
448
+
449
+ Greenfield note: this table previously stored the enable/disable toggle (`enabled` plus a vestigial `config_json`); both were dropped when enable moved to the config layers. Per the pre-1.0 greenfield posture the redefinition is applied inline to `001_initial.sql` with no migration file; the `scan_meta.schema_fingerprint` drift path rebuilds the cache on the first scan.
450
450
 
451
451
  ### `config_preferences`
452
452
 
package/index.json CHANGED
@@ -174,14 +174,14 @@
174
174
  }
175
175
  ]
176
176
  },
177
- "specPackageVersion": "0.62.1",
177
+ "specPackageVersion": "0.64.0",
178
178
  "integrity": {
179
179
  "algorithm": "sha256",
180
180
  "files": {
181
- "CHANGELOG.md": "94c7b5d10677512dcd3815fcf6971821a656ac9d55e76bc150a7e5ddcd0375f9",
181
+ "CHANGELOG.md": "44f2ede341c9c7affd4aaed8ce0aa0a2145feed820bb050e03b3261ff31ba9e3",
182
182
  "README.md": "a790cd010b46d47883d1f37e3893cea9d7aa69ec4750c0202e6a0c99991e7980",
183
- "architecture.md": "434954b096c0f34752002996356d2d8e7cf578b345f62525fe80a50a72ccb6ba",
184
- "cli-contract.md": "630ecf6598a17e5df941c3ad630cbec519ef5e6e57207a81996ef881aec4e6be",
183
+ "architecture.md": "5c4da8eba842994654ba112debe0fc3271228fe9b7f289c17aa8f3ecc0d5a648",
184
+ "cli-contract.md": "198469bab4a9a1cc31870136bf9a5fb90ea9bc80372c2926a65564c2f6c5b217",
185
185
  "conformance/README.md": "dcbef7249f161acf597552a05dcadc813cd0ced430dcd3f813fcf5e1c876335d",
186
186
  "conformance/cases/backtick-path-extraction.json": "4620e7f8bc161fc57cb44001e9d99879c7e22b4865a0c27a20dc28969cd936d9",
187
187
  "conformance/cases/extractor-collision-detection.json": "179a02c61892f0d26492de0c4e2c327fa6b4986d1265a8f119e871df6afe4658",
@@ -230,16 +230,16 @@
230
230
  "conformance/fixtures/view-contribution-payloads/notes/example.md": "312b1919cd7fd0f233648b053acfb2975662ede3c65dd391cc508204b67ad6fb",
231
231
  "conformance/fixtures/view-slots-all/.skill-map/plugins/all-slots/analyzers/everything/index.js": "ea0022fec7f0fd5a26ba12db1310335f434f2f820682206a3a9542d98db0d346",
232
232
  "conformance/fixtures/view-slots-all/.skill-map/plugins/all-slots/plugin.json": "c48e8a0574947ade0b4eb189d6bc27a48e24f92f616aacdc177f2d22d472a599",
233
- "db-schema.md": "4c0f4df57f98b0b91a97382a4570f6178179a2f5282744a1d39d3b123ad18445",
233
+ "db-schema.md": "05a4e323877d391180201f08eca9a226bd70f6ca593e1052602c54827685ef70",
234
234
  "interfaces/security-scanner.md": "0996dd782e2d39d4791f2e290da4bb1a68a5b30c1f79187977188ec8e3fe6ef2",
235
235
  "job-events.md": "2c7017f5f0003b19653424111a07043487173cbe88b51e961598bb1693987059",
236
236
  "job-lifecycle.md": "ce33bc8bb5090ea183f860e495bfccc2a4a0ac2e23f6ebad83b9c28aad59124e",
237
- "plugin-author-guide.md": "9244ee6aef49100867d46b324e13b3c3413a071813e34889563d4bfcef78c7f0",
237
+ "plugin-author-guide.md": "75ffa1ca20a46b1097fd85f0f99d43875f50b1c9a4f31fad5962ccae31c7c64d",
238
238
  "plugin-kv-api.md": "5e095581020043af73ff028e272f56d42ca9eb6e506dd777d45703f9db796a5b",
239
239
  "plugin-quickstart.md": "19092b278d80df357ea623dc3bd9f833d059582ee1356f317621913d91e50512",
240
240
  "prompt-preamble.md": "5d0f836688aa23eafc32104c3174132340b268361f6060326eec84da17c6ad6d",
241
241
  "schemas/annotations.schema.json": "09fcebc86e3b793bf9f03a35b38e5ca2a08d79ac3504f6f03895ac2ae1c2aded",
242
- "schemas/api/rest-envelope.schema.json": "28b9155476dbeca18b51ba019deb5cfa687503574f8b1d691830c60746ede9d4",
242
+ "schemas/api/rest-envelope.schema.json": "994bee11c3809210416489a97d9a2d1ba418f80366b4130adf0568e7afa9fd03",
243
243
  "schemas/bump-report.schema.json": "c763e1f89f2665c479d6a4985c1d324c65e5278331ebab82220287a07e4c4429",
244
244
  "schemas/conformance-case.schema.json": "958b316d646d0c64a715a7a28cee66d2c2d2498a60dbfc5ae8970687c2a96954",
245
245
  "schemas/conformance-result.schema.json": "14f983a8f4e62cd4ff964688c9b2b026a3bee3a0b762b64091c8c34db5b75777",
@@ -251,7 +251,7 @@
251
251
  "schemas/extensions/formatter.schema.json": "880dc379ad545a62404403533a01eda5171edba0390561fc46ec6e986e0b9bd3",
252
252
  "schemas/extensions/hook.schema.json": "f56aef59e9986ffdf7d86aa2e048dccccf217000a358b8c64737cbd911c48dad",
253
253
  "schemas/extensions/provider-kind.schema.json": "499b2418bbe6d8a84a1608e26c56b52c2652a30ce314bc2989094418797dc1e6",
254
- "schemas/extensions/provider.schema.json": "33f189cb6d6e987d7af1d5a8968834f92ba674f5621953aaa1f84d3a079a53e3",
254
+ "schemas/extensions/provider.schema.json": "a45361a5a3c5fabeb5c4881ebfecc8cbb8a83daf232e949f8735c748a66e4016",
255
255
  "schemas/frontmatter/base.schema.json": "47f05ffa2a51f465f1b8df70cc7a1e7afe2c40f8d37826cd8a569977e9036b8d",
256
256
  "schemas/history-stats.schema.json": "436aa0ffe744bdb699000447e86b45724fbd2cc4642781074eb1527522b9058c",
257
257
  "schemas/input-types.schema.json": "93b27a1cbd1f131d42730eb9a89cf3af6889e9f17b20a48ce36133885503e01b",
@@ -261,7 +261,7 @@
261
261
  "schemas/node.schema.json": "1ebba38e0c0ae022fccbc0cdf7c298da1720a68d4cb375f0baf9f0847998a0d8",
262
262
  "schemas/plugins-doctor.schema.json": "03e2dc51c052a09bf0198c80e2c26e6129734ada4a748e483245de3dd8576c42",
263
263
  "schemas/plugins-registry.schema.json": "211d081691fc83526e1593c79ed9741ad8a5dbd4db1a756f72141b0cced2ea15",
264
- "schemas/project-config.schema.json": "67e4d83d69b336bc2017cc438ba6d562445fd97061e7e543a16d4e951b7047fc",
264
+ "schemas/project-config.schema.json": "6b654c0aa5ad4cd950166804327b41059f0c5a1d84e1b62ee1015c22a1c100ba",
265
265
  "schemas/refresh-report.schema.json": "47184d4f6b15e9b7671dc178b3b3886a64422da198898508ecdb2cb27876db04",
266
266
  "schemas/report-base-deterministic.schema.json": "59785fe6f3ceb34814bbbd03d10fa7336a32835ce598946f2923d469b32aa32a",
267
267
  "schemas/report-base.schema.json": "e4d25f055e24f18ae0f77c24661c1bddc87ff2e43b001b6a827fcb14f9753f44",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skill-map/spec",
3
- "version": "0.62.1",
3
+ "version": "0.64.0",
4
4
  "description": "JSON Schemas, prose contracts, and conformance suite for the skill-map specification.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -60,7 +60,7 @@ The kernel scans one root: `<cwd>/.skill-map/plugins/`, committed-with-the-repo
60
60
 
61
61
  A plugin is any direct child directory of that root containing a `plugin.json`. Nested directories are not searched recursively. Pass `--plugin-dir <path>` to replace the default root with a custom directory (mostly for testing, or a plugin set the operator opts into).
62
62
 
63
- **Import trust.** A project-local plugin is discovered (manifest parsed, listed by `sm plugins list`) but its code is NOT imported by the runtime verbs (`sm scan`, `sm serve`, ...) until the operator trusts it locally with `sm plugins enable <id>` (or the Settings toggle), which writes a `config_plugins` override. This is a security boundary: cloning a repo and scanning it must not auto-execute the repo's plugins, so the committed `settings.json` baseline cannot grant import trust, only the local DB override can. Authors developing a plugin enable it once locally; `--plugin-dir` is not gated. See [`architecture.md` §Locality](./architecture.md).
63
+ **Import trust.** A project-local plugin is discovered (manifest parsed, listed by `sm plugins list`) but its code is NOT imported by the runtime verbs (`sm scan`, `sm serve`, ...) until the operator trusts it locally with `sm plugins trust <id>` (or the per-plugin Trust control in the Settings UI), which writes a per-plugin row in the `config_plugins` (DB) trust store. This is a security boundary, and a SEPARATE axis from enable/disable (the operational toggle, which lives in the config layers): a plugin runs only when it is both enabled and trusted. Cloning a repo and scanning it must not auto-execute the repo's plugins, so the committed `settings.json` baseline cannot grant import trust, only the local DB trust store (or the local opt-in `pluginTrust.projectEnabled`) can. Authors developing a plugin trust it once locally; `--plugin-dir` is not gated. See [`architecture.md` §Locality](./architecture.md).
64
64
 
65
65
  After every change to `plugins/`, run `sm plugins list` to see each plugin's load status. The seven statuses are documented under [Diagnostics](#diagnostics).
66
66
 
@@ -94,7 +94,7 @@ Examples from the reference impl's built-in extensions:
94
94
  Built-ins split between two namespaces:
95
95
 
96
96
  - **`core/`**, kernel-internal primitives, platform-agnostic: every built-in analyzer, the ASCII formatter, the cross-vendor extractors (`annotations`, `markdown-link`, `backtick-path`, `external-url-counter`), the universal `markdown` Provider fallback, and the `update-check` hook.
97
- - **`claude/`**, the Claude Code Provider plugin: the Provider plus the Claude-flavoured extractors (`slash-command`, `at-directive`). Other vendor plugins (`antigravity`, `codex`, `agent-skills`) follow the same shape (Provider only).
97
+ - **`claude/`**, the Claude Code Provider plugin: the Provider plus the Claude-flavoured extractors (`slash-command`, `at-directive`). **`codex/`** ships the Provider plus its OWN grammar extractors (`dollar-skill` for `$skill` invocation, `at-file` for `@`-file references), because Codex's invocation grammar differs from Claude's (`/` is a built-in command, `@` is a file picker). The other vendor plugins (`antigravity`, `agent-skills`) are Provider-only: Antigravity reuses claude's `/command` parser via its precondition list (it shares the `/`-invoke grammar), and the neutral `agent-skills` lens relies on the universal `core/` extractors only.
98
98
 
99
99
  ### Extension id shape
100
100
 
@@ -115,7 +115,7 @@ Two id shapes resolve at the toggle surface:
115
115
 
116
116
  `--all` is the cascade variant: expands to every extension in every discovered plugin under the same `--yes` / TTY-confirm gate.
117
117
 
118
- Resolution order per id: DB override (`config_plugins`) > `settings.json#/plugins/<id>/enabled` > installed default. The installed default is `true` for ordinary extensions and `false` for extensions declaring `stability: 'experimental'` or `stability: 'deprecated'` (they ship disabled until the operator opts in; see [Extension manifests](#extension-manifests)). Persisted toggle keys are always qualified `<plugin>/<ext>` ids (the bundle macro expands at write time).
118
+ Resolution order per id (the operational ENABLE axis): per-extension config (`plugins.<id>.extensions.<ext>.enabled`) > plugin-level config (`plugins.<id>.enabled`) > installed default, resolved through the config layers (`settings.local.json` over `settings.json`). The installed default is `true` for ordinary extensions and `false` for extensions declaring `stability: 'experimental'` or `stability: 'deprecated'` (they ship disabled until the operator opts in; see [Extension manifests](#extension-manifests)). Enable no longer reads from the DB; the `config_plugins` table now holds only the per-plugin import-trust grant (the security axis, see Import trust above). Persisted enable keys are written per qualified `<plugin>/<ext>` (the bundle macro expands at write time).
119
119
 
120
120
  ### Extractor / Analyzer / Action `precondition`, narrow the pipeline
121
121
 
@@ -214,7 +214,7 @@ The kernel knows six categories. Each has a JSON Schema under [`schemas/extensio
214
214
 
215
215
  The runtime instance you `export default` includes both the manifest fields (`version`, `description`, plus kind-specific metadata) AND the runtime method. The kernel strips function-typed properties before AJV-validating the manifest, so the method lives beside metadata.
216
216
 
217
- Base manifest fields shared by every kind (normative shape in [`schemas/extensions/base.schema.json`](./schemas/extensions/base.schema.json)): `version` (required for external plugins), `description` (required), and the optional `stability`, `order`, `annotation`, `settings`. `stability` (`'experimental' | 'beta' | 'stable' | 'deprecated'`, default `stable`) is a lifecycle label: the non-default values render as a badge next to the extension in `sm plugins list <id>` / `sm plugins show` and the Settings plugins panel. Presentation-only for `beta` and `stable`, but `experimental` and `deprecated` additionally flip the extension's installed default to DISABLED: it does not load (does not run, does not register, toggle shows off) until the operator opts in via `sm plugins enable <plugin>/<ext>`, the Settings toggle, or a `settings.json` / `config_plugins` override. The opt-in wins over the installed default, so a `deprecated` extension can be kept running during a migration. A stable extension omits the field; declaring `stability: 'stable'` is valid but renders nothing.
217
+ Base manifest fields shared by every kind (normative shape in [`schemas/extensions/base.schema.json`](./schemas/extensions/base.schema.json)): `version` (required for external plugins), `description` (required), and the optional `stability`, `order`, `annotation`, `settings`. `stability` (`'experimental' | 'beta' | 'stable' | 'deprecated'`, default `stable`) is a lifecycle label: the non-default values render as a badge next to the extension in `sm plugins list <id>` / `sm plugins show` and the Settings plugins panel. Presentation-only for `beta` and `stable`, but `experimental` and `deprecated` additionally flip the extension's installed default to DISABLED: it does not load (does not run, does not register, toggle shows off) until the operator opts in via `sm plugins enable <plugin>/<ext>`, the Settings toggle, or a `settings.json` / `settings.local.json` enable override. The opt-in wins over the installed default, so a `deprecated` extension can be kept running during a migration. A stable extension omits the field; declaring `stability: 'stable'` is valid but renders nothing.
218
218
 
219
219
  ### Extractors
220
220
 
@@ -183,6 +183,12 @@
183
183
  "type": "string",
184
184
  "minLength": 1,
185
185
  "description": "Name of the parsed-frontmatter field that carries this Provider's node body, projected from its `read.bodyField` (see `provider.schema.json`). Present only for Providers whose prompt lives inside structured frontmatter rather than after a fence (OpenAI Codex sub-agents are pure TOML whose markdown prompt is `developer_instructions`). The UI uses it to render that field as the node body and to omit it from the metadata dump; absent for ordinary frontmatter-fence Providers."
186
+ },
187
+ "invocationSigil": {
188
+ "type": "string",
189
+ "minLength": 1,
190
+ "maxLength": 1,
191
+ "description": "Single glyph this lens's runtime uses to invoke a skill / command, projected from its `presentation.invocationSigil` (see `provider.schema.json`). The UI joins it against the active lens to paint the `invokes` edge-kind glyph (and tooltip example) in the link-kind palette: `/` for `claude` / `antigravity`, `$` for `codex`. Absent for lenses with no invocation channel (`agent-skills`, `markdown`), under which no `invokes` edge arises."
186
192
  }
187
193
  }
188
194
  }
@@ -71,6 +71,12 @@
71
71
  "hideChip": {
72
72
  "type": "boolean",
73
73
  "description": "When `true`, the UI does NOT paint this Provider's chip on node cards. Reserved for the universal base Provider (`markdown`): the majority of nodes in any project carry it, so badging every generic `.md` would be visual noise and dilute the chip's purpose (signalling when a node came from a NON-default platform). The markdown base is non-gated (`gatedByActiveLens: false`, `isLens: false`), so it is not a selectable lens and never appears in the active-lens dropdown or topbar lens chip either; `hideChip` additionally suppresses its per-card badge. Defaults to `false` (chip shown)."
74
+ },
75
+ "invocationSigil": {
76
+ "type": "string",
77
+ "minLength": 1,
78
+ "maxLength": 1,
79
+ "description": "Single glyph the lens's runtime uses to invoke a skill / command, surfaced as the `invokes` edge-kind glyph (and its tooltip example) in the link-kind palette so the operator recognises this lens's source syntax instantly. `/` for the slash-invoking lenses (`claude` commands + skills, `antigravity` skills + workflows), `$` for `codex` (whose skills are invoked `$skill`, with `/` reserved for Codex's own built-in commands). Omitted for lenses with no `/`/`$` invocation channel (the open-standard `agent-skills`, where skills activate by `description`, and the non-lens `markdown` base): under those lenses no `invokes` edge arises, so the palette never paints the glyph. Mirrors `IProviderUi.invocationSigil`; projected into `providerRegistry` (`api/rest-envelope.schema.json`) and joined client-side against the active lens."
74
80
  }
75
81
  }
76
82
  },
@@ -78,13 +84,17 @@
78
84
  "type": "object",
79
85
  "required": ["markers"],
80
86
  "additionalProperties": false,
81
- "description": "Auto-detection markers for the active-provider lens. The lens resolver checks each marker path (relative to the scope root) and, when present, suggests this Provider as a candidate lens. Replaces the former hardcoded detection table: the set of detectable Providers now derives from the registered Providers themselves. Optional, a Provider with no `detect` block is never auto-suggested (it can still be selected manually). When several Providers match, the resolver returns the full candidate list in Provider iteration order and the first match is the default suggestion.",
87
+ "description": "Auto-detection markers for the active-provider lens. The lens resolver checks each marker path (relative to the scope root) and, when present, suggests this Provider as a candidate lens. Replaces the former hardcoded detection table: the set of detectable Providers now derives from the registered Providers themselves. Optional, a Provider with no `detect` block is never auto-suggested (it can still be selected manually). When several Providers match, the resolver returns the full candidate list in Provider iteration order and the first match is the default suggestion. **Fallback precedence**: a Provider whose `detect.fallback` is `true` (the open-standard `agent-skills` lens, whose `.agents/` marker is also the shared skill home that vendor lenses populate) is dropped from the candidate list whenever any non-fallback (vendor) Provider also matched, so a project carrying `.codex/` alongside `.agents/` resolves to `codex` outright instead of prompting `codex` vs `agent-skills`. The fallback only stands when no vendor marker is present; several vendor markers still produce a genuine ambiguous list.",
82
88
  "properties": {
83
89
  "markers": {
84
90
  "type": "array",
85
91
  "minItems": 1,
86
92
  "description": "Paths relative to the scope root whose existence signals this Provider's presence (e.g. `['.claude']`, `['.codex', 'AGENTS.md']`). A directory or a file both count; existence is the only test.",
87
93
  "items": { "type": "string", "minLength": 1 }
94
+ },
95
+ "fallback": {
96
+ "type": "boolean",
97
+ "description": "When `true`, this Provider is the open-standard FALLBACK lens: its markers produce a detection candidate ONLY when no non-fallback (vendor) Provider matched under the same scope. Reserved for `agent-skills`, whose `.agents/` marker is the shared open-standard skill home that vendor lenses (`codex`, `antigravity`) also populate; without this flag a `.codex/` + `.agents/` project would falsely read as an ambiguous `codex` vs `agent-skills` pair. Vendor Providers omit it (default `false`) so two vendor markers still surface a real ambiguous prompt. Mirrors `IProviderDetect.fallback`."
88
98
  }
89
99
  }
90
100
  },
@@ -85,11 +85,15 @@
85
85
  "enabled": { "type": "boolean" },
86
86
  "extensions": {
87
87
  "type": "object",
88
- "description": "Per-extension overrides, keyed by extension id (the leaf folder name, NOT the qualified `<plugin>/<ext>` id, the plugin is already the parent key). Today only `settings`; a future per-extension `enabled` filter (the deferred Phase 4+ work) would slot in alongside it.",
88
+ "description": "Per-extension overrides, keyed by extension id (the leaf folder name, NOT the qualified `<plugin>/<ext>` id, the plugin is already the parent key). Carries the per-extension `enabled` toggle (operational) and the operator-supplied `settings` values.",
89
89
  "additionalProperties": {
90
90
  "type": "object",
91
91
  "additionalProperties": false,
92
92
  "properties": {
93
+ "enabled": {
94
+ "type": "boolean",
95
+ "description": "Per-extension operational on/off, resolved over the plugin-level `enabled` and the extension's installed default (`false` for `experimental` / `deprecated`, `true` otherwise). Shareable: lands in `settings.json` (team baseline) or `settings.local.json` (per-checkout override) via the normal config layering. This is the OPERATIONAL axis only; it does NOT grant import trust for a project-local plugin (see the top-level `pluginTrust`)."
96
+ },
93
97
  "settings": {
94
98
  "type": "object",
95
99
  "description": "Operator-supplied values for the extension's declared settings, keyed by settingId. Values are intentionally NOT validated by this schema: the kernel's settings resolver validates each value against the per-type value schema of the input-type the manifest declares (`input-types.schema.json#/$defs/ISettingDeclaration`), since this schema cannot know which type a given settingId picked. A non-`secret` setting lands here in `settings.json` (team-shared) or `settings.local.json` (per-checkout override) via the normal config layering; `secret` settings are NOT stored here, they ride the dedicated encrypted `state_secrets` path (see `input-types.schema.json#/$defs/Setting_Secret`).",
@@ -101,6 +105,17 @@
101
105
  }
102
106
  }
103
107
  },
108
+ "pluginTrust": {
109
+ "type": "object",
110
+ "additionalProperties": false,
111
+ "description": "Local, per-machine plugin import-trust preferences. NOT part of the shareable enable/disable axis. Project-local ONLY: the config loader strips `pluginTrust` from the committed `project` layer (it is honoured only from `settings.local.json`), so a cloned repo can never auto-grant import trust to its own project-local plugins. Setting `projectEnabled: true` expands the local execution surface and is gated behind a confirm (CLI `--yes`, UI confirm dialog), like `scan.referencePaths`.",
112
+ "properties": {
113
+ "projectEnabled": {
114
+ "type": "boolean",
115
+ "description": "When true, every plugin this project ENABLES is treated as locally trusted, so its code may be imported without an explicit per-plugin trust grant in the `config_plugins` (DB) trust store. Default false. The operator's local opt-in for teams that vet plugins in code review. Project-local only (stripped from the committed `project` layer)."
116
+ }
117
+ }
118
+ },
104
119
  "jobs": {
105
120
  "type": "object",
106
121
  "additionalProperties": false,