@skill-map/spec 0.12.0 → 0.13.1

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/architecture.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Architecture
2
2
 
3
- Normative description of skill-map's internal boundaries: the **kernel**, the **ports** it exposes, the **adapters** that drive and serve it, and the five **extension kinds** that live outside the kernel.
3
+ Normative description of skill-map's internal boundaries: the **kernel**, the **ports** it exposes, the **adapters** that drive and serve it, and the six **extension kinds** that live outside the kernel.
4
4
 
5
5
  Any conforming implementation — reference or third-party — MUST respect these boundaries. The conformance suite under [`conformance/`](./conformance/README.md) enforces the kernel-agnostic invariants; per-Provider suites (e.g. `src/extensions/providers/claude/conformance/` for the reference impl's Claude Provider) enforce the kind-catalog cases. Both are driven via `sm conformance run`.
6
6
 
@@ -150,7 +150,7 @@ Mode is a property of the extension as a whole, not of an individual call. **An
150
150
 
151
151
  Provider and Formatter are locked to deterministic because they sit at the **boundaries** of the system. A Provider resolves `path → kind` during boot; probabilistic classification would make the boot phase slow, costly, and non-reproducible. A formatter must produce diffable output (`sm scan` snapshots round-trip in CI). Probabilistic narrators of the graph are a valid product but they live in jobs and emit Findings, not in formatters.
152
152
 
153
- > **Naming note — `Provider` vs hexagonal `adapter`.** The extension kind formerly named `Adapter` is now `Provider`. The hexagonal-architecture term `adapter` (driving / driven adapters that implement ports — `RunnerPort.adapter`, `StoragePort.adapter`, `FilesystemPort.adapter`, `PluginLoaderPort.adapter`) is unchanged: those live in `kernel/adapters/` and are internal to the impl. A `Provider` is an **extension** authored by plugins; an **adapter** in the hexagonal sense is a **port implementation** internal to the kernel package. The two concepts share an architectural lineage (both bridge two worlds) but live in deliberately disjoint namespaces so plugin authors and impl maintainers never confuse them.
153
+ > **Naming note — `Provider` vs hexagonal `adapter`.** A `Provider` is an **extension** authored by plugins (it recognises a platform and declares its kind catalog). The hexagonal-architecture term `adapter` refers to **port implementations** internal to the kernel package — `RunnerPort.adapter`, `StoragePort.adapter`, `FilesystemPort.adapter`, `PluginLoaderPort.adapter` and lives under `kernel/adapters/`. The two concepts share an architectural lineage (both bridge two worlds) but live in deliberately disjoint namespaces so plugin authors and impl maintainers never confuse them.
154
154
 
155
155
  ### When each mode runs
156
156
 
@@ -182,12 +182,27 @@ Six kinds, all first-class, all loaded through the same registry. Each kind has
182
182
 
183
183
  ### Provider · `kinds` catalog
184
184
 
185
- Every `Provider` extension MUST declare a map `kinds: { <kind>: { schema: string, defaultRefreshAction: string } }` covering every `kind` it can classify into. Each entry has two required fields:
185
+ Every `Provider` MUST declare a non-empty map `kinds: { <kind>: { schema, defaultRefreshAction, ui } }` covering every `kind` it classifies into. Each entry carries three required fields:
186
186
 
187
- - **`schema`** — path to the kind's frontmatter JSON Schema, relative to the Provider's package directory. The schema MUST extend the spec's universal [`frontmatter/base.schema.json`](./schemas/frontmatter/base.schema.json) via `allOf` + `$ref` to base's `$id` so cross-package resolution works without copying base into every Provider. The kernel registers each Provider's schemas with AJV at scan boot and validates each node's frontmatter against the entry that matches its classified kind.
188
- - **`defaultRefreshAction`** — qualified action id (`<plugin-id>/<action-id>`) the UI's probabilistic-refresh surface (`🧠 prob`) dispatches for nodes of this kind. The referenced action MUST exist in the registry by the time the graph is queried; a dangling reference is a load-time error for the Provider (status `invalid-manifest`). Consumers dispatch `sm job submit <defaultRefreshAction> -n <nodePath>` when the user asks for a probabilistic refresh. Implementations MAY allow plugins to override the default per-node via `metadata.refreshAction`, but the Provider default is normative.
187
+ - **`schema`** — path (relative to the Provider package) to the kind's frontmatter JSON Schema. The schema MUST extend [`frontmatter/base.schema.json`](./schemas/frontmatter/base.schema.json) via `allOf` + `$ref` to base's `$id`. The kernel registers it with AJV at boot and validates every node's frontmatter against the entry matching its classified kind.
188
+ - **`defaultRefreshAction`** — qualified action id (`<plugin-id>/<action-id>`) the UI's probabilistic-refresh surface (`🧠 prob`) dispatches for nodes of this kind. The action MUST exist in the registry; a dangling reference disables the Provider with status `invalid-manifest`. Plugins MAY override per-node via `metadata.refreshAction`; the Provider default is normative.
189
+ - **`ui`** — presentation block: `{ label, color, colorDark?, emoji?, icon? }`. See §Provider · `ui` presentation below.
189
190
 
190
- The catalog is the single source of truth for "which kinds does this Provider emit" — the `IProvider` runtime contract derives the kind set from `Object.keys(kinds)`. Spec 0.8.0 (Phase 3 of plug-in model overhaul) replaced two earlier fields (`emits: string[]` and a flat `defaultRefreshAction: { <kind>: actionId }`) with this richer map; the catalog also subsumes per-kind frontmatter schemas, which previously lived in spec under `schemas/frontmatter/<kind>.schema.json`.
191
+ The catalog is the single source of truth for "which kinds does this Provider emit" — the `IProvider` runtime contract derives the kind set from `Object.keys(kinds)`.
192
+
193
+ ### Provider · `ui` presentation
194
+
195
+ Each `kinds[*].ui` entry declares how the UI renders nodes of that kind:
196
+
197
+ - **`label`** — short human name (e.g. `'Skill'`, `'Agent'`). Used in palette chips, list view, inspector header.
198
+ - **`color`** — base color (any CSS color string) for the kind. The UI derives bg / fg tints per theme via a deterministic helper, so the Provider declares one base color per theme rather than four hex values.
199
+ - **`colorDark?`** — optional dark-theme override. Defaults to `color` when omitted.
200
+ - **`emoji?`** — optional single-glyph emoji rendered alongside the label.
201
+ - **`icon?`** — optional discriminated union: either `{ kind: 'pi'; id: 'pi-…' }` (a PrimeIcons class id) or `{ kind: 'svg'; path: '…' }` (raw SVG path data wrapped by the UI in `viewBox="0 0 24 24"` and tinted with `currentColor`). The discriminator keeps UI dispatch exhaustive without string-sniffing; AJV validates each variant cleanly.
202
+
203
+ The `ui` block is required (not optional) by design: making it optional would force the UI to invent visuals for missing entries, silently collapsing unknown kinds to a default rendering and hiding manifest gaps. Forcing the Provider to declare presentation up-front means the UI never guesses.
204
+
205
+ The kernel ships every Provider's `ui` block to the BFF at boot; the BFF aggregates them into a `kindRegistry` map and embeds it 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.
191
206
 
192
207
  ### Provider · `explorationDir`
193
208
 
@@ -400,7 +415,9 @@ This is what makes "CLI-first" a coherent rule: every CLI verb is a kernel funct
400
415
 
401
416
  The **port list** is stable as of spec v1.0.0. Adding a sixth port is a major bump.
402
417
 
403
- The **extension kind list** (5 kinds) is stable as of spec v1.0.0. Adding a sixth kind is a major bump.
418
+ The **extension kind list** (6 kinds: Provider, Extractor, Rule, Action, Formatter, Hook) is stable as of spec v1.0.0. Adding a seventh kind is a major bump. Removing or renaming a kind is a major bump.
419
+
420
+ The **Hook curated trigger set** (eight events: `scan.started`, `scan.completed`, `extractor.completed`, `rule.completed`, `action.completed`, `job.spawning`, `job.completed`, `job.failed`) is stable as of spec v1.0.0. Adding a ninth trigger is a minor bump; removing or renaming any of the eight is a major bump.
404
421
 
405
422
  The **execution modes** (`deterministic` / `probabilistic`) and the per-kind mode capability matrix above are stable as of spec v1.0.0. Adding a third mode or changing which kinds are dual-mode is a major bump. Renaming or repurposing the mode enum values is a major bump.
406
423
 
package/cli-contract.md CHANGED
@@ -316,7 +316,7 @@ Destructive verbs (`reset --state`, `reset --hard`, `restore`) require interacti
316
316
 
317
317
  | Command | Purpose |
318
318
  |---|---|
319
- | `sm serve [--port N] [--host ...] [--scope project\|global] [--db <path>] [--no-built-ins] [--no-plugins] [--open\|--no-open] [--dev-cors] [--ui-dist <path>]` | Start Hono + WebSocket for the Web UI. Single-port mandate: SPA + REST + WS under one listener. Default port 4242, default host 127.0.0.1 (loopback-only through v0.6.0; multi-host deferred — see §Server). |
319
+ | `sm serve [--port N] [--host ...] [--scope project\|global] [--db <path>] [--no-built-ins] [--no-plugins] [--open\|--no-open] [--dev-cors] [--ui-dist <path>] [--no-watcher]` | Start Hono + WebSocket for the Web UI. Single-port mandate: SPA + REST + WS under one listener. Default port 4242, default host 127.0.0.1 (loopback-only through v0.6.0; multi-host deferred — see §Server). The watcher is on by default (Decision #121: a server with stale DB is a footgun); pass `--no-watcher` for CI / read-only deployments. |
320
320
 
321
321
  #### Server
322
322
 
@@ -326,14 +326,27 @@ The reference implementation ships a Hono BFF rooted at `src/server/`. One Node
326
326
 
327
327
  **Boot resilience**: `sm serve` boots even when the project DB is missing. `/api/health` reports `db: 'missing'` so the SPA can render an empty-state CTA instead of failing the connection. Explicit `--db <path>` that doesn't exist is the exception — that exits 5 (NotFound) per `§Exit codes`.
328
328
 
329
- **Endpoints (v14.1 surface)**:
329
+ **Endpoints (v14.2 surface)**:
330
330
 
331
331
  | Path | Status | Shape |
332
332
  |---|---|---|
333
333
  | `GET /api/health` | implemented | `{ ok: true, schemaVersion, specVersion, implVersion, scope: 'project'\|'global', db: 'present'\|'missing' }` |
334
- | `ALL /api/*` (other) | reserved | structured 404 envelope (see below); real endpoints land at v14.2 |
335
- | `GET /ws` | upgrade-only | accepts WebSocket upgrade and immediately closes; broadcaster lands at v14.4 |
336
- | `GET *` | implemented | static asset from the resolved UI bundle, falling back to `index.html` for SPA deep links |
334
+ | `GET /api/scan` | implemented | latest persisted `ScanResult` (1:1 with `scan-result.schema.json`; byte-equal to `sm scan --json` modulo whitespace). DB absent empty `ScanResult` shape (zero `nodes` / `links` / `issues`). |
335
+ | `GET /api/scan?fresh=1` | implemented | runs an in-memory scan and returns the produced `ScanResult` without persistence. Rejects with `bad-query` (400) when the server was started with `--no-built-ins` or `--no-plugins` (would yield empty / partial results). |
336
+ | `GET /api/nodes?kind=&hasIssues=&path=&limit=&offset=` | implemented | `RestEnvelope` (`kind: 'nodes'`) — paginated, filtered list. Filters share the `kind=` / `has=issues` / `path=<glob>` grammar with `sm export`. `hasIssues=false` is a server-side post-filter (not representable in the kernel grammar). Pagination defaults `offset=0`, `limit=100`; max `limit=1000`. |
337
+ | `GET /api/nodes/:pathB64[?include=body]` | implemented | Single-node detail envelope: `{ schemaVersion, kind: 'node', item: Node, links: { incoming: Link[], outgoing: Link[] }, issues: Issue[] }`. `:pathB64` is base64url (RFC 4648 §5, no padding) of `node.path`. Missing node or malformed `pathB64` → 404 `not-found`. **`?include=body`** (Step 14.5.a) — opt-in flag that adds `item.body: string \| null` to the response. The body is read from disk on demand at request time (the kernel persists `bodyHash` only). `null` indicates the source file was missing / unreadable when the request landed (the watcher will re-emit `scan.completed` when it catches up). Without the flag, `item.body` is `undefined` and the handler does not touch the filesystem. |
338
+ | `GET /api/links?kind=&from=&to=` | implemented | `RestEnvelope` (`kind: 'links'`) — list of links. Filters: `kind` (CSV whitelist of `link.kind`), `from` (exact match on `link.source`), `to` (exact match on `link.target`). No pagination at v14.2. |
339
+ | `GET /api/issues?severity=&ruleId=&node=` | implemented | `RestEnvelope` (`kind: 'issues'`) — list of issues. Filters: `severity` (CSV from `error\|warn\|info`), `ruleId` (CSV; qualified or short suffix per `sm check --rules`), `node` (filter to issues whose `nodeIds` includes the path). No pagination at v14.2. |
340
+ | `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`. |
341
+ | `GET /api/config` | implemented | `RestEnvelope` (`kind: 'config'`) — merged effective config (defaults → user → user-local → project → project-local → override). |
342
+ | `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'\|'global' }`. |
343
+ | `ALL /api/*` (other) | reserved | structured 404 envelope (see below); future endpoints land in subsequent sub-steps. |
344
+ | `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. |
345
+ | `GET *` | implemented | static asset from the resolved UI bundle, falling back to `index.html` for SPA deep links. |
346
+
347
+ List endpoints conform to [`schemas/api/rest-envelope.schema.json`](schemas/api/rest-envelope.schema.json). The `/api/scan` and `/api/health` responses carry their underlying `ScanResult` / `IHealthResponse` shapes directly (no envelope wrap). The `/api/graph` response carries the formatter's native textual output.
348
+
349
+ **`kindRegistry` envelope field.** Every payload-bearing variant of the REST envelope (`nodes` / `links` / `issues` / `plugins` lists, the `node` single, the `config` value envelope) embeds a required `kindRegistry: { [kindName]: { providerId, label, color, colorDark?, emoji?, icon? } }` field. Sentinel envelopes (`health`, `scan`, `graph`) are exempt — they carry no payload at the wire level. The BFF assembles the registry once at boot from every enabled Provider's `kinds[*].ui` block (see [`architecture.md` §Provider · `ui` presentation](architecture.md#provider--ui-presentation)) and attaches the same map to every applicable response. The UI consumes `kindRegistry` directly to render kind palettes, list rows, and inspector headers — built-in and user-plugin kinds render identically. A kind appearing in a response payload (e.g. `node.kind`) without a matching `kindRegistry` entry is a contract violation; the kernel rejects Providers without a `ui` block at load time so the registry is always complete for whatever kinds appear in the response.
337
350
 
338
351
  **Error envelope** (mirrors `§Machine-readable output rules`):
339
352
 
@@ -350,6 +363,13 @@ The reference implementation ships a Hono BFF rooted at `src/server/`. One Node
350
363
 
351
364
  HTTP status mapping: `400` → `bad-query`, `404` → `not-found`, `500` → `internal` / `db-missing`.
352
365
 
366
+ Error code sources at v14.2:
367
+
368
+ - `not-found` (404) — unknown `/api/*` path; missing node on `/api/nodes/:pathB64`; malformed `pathB64` (treated as "no such node" so the client UX is uniform).
369
+ - `bad-query` (400) — `ExportQueryError` from `parseExportQuery`; pagination beyond `limit ≤ 1000`; non-integer / negative `limit` / `offset`; unknown formatter on `/api/graph`; `?fresh=1` when the server started with `--no-built-ins` or `--no-plugins`.
370
+ - `internal` (500) — uncaught error during a request (e.g. config-load failure, DB corruption surfacing through `loadScanResult`).
371
+ - `db-missing` (500) — reserved for endpoints that cannot degrade to an empty result. The v14.2 routes uniformly degrade (`/api/scan` returns the empty shape; list endpoints return zero items) so this code is not currently emitted by any handler — it is documented for future endpoints (post-v0.6.0 mutations) where degradation is not safe.
372
+
353
373
  **Flag surface**:
354
374
 
355
375
  | Flag | Default | Purpose |
@@ -363,8 +383,32 @@ HTTP status mapping: `400` → `bad-query`, `404` → `not-found`, `500` → `in
363
383
  | `--open` / `--no-open` | `--open` | Auto-open the SPA in the user's default browser after listen. |
364
384
  | `--dev-cors` | off | Enable permissive CORS for the Angular dev-server proxy workflow. Loopback-only when set. |
365
385
  | `--ui-dist <path>` | auto | Override the UI bundle directory. Hidden flag — used by the demo build pipeline + tests; everyday users never need it. |
366
-
367
- **Graceful shutdown**: SIGINT / SIGTERM trigger a graceful close; the verb returns exit 0 on clean shutdown. Bind failure (port in use, EACCES) returns exit 2.
386
+ | `--no-watcher` | off | Disable the chokidar-fed scan-and-broadcast loop. Use only for CI / read-only deployments — without the watcher, `/ws` stays open but no `scan.*` events ever fire. Combining with `--no-built-ins` is rejected (the watcher cannot run with an empty pipeline; would persist empty scans on every batch). |
387
+
388
+ **WebSocket protocol** *(Stability: experimental — locks at v0.6.0)*:
389
+
390
+ The `/ws` endpoint is the live-events channel for the SPA. Clients connect once at bootstrap, the server pushes events as they happen, and the SPA reconciles its in-memory store against the deltas. The wire envelope and `scan.*` payload shapes are normative in [`job-events.md`](./job-events.md) — the BFF emits them verbatim.
391
+
392
+ - **Wire format**: each event is a single WebSocket text frame carrying one JSON object that conforms to `job-events.md` §Common envelope (`type`, `timestamp`, `runId?`, `jobId? | null`, `data`).
393
+ - **Event catalog at v14.4.a**:
394
+ - `scan.started` (per `job-events.md` §Scan events line 325).
395
+ - `scan.progress` (per `job-events.md` line 345 — emitted by the kernel orchestrator at every node; throttling deferred to a follow-up).
396
+ - `scan.completed` (per `job-events.md` line 363).
397
+ - `extractor.completed` (per `job-events.md` line 384) and `rule.completed` (per `job-events.md` line 404) ride along as side effects of the same emitter bridge.
398
+ - `extension.error` (kernel-internal — emitted when an extension violates its declared contract; the BFF forwards verbatim).
399
+ - `watcher.started` and `watcher.error` — BFF-internal advisories. Non-normative; consumers MUST ignore unknown event types per the forward-compatibility rule.
400
+ - **Deferred to a follow-up**: `issue.added` / `issue.resolved` (per `job-events.md` §Issue events line 446) and `scan.failed`. The 14.4.a surface fans out only the events the kernel emitter already produces; the diff-based issue events and a dedicated batch-failure event require additional plumbing inside the BFF watcher loop.
401
+ - **Connection lifecycle**:
402
+ 1. Client opens `ws://<host>:<port>/ws`. The server completes the WS handshake and registers the underlying socket with the broadcaster.
403
+ 2. Server pushes events. The client sends nothing at v14.4.a — `onMessage` is intentionally not registered. A future heartbeat / subscribe / filter request lands in a follow-up.
404
+ 3. Server has NO state push on connect (no replay of last events). The client SHOULD poll `/api/scan` once on connect to seed initial state, then rely on `/ws` for deltas.
405
+ 4. On normal disconnect: client closes with code 1000 ('normal closure') or 1001 ('going away'). The broadcaster unregisters silently.
406
+ 5. On server shutdown (SIGINT / SIGTERM): the broadcaster sends close code 1001 + reason `'server shutdown'` to every client, then closes the http listener.
407
+ 6. **Backpressure**: if a client's outbound buffer (`bufferedAmount`) exceeds an implementation-defined threshold (the reference impl uses 4 MiB), the broadcaster closes that client with code 1009 ('message too big') and unregisters it. Clients SHOULD reconnect after backpressure eviction with a fresh `/api/scan` poll.
408
+ 7. **Reconnect responsibility**: the server does NOT reconnect on the client's behalf and does NOT replay missed events on reconnect. The client SHOULD treat `/ws` as a best-effort delta channel and re-seed via `/api/scan` whenever the connection drops.
409
+ - **Loopback-only assumption (Decision #119)**: no per-connection authentication on `/ws` through v0.6.0. The transport security boundary is the `--host` flag (defaults to `127.0.0.1`); the server rejects `--dev-cors` combined with a non-loopback `--host` precisely because that combination would expose `/ws` over the network without auth. Multi-host serve and an auth model re-open post-v0.6.0.
410
+
411
+ **Graceful shutdown**: SIGINT / SIGTERM trigger a graceful close; the verb returns exit 0 on clean shutdown. Bind failure (port in use, EACCES) returns exit 2. The shutdown sequence drains the in-flight watcher batch (if any), closes every WS client with code 1001, then closes the http listener.
368
412
 
369
413
  ---
370
414
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  Language-neutral test suite the specification demands. A conforming implementation passes every case; failing any case is a conformance bug.
4
4
 
5
- The suite splits across two ownership boundaries (Phase 5 / A.13 of spec 0.8.0):
5
+ The suite splits across two ownership boundaries:
6
6
 
7
7
  - **Spec-owned cases** — kernel-agnostic. They live in this directory and ship with `@skill-map/spec`. Today: `kernel-empty-boot` (boot invariant) and the `preamble-bitwise-match` deferred case. The universal preamble fixture (`preamble-v1.txt`) lives here too.
8
8
  - **Provider-owned cases** — exercise a Provider's own `kinds` catalog. They live next to the Provider's manifest, under `<plugin-dir>/conformance/`. The reference impl ships one such suite at [`src/extensions/providers/claude/conformance/`](../../src/extensions/providers/claude/conformance/) covering Claude's five kinds (`skill` / `agent` / `command` / `hook` / `note`) via cases `basic-scan`, `rename-high`, `orphan-detection`.
@@ -154,7 +154,7 @@ for (const caseFile of await readdir('spec/conformance/cases')) {
154
154
  }
155
155
  ```
156
156
 
157
- A Provider-owned runner mirrors the loop with a different cases / fixtures root — `<plugin-dir>/conformance/cases/` and `<plugin-dir>/conformance/fixtures/`. The reference CLI ships both as `sm conformance run` (Phase 5 / A.13); the verb resolves the spec scope via `@skill-map/spec` and discovers Provider scopes by walking each built-in plugin's `conformance/` directory.
157
+ A Provider-owned runner mirrors the loop with a different cases / fixtures root — `<plugin-dir>/conformance/cases/` and `<plugin-dir>/conformance/fixtures/`. The reference CLI ships both as `sm conformance run`; the verb resolves the spec scope via `@skill-map/spec` and discovers Provider scopes by walking each built-in plugin's `conformance/` directory.
158
158
 
159
159
  The reference implementation's runner ships under `src/conformance/index.ts`; the verb lives at `src/cli/commands/conformance.ts` and uses the runner one case at a time.
160
160
 
@@ -0,0 +1,17 @@
1
+ {
2
+ "$schema": "https://skill-map.dev/spec/v0/conformance-case.schema.json",
3
+ "id": "plugin-missing-ui-rejected",
4
+ "description": "A drop-in Provider plugin whose `kinds[*]` entry omits the required `ui` block (Step 14.5.d) MUST be rejected by the loader with a clear `missing required property 'ui'` diagnostic on stderr, AND `sm scan` MUST exit cleanly with the rest of the pipeline (built-in Claude Provider) still running. Locks the contract that one bad plugin does not take down the scan.",
5
+ "fixture": "plugin-missing-ui",
6
+ "invoke": {
7
+ "verb": "scan",
8
+ "flags": ["--json"]
9
+ },
10
+ "assertions": [
11
+ { "type": "exit-code", "value": 0 },
12
+ { "type": "stderr-matches", "pattern": "plugin bad-provider:.*invalid.*must have required property 'ui'" },
13
+ { "type": "json-path", "path": "$.providers.length", "equals": 1 },
14
+ { "type": "json-path", "path": "$.providers[0]", "equals": "claude" },
15
+ { "type": "json-path", "path": "$.nodes.length", "equals": 1 }
16
+ ]
17
+ }
@@ -18,22 +18,23 @@ This file is hand-maintained. A CI check before spec release compares the schema
18
18
  | 8 | `job.schema.json` | — | 🔴 missing | Blocked by Step 10 (job system). Needs a case that submits a local action (no LLM), inspects `sm job show --json`. |
19
19
  | 9 | `report-base.schema.json` | — | 🔴 missing | Indirect coverage once any summarizer case lands. Direct contract case: validate a handcrafted minimal report ({confidence, safety}) against the base schema. |
20
20
  | 10 | `conformance-case.schema.json` | — | 🔴 missing | Self-referential: every `*.json` under `cases/` MUST validate against this schema. Add a meta-case that enumerates + validates all cases. |
21
- | 11 | `frontmatter/base.schema.json` | — | 🔴 missing | Universal frontmatter shape. Per-kind schemas (skill / agent / command / hook / note) are no longer in spec they relocated to the **Claude Provider** under `src/extensions/providers/claude/schemas/` in spec 0.8.0 (Phase 3 of plug-in model overhaul) and extend this base via `$ref`-by-`$id`. The cases that exercised it indirectly (`basic-scan` and friends) moved to the Provider in Phase 5 / A.13. Direct spec-level case still pending: fixture with min-required frontmatter only, no Provider needed (Provider-disabled mode + a single `notes/<file>.md` with `name: ...` + `description: ...`). |
21
+ | 11 | `frontmatter/base.schema.json` | — | 🔴 missing | Universal frontmatter shape. Per-kind schemas (`skill` / `agent` / `command` / `hook` / `note`) live with the Provider that emits them (the Claude Provider ships them under `src/extensions/providers/claude/schemas/`) and extend this base via `$ref`-by-`$id`. Direct spec-level case still pending: fixture with min-required frontmatter only, no Provider needed (Provider-disabled mode + a single `notes/<file>.md` with `name: ...` + `description: ...`). |
22
22
  | 12 | `summaries/skill.schema.json` | — | 🔴 missing | Blocked by Step 10 (`skill-summarizer`). Case: submit summarizer, validate report. |
23
23
  | 13 | `summaries/agent.schema.json` | — | 🔴 missing | Blocked by Step 11. |
24
24
  | 14 | `summaries/command.schema.json` | — | 🔴 missing | Blocked by Step 11. |
25
25
  | 15 | `summaries/hook.schema.json` | — | 🔴 missing | Blocked by Step 11. |
26
26
  | 16 | `summaries/note.schema.json` | — | 🔴 missing | Blocked by Step 11. |
27
27
  | 17 | `extensions/base.schema.json` | — | 🔴 missing | Meta-case: every manifest under `src/extensions/` validates against the appropriate kind schema (which extends base via `allOf`). |
28
- | 18 | `extensions/provider.schema.json` | | 🔴 missing | Case: the `claude` Provider manifest validates; a crafted invalid manifest (missing `kinds` or `explorationDir`) fails with `invalid-manifest`. |
28
+ | 18 | `extensions/provider.schema.json` | `plugin-missing-ui-rejected` | 🟡 partial | A drop-in Provider whose `kinds[*]` entry omits the required `ui` block fails AJV validation with `invalid-manifest` while the rest of the pipeline keeps running (built-in Claude Provider, exit 0). The complementary positive case (canonical Claude Provider manifest validates) lives in `provider:claude` conformance. Direct cases for missing `kinds` / `explorationDir` rejection still pending. |
29
29
  | 19 | `extensions/extractor.schema.json` | — | 🔴 missing | Case: `frontmatter` + `slash` + `at-directive` extractor manifests validate; an extractor emitting a disallowed `emitsLinkKinds` value fails. |
30
30
  | 20 | `extensions/rule.schema.json` | — | 🔴 missing | Case: `trigger-collision`, `broken-ref`, `superseded` manifests validate. |
31
31
  | 21 | `extensions/action.schema.json` | — | 🔴 missing | Case: a `deterministic` action manifest validates; a `probabilistic` action WITHOUT `promptTemplateRef` fails. |
32
32
  | 22 | `extensions/formatter.schema.json` | — | 🔴 missing | Case: `ascii` formatter manifest validates. |
33
33
  | 23 | `history-stats.schema.json` | — | 🔴 missing | Blocked by Step 5 (history). Case: seed `state_executions` with a deterministic fixture, run `sm history stats --json --since <T0> --until <T1> --period month --top 5`, assert the document validates and that `totals.executionsCount == sum(perAction.executionsCount)` and `errorRates.global == totals.failedCount / totals.executionsCount`. Percentiles (`p95`/`p99`) intentionally omitted in v1 — add later as a minor bump without breaking consumers. |
34
34
  | 24 | `extensions/hook.schema.json` | — | 🔴 missing | Case: a `deterministic` hook manifest with `triggers: ['scan.completed']` validates; a hook declaring an unknown trigger (e.g. `scan.progress`) fails with `invalid-manifest` at load time. |
35
+ | 25 | `api/rest-envelope.schema.json` | — | 🔴 missing | Step 14.2 BFF list-envelope shape (`{ schemaVersion, kind, items \| item \| value, filters, counts }`). Case: hit `GET /api/nodes` against a primed scope, validate the response against the schema; assert the `oneOf` rejects an envelope that carries both `items` and `item`. Implementation-side coverage exists today (`src/test/server-endpoints.test.ts`) but a kernel-agnostic conformance case is required before v1.0.0 ships. |
35
36
 
36
- > **Note on Provider-owned schemas.** Per spec 0.8.0 Phase 3, the per-kind frontmatter schemas (`skill`, `agent`, `command`, `hook`, `note`) live with the Provider that emits them — for the built-in Claude Provider, that is `src/extensions/providers/claude/schemas/`. Those schemas are NOT counted in the spec's coverage matrix above; they belong to the Provider's own conformance suite (Phase 5 / A.13 — `src/extensions/providers/claude/conformance/coverage.md`). Phase 5 / A.13 also relocated the cases that exercised them (`basic-scan`, `rename-high`, `orphan-detection`) to the Provider's own `cases/` directory. The matrix shrinks from 28 to 23 rows accordingly. The Hook kind (A.11) brings it back up to 24.
37
+ > **Note on Provider-owned schemas.** Per-kind frontmatter schemas (`skill`, `agent`, `command`, `hook`, `note`) live with the Provider that emits them — for the built-in Claude Provider, under `src/extensions/providers/claude/schemas/`. Those schemas are NOT counted in the spec's coverage matrix above; they belong to the Provider's own conformance suite at `src/extensions/providers/claude/conformance/coverage.md`. The same split applies to the cases that exercise Provider-specific kinds (`basic-scan`, `rename-high`, `orphan-detection`) they live in the Provider's `cases/` directory.
37
38
 
38
39
  Status legend: 🟢 covered (at least one case asserts the schema end-to-end) · 🟡 partial (covered only indirectly or via a sub-shape) · 🔴 missing.
39
40
 
@@ -51,7 +52,7 @@ These have their own conformance cases even though they are not JSON Schemas.
51
52
  | F | Nonce mismatch | — | 🔴 missing | Blocked by Step 10. `sm record` with wrong nonce → exit 4. |
52
53
  | G | Reap | — | 🔴 missing | Blocked by Step 10. Set TTL to 1s; claim; wait; next `sm job run` reaps with reason `abandoned`. |
53
54
  | H | `run.*` event envelope for Skill agent | — | 🔴 missing | Blocked by Step 10. Skill-agent flow emits synthetic `r-ext-*` run envelope around one job. |
54
- | I | Rename heuristic | `rename-high`, `orphan-detection` (Provider-owned) | 🟢 covered | High-confidence rename emits no issue and the new path is the sole node. Orphan branch emits exactly one `orphan` issue (severity `info`) when a deleted node has no replacement. Cases moved with the Claude Provider in Phase 5 / A.13 (they reach a Provider's `kinds` catalog by construction); see [`src/extensions/providers/claude/conformance/`](../../src/extensions/providers/claude/conformance/). Medium / ambiguous branches are exercised by `src/test/rename-heuristic.test.ts` until the conformance schema grows richer assertions. |
55
+ | I | Rename heuristic | `rename-high`, `orphan-detection` (Provider-owned) | 🟢 covered | High-confidence rename emits no issue and the new path is the sole node. Orphan branch emits exactly one `orphan` issue (severity `info`) when a deleted node has no replacement. Cases live with the Claude Provider (they reach a Provider's `kinds` catalog by construction); see [`src/extensions/providers/claude/conformance/`](../../src/extensions/providers/claude/conformance/). Medium / ambiguous branches are exercised by `src/test/rename-heuristic.test.ts` until the conformance schema grows richer assertions. |
55
56
  | J | Plugin DDL rejection | — | 🔴 missing | Blocked by Step 9. Plugin migration referencing `state_jobs` → disabled with `invalid-manifest`. |
56
57
  | K | Plugin prefix injection | — | 🔴 missing | Blocked by Step 9. Plugin declares `CREATE TABLE foo` → kernel applies as `plugin_<id>_foo`. |
57
58
  | L | Elapsed-time reporting | — | 🔴 missing | Blocked by Step 4 (first real verb work). Run any in-scope verb; stderr last line MUST match `/^done in (\d+ms\|\d+\.\d+s\|\d+m \d+s)$/`. In-scope verb with `--json` returning an object MUST carry `elapsedMs`. Exempt verb (`sm version`) MUST NOT emit the line. |
@@ -61,3 +62,11 @@ These have their own conformance cases even though they are not JSON Schemas.
61
62
  - **spec v0.x**: partial coverage acceptable. Every case added as the reference impl lands the verb that makes it runnable.
62
63
  - **spec v1.0.0 release**: all rows above MUST be 🟢 covered or explicitly 🟠 deferred to v1.1 with a linked issue.
63
64
  - **CI check**: [`scripts/check-coverage.js`](../../scripts/check-coverage.js) compares `spec/schemas/**/*.schema.json` against the matrix above on every PR. A schema without a row here, or a row pointing at a missing schema, fails CI (exit 1 with a `::error::` annotation). Wired into `ci.yml` §validate and into `npm run spec:check`.
65
+
66
+ ## Stability
67
+
68
+ The **coverage matrix gating policy** (every shipped schema MUST have a row; every row MUST be 🟢 covered or 🟠 deferred-to-v1.1 before the spec v1.0.0 tag) is stable as of spec v1.0.0. Relaxing the v1.0 release gate (e.g. allowing 🔴 missing rows to ship) is a major bump. Tightening the gate further (e.g. forbidding 🟠 deferrals) is a minor bump.
69
+
70
+ The **assertion-type vocabulary** (`exit-code`, `json-path`, `file-exists`, `file-contains-verbatim`, `file-matches-schema`, `stderr-matches`) is stable as of spec v1.0.0. Adding a new assertion type is a minor bump; renaming or removing one is a major bump.
71
+
72
+ The **per-row schema and case set** above is mutable through every spec release: rows are added when a new schema or normative artifact lands, marked deferred when the runtime that exercises them ships in a later step, and flipped to 🟢 covered when the case file is committed. Adding rows is non-breaking; deleting rows MUST coincide with deleting the corresponding schema (which is itself a major bump).
@@ -0,0 +1,6 @@
1
+ {
2
+ "id": "bad-provider",
3
+ "version": "0.1.0",
4
+ "specCompat": "*",
5
+ "extensions": ["provider.js"]
6
+ }
@@ -0,0 +1,31 @@
1
+ // Conformance fixture: provider whose `kinds[*]` entry deliberately
2
+ // omits the required `ui` block (Step 14.5.d). The plugin loader MUST
3
+ // reject this manifest with a clear "missing required property 'ui'"
4
+ // diagnostic and the plugin MUST end up in `invalid-manifest` status.
5
+ // The companion case `plugin-missing-ui-rejected.json` asserts the
6
+ // stderr text and that `sm scan` survives (the loader degrades the
7
+ // bad plugin and lets the rest of the pipeline continue).
8
+ export default {
9
+ kind: 'provider',
10
+ id: 'bad-provider-provider',
11
+ version: '0.1.0',
12
+ description: 'provider whose note kind is missing the ui block',
13
+ stability: 'experimental',
14
+ explorationDir: '~/.bad',
15
+ kinds: {
16
+ note: {
17
+ schema: './schemas/note.schema.json',
18
+ schemaJson: {
19
+ $id: 'urn:test:bad-provider/note',
20
+ type: 'object',
21
+ additionalProperties: true,
22
+ },
23
+ defaultRefreshAction: 'bad-provider/summarize-note',
24
+ // NOTE: deliberately no `ui` — this is what the case asserts.
25
+ },
26
+ },
27
+ async *walk() {},
28
+ classify() {
29
+ return 'note';
30
+ },
31
+ };
@@ -0,0 +1,8 @@
1
+ ---
2
+ name: example
3
+ description: Trivial markdown so the Claude built-in has something to scan.
4
+ metadata:
5
+ version: 1.0.0
6
+ ---
7
+
8
+ Body.
package/db-schema.md CHANGED
@@ -177,7 +177,7 @@ Primary key: `(node_path, extractor_id)`. Indexes: `ix_scan_extractor_runs_node`
177
177
 
178
178
  Universal enrichment layer (A.8). Stores `ctx.enrichNode(partial)` outputs separately from the author-supplied frontmatter on `scan_nodes.frontmatter_json`, which the Extractor pipeline NEVER mutates.
179
179
 
180
- One row per `(node_path, extractor_id)` pair an Extractor enriched. Both deterministic and probabilistic Extractors write here; only probabilistic rows participate in stale tracking — when a body changes between scans, the kernel flags the surviving probabilistic row `stale = 1` (NOT deleted, preserving the LLM cost paid to produce it). Deterministic rows simply pisar via PRIMARY KEY conflict on the next re-extract through the A.9 cache.
180
+ One row per `(node_path, extractor_id)` pair an Extractor enriched. Both deterministic and probabilistic Extractors write here; only probabilistic rows participate in stale tracking — when a body changes between scans, the kernel flags the surviving probabilistic row `stale = 1` (NOT deleted, preserving the LLM cost paid to produce it). Deterministic rows are simply overwritten via PRIMARY KEY conflict on the next re-extract through the A.9 cache.
181
181
 
182
182
  | Column | Type | Constraint |
183
183
  |---|---|---|
package/index.json CHANGED
@@ -166,25 +166,30 @@
166
166
  }
167
167
  ]
168
168
  },
169
- "specPackageVersion": "0.12.0",
169
+ "specPackageVersion": "0.13.1",
170
170
  "integrity": {
171
171
  "algorithm": "sha256",
172
172
  "files": {
173
- "CHANGELOG.md": "c85703fa37d3c084e2251c0b77626c94fa5c6897289c1534432ae45ac168762b",
173
+ "CHANGELOG.md": "e99ca15631aa9ce6392612d055379d5cb2f2757df2008c8c184acc059a4178c3",
174
174
  "README.md": "bd30780525e75379eaeb5f8a903bdc601daf3862f3ec69dffc96c437e8d476fc",
175
- "architecture.md": "9a6d96d150af60ed8d476af572d07dcce605f116fde720bebb2662b11250bf4b",
176
- "cli-contract.md": "89dcd366624821c1ab77d1014229b4c209953f337d0c62d16b24472c64c3bad4",
177
- "conformance/README.md": "838b1247e1ffb402d96bd8a0fe9c1c0f4a99ed0fbc4bf8156f7a58330cac27f5",
175
+ "architecture.md": "c14e69faa7ce7f657d6a2790762daaea8a5ff350375de8c254cd870b5494b896",
176
+ "cli-contract.md": "1aa9349ef1a8db94236a8057ed77dd2802b561eec382451be9a2afdbbbdc6f91",
177
+ "conformance/README.md": "5f94a6ac637b7c992fcd7e53d32eed1b8887eeef05eb6ca3b5ec8a0b5045cd21",
178
178
  "conformance/cases/kernel-empty-boot.json": "ad4bbe9d637537625025c8bdb61285b1433568a2544b1ce0248f304ccff8c350",
179
- "conformance/coverage.md": "4df23b78ec44887dc355e0622b9008bb2514f3b8e9c302db18eb51532fda5275",
179
+ "conformance/cases/plugin-missing-ui-rejected.json": "c6ce8f62a430d662aea33dec8ebf6493be6455037be3114e0d93d0eb57777287",
180
+ "conformance/coverage.md": "a06b650c5b5684aec9b508181b3e091bf1aaa0cb45a0f8e76fdfb8f2a9c7b9a7",
181
+ "conformance/fixtures/plugin-missing-ui/.skill-map/plugins/bad-provider/plugin.json": "4d78af6f12faa9d131e2a19f1dbb8f250baacc525978f3a8c858932b95da4ff6",
182
+ "conformance/fixtures/plugin-missing-ui/.skill-map/plugins/bad-provider/provider.js": "da40b134d70f8bc8175cfa9c380ecb55d26b2240c8b467f22f3fcfab750c8747",
183
+ "conformance/fixtures/plugin-missing-ui/notes/example.md": "55767f0aa1b6774546a99f28c58e7b732aa9cfa5dfce8d0326470f7f622f577e",
180
184
  "conformance/fixtures/preamble-v1.txt": "1e0aeef224b64477bdc13a949c3ad402e68249caf499ecdba1302371677c068b",
181
- "db-schema.md": "cbd2d3395ca4f01065d6f15405c7442d59a468b15448377d6f9373fa6aeff334",
185
+ "db-schema.md": "730b423d844d7b10aecaec59a78f254b600f0e16b9300305c49aa8c01d03d68f",
182
186
  "interfaces/security-scanner.md": "4a982667008f233656f44c61ce9948e062432d3debdcbf7a134da03bd4139d7d",
183
187
  "job-events.md": "8f371e0991816eca2e1a55cbd8a50733546ca5e7c861588048c18be1d22dbd57",
184
188
  "job-lifecycle.md": "12bfc27690c92cf93682a3b6fbfeb7e2d252d33f704fd2d7de9a13db713e6281",
185
- "plugin-author-guide.md": "2dcdf8c570342d94c2c8f8d47594715254e3956d7939443023f1d6420e2b30d0",
189
+ "plugin-author-guide.md": "ada6af03cc26439fd890efc9fa6939d1757f4625ea88f4be6f5e07497244f42e",
186
190
  "plugin-kv-api.md": "04b2178f46fb88adeae9240df9c9e1761b660396072001dac32cd402e11a2d7d",
187
191
  "prompt-preamble.md": "fb40ab510234383326f198dec82cd6d744f28b7432eebac6cbfbb7ca1c483b7d",
192
+ "schemas/api/rest-envelope.schema.json": "7d9d74bcb2158019cb6e30306d40b9c7ffc67e9d202fb8210fe4e4a9e8fa4dab",
188
193
  "schemas/conformance-case.schema.json": "7cd0f3aae5124f24be57cddb213d002d0466f79d06fd3da896075c8b28650410",
189
194
  "schemas/execution-record.schema.json": "9628fa557cb856402f3a5f1d1167c609e46a197c850fe8171abfddd46c1028a8",
190
195
  "schemas/extensions/action.schema.json": "262272175c06a2e33c08f819a45c3ef8260276c91a9d0542fdffc932aeb32db7",
@@ -192,7 +197,7 @@
192
197
  "schemas/extensions/extractor.schema.json": "122d3f81ef91edcde9798e7dc8fcbf442a2996deea65aa4b03c9d5cb01ba2519",
193
198
  "schemas/extensions/formatter.schema.json": "2ab092aa37ae349c69b93071ed4f0e131affb7bb5799516ca82c721262631b36",
194
199
  "schemas/extensions/hook.schema.json": "7465c38e0765edf23e49d4f96c539d04323f1cf564af1c60ee637c79a6d39239",
195
- "schemas/extensions/provider.schema.json": "27c627151fb98cf60763aaa122d807bbf007317f06bd31e92a2fca43c100e4b8",
200
+ "schemas/extensions/provider.schema.json": "518d7666841cfef8eb28aab788a6e6dfabf9e12f3f06de1c81be915cbe6c1088",
196
201
  "schemas/extensions/rule.schema.json": "8ff420bde498f50db114c352305d487c71aef2dd746fd0c24976ff6a09865c22",
197
202
  "schemas/frontmatter/base.schema.json": "dfee192458765b8cb872ef9e7145ec31b9e07ceb19ee44be48af2172329e7a38",
198
203
  "schemas/history-stats.schema.json": "23f472d1de06d23fc775aabba821f8375f347af4dc8d89ba567980d61a11f9de",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skill-map/spec",
3
- "version": "0.12.0",
3
+ "version": "0.13.1",
4
4
  "description": "JSON Schemas, prose contracts, and conformance suite for the skill-map specification.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -264,7 +264,7 @@ Pure single-node analysis. **Never** read another node, the graph, or the databa
264
264
  The runtime method is `extract(ctx) → void`. Output flows through three callbacks the kernel binds onto the context:
265
265
 
266
266
  - **`ctx.emitLink(link)`** — append a `Link` to the kernel's `links` table. The kernel validates against the extractor's declared `emitsLinkKinds` before persistence; off-contract kinds are dropped and surface as `extension.error` events. URL-shaped targets are partitioned into `node.externalRefsCount` and never persisted.
267
- - **`ctx.enrichNode(partial)`** — merge canonical, kernel-curated properties onto the node's enrichment layer (persisted into `node_enrichments` per `db-schema.md`). **Strictly separate from the author-supplied frontmatter** — the latter is IMMUTABLE from any Extractor. Use the enrichment layer for facts the author did not write but the extractor inferred (computed titles, summaries, signals from probabilistic extractors). Probabilistic enrichments track `body_hash_at_enrichment`; when the scan loop sees a body change, those rows are flagged `stale = 1` (NOT deleted, preserving the LLM cost paid to produce them) and surface for refresh via `sm refresh <node>` or `sm refresh --stale`. Deterministic enrichments simply pisar via PRIMARY KEY conflict on the next re-extract through the A.9 cache and are never stale-flagged.
267
+ - **`ctx.enrichNode(partial)`** — merge canonical, kernel-curated properties onto the node's enrichment layer (persisted into `node_enrichments` per `db-schema.md`). **Strictly separate from the author-supplied frontmatter** — the latter is IMMUTABLE from any Extractor. Use the enrichment layer for facts the author did not write but the Extractor inferred (computed titles, summaries, signals from probabilistic Extractors). Probabilistic enrichments track `body_hash_at_enrichment`; when the scan loop sees a body change, those rows are flagged `stale = 1` (NOT deleted, preserving the LLM cost paid to produce them) and surface for refresh via `sm refresh <node>` or `sm refresh --stale`. Deterministic enrichments are simply overwritten via PRIMARY KEY conflict on the next re-extract through the A.9 cache and are never stale-flagged.
268
268
  - **`ctx.store`** — plugin-scoped persistence. Optional, only present when your `plugin.json` declares `storage.mode`. Shape depends on the mode (`KvStore` for mode A, scoped `Database` for mode B). See [`plugin-kv-api.md`](./plugin-kv-api.md).
269
269
 
270
270
  A probabilistic extractor additionally receives `ctx.runner` (the `RunnerPort`) for LLM dispatch.
@@ -300,7 +300,6 @@ export default {
300
300
  };
301
301
  ```
302
302
 
303
- > **Migration note (spec 0.8.x).** This kind was previously named `Detector` with a `detect(ctx) → Link[]` signature. The rename to `Extractor` and the move to callback-based output landed as a single breaking minor in the pre-1.0 line. The mechanical migration: rename `kind: 'detector'` → `kind: 'extractor'`, rename `detect` → `extract`, replace `return links` with `for (const l of links) ctx.emitLink(l)`. The `applicableKinds`, `emitsLinkKinds`, `defaultConfidence`, and `scope` fields are unchanged.
304
303
 
305
304
  ### Rules
306
305
 
@@ -407,11 +406,15 @@ These ship later in the v1.x line as bundled built-ins; the spec already pins th
407
406
 
408
407
  #### Provider — `kinds` catalog and `explorationDir`
409
408
 
410
- Every Provider declares two required fields beyond the manifest base.
409
+ Every Provider declares two required top-level fields beyond the manifest base: `kinds` and `explorationDir`.
411
410
 
412
- **`kinds` catalog.** Maps each kind the Provider emits to its frontmatter schema (path relative to the Provider's package directory) and its qualified `defaultRefreshAction`. The catalog is the single source of truth for "which kinds does this Provider emit"; the kernel derives the supported kind set from `Object.keys(kinds)`. The schema MUST extend the spec's universal [`schemas/frontmatter/base.schema.json`](./schemas/frontmatter/base.schema.json) via `allOf` + `$ref` to base's `$id`, so cross-package resolution works without copying base into every Provider.
411
+ **`kinds` catalog.** Maps each kind the Provider emits to its frontmatter schema, its qualified `defaultRefreshAction`, and its `ui` presentation block. The kernel derives the supported kind set from `Object.keys(kinds)`. Each entry has three required fields:
413
412
 
414
- **`explorationDir`.** Filesystem directory the kernel walks at boot/scan time to discover candidate files; `sm doctor` checks the resolved path exists and emits a non-blocking warning when it does not the user may legitimately install the matching platform later.
413
+ - **`schema`** path (relative to the Provider package) to the kind's frontmatter JSON Schema. MUST extend [`schemas/frontmatter/base.schema.json`](./schemas/frontmatter/base.schema.json) via `allOf` + `$ref` to base's `$id`.
414
+ - **`defaultRefreshAction`** — qualified action id (`<plugin-id>/<action-id>`) the UI's `🧠 prob` button dispatches. The action MUST exist in the registry; a dangling reference disables the Provider with `invalid-manifest`.
415
+ - **`ui`** — presentation block: `{ label, color, colorDark?, emoji?, icon? }`. The UI ships every `ui` block to the front-end via the `kindRegistry` envelope so built-in and user-plugin kinds render identically. `icon` is a discriminated union (`{ kind: 'pi'; id }` for PrimeIcons, `{ kind: 'svg'; path }` for raw SVG). The `ui` block is required (not optional) so the UI never has to invent visuals for unknown kinds. See [`architecture.md` §Provider · `ui` presentation](./architecture.md#provider--ui-presentation) for the field-by-field contract.
416
+
417
+ **`explorationDir`.** Filesystem directory the kernel walks at boot/scan time to discover candidate files. `sm doctor` checks the resolved path exists and emits a non-blocking warning when it does not — the user may legitimately install the matching platform later. Bare `~` and `~/...` resolve against the current user's home (shell convention); relative paths fall back to the cwd.
415
418
 
416
419
  ```jsonc
417
420
  {
@@ -422,20 +425,27 @@ Every Provider declares two required fields beyond the manifest base.
422
425
  "kinds": {
423
426
  "skill": {
424
427
  "schema": "./schemas/skill.schema.json",
425
- "defaultRefreshAction": "cursor/summarize-skill"
428
+ "defaultRefreshAction": "cursor/summarize-skill",
429
+ "ui": {
430
+ "label": "Skill",
431
+ "color": "#7c3aed",
432
+ "colorDark": "#a78bfa",
433
+ "icon": { "kind": "pi", "id": "pi-bolt" }
434
+ }
426
435
  },
427
436
  "command": {
428
437
  "schema": "./schemas/command.schema.json",
429
- "defaultRefreshAction": "cursor/summarize-command"
438
+ "defaultRefreshAction": "cursor/summarize-command",
439
+ "ui": {
440
+ "label": "Command",
441
+ "color": "#0ea5e9",
442
+ "icon": { "kind": "svg", "path": "M3 6h18M3 12h18M3 18h18" }
443
+ }
430
444
  }
431
445
  }
432
446
  }
433
447
  ```
434
448
 
435
- Bare `~` and `~/...` prefixes in `explorationDir` resolve against the current user's home (the same convention the shell applies); relative paths fall back to the cwd. Keep `explorationDir` short and platform-canonical; the doctor warning is the only place the user sees the field, so misleading values create confusion later.
436
-
437
- > **Migration note (spec 0.8.x).** Pre-0.8 Providers declared two separate fields, `emits: string[]` and a flat `defaultRefreshAction: { <kind>: actionId }`. Both collapsed into the `kinds` map in 0.8.0 (Phase 3 of plug-in model overhaul); the per-kind frontmatter schema (which previously lived under `spec/schemas/frontmatter/<kind>.schema.json`) joined the same map entry. Migration: drop `emits` (replaced by `Object.keys(kinds)`); move each `defaultRefreshAction[<kind>]` value into `kinds[<kind>].defaultRefreshAction`; ship your per-kind schemas inside the plugin package and reference them via `kinds[<kind>].schema`.
438
-
439
449
  ---
440
450
 
441
451
  ## Frontmatter validation — three-tier model