@skill-map/spec 0.20.0 → 0.22.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/cli-contract.md CHANGED
@@ -141,7 +141,7 @@ Diagnostic report:
141
141
  - `state_job_contents` GC stragglers (count of rows referenced by zero `state_jobs` rows; `sm job prune` collects these).
142
142
  - Plugins in error state (list).
143
143
  - LLM runner availability (`claude` binary on PATH, version).
144
- - Detected Providers that matched nothing, or whose `explorationDir` does not exist on disk (non-blocking warning).
144
+ - Detected Providers that matched nothing (non-blocking warning).
145
145
 
146
146
  Exit: 0 if all green, 1 if warnings, 2 if any `error`-level problem.
147
147
 
@@ -188,14 +188,22 @@ Keys are dot-paths (`jobs.minimumTtlSeconds`, `scan.tokenize`). Unknown keys →
188
188
 
189
189
  #### Privacy-sensitive config
190
190
 
191
- Keys whose value opens disk access OUTSIDE the project root (today: `scan.includeHome`, `scan.extraRoots`, `scan.referencePaths`) are gated behind `--yes` so the user never expands the scan surface by accident. The analyzer:
191
+ 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:
192
192
 
193
- - `sm config set <privacy-key> <value>` (without `--yes`) — when the new value would expand the surface (toggling `includeHome` from `false`→`true`, or adding paths to `extraRoots` / `referencePaths` that resolve outside the project root) — exits with code `2` and prints the full list of paths the change would expose to stderr, suggesting `--yes` to confirm.
193
+ - `sm config set <privacy-key> <value>` (without `--yes`) — when the new value would expand the surface (adding paths to `extraFolders` / `referencePaths` that resolve outside the project root) — exits with code `2` and prints the full list of paths the change would expose to stderr, suggesting `--yes` to confirm.
194
194
  - `sm config set <privacy-key> <value> --yes` — proceeds with the write and prints the same list as a confirmation receipt.
195
- - Writes that NARROW the surface (toggling `includeHome` from `true`→`false`, removing paths) do not require `--yes`.
195
+ - Writes that NARROW the surface (removing paths) do not require `--yes`.
196
196
 
197
197
  The Settings UI's Project section enforces the same analyzer via a confirm dialog that enumerates the paths.
198
198
 
199
+ #### Project-local-only config
200
+
201
+ 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:
202
+
203
+ - `sm config set` writes them to `<cwd>/.skill-map/settings.local.json` (gitignored) by default. The user MAY also set them at user scope with `-g` (writes to `~/.skill-map/settings.json`).
204
+ - Attempting to write any of them with `--scope project` (or equivalent forcing flag) is REJECTED with exit `2` and a directed message pointing to `settings.local.json` or `-g`.
205
+ - The loader strips them (with a warning) when found in the committed `project` layer. An older install that wrote one of these keys to `settings.json` keeps validating against the schema, but the value is ignored at read time and `sm config show --source` surfaces the warning.
206
+
199
207
  ---
200
208
 
201
209
  ### Scan
@@ -206,7 +214,6 @@ The Settings UI's Project section enforces the same analyzer via a confirm dialo
206
214
  | `sm scan -n <node.path>` | Partial scan: one node. |
207
215
  | `sm scan --changed` | Incremental: only files changed since last scan (mtime heuristic). |
208
216
  | `sm scan --watch` | Long-running: watch the roots and trigger an incremental scan after each debounced batch of filesystem events. Alias of `sm watch`. |
209
- | `sm scan -g` / `sm scan --global` | Global scan. Roots default to every active Provider's `explorationDir` resolved against `~` (typically `~/.claude`, `~/.gemini`, `~/.agents`); the cwd is NOT included. Config + DB resolve from the global scope (`~/.skill-map/...`). Mutually exclusive with explicit positional roots — passing both is rejected with exit `2`. |
210
217
  | `sm scan compare-with <dump> [roots...]` | Delta report: run a fresh scan in memory and compare against the saved `ScanResult` dump at `<dump>`. Read-only — does not modify the DB. Exit `0` on empty delta, `1` on any drift, `2` on operational error (missing or malformed dump, schema violation). |
211
218
  | `sm watch [roots...]` | Long-running watcher. Same semantics as `sm scan --watch`, exposed as a top-level verb because the watcher is a loop, not a one-shot scan. |
