@skill-map/spec 0.22.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 (41) hide show
  1. package/CHANGELOG.md +81 -0
  2. package/README.md +4 -4
  3. package/architecture.md +130 -119
  4. package/cli-contract.md +102 -96
  5. package/conformance/README.md +13 -13
  6. package/conformance/coverage.md +41 -38
  7. package/db-schema.md +45 -45
  8. package/index.json +40 -37
  9. package/interfaces/security-scanner.md +20 -20
  10. package/job-events.md +21 -21
  11. package/job-lifecycle.md +21 -21
  12. package/package.json +1 -1
  13. package/plugin-author-guide.md +133 -108
  14. package/plugin-kv-api.md +10 -10
  15. package/prompt-preamble.md +8 -8
  16. package/schemas/annotations.schema.json +3 -3
  17. package/schemas/api/rest-envelope.schema.json +10 -10
  18. package/schemas/conformance-result.schema.json +120 -0
  19. package/schemas/execution-record.schema.json +2 -2
  20. package/schemas/extensions/analyzer.schema.json +9 -0
  21. package/schemas/extensions/base.schema.json +4 -4
  22. package/schemas/extensions/extractor.schema.json +4 -4
  23. package/schemas/extensions/formatter.schema.json +1 -1
  24. package/schemas/extensions/hook.schema.json +3 -3
  25. package/schemas/extensions/provider.schema.json +5 -5
  26. package/schemas/frontmatter/base.schema.json +1 -1
  27. package/schemas/history-stats.schema.json +4 -4
  28. package/schemas/input-types.schema.json +3 -3
  29. package/schemas/issue.schema.json +1 -1
  30. package/schemas/job.schema.json +2 -2
  31. package/schemas/node.schema.json +5 -5
  32. package/schemas/plugins-doctor.schema.json +97 -0
  33. package/schemas/plugins-registry.schema.json +2 -2
  34. package/schemas/project-config.schema.json +9 -9
  35. package/schemas/refresh-report.schema.json +52 -0
  36. package/schemas/report-base-deterministic.schema.json +1 -1
  37. package/schemas/sidecar.schema.json +3 -3
  38. package/schemas/summaries/markdown.schema.json +1 -1
  39. package/schemas/summaries/skill.schema.json +1 -1
  40. package/schemas/view-slots.schema.json +7 -7
  41. 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,15 +433,15 @@ 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
436
+ #### Provider, `kinds` catalog
412
437
 
413
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
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
 
@@ -451,19 +476,19 @@ The Provider's walker hardcodes the paths it scans within the project (e.g. `.cl
451
476
 
452
477
  ---
453
478
 
454
- ## Frontmatter validation three-tier model
479
+ ## Frontmatter validation, three-tier model
455
480
 
456
- 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`.
457
482
 
458
483
  | Tier | Mechanism | Behavior on unknown / non-conforming fields |
459
484
  |---|---|---|
460
- | **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). |
461
- | **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). |
462
- | **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. |
463
488
 
464
- > 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).
465
490
 
466
- ### Worked example same node, three tiers
491
+ ### Worked example, same node, three tiers
467
492
 
468
493
  Starting frontmatter on a skill node:
469
494
 
@@ -477,7 +502,7 @@ priority: high # ← author-defined, not in any schema
477
502
  ---
478
503
  ```
479
504
 
480
- **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.
481
506
 
482
507
  **Tier 1 (always-active `unknown-field` analyzer).** After `sm scan`, the analyzer emits:
483
508
 
@@ -490,7 +515,7 @@ priority: high # ← author-defined, not in any schema
490
515
  }
491
516
  ```
492
517
 
493
- `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.
494
519
 
495
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.
496
521
 
@@ -512,7 +537,7 @@ A reasonable next thought is: "I want my plugin to widen the frontmatter schema
512
537
  2. Validates them against whatever shape your domain expects (regex, enum, cross-node consistency).
513
538
  3. Emits Issues for violations.
