@skill-map/spec 0.21.0 → 0.23.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.
Files changed (42) hide show
  1. package/CHANGELOG.md +127 -0
  2. package/README.md +4 -4
  3. package/architecture.md +134 -128
  4. package/cli-contract.md +107 -104
  5. package/conformance/README.md +13 -13
  6. package/conformance/coverage.md +42 -39
  7. package/conformance/fixtures/plugin-missing-ui/.skill-map/plugins/bad-provider/provider.js +0 -1
  8. package/db-schema.md +45 -45
  9. package/index.json +41 -38
  10. package/interfaces/security-scanner.md +20 -20
  11. package/job-events.md +21 -21
  12. package/job-lifecycle.md +21 -21
  13. package/package.json +3 -2
  14. package/plugin-author-guide.md +135 -111
  15. package/plugin-kv-api.md +10 -10
  16. package/prompt-preamble.md +8 -8
  17. package/schemas/annotations.schema.json +3 -3
  18. package/schemas/api/rest-envelope.schema.json +15 -11
  19. package/schemas/conformance-result.schema.json +120 -0
  20. package/schemas/execution-record.schema.json +2 -2
  21. package/schemas/extensions/analyzer.schema.json +9 -0
  22. package/schemas/extensions/base.schema.json +4 -4
  23. package/schemas/extensions/extractor.schema.json +4 -4
  24. package/schemas/extensions/formatter.schema.json +1 -1
  25. package/schemas/extensions/hook.schema.json +3 -3
  26. package/schemas/extensions/provider.schema.json +6 -11
  27. package/schemas/frontmatter/base.schema.json +1 -1
  28. package/schemas/history-stats.schema.json +4 -4
  29. package/schemas/input-types.schema.json +3 -3
  30. package/schemas/issue.schema.json +1 -1
  31. package/schemas/job.schema.json +2 -2
  32. package/schemas/node.schema.json +5 -5
  33. package/schemas/plugins-doctor.schema.json +97 -0
  34. package/schemas/plugins-registry.schema.json +2 -2
  35. package/schemas/project-config.schema.json +10 -14
  36. package/schemas/refresh-report.schema.json +52 -0
  37. package/schemas/report-base-deterministic.schema.json +1 -1
  38. package/schemas/sidecar.schema.json +3 -3
  39. package/schemas/summaries/markdown.schema.json +1 -1
  40. package/schemas/summaries/skill.schema.json +1 -1
  41. package/schemas/view-slots.schema.json +7 -7
  42. package/versioning.md +7 -7
@@ -2,7 +2,7 @@
2
2
 
3
3
  How to ship a third-party `skill-map` plugin: directory layout, manifest fields, the six extension kinds, storage choice, version compatibility, dual-mode posture, and how to test the result with `@skill-map/testkit`.
4
4
 
5
- This guide is **descriptive prose**, not the normative contract. The normative pieces live in the schemas and the architecture document every claim here is cross-linked to its source. When the two disagree, [`architecture.md`](./architecture.md) wins.
5
+ This guide is **descriptive prose**, not the normative contract. The normative pieces live in the schemas and the architecture document, every claim here is cross-linked to its source. When the two disagree, [`architecture.md`](./architecture.md) wins.
6
6
 
7
7
  > **Status.** Ships with spec v1.0.0. The author surface is intended to stay stable through the v1.x line; widening (new extension kind, new storage mode) is a minor bump per [`versioning.md`](./versioning.md).
8
8
 
@@ -58,8 +58,8 @@ Drop the directory under one of the discovery roots and `sm plugins list` will p
58
58
 
59
59
  The kernel scans two roots, in this order:
60
60
 
61
- 1. `<project>/.skill-map/plugins/` committed-with-the-repo plugins.
62
- 2. `~/.skill-map/plugins/` user-level plugins available across every project.
61
+ 1. `<project>/.skill-map/plugins/`, committed-with-the-repo plugins.
62
+ 2. `~/.skill-map/plugins/`, user-level plugins available across every project.
63
63
 
64
64
  A plugin is any direct child directory containing a `plugin.json`. Nested directories are not searched recursively. Pass `--plugin-dir <path>` to override both roots (mostly for testing).
65
65
 
@@ -70,13 +70,13 @@ After every change to the `plugins/` folder, run `sm plugins list` to see the lo
70
70
  The `id` declared in `plugin.json` is **globally unique** across every active discovery root. The kernel enforces this in two places:
71
71
 
72
72
  1. **Directory name MUST equal manifest id.** A plugin lives at `<root>/<id>/plugin.json`. If `basename(<plugin-dir>) !== manifest.id`, discovery surfaces the plugin with status `invalid-manifest` and a reason naming both names. This analyzer eliminates same-root collisions by construction (a filesystem cannot host two siblings with the same name).
73
- 2. **Cross-root id collisions are blocked, both sides.** If two plugins from different roots (project + global, or any combination of `--plugin-dir`) declare the same `id`, **both** receive status `id-collision`. There is no precedence analyzer neither plugin loads its extensions; the user resolves the conflict by renaming one and rerunning. Coherent with the spec analyzer that no extension is privileged.
73
+ 2. **Cross-root id collisions are blocked, both sides.** If two plugins from different roots (project + global, or any combination of `--plugin-dir`) declare the same `id`, **both** receive status `id-collision`. There is no precedence analyzer, neither plugin loads its extensions; the user resolves the conflict by renaming one and rerunning. Coherent with the spec analyzer that no extension is privileged.
74
74
 
75
75
  `sm plugins list` shows the conflict; `sm plugins doctor` exits `1` whenever any `id-collision` is present.
76
76
 
77
77
  ### Qualified extension ids
78
78
 
79
- Every extension is identified in the registry and in any cross-extension reference by its **qualified id** `<plugin-id>/<extension-id>`. The plugin's manifest `id` is therefore not just a discovery key: it doubles as the **namespace** for every extension the plugin ships.
79
+ Every extension is identified in the registry, and in any cross-extension reference, by its **qualified id** `<plugin-id>/<extension-id>`. The plugin's manifest `id` is therefore not just a discovery key: it doubles as the **namespace** for every extension the plugin ships.
80
80
 
81
81
  Concrete examples for the reference impl's bundled extensions:
82
82
 
@@ -95,48 +95,48 @@ Concrete examples for the reference impl's bundled extensions:
95
95
 
96
96
  Built-ins split between two namespaces:
97
97
 
98
- - **`core/`** kernel-internal primitives, platform-agnostic. Owns every built-in analyzer (including `validate-all`), the ASCII formatter, and the cross-vendor extractors (`annotations`, `slash`, `at-directive`, `markdown-link`, `external-url-counter`) any Provider can rely on.
99
- - **`claude/`** the Claude Code Provider bundle: the Provider that classifies `.claude/{agents,commands,skills}` paths and parses their frontmatter. Vendor-specific bundles (`gemini`, `agent-skills`) follow the same shape Provider only since the syntax their nodes use is shared with Claude and lives in `core`.
98
+ - **`core/`**, kernel-internal primitives, platform-agnostic. Owns every built-in analyzer (including `validate-all`), the ASCII formatter, and the cross-vendor extractors (`annotations`, `slash`, `at-directive`, `markdown-link`, `external-url-counter`) any Provider can rely on.
99
+ - **`claude/`**, the Claude Code Provider bundle: the Provider that classifies `.claude/{agents,commands,skills}` paths and parses their frontmatter. Vendor-specific bundles (`gemini`, `agent-skills`) follow the same shape, Provider only, since the syntax their nodes use is shared with Claude and lives in `core`.
100
100
 