212
219
  | `sm refresh <node.path>` | Re-run Extractors against a single node and upsert their outputs into the universal enrichment layer (`node_enrichments`, see [`db-schema.md`](./db-schema.md#node_enrichments)). Extractors are deterministic-only — they run synchronously and persist. Exit `0` on success, `2` on failure, `5` if the node is not in the persisted scan. |
@@ -216,10 +223,8 @@ The Settings UI's Project section enforces the same analyzer via a confirm dialo
216
223
 
217
224
  **Effective roots** (one-shot `sm scan`):
218
225
 
219
- - `sm scan [roots...]`: when positional roots are given, they ARE the effective roots (verbatim). When omitted: the effective roots are `[cwd]` plus the appended sets below.
220
- - `scan.includeHome === true` appends every active Provider's `explorationDir` resolved against `~` to the effective roots.
221
- - `scan.extraRoots[]` is appended verbatim (entries starting with `~` resolve against the user home; relative entries resolve against the project root).
222
- - `sm scan -g`: effective roots are the active Providers' `explorationDir` only; `scan.includeHome` and `scan.extraRoots` are ignored. The cwd is NOT included. Config + DB resolve from the global scope.
226
+ - `sm scan [roots...]`: when positional roots are given, they ARE the effective roots (verbatim). When omitted: the effective roots are `[cwd]` plus the appended set below.
227
+ - `scan.extraFolders[]` is appended verbatim (entries starting with `~` resolve against the user home; relative entries resolve against the project root). This is the ONLY way to extend the scan beyond the project: there is no implicit HOME walk and Providers cannot opt their own directory in.
223
228
 
224
229
  **Reference paths** (`scan.referencePaths[]`): walked in parallel by the scan to collect existing absolute paths into a side set. Files there are NOT parsed and NOT indexed as nodes; the kernel passes the set to analyzers via `IAnalyzerContext.referenceablePaths` so `core/broken-ref` can resolve a link against the filesystem when the in-graph lookup misses.
225
230
 
@@ -260,11 +265,11 @@ The built-in deterministic `core/bump` Action is the canonical write channel for
260
265
 
261
266
  | Command | Purpose |
262
267
  |---|---|
263
- | `sm bump <node.path> [--force]` | Single-node bump. Wraps `core/bump`. Refuses on a fresh node (`{ ok: false, reason: 'fresh' }`, exit `2`) unless `--force` is passed; with `--force` on a fresh node the verb is a silent no-op (exit `0`, no stdout). On a stale node (or first-time creation) increments `annotations.version`, refreshes `for.{bodyHash, frontmatterHash}`, and stamps the audit block (`audit.lastBumpedAt` + `audit.lastBumpedBy: 'cli'`; on first creation also `audit.createdAt` + `audit.createdBy: 'cli'`). Exit `5` if the node is not in the persisted scan. `--json` emits the report shape declared by `bump-report.schema.json`. |
264
- | `sm bump --pending [--staged] [--force]` | Batch bump. Walks every node whose sidecar overlay reports drift in `node.path` ASC order and bumps each. `--staged` runs `git add <sidecar-path>` after each successful bump so the new content lands in the same commit; `git add` failure degrades to a stderr warning, the batch keeps running. Empty stale set → exit `0` with a "nothing to do" advisory. `--json` envelope: `{ bumped, refused, skipped, errors[], elapsedMs }`. Exit `0` on a clean run; `1` when at least one per-node error landed in `errors[]`. **Git error matrix for `--staged`**: not inside a git repo (no `.git/` parent of `cwd`) → exit `5`; `git` binary not on PATH (spawn ENOENT) → exit `2`. Both checks run BEFORE any sidecar write so a misconfigured environment never produces partial state. |
265
- | `sm sidecar refresh <node.path>` | Hash-only update on the sidecar. Refreshes `for.{bodyHash, frontmatterHash}` to match the live node WITHOUT bumping `annotations.version` and WITHOUT touching the audit block. Useful when the user knows a body change is editorial-only and doesn't want to spend a version increment. Distinct from the top-level `sm refresh` (which targets the enrichment layer at Step A.8) — different storage, different concept; the sub-namespace prefix prevents the collision. Exit `5` if the node has no sidecar or is not in the persisted scan. No-op on a fresh node (informational stderr, exit `0`). |
266
- | `sm sidecar prune [--dry-run] [--yes]` | Delete orphan `.sm` files (sidecars whose accompanying `<basename>.md` does not exist on disk). Destructive — without `--dry-run` prompts for interactive confirmation listing every file to be deleted (per the §Dry-run analyzer for destructive verbs). `--yes` (alias `--force`) bypasses the prompt for non-interactive callers (CI, the pre-commit hook, scripts). With `--dry-run` reports what would be deleted without touching disk and never prompts. Different domain from `sm orphans` — that verb operates on the node graph (rename heuristic); this one operates on the filesystem layer. `--json` envelope: `{ deleted, wouldDelete, errors, items[], elapsedMs }`. Exit `1` when delete failures landed in `errors`. |
267
- | `sm sidecar annotate <node.path> [--force]` | Pure scaffolding. Writes a minimal `.sm` next to the `.md` with the `identity:` block populated and an empty `annotations: {}` block, ready for editing. Refuses if the file exists; `--force` overwrites. The optional legacy-frontmatter migration helper (`--from-frontmatter`) is deferred — no released consumer demands it. |
268
+ | `sm bump <node.path> [--force] [--yes]` | Single-node bump. Wraps `core/bump`. Refuses on a fresh node (`{ ok: false, reason: 'fresh' }`, exit `2`) unless `--force` is passed; with `--force` on a fresh node the verb is a silent no-op (exit `0`, no stdout). On a stale node (or first-time creation) increments `annotations.version`, refreshes `for.{bodyHash, frontmatterHash}`, and stamps the audit block (`audit.lastBumpedAt` + `audit.lastBumpedBy: 'cli'`; on first creation also `audit.createdAt` + `audit.createdBy: 'cli'`). Exit `5` if the node is not in the persisted scan. `--json` emits the report shape declared by `bump-report.schema.json`. `--yes` confirms consent for `.sm` writes in this project — see §`.sm` write consent below. |
269
+ | `sm bump --pending [--staged] [--force] [--yes]` | Batch bump. Walks every node whose sidecar overlay reports drift in `node.path` ASC order and bumps each. `--staged` runs `git add <sidecar-path>` after each successful bump so the new content lands in the same commit; `git add` failure degrades to a stderr warning, the batch keeps running. Empty stale set → exit `0` with a "nothing to do" advisory. `--json` envelope: `{ bumped, refused, skipped, errors[], elapsedMs }`. Exit `0` on a clean run; `1` when at least one per-node error landed in `errors[]`. **Git error matrix for `--staged`**: not inside a git repo (no `.git/` parent of `cwd`) → exit `5`; `git` binary not on PATH (spawn ENOENT) → exit `2`. Both checks run BEFORE any sidecar write so a misconfigured environment never produces partial state. `--yes` confirms consent for `.sm` writes — see §`.sm` write consent below. |
270
+ | `sm sidecar refresh <node.path> [--yes]` | Hash-only update on the sidecar. Refreshes `for.{bodyHash, frontmatterHash}` to match the live node WITHOUT bumping `annotations.version` and WITHOUT touching the audit block. Useful when the user knows a body change is editorial-only and doesn't want to spend a version increment. Distinct from the top-level `sm refresh` (which targets the enrichment layer at Step A.8) — different storage, different concept; the sub-namespace prefix prevents the collision. Exit `5` if the node has no sidecar or is not in the persisted scan. No-op on a fresh node (informational stderr, exit `0`). `--yes` confirms consent for `.sm` writes — see §`.sm` write consent below. |
271
+ | `sm sidecar prune [--dry-run] [--yes]` | Delete orphan `.sm` files (sidecars whose accompanying `<basename>.md` does not exist on disk). Destructive — without `--dry-run` prompts for interactive confirmation listing every file to be deleted (per the §Dry-run analyzer for destructive verbs). `--yes` (alias `--force`) bypasses the destructive-confirmation prompt for non-interactive callers (CI, the pre-commit hook, scripts) — NOT to be confused with the `.sm` write-consent gate, which does not apply to prune (delete is not a write). With `--dry-run` reports what would be deleted without touching disk and never prompts. Different domain from `sm orphans` — that verb operates on the node graph (rename heuristic); this one operates on the filesystem layer. `--json` envelope: `{ deleted, wouldDelete, errors, items[], elapsedMs }`. Exit `1` when delete failures landed in `errors`. |
272
+ | `sm sidecar annotate <node.path> [--force] [--yes]` | Pure scaffolding. Writes a minimal `.sm` next to the `.md` with the `identity:` block populated and an empty `annotations: {}` block, ready for editing. Refuses if the file exists; `--force` overwrites. The optional legacy-frontmatter migration helper (`--from-frontmatter`) is deferred — no released consumer demands it. `--yes` confirms consent for `.sm` writes — see §`.sm` write consent below. |
268
273
  | `sm hooks install pre-commit-bump [--dry-run]` | Install (or chain into) a git pre-commit hook that runs `sm bump --pending --staged` so any staged drift in `.sm` sidecars auto-bumps before the commit lands. Idempotent: re-running detects the skill-map marker and no-ops. When the repo already has a custom `pre-commit`, the verb appends the skill-map block to the existing file rather than replacing it. `--dry-run` prints the planned content with `--- target: <path> ---` markers and writes nothing. Exit `5` if no `.git/` parent is found at or above `cwd`; exit `2` on write failures or unknown hook flavours. |
269
274
 
270
275
  **`.sm` round-trip contract.** The `bump` verb, `sm sidecar refresh`, and `sm sidecar annotate` write through `FilesystemSidecarStore`, which re-serialises the merged result via `js-yaml` `dump` with `sortKeys: true`. **`.sm` files are managed artifacts; comments and key order are not preserved on round-trip.** Author commentary belongs in the markdown body or in a separate documentation file, not inside `.sm`. The integrity guarantee is that the merged YAML always validates against `sidecar.schema.json` + `annotations.schema.json` and that the file is written atomically (`.tmp + rename`).
@@ -320,11 +325,12 @@ The Hono BFF exposes the single-node bump flow over REST so the UI can drive the
320
325
  | Field | Value |
321
326
  |---|---|
322
327
  | Method + path | `POST /api/sidecar/bump` |
323
- | Request body | `{ "nodePath": <string, required>, "force"?: <boolean, default false> }` (JSON). `nodePath` is the canonical scope-root-relative `Node.path`. |
328
+ | Request body | `{ "nodePath": <string, required>, "force"?: <boolean, default false>, "confirm"?: <boolean, default false> }` (JSON). `nodePath` is the canonical scope-root-relative `Node.path`. `confirm` is the per-request `.sm` write-consent bypass — see §`.sm` write consent below. |
324
329
  | 200 envelope | `{ "schemaVersion": "1", "kind": "sidecar.bumped", "value": { "nodePath": <string>, "version": <int|null>, "status": "fresh" }, "elapsedMs": <int> }`. The `kind` value is part of the canonical `rest-envelope.schema.json#/properties/kind/enum` and validates under the action-result `oneOf` variant (`value` + `elapsedMs` siblings, no `filters` / `counts` / `kindRegistry`). |
325
330
  | 409 envelope | `{ "ok": false, "error": { "code": "sidecar-fresh", "message": <string>, "details": null } }`. Returned when the target node is fresh and `force !== true`. The `'sidecar-fresh'` code is added to `app.ts`'s `TErrorCode` union. |
331
+ | 412 envelope | `{ "ok": false, "error": { "code": "confirm-required", "message": <string>, "details": { "key": "allowEditSmFiles" } } }`. Returned when `allowEditSmFiles` is `false` and `confirm !== true`. The UI catches this and opens a ConfirmDialog; on accept it retries the POST with `{ ..., "confirm": true }` — the kernel then persists `allowEditSmFiles: true` to `<cwd>/.skill-map/settings.local.json` and performs the bump. See §`.sm` write consent below. |
326
332
  | 404 envelope | Standard `'not-found'` envelope. Returned when the DB is missing OR `nodePath` is not in the persisted scan. |
327
- | 400 envelope | Standard `'bad-query'` envelope. Body must be a JSON object with `nodePath` (non-empty string); `force` (when present) must be a boolean. |
333
+ | 400 envelope | Standard `'bad-query'` envelope. Body must be a JSON object with `nodePath` (non-empty string); `force` / `confirm` (when present) must be booleans. |
328
334
  | 200 force-on-fresh | Per the Action spec, `force: true` on a fresh node is a silent no-op — the response carries the existing `version` (read off the sidecar overlay) and `status: 'fresh'`. **No WS broadcast** is emitted in this case (decision: no-op = no event; nothing changed on disk, sending `sidecar.bumped` would tell every connected UI to refresh state that hasn't moved). |
329
335
 
330
336
  **WS event — `sidecar.bumped`** (Step 9.6.5; canonical envelope shape locked in 9.6.7 / R9). After every successful bump that materialises a write, the BFF broadcasts a `sidecar.bumped` event over `/ws` so all connected clients refresh in lockstep. The event uses the canonical `IWsEventEnvelope` wire shape (matches every other kernel→broadcaster bridge — `scan.*`, `watcher.*`, etc.):
@@ -363,6 +369,19 @@ Read-only catalog of plugin-contributed annotation keys. The endpoint is a pure
363
369
  | Empty case | When the kernel was booted with no plugin contributions (or `--no-plugins`): `{ "items": [], "counts": { "total": 0 } }`. |
364
370
  | Refresh policy | Same as the rest of the BFF's plugin surface — discovery happens once at `sm serve` boot. An operator that installs a new plugin restarts the server (matches the watcher's "loaded ONCE at boot" contract). |
365
371
 
372
+ ##### `.sm` write consent
373
+
374
+ Every verb in this section that writes `.sm` (the `bump` table rows, `sm sidecar refresh`, `sm sidecar annotate`, and the BFF's `POST /api/sidecar/bump`) consults the `allowEditSmFiles` setting (see [`architecture.md` §Config layering · Per-key locality](./architecture.md#per-key-locality) and §Annotation system · Write consent). Behaviour:
375
+
376
+ - **`allowEditSmFiles === true`** — the verb proceeds silently. No prompt, no flag mutation, identical to the pre-consent behaviour.
377
+ - **`allowEditSmFiles === false` and the operator passes `--yes` (CLI) or `{ "confirm": true }` (BFF body)** — the kernel persists `allowEditSmFiles: true` to `<cwd>/.skill-map/settings.local.json` (gitignored) and proceeds with the write. The flag flip is durable; the next invocation will not re-ask.
378
+ - **`allowEditSmFiles === false` and the operator did NOT confirm**:
379
+ - **CLI on a TTY** — the verb prints a one-paragraph explanation of what `.sm` files are and where they will land, then runs an interactive `confirm()` prompt. Accept proceeds (same effect as `--yes`); decline aborts the verb without persisting the rejection (exit `2`, the verb's reported `errors[]` carries one entry with code `confirm-required`). The next invocation re-asks; declining is never "remembered".
380
+ - **CLI without a TTY** (CI, piped stdin, agent harness) — the verb exits `2` immediately with a stderr message: `consent required: pass --yes to allow .sm sidecars in this project (writes to .skill-map/settings.local.json — gitignored)`.
381
+ - **BFF** — the route returns 412 `confirm-required` (envelope shown in the bump-endpoint table above). The UI catches the code and opens a `ConfirmationService.confirm({ ... })` dialog; on accept it retries the original request with `{ "confirm": true }`; on reject the action is silently abandoned (no toast spam — the user opted out).
382
+
383
+ `sm sidecar prune --yes` is unaffected: `--yes` on `prune` bypasses the destructive-delete confirmation prompt (the verb does not write `.sm`; it deletes orphans). The two flags share a spelling but address orthogonal concerns.
384
+
366
385
  ---
367
386
 
368
387
  ### Jobs
@@ -431,7 +450,7 @@ Authentication: the nonce is the sole credential. An implementation MUST reject
431
450
  | `sm plugins list` | Auto-discovered plugins with status. `--json` emits an array of `DiscoveredPlugin`. |
432
451
  | `sm plugins show <id>` | Full manifest + compat detail. |
433
452
  | `sm plugins enable <id> \| --all` | Toggle on. Persists in `config_plugins`. `--all` applies to every discovered plugin. |
434
- | `sm plugins disable <id> \| --all` | Toggle off; does not delete the plugin directory. `--all` applies to every discovered plugin. |
453
+ | `sm plugins disable <id> \| --all` | Toggle off; does not delete the plugin directory. Eagerly purges the plugin'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`). `--all` applies to every discovered plugin. |
435
454
  | `sm plugins doctor` | Revalidate all plugins against current spec version; update `status` fields. |
436
455
 
437
456
  ---
@@ -490,16 +509,19 @@ The reference implementation ships a Hono BFF rooted at `src/server/`. One Node
490
509
  | `GET /api/issues?severity=&analyzerId=&node=` | implemented | `RestEnvelope` (`kind: 'issues'`) — list of issues. Filters: `severity` (CSV from `error\|warn\|info`), `analyzerId` (CSV; qualified or short suffix per `sm check --analyzers`), `node` (filter to issues whose `nodeIds` includes the path). No pagination at v14.2. |
491
510
  | `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`. |
492
511
  | `GET /api/config` | implemented | `RestEnvelope` (`kind: 'config'`) — merged effective config (defaults → user → user-local → project → project-local → override). |
493
- | `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', granularity: 'bundle'\|'extension', description?: string, locked?: boolean, extensions?: Array<{ id, kind, version, enabled, description?: string, locked?: boolean }> }`. The `granularity` field reflects the manifest declaration (built-ins: hardcoded per `built-in-plugins/built-ins.ts`; drop-ins: from `plugin.json#/granularity`, default `'bundle'`). The `description` field on the bundle item carries the manifest-declared description (built-ins: hardcoded on `IBuiltInBundle`; drop-ins: `plugin.json#/description`); each `extensions[]` entry carries its extension manifest's `description` per `IExtensionBase` (`extensions/base.schema.json#/properties/description`). The SPA's Settings list renders the descriptions as muted secondary text and includes them in its substring-search index alongside the ids. The `extensions` array is present **only** when `granularity === 'extension'` AND the plugin loaded successfully; each entry's `enabled` reflects the per-extension override resolution (DB > settings.json > installed default). For `granularity: 'bundle'` plugins the array is omitted (the bundle is the only toggle-able key). The optional `locked: true` flag is stamped when the bundle 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` against them returns `403 locked`. The flag is omitted when false. |
494
- | `PATCH /api/plugins/:id` | implemented | Toggle one plugin's user override. `:id` MUST be a top-level bundle id; qualified-id form (`bundle/extension`) is the sibling route below. Body `{ enabled: boolean }` (JSON). Persists to `config_plugins` via `IConfigPluginsPort.set` — same path the CLI's `sm plugins enable\|disable` uses. Response is the canonical `{ id, version, kinds, status, reason, source }` row for the affected plugin (post-write `status` reflects the new override resolution). **Granularity** — rejected with 400 `bad-query` when the target bundle declares `granularity: 'extension'` (only the qualified-id form is toggle-able for those). **Lock** — rejected with 403 `locked` when the bundle id is in the host lock-list (`src/server/locked-plugins.ts`). **Restart required** — the loaded plugin runtime is boot-cached; the new value applies on the next `sm scan` or `sm serve` restart. The endpoint does NOT broadcast a WS event today. |
495
- | `PATCH /api/plugins/:bundleId/extensions/:extensionId` | implemented | Qualified-id form for `granularity: 'extension'` bundles (today: `core` + any user plugin that opts in). Body `{ enabled: boolean }`. Both segments are URL-path-segment-encoded (no slash inside `:bundleId` or `:extensionId`). Rejected with 400 `bad-query` when the target bundle declares `granularity: 'bundle'` (use the sibling route above). **Lock** — rejected with 403 `locked` when either the bundle id or the qualified `bundleId/extensionId` appears in the host lock-list. Same persistence + restart-required semantics as the bundle form. |
512
+ | `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', granularity: 'bundle'\|'extension', description?: string, locked?: boolean, startsAsDisabled?: boolean, extensions?: Array<{ id, kind, version, enabled, description?: string, locked?: boolean }> }`. The `granularity` field reflects the manifest declaration (built-ins: hardcoded per `built-in-plugins/built-ins.ts`; drop-ins: from `plugin.json#/granularity`, default `'bundle'`). The `description` field on the bundle item carries the manifest-declared description (built-ins: hardcoded on `IBuiltInBundle`; drop-ins: `plugin.json#/description`); each `extensions[]` entry carries its extension manifest's `description` per `IExtensionBase` (`extensions/base.schema.json#/properties/description`). The SPA's Settings list renders the descriptions as muted secondary text and includes them in its substring-search index alongside the ids. The `extensions` array is present **only** when `granularity === 'extension'` AND the plugin loaded successfully; each entry's `enabled` reflects the per-extension override resolution (DB > settings.json > installed default). For `granularity: 'bundle'` plugins the array is omitted (the bundle is the only toggle-able key). The optional `locked: true` flag is stamped when the bundle 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` against them returns `403 locked`. The flag is omitted when false. The optional `startsAsDisabled: true` flag is stamped on drop-in plugins (never built-ins) whose discovery-time `status` was `'disabled'` — that is, the user had them disabled in `config_plugins` / `settings.json` at `sm serve` boot, so their handlers were never bucketed into the runtime. The SPA renders a per-row hint when this flag is set AND the user toggles the row back on, since re-enabling them requires `sm serve` restart (the rest of the toggle pipeline applies live). The flag is omitted when false. |
513
+ | `PATCH /api/plugins/:id` | implemented | Toggle one plugin's user override. `:id` MUST be a top-level bundle id; qualified-id form (`bundle/extension`) is the sibling route below. Body `{ enabled: boolean }` (JSON). Persists to `config_plugins` via `IConfigPluginsPort.set` — same path the CLI's `sm plugins enable\|disable` uses. Response is the canonical `{ id, version, kinds, status, reason, source }` row for the affected plugin (post-write `status` reflects the new override resolution). **Granularity** — rejected with 400 `bad-query` when the target bundle declares `granularity: 'extension'` (only the qualified-id form is toggle-able for those). **Lock** — rejected with 403 `locked` when the bundle id is in the host lock-list (`src/server/locked-plugins.ts`). **Apply window** — the override applies on the next scan (manual via `POST /api/scan` or `sm scan`, automatic via watcher batch); 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 the plugin immediately on disable 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` on the read shape) are NOT in the runtime extension buckets; re-enabling them via PATCH persists the override but requires `sm serve` restart for the handlers to be loaded. The SPA surfaces this per-row when the user re-enables such a plugin. The endpoint does NOT broadcast a WS event today. |
514
+ | `PATCH /api/plugins/:bundleId/extensions/:extensionId` | implemented | Qualified-id form for `granularity: 'extension'` bundles (today: `core` + any user plugin that opts in). Body `{ enabled: boolean }`. Both segments are URL-path-segment-encoded (no slash inside `:bundleId` or `:extensionId`). Rejected with 400 `bad-query` when the target bundle declares `granularity: 'bundle'` (use the sibling route above). **Lock** — rejected with 403 `locked` when either the bundle id or the qualified `bundleId/extensionId` appears in the host lock-list. Same persistence + apply-window semantics as the bundle form (including the `startsAsDisabled` exception). |
515
+ | `PATCH /api/plugins` | implemented | Bulk toggle. Body `{ "changes": Array<{ "id": string, "enabled": boolean }> }` where each `id` is either a bare bundle id or a qualified `<bundle>/<extension>` id (the dispatcher branches on the slash exactly like the single-id routes above). Empty `changes` array is accepted as a no-op (returns the current `GET /api/plugins` envelope). The route validates the **entire batch** before writing — any invalid entry (`unknown-plugin` / `granularity-mismatch` / `locked`) rejects the whole request with the offending id in `error.details.id`, and the DB is not touched. Valid batches are applied in **one SQLite transaction**: `IConfigPluginsPort.set` per entry, then one grouped `scan_contributions` purge per disabled plugin (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 this envelope. 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 exists so the SPA can stage edits in a buffered modal and ship the final delta atomically. |
496
516
  | `ALL /api/*` (other) | reserved | structured 404 envelope (see below); future endpoints land in subsequent sub-steps. |
497
517
  | `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. |
498
518
  | `GET *` | implemented | static asset from the resolved UI bundle, falling back to `index.html` for SPA deep links. |
499
519
 
500
520
  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.
501
521
 
502
- **`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.
522
+ **`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 built-in Provider's `kinds[*].ui` block (regardless of the boot-time enabled verdict — their module code is statically imported by `built-in-bundles.ts` and always in memory) PLUS every drop-in user Provider that loaded successfully at boot (see [`architecture.md` §Provider · `ui` presentation](architecture.md#provider--ui-presentation)). The registry is then attached to every applicable response. Built-ins are listed unconditionally because a user re-enabling one mid-session expects its kinds to render on the next scan; the runtime enabled/disabled axis is enforced at SCAN-TIME by `composeScanExtensions` reading the fresh resolver, not by hiding kinds from the registry. Drop-ins that loaded as `disabled` carry `startsAsDisabled: true` on `GET /api/plugins` and need `sm serve` restart to register — their module code was never imported. 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.
523
+
524
+ **`contributionsRegistry` envelope field.** Same payload-bearing envelopes also embed `contributionsRegistry: { "<pluginId>/<extensionId>/<contributionId>": { pluginId, extensionId, contributionId, slot, label?, tooltip?, icon?, emptyText?, emitWhenEmpty } }`. Same boot-time assembly discipline as `kindRegistry`: ALL built-in declarations are listed regardless of enabled state (so re-enabling a built-in mid-session renders correctly on the next scan), plus drop-in user plugins that loaded at boot. The `slot` value comes from the closed catalog in `spec/schemas/view-slots.schema.json`. A view contribution emitted by an extension whose qualified id is missing from the registry is dropped by the UI's slot host (mirrors the kindRegistry contract — `startsAsDisabled` drop-ins illustrate the absence path).
503
525
 
504
526
  **Error envelope** (mirrors `§Machine-readable output analyzers`):
505
527
 
@@ -521,10 +543,10 @@ Error code sources at v14.2:
521
543
  - `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).
522
544
  - `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`.
523
545
  - `internal` (500) — uncaught error during a request (e.g. config-load failure, DB corruption surfacing through `loadScanResult`).
524
- - `db-missing` (500) — emitted by mutation endpoints (`PATCH /api/plugins/:id`, `PATCH /api/plugins/:bundleId/extensions/:extensionId`) when the project DB is absent. Read-side routes uniformly degrade to the empty shape (`/api/scan`) or zero items (list endpoints) so they do not emit this code; mutation endpoints cannot persist without a DB so they fail fast instead of silently dropping the write.
525
- - `not-found` (404) on `PATCH /api/plugins/:id` — unknown plugin id (no built-in bundle, no discovered drop-in matches). The qualified-id form returns the same code when either segment misses.
526
- - `bad-query` (400) on `PATCH /api/plugins/:id` — granularity mismatch (bundle-level call against a `granularity: 'extension'` bundle, or qualified-id call against a `granularity: 'bundle'` bundle), malformed body (missing `enabled`, wrong type), unknown extension id under a known bundle.
527
- - `locked` (403) on `PATCH /api/plugins/:id` and the qualified-id sibling — the target bundle id or qualified extension id is in the host lock-list (`src/server/locked-plugins.ts`). The list is hardcoded, host-only, and not user-editable; `GET /api/plugins` mirrors the same analyzer by stamping `locked: true` on the affected items.
546
+ - `db-missing` (500) — emitted by mutation endpoints (`PATCH /api/plugins/:id`, `PATCH /api/plugins/:bundleId/extensions/:extensionId`, `PATCH /api/plugins`) when the project DB is absent. Read-side routes uniformly degrade to the empty shape (`/api/scan`) or zero items (list endpoints) so they do not emit this code; mutation endpoints cannot persist without a DB so they fail fast instead of silently dropping the write.
547
+ - `not-found` (404) on `PATCH /api/plugins/:id` — unknown plugin id (no built-in bundle, no discovered drop-in matches). The qualified-id form returns the same code when either segment misses. The bulk `PATCH /api/plugins` returns the same code with `error.details.id` set to the first offending id; the batch is rejected before any DB write.
548
+ - `bad-query` (400) on `PATCH /api/plugins/:id` — granularity mismatch (bundle-level call against a `granularity: 'extension'` bundle, or qualified-id call against a `granularity: 'bundle'` bundle), malformed body (missing `enabled`, wrong type), unknown extension id under a known bundle. The bulk `PATCH /api/plugins` returns the same code for the same conditions (per-entry granularity mismatch, missing/typeless `enabled`, malformed `changes` array), with `error.details.id` set to the first offending entry's id.
549
+ - `locked` (403) on `PATCH /api/plugins/:id` and the qualified-id sibling — the target bundle id or qualified extension id is in the host lock-list (`src/server/locked-plugins.ts`). The list is hardcoded, host-only, and not user-editable; `GET /api/plugins` mirrors the same analyzer by stamping `locked: true` on the affected items. The bulk `PATCH /api/plugins` returns the same code with `error.details.id` set to the first locked entry; the batch is rejected before any DB write.
528
550
  - `bad-query` (400) on `POST /api/scan` — the server was started with `--no-built-ins` or `--no-plugins` (partial pipeline would persist a misleading DB).
529
551
  - `scan-busy` (409) on `POST /api/scan` — another scan (a watcher batch or another POST) is already in flight. Retry once the in-flight scan resolves; the WS `scan.completed` envelope is the unambiguous "now safe" signal.
530
552
 
@@ -25,7 +25,7 @@ This file is hand-maintained. A CI check before spec release compares the schema
25
25
  | 15 | `summaries/hook.schema.json` | — | 🔴 missing | Blocked by Step 11. |
26
26
  | 16 | `summaries/markdown.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` | `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. |
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 case for missing `kinds` 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/analyzer.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. |
@@ -11,7 +11,6 @@ export default {
11
11
  version: '0.1.0',
12
12
  description: 'provider whose markdown kind is missing the ui block',
13
13
  stability: 'experimental',
14
- explorationDir: '~/.bad',
15
14
  kinds: {
16
15
  markdown: {
17
16
  schema: './schemas/markdown.schema.json',
package/db-schema.md CHANGED
@@ -166,6 +166,7 @@ The orchestrator consults this table on `sm scan --changed`: a node-level cache
166
166
  | `node_path` | TEXT | NOT NULL | FK semantically to `scan_nodes.path`; MAY be unenforced (the row is deleted in the same tx as the parent node when the file disappears). |
167
167
  | `extractor_id` | TEXT | NOT NULL | Qualified id `<plugin_id>/<id>` per spec § A.6. |
168
168
  | `body_hash_at_run` | TEXT | NOT NULL | The `node.body_hash` the Extractor processed; sha256, hex. |
169
+ | `sidecar_annotations_hash_at_run` | TEXT | NOT NULL | sha256 of the canonical-form `node.sidecar.annotations` block the Extractor saw on its run. Always populated — an absent sidecar or one without annotations canonicalises to `{}` so the hash stays stable across "no sidecar" → "empty annotations" transitions. Participates in the cache hit condition for every Extractor: a `.sm`-only edit invalidates the cached run, no opt-in flag required. The author-facing alternative was considered and rejected because forgetting the flag yielded silent stale-data bugs; universal invalidation costs one re-run on sidecar edits (negligible — sidecars change rarely, Extractors are pure-CPU). |
169
170
  | `ran_at` | INTEGER | NOT NULL | Unix milliseconds — wall-clock when the Extractor finished or was last carried forward via cache reuse. Used for diagnostics + future GC of stale rows. |
170
171
 
171
172
  Primary key: `(node_path, extractor_id)`. Indexes: `ix_scan_extractor_runs_node`, `ix_scan_extractor_runs_extractor`.
@@ -229,7 +230,7 @@ Primary key: `(plugin_id, extension_id, node_path, contribution_id)`. Indexes: `
229
230
  **Persistence — orphan + catalog + per-tuple sweep + upsert (NOT pure replace-all).** The watcher's cached pass leaves the contributions buffer empty for cached nodes — the orchestrator skips `extract()` when the per-(node, extractor) cache hits, so no `emitContribution` fires. A naive wipe-all would silently drop the prior valid rows on every watcher boot. The persist runs four passes inside the same tx as the rest of the scan zone:
230
231
 
231
232
  1. **Orphan sweep** — drops every row whose `node_path` is NOT in the current live node set (`livePaths` derived from `result.nodes`). Disappeared nodes lose their contributions automatically.
232
- 2. **Catalog sweep** — drops every row whose qualified id `(pluginId, extensionId, contributionId)` is NOT in the registered runtime catalog (`registeredContributionKeys` collected via `collectRegisteredContributionKeys(composed)`). Uninstalled plugins, disabled bundles, and removed contributions lose their rows on the next scan.
233
+ 2. **Catalog sweep** — drops every row whose qualified id `(pluginId, extensionId, contributionId)` is NOT in the registered runtime catalog (`registeredContributionKeys` collected via `collectRegisteredContributionKeys(composed)`). Uninstalled-on-disk plugins and removed contributions lose their rows on the next scan. Disabled bundles are normally purged eagerly by `sm plugins disable` (see `purgeByPlugin` below), so the catalog sweep here is the fallback for the rare "config flipped between scans without going through the CLI" case.
233
234
  3. **Per-tuple sweep** — for every `(pluginId, extensionId, node_path)` tuple in `freshlyRunTuples` (extension actually ran against that node this scan: extractor cache miss, OR analyzer), drop any row carrying that triple whose `contribution_id` is NOT refreshed by the buffer. Catches the "extractor used to emit, now does not" case without touching cached-extractor rows. Tuple format: `<pluginId>/<extensionId>/<nodePath>`.
234
235
  4. **Upsert** — `INSERT ... ON CONFLICT DO UPDATE SET payload_json = excluded.payload_json, slot = excluded.slot` for every row in the buffer. PK conflict refreshes `payload_json` + `slot` + `emitted_at`.
235
236
 
@@ -239,6 +240,8 @@ Cached nodes' rows survive untouched — they're neither orphaned (still in the
239
240
 
240
241
  NOT analogous to `state_plugin_kvs` (which is plugin-managed). Belongs to the `scan_*` family — sweep semantics replace pure replace-all but the data is still scan-derived.
241
242
 
243
+ **Eager purge on disable.** `sm plugins disable <id>` calls `StoragePort.contributions.purgeByPlugin(pluginId, extensionId?)` immediately after persisting `config_plugins[<id>].enabled = false`. `extensionId` is omitted for bundle-granularity ids (e.g. `claude`) and supplied for qualified ids (e.g. `core/slash`), mirroring how the catalog sweep groups rows. The eager purge avoids the "I disabled the plugin but its chips are still rendered in the UI until I re-scan" gap. Re-enabling (`sm plugins enable <id>`) does NOT restore the rows — the next scan re-emits them, same as a cold start. Contributions are scan-derived, so this is cheap; for plugin-managed state (`state_plugin_kvs`, dedicated tables) the opposite policy holds — see `plugin-kv-api.md` § "disable does not drop data".
244
+
242
245
  ### `scan_node_tags`
243
246
 
244
247
  Tags · dual-source. One row per `(node_path, tag, source)` triple, projected at persist time from BOTH `frontmatter.tags` (with `source='author'`) and `sidecar.annotations.tags` (with `source='user'`). Drives `sm list --tag <name>` and the UI's tag-faceted search; the `(tag)` index keeps "find all nodes with tag X" `O(log n)`.
package/index.json CHANGED
@@ -174,24 +174,24 @@
174
174
  }
175
175
  ]
176
176
  },
177
- "specPackageVersion": "0.20.0",
177
+ "specPackageVersion": "0.22.0",
178
178
  "integrity": {
179
179
  "algorithm": "sha256",
180
180
  "files": {
181
- "CHANGELOG.md": "c1207870c14e59ad7a34dc709e19cb4dbfe4f9fa797b85614ba2466ef8a6dd95",
181
+ "CHANGELOG.md": "8e8333f49fecfe1b7a36efe23ede8ca17edc6caa5123b9a816b693feaa913e56",
182
182
  "README.md": "b551522ab0c7f5ef702e9ea4d4f67fd7ad838b080d85975c2834d8d40af14a00",
183
- "architecture.md": "181f54e12cff7b2a86e6a741520391ed828799b5b59725028eb4947b819066a7",
184
- "cli-contract.md": "af43179f3b363801fde1ddbbaede2185eaec6a7f42b89a748e98e505799663e2",
183
+ "architecture.md": "ca37e20cbe61a52c156a34f5ea2d660c3b920dfcda40cdb5601b5eac11f8fc21",
184
+ "cli-contract.md": "49a2f8042868f32f42a2504bc1c7e07d14642dc4f354c8ff6833d3a5a97a9a76",
185
185
  "conformance/README.md": "70e3101104765ef359d5322d0a7c9248d2157f78a510fb2cc8005b4eba3173d6",
186
186
  "conformance/cases/kernel-empty-boot.json": "2a5be9c93143d07a16d998df09dcc8fa4ea2d2f9a0bff6417573ed5a770352c1",
187
187
  "conformance/cases/orphan-markdown-fallback.json": "8ef6e49b7e6532bd845d9f54974a16e537cf98d355f0c5e4f4fb06abac3adcc5",
188
188
  "conformance/cases/plugin-missing-ui-rejected.json": "bdebee810436e6be88edf2fe38ddc6939fd3f53e6a12dc1d66da051c4922f1e9",
189
189
  "conformance/cases/sidecar-end-to-end.json": "24a73e7c857709d001cf7013b8fe5ccad4027e064b39533dda33697d80b56e7a",
190
- "conformance/coverage.md": "45208fd74c5b548962025307d489deb91eaeedc57c0b10ff7c941631851b6f07",
190
+ "conformance/coverage.md": "c35846bea401fcb1f4fd6e26f6ef8f6459ab46b48757f9b3f39c137636628260",
191
191
  "conformance/fixtures/orphan-markdown/.claude/agents/reviewer.md": "7f062731106f2d9811e4fffcf6ab44b8dfff4cfb16536a469514cc0664e832bf",
192
192
  "conformance/fixtures/orphan-markdown/ARCHITECTURE.md": "d6b6e18d4b963b26a292de73348c3396fd4710ab4c4bdd6cf094e581f99ec8d6",
193
193
  "conformance/fixtures/plugin-missing-ui/.skill-map/plugins/bad-provider/plugin.json": "4d78af6f12faa9d131e2a19f1dbb8f250baacc525978f3a8c858932b95da4ff6",
194
- "conformance/fixtures/plugin-missing-ui/.skill-map/plugins/bad-provider/provider.js": "d1f4898b43201d24f048171ce84d433b68694457452fbc64498857f5da3e9bbb",
194
+ "conformance/fixtures/plugin-missing-ui/.skill-map/plugins/bad-provider/provider.js": "45843e8e1a679482a17e01dcd43e1800e1d3148f4ac2cd6ab8a29ef0430b6152",
195
195
  "conformance/fixtures/plugin-missing-ui/notes/example.md": "55767f0aa1b6774546a99f28c58e7b732aa9cfa5dfce8d0326470f7f622f577e",
196
196
  "conformance/fixtures/preamble-v1.txt": "1e0aeef224b64477bdc13a949c3ad402e68249caf499ecdba1302371677c068b",
197
197
  "conformance/fixtures/sidecar-end-to-end/.claude/agents/orphan.sm": "3102ff10a0f08f60c014f82409d45ad4faf2cefa04d652a87676d3557ad64944",
@@ -199,15 +199,15 @@
199
199
  "conformance/fixtures/sidecar-end-to-end/.claude/agents/stale.sm": "cb04f7f3103b4218b09fd4da92f7ea429588b04c1dac6a9547ce362263b11224",
200
200
  "conformance/fixtures/sidecar-example/agent-example.md": "741131403e8c9580d0b7a8c2446cb4502d01f80053b7a2092663de92431aaa82",
201
201
  "conformance/fixtures/sidecar-example/agent-example.sm": "41200387e74a120c554a34dfabc50dd2151067a1c6599695c59412d8eab38bb4",
202
- "db-schema.md": "3e3ffdb245e68675f5a366c2ba8ad038e0233ce34105e9276e92c02cc9cdf69f",
202
+ "db-schema.md": "8d0725443ae4cf1231378b8bbadcae46b32cb1b6cae06fb98865005debb080a1",
203
203
  "interfaces/security-scanner.md": "aefe9f02f190615ba18649df03c1bdd79d98691039563c659e90f34362e5f1d5",
204
204
  "job-events.md": "b223bf0e576cbd481688e163ab3ce0a6e952a8a4a3912f1342237b664984e388",
205
205
  "job-lifecycle.md": "1d9c42632f8e77ef58ff47ae6d9680e7ed5939760627c75253aab8c80f728fd1",
206
- "plugin-author-guide.md": "7286ab5be91cdf2ca9f1132c64c44d9a353ee7a4bc3473f8eeeda0c57edd2a6a",
207
- "plugin-kv-api.md": "3e932e74ad27ce4e7e6218cbbddd2437c810d12f90b1590ef2313019d9b7d82f",
206
+ "plugin-author-guide.md": "c77d5ac616d0f8ce029ef183cc4d29a480ffc50a0910320ed95a26cf1b33bce9",
207
+ "plugin-kv-api.md": "673e0a65825ba1aabf9b4ba0b4e0d5baf8e81dc5de1c13bee9532fbb33e7b440",
208
208
  "prompt-preamble.md": "4860c310ccf2823870d318993ad8f067571799dade90bddb6634c3dbedd636b7",
209
209
  "schemas/annotations.schema.json": "b3a9aa66de17058ccfd890ea9ff1b9ee315a0877e9dd4a58fd8b76e26a99d00e",
210
- "schemas/api/rest-envelope.schema.json": "0f33b58e885cd0d74682a534d24765edee88fc35a63c03e987f73bdad451c892",
210
+ "schemas/api/rest-envelope.schema.json": "80d5480ebb96d41de497f35e4e5415025e0b69d1b424435c6b0d3a45945e2f63",
211
211
  "schemas/bump-report.schema.json": "c2d853715d5f50098567bc23382a4e81baf78d589c6e1baf67d3b841e7f7d8ae",
212
212
  "schemas/conformance-case.schema.json": "f6d4c9fb92e79cb516eeeb9d042223572a3bd5ff8e7871a0becce13916f20cf6",
213
213
  "schemas/execution-record.schema.json": "9628fa557cb856402f3a5f1d1167c609e46a197c850fe8171abfddd46c1028a8",
@@ -217,7 +217,7 @@
217
217
  "schemas/extensions/extractor.schema.json": "a859a53a7a5b009b1fe20d322bc1a8ff62e4b91ef938e98b1c80c802bd734b37",
218
218
  "schemas/extensions/formatter.schema.json": "2ab092aa37ae349c69b93071ed4f0e131affb7bb5799516ca82c721262631b36",
219
219
  "schemas/extensions/hook.schema.json": "a55cec50f6fda5b924de86359b910d22548d0a5bb61b2051edb82a80d3b36a2b",
220
- "schemas/extensions/provider.schema.json": "077c0c079e3965cee667019f76ee1e180d6b1f4162767d868bccc912e8dfbf89",
220
+ "schemas/extensions/provider.schema.json": "c33219571b7bf5a80f8424c302e85f779895fd6a71daec58d0948be609b1310a",
221
221
  "schemas/frontmatter/base.schema.json": "ec4abde950c31639974fc078e6bdc74ed48da4d2c0a996f5248684406910a178",
222
222
  "schemas/history-stats.schema.json": "23f472d1de06d23fc775aabba821f8375f347af4dc8d89ba567980d61a11f9de",
223
223
  "schemas/input-types.schema.json": "f1f51ccda746ea3c8a404757f60c89e403619e88ec4137a50af100ec89f8f4b5",
@@ -226,7 +226,7 @@
226
226
  "schemas/link.schema.json": "7fc429d03aca7e4c0b9a28241712c1aa2a5275870cea5ed938c2f97e8cccb081",
227
227
  "schemas/node.schema.json": "2ede4385e796cbf416c494d810dcb6d6036b35e71561efee46f5675bf0a015fe",
228
228
  "schemas/plugins-registry.schema.json": "678f476cf460d0b5876a92e72e0d572b6db265dd9fad6e95db553c56f77db5d9",
229
- "schemas/project-config.schema.json": "f6479bc73aa58821128965a5cea957cdd979cbaa4b942d76a251218cacfdeafa",
229
+ "schemas/project-config.schema.json": "a34fc136e606fe9700228a836d1211add068613226ef16239af1101843aef76e",
230
230
  "schemas/report-base-deterministic.schema.json": "6f8b38c097994ee87e0639935c42b5e85d8ea4244959ca397978171b0d7d2222",
231
231
  "schemas/report-base.schema.json": "a1021e9a59b4df9f99cd92454d797e88469766e7d49f52d231c4645ffdfdad8f",
232
232
  "schemas/scan-result.schema.json": "d1a8782e198bc9bb92dad247437aefa1b02f92ff8dca8562eaf2348fd7c5cf0c",
@@ -236,7 +236,7 @@
236
236
  "schemas/summaries/hook.schema.json": "58420ec485e152fdd21fa3d87337ad74b0d81a48d3b83dd072d4a2d196f78573",
237
237
  "schemas/summaries/markdown.schema.json": "33e2a1a11ec08a860c0c220609235c6fbdfda9ce19b6d65238f467f132ed4e54",
238
238
  "schemas/summaries/skill.schema.json": "f01bab92c51d64ee23e61587e42cf0dc5b37a2f518f5b12b3d1d456390338aa8",
239
- "schemas/view-slots.schema.json": "44e329a2d0fff8f4ed6b3c92209661b5dae39927742fb6121ce71584941d27d6",
239
+ "schemas/view-slots.schema.json": "59a6fd09af79d38dd16ae90dd3fe2965069335941909bc5b7f78110f3ec019fd",
240
240
  "versioning.md": "996e62006423edc01151a6f7869605f76c5e1454cc30b38d9f616925b5bcfb64"
241
241
  }
242
242
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skill-map/spec",
3
- "version": "0.20.0",
3
+ "version": "0.22.0",
4
4
  "description": "JSON Schemas, prose contracts, and conformance suite for the skill-map specification.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -49,7 +49,8 @@
49
49
  "spec:check": "node scripts/build-index.js --check && node scripts/check-coverage.js",
50
50
  "pin": "node scripts/sync-pin.js",
51
51
  "pin:check": "node scripts/sync-pin.js --check",
52
- "validate": "npm run spec:check && npm run pin:check"
52
+ "validate": "npm run validate:compile",
53
+ "validate:compile": "npm run spec:check && npm run pin:check"
53
54
  },
54
55
  "publishConfig": {
55
56
  "access": "public"
@@ -271,6 +271,8 @@ The runtime method is `extract(ctx) → void`. Output flows through three callba
271
271
 
272
272
  Extractors are deterministic-only and never see `ctx.runner`. If an Extractor needs LLM-derived data on a node, that workload belongs in an Action — see [`architecture.md` §Execution modes](./architecture.md#execution-modes).
273
273
 
274
+ You can read `ctx.node.sidecar.*` freely — the kernel's per-`(node, extractor)` cache hashes the sidecar `annotations` block alongside the `.md` body, so a `.sm`-only edit invalidates the cached run automatically. No manifest flag, no opt-in: just read what you need.
275
+
274
276
  > **Pick a syntax that doesn't collide with built-ins.** The built-in `at-directive` extractor fires on any `@token`; the built-in `slash` extractor fires on any `/token`. A new extractor that also matches one of those prefixes will likely fire on the same input, and if the two emit different `target` shapes the kernel raises a `trigger-collision` error. The example below uses a wikilink-style `[[ref:<name>]]` pattern to side-step this; reserve `@` and `/` for the built-ins.
275
277
 
276
278
  ```javascript
@@ -406,9 +408,9 @@ export default {
406
408
 
407
409
  These ship later in the v1.x line as bundled built-ins; the spec already pins their manifest shapes. Until the testkit grows full helpers for them (planned alongside Step 10), authors are encouraged to test them with a live kernel via `sm scan` against a fixture directory rather than in unit tests.
408
410
 
409
- #### Provider — `kinds` catalog and `explorationDir`
411
+ #### Provider — `kinds` catalog
410
412
 
411
- Every Provider declares two required top-level fields beyond the manifest base: `kinds` and `explorationDir`.
413
+ Every Provider declares one required top-level field beyond the manifest base: `kinds`.
412
414
 
413
415
  **`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:
414
416
 
@@ -416,14 +418,13 @@ Every Provider declares two required top-level fields beyond the manifest base:
416
418
  - **`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`.
417
419
  - **`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.
418
420
 
419
- **`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.
421
+ The Provider's walker hardcodes the paths it scans within the project (e.g. `.claude/`, `.cursor/rules`). The kernel does NOT extend the scan into the user's HOME based on Provider hints; the only way to scan paths outside the project is `scan.extraFolders` (set by the operator), which is privacy-sensitive and gated by `--yes`.
420
422
 
421
423
  ```jsonc
422
424
  {
423
425
  "id": "cursor",
424
426
  "kind": "provider",
425
427
  "version": "1.0.0",
426
- "explorationDir": "~/.cursor",
427
428
  "kinds": {
428
429
  "skill": {
429
430
  "schema": "./schemas/skill.schema.json",
@@ -673,7 +674,7 @@ Full surface in `@skill-map/testkit/index.ts`.
673
674
  | Status | Meaning | Common cause |
674
675
  |---|---|---|
675
676
  | `loaded` | manifest valid, specCompat satisfied, every extension imported and validated. | — |
676
- | `disabled` | user toggled it off via `sm plugins disable` or `settings.json#/plugins/<id>/enabled`. Manifest parsed; extensions not imported. | Intentional. |
677
+ | `disabled` | user toggled it off via `sm plugins disable` or `settings.json#/plugins/<id>/enabled`. Manifest parsed; extensions not imported. The plugin's `scan_contributions` rows are purged eagerly so its UI chips disappear immediately; plugin-managed KV / dedicated-table state is preserved (see `plugin-kv-api.md`). | Intentional. |
677
678
  | `incompatible-spec` | manifest parsed but `semver.satisfies` failed against the installed spec. | Plugin built against an older / newer spec. |
678
679
  | `invalid-manifest` | `plugin.json` missing, unparseable, AJV-fails, OR the directory name does not equal the manifest id. | Typo, missing required field, wrong shape, mismatched directory name. |
679
680
  | `load-error` | manifest passed but an extension module failed to import or its default export failed schema validation. | Missing `kind` field, wrong `kind` for the file, runtime import error. |
@@ -843,7 +844,7 @@ Inside any extension manifest (`IExtractor`, `IAnalyzer`, ...), declare a `viewC
843
844
  "emptyText": "No matches."
844
845
  },
845
846
  "total": {
846
- "slot": "card.footer.left.counter",
847
+ "slot": "card.footer.left",
847
848
  "icon": "🔍",
848
849
  "label": "kw",
849
850
  "emitWhenEmpty": false
@@ -859,19 +860,35 @@ Field reference (full schema in [`schemas/view-slots.schema.json`](./schemas/vie
859
860
  | `slot` | yes | One of the 15 catalog names (see below). Unknown name → `invalid-manifest` at load. |
860
861
  | `label` | no | Short human-readable label. English-only per [`AGENTS.md`](../AGENTS.md) (`Externalized texts, not internationalized`). |
861
862
  | `tooltip` | no | Hover tooltip on the chip / panel header. |
862
- | `icon` | no, but required for counter slots and `card.title.right` | Single string. If matches Unicode `\p{Extended_Pictographic}` emoji. Otherwise → PrimeIcons name (no `pi-` prefix). |
863
+ | `icon` | no, but required for counter slots and `card.title.right` | Single prefix-discriminated string. Emoji renders as text; `pi-foo` / `pi pi-foo` → PrimeIcons; `fa-solid fa-foo` / `fa-regular fa-foo` / `fa-brands fa-foo` → FontAwesome (full pass-through); `fa-foo` → defaults to `fa-solid fa-foo`. Bare names without prefix are rejected at load. See [Icon string forms](#icon-string-forms) below. |
863
864
  | `emptyText` | no | Text shown when payload is empty AND `emitWhenEmpty: true`. |
864
865
  | `emitWhenEmpty` | no, default `false` | When `false`, kernel drops empty payloads silently so the slot stays clean. |
865
866
 
867
+ #### Icon string forms
868
+
869
+ Four valid shapes, prefix-discriminated by the UI resolver:
870
+
871
+ ```jsonc
872
+ { "icon": "🔍" } // emoji — renders as text
873
+ { "icon": "pi-search" } // PrimeIcons — equivalent to "pi pi-search"
874
+ { "icon": "pi pi-search" } // PrimeIcons — full class string accepted
875
+ { "icon": "fa-solid fa-magnifying-glass" } // FontAwesome — explicit family, pass-through
876
+ { "icon": "fa-regular fa-star" } // FontAwesome — outlined variant
877
+ { "icon": "fa-brands fa-github" } // FontAwesome — brand glyph
878
+ { "icon": "fa-magnifying-glass" } // FontAwesome shorthand — defaults to `fa-solid`
879
+ ```
880
+
881
+ Anything else (e.g. bare `"search"` without a prefix) is rejected at manifest load with `invalid-manifest`. Pick the family that fits the visual; emoji is the cross-platform safe choice when you do not care about variant. FontAwesome Free's `regular` set is limited — only a handful of icons (e.g. `fa-star`, `fa-sun`, `fa-moon`, `fa-circle-up`) have outlined variants. PrimeIcons covers more generic UI glyphs.
882
+
866
883
  ### Slot catalog (closed)
867
884
 
868
- The kernel ships exactly these 15 slots. Each slot fixes a renderer + a payload shape; multiple slots may share a payload shape (e.g. all counter slots accept `{ value }`). Adding a slot requires a spec / UI / scaffolder round-trip — discuss in [`ROADMAP.md`](../ROADMAP.md) before opening a PR.
885
+ The kernel ships exactly these 14 slots. Each slot fixes a renderer + a payload shape; multiple slots may share a payload shape (e.g. all counter slots accept `{ value }`). Adding a slot requires a spec / UI / scaffolder round-trip — discuss in [`ROADMAP.md`](../ROADMAP.md) before opening a PR.
869
886
 
870
887
  | Slot | Payload shape | Renderer |
871
888
  |---|---|---|
872
889
  | `card.title.right` | `{ icon?, severity?, tooltip? }` | icon marker (manifest icon required) |
873
890
  | `card.subtitle.left` | `{ value: integer ≥ 0, severity?, tooltip? }` | counter chip (manifest icon required) |
874
- | `card.footer.left.counter` | `{ value: integer ≥ 0, severity?, tooltip? }` | counter chip (manifest icon required) |
891
+ | `card.footer.left` | `{ value: integer ≥ 0, severity?, tooltip? }` | counter chip (manifest icon required) |
875
892
  | `card.footer.right` | `{ value: integer ≥ 0, severity?, tooltip? }` | counter chip (manifest icon required) |
876
893
  | `graph.node.alert` | `{ icon?, severity?, count?, tooltip? }` | graph corner badge |
877
894
  | `inspector.header.badge.counter` | `{ value: integer ≥ 0, severity?, tooltip? }` | counter chip (manifest icon required) |
@@ -882,7 +899,7 @@ The kernel ships exactly these 15 slots. Each slot fixes a renderer + a payload
882
899
  | `inspector.body.panel.key-values` | `{ entries: Array<{ key, value, tooltip? }> }` (≤ 50) | definition list panel |
883
900
  | `inspector.body.panel.link-list` | `{ entries: Array<{ path, label?, kind? }> }` (≤ 100) | clickable list panel |
884
901
  | `inspector.body.panel.markdown` | `{ markdown }` (≤ 4096 chars, sanitized) | sanitized markdown panel |
885
- | `topbar.actions.indicator` | `{ value, label?, severity?, tooltip? }` | scope chip |
902
+ | `topbar.nav.start` | `{ value, label?, severity?, tooltip? }` | scope chip |
886
903
 
887
904
  Per-slot semantics, edge cases, and exact payload schemas live in [`view-slots.md`](./view-slots.md) (catalog reference) and [`schemas/view-slots.schema.json`](./schemas/view-slots.schema.json) at `$defs/payloads/<slot>`. Read those before emitting.
888
905
 
@@ -902,7 +919,7 @@ The first argument is the manifest Record key (`'breakdown'` or `'total'` above)
902
919
 
903
920
  The kernel validates the payload against the slot's payload schema in `view-slots.schema.json#/$defs/payloads/<slot>`. Off-shape payloads emit an `extension.error` event and drop silently — same posture as `emitLink` rejecting links not in your `emitsLinkKinds`.
904
921
 
905
- For `topbar.actions.indicator`, analyzers use `ctx.emitScopeContribution(id, payload)` (extractors do not see this method — scope-level emission lives in analyzer context).
922
+ For `topbar.nav.start`, analyzers use `ctx.emitScopeContribution(id, payload)` (extractors do not see this method — scope-level emission lives in analyzer context). **The `emitScopeContribution` callback is reserved in the spec but not yet implemented** on `IAnalyzerContext`; a manifest declaring a `topbar.nav.start` contribution loads fine, but emissions are deferred until the runtime callback ships. See `architecture.md` §View contribution system → Emit path for the canonical status.
906
923
 
907
924
  ### Multi-slot rendering
908
925
 
@@ -911,7 +928,7 @@ Want the same data in two surfaces? Declare two contributions, each pointing at
911
928
  ```jsonc
912
929
  "viewContributions": {
913
930
  "mentionsFooter": {
914
- "slot": "card.footer.left.counter",
931
+ "slot": "card.footer.left",
915
932
  "icon": "@",
916
933
  "label": "mentions"
917
934
  },
@@ -1044,7 +1061,7 @@ export const extractor = {
1044
1061
  emptyText: 'No matches.',
1045
1062
  },
1046
1063
  total: {
1047
- slot: 'card.footer.left.counter',
1064
+ slot: 'card.footer.left',
1048
1065
  icon: '🔍',
1049
1066
  label: 'kw',
1050
1067
  emitWhenEmpty: false,
package/plugin-kv-api.md CHANGED
@@ -184,7 +184,7 @@ A plugin MUST declare **exactly one** storage mode. Mixing modes in the same plu
184
184
 
185
185
  - Mode A rows are stored in `state_plugin_kvs` and are backed up with `sm db backup`.
186
186
  - Mode B rows live in the plugin's dedicated tables, prefixed `plugin_<id>_`, and are likewise backed up.
187
- - `sm plugins disable <id>` does NOT drop the plugin's data — disabled plugins keep their KV rows and dedicated tables. `sm plugins forget <id>` (deferred to post-`v1.0`) is the verb that wipes.
187
+ - `sm plugins disable <id>` does NOT drop the plugin's data — disabled plugins keep their KV rows and dedicated tables. (`scan_contributions` rows ARE purged eagerly on disable — see `db-schema.md` § `scan_contributions` — because those are scan-derived and would otherwise keep rendering in the UI until the next scan. The KV / dedicated-table data is plugin-managed and survives toggle cycles so re-enabling restores state.) `sm plugins forget <id>` (deferred to post-`v1.0`) is the verb that wipes everything.
188
188
  - `sm db reset` (no modifier) drops only `scan_*`. Plugin KV rows (mode A) and plugin-dedicated tables (mode B) are **preserved** — the reset is non-destructive to plugin storage.
189
189
  - `sm db reset --state` drops `state_*` AND every `plugin_<normalized_id>_*` table, which includes `state_plugin_kvs` (mode A) AND the plugin-dedicated tables (mode B). The CLI MUST require interactive confirmation unless `--yes` is passed.
190
190
  - `sm db reset --hard` deletes the DB file entirely, destroying all plugin storage regardless of mode.
@@ -120,7 +120,11 @@
120
120
  "tooltip": { "type": "string", "maxLength": 256 },
121
121
  "icon": { "type": "string", "maxLength": 64 },
122
122
  "emptyText": { "type": "string", "maxLength": 128 },
123
- "emitWhenEmpty": { "type": "boolean", "default": false }
123
+ "emitWhenEmpty": { "type": "boolean", "default": false },
124
+ "priority": {
125
+ "type": "number",
126
+ "description": "Optional ordering hint (default 100 when omitted). Slots whose `order` is `'priority'` sort contributions ASC by this value with alphabetical tie-break by qualified id. Mirror of `IViewContribution.priority` in `view-slots.schema.json#/$defs/ViewContribution`; propagated so the UI can apply the manifest-declared order without a second round-trip."
127
+ }
124
128
  }
125
129
  }
126
130
  },
@@ -2,20 +2,15 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "https://skill-map.dev/spec/v0/extensions/provider.schema.json",
4
4
  "title": "ExtensionProvider",
5
- "description": "Manifest shape for a `Provider` extension. A Provider declares its own universe: the platform it recognises (Claude Code, Codex, Gemini, Obsidian vault, generic MD), the catalog of node `kind`s it emits, the per-kind frontmatter schema each kind follows, and the filesystem directory (`explorationDir`) where its content lives. The catalog lives in the `kinds` map, keyed by kind name. Each map entry declares the relative path to the kind's frontmatter schema (resolved against the Provider's directory) and the qualified `defaultRefreshAction` id the UI's probabilistic-refresh surface dispatches for that kind. Spec only ships `frontmatter/base.schema.json` (universal); per-kind schemas live with their owning Provider so that adding a new platform is purely additive — no spec bump needed to introduce kinds. Exactly zero or one Provider MUST match any given file; multiple matches → the kernel emits an issue `provider-ambiguous` and the file is left unclassified. Providers are deterministic-only — they sit at the filesystem boundary and run during boot; probabilistic classification would make boot slow, costly, and non-reproducible. The `mode` field MUST NOT appear in Provider manifests. If you need LLM-assisted classification, write a probabilistic Action that runs as a queued job and writes back through the enrichment layer; Extractors are deterministic-only and Providers stay on the deterministic boot path. Distinct from the **hexagonal-architecture** 'adapter' (`RunnerPort.adapter`, `StoragePort.adapter`, etc.), which is an internal driven-adapter implementing a port — Providers live in the extension surface, hexagonal adapters live in `src/kernel/adapters/`. Stability: stable as of spec v1.0.0 except where noted.",
5
+ "description": "Manifest shape for a `Provider` extension. A Provider declares its own universe: the platform it recognises (Claude Code, Codex, Gemini, Obsidian vault, generic MD), the catalog of node `kind`s it emits, and the per-kind frontmatter schema each kind follows. The catalog lives in the `kinds` map, keyed by kind name. Each map entry declares the relative path to the kind's frontmatter schema (resolved against the Provider's directory) and the qualified `defaultRefreshAction` id the UI's probabilistic-refresh surface dispatches for that kind. Spec only ships `frontmatter/base.schema.json` (universal); per-kind schemas live with their owning Provider so that adding a new platform is purely additive — no spec bump needed to introduce kinds. Exactly zero or one Provider MUST match any given file; multiple matches → the kernel emits an issue `provider-ambiguous` and the file is left unclassified. Providers are deterministic-only — they sit at the filesystem boundary and run during boot; probabilistic classification would make boot slow, costly, and non-reproducible. The `mode` field MUST NOT appear in Provider manifests. If you need LLM-assisted classification, write a probabilistic Action that runs as a queued job and writes back through the enrichment layer; Extractors are deterministic-only and Providers stay on the deterministic boot path. Distinct from the **hexagonal-architecture** 'adapter' (`RunnerPort.adapter`, `StoragePort.adapter`, etc.), which is an internal driven-adapter implementing a port — Providers live in the extension surface, hexagonal adapters live in `src/kernel/adapters/`. Stability: stable as of spec v1.0.0 except where noted.",
6
6
  "allOf": [
7
7
  { "$ref": "base.schema.json" }
8
8
  ],
9
9
  "type": "object",
10
- "required": ["id", "kind", "version", "kinds", "explorationDir"],
10
+ "required": ["id", "kind", "version", "kinds"],
11
11
  "unevaluatedProperties": false,
12
12
  "properties": {
13
13
  "kind": { "const": "provider" },
14
- "explorationDir": {
15
- "type": "string",
16
- "minLength": 1,
17
- "description": "Filesystem directory (relative to user home or project root) where this Provider's content lives. Required. Examples: '~/.claude' for the Claude Provider; '~/.cursor' for a hypothetical Cursor Provider. The kernel walks this directory during boot/scan to discover nodes; the Provider's `globs` (if declared) refines what to match inside. `sm doctor` validates the directory exists; missing directory yields a non-blocking warning."
18
- },
19
14
  "roots": {
20
15
  "type": "array",
21
16
  "description": "Path globs (relative to scope root) that this Provider SHOULD be consulted for. Advisory — the kernel walks all roots and consults every Provider regardless, but this field lets `sm doctor` warn when no file matched a specific Provider (i.e. the Provider was loaded for a platform that isn't in this scope).",