514
539
 
515
- 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.
516
541
 
517
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.
518
543
 
@@ -522,7 +547,7 @@ If the analyzer needs to be CI-blocking, the analyzer itself emits the Issue at
522
547
 
523
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).
524
549
 
525
- ### Mode A KV
550
+ ### Mode A, KV
526
551
 
527
552
  ```jsonc
528
553
  { "storage": { "mode": "kv" } }
@@ -532,7 +557,7 @@ Backed by the kernel-owned `state_plugin_kvs` table. The plugin gets `ctx.store`
532
557
 
533
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.
534
559
 
535
- ### Mode B Dedicated
560
+ ### Mode B, Dedicated
536
561
 
537
562
  ```jsonc
538
563
  {
@@ -558,13 +583,13 @@ Every DDL or DML object a plugin migration creates / alters / drops MUST live in
558
583
 
559
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.
560
585
 
561
- ### `outputSchema` opt-in correctness for custom storage writes
586
+ ### `outputSchema`, opt-in correctness for custom storage writes
562
587
 
563
- `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.
564
589
 
565
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.
566
591
 
567
- **Mode A (`kv`) single value-shape schema.**
592
+ **Mode A (`kv`), single value-shape schema.**
568
593
 
569
594
  ```jsonc
570
595
  {
@@ -575,9 +600,9 @@ Plugin authors who want correctness for their own writes opt in by declaring JSO
575
600
  }
576
601
  ```
577
602
 
578
- 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).
579
604
 
580
- **Mode B (`dedicated`) per-table schemas.**
605
+ **Mode B (`dedicated`), per-table schemas.**
581
606
 
582
607
  ```jsonc
583
608
  {
@@ -592,7 +617,7 @@ The kernel validates the value passed to `ctx.store.set(key, value)` against `kv
592
617
  }
593
618
  ```
594
619
 
595
- 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.
596
621
 
597
622
  **Failure modes.**
598
623
 
@@ -601,21 +626,21 @@ The kernel validates the row passed to `ctx.store.write(table, row)` against the
601
626
 
602
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.
603
628
 
604
- `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.
605
630
 
606
631
  ---
607
632
 
608
633
  ## Execution modes
609
634
 
610
- 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.
611
636
 
612
637
  ```jsonc
613
- // extractor deterministic by spec, no mode field
638
+ // extractor, deterministic by spec, no mode field
614
639
  { "kind": "extractor", "id": "my-extractor", ... }
615
640
  ```
616
641
 
617
642
  ```jsonc
618
- // 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`
619
644
  { "kind": "action", "id": "my-action", "mode": "probabilistic", ... }
620
645
  ```
621
646
 
@@ -673,7 +698,7 @@ Full surface in `@skill-map/testkit/index.ts`.
673
698
 
674
699
  | Status | Meaning | Common cause |
675
700
  |---|---|---|
676
- | `loaded` | manifest valid, specCompat satisfied, every extension imported and validated. | |
701
+ | `loaded` | manifest valid, specCompat satisfied, every extension imported and validated. |, |
677
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. |
678
703
  | `incompatible-spec` | manifest parsed but `semver.satisfies` failed against the installed spec. | Plugin built against an older / newer spec. |
679
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. |
@@ -716,7 +741,7 @@ Field-by-field:
716
741
  | `location` | `'namespaced'` \| `'root'` | `'namespaced'` | Where the key lands inside the sidecar (see below). |
717
742
  | `ownership` | `'shared'` \| `'exclusive'` | `'shared'` | Conflict policy. REQUIRED to be `'exclusive'` when `location: 'root'`. |
718
743
 
719
- 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.
720
745
 
721
746
  ### Namespacing default vs root opt-in
722
747
 
@@ -735,12 +760,12 @@ annotations:
735
760
  reviewer:
736
761
  lastReviewedAt: 2026-05-06T10:00:00Z
737
762
 
738
- # Plugin 'auditor' also contributes 'lastReviewedAt' different namespace, no conflict
763
+ # Plugin 'auditor' also contributes 'lastReviewedAt', different namespace, no conflict
739
764
  auditor:
740
765
  lastReviewedAt: 2026-05-05T18:30:00Z
741
766
  ```
742
767
 
743
- 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.
744
769
 
745
770
  ```js
746
771
  // compliance-plugin/extensions/analyzer.js
@@ -777,12 +802,12 @@ compliance:
777
802
 
778
803
  ### Ownership analyzers
779
804
 
780
- - `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.
781
- - `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".
782
807
 
783
- ### Collision behaviour hard fail, no boot
808
+ ### Collision behaviour, hard fail, no boot
784
809
 
785
- 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.
786
811
 
787
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.
788
813
 
@@ -790,9 +815,9 @@ This is the only fatal path on the plugin-load surface. Every other failure mode
790
815
 
791
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:
792
817
 
793
- 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.
794
- 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.
795
- 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`.
796
821
 
797
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.
798
823
 
@@ -805,7 +830,7 @@ Once every plugin has loaded, the runtime catalog is reachable via `kernel.getRe
805
830
  const keys = kernel.getRegisteredAnnotationKeys();
806
831
  ```
807
832
 
808
- 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.
809
834
 
810
835
  ---
811
836
 
@@ -815,7 +840,7 @@ Pure read; no side effects. Built-in catalog fields from `annotations.schema.jso
815
840
 
816
841
  ### What it solves
817
842
 
818
- 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.
819
844
 
820
845
  ### What you NEVER write
821
846
 
@@ -869,20 +894,20 @@ Field reference (full schema in [`schemas/view-slots.schema.json`](./schemas/vie
869
894
  Four valid shapes, prefix-discriminated by the UI resolver:
870
895
 
871
896
  ```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`
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`
879
904
  ```
880
905
 
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.
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.
882
907
 
883
908
  ### Slot catalog (closed)
884
909
 
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.
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.
886
911
 
887
912
  | Slot | Payload shape | Renderer |
888
913
  |---|---|---|
@@ -917,13 +942,13 @@ ctx.emitContribution('total', { value: total });
917
942
 
918
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.
919
944
 
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`.
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`.
921
946
 
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.
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.
923
948
 
924
949
  ### Multi-slot rendering
925
950
 
926
- 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.
927
952
 
928
953
  ```jsonc
929
954
  "viewContributions": {
@@ -1008,7 +1033,7 @@ Independent of `specCompat` (the spec version range). Mismatch surfaces as `inco
1008
1033
 
1009
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`.
1010
1035
 
1011
- ### Worked example `acme/keyword-finder`
1036
+ ### Worked example, `acme/keyword-finder`
1012
1037
 
1013
1038
  Full plugin walkthrough:
1014
1039
 
@@ -1114,9 +1139,9 @@ The scaffolder walks you through the closed catalogs (settings + view contributi
1114
1139
 
1115
1140
  Companion verbs:
1116
1141
 
1117
- - `sm plugins doctor` surfaces `incompatible-catalog`, `invalid-manifest`, deprecated-slot usage.
1118
- - `sm plugins upgrade <id>` applies catalog migrations registered in the kernel.
1119
- - `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.
1120
1145
 
1121
1146
  ### Watch out for
1122
1147
 
@@ -1130,11 +1155,11 @@ Companion verbs:
1130
1155
 
1131
1156
  ## See also
1132
1157
 
1133
- - [`architecture.md`](./architecture.md) extension contract, ports, execution modes.
1134
- - [`plugin-kv-api.md`](./plugin-kv-api.md) Storage Mode A normative API.
1135
- - [`db-schema.md`](./db-schema.md) table catalog and migration analyzers (Mode B).
1136
- - [`schemas/plugins-registry.schema.json`](./schemas/plugins-registry.schema.json) normative manifest shape.
1137
- - [`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.
1138
1163
 
1139
1164
  ---
1140
1165