101
- For your own plugin, the `id` you declare in `plugin.json` is the namespace for every extension the plugin contains. If your manifest declares `id: "my-plugin"` and your extension file declares `id: "foo-extractor"`, the kernel registers it as `my-plugin/foo-extractor`. You do **not** write the qualifier yourself the loader injects it.
101
+ For your own plugin, the `id` you declare in `plugin.json` is the namespace for every extension the plugin contains. If your manifest declares `id: "my-plugin"` and your extension file declares `id: "foo-extractor"`, the kernel registers it as `my-plugin/foo-extractor`. You do **not** write the qualifier yourself, the loader injects it.
102
102
 
103
103
  What this means in practice:
104
104
 
105
105
  - **In the extension file**, declare only the short id (`id: "greet"`). Do **not** prefix it with the plugin id (`id: "my-plugin/greet"` is rejected as a kebab-case violation).
106
- - **In the manifest's `extensions[]`**, list relative paths to extension files as before nothing changes.
106
+ - **In the manifest's `extensions[]`**, list relative paths to extension files as before, nothing changes.
107
107
  - **In `defaultRefreshAction` (Provider)** and any other cross-extension reference, use the qualified id of the target. A built-in Provider that wants the `core/summarize-agent` action references it by the qualified form; a third-party Provider that wants its own bundled action references `<my-plugin>/<my-action>`.
108
108
  - **`sm plugins list` and `sm plugins show`** print qualified ids for every extension. The plugin id itself stays unqualified (it IS the namespace; nothing wraps it).
109
109
  - **`sm plugins enable/disable <id>`** still operates on the **plugin id** (the namespace), not on individual extensions. Toggle the namespace and every extension under it follows.
110
110
 
111
111
  The kernel guards against two foot-guns:
112
112
 
113
- - If the extension file injects a `pluginId` field that doesn't match `plugin.json#/id`, the loader emits `invalid-manifest` with a directed reason. The composed qualifier MUST come from `plugin.json` there is no second source of truth.
113
+ - If the extension file injects a `pluginId` field that doesn't match `plugin.json#/id`, the loader emits `invalid-manifest` with a directed reason. The composed qualifier MUST come from `plugin.json`, there is no second source of truth.
114
114
  - The kebab-case pattern on the extension `id` deliberately forbids `/`. This keeps the analyzer "the qualifier always lives in the plugin id, never in the extension id" enforced by AJV.
115
115
 
116
- For built-ins, the reference impl's `src/extensions/built-ins.ts` declares each extension's `pluginId` (`core` or `claude`) explicitly built-ins do not have a `plugin.json`, so the bundle declaration IS the source of truth for their namespace.
116
+ For built-ins, the reference impl's `src/extensions/built-ins.ts` declares each extension's `pluginId` (`core` or `claude`) explicitly, built-ins do not have a `plugin.json`, so the bundle declaration IS the source of truth for their namespace.
117
117
 
118
- ### Granularity bundle vs extension
118
+ ### Granularity, bundle vs extension
119
119
 
120
120
  Every plugin and every built-in bundle declares a **granularity** that controls how its extensions are toggled by `sm plugins enable / disable` and by `config_plugins` / `settings.json`. Two modes:
121
121
 
122
122
  | Granularity | Toggle key | When to use |
123
123
  |---|---|---|
124
124
  | `bundle` (default) | the bundle id alone (e.g. `my-plugin`, `claude`) | The plugin's extensions form a coherent product (e.g. a Provider and the extractors that decode its native syntax). The user wants one switch. **95% of plugins.** |
125
- | `extension` | the qualified extension id (`<bundle>/<ext-id>`, e.g. `core/superseded`, `my-plugin/orphan-skill`) | The plugin ships several orthogonal capabilities a user might reasonably want piecemeal. **Built-in `core` is the canonical example** the spec promises every kernel built-in is removable, so each one toggles independently. |
125
+ | `extension` | the qualified extension id (`<bundle>/<ext-id>`, e.g. `core/superseded`, `my-plugin/orphan-skill`) | The plugin ships several orthogonal capabilities a user might reasonably want piecemeal. **Built-in `core` is the canonical example**, the spec promises every kernel built-in is removable, so each one toggles independently. |
126
126
 
127
127
  Built-in mapping:
128
128
 
129
- - **`claude`** / **`gemini`** / **`agent-skills`** `granularity: 'bundle'`. Each vendor Provider bundle is enabled or disabled as a whole; today every such bundle ships only its Provider, so the toggle flips classification + frontmatter parsing for that platform.
130
- - **`core`** `granularity: 'extension'`. `sm plugins disable core/superseded` flips just the supersession analyzer; every other core extension (every other analyzer, the ASCII formatter, the cross-vendor extractors) stays live.
129
+ - **`claude`** / **`gemini`** / **`agent-skills`**, `granularity: 'bundle'`. Each vendor Provider bundle is enabled or disabled as a whole; today every such bundle ships only its Provider, so the toggle flips classification + frontmatter parsing for that platform.
130
+ - **`core`**, `granularity: 'extension'`. `sm plugins disable core/superseded` flips just the supersession analyzer; every other core extension (every other analyzer, the ASCII formatter, the cross-vendor extractors) stays live.
131
131
 
132
132
  Per-verb behaviour:
133
133
 
134
134
  | Command | Bundle granularity | Extension granularity |
135
135
  |---|---|---|
136
- | `sm plugins enable claude` | OK flips the bundle. | Rejected: `'core' has granularity=extension; use sm plugins enable core/<ext-id>`. |
136
+ | `sm plugins enable claude` | OK, flips the bundle. | Rejected: `'core' has granularity=extension; use sm plugins enable core/<ext-id>`. |
137
137
  | `sm plugins enable claude/claude` | Rejected: `'claude' has granularity=bundle; use sm plugins enable claude`. | n/a (no bundle of granularity=bundle accepts qualified ids) |
138
138
  | `sm plugins disable core` | n/a | Rejected: same directed message as the bundle row above. |
139
- | `sm plugins disable core/superseded` | n/a | OK persists `config_plugins['core/superseded'].enabled = 0`. |
139
+ | `sm plugins disable core/superseded` | n/a | OK, persists `config_plugins['core/superseded'].enabled = 0`. |
140
140
 
141
141
  Resolution order is the same as for plugin enabled-state: DB override (`config_plugins`) > settings.json (`#/plugins/<id>/enabled`) > installed default (`true`). For granularity=extension bundles the row key is the qualified id; for granularity=bundle bundles the row key is the bundle id. `settings.json#/plugins` keys are arbitrary strings (no AJV pattern), so both forms are accepted there too.
142
142
 
@@ -157,11 +157,11 @@ In your own plugin's `plugin.json`, set `granularity` only when you opt into the
157
157
  }
158
158
  ```
159
159
 
160
- The default (`'bundle'`) is the right answer for almost every plugin keep the manifest minimal until the plugin actually ships several independent capabilities.
160
+ The default (`'bundle'`) is the right answer for almost every plugin, keep the manifest minimal until the plugin actually ships several independent capabilities.
161
161
 
162
- ### Extractor `applicableKinds` narrow the pipeline
162
+ ### Extractor `applicableKinds`, narrow the pipeline
163
163
 
164
- An `Extractor` extension MAY declare an `applicableKinds` array on its manifest. When declared, the kernel runs the extractor **only** against nodes whose `kind` is in the list the filter is fail-fast (no extractor context, no method call) so the extractor wastes zero CPU on nodes it cannot meaningfully process.
164
+ An `Extractor` extension MAY declare an `applicableKinds` array on its manifest. When declared, the kernel runs the extractor **only** against nodes whose `kind` is in the list, the filter is fail-fast (no extractor context, no method call) so the extractor wastes zero CPU on nodes it cannot meaningfully process.
165
165
 
166
166
  | `applicableKinds` | Behaviour |
167
167
  |---|---|
@@ -170,9 +170,9 @@ An `Extractor` extension MAY declare an `applicableKinds` array on its manifest.
170
170
  | `['skill', 'agent']` | Runs on skills + agents. Hooks, commands, notes are skipped. |
171
171
  | `[]` | **Invalid.** AJV rejects the manifest at load time (`minItems: 1`). The absence of the field already means "every kind"; an empty array is reserved for "this is a typo". |
172
172
 
173
- There is no wildcard syntax (no `'*'`) omitting the field IS the wildcard. The pattern is intentional: a literal absence is unambiguous, a string sentinel would invite typos that silently disable the extractor.
173
+ There is no wildcard syntax (no `'*'`), omitting the field IS the wildcard. The pattern is intentional: a literal absence is unambiguous, a string sentinel would invite typos that silently disable the extractor.
174
174
 
175
- Use case a deterministic frontmatter-tag extractor that only makes sense for skills:
175
+ Use case, a deterministic frontmatter-tag extractor that only makes sense for skills:
176
176
 
177
177
  ```javascript
178
178
  export default {
@@ -185,7 +185,7 @@ export default {
185
185
  scope: 'frontmatter',
186
186
  applicableKinds: ['skill'],
187
187
  async extract(ctx) {
188
- // Never invoked for agents, commands, hooks, or notes the kernel
188
+ // Never invoked for agents, commands, hooks, or notes, the kernel
189
189
  // skipped this node before reaching us.
190
190
  const tags = Array.isArray(ctx.frontmatter.tags) ? ctx.frontmatter.tags : [];
191
191
  for (const t of tags) {
@@ -201,9 +201,25 @@ export default {
201
201
  };
202
202
  ```
203
203
 
204
- > **Why no `mode` field?** Extractors are deterministic-only they sit on `sm scan`'s synchronous loop, and the loop must stay fast and reproducible. If you need an LLM to infer something about a node (tags, summaries, suspicious imports), write an `Action` instead and let the user dispatch it via `sm job submit action:<id>`. The Action's report flows back through the job lifecycle, not through the Extractor pipeline.
204
+ > **Why no `mode` field?** Extractors are deterministic-only, they sit on `sm scan`'s synchronous loop, and the loop must stay fast and reproducible. If you need an LLM to infer something about a node (tags, summaries, suspicious imports), write an `Action` instead and let the user dispatch it via `sm job submit action:<id>`. The Action's report flows back through the job lifecycle, not through the Extractor pipeline.
205
205
 
206
- **Unknown kinds are non-blocking.** An extractor that lists a kind no installed Provider declares (typo, missing Provider plugin) still loads with status `loaded`; `sm plugins doctor` surfaces an informational warning so the author sees the mismatch. The exit code of `doctor` is NOT promoted to 1 by this warning the corresponding Provider may legitimately arrive later (e.g. when the user installs the matching plugin), and the load contract favours forward compatibility over rigid checks. The full set of "known kinds" is the union of every installed Provider's `defaultRefreshAction` keys.
206
+ **Unknown kinds are non-blocking.** An extractor that lists a kind no installed Provider declares (typo, missing Provider plugin) still loads with status `loaded`; `sm plugins doctor` surfaces an informational warning so the author sees the mismatch. The exit code of `doctor` is NOT promoted to 1 by this warning, the corresponding Provider may legitimately arrive later (e.g. when the user installs the matching plugin), and the load contract favours forward compatibility over rigid checks. The full set of "known kinds" is the union of every installed Provider's `defaultRefreshAction` keys.
207
+
208
+ ### Module top-level side effects survive load timeouts
209
+
210
+ The plugin loader wraps every `import()` in an `AbortController`-backed timeout (5s in the reference impl). When the timeout fires, the loader marks the plugin `load-error` and proceeds; the kernel itself is never stuck on a slow or hostile extension.
211
+
212
+ **However, Node has no way to cancel an in-flight `import()`**: once the runtime decides to evaluate the module, every line at the file's top level WILL eventually run, even after the loader has given up on the result. That includes:
213
+
214
+ - A `setInterval(...)` (or `setTimeout(...)`) declared at module top level. The handle has no home in `IExtension` after the timeout, but the timer still ticks until the process exits.
215
+ - A `fetch(...)` / network call started at top level. The promise resolves into nothing observable, but the request still hits the wire.
216
+ - A filesystem write at top level. The write completes regardless.
217
+
218
+ The plugin contract is therefore: **do NOT do work at module top level**. Place every side effect inside an extension's lifecycle method (`Extractor.extract`, `Hook.on`, `Action.invoke`, etc.) so it runs under the loop the kernel actually drives, and only when the load succeeded.
219
+
220
+ This is doubly important for any code that touches secrets, opens long-running resources, or runs unbounded work: a typo in `plugin.json#/specCompat` that fails the compat check will still let the top-level code execute (the loader imports the module before checking the manifest's compat fields), so "the load failed" is not a defence.
221
+
222
+ If you genuinely need module-level state (e.g. caching a compiled regex), guard it behind `lazy` initialisation inside the lifecycle method, the first call computes and memoises, the import alone does nothing observable.
207
223
 
208
224
  ---
209
225
 
@@ -223,7 +239,7 @@ Optional fields:
223
239
  | Field | Type | Notes |
224
240
  |---|---|---|
225
241
  | `description` | string | One-line summary shown in `sm plugins list`. |
226
- | `granularity` | `'bundle' \| 'extension'` | Controls how `sm plugins enable / disable` operates on this plugin. Default `'bundle'`. See [Granularity bundle vs extension](#granularity--bundle-vs-extension). |
242
+ | `granularity` | `'bundle' \| 'extension'` | Controls how `sm plugins enable / disable` operates on this plugin. Default `'bundle'`. See [Granularity, bundle vs extension](#granularity--bundle-vs-extension). |
227
243
  | `storage` | object | `{ "mode": "kv" }` or `{ "mode": "dedicated", "tables": [...], "migrations": [...] }`. Absent means the plugin does not persist state. |
228
244
  | `author` | string | Free-form. |
229
245
  | `license` | string | SPDX identifier. |
@@ -232,13 +248,13 @@ Optional fields:
232
248
 
233
249
  ### `specCompat` strategy
234
250
 
235
- Pre-`v1.0.0` of the spec, narrow ranges are the defensive default minor bumps **MAY** carry breaking changes per [`versioning.md`](./versioning.md). A plugin that spans minor boundaries can load successfully and crash at first use against a changed schema.
251
+ Pre-`v1.0.0` of the spec, narrow ranges are the defensive default, minor bumps **MAY** carry breaking changes per [`versioning.md`](./versioning.md). A plugin that spans minor boundaries can load successfully and crash at first use against a changed schema.
236
252
 
237
253
  After the spec hits v1.0.0, the recommended ranges are:
238
254
 
239
- - `"^1.0.0"` most plugins. Loads against any v1.x.
240
- - `">=1.0.0 <2.0.0"` equivalent, more explicit.
241
- - A pre-release pin (`"^1.0.0-beta.5"`) only when you depend on a feature added between minors.
255
+ - `"^1.0.0"`, most plugins. Loads against any v1.x.
256
+ - `">=1.0.0 <2.0.0"`, equivalent, more explicit.
257
+ - A pre-release pin (`"^1.0.0-beta.5"`), only when you depend on a feature added between minors.
242
258
 
243
259
  Authors who explicitly review each minor's changelog **MAY** widen across the next major (`"^1.0.0 || ^2.0.0"`) at their own risk.
244
260
 
@@ -261,17 +277,17 @@ The runtime instance you `export default` from an extension file MUST include bo
261
277
 
262
278
  ### Extractors
263
279
 
264
- Pure single-node analysis. **Never** read another node, the graph, or the database cross-node reasoning is for analyzers. Spec at [`schemas/extensions/extractor.schema.json`](./schemas/extensions/extractor.schema.json).
280
+ Pure single-node analysis. **Never** read another node, the graph, or the database, cross-node reasoning is for analyzers. Spec at [`schemas/extensions/extractor.schema.json`](./schemas/extensions/extractor.schema.json).
265
281
 
266
282
  The runtime method is `extract(ctx) → void`. Output flows through three callbacks the kernel binds onto the context:
267
283
 
268
- - **`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.
269
- - **`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 derived from the body). Enrichment rows are overwritten via PRIMARY KEY conflict on the next re-extract through the A.9 cache and are never stale-flagged (Extractors are deterministic; re-running is free).
270
- - **`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).
284
+ - **`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.
285
+ - **`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 derived from the body). Enrichment rows are overwritten via PRIMARY KEY conflict on the next re-extract through the A.9 cache and are never stale-flagged (Extractors are deterministic; re-running is free).
286
+ - **`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).
271
287
 
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).
288
+ 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
289
 
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.
290
+ 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
291
 
276
292
  > **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.
277
293
 
@@ -309,7 +325,7 @@ export default {
309
325
 
310
326
  Cross-node reasoning over the merged graph. Run after every Provider and extractor has completed. Spec at [`schemas/extensions/analyzer.schema.json`](./schemas/extensions/analyzer.schema.json).
311
327
 
312
- Analyzers are dual-mode (`deterministic` default; `probabilistic` opt-in via the manifest). Deterministic analyzers run synchronously inside `sm scan` / `sm check` same CI-safe baseline as today. Probabilistic analyzers are dispatched as queued jobs via the kernel's `RunnerPort`; they NEVER participate in the deterministic scan-time pipeline. Until the job subsystem ships at Step 10 the dispatch is stubbed: `sm scan` always skips probabilistic analyzers silently, and `sm check` exposes them via the opt-in `--include-prob` flag the verb loads the plugin runtime, finds the registered prob analyzers (filtered by `--analyzers` and `-n` if set), and emits a stderr advisory naming them. The flag default is unchanged: deterministic-only, CI-safe. The `--async` companion is reserved for the future encoding (returns job ids without waiting once jobs land); today it is a no-op the advisory simply mentions. The flag does NOT extend to `sm scan` or `sm list`.
328
+ Analyzers are dual-mode (`deterministic` default; `probabilistic` opt-in via the manifest). Deterministic analyzers run synchronously inside `sm scan` / `sm check`, same CI-safe baseline as today. Probabilistic analyzers are dispatched as queued jobs via the kernel's `RunnerPort`; they NEVER participate in the deterministic scan-time pipeline. Until the job subsystem ships at Step 10 the dispatch is stubbed: `sm scan` always skips probabilistic analyzers silently, and `sm check` exposes them via the opt-in `--include-prob` flag, the verb loads the plugin runtime, finds the registered prob analyzers (filtered by `--analyzers` and `-n` if set), and emits a stderr advisory naming them. The flag default is unchanged: deterministic-only, CI-safe. The `--async` companion is reserved for the future encoding (returns job ids without waiting once jobs land); today it is a no-op the advisory simply mentions. The flag does NOT extend to `sm scan` or `sm list`.
313
329
 
314
330
  ```javascript
315
331
  export default {
@@ -334,6 +350,15 @@ export default {
334
350
  };
335
351
  ```
336
352
 
353
+ > **`recommendedActions`, analyzer-side hint, not a precondition.** An Analyzer MAY declare `recommendedActions: string[]` with the qualified ids (`<pluginId>/<id>`) of the per-node Actions that resolve its findings. The built-in `core/annotation-stale` analyzer declares `['core/bump']` because bumping the node refreshes the `for.*` hashes that drove the warning. The UI surfaces matching Actions in the node inspector under "Recommended for issues" alongside the always-applicable list driven by `Action.precondition`.
354
+ >
355
+ > The two surfaces are distinct:
356
+ >
357
+ > - **`Action.precondition`**, declared on the Action side, answers "which nodes does this Action apply to?". Always evaluated against the node the inspector is focused on.
358
+ > - **`Analyzer.recommendedActions`**, declared on the Analyzer side, answers "which Actions are the natural fix when THIS analyzer fires?". Surfaces only when the analyzer emitted an issue against the focused node.
359
+ >
360
+ > Each entry MUST be the qualified id of a registered Action. The kernel logs `recommended-action-missing` (an `extension.error` event) when a referenced action is not loaded, and the analyzer stays registered, only the recommendation hint is dropped. Project-level cleanup verbs (orphan file prune, contribution relink) are CLI commands, not Actions, and are NOT linked through this field. Omit `recommendedActions` when the issue is a deliberate user declaration with no "fix" (e.g. `core/superseded` surfaces user-authored supersession statements).
361
+
337
362
  ### Formatters
338
363
 
339
364
  Graph-to-string serializers. Invoked by `sm graph --format <name>`. Output **MUST** be byte-deterministic for the same input graph (the snapshot-test suite relies on this). Spec at [`schemas/extensions/formatter.schema.json`](./schemas/extensions/formatter.schema.json).
@@ -361,18 +386,18 @@ export default {
361
386
 
362
387
  Declarative subscribers to a curated set of kernel lifecycle events. Use case: notification (Slack on `job.completed`), integration glue (CI webhook on `job.failed`), and bookkeeping (per-extractor metrics). Spec at [`schemas/extensions/hook.schema.json`](./schemas/extensions/hook.schema.json) and the trigger semantics at [`architecture.md` §Hook · curated trigger set](./architecture.md#hook--curated-trigger-set).
363
388
 
364
- The runtime method is `on(ctx) → void`. The hook reacts to events; it cannot mutate the pipeline or alter outputs. Errors are caught by the kernel's dispatcher (logged as `extension.error` with `kind: 'hook-error'`) and NEVER block the main flow a buggy hook degrades gracefully.
389
+ The runtime method is `on(ctx) → void`. The hook reacts to events; it cannot mutate the pipeline or alter outputs. Errors are caught by the kernel's dispatcher (logged as `extension.error` with `kind: 'hook-error'`) and NEVER block the main flow, a buggy hook degrades gracefully.
365
390
 
366
391
  The eight hookable triggers (declaring any other event yields `invalid-manifest` at load time):
367
392
 
368
- 1. `scan.started` pre-scan setup (one per scan).
369
- 2. `scan.completed` post-scan reaction (one per scan).
370
- 3. `extractor.completed` aggregated per-Extractor outputs.
371
- 4. `analyzer.completed` aggregated per-Analyzer outputs.
372
- 5. `action.completed` Action executed on a node.
373
- 6. `job.spawning` pre-spawn of runner subprocess (Step 10).
374
- 7. `job.completed` most common trigger (Step 10).
375
- 8. `job.failed` alerts, retry triggers (Step 10).
393
+ 1. `scan.started`, pre-scan setup (one per scan).
394
+ 2. `scan.completed`, post-scan reaction (one per scan).
395
+ 3. `extractor.completed`, aggregated per-Extractor outputs.
396
+ 4. `analyzer.completed`, aggregated per-Analyzer outputs.
397
+ 5. `action.completed`, Action executed on a node.
398
+ 6. `job.spawning`, pre-spawn of runner subprocess (Step 10).
399
+ 7. `job.completed`, most common trigger (Step 10).
400
+ 8. `job.failed`, alerts, retry triggers (Step 10).
376
401
 
377
402
  ```javascript
378
403
  export default {
@@ -383,7 +408,7 @@ export default {
383
408
  triggers: ['scan.completed'],
384
409
  // Optional: only fire when the scan actually surfaced issues.
385
410
  // Filter keys are top-level event.data fields; values are literal matches.
386
- // filter: { issuesCount: 0 } example only; this hook fires on every scan.
411
+ // filter: { issuesCount: 0 }, example only; this hook fires on every scan.
387
412
  async on(ctx) {
388
413
  const stats = ctx.event.data?.stats;
389
414
  if (!stats || stats.issuesCount === 0) return;
@@ -398,7 +423,7 @@ export default {
398
423
  };
399
424
  ```
400
425
 
401
- > **Filter narrows fan-out, not the trigger enum.** `filter` is a runtime predicate over the event payload it does NOT extend the hookable trigger set. Declaring `triggers: ['scan.progress']` is rejected at load time regardless of any filter, because `scan.progress` is intentionally non-hookable (per-node fan-out is too verbose for a reactive surface).
426
+ > **Filter narrows fan-out, not the trigger enum.** `filter` is a runtime predicate over the event payload, it does NOT extend the hookable trigger set. Declaring `triggers: ['scan.progress']` is rejected at load time regardless of any filter, because `scan.progress` is intentionally non-hookable (per-node fan-out is too verbose for a reactive surface).
402
427
 
403
428
  > **Mode semantics.** Default `mode: 'deterministic'` runs `on(ctx)` in-process during the dispatch of the matching event, synchronously between the event's emission and the next pipeline step. `mode: 'probabilistic'` enqueues the hook as a job; until the job subsystem ships at Step 10, probabilistic hooks load but skip dispatch with a stderr advisory.
404
429
 
@@ -408,24 +433,23 @@ export default {
408
433
 
409
434
  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.
410
435
 
411
- #### Provider `kinds` catalog and `explorationDir`
436
+ #### Provider, `kinds` catalog
412
437
 
413
- Every Provider declares two required top-level fields beyond the manifest base: `kinds` and `explorationDir`.
438
+ Every Provider declares one required top-level field beyond the manifest base: `kinds`.
414
439
 
415
440
  **`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:
416
441
 
417
- - **`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`.
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`.
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.
442
+ - **`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`.
443
+ - **`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`.
444
+ - **`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.
420
445
 
421
- **`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.
446
+ 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`.
422
447
 
423
448
  ```jsonc
424
449
  {
425
450
  "id": "cursor",
426
451
  "kind": "provider",
427
452
  "version": "1.0.0",
428
- "explorationDir": "~/.cursor",
429
453
  "kinds": {
430
454
  "skill": {
431
455
  "schema": "./schemas/skill.schema.json",
@@ -452,19 +476,19 @@ Every Provider declares two required top-level fields beyond the manifest base:
452
476
 
453
477
  ---
454
478
 
455
- ## Frontmatter validation three-tier model
479
+ ## Frontmatter validation, three-tier model
456
480
 
457
- The kernel validates frontmatter on a graduated dial; tighter is opt-in. The model is normative every conforming implementation MUST honour the three tiers but the policy lives in **analyzers**, not the JSON Schemas. The schemas stay shape-only ([`schemas/frontmatter/base.schema.json`](./schemas/frontmatter/base.schema.json) declares `additionalProperties: true` deliberately) so that authors can extend their own nodes without forking the spec. Per-kind frontmatter schemas live with the **Provider** that emits the kind (declared via `provider.kinds[<kind>].schema`); spec only ships the universal `base`.
481
+ The kernel validates frontmatter on a graduated dial; tighter is opt-in. The model is normative, every conforming implementation MUST honour the three tiers, but the policy lives in **analyzers**, not the JSON Schemas. The schemas stay shape-only ([`schemas/frontmatter/base.schema.json`](./schemas/frontmatter/base.schema.json) declares `additionalProperties: true` deliberately) so that authors can extend their own nodes without forking the spec. Per-kind frontmatter schemas live with the **Provider** that emits the kind (declared via `provider.kinds[<kind>].schema`); spec only ships the universal `base`.
458
482
 
459
483
  | Tier | Mechanism | Behavior on unknown / non-conforming fields |
460
484
  |---|---|---|
461
- | **0 Default permissive** | `additionalProperties: true` on `base.schema.json` and on every per-kind frontmatter schema declared by an installed Provider. | Field passes silently, persists in `node.frontmatter`, and is available to every extension (extractors, analyzers, actions, formatters). |
462
- | **1 Built-in `unknown-field` analyzer** | Deterministic Analyzer shipped with the kernel. Always active. | Emits an Issue with `severity: 'warn'` for every key outside the documented catalog (base + the matched kind's schema). |
463
- | **2 Strict mode** | [`schemas/project-config.schema.json`](./schemas/project-config.schema.json) `scan.strict: true` (team default in `settings.json`); also via `--strict` on `sm scan`. | Promotes **all** frontmatter warnings to `severity: 'error'`. They persist in the DB; `sm check` then exits `1` on the next read. CI fails. |
485
+ | **0, Default permissive** | `additionalProperties: true` on `base.schema.json` and on every per-kind frontmatter schema declared by an installed Provider. | Field passes silently, persists in `node.frontmatter`, and is available to every extension (extractors, analyzers, actions, formatters). |
486
+ | **1, Built-in `unknown-field` analyzer** | Deterministic Analyzer shipped with the kernel. Always active. | Emits an Issue with `severity: 'warn'` for every key outside the documented catalog (base + the matched kind's schema). |
487
+ | **2, Strict mode** | [`schemas/project-config.schema.json`](./schemas/project-config.schema.json) `scan.strict: true` (team default in `settings.json`); also via `--strict` on `sm scan`. | Promotes **all** frontmatter warnings to `severity: 'error'`. They persist in the DB; `sm check` then exits `1` on the next read. CI fails. |
464
488
 
465
- > Tier 1 is normative behavior the kernel ships the analyzer out-of-the-box. Disabling it is not a supported configuration; an unknown key that you want to keep is either (a) moved under `metadata.*` (the spec permits free-form keys there), or (b) carried as-is at the cost of a persistent `warn`-severity issue (informational unless you run Tier 2).
489
+ > Tier 1 is normative behavior, the kernel ships the analyzer out-of-the-box. Disabling it is not a supported configuration; an unknown key that you want to keep is either (a) moved under `metadata.*` (the spec permits free-form keys there), or (b) carried as-is at the cost of a persistent `warn`-severity issue (informational unless you run Tier 2).
466
490
 
467
- ### Worked example same node, three tiers
491
+ ### Worked example, same node, three tiers
468
492
 
469
493
  Starting frontmatter on a skill node:
470
494
 
@@ -478,7 +502,7 @@ priority: high # ← author-defined, not in any schema
478
502
  ---
479
503
  ```
480
504
 
481
- **Tier 0 (default permissive no project config, default scan).** The field validates fine. `node.frontmatter.priority === 'high'` for any extractor / analyzer / action that reads the node. No issues raised by the schema itself.
505
+ **Tier 0 (default permissive, no project config, default scan).** The field validates fine. `node.frontmatter.priority === 'high'` for any extractor / analyzer / action that reads the node. No issues raised by the schema itself.
482
506
 
483
507
  **Tier 1 (always-active `unknown-field` analyzer).** After `sm scan`, the analyzer emits:
484
508
 
@@ -491,7 +515,7 @@ priority: high # ← author-defined, not in any schema
491
515
  }
492
516
  ```
493
517
 
494
- `sm scan` exits `0` (warnings do not fail the verb). The author can either move the key under `metadata.*` where [`schemas/frontmatter/base.schema.json`](./schemas/frontmatter/base.schema.json) already permits free-form keys, so the `unknown-field` analyzer does not match or accept the persistent warning and add a Analyzer that consumes `priority` for whatever cross-node logic motivated the field.
518
+ `sm scan` exits `0` (warnings do not fail the verb). The author can either move the key under `metadata.*`, where [`schemas/frontmatter/base.schema.json`](./schemas/frontmatter/base.schema.json) already permits free-form keys, so the `unknown-field` analyzer does not match, or accept the persistent warning and add a Analyzer that consumes `priority` for whatever cross-node logic motivated the field.
495
519
 
496
520
  **Tier 2 (strict mode).** Either `scan.strict: true` in `.skill-map/settings.json`, or `sm scan --strict` on the CLI. The same `unknown-field` warning is now persisted at `severity: 'error'`. `sm scan --strict` exits `1` when the issue is created; `sm check` (which reads from the DB) also exits `1` thereafter. CI breaks until the field is reconciled.
497
521
 
@@ -513,7 +537,7 @@ A reasonable next thought is: "I want my plugin to widen the frontmatter schema
513
537
  2. Validates them against whatever shape your domain expects (regex, enum, cross-node consistency).
514
538
  3. Emits Issues for violations.
515
539
 
516
- The trade-off is intentional: a "schema-extender" kind would force every consumer (the kernel, the storage layer, every other plugin, the UI) to re-resolve the active schema set per scan. A Analyzer-driven approach keeps the kernel's parser one-pass and the validation surface composable the union of every author's analyzers is the project's policy.
540
+ The trade-off is intentional: a "schema-extender" kind would force every consumer (the kernel, the storage layer, every other plugin, the UI) to re-resolve the active schema set per scan. A Analyzer-driven approach keeps the kernel's parser one-pass and the validation surface composable, the union of every author's analyzers is the project's policy.
517
541
 
518
542
  If the analyzer needs to be CI-blocking, the analyzer itself emits the Issue at `severity: 'error'`. `--strict` / `scan.strict` apply only to the kernel's own frontmatter-shape and `unknown-field` warnings; plugin-authored analyzers pick their own severity directly.
519
543
 
@@ -523,7 +547,7 @@ If the analyzer needs to be CI-blocking, the analyzer itself emits the Issue at
523
547
 
524
548
  A plugin that needs to persist state declares `storage` in its manifest. Two modes; each is documented in full at [`plugin-kv-api.md`](./plugin-kv-api.md).
525
549
 
526
- ### Mode A KV
550
+ ### Mode A, KV
527
551
 
528
552
  ```jsonc
529
553
  { "storage": { "mode": "kv" } }
@@ -533,7 +557,7 @@ Backed by the kernel-owned `state_plugin_kvs` table. The plugin gets `ctx.store`
533
557
 
534
558
  Pick KV when your state is a small map (less than ~1 MB total, simple key lookup or prefix list). 90 % of plugins fit.
535
559
 
536
- ### Mode B Dedicated
560
+ ### Mode B, Dedicated
537
561
 
538
562
  ```jsonc
539
563
  {
@@ -559,13 +583,13 @@ Every DDL or DML object a plugin migration creates / alters / drops MUST live in
559
583
 
560
584
  Forbidden in plugin migrations: `BEGIN` / `COMMIT` / `ROLLBACK` / `SAVEPOINT` / `PRAGMA` / `ATTACH` / `DETACH` / `VACUUM` / `REINDEX` / `ANALYZE`. The runner wraps each migration in its own transaction. Schema qualifiers other than `main.` are also rejected.
561
585
 
562
- ### `outputSchema` opt-in correctness for custom storage writes
586
+ ### `outputSchema`, opt-in correctness for custom storage writes
563
587
 
564
- `emitLink` and `enrichNode` are universally validated by the kernel every link goes through `link.schema.json` and every enrichment partial through `node.schema.json` before it persists. `ctx.store` writes are different: by default the kernel accepts any shape, because the plugin author owns the table layout and the kernel doesn't know the row shape ahead of time.
588
+ `emitLink` and `enrichNode` are universally validated by the kernel, every link goes through `link.schema.json` and every enrichment partial through `node.schema.json` before it persists. `ctx.store` writes are different: by default the kernel accepts any shape, because the plugin author owns the table layout and the kernel doesn't know the row shape ahead of time.
565
589
 
566
590
  Plugin authors who want correctness for their own writes opt in by declaring JSON Schemas in the manifest. The kernel then AJV-validates each `set` / `write` call before persisting.
567
591
 
568
- **Mode A (`kv`) single value-shape schema.**
592
+ **Mode A (`kv`), single value-shape schema.**
569
593
 
570
594
  ```jsonc
571
595
  {
@@ -576,9 +600,9 @@ Plugin authors who want correctness for their own writes opt in by declaring JSO
576
600
  }
577
601
  ```
578
602
 
579
- The kernel validates the value passed to `ctx.store.set(key, value)` against `kv-value.schema.json` on every call. The schema is single-shape every key in the namespace stores a value of the same shape. Plugins that need heterogeneous values per key MUST switch to Mode B (or skip validation).
603
+ The kernel validates the value passed to `ctx.store.set(key, value)` against `kv-value.schema.json` on every call. The schema is single-shape, every key in the namespace stores a value of the same shape. Plugins that need heterogeneous values per key MUST switch to Mode B (or skip validation).
580
604
 
581
- **Mode B (`dedicated`) per-table schemas.**
605
+ **Mode B (`dedicated`), per-table schemas.**
582
606
 
583
607
  ```jsonc
584
608
  {
@@ -593,7 +617,7 @@ The kernel validates the value passed to `ctx.store.set(key, value)` against `kv
593
617
  }
594
618
  ```
595
619
 
596
- The kernel validates the row passed to `ctx.store.write(table, row)` against the schema declared for that table. Tables present in `tables` but absent from `schemas` (here, `history`) accept any shape the map is sparse on purpose, so authors can validate the columns they care about without writing schemas for cache / log tables.
620
+ The kernel validates the row passed to `ctx.store.write(table, row)` against the schema declared for that table. Tables present in `tables` but absent from `schemas` (here, `history`) accept any shape, the map is sparse on purpose, so authors can validate the columns they care about without writing schemas for cache / log tables.
597
621
 
598
622
  **Failure modes.**
599
623
 
@@ -602,21 +626,21 @@ The kernel validates the row passed to `ctx.store.write(table, row)` against the
602
626
 
603
627
  **When to use.** Opt in for tables / KV namespaces whose shape is part of the plugin's contract with downstream consumers (e.g. another extension that joins on the row, the UI inspector that renders the value). Skip for tables with free-form payloads (cache rows, observability counters) where validation is friction with no payoff.
604
628
 
605
- `emitLink` and `enrichNode` keep their universal validation regardless of the `outputSchema` opt-in those go through the kernel's own `link.schema.json` / `node.schema.json` validators, not the per-plugin map.
629
+ `emitLink` and `enrichNode` keep their universal validation regardless of the `outputSchema` opt-in, those go through the kernel's own `link.schema.json` / `node.schema.json` validators, not the per-plugin map.
606
630
 
607
631
  ---
608
632
 
609
633
  ## Execution modes
610
634
 
611
- Analyzer / Action / Hook declare `mode` in the manifest. Action's `mode` is required; Analyzer and Hook default to `deterministic`. Provider / Extractor / Formatter must NOT declare `mode` they are deterministic-only by spec.
635
+ Analyzer / Action / Hook declare `mode` in the manifest. Action's `mode` is required; Analyzer and Hook default to `deterministic`. Provider / Extractor / Formatter must NOT declare `mode`, they are deterministic-only by spec.
612
636
 
613
637
  ```jsonc
614
- // extractor deterministic by spec, no mode field
638
+ // extractor, deterministic by spec, no mode field
615
639
  { "kind": "extractor", "id": "my-extractor", ... }
616
640
  ```
617
641
 
618
642
  ```jsonc
619
- // probabilistic action runs only as a queued job, dispatched via `sm job submit action:my-action`
643
+ // probabilistic action, runs only as a queued job, dispatched via `sm job submit action:my-action`
620
644
  { "kind": "action", "id": "my-action", "mode": "probabilistic", ... }
621
645
  ```
622
646
 
@@ -674,7 +698,7 @@ Full surface in `@skill-map/testkit/index.ts`.
674
698
 
675
699
  | Status | Meaning | Common cause |
676
700
  |---|---|---|
677
- | `loaded` | manifest valid, specCompat satisfied, every extension imported and validated. | |
701
+ | `loaded` | manifest valid, specCompat satisfied, every extension imported and validated. |, |
678
702
  | `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. |
679
703
  | `incompatible-spec` | manifest parsed but `semver.satisfies` failed against the installed spec. | Plugin built against an older / newer spec. |
680
704
  | `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. |
@@ -717,7 +741,7 @@ Field-by-field:
717
741
  | `location` | `'namespaced'` \| `'root'` | `'namespaced'` | Where the key lands inside the sidecar (see below). |
718
742
  | `ownership` | `'shared'` \| `'exclusive'` | `'shared'` | Conflict policy. REQUIRED to be `'exclusive'` when `location: 'root'`. |
719
743
 
720
- The `schema` field is **inline** an object literal in the manifest, not a `$ref` to a file. Aligns with how the extractor / analyzer / action schemas already declare other inline shapes; avoids an extra path-resolution step at load time.
744
+ The `schema` field is **inline**, an object literal in the manifest, not a `$ref` to a file. Aligns with how the extractor / analyzer / action schemas already declare other inline shapes; avoids an extra path-resolution step at load time.
721
745
 
722
746
  ### Namespacing default vs root opt-in
723
747
 
@@ -736,12 +760,12 @@ annotations:
736
760
  reviewer:
737
761
  lastReviewedAt: 2026-05-06T10:00:00Z
738
762
 
739
- # Plugin 'auditor' also contributes 'lastReviewedAt' different namespace, no conflict
763
+ # Plugin 'auditor' also contributes 'lastReviewedAt', different namespace, no conflict
740
764
  auditor:
741
765
  lastReviewedAt: 2026-05-05T18:30:00Z
742
766
  ```
743
767
 
744
- Opting into a top-level (root) key requires `location: 'root'` AND `ownership: 'exclusive'`. The pair travels together a top-level reserved key cannot be silently shared between plugins, because `.sm` writes deep-merge per the `SidecarStore` contract and a shared root key would route non-deterministically. Use root sparingly: for every plugin that contributes a root key, the kernel reserves that name across the whole installed-plugin surface.
768
+ Opting into a top-level (root) key requires `location: 'root'` AND `ownership: 'exclusive'`. The pair travels together, a top-level reserved key cannot be silently shared between plugins, because `.sm` writes deep-merge per the `SidecarStore` contract and a shared root key would route non-deterministically. Use root sparingly: for every plugin that contributes a root key, the kernel reserves that name across the whole installed-plugin surface.
745
769
 
746
770
  ```js
747
771
  // compliance-plugin/extensions/analyzer.js
@@ -778,12 +802,12 @@ compliance:
778
802
 
779
803
  ### Ownership analyzers
780
804
 
781
- - `shared` (default) multiple plugins MAY write the same key. Every plugin gets its own namespaced block; `last-write-wins` is per-`(plugin, key)` tuple inside `FilesystemSidecarStore.applyPatch`. Two plugins on the SAME namespaced key from the same plugin id is structurally impossible (one extension per kind per plugin id by spec), so the only collision surface is intra-extension.
782
- - `exclusive` only this plugin may write the key. The kernel rejects any other plugin that tries to claim the same `(key, location: 'root')` tuple as `exclusive`. `exclusive` + `namespaced` is permitted but redundant in practice (the namespace already isolates by plugin id); use it as documentation when you want the manifest to scream "no other plugin should ever write this".
805
+ - `shared` (default), multiple plugins MAY write the same key. Every plugin gets its own namespaced block; `last-write-wins` is per-`(plugin, key)` tuple inside `FilesystemSidecarStore.applyPatch`. Two plugins on the SAME namespaced key from the same plugin id is structurally impossible (one extension per kind per plugin id by spec), so the only collision surface is intra-extension.
806
+ - `exclusive`, only this plugin may write the key. The kernel rejects any other plugin that tries to claim the same `(key, location: 'root')` tuple as `exclusive`. `exclusive` + `namespaced` is permitted but redundant in practice (the namespace already isolates by plugin id); use it as documentation when you want the manifest to scream "no other plugin should ever write this".
783
807
 
784
- ### Collision behaviour hard fail, no boot
808
+ ### Collision behaviour, hard fail, no boot
785
809
 
786
- Two plugins claiming the same `(key, location: 'root', ownership: 'exclusive')` tuple is a **fatal startup error**. The kernel does NOT boot in this state `loadPluginRuntime` throws `AnnotationContributionConflictError` and the host (CLI verb, BFF, watch mode) propagates the error and exits non-zero with a clear stderr message naming both offenders. Stricter than the default per-plugin `invalid-manifest` "disable just that plugin" path: annotation-namespace conflicts are non-recoverable because annotated `.sm` files would otherwise become non-deterministically routed.
810
+ Two plugins claiming the same `(key, location: 'root', ownership: 'exclusive')` tuple is a **fatal startup error**. The kernel does NOT boot in this state, `loadPluginRuntime` throws `AnnotationContributionConflictError` and the host (CLI verb, BFF, watch mode) propagates the error and exits non-zero with a clear stderr message naming both offenders. Stricter than the default per-plugin `invalid-manifest` "disable just that plugin" path: annotation-namespace conflicts are non-recoverable because annotated `.sm` files would otherwise become non-deterministically routed.
787
811
 
788
812
  This is the only fatal path on the plugin-load surface. Every other failure mode (manifest invalid, schema invalid, dynamic-import failure, id collision) is per-plugin and the kernel keeps booting on the survivors.
789
813
 
@@ -791,9 +815,9 @@ This is the only fatal path on the plugin-load surface. Every other failure mode
791
815
 
792
816
  The built-in `core/unknown-field` Analyzer walks every parsed `.sm` and emits a `warn` issue per truly-unknown key. Three surfaces are checked:
793
817
 
794
- 1. Inside `annotations:` keys not in `annotations.schema.json`'s curated catalog (the ~25 conventional fields). Plugins do NOT contribute to `annotations:`; that block is skill-map-curated.
795
- 2. At the sidecar root keys outside the four reserved blocks (`for`, `annotations`, `settings`, `audit`) that are also NOT a registered plugin namespace `<plugin-id>:` AND NOT a registered `location: 'root'` contribution.
796
- 3. Inside a registered `<plugin-id>:` namespace values that fail the schema declared by the owning plugin's `annotationContributions[<key>].schema`.
818
+ 1. Inside `annotations:`, keys not in `annotations.schema.json`'s curated catalog (the ~25 conventional fields). Plugins do NOT contribute to `annotations:`; that block is skill-map-curated.
819
+ 2. At the sidecar root, keys outside the four reserved blocks (`for`, `annotations`, `settings`, `audit`) that are also NOT a registered plugin namespace `<plugin-id>:` AND NOT a registered `location: 'root'` contribution.
820
+ 3. Inside a registered `<plugin-id>:` namespace, values that fail the schema declared by the owning plugin's `annotationContributions[<key>].schema`.
797
821
 
798
822
  The analyzer never blocks a scan; advisories surface through the standard issue channel (CLI, UI, REST). When you ship a contribution, the loader compiles your inline schema, the runtime catalog publishes it, and `core/unknown-field` automatically validates user writes against your declaration.
799
823
 
@@ -806,7 +830,7 @@ Once every plugin has loaded, the runtime catalog is reachable via `kernel.getRe
806
830
  const keys = kernel.getRegisteredAnnotationKeys();
807
831
  ```
808
832
 
809
- Pure read; no side effects. Built-in catalog fields from `annotations.schema.json` are NOT included this catalog is plugin-only. The UI knows the built-in catalog separately via the schema bundle. The (future) BFF endpoint surfaces this through `GET /api/annotations/catalog` for autocomplete.
833
+ Pure read; no side effects. Built-in catalog fields from `annotations.schema.json` are NOT included, this catalog is plugin-only. The UI knows the built-in catalog separately via the schema bundle. The (future) BFF endpoint surfaces this through `GET /api/annotations/catalog` for autocomplete.
810
834
 
811
835
  ---
812
836
 
@@ -816,7 +840,7 @@ Pure read; no side effects. Built-in catalog fields from `annotations.schema.jso
816
840
 
817
841
  ### What it solves
818
842
 
819
- Today, the only way a plugin can surface UI is implicit: extractors emit `Link` (rendered by the kernel-built `linked-nodes-panel`), analyzers emit `Issue` (rendered by the kernel-built issues panel), providers ship `kinds[*].ui` styling, and one-off plugins write into the sidecar via `annotationContributions`. The moment your extractor wants to surface anything else a counter on each card, a stat breakdown panel in the inspector, a tree showing parsed structure, a per-node tag there is no path. View contributions fill that gap. You declare what to surface and where; the kernel validates the payload against the slot's shape and the UI renders.
843
+ Today, the only way a plugin can surface UI is implicit: extractors emit `Link` (rendered by the kernel-built `linked-nodes-panel`), analyzers emit `Issue` (rendered by the kernel-built issues panel), providers ship `kinds[*].ui` styling, and one-off plugins write into the sidecar via `annotationContributions`. The moment your extractor wants to surface anything else, a counter on each card, a stat breakdown panel in the inspector, a tree showing parsed structure, a per-node tag, there is no path. View contributions fill that gap. You declare what to surface and where; the kernel validates the payload against the slot's shape and the UI renders.
820
844
 
821
845
  ### What you NEVER write
822
846
 
@@ -870,20 +894,20 @@ Field reference (full schema in [`schemas/view-slots.schema.json`](./schemas/vie
870
894
  Four valid shapes, prefix-discriminated by the UI resolver:
871
895
 
872
896
  ```jsonc
873
- { "icon": "🔍" } // emoji renders as text
874
- { "icon": "pi-search" } // PrimeIcons equivalent to "pi pi-search"
875
- { "icon": "pi pi-search" } // PrimeIcons full class string accepted
876
- { "icon": "fa-solid fa-magnifying-glass" } // FontAwesome explicit family, pass-through
877
- { "icon": "fa-regular fa-star" } // FontAwesome outlined variant
878
- { "icon": "fa-brands fa-github" } // FontAwesome brand glyph
879
- { "icon": "fa-magnifying-glass" } // FontAwesome shorthand defaults to `fa-solid`
897
+ { "icon": "🔍" } // emoji, renders as text
898
+ { "icon": "pi-search" } // PrimeIcons, equivalent to "pi pi-search"
899
+ { "icon": "pi pi-search" } // PrimeIcons, full class string accepted
900
+ { "icon": "fa-solid fa-magnifying-glass" } // FontAwesome, explicit family, pass-through
901
+ { "icon": "fa-regular fa-star" } // FontAwesome, outlined variant
902
+ { "icon": "fa-brands fa-github" } // FontAwesome, brand glyph
903
+ { "icon": "fa-magnifying-glass" } // FontAwesome shorthand, defaults to `fa-solid`
880
904
  ```
881
905
 
882
- 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.
906
+ 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.
883
907
 
884
908
  ### Slot catalog (closed)
885
909
 
886
- 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.
910
+ 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.
887
911
 
888
912
  | Slot | Payload shape | Renderer |
889
913
  |---|---|---|
@@ -918,13 +942,13 @@ ctx.emitContribution('total', { value: total });
918
942
 
919
943
  The first argument is the manifest Record key (`'breakdown'` or `'total'` above), NOT the slot name. The kernel composes the qualified id from your plugin id, extension id, and this Record key, and looks up the slot you declared in the manifest to validate the payload.
920
944
 
921
- 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`.
945
+ 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`.
922
946
 
923
- For `topbar.nav.start`, analyzers use `ctx.emitScopeContribution(id, payload)` (extractors do not see this method scope-level emission lives in analyzer context).
947
+ 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.
924
948
 
925
949
  ### Multi-slot rendering
926
950
 
927
- Want the same data in two surfaces? Declare two contributions, each pointing at a different slot. There is no broadcast the slot you pick is the slot the data renders in.
951
+ Want the same data in two surfaces? Declare two contributions, each pointing at a different slot. There is no broadcast, the slot you pick is the slot the data renders in.
928
952
 
929
953
  ```jsonc
930
954
  "viewContributions": {
@@ -1009,7 +1033,7 @@ Independent of `specCompat` (the spec version range). Mismatch surfaces as `inco
1009
1033
 
1010
1034
  `catalogCompat` is **optional**: omit it if your plugin declares no `viewContributions` and no `settings`. The doctor verb (`sm plugins doctor`) warns if such a plugin actually emits via `viewContributions` or declares `settings`.
1011
1035
 
1012
- ### Worked example `acme/keyword-finder`
1036
+ ### Worked example, `acme/keyword-finder`
1013
1037
 
1014
1038
  Full plugin walkthrough:
1015
1039
 
@@ -1115,9 +1139,9 @@ The scaffolder walks you through the closed catalogs (settings + view contributi
1115
1139
 
1116
1140
  Companion verbs:
1117
1141
 
1118
- - `sm plugins doctor` surfaces `incompatible-catalog`, `invalid-manifest`, deprecated-slot usage.
1119
- - `sm plugins upgrade <id>` applies catalog migrations registered in the kernel.
1120
- - `sm plugins slots list` prints the catalog (slots + input-types), flags deprecated entries.
1142
+ - `sm plugins doctor`, surfaces `incompatible-catalog`, `invalid-manifest`, deprecated-slot usage.
1143
+ - `sm plugins upgrade <id>`, applies catalog migrations registered in the kernel.
1144
+ - `sm plugins slots list`, prints the catalog (slots + input-types), flags deprecated entries.
1121
1145
 
1122
1146
  ### Watch out for
1123
1147
 
@@ -1131,11 +1155,11 @@ Companion verbs:
1131
1155
 
1132
1156
  ## See also
1133
1157
 
1134
- - [`architecture.md`](./architecture.md) extension contract, ports, execution modes.
1135
- - [`plugin-kv-api.md`](./plugin-kv-api.md) Storage Mode A normative API.
1136
- - [`db-schema.md`](./db-schema.md) table catalog and migration analyzers (Mode B).
1137
- - [`schemas/plugins-registry.schema.json`](./schemas/plugins-registry.schema.json) normative manifest shape.
1138
- - [`schemas/extensions/*.schema.json`](./schemas/extensions) per-kind manifest schemas.
1158
+ - [`architecture.md`](./architecture.md), extension contract, ports, execution modes.
1159
+ - [`plugin-kv-api.md`](./plugin-kv-api.md), Storage Mode A normative API.
1160
+ - [`db-schema.md`](./db-schema.md), table catalog and migration analyzers (Mode B).
1161
+ - [`schemas/plugins-registry.schema.json`](./schemas/plugins-registry.schema.json), normative manifest shape.
1162
+ - [`schemas/extensions/*.schema.json`](./schemas/extensions), per-kind manifest schemas.
1139
1163
 
1140
1164
  ---
1141
1165