@skill-map/spec 0.39.0 → 0.41.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 (57) hide show
  1. package/CHANGELOG.md +55 -2307
  2. package/README.md +8 -11
  3. package/architecture.md +74 -51
  4. package/cli-contract.md +38 -9
  5. package/conformance/README.md +1 -1
  6. package/conformance/cases/extractor-emits-signal.json +1 -1
  7. package/conformance/cases/kernel-empty-boot.json +1 -1
  8. package/conformance/cases/no-global-scope.json +1 -1
  9. package/conformance/cases/orphan-markdown-fallback.json +1 -1
  10. package/conformance/cases/plugin-missing-ui-rejected.json +1 -1
  11. package/conformance/cases/sidecar-end-to-end.json +1 -1
  12. package/conformance/cases/signal-collision-detection.json +1 -1
  13. package/conformance/coverage.md +1 -1
  14. package/conformance/fixtures/sidecar-example/agent-example.sm +3 -3
  15. package/db-schema.md +21 -7
  16. package/index.json +56 -55
  17. package/package.json +3 -2
  18. package/plugin-author-guide.md +273 -776
  19. package/schemas/annotations.schema.json +2 -2
  20. package/schemas/api/rest-envelope.schema.json +1 -1
  21. package/schemas/bump-report.schema.json +1 -1
  22. package/schemas/conformance-case.schema.json +1 -1
  23. package/schemas/conformance-result.schema.json +1 -1
  24. package/schemas/execution-record.schema.json +1 -1
  25. package/schemas/extensions/action.schema.json +1 -1
  26. package/schemas/extensions/analyzer.schema.json +1 -1
  27. package/schemas/extensions/base.schema.json +1 -1
  28. package/schemas/extensions/extractor.schema.json +1 -1
  29. package/schemas/extensions/formatter.schema.json +1 -1
  30. package/schemas/extensions/hook.schema.json +1 -1
  31. package/schemas/extensions/provider-kind.schema.json +1 -1
  32. package/schemas/extensions/provider.schema.json +1 -1
  33. package/schemas/frontmatter/base.schema.json +2 -7
  34. package/schemas/history-stats.schema.json +1 -1
  35. package/schemas/input-types.schema.json +1 -1
  36. package/schemas/issue.schema.json +1 -1
  37. package/schemas/job.schema.json +1 -1
  38. package/schemas/link.schema.json +1 -1
  39. package/schemas/node.schema.json +1 -1
  40. package/schemas/plugins-doctor.schema.json +1 -1
  41. package/schemas/plugins-registry.schema.json +1 -1
  42. package/schemas/project-config.schema.json +1 -1
  43. package/schemas/refresh-report.schema.json +1 -1
  44. package/schemas/report-base-deterministic.schema.json +1 -1
  45. package/schemas/report-base.schema.json +1 -1
  46. package/schemas/scan-result.schema.json +1 -1
  47. package/schemas/sidecar.schema.json +1 -1
  48. package/schemas/signal.schema.json +1 -1
  49. package/schemas/summaries/agent.schema.json +1 -1
  50. package/schemas/summaries/command.schema.json +1 -1
  51. package/schemas/summaries/hook.schema.json +1 -1
  52. package/schemas/summaries/markdown.schema.json +1 -1
  53. package/schemas/summaries/skill.schema.json +1 -1
  54. package/schemas/user-settings.schema.json +32 -1
  55. package/schemas/view-slots.schema.json +1 -1
  56. package/telemetry.md +294 -0
  57. package/versioning.md +2 -2
@@ -2,9 +2,9 @@
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 unit-test the result against the kernel's public types.
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 JSON Schemas under [`schemas/`](./schemas/) and in [`architecture.md`](./architecture.md); every claim here is cross-linked to its source. When this guide disagrees with a schema, the schema wins; when it disagrees with `architecture.md` on system behaviour, `architecture.md` wins. To keep the guide thin, the deep per-system contracts (extension semantics, the resolver phase, the persistence sweeps, the isolation model) are NOT restated here, follow the links.
6
6
 
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).
7
+ > **Status.** Pre-1.0 (`spec` is in `0.y.z`). The author surface is still settling; breaking changes ship as **minor** bumps per [`versioning.md`](./versioning.md) until the first `1.0.0`. The shape documented here matches the manifest schemas as of the structure-as-truth refactor (the kernel derives `id` / `kind` / the Provider kind catalog from disk, so they are no longer manifest fields).
8
8
 
9
9
  ---
10
10
 
@@ -16,57 +16,54 @@ my-plugin/
16
16
  └── extractors/ ← one folder per extension kind
17
17
  └── my-extractor/
18
18
  ├── index.js ← extension entry (required)
19
- ├── text.ts ← user-facing strings (optional, see below)
19
+ ├── text.ts ← user-facing strings (optional)
20
20
  └── my-extractor.test.ts ← tests live next to the code (optional)
21
21
  ```
22
22
 
23
23
  The kernel auto-discovers extensions by walking
24
24
  `<plugin-dir>/<kind>s/<name>/index.{js,mjs,ts}` for each known kind
25
25
  (`providers`, `extractors`, `analyzers`, `actions`, `formatters`,
26
- `hooks`). The folder layout IS the source of truth: bundle from the
27
- top-level dir, kind from the subfolder name, extension id from the
28
- extension folder name. The manifest no longer declares an
29
- `extensions[]` array.
26
+ `hooks`). **The folder layout IS the source of truth**: the bundle id comes from the
27
+ top-level dir, the kind from the subfolder name, the extension id from the
28
+ extension folder name. The manifest does NOT declare an
29
+ `extensions[]` array, and an extension file does NOT declare its own `id` or `kind`
30
+ (a manifest carrying either is rejected as `invalid-manifest`).
30
31
 
31
32
  **Co-located files convention**: any siblings of `index.{js,mjs,ts}`
32
33
  that the kernel does NOT recognise as an entry point are author
33
- files (texts, tests, schemas, fixtures). Two names are blessed by
34
- convention so consumers know where to look without grepping:
34
+ files. Two names are blessed by convention:
35
35
 
36
36
  - **`text.ts`** holds the extension's externalised user-facing
37
- strings (the `tx()`-fed templates, error messages, glyph labels).
38
- One per extension; imported by `index.ts` as `./text.js`. Keeps
39
- copy out of the code path and makes the surface review-friendly.
37
+ strings. One per extension; imported by `index.ts` as `./text.js`.
40
38
  Plain TS module, no schema, no codegen.
41
39
  - **`<extension-name>.test.ts`** (or `.test.mjs` / `.test.js`) is
42
- the colocated test suite. Picked up by the workspace's test glob
43
- (`plugins/**/*.test.ts`); no separate test directory.
40
+ the colocated test suite, picked up by the workspace's test glob
41
+ (`plugins/**/*.test.ts`).
44
42
 
45
- Both files are optional. The kernel ignores everything that isn't
46
- `index.{js,mjs,ts}`, so future per-extension fixtures, schemas, or
47
- conformance scopes can live in the same folder without manifest
48
- plumbing.
43
+ Both are optional. The kernel ignores everything that isn't
44
+ `index.{js,mjs,ts}`, so future per-extension fixtures or schemas can
45
+ live in the same folder without manifest plumbing.
49
46
 
50
47
  ```jsonc
51
48
  // my-plugin/plugin.json
52
49
  {
53
- "id": "my-plugin",
54
50
  "version": "1.0.0",
55
- "specCompat": "^1.0.0"
51
+ "specCompat": "^0.40.0",
52
+ "catalogCompat": "^1.0.0",
53
+ "description": "Example plugin."
56
54
  }
57
55
  ```
58
56
 
59
57
  ```javascript
60
58
  // my-plugin/extractors/my-extractor/index.js
61
59
  export default {
62
- id: 'my-extractor',
63
- kind: 'extractor',
64
- version: '1.0.0',
65
- emitsLinkKinds: ['references'],
66
- defaultConfidence: 'high',
60
+ // id, kind, version, pluginId are NOT declared here:
61
+ // - id / kind come from the folder path
62
+ // - version / pluginId are injected by the loader
63
+ description: 'Emits a reference per something.md mention.',
67
64
  scope: 'body',
68
65
  extract(ctx) {
69
- // ctx.node, ctx.body, ctx.frontmatter, ctx.emitLink, ctx.enrichNode
66
+ // ctx.node, ctx.body, ctx.frontmatter, ctx.emitLink, ctx.enrichNode, ctx.emitContribution
70
67
  // Output flows through the callbacks; the method returns void.
71
68
  ctx.emitLink({
72
69
  source: ctx.node.path,
@@ -79,38 +76,38 @@ export default {
79
76
  };
80
77
  ```
81
78
 
79
+ > **Note.** External (user-authored) plugins MUST declare `version` per extension; the AJV check rejects manifests missing it. The example omits it only because the loader injects it for the reference impl's built-ins. For your own plugin, add `version: '1.0.0'` to the export.
80
+
82
81
  Drop the directory under `<cwd>/.skill-map/plugins/` and
83
- `sm plugins list` will pick it up. The kernel injects `pluginId`
84
- from `plugin.json#/id` at load time; do NOT hardcode it in the
85
- extension export. A folder/kind mismatch (e.g. an extractor placed
82
+ `sm plugins list` picks it up. A folder/kind mismatch (e.g. an extractor placed
86
83
  under `analyzers/`) surfaces as `invalid-manifest`.
87
84
 
88
85
  ---
89
86
 
90
87
  ## Discovery
91
88
 
92
- The kernel scans one root: `<cwd>/.skill-map/plugins/`, committed-with-the-repo plugins. There is no implicit user-level discovery (see `cli-contract.md` §Scope is always project-local for the broader principle): plugins live with the project that uses them.
89
+ The kernel scans one root: `<cwd>/.skill-map/plugins/`, committed-with-the-repo plugins. There is no implicit user-level discovery (see [`cli-contract.md` §Scope is always project-local](./cli-contract.md)): plugins live with the project that uses them.
93
90
 
94
- A plugin is any direct child directory of that root containing a `plugin.json`. Nested directories are not searched recursively. Pass `--plugin-dir <path>` to replace the default root with a custom directory (mostly for testing, or for loading a user-level plugin set the operator explicitly opts into).
91
+ A plugin is any direct child directory of that root containing a `plugin.json`. Nested directories are not searched recursively. Pass `--plugin-dir <path>` to replace the default root with a custom directory (mostly for testing, or for loading a plugin set the operator explicitly opts into).
95
92
 
96
- After every change to the `plugins/` folder, run `sm plugins list` to see the load status of each. The six statuses are documented under [Diagnostics](#diagnostics) below.
93
+ After every change to the `plugins/` folder, run `sm plugins list` to see each plugin's load status. The seven statuses are documented under [Diagnostics](#diagnostics).
97
94
 
98
95
  ### Plugin id uniqueness
99
96
 
100
- The `id` declared in `plugin.json` is **globally unique** across every active discovery root. The kernel enforces this in two places:
97
+ The plugin `id` is the **directory name** (`<root>/<id>/plugin.json`), not a manifest field, and is **globally unique** across every active discovery root. The kernel enforces this in two places:
101
98
 
102
- 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).
103
- 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.
99
+ 1. **Directory name IS the id.** A manifest carrying an `id` key is rejected as `invalid-manifest`. Same-root collisions are impossible by construction (a filesystem cannot host two siblings with the same name).
100
+ 2. **Cross-root id collisions are blocked, both sides.** If two plugins from different roots (project + `--plugin-dir`) share a directory name, **both** receive status `id-collision`. There is no precedence rule, neither loads its extensions; the user renames one and reruns.
104
101
 
105
102
  `sm plugins list` shows the conflict; `sm plugins doctor` exits `1` whenever any `id-collision` is present.
106
103
 
107
104
  ### Qualified extension ids
108
105
 
109
- 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.
106
+ Every extension is identified in the registry, and in any cross-extension reference, by its **qualified id** `<plugin-id>/<extension-id>`. The plugin id (the directory name) is therefore also the **namespace** for every extension the plugin ships.
110
107
 
111
108
  Concrete examples for the reference impl's bundled extensions:
112
109
 
113
- | Extension | Short id (in the file) | Qualified id (in the registry) |
110
+ | Extension | Short id (folder name) | Qualified id (in the registry) |
114
111
  |---|---|---|
115
112
  | Claude Provider | `claude` | `claude/claude` |
116
113
  | Annotations extractor | `annotations` | `core/annotations` |
@@ -119,121 +116,69 @@ Concrete examples for the reference impl's bundled extensions:
119
116
  | Markdown-link extractor | `markdown-link` | `core/markdown-link` |
120
117
  | External-URL counter | `external-url-counter` | `core/external-url-counter` |
121
118
  | Reference-broken analyzer | `reference-broken` | `core/reference-broken` |
122
- | Trigger-collision analyzer | `trigger-collision` | `core/trigger-collision` |
123
119
  | ASCII formatter | `ascii` | `core/ascii` |
124
- | Schema-violation analyzer | `schema-violation` | `core/schema-violation` |
125
120
 
126
121
  Built-ins split between two namespaces:
127
122
 
128
- - **`core/`**, kernel-internal primitives, platform-agnostic. Owns every built-in analyzer (including `schema-violation`), the ASCII formatter, and the cross-vendor extractors (`annotations`, `markdown-link`, `external-url-counter`) any Provider can rely on.
129
- - **`claude/`**, the Claude Code Provider bundle: the Provider that classifies `.claude/{agents,commands,skills}` paths and parses their frontmatter, plus the Claude-flavoured extractors (`slash-command`, `at-directive`). Other vendor bundles (`antigravity`, `openai`, `agent-skills`) follow the same shape, Provider only, since the syntax their nodes use is shared with Claude.
123
+ - **`core/`**, kernel-internal primitives, platform-agnostic: every built-in analyzer, the ASCII formatter, the cross-vendor extractors (`annotations`, `markdown-link`, `external-url-counter`), the universal `markdown` Provider fallback, and the `update-check` hook.
124
+ - **`claude/`**, the Claude Code Provider bundle: the Provider plus the Claude-flavoured extractors (`slash-command`, `at-directive`). Other vendor bundles (`antigravity`, `openai`, `agent-skills`) follow the same shape (Provider only).
130
125
 
131
126
  ### Extension id shape
132
127
 
133
- The naming convention applied to every built-in extension id is **`<domain>-<detail>`** (general to specific). The leftmost segment names the entity the extension reasons about (`node`, `link`, `annotation`, `reference`, `name`, `field`, `tools`, ...), the rest narrows the specific behaviour or signal it produces. Examples: `annotation-orphan`, `link-counter`, `node-stability`, `name-reserved`, `reference-broken`.
134
-
135
- Authors building their own plugins are not required to follow this pattern, but doing so makes `sm plugins list` self-grouping and the qualified ids predictable. Verb-style ids (e.g. `bump`, `mark-superseded`) are deliberately avoided on built-ins: even Actions live under their entity domain (`node-bump`, `node-supersede`) so the catalog reads as a structured list rather than a mix of nouns and imperatives.
136
-
137
- 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.
138
-
139
- What this means in practice:
140
-
141
- - **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).
142
- - **In the manifest's `extensions[]`**, list relative paths to extension files as before, nothing changes.
143
- - **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>`.
144
- - **`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).
145
- - **`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.
146
-
147
- The kernel guards against two foot-guns:
148
-
149
- - 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.
150
- - 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.
128
+ The convention applied to every built-in extension id is **`<domain>-<detail>`** (general to specific): the leftmost segment names the entity the extension reasons about (`node`, `link`, `annotation`, `reference`, `name`, ...), the rest narrows the behaviour. Examples: `annotation-orphan`, `link-counter`, `node-stability`, `name-reserved`, `reference-broken`. Even Actions live under their entity domain (`node-bump`, `node-supersede`) rather than verb-style ids, so the catalog reads as a structured list.
151
129
 
152
- For built-ins, the reference impl's `src/plugins/<bundle>/plugin.json` provides the bundle's `id` and the codegen at `scripts/generate-built-ins.js` inlines the `pluginId` injection at build time (the resulting `src/plugins/built-ins.ts` is auto-generated and committed). Authors never hardcode `pluginId` on the extension export.
130
+ Authors are not required to follow this, but it makes `sm plugins list` self-grouping. In the extension file, declare only the short id-bearing **folder name**, not a prefixed id; the loader composes `<plugin-id>/<short-id>` from `plugin.json` (the directory name) and the extension folder. Any other cross-extension reference (`precondition.analyzerIds`, ...) uses the qualified id of the target.
153
131
 
154
132
  ### Toggle model
155
133
 
156
- Every extension is independently toggle-able by its qualified id `<bundle>/<ext-id>` (e.g. `claude/at-directive`, `core/node-superseded`, `my-plugin/orphan-skill`). The **bundle is a presentational grouping**, not a toggle target, the user sees a row per bundle in `sm plugins list` and the Settings UI, with the bundle's extensions listed underneath, each with its own enabled / disabled state.
134
+ Every extension is independently toggle-able by its qualified id `<bundle>/<ext-id>` (e.g. `claude/at-directive`, `core/node-superseded`). The **bundle is a presentational grouping**, not a toggle target: the user sees a row per bundle in `sm plugins list` and the Settings UI, with each extension listed underneath with its own enabled / disabled state.
157
135
 
158
136
  Two id shapes resolve at the toggle surface:
159
137
 
160
138
  - **Qualified id** (`<bundle>/<ext-id>`): flips exactly that extension. No prompt.
161
- - **Bare bundle id** (`claude`, `core`, `my-plugin`): the **macro form**. Fans the toggle out across every extension inside the bundle.
162
- - Bundle with exactly one extension (`openai`, `antigravity`, `agent-skills`): applies the toggle directly. No prompt (1-1 mapping).
163
- - Bundle with ≥2 extensions (`claude`, `core`, multi-extension user plugins): requires `--yes` OR an interactive TTY confirm. Pipe / CI contexts must pass `--yes`; without it the verb refuses and prints the list of affected extensions plus the re-run hint.
139
+ - **Bare bundle id** (`claude`, `core`): the **macro form**, fans the toggle across every extension inside the bundle.
140
+ - Single-extension bundle (`openai`, `antigravity`, `agent-skills`): applies directly, no prompt.
141
+ - Multi-extension bundle (`claude`, `core`): requires `--yes` OR an interactive TTY confirm. CI / pipe contexts must pass `--yes`.
164
142
 
165
- `--all` is the cascade variant of the macro: it expands to every extension in every discovered bundle (built-ins + user plugins) and applies the same `--yes` / TTY-confirm gate as a multi-extension bundle id.
166
-
167
- Per-verb behaviour:
168
-
169
- | Command | Result |
170
- |---|---|
171
- | `sm plugins enable claude/at-directive` | OK, flips just that extension. |
172
- | `sm plugins enable openai` | OK, single-child bundle, flips `openai/openai`. No prompt. |
173
- | `sm plugins disable claude` | Multi-child bundle; TTY: prompts `[y/N]`; non-TTY: refuses without `--yes`. |
174
- | `sm plugins disable claude --yes` | OK, flips every extension under `claude`. |
175
- | `sm plugins disable core` | Multi-child bundle; same gate as `claude` above. |
176
- | `sm plugins disable core/node-superseded` | OK, flips just that analyzer. |
177
- | `sm plugins disable --all` | Cascades through every bundle; requires `--yes` in non-TTY. |
178
-
179
- Resolution order per id is the same as for plugin enabled-state: DB override (`config_plugins`) > settings.json (`#/plugins/<id>/enabled`) > installed default (`true`). `settings.json#/plugins` keys are arbitrary strings (no AJV pattern); persisted toggle keys are always qualified `<bundle>/<ext>` ids (the macro path expands at write time so the DB only ever stores per-extension rows).
180
-
181
- Set the manifest fields in your `plugin.json`; the folder layout supplies the extensions and the kernel discovers them automatically. There is no `granularity` field anymore (a manifest that declares it fails AJV with `additionalProperties`):
182
-
183
- ```jsonc
184
- {
185
- "id": "my-multi-tool",
186
- "version": "1.0.0",
187
- "specCompat": "^1.0.0"
188
- }
189
- ```
143
+ `--all` is the cascade variant: it expands to every extension in every discovered bundle and applies the same `--yes` / TTY-confirm gate.
190
144
 
191
- ```text
192
- my-multi-tool/
193
- ├── plugin.json
194
- ├── analyzers/
195
- │ └── orphan-skill/
196
- │ └── index.js
197
- └── formatters/
198
- └── csv/
199
- └── index.js
200
- ```
145
+ Resolution order per id: DB override (`config_plugins`) > `settings.json#/plugins/<id>/enabled` > installed default (`true`). Persisted toggle keys are always qualified `<bundle>/<ext>` ids (the macro path expands at write time).
201
146
 
202
- The default (`'bundle'`) is the right answer for almost every plugin, keep the manifest minimal until the plugin actually ships several independent capabilities.
147
+ There is no `granularity` manifest field; per-extension toggling is the only model.
203
148
 
204
- ### Extractor `precondition`, narrow the pipeline
149
+ ### Extractor / Analyzer / Action `precondition`, narrow the pipeline
205
150
 
206
- An `Extractor` extension MAY declare a `precondition` block on its manifest. When declared, the kernel runs the extractor **only** against nodes that satisfy every declared sub-filter, the filter is fail-fast (no extractor context, no method call) so the extractor wastes zero CPU on nodes it cannot meaningfully process. The same shape is shared by `Analyzer` and `Action`.
151
+ An Extractor, Analyzer, or Action MAY declare an optional `precondition` block. When declared, the kernel runs the extension **only** against nodes that satisfy every declared sub-filter, fail-fast (no context built, no method call) so it wastes zero CPU on nodes it cannot process. The shape is shared across the three kinds:
207
152
 
208
153
  ```ts
209
154
  precondition?: {
210
- kind?: string[]; // qualified `<plugin>/<kindName>` ids
211
- provider?: string[]; // plugin ids
155
+ kind?: string[]; // qualified `<plugin>/<kindName>` ids
156
+ provider?: string[]; // plugin ids
157
+ analyzerIds?: string[]; // Action only: which analyzers' findings this action resolves (Modelo B)
212
158
  };
213
159
  ```
214
160
 
215
161
  | `precondition` | Behaviour |
216
162
  |---|---|
217
- | Absent (`undefined`) | **Default.** The extractor runs on every kind the loaded Providers emit. |
163
+ | Absent (`undefined`) | **Default.** Runs on every kind the loaded Providers emit. |
218
164
  | `{ kind: ['claude/skill'] }` | Runs only on skill nodes from the Claude provider. |
219
165
  | `{ kind: ['claude/skill', 'agent-skills/skill'] }` | Runs on skills from either provider. |
220
166
  | `{ provider: ['claude'] }` | Coarser: runs on every kind the `claude` plugin declares. |
221
167
  | `{ kind: ['claude/skill'], provider: ['claude'] }` | Both filters apply (AND). |
222
168
 
223
- Use `precondition.kind` over `precondition.provider` when the filter is really about the kind, not the provider. There is no wildcard syntax, omitting the field IS the wildcard.
169
+ Prefer `precondition.kind` over `precondition.provider` when the filter is really about the kind. There is no wildcard syntax, omitting the field IS the wildcard.
170
+
171
+ **Unknown qualified kinds are non-blocking.** A `precondition.kind` naming a kind no installed Provider declares (typo, missing Provider plugin) still loads with status `enabled`; `sm plugins doctor` surfaces an informational `precondition-kind-unknown` warning without promoting its exit code, the matching Provider may arrive later.
224
172
 
225
173
  Use case, a deterministic frontmatter-tag extractor that only makes sense for skills:
226
174
 
227
175
  ```javascript
228
176
  export default {
229
- // id, kind, pluginId injected by the loader from the folder path
230
177
  version: '1.0.0',
231
178
  description: 'Lifts the `tags:` frontmatter array into `references` links for skill nodes.',
232
179
  scope: 'frontmatter',
233
180
  precondition: { kind: ['claude/skill'] },
234
- async extract(ctx) {
235
- // Never invoked for agents, commands, hooks, or notes, the kernel
236
- // skipped this node before reaching us.
181
+ extract(ctx) {
237
182
  const tags = Array.isArray(ctx.frontmatter.tags) ? ctx.frontmatter.tags : [];
238
183
  for (const t of tags) {
239
184
  ctx.emitLink({
@@ -248,117 +193,73 @@ export default {
248
193
  };
249
194
  ```
250
195
 
251
- > **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.
252
-
253
- > **Why no `emitsLinkKinds` / `defaultConfidence`?** Both fields were retired with the structure-as-truth refactor. Link kinds are constrained by the global closed enum (`invokes`, `references`, `mentions`, `supersedes`); off-enum emissions drop with `extension.error`. Confidence is declared per-emit on every `ctx.emitLink({ ..., confidence })` call (default `'medium'` if omitted).
254
-
255
- **Unknown qualified kinds are non-blocking.** An extractor that lists a kind no installed Provider declares (typo, missing Provider plugin) still loads with status `enabled`; `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.
196
+ > **Why no `mode` field on Extractors?** Extractors are deterministic-only; they sit on `sm scan`'s synchronous loop, which must stay fast and reproducible. If you need an LLM to infer something about a node, write a probabilistic **Action** and let the user dispatch it as a job. See [`architecture.md` §Execution modes](./architecture.md#execution-modes).
256
197
 
257
198
  ### Module top-level side effects survive load timeouts
258
199
 
259
- 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.
200
+ The plugin loader wraps every `import()` in an `AbortController`-backed timeout (5s in the reference impl). When it fires, the loader marks the plugin `load-error` and proceeds.
260
201
 
261
- **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:
202
+ **Node cannot cancel an in-flight `import()`**: once the runtime evaluates the module, every top-level line WILL run, even after the loader gave up. That includes a top-level `setInterval`, a top-level `fetch`, or a top-level filesystem write.
262
203
 
263
- - 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.
264
- - A `fetch(...)` / network call started at top level. The promise resolves into nothing observable, but the request still hits the wire.
265
- - A filesystem write at top level. The write completes regardless.
266
-
267
- 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.
268
-
269
- 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.
270
-
271
- 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.
204
+ The contract is therefore: **do NOT do work at module top level**. Place every side effect inside an extension's lifecycle method (`extract`, `on`, `run`, ...) so it runs under the loop the kernel actually drives, and only when the load succeeded. A failed compat check does not protect you, the loader imports the module before checking `specCompat`. If you need module-level state (e.g. a compiled regex), memoise it lazily inside the lifecycle method.
272
205
 
273
206
  ---
274
207
 
275
208
  ## Manifest
276
209
 
277
- Required fields (see [`schemas/plugins-registry.schema.json#/$defs/PluginManifest`](./schemas/plugins-registry.schema.json) for the normative shape):
210
+ Required fields (normative shape in [`schemas/plugins-registry.schema.json#/$defs/PluginManifest`](./schemas/plugins-registry.schema.json)):
278
211
 
279
212
  | Field | Type | Notes |
280
213
  |---|---|---|
281
214
  | `version` | semver | Plugin version, independent of `specCompat`. |
282
- | `specCompat` | semver range | Spec versions this plugin is compatible with. Checked via `semver.satisfies(specVersion, this)` at load time. |
283
- | `catalogCompat` | semver range | Semver range against the view-slots + input-types catalog. Independent from `specCompat` because the catalog evolves on its own cadence. Required as of the structure-as-truth refactor (was optional). |
284
- | `description` | string | Required short description shown in `sm plugins list` and the UI. |
215
+ | `specCompat` | semver range | Spec versions this plugin is compatible with. Checked via `semver.satisfies(specVersion, this)` at load. |
216
+ | `catalogCompat` | semver range | **Required.** Range against the view-slots + input-types catalog, which evolves on its own cadence independent of `specCompat`. |
217
+ | `description` | string | Short description shown in `sm plugins list` and the UI. English-only. |
285
218
 
286
- Optional fields:
219
+ Optional fields: `storage` (`{ mode: 'kv' }` or `{ mode: 'dedicated', tables, migrations }`), `author`, `license` (SPDX), `homepage`, `repository`.
287
220
 
288
- | Field | Type | Notes |
289
- |---|---|---|
290
- | `storage` | object | `{ "mode": "kv" }` or `{ "mode": "dedicated", "tables": [...], "migrations": [...] }`. Absent means the plugin does not persist state. |
291
- | `author` | string | Free-form. |
292
- | `license` | string | SPDX identifier. |
293
- | `homepage` | string | URL. |
294
- | `repository` | string | URL. |
295
-
296
- **Structure-as-truth**: the plugin id is the directory name (`<root>/<id>/plugin.json`); it is NOT a manifest field. Manifests carrying an `id` literal are rejected as `invalid-manifest`. Settings moved out of `plugin.json` into each extension's own manifest with the same refactor (see [Extension manifest](#extension-manifest)).
297
-
298
- The manifest does NOT list extensions. The kernel discovers each extension by walking `<plugin-dir>/<kind>s/<name>/index.{js,mjs,ts}`; the path is authoritative for both the kind and the local id. A Provider's kind catalog lives on disk at `<plugin>/kinds/<kindName>/{schema.json, kind.json}` (see [Providers](#providers--actions)).
221
+ **Structure-as-truth.** The plugin id is the directory name, NOT a manifest field; a manifest carrying `id` is rejected. The manifest does NOT list extensions, the kernel discovers each by walking `<plugin-dir>/<kind>s/<name>/index.{js,mjs,ts}`. A Provider's kind catalog lives on disk at `<plugin>/kinds/<kindName>/{schema.json, kind.json}` (see [The six extension kinds → Providers](#providers)).
299
222
 
300
223
  ### `specCompat` strategy
301
224
 
302
- 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.
303
-
304
- After the spec hits v1.0.0, the recommended ranges are:
305
-
306
- - `"^1.0.0"`, most plugins. Loads against any v1.x.
307
- - `">=1.0.0 <2.0.0"`, equivalent, more explicit.
308
- - A pre-release pin (`"^1.0.0-beta.5"`), only when you depend on a feature added between minors.
309
-
310
- Authors who explicitly review each minor's changelog **MAY** widen across the next major (`"^1.0.0 || ^2.0.0"`) at their own risk.
225
+ Pre-`v1.0.0`, narrow ranges are the defensive default: minor bumps MAY carry breaking changes per [`versioning.md`](./versioning.md), so a plugin spanning minor boundaries can load and then crash at first use against a changed schema. Pin to the minor you tested (`"^0.40.0"` resolves any `0.40.x`; `">=0.40.0 <0.41.0"` is the explicit form). After the spec hits v1.0.0, `"^1.0.0"` is the recommended range for most plugins.
311
226
 
312
227
  ---
313
228
 
314
229
  ## The six extension kinds
315
230
 
316
- The kernel knows six categories. Three are dual-mode (deterministic or probabilistic per [`architecture.md` §Execution modes](./architecture.md)); three are deterministic-only because they sit on the deterministic scan path.
231
+ The kernel knows six categories. Each has a JSON Schema under [`schemas/extensions/`](./schemas/extensions/); the kernel validates every manifest against the schema for its declared kind at load time. The full per-kind behavioural contract lives in [`architecture.md` §Extension kinds](./architecture.md#extension-kinds), this section is the author-facing summary plus one minimal example per kind.
317
232
 
318
233
  | Kind | Method | Receives | Returns | Mode |
319
234
  |---|---|---|---|---|
320
- | `provider` | `walk(roots, opts)` | filesystem roots | `IRawNode[]` | deterministic only |
321
- | `extractor` | `extract(ctx)` | one node + body + frontmatter + callbacks | `void` (output via `ctx.emitLink` / `ctx.enrichNode` / `ctx.store`) | deterministic only |
235
+ | `provider` | `walk` / `classify` | filesystem roots, candidate path | `{ kind, provider } \| null` | deterministic only |
236
+ | `extractor` | `extract(ctx)` | one node + body + frontmatter + callbacks | `void` (via `ctx.emitLink` / `ctx.enrichNode` / `ctx.emitContribution` / `ctx.store`) | deterministic only |
322
237
  | `analyzer` | `evaluate(ctx)` | full graph | `Issue[]` | dual-mode |
323
- | `action` | `run(ctx)` | one or more nodes | execution record | dual-mode |
238
+ | `action` | `run(ctx)` | one or more nodes | report / rendered prompt | dual-mode |
324
239
  | `formatter` | `format(ctx)` | full graph | `string` | deterministic only |
325
- | `hook` | `on(ctx)` | a curated lifecycle event payload | `void` (reactions are side effects) | dual-mode |
240
+ | `hook` | `on(ctx)` | a curated lifecycle event payload | `void` (side effects) | **deterministic only** |
326
241
 
327
- The runtime instance you `export default` from an extension file MUST include both the manifest fields (id, kind, version, plus kind-specific metadata) AND the runtime method. The kernel strips function-typed properties before AJV-validating the manifest shape, so `extract` / `evaluate` / etc. live alongside metadata without confusing the schema.
242
+ The runtime instance you `export default` includes both the manifest fields (`version`, `description`, plus kind-specific metadata) AND the runtime method. The kernel strips function-typed properties before AJV-validating the manifest, so the method lives alongside metadata.
328
243
 
329
244
  ### Extractors
330
245
 
331
- 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).
332
-
333
- The runtime method is `extract(ctx) → void`. Output flows through three callbacks the kernel binds onto the context:
246
+ Pure single-node analysis. **Never** read another node, the graph, or the database, cross-node reasoning is for analyzers. Manifest fields beyond the base: `scope` (`'frontmatter'` | `'body'` | `'both'`), optional `precondition`, optional `ui` (view contributions). Spec at [`schemas/extensions/extractor.schema.json`](./schemas/extensions/extractor.schema.json).
334
247
 
335
- - **`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.
336
- - **`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).
337
- - **`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).
248
+ `extract(ctx) void`. Output flows through callbacks the kernel binds onto `ctx`:
338
249
 
339
- 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).
250
+ - **`ctx.emitLink(link)`**, append a `Link`. The kernel validates `link.kind` against the **global closed enum** (`invokes`, `references`, `mentions`, `supersedes`); off-enum kinds drop as `extension.error`. Confidence is declared per emit (default `'medium'`). URL-shaped targets are partitioned into `node.externalRefsCount` and never persisted. (There is no per-extractor `emitsLinkKinds` allowlist anymore.)
251
+ - **`ctx.enrichNode(partial)`**, merge kernel-curated properties onto the node's enrichment layer (persisted into `node_enrichments`). **Strictly separate from the author frontmatter**, which is immutable from any Extractor. Use it for inferred facts (computed titles, summaries) the author did not write.
252
+ - **`ctx.emitContribution(id, payload)`**, view contributions (see [View contributions](#view-contributions)).
253
+ - **`ctx.store`**, plugin-scoped persistence, present only when `plugin.json` declares `storage.mode`. See [`plugin-kv-api.md`](./plugin-kv-api.md).
340
254
 
341
- 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.
255
+ You can read `ctx.node.sidecar.*` freely: the per-`(node, extractor)` cache hashes the sidecar `annotations` block alongside the body, so a `.sm`-only edit invalidates the cached run automatically.
342
256
 
343
- > **Pick a syntax that doesn't collide with built-ins.** The built-in `at-directive` and `slash` extractors claim the `@` and `/` prefixes with LLM-aligned semantics:
344
- >
345
- > - **`core/at-directive`**: bare handles (`@team-lead`) and namespaced agents (`@my-plugin/foo-extractor`, `@skill-map:explore`) emit `mentions` links; file-flavoured tokens (`@docs/api/v1.md`, `@./readme.md`, `@../parent.md`, `@/abs/path.md`) emit `references` links so the graph treats them as file pointers, not entity mentions, the same way Claude Code / Antigravity CLI / Cursor would resolve them. The kind dispatch keys on (a) an explicit relative / absolute path prefix or (b) a known file extension at the tail.
346
- > - **`core/slash-command`**: bare commands (`/scan`, `/skill-map:explore`) emit `invokes`; tokens whose next character is another `/` or any other identifier char are dropped as path segments (`/Volumes/disk`, `/api/v1/items`).
347
- > - **Both extractors strip fenced code blocks and inline backticks before matching**, so author-marked literal payload never registers as invocation surface.
348
- >
349
- > 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.
257
+ > **Pick a syntax that doesn't collide with built-ins.** `core/at-directive` claims `@`, `core/slash-command` claims `/`, both with LLM-aligned semantics (and both strip fenced code blocks + inline backticks before matching). A new extractor matching one of those prefixes will fire on the same input and, if it emits a different `target` shape, raises a `trigger-collision`. The example below uses a wikilink-style `[[ref:<name>]]` pattern to side-step this. See [`architecture.md` §Extractor · trigger normalization](./architecture.md#extractor--trigger-normalization) for the normalization pipeline.
350
258
 
351
259
  ```javascript
352
- import { normalizeTrigger } from '@skill-map/cli';
353
-
354
260
  export default {
355
- id: 'ref-extractor',
356
- kind: 'extractor',
357
261
  version: '1.0.0',
358
262
  description: 'Extracts [[ref:<name>]] tokens from the body.',
359
- stability: 'experimental',
360
- emitsLinkKinds: ['references'],
361
- defaultConfidence: 'medium',
362
263
  scope: 'body',
363
264
  extract(ctx) {
364
265
  for (const m of ctx.body.matchAll(/\[\[ref:([a-z0-9-]+)\]\]/gi)) {
@@ -371,32 +272,27 @@ export default {
371
272
  trigger: { originalTrigger: m[0], normalizedTrigger: m[0].toLowerCase() },
372
273
  });
373
274
  }
374
- // Optional: emit a canonical title onto the enrichment layer.
375
- // ctx.enrichNode({ title: 'Computed title' });
376
275
  },
377
276
  };
378
277
  ```
379
278
 
380
-
381
279
  ### Analyzers
382
280
 
383
- 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).
281
+ Cross-node reasoning over the merged graph; runs after every Provider and extractor. Dual-mode (`mode: 'deterministic'` default, `'probabilistic'` opt-in). Deterministic analyzers run synchronously inside `sm scan` / `sm check`; probabilistic ones dispatch as jobs and NEVER participate in the deterministic scan pipeline. Optional `precondition` and `ui`. Spec at [`schemas/extensions/analyzer.schema.json`](./schemas/extensions/analyzer.schema.json).
384
282
 
385
- 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`.
283
+ The analyzer↔action relationship is declared from the **Action** side via `precondition.analyzerIds` (Modelo B); there is no `recommendedActions` field on the Analyzer.
386
284
 
387
285
  ```javascript
388
286
  export default {
389
- id: 'orphan-skill',
390
- kind: 'analyzer',
391
287
  version: '1.0.0',
392
288
  description: 'Flags skill nodes with zero inbound links.',
393
289
  evaluate(ctx) {
394
- const inboundCount = new Map();
290
+ const inbound = new Map();
395
291
  for (const link of ctx.links) {
396
- inboundCount.set(link.target, (inboundCount.get(link.target) ?? 0) + 1);
292
+ inbound.set(link.target, (inbound.get(link.target) ?? 0) + 1);
397
293
  }
398
294
  return ctx.nodes
399
- .filter((n) => n.kind === 'skill' && (inboundCount.get(n.path) ?? 0) === 0)
295
+ .filter((n) => n.kind === 'skill' && (inbound.get(n.path) ?? 0) === 0)
400
296
  .map((n) => ({
401
297
  analyzerId: 'orphan-skill',
402
298
  severity: 'info',
@@ -407,27 +303,17 @@ export default {
407
303
  };
408
304
  ```
409
305
 
410
- > **`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/node-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`.
411
- >
412
- > The two surfaces are distinct:
413
- >
414
- > - **`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.
415
- > - **`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.
416
- >
417
- > 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/node-superseded` surfaces user-authored supersession statements).
306
+ > Until the job subsystem ships (Step 10), probabilistic analyzers are skipped silently by `sm scan`; `sm check --include-prob` loads them, lists them on stderr, and the `--async` companion is a reserved no-op.
418
307
 
419
308
  ### Formatters
420
309
 
421
- 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).
422
-
423
- The manifest field `formatId` carries the identifier the user types on the command line (matching `sm graph --format <name>`); the runtime method `format(ctx)` produces the serialized output. The split is deliberate: the method reads naturally as `Formatter.format()`, and the field is the lookup key used by the kernel.
310
+ Graph-to-string serializers, invoked by `sm graph --format <name>`. The format **name** comes from the formatter's folder name; the manifest declares `contentType` (MIME hint). Output **MUST** be byte-deterministic for the same input graph (the snapshot suite relies on it). Spec at [`schemas/extensions/formatter.schema.json`](./schemas/extensions/formatter.schema.json).
424
311
 
425
312
  ```javascript
313
+ // formatters/csv/index.js → sm graph --format csv
426
314
  export default {
427
- id: 'csv-formatter',
428
- kind: 'formatter',
429
315
  version: '1.0.0',
430
- formatId: 'csv',
316
+ description: 'Serializes links as CSV.',
431
317
  contentType: 'text/csv',
432
318
  format(ctx) {
433
319
  const rows = ['source,target,kind,confidence'];
@@ -441,31 +327,17 @@ export default {
441
327
 
442
328
  ### Hooks
443
329
 
444
- 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).
330
+ Declarative subscribers to a curated set of kernel lifecycle events. **Deterministic-only**: a hook reacts to events and cannot mutate the pipeline, block emission, or alter outputs. Errors are caught by the dispatcher (logged as `extension.error` with `kind: 'hook-error'`) and NEVER block the main flow. LLM-dependent reactions are modeled as a deterministic Hook that enqueues a probabilistic Action via `ctx.queue('<plugin>/<action>', payload)`. Spec at [`schemas/extensions/hook.schema.json`](./schemas/extensions/hook.schema.json); trigger semantics at [`architecture.md` §Hook · curated trigger set](./architecture.md#hook--curated-trigger-set).
445
331
 
446
- 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.
447
-
448
- The eight hookable triggers (declaring any other event yields `invalid-manifest` at load time):
449
-
450
- 1. `scan.started`, pre-scan setup (one per scan).
451
- 2. `scan.completed`, post-scan reaction (one per scan).
452
- 3. `extractor.completed`, aggregated per-Extractor outputs.
453
- 4. `analyzer.completed`, aggregated per-Analyzer outputs.
454
- 5. `action.completed`, Action executed on a node.
455
- 6. `job.spawning`, pre-spawn of runner subprocess (Step 10).
456
- 7. `job.completed`, most common trigger (Step 10).
457
- 8. `job.failed`, alerts, retry triggers (Step 10).
332
+ The ten hookable triggers (any other event yields `invalid-manifest`): eight pipeline-driven, `scan.started`, `scan.completed`, `extractor.completed`, `analyzer.completed`, `action.completed`, `job.spawning`, `job.completed`, `job.failed`, plus two CLI-process-driven, `boot` (before verb routing) and `shutdown` (after the verb's exit code resolves).
458
333
 
459
334
  ```javascript
460
335
  export default {
461
- id: 'slack-notifier',
462
- kind: 'hook',
463
336
  version: '1.0.0',
464
337
  description: 'Posts to Slack when a scan completes with issues.',
465
338
  triggers: ['scan.completed'],
466
- // Optional: only fire when the scan actually surfaced issues.
467
- // Filter keys are top-level event.data fields; values are literal matches.
468
- // filter: { issuesCount: 0 }, example only; this hook fires on every scan.
339
+ // Optional: filter narrows fan-out over the event payload (top-level fields only).
340
+ // filter: { ... }
469
341
  async on(ctx) {
470
342
  const stats = ctx.event.data?.stats;
471
343
  if (!stats || stats.issuesCount === 0) return;
@@ -480,129 +352,50 @@ export default {
480
352
  };
481
353
  ```
482
354
 
483
- > **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).
484
-
485
- > **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.
486
-
487
- > **What hooks CANNOT do.** Hooks REACT to events; they cannot block emission, mutate the graph, alter Extractor / Analyzer output, or enrich nodes. For graph mutations use `extractor.enrichNode`; for graph reasoning use a Analyzer; for periodic background work use a probabilistic Action wrapped in a hook that submits the job. The single-responsibility split keeps the kernel's deterministic baseline stable.
355
+ > **Filter narrows fan-out, not the trigger enum.** `filter` is a runtime predicate over the payload; it does not extend the hookable set. Declaring a non-curated trigger (e.g. `scan.progress`) is rejected at load regardless of any filter.
488
356
 
489
- ### Providers / Actions
357
+ ### Providers
490
358
 
491
- These ship later in the v1.x line as bundled built-ins; the spec already pins their manifest shapes. Until Step 10 lands the job subsystem, authors are encouraged to test them with a live kernel via `sm scan` against a fixture directory rather than in unit tests.
359
+ Recognise a platform and declare a kind catalog. The catalog lives **on disk** (structure-as-truth): each kind under `<plugin>/kinds/<kindName>/` ships exactly two files, `schema.json` (the kind's frontmatter JSON Schema, MUST extend [`schemas/frontmatter/base.schema.json`](./schemas/frontmatter/base.schema.json) via `allOf` + `$ref`) and `kind.json` (per-kind metadata, today `{ ui: { label, color, colorDark?, emoji?, icon? } }`, validated against [`provider-kind.schema.json`](./schemas/extensions/provider-kind.schema.json)). The kernel derives the supported kind set from the `kinds/` directory listing; there is no inline `kinds` map and no `defaultRefreshAction` field.
492
360
 
493
- #### Provider, `kinds` catalog
361
+ The Provider manifest itself declares a top-level `presentation` block (its own identity in the lens dropdown / topbar / per-card chip, distinct from its kinds' `ui`), plus optional `detect`, `roots`, `gatedByActiveLens`, `read`, and `resolverRules`. The walker hardcodes the paths it scans within the project (`.claude/`, `.codex/`, ...); the kernel never extends the scan into `$HOME`. Spec at [`schemas/extensions/provider.schema.json`](./schemas/extensions/provider.schema.json); full behaviour (dispatch order, the universal markdown fallback, resolution / reservedNames / identifiers) in [`architecture.md` §Extension kinds](./architecture.md#extension-kinds).
494
362
 
495
- Every Provider declares one required top-level field beyond the manifest base: `kinds`.
496
-
497
- **`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:
363
+ ```text
364
+ my-provider/
365
+ ├── plugin.json
366
+ ├── providers/my-provider/index.{ts,js} ← walk / classify
367
+ └── kinds/
368
+ ├── skill/{schema.json, kind.json}
369
+ └── command/{schema.json, kind.json}
370
+ ```
498
371
 
499
- - **`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`.
500
- - **`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`.
501
- - **`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.
372
+ ### Actions
502
373
 
503
- 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 by passing them as positional roots to `sm scan [roots...]` (per-invocation, not persisted).
504
-
505
- ```jsonc
506
- {
507
- "id": "cursor",
508
- "kind": "provider",
509
- "version": "1.0.0",
510
- "kinds": {
511
- "skill": {
512
- "schema": "./schemas/skill.schema.json",
513
- "defaultRefreshAction": "cursor/summarize-skill",
514
- "ui": {
515
- "label": "Skill",
516
- "color": "#7c3aed",
517
- "colorDark": "#a78bfa",
518
- "icon": { "kind": "pi", "id": "pi-bolt" }
519
- }
520
- },
521
- "command": {
522
- "schema": "./schemas/command.schema.json",
523
- "defaultRefreshAction": "cursor/summarize-command",
524
- "ui": {
525
- "label": "Command",
526
- "color": "#0ea5e9",
527
- "icon": { "kind": "svg", "path": "M3 6h18M3 12h18M3 18h18" }
528
- }
529
- }
530
- }
531
- }
532
- ```
374
+ Operate on one or more nodes. Dual-mode (`mode` optional, default `'deterministic'`). Files-by-convention: every Action carries `<action-dir>/report.schema.json`; probabilistic Actions additionally carry `<action-dir>/prompt.md`. Probabilistic estimates go in `probExpectedDurationSeconds` (drives job TTL). Optional `precondition` (including `analyzerIds`, the Modelo B link). These ship later in the v1.x line as bundled built-ins; until Step 10 lands the job subsystem, test them with a live kernel via `sm scan` against a fixture rather than in unit tests. Spec at [`schemas/extensions/action.schema.json`](./schemas/extensions/action.schema.json).
533
375
 
534
376
  ---
535
377
 
536
378
  ## Frontmatter validation, three-tier model
537
379
 
538
- 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`.
380
+ The kernel validates frontmatter on a graduated dial; tighter is opt-in. The policy lives in **analyzers**, not the JSON Schemas, the schemas stay shape-only ([`base.schema.json`](./schemas/frontmatter/base.schema.json) declares `additionalProperties: true`) so authors extend their own nodes without forking the spec. Per-kind schemas live with the **Provider** that emits the kind.
539
381
 
540
- | Tier | Mechanism | Behavior on unknown / non-conforming fields |
382
+ | Tier | Mechanism | Behaviour on unknown / non-conforming fields |
541
383
  |---|---|---|
542
- | **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). |
543
- | **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). |
544
- | **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. |
545
-
546
- > 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).
384
+ | **0, Default permissive** | `additionalProperties: true` on `base` and every per-kind schema. | Field passes silently, persists in `node.frontmatter`, available to every extension. |
385
+ | **1, Built-in `unknown-field` analyzer** | Deterministic, always active. | Emits a `warn` Issue for every key outside the documented catalog. |
386
+ | **2, Strict mode** | `scan.strict: true` in settings, or `--strict` on `sm scan`. | Promotes all frontmatter warnings to `error`; `sm check` then exits `1`. CI fails. |
547
387
 
548
- ### Worked example, same node, three tiers
549
-
550
- Starting frontmatter on a skill node:
551
-
552
- ```yaml
553
- ---
554
- name: code-reviewer
555
- description: Reviews diffs against repo conventions.
556
- metadata:
557
- version: 1.0.0
558
- priority: high # ← author-defined, not in any schema
559
- ---
560
- ```
561
-
562
- **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.
563
-
564
- **Tier 1 (always-active `unknown-field` analyzer).** After `sm scan`, the analyzer emits:
565
-
566
- ```jsonc
567
- {
568
- "analyzerId": "unknown-field",
569
- "severity": "warn",
570
- "message": "Unknown frontmatter field 'priority' on skill node 'code-reviewer'. Add it to a custom analyzer or move it under metadata.* if intentional.",
571
- "nodeIds": ["code-reviewer.md"]
572
- }
573
- ```
574
-
575
- `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.
576
-
577
- **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.
578
-
579
- ```jsonc
580
- // .skill-map/settings.json
581
- {
582
- "schemaVersion": 1,
583
- "scan": { "strict": true }
584
- }
585
- ```
586
-
587
- The CLI flag wins when both are set (see the `--strict` description on `sm scan`); the flag is the per-invocation override, the config field is the team default.
388
+ Tier 1 is normative: the kernel ships the analyzer out of the box. To keep an unknown key quietly, either move it under `metadata.*` (the base schema permits free-form keys there) or accept the persistent `warn`.
588
389
 
589
390
  ### Why no "schema-extender" plugin kind
590
391
 
591
- A reasonable next thought is: "I want my plugin to widen the frontmatter schema so my custom keys are first-class." The spec deliberately rejects that route. The accepted path is to write a deterministic **Analyzer** that:
592
-
593
- 1. Reads the candidate keys from `node.frontmatter` (which Tier 0 already exposes).
594
- 2. Validates them against whatever shape your domain expects (regex, enum, cross-node consistency).
595
- 3. Emits Issues for violations.
596
-
597
- 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.
598
-
599
- 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.
392
+ To make custom frontmatter keys first-class, write a deterministic **Analyzer** that reads the keys from `node.frontmatter` (Tier 0 already exposes them), validates them against your domain shape, and emits Issues. A "schema-extender" kind would force every consumer to re-resolve the active schema set per scan; an analyzer-driven approach keeps the parser one-pass and the validation surface composable. If the check must be CI-blocking, the analyzer emits at `severity: 'error'` directly (`--strict` / `scan.strict` apply only to the kernel's own frontmatter warnings).
600
393
 
601
394
  ---
602
395
 
603
396
  ## Storage
604
397
 
605
- 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).
398
+ A plugin that persists state declares `storage` in its manifest. Two modes, both documented in full at [`plugin-kv-api.md`](./plugin-kv-api.md).
606
399
 
607
400
  ### Mode A, KV
608
401
 
@@ -610,9 +403,7 @@ A plugin that needs to persist state declares `storage` in its manifest. Two mod
610
403
  { "storage": { "mode": "kv" } }
611
404
  ```
612
405
 
613
- Backed by the kernel-owned `state_plugin_kvs` table. The plugin gets `ctx.store` with `get` / `set` / `list` / `delete`. No migrations to write, ready immediately.
614
-
615
- Pick KV when your state is a small map (less than ~1 MB total, simple key lookup or prefix list). 90 % of plugins fit.
406
+ Backed by the kernel-owned `state_plugin_kvs` table. `ctx.store` exposes `get` / `set` / `list` / `delete`. No migrations, ready immediately. Pick KV when state is a small map (< ~1 MB, simple key lookup or prefix list). 90% of plugins fit.
616
407
 
617
408
  ### Mode B, Dedicated
618
409
 
@@ -620,193 +411,66 @@ Pick KV when your state is a small map (less than ~1 MB total, simple key lookup
620
411
  {
621
412
  "storage": {
622
413
  "mode": "dedicated",
623
- "tables": ["plugin_my_plugin_items", "plugin_my_plugin_history"],
414
+ "tables": ["plugin_my_plugin_items"],
624
415
  "migrations": ["./migrations/001_init.sql"]
625
416
  }
626
417
  }
627
418
  ```
628
419
 
629
- The plugin owns SQL tables prefixed `plugin_<normalizedId>_*`. Migrations live under `<plugin-dir>/migrations/NNN_<name>.sql` and apply through `sm db migrate` (mixed with kernel migrations, after them).
630
-
631
- Pick Dedicated when you need indexes, joins, or relational shape.
632
-
633
- #### Triple protection
634
-
635
- Every DDL or DML object a plugin migration creates / alters / drops MUST live in the `plugin_<normalizedId>_*` namespace. The kernel enforces this in three places:
636
-
637
- 1. **Discovery (Layer 1)**: every pending migration file is parsed and validated before any of them run. A bad file aborts the whole batch with no DB writes.
638
- 2. **Apply (Layer 2)**: the same validator re-runs immediately before `db.exec(sql)`, defending against TOCTOU edits between discovery and apply.
639
- 3. **Catalog assertion (Layer 3)**: `sqlite_master` is swept after each plugin's batch commits; any new object outside the prefix is reported as an intrusion (exit 2).
640
-
641
- 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.
642
-
643
- ### `outputSchema`, opt-in correctness for custom storage writes
644
-
645
- `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.
646
-
647
- 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.
648
-
649
- **Mode A (`kv`), single value-shape schema.**
650
-
651
- ```jsonc
652
- {
653
- "storage": {
654
- "mode": "kv",
655
- "schema": "./schemas/kv-value.schema.json"
656
- }
657
- }
658
- ```
659
-
660
- 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).
661
-
662
- **Mode B (`dedicated`), per-table schemas.**
663
-
664
- ```jsonc
665
- {
666
- "storage": {
667
- "mode": "dedicated",
668
- "tables": ["items", "history"],
669
- "migrations": ["./migrations/001_init.sql"],
670
- "schemas": {
671
- "items": "./schemas/items-row.schema.json"
672
- }
673
- }
674
- }
675
- ```
676
-
677
- 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.
420
+ The plugin owns SQL tables prefixed `plugin_<normalizedId>_*`. Migrations live under `<plugin-dir>/migrations/NNN_<name>.sql` and apply through `sm db migrate`. Pick Dedicated when you need indexes, joins, or relational shape. The kernel enforces the namespace prefix at three layers (discovery, apply, post-commit catalog sweep) and forbids transaction / pragma statements in migration files, see [`plugin-kv-api.md`](./plugin-kv-api.md) and [`db-schema.md`](./db-schema.md) for the normative rules.
678
421
 
679
- **Failure modes.**
422
+ ### Opt-in write validation
680
423
 
681
- - A schema file missing on disk OR unparseable as JSON OR rejected by AJV's compiler at load time the plugin's status flips to `load-error` and its extensions are NOT registered. The diagnostic names the offending plugin, table (Mode B), and schema path.
682
- - A `set` / `write` call whose value violates the declared schema → the kernel throws synchronously from inside the wrapper. The throw message names the plugin id, the schema path, and the AJV errors.
424
+ `emitLink` and `enrichNode` are always validated by the kernel against `link.schema.json` / `node.schema.json`. `ctx.store` writes are permissive by default (the author owns the table layout). To validate your own writes, declare JSON Schemas in the manifest:
683
425
 
684
- **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.
426
+ - **Mode A**: `storage.schema` (single value-shape) validates every `ctx.store.set(key, value)`.
427
+ - **Mode B**: `storage.schemas` (sparse map, table → schema path) validates `ctx.store.write(table, row)` for the named tables; tables absent from the map accept any shape.
685
428
 
686
- `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.
429
+ A schema file missing / unparseable / AJV-rejected at load flips the plugin to `load-error`. A write violating its declared schema throws synchronously, naming the plugin, table, and AJV errors. Skip validation for free-form payloads (cache rows, counters) where it is friction with no payoff.
687
430
 
688
431
  ---
689
432
 
690
433
  ## Execution modes
691
434
 
692
- 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.
693
-
694
- ```jsonc
695
- // extractor, deterministic by spec, no mode field
696
- { "kind": "extractor", "id": "my-extractor", ... }
697
- ```
698
-
699
- ```jsonc
700
- // probabilistic action, runs only as a queued job, dispatched via `sm job submit action:my-action`
701
- { "kind": "action", "id": "my-action", "mode": "probabilistic", ... }
702
- ```
703
-
704
- A `probabilistic` extension receives `ctx.runner` (a `RunnerPort`) and dispatches its work to the configured LLM runner (CLI, Skill Agent, or in-process per [`architecture.md`](./architecture.md)). It MUST NOT register scan-time hooks; the kernel rejects probabilistic extensions that do.
705
-
706
- The full per-kind capability matrix lives in [`architecture.md` §Execution modes](./architecture.md).
707
-
708
- ---
709
-
710
- ## Testing your plugin
711
-
712
- Plugin extensions are plain ESM modules with a single entry point per kind (`extract` / `evaluate` / `format` / `run` / `on`); their inputs are well-typed context objects from `@skill-map/cli`. That makes them straightforward to unit-test without a kernel or DB: build a fake `ctx` literal, call the entry point, assert on what it captured.
713
-
714
- ```javascript
715
- import { test } from 'node:test';
716
- import { strictEqual } from 'node:assert';
717
-
718
- import extractor from '../extractors/my-extractor/index.js';
719
-
720
- test('emits one reference per [[ref:<name>]] token', async () => {
721
- const links = [];
722
- await extractor.extract({
723
- node: { path: 'a.md', kind: 'skill', provider: 'claude' },
724
- body: 'Talk to [[ref:architect]] or [[ref:sre]].',
725
- frontmatter: {},
726
- settings: {},
727
- emitLink: (link) => links.push(link),
728
- enrichNode: () => {},
729
- emitContribution: () => {},
730
- });
731
- strictEqual(links.length, 2);
732
- strictEqual(links[0].target, 'architect');
733
- });
734
- ```
735
-
736
- For analyzers, the same pattern applies: build a `ctx` with `nodes`, `links`, an `emitContribution` spy if you assert on view contributions, and call `analyzer.evaluate(ctx)`, it returns the issue array. Formatters take `{ nodes, links, issues }` and return a string from `formatter.format(ctx)`.
737
-
738
- For probabilistic extensions (Actions / Hooks running in `mode: 'probabilistic'`), shape a fake `ctx.runner` that records the calls your test cares about:
739
-
740
- ```javascript
741
- const calls = [];
742
- const runner = {
743
- async run(call) {
744
- calls.push(call);
745
- return { text: 'mocked response' };
746
- },
747
- };
748
- await myAction.run({ runner, /* … */ });
749
- strictEqual(calls[0].action, 'skill-summarizer');
750
- ```
751
-
752
- The public TypeScript types (`IExtractor`, `IAnalyzer`, `IFormatter`, `IExtractorContext`, `IAnalyzerContext`, `IFormatterContext`, `Node`, `Link`, `Issue`, …) are re-exported from `@skill-map/cli` so authors can type-check their fakes against the same surface the kernel consumes.
753
-
754
- ---
755
-
756
- ## Diagnostics
757
-
758
- `sm plugins list` shows every discovered plugin with one of six statuses. When a plugin doesn't behave the way you expect, this is the first thing to check.
435
+ Analyzer and Action declare `mode` (optional, default `'deterministic'`); Provider / Extractor / Formatter / Hook are deterministic-only by spec and MUST NOT declare it.
759
436
 
760
- | Status | Meaning | Common cause |
761
- |---|---|---|
762
- | `loaded` | manifest valid, specCompat satisfied, every extension imported and validated. |, |
763
- | `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. |
764
- | `incompatible-spec` | manifest parsed but `semver.satisfies` failed against the installed spec. | Plugin built against an older / newer spec. |
765
- | `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. |
766
- | `load-error` | manifest passed but an extension module failed to import or its default export failed schema validation. | Missing `kind` field, wrong `kind` for the file, runtime import error. |
767
- | `id-collision` | two plugins reachable from different roots declared the same `id`. Both collided plugins receive this status; no precedence analyzer applies. | A project-local plugin and a `--plugin-dir` plugin (or two `--plugin-dir` plugins) sharing an id. Rename one and rerun. |
768
-
769
- `sm plugins doctor` runs the full load pass and exits 1 if any plugin is in a non-`loaded` / non-`disabled` state (so any of `incompatible-spec` / `invalid-manifest` / `load-error` / `id-collision` trips it). Wire it into CI to catch breakage early.
437
+ A `probabilistic` Analyzer / Action receives `ctx.runner` (a `RunnerPort`) and dispatches its work to the configured LLM runner; it runs ONLY as a queued job (`sm job submit <kind>:<id>`), never in `sm scan`. The full per-kind capability matrix lives in [`architecture.md` §Execution modes](./architecture.md#execution-modes).
770
438
 
771
439
  ---
772
440
 
773
- ## Annotation contributions
441
+ ## Annotation contribution
774
442
 
775
- > **Status.** Ships with spec v0.18.0 (Step 9.6.6). Plugins that want to write first-class fields into a node's co-located `.sm` sidecar declare them in their extension manifest under `annotationContributions`. The kernel validates the contributions at load time, surfaces the runtime catalog via `kernel.getRegisteredAnnotationKeys()` (consumed by the BFF / UI for autocomplete), and treats two plugins claiming the same root-exclusive key as a fatal startup error.
443
+ > Plugins that want to write a first-class field into a node's co-located `.sm` sidecar declare it via the optional `annotation` block on their extension manifest. The kernel validates it at load time, surfaces the runtime catalog via `kernel.getRegisteredAnnotationKeys()` (consumed by the BFF / UI for autocomplete), and treats two plugins claiming the same root-exclusive key as a fatal startup error. Normative contract: [`architecture.md` §Annotation system → Plugin contributions](./architecture.md#plugin-contributions).
776
444
 
777
445
  ### Manifest shape
778
446
 
779
- `annotationContributions` is an object map keyed by the annotation key the extension wants to own. Each entry declares an inline JSON Schema for the value plus two policy fields:
447
+ `annotation` is a **single** declaration per extension; **the contributed key is the extension's id** (its folder name). An extension that needs several keys splits into several extensions, one per key. The block declares an inline JSON Schema for the value plus two policy fields:
780
448
 
781
449
  ```js
782
- // my-plugin/extractors/my-extractor/index.js
450
+ // my-plugin/extractors/last-reviewed-at/index.js → contributes key `last-reviewed-at`
783
451
  export default {
784
- id: 'my-extractor',
785
- kind: 'extractor',
786
452
  version: '1.0.0',
787
- // ...rest of the extractor manifest...
788
- annotationContributions: {
789
- lastReviewedAt: {
790
- schema: { type: 'string', format: 'date-time' },
791
- // location and ownership default to 'namespaced' / 'shared'
792
- },
453
+ description: 'Records the last review timestamp on each node.',
454
+ scope: 'frontmatter',
455
+ annotation: {
456
+ schema: { type: 'string', format: 'date-time' },
457
+ // location defaults to 'namespaced', ownership to 'shared'
793
458
  },
459
+ // ...extract(ctx) writes the value through the kernel's sidecar path...
794
460
  };
795
461
  ```
796
462
 
797
- Field-by-field:
463
+ | Field | Type | Default | Meaning |
464
+ |---|---|---|---|
465
+ | `schema` | inline JSON Schema (object) | required | Validates the value written under this key. Compiled with AJV at load. |
466
+ | `location` | `'namespaced'` \| `'root'` | `'namespaced'` | Where the key lands in the sidecar. |
467
+ | `ownership` | `'shared'` \| `'exclusive'` | `'shared'` | Conflict policy. REQUIRED to be `'exclusive'` when `location: 'root'`. |
798
468
 
799
- | Field | Type | Default | Meaning |
800
- |--------------|-----------------------------------|----------------|------------------------------------------------------------------------------------------------------|
801
- | `schema` | inline JSON Schema (object) | required | Validates the value the extension writes under this key. Compiled with AJV at load time. |
802
- | `location` | `'namespaced'` \| `'root'` | `'namespaced'` | Where the key lands inside the sidecar (see below). |
803
- | `ownership` | `'shared'` \| `'exclusive'` | `'shared'` | Conflict policy. REQUIRED to be `'exclusive'` when `location: 'root'`. |
804
-
805
- 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.
469
+ The `schema` is **inline** (an object literal in the manifest), not a `$ref` to a file.
806
470
 
807
471
  ### Namespacing default vs root opt-in
808
472
 
809
- By default a contribution lands inside the plugin's `<plugin-id>:` block at the sidecar root. Two plugins can ship a contribution with the same key and never collide because the runtime path keeps them under separate namespaces:
473
+ By default a contribution lands inside the plugin's `<plugin-id>:` block at the sidecar root, so two plugins can contribute the same extension-id key without colliding:
810
474
 
811
475
  ```yaml
812
476
  # .claude/agents/architect.sm
@@ -816,92 +480,52 @@ identity:
816
480
  frontmatterHash: ...
817
481
  annotations:
818
482
  version: 3
819
-
820
- # Plugin 'reviewer' contributes 'lastReviewedAt'
821
- reviewer:
822
- lastReviewedAt: 2026-05-06T10:00:00Z
823
-
824
- # Plugin 'auditor' also contributes 'lastReviewedAt', different namespace, no conflict
825
- auditor:
826
- lastReviewedAt: 2026-05-05T18:30:00Z
483
+ reviewer: # plugin 'reviewer', extension 'last-reviewed-at'
484
+ last-reviewed-at: 2026-05-06T10:00:00Z
485
+ auditor: # plugin 'auditor', same key, different namespace, no conflict
486
+ last-reviewed-at: 2026-05-05T18:30:00Z
827
487
  ```
828
488
 
829
- 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.
489
+ A top-level (root) key requires `location: 'root'` AND `ownership: 'exclusive'`. The pair travels together: `.sm` writes deep-merge per the `SidecarStore` contract, so a shared root key would route non-deterministically. Use root sparingly, each root contribution reserves that name across the whole installed-plugin surface.
830
490
 
831
491
  ```js
832
- // compliance-plugin/analyzers/compliance-checker/index.js
492
+ // compliance-plugin/analyzers/compliance/index.js → contributes root key `compliance`
833
493
  export default {
834
- id: 'compliance-checker',
835
- kind: 'analyzer',
836
- // ...
837
- annotationContributions: {
838
- compliance: {
839
- schema: {
840
- type: 'object',
841
- required: ['audit'],
842
- properties: {
843
- audit: { type: 'string' },
844
- dueAt: { type: 'string', format: 'date-time' },
845
- },
494
+ version: '1.0.0',
495
+ description: 'Stamps a compliance block on audited nodes.',
496
+ annotation: {
497
+ schema: {
498
+ type: 'object',
499
+ required: ['audit'],
500
+ properties: {
501
+ audit: { type: 'string' },
502
+ dueAt: { type: 'string', format: 'date-time' },
846
503
  },
847
- location: 'root',
848
- ownership: 'exclusive',
849
504
  },
505
+ location: 'root',
506
+ ownership: 'exclusive',
850
507
  },
851
508
  };
852
509
  ```
853
510
 
854
- The resulting sidecar block:
855
-
856
- ```yaml
857
- # .claude/agents/architect.sm
858
- identity: { path: ..., bodyHash: ..., frontmatterHash: ... }
859
- compliance:
860
- audit: sox-2026
861
- dueAt: 2026-12-31T23:59:59Z
862
- ```
863
-
864
- ### Ownership analyzers
865
-
866
- - `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.
867
- - `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".
868
-
869
- ### Collision behaviour, hard fail, no boot
511
+ ### Ownership and collision behaviour
870
512
 
871
- 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.
513
+ - **`shared`** (default): multiple plugins MAY write the same key; each gets its own namespaced block, last-write-wins per `(plugin, key)` in `FilesystemSidecarStore.applyPatch`.
514
+ - **`exclusive`**: only this plugin may write the key. The kernel rejects any other plugin claiming the same `(key, location: 'root')` tuple. `exclusive` + `namespaced` is permitted but redundant (the namespace already isolates).
872
515
 
873
- 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.
516
+ Two plugins claiming the same `(key, location: 'root', ownership: 'exclusive')` tuple is a **fatal startup error**: `loadPluginRuntime` throws `AnnotationContributionConflictError`, the host exits non-zero, the kernel does NOT boot. This is the only fatal path on the plugin-load surface (every other failure is per-plugin and the kernel keeps booting on the survivors), because otherwise annotated `.sm` files would become non-deterministically routed.
874
517
 
875
- ### Tier-1 typo guard (`core/annotation-field-unknown`)
518
+ ### Typo guard and runtime catalog
876
519
 
877
- The built-in `core/annotation-field-unknown` Analyzer walks every parsed `.sm` and emits a `warn` issue per truly-unknown key. Three surfaces are checked:
520
+ The built-in `core/annotation-field-unknown` Analyzer walks every parsed `.sm` and emits a `warn` issue per truly-unknown key (a key outside the curated `annotations:` catalog, outside the reserved blocks, and not matching any registered plugin namespace or root contribution; or a value failing the owning plugin's declared schema). It never blocks a scan.
878
521
 
879
- 1. Inside `annotations:`, keys not in `annotations.schema.json`'s curated catalog (the 10 conventional fields). Plugins do NOT contribute to `annotations:`; that block is skill-map-curated.
880
- 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.
881
- 3. Inside a registered `<plugin-id>:` namespace, values that fail the schema declared by the owning plugin's `annotationContributions[<key>].schema`.
882
-
883
- 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/annotation-field-unknown` automatically validates user writes against your declaration.
884
-
885
- ### Runtime catalog accessor
886
-
887
- Once every plugin has loaded, the runtime catalog is reachable via `kernel.getRegisteredAnnotationKeys()`:
888
-
889
- ```ts
890
- // Each entry: { pluginId, key, location, ownership, schema }
891
- const keys = kernel.getRegisteredAnnotationKeys();
892
- ```
893
-
894
- 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.
522
+ The runtime catalog is reachable via `kernel.getRegisteredAnnotationKeys()` (each entry `{ pluginId, key, location, ownership, schema }`); built-in catalog fields from `annotations.schema.json` are NOT included. The BFF surfaces it through `GET /api/annotations/registered` for autocomplete.
895
523
 
896
524
  ---
897
525
 
898
526
  ## View contributions
899
527
 
900
- > **Status.** Sibling system to annotation contributions, designed to let plugins surface per-node data in the UI without shipping any UI code. Plugin authors pick a **slot** by name from a closed kernel catalog; the slot fixes both the renderer and the payload shape. Authors declare per-node emissions in their extension manifest and emit payloads at scan time via `ctx.emitContribution(id, payload)`. See [`architecture.md`](./architecture.md) §View contribution system for the normative contract.
901
-
902
- ### What it solves
903
-
904
- 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.
528
+ > Lets plugins surface per-node data in the UI **without shipping any UI code**. You pick a **slot** by name from a closed kernel catalog; the slot fixes both the renderer and the payload shape. You declare per-node emissions in the extension manifest's `ui` map and emit payloads at scan time via `ctx.emitContribution(...)`. Normative contract: [`architecture.md` §View contribution system](./architecture.md#view-contribution-system).
905
529
 
906
530
  ### What you NEVER write
907
531
 
@@ -909,21 +533,15 @@ Today, the only way a plugin can surface UI is implicit: extractors emit `Link`
909
533
  - JSON Schema for your contributions or your settings.
910
534
  - The renderer component that draws your contribution.
911
535
 
912
- You DO write:
913
-
914
- - The `slot` name (one of 15 closed-catalog values). The slot you pick fixes both where the data renders and what payload shape the kernel will accept.
915
- - Optional `label`, `tooltip`, `icon`, `emptyText`, `emitWhenEmpty` per contribution.
916
- - The per-node payload your `extract(ctx)` emits via `ctx.emitContribution(...)`.
536
+ You DO write: the `slot` name, optional presentation tuning per contribution, and the per-node payload your `extract(ctx)` / `evaluate(ctx)` emits.
917
537
 
918
538
  ### Manifest shape
919
539
 
920
- Inside any extension manifest (`IExtractor`, `IAnalyzer`, ...), declare a `viewContributions` map next to `annotationContributions`. Each key is your local contribution id; the value picks a slot.
540
+ Inside an extractor or analyzer manifest, declare a `ui` map (sibling of `annotation` / `settings`). Each key is your local contribution id; the value picks a slot. (The runtime catalog keeps the historical name `viewContributions`; only the manifest field is `ui`.)
921
541
 
922
542
  ```jsonc
923
543
  {
924
- "id": "keyword-finder",
925
- "kind": "extractor",
926
- "viewContributions": {
544
+ "ui": {
927
545
  "breakdown": {
928
546
  "slot": "inspector.body.panel.breakdown",
929
547
  "label": "Keyword hits",
@@ -943,134 +561,82 @@ Field reference (full schema in [`schemas/view-slots.schema.json`](./schemas/vie
943
561
 
944
562
  | Field | Required | Notes |
945
563
  |---|---|---|
946
- | `slot` | yes | One of the 15 catalog names (see below). Unknown name → `invalid-manifest` at load. |
947
- | `label` | no | Short human-readable label. English-only per [`AGENTS.md`](../AGENTS.md) (`Externalized texts, not internationalized`). |
564
+ | `slot` | yes | One of the 14 catalog names (see below). Unknown name → `invalid-manifest` at load. |
565
+ | `label` | no | Short human-readable label. English-only. |
948
566
  | `tooltip` | no | Hover tooltip on the chip / panel header. |
949
- | `icon` | no, but required for counter slots and `card.title.right` | Single prefix-discriminated string. Emoji renders as text; `pi-foo` / `pi pi-foo` → PrimeIcons; `fa-solid fa-foo` / `fa-regular fa-foo` / `fa-brands fa-foo` → FontAwesome (full pass-through); `fa-foo` → defaults to `fa-solid fa-foo`. Bare names without prefix are rejected at load. See [Icon string forms](#icon-string-forms) below. |
567
+ | `icon` | no, but required for counter slots and `card.title.right` | Prefix-discriminated string (see below). |
950
568
  | `emptyText` | no | Text shown when payload is empty AND `emitWhenEmpty: true`. |
951
- | `emitWhenEmpty` | no, default `false` | When `false`, kernel drops empty payloads silently so the slot stays clean. |
569
+ | `emitWhenEmpty` | no, default `false` | When `false`, the kernel drops empty payloads silently. |
570
+ | `priority` | no | Ordering hint when multiple contributions share a slot. |
952
571
 
953
572
  #### Icon string forms
954
573
 
955
- Four valid shapes, prefix-discriminated by the UI resolver:
574
+ Prefix-discriminated by the UI resolver:
956
575
 
957
576
  ```jsonc
958
- { "icon": "🔍" } // emoji, renders as text
959
- { "icon": "pi-search" } // PrimeIcons, equivalent to "pi pi-search"
960
- { "icon": "pi pi-search" } // PrimeIcons, full class string accepted
961
- { "icon": "fa-solid fa-magnifying-glass" } // FontAwesome, explicit family, pass-through
962
- { "icon": "fa-regular fa-star" } // FontAwesome, outlined variant
963
- { "icon": "fa-brands fa-github" } // FontAwesome, brand glyph
964
- { "icon": "fa-magnifying-glass" } // FontAwesome shorthand, defaults to `fa-solid`
577
+ { "icon": "🔍" } // emoji, renders as text
578
+ { "icon": "pi-search" } // PrimeIcons, equivalent to "pi pi-search"
579
+ { "icon": "pi pi-search" } // PrimeIcons, full class string
580
+ { "icon": "fa-solid fa-magnifying-glass" } // FontAwesome, explicit family
581
+ { "icon": "fa-regular fa-star" } // FontAwesome, outlined variant
582
+ { "icon": "fa-brands fa-github" } // FontAwesome, brand glyph
583
+ { "icon": "fa-magnifying-glass" } // FontAwesome shorthand fa-solid
965
584
  ```
966
585
 
967
- 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.
968
-
969
- ### Slot catalog (closed)
970
-
971
- 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.
972
-
973
- | Slot | Payload shape | Renderer |
974
- |---|---|---|
975
- | `card.title.right` | `{ icon?, severity?, tooltip? }` | icon marker (manifest icon required) |
976
- | `card.subtitle.left` | `{ value: integer ≥ 0, severity?, tooltip? }` | counter chip (manifest icon required) |
977
- | `card.footer.left` | `{ value: integer ≥ 0, severity?, tooltip? }` | counter chip (manifest icon required) |
978
- | `card.footer.right` | `{ value: integer ≥ 0, severity?, tooltip? }` | counter chip (manifest icon required) |
979
- | `graph.node.alert` | `{ icon?, severity?, count?, tooltip? }` | graph corner badge |
980
- | `inspector.header.badge.counter` | `{ value: integer ≥ 0, severity?, tooltip? }` | counter chip (manifest icon required) |
981
- | `inspector.header.badge.tag` | `{ label, severity?, tooltip? }` | tag chip |
982
- | `inspector.body.panel.breakdown` | `{ entries: Array<{ label, value, tooltip? }> }` (≤ 20) | bar chart panel |
983
- | `inspector.body.panel.records` | `{ columns: ≤6, rows: ≤50 }` | table panel |
984
- | `inspector.body.panel.tree` | recursive `{ label, marker?, children? }` (depth ≤ 6, total ≤ 200) | tree panel |
985
- | `inspector.body.panel.key-values` | `{ entries: Array<{ key, value, tooltip? }> }` (≤ 50) | definition list panel |
986
- | `inspector.body.panel.link-list` | `{ entries: Array<{ path, label?, kind? }> }` (≤ 100) | clickable list panel |
987
- | `inspector.body.panel.markdown` | `{ markdown }` (≤ 4096 chars, sanitized) | sanitized markdown panel |
988
- | `topbar.nav.start` | `{ value, label?, severity?, tooltip? }` | scope chip |
586
+ A bare name without a prefix (`"search"`) is rejected at load. Emoji is the cross-platform safe choice; PrimeIcons covers generic UI glyphs; FontAwesome Free's `regular` set is limited.
989
587
 
990
- Per-slot semantics, edge cases, and exact payload schemas live in [`view-slots.md`](./view-slots.md) (catalog reference) and [`schemas/view-slots.schema.json`](./schemas/view-slots.schema.json) at `$defs/payloads/<slot>`. Read those before emitting.
588
+ ### Slot catalog (closed, 14 slots)
991
589
 
992
- ### Chip vs Issue, what counts and what only shows
590
+ The kernel ships exactly these 14 slots. Each fixes a renderer + a payload shape; the **per-slot semantics, edge cases, and exact payload schemas are the canonical reference in [`view-slots.md`](./view-slots.md)** (and [`schemas/view-slots.schema.json`](./schemas/view-slots.schema.json) at `$defs/payloads/<slot>`). Read those before emitting. Adding a slot requires a spec / UI / scaffolder round-trip.
993
591
 
994
- For analyzers, the per-node card surfaces a finding through **two independent channels**:
995
-
996
- - The `Issue` returned by `evaluate(ctx)` feeds the **aggregated stats** (`errorCount` / `warnCount` on the card) and the **scan / check exit code** (`severity: 'error'` → exit 1; `'warn'` / `'info'` → exit 0). `info` issues never appear in the card's visible issues list, only in `sm show` / `--json`.
997
- - A view contribution to `card.footer.right` (a chip) is **purely presentational**: its `severity` controls only the chip's own colour, never the aggregated count, never the exit code.
998
-
999
- The matrix:
1000
-
1001
- | Goal | Issue? | Chip? | Issue severity | Chip severity |
1002
- |---|---|---|---|---|
1003
- | Surface a problem AND count it | yes | yes | `error` / `warn` | `danger` / `warn` (match) |
1004
- | Show an attribute without counting | no (or `info`) | yes | `info` (or none) | `info` / `success` / none |
1005
- | Count without a dedicated chip | yes | no | `error` / `warn` | |
1006
- | No surface | no | no | — | — |
1007
-
1008
- **Colour rule.** A chip MAY paint `warn` (yellow) or `danger` (red) **only when** the same analyzer emits a matching Issue at `warn` or `error` severity for the same node. Decorative chips use `severity: 'info'`, `'success'`, or omit the field. The rule is enforced by code review (not by the manifest schema), but breaking it produces visually misleading cards: a red chip on a node that contributes zero to the error stat reads as "missed an exit-code escalation" to the operator.
1009
-
1010
- The corner slot `graph.node.alert` is **reserved** and is NOT part of this matrix, see `view-slots.md` (and the slot's row in the catalog table) for the policy. No built-in analyzer ships an emission there; the slot is kept for genuinely independent, special-case signals (a future plugin with a one-off corner decoration).
592
+ | Slot | Renderer |
593
+ |---|---|
594
+ | `card.title.right` | icon marker (icon required) |
595
+ | `card.subtitle.left` | counter chip (icon required) |
596
+ | `card.footer.left` | counter chip (icon required) |
597
+ | `card.footer.right` | counter chip (icon required) |
598
+ | `graph.node.alert` | graph corner badge (reserved, see `view-slots.md`) |
599
+ | `inspector.header.badge.counter` | counter chip (icon required) |
600
+ | `inspector.header.badge.tag` | tag chip |
601
+ | `inspector.body.panel.breakdown` | bar chart panel |
602
+ | `inspector.body.panel.records` | table panel |
603
+ | `inspector.body.panel.tree` | tree panel |
604
+ | `inspector.body.panel.key-values` | definition list panel |
605
+ | `inspector.body.panel.link-list` | clickable list panel |
606
+ | `inspector.body.panel.markdown` | sanitized markdown panel |
607
+ | `topbar.nav.start` | scope chip |
608
+
609
+ ### Chip vs Issue
610
+
611
+ For analyzers, a per-node card surfaces a finding through two independent channels: the `Issue` returned by `evaluate(ctx)` feeds the aggregated stats and the scan / check exit code; a view contribution to a card slot is **purely presentational** (its `severity` controls only the chip's own colour, never the count, never the exit code). The colour rule, when a chip may paint `warn` / `danger`, and the reserved status of `graph.node.alert` are documented in [`view-slots.md` §Chip vs Issue](./view-slots.md). Breaking the colour rule produces visually misleading cards and is caught in code review, not by the schema.
1011
612
 
1012
613
  ### Emit path
1013
614
 
1014
- Inside `extract(ctx)`, call:
1015
-
1016
615
  ```ts
1017
- ctx.emitContribution('breakdown', {
1018
- entries: Object.entries(perKeyword).map(([label, value]) => ({ label, value })),
1019
- });
1020
-
616
+ // Extractor (per-node walk): nodePath is implicit (ctx.node.path)
617
+ ctx.emitContribution('breakdown', { entries: [...] });
1021
618
  ctx.emitContribution('total', { value: total });
1022
- ```
1023
-
1024
- 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.
1025
-
1026
- 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`.
1027
619
 
1028
- 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.
1029
-
1030
- ### Multi-slot rendering
1031
-
1032
- 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.
1033
-
1034
- ```jsonc
1035
- "viewContributions": {
1036
- "mentionsFooter": {
1037
- "slot": "card.footer.left",
1038
- "icon": "@",
1039
- "label": "mentions"
1040
- },
1041
- "mentionsBadge": {
1042
- "slot": "inspector.header.badge.counter",
1043
- "icon": "@",
1044
- "label": "mentions"
1045
- }
1046
- }
620
+ // Analyzer (post-merge graph): explicit nodePath, the analyzer sees every node at once
621
+ ctx.emitContribution(nodePath, 'breakdown', { ... });
1047
622
  ```
1048
623
 
1049
- Then emit twice (typically with the same value):
624
+ The first id argument is the **manifest `ui` key**, NOT the slot name; the kernel composes the qualified id from your plugin id, extension id, and the key, and looks up the declared slot to validate the payload against `view-slots.schema.json#/$defs/payloads/<slot>`. Off-shape payloads emit an `extension.error` and drop silently, same posture as `emitLink`. For `topbar.nav.start`, analyzers use `ctx.emitScopeContribution(id, payload)` (reserved in the spec; the runtime callback lands when the first scope-level adopter arrives).
1050
625
 
1051
- ```ts
1052
- ctx.emitContribution('mentionsFooter', { value: count });
1053
- ctx.emitContribution('mentionsBadge', { value: count });
1054
- ```
626
+ To surface the same data in two surfaces, declare two contributions (one per slot) and emit twice, there is no broadcast.
1055
627
 
1056
- This is intentional: one source of truth per surface, no surprise duplication when a renderer changes its mind about which slots to draw in.
628
+ ---
1057
629
 
1058
- ### Settings
630
+ ## Settings
1059
631
 
1060
- User-configurable settings live at the manifest root in `settings: Record<string, ISettingDeclaration>`. Each entry picks an `input-type` from a closed catalog. You NEVER write JSON Schema for settings.
632
+ User-configurable settings live on each extension's manifest in `settings: Record<string, ISettingDeclaration>` (sibling of `ui` / `annotation`). Each entry picks an `input-type` from a closed catalog; you NEVER write JSON Schema for settings. Plugin-level settings are not supported, the field is per-extension.
1061
633
 
1062
634
  ```jsonc
1063
635
  {
1064
- "id": "keyword-finder",
1065
- "version": "1.0.0",
1066
- "specCompat": "^0.20.0",
1067
- "catalogCompat": "^1.0.0",
1068
- "extensions": ["./extension.js"],
1069
636
  "settings": {
1070
637
  "keywords": {
1071
638
  "type": "string-list",
1072
639
  "label": "Keywords to track",
1073
- "description": "Words counted across each node's body.",
1074
640
  "default": ["TODO", "FIXME"],
1075
641
  "min": 1
1076
642
  },
@@ -1083,171 +649,102 @@ User-configurable settings live at the manifest root in `settings: Record<string
1083
649
  }
1084
650
  ```
1085
651
 
1086
- The 10 input-types:
652
+ The ten input-types: `string-list`, `single-string`, `boolean-flag`, `integer`, `enum-pick`, `enum-multipick`, `path-glob`, `regex`, `secret`, `key-value-list`. The per-type parameters and runtime value shapes are the canonical reference in [`input-types.md`](./input-types.md) (schema at [`schemas/input-types.schema.json`](./schemas/input-types.schema.json) at `$defs/Setting_<TypeName>`).
1087
653
 
1088
- | Type | Value at runtime | Use for |
1089
- |---|---|---|
1090
- | `string-list` | `string[]` | keyword lists, ignore patterns |
1091
- | `single-string` | `string` | URLs, names, identifiers |
1092
- | `boolean-flag` | `boolean` | toggles |
1093
- | `integer` | `number` (always integer) | counts, thresholds |
1094
- | `enum-pick` | `string` | pick one from a closed set |
1095
- | `enum-multipick` | `string[]` | pick zero or more |
1096
- | `path-glob` | `string` or `string[]` | glob patterns |
1097
- | `regex` | `string` | ECMAScript regex (body, no `/` delimiters) |
1098
- | `secret` | `string` | tokens, passwords (encrypted at rest) |
1099
- | `key-value-list` | `Array<{ key, value }>` | custom maps, alias dictionaries |
1100
-
1101
- Per-type parameter schema lives in [`schemas/input-types.schema.json`](./schemas/input-types.schema.json) at `$defs/Setting_<TypeName>`.
1102
-
1103
- The kernel exposes resolved settings to extractors via `ctx.settings.<settingId>`. Settings are read once at extractor invocation; **changing a setting requires `sm scan` to re-emit** affected contributions. The UI surfaces a "settings changed, rescan needed" indicator.
654
+ The kernel exposes resolved settings via `ctx.settings.<settingId>`. Settings are read once at extension invocation; **changing a setting requires `sm scan` to re-emit** affected contributions (the UI surfaces a "settings changed, rescan needed" indicator).
1104
655
 
1105
656
  ### Catalog version
1106
657
 
1107
- The catalog of slots and input-types evolves on its own cadence. Declare a semver range in your manifest:
1108
-
1109
- ```jsonc
1110
- { "catalogCompat": "^1.0.0" }
1111
- ```
1112
-
1113
- Independent of `specCompat` (the spec version range). Mismatch surfaces as `incompatible-catalog` plugin status; resolution is `sm plugins upgrade <id>`, which runs registered migrations from the kernel's closed migration registry. When auto-migration is impossible (a slot you used was removed entirely), the upgrade verb fails loud (CLI exit ≠ 0 + console message) and your manifest needs a manual edit.
658
+ The slot + input-type catalog evolves on its own cadence. `catalogCompat` (required in the manifest) is the semver range you tested against, independent of `specCompat`. A mismatch surfaces as `incompatible-catalog`; resolution is `sm plugins upgrade <id>`, which runs registered migrations from the kernel's closed registry. When auto-migration is impossible (a slot you used was removed), the upgrade verb fails loud and your manifest needs a manual edit.
1114
659
 
1115
- `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`.
660
+ ---
1116
661
 
1117
- ### Worked example, `acme/keyword-finder`
662
+ ## Testing your plugin
1118
663
 
1119
- Full plugin walkthrough:
664
+ Extensions are plain ESM modules with one entry point per kind; their inputs are well-typed context objects from `@skill-map/cli`. Unit-test without a kernel or DB: build a fake `ctx` literal, call the entry point, assert on what it captured.
1120
665
 
1121
- ```
1122
- plugins/acme-keyword-finder/
1123
- ├── plugin.json ← manifest with settings + catalogCompat
1124
- └── extractors/
1125
- └── keyword-finder/
1126
- └── index.js ← extract() with ctx.emitContribution
1127
- ```
666
+ ```javascript
667
+ import { test } from 'node:test';
668
+ import { strictEqual } from 'node:assert';
1128
669
 
1129
- `plugin.json`:
670
+ import extractor from '../extractors/my-extractor/index.js';
1130
671
 
1131
- ```jsonc
1132
- {
1133
- "id": "acme-keyword-finder",
1134
- "version": "1.0.0",
1135
- "specCompat": "^0.20.0",
1136
- "catalogCompat": "^1.0.0",
1137
- "settings": {
1138
- "keywords": {
1139
- "type": "string-list",
1140
- "label": "Keywords to track",
1141
- "default": ["TODO", "FIXME"],
1142
- "min": 1
1143
- }
1144
- }
1145
- }
672
+ test('emits one reference per [[ref:<name>]] token', async () => {
673
+ const links = [];
674
+ await extractor.extract({
675
+ node: { path: 'a.md', kind: 'skill', provider: 'claude' },
676
+ body: 'Talk to [[ref:architect]] or [[ref:sre]].',
677
+ frontmatter: {},
678
+ settings: {},
679
+ emitLink: (link) => links.push(link),
680
+ enrichNode: () => {},
681
+ emitContribution: () => {},
682
+ });
683
+ strictEqual(links.length, 2);
684
+ strictEqual(links[0].target, 'architect');
685
+ });
1146
686
  ```
1147
687
 
1148
- `extractors/keyword-finder/index.js`:
688
+ Analyzers take a `ctx` with `nodes`, `links`, and (if you assert on view contributions) an `emitContribution` spy, and return the issue array. Formatters take `{ nodes, links, issues }` and return a string. For probabilistic Actions, shape a fake `ctx.runner` that records the calls your test cares about. The public TypeScript types (`IExtractor`, `IAnalyzer`, `IFormatter`, the matching `*Context` types, `Node`, `Link`, `Issue`, ...) are re-exported from `@skill-map/cli`.
1149
689
 
1150
- ```js
1151
- export default {
1152
- id: 'keyword-finder',
1153
- kind: 'extractor',
1154
- version: '1.0.0',
1155
- description: 'Counts configured keywords per node.',
1156
- stability: 'stable',
1157
- emitsLinkKinds: [],
1158
- defaultConfidence: 'high',
1159
- scope: 'body',
1160
-
1161
- viewContributions: {
1162
- breakdown: {
1163
- slot: 'inspector.body.panel.breakdown',
1164
- label: 'Keyword hits',
1165
- emptyText: 'No matches.',
1166
- },
1167
- total: {
1168
- slot: 'card.footer.left',
1169
- icon: '🔍',
1170
- label: 'kw',
1171
- emitWhenEmpty: false,
1172
- },
1173
- },
1174
-
1175
- extract(ctx) {
1176
- const keywords = ctx.settings.keywords;
1177
- const perKeyword = Object.create(null);
1178
- let total = 0;
1179
-
1180
- for (const kw of keywords) {
1181
- const re = new RegExp(`\\b${escapeRegex(kw)}\\b`, 'gi');
1182
- const n = (ctx.body.match(re) ?? []).length;
1183
- perKeyword[kw] = n;
1184
- total += n;
1185
- }
1186
-
1187
- ctx.emitContribution('breakdown', {
1188
- entries: Object.entries(perKeyword).map(([label, value]) => ({ label, value })),
1189
- });
690
+ ---
1190
691
 
1191
- if (total > 0) {
1192
- ctx.emitContribution('total', { value: total });
1193
- }
1194
- },
1195
- };
692
+ ## Diagnostics
1196
693
 
1197
- function escapeRegex(s) {
1198
- return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1199
- }
1200
- ```
694
+ `sm plugins list` shows every discovered plugin with one of **seven** statuses. This is the first thing to check when a plugin doesn't behave.
1201
695
 
1202
- After `sm scan`, the UI surfaces:
696
+ | Status | Meaning | Common cause |
697
+ |---|---|---|
698
+ | `loaded` | manifest valid, compat satisfied, every extension imported and validated. | (none) |
699
+ | `disabled` | user toggled it off. Manifest parsed; extensions not imported; `scan_contributions` rows purged eagerly (UI chips disappear); KV / dedicated state preserved. | Intentional. |
700
+ | `incompatible-spec` | `semver.satisfies` failed against the installed spec. | Built against an older / newer spec. |
701
+ | `incompatible-catalog` | `catalogCompat` failed against the installed view-slots + input-types catalog. | Slot / input-type catalog moved; run `sm plugins upgrade <id>`. |
702
+ | `invalid-manifest` | `plugin.json` missing / unparseable / AJV-fails, OR the manifest carries `id` / `kind`, OR an extension declares an unknown `slot`. | Typo, missing required field, wrong shape. |
703
+ | `load-error` | manifest passed but an extension module failed to import or its export failed validation. | Wrong `kind` folder, runtime import error, bad storage schema. |
704
+ | `id-collision` | two plugins from different roots share a directory name. Both collided plugins get this status; no precedence. | Rename one and rerun. |
1203
705
 
1204
- - A `🔍 N` chip on every node's card (when `total > 0`).
1205
- - A "Keyword hits" panel in the inspector body for every node, with a horizontal bar chart per keyword.
706
+ `sm plugins doctor` runs the full load pass and exits `1` if any plugin is in a non-`loaded` / non-`disabled` state. Wire it into CI.
1206
707
 
1207
- The plugin author wrote zero UI code, zero CSS, zero HTML, zero JSON Schema, and zero renderer logic.
708
+ ---
1208
709
 
1209
- ### Scaffolder
710
+ ## Scaffolder
1210
711
 
1211
- Hand-writing the manifest is supported but discouraged. Run:
712
+ Hand-writing the manifest is supported (the spec is the source of truth) but discouraged. Run:
1212
713
 
1213
714
  ```sh
1214
715
  sm plugins create
1215
716
  ```
1216
717
 
1217
- The scaffolder walks you through the closed catalogs (settings + view contribution slots) and emits a complete plugin directory with manifest, extension stub, test scaffold, and README. Hand-writing remains valid because the spec is the source of truth, but the scaffolder catches invalid slot picks at author time, while a hand-written manifest only fails at load time.
1218
-
1219
- Companion verbs:
718
+ The scaffolder walks you through the closed catalogs (settings + view slots) and emits a complete plugin directory with manifest, extension stub, test scaffold, and README; it catches invalid slot picks at author time, while a hand-written manifest only fails at load. Companion verbs:
1220
719
 
1221
720
  - `sm plugins doctor`, surfaces `incompatible-catalog`, `invalid-manifest`, deprecated-slot usage.
1222
- - `sm plugins upgrade <id>`, applies catalog migrations registered in the kernel.
721
+ - `sm plugins upgrade <id>`, applies catalog migrations.
1223
722
  - `sm plugins slots list`, prints the catalog (slots + input-types), flags deprecated entries.
1224
723
 
1225
724
  ### Watch out for
1226
725
 
1227
- - **Pick exactly one slot per contribution.** The slot determines both the renderer and the payload shape. If you want the same data in two surfaces (e.g. card chip + inspector badge), declare two contributions in the manifest, one per slot, and emit twice.
1228
- - **Don't write JSON Schema.** Settings use `type` from the input-type catalog; view contributions use `slot` from the slot catalog.
1229
- - **Don't mutate payloads after emission.** The kernel validates and serializes at emit time; a plugin holding a reference to the emitted payload and mutating it later has undefined behavior.
1230
- - **Don't emit HTML.** `node-markdown` accepts markdown with a sanitized allow-list; `[innerHTML]` bindings in the renderer are lint-banned (see [`context/view-contributions.md`](../context/view-contributions.md)).
1231
- - **Don't try to read another plugin's contributions.** The BFF rejects cross-plugin reads at the route level.
726
+ - **Pick exactly one slot per contribution.** Same data in two surfaces = two contributions, emit twice.
727
+ - **Don't write JSON Schema** for settings (use `type`) or view contributions (use `slot`).
728
+ - **Don't mutate payloads after emission**, the kernel validates and serializes at emit time.
729
+ - **Don't emit HTML.** `inspector.body.panel.markdown` accepts a sanitized allow-list; `[innerHTML]` bindings are lint-banned in the UI (see [`context/view-slots.md`](../context/view-slots.md)).
730
+ - **Don't read another plugin's contributions**, the BFF rejects cross-plugin reads at the route level.
1232
731
 
1233
732
  ---
1234
733
 
1235
734
  ## See also
1236
735
 
1237
- - [`architecture.md`](./architecture.md), extension contract, ports, execution modes.
1238
- - [`plugin-kv-api.md`](./plugin-kv-api.md), Storage Mode A normative API.
1239
- - [`db-schema.md`](./db-schema.md), table catalog and migration analyzers (Mode B).
1240
- - [`schemas/plugins-registry.schema.json`](./schemas/plugins-registry.schema.json), normative manifest shape.
1241
- - [`schemas/extensions/*.schema.json`](./schemas/extensions), per-kind manifest schemas.
736
+ - [`architecture.md`](./architecture.md), normative extension contract, ports, execution modes, annotation + view contribution systems.
737
+ - [`view-slots.md`](./view-slots.md), canonical per-slot catalog reference.
738
+ - [`input-types.md`](./input-types.md), canonical per-input-type catalog reference.
739
+ - [`plugin-kv-api.md`](./plugin-kv-api.md), `ctx.store` contract (Storage Mode A + B).
740
+ - [`db-schema.md`](./db-schema.md), table catalog and migration rules (Mode B).
741
+ - [`schemas/plugins-registry.schema.json`](./schemas/plugins-registry.schema.json) and [`schemas/extensions/*.schema.json`](./schemas/extensions), normative manifest shapes.
1242
742
 
1243
743
  ---
1244
744
 
1245
745
  ## Stability
1246
746
 
1247
- - Document status: **stable** as of spec v1.0.0. Future minor revisions add new sections (e.g. Action / Hook testing patterns once Step 10 lands the job subsystem); breaking edits to the documented surface require a major bump per [`versioning.md`](./versioning.md).
1248
- - The six plugin statuses (`loaded` / `disabled` / `incompatible-spec` / `invalid-manifest` / `load-error` / `id-collision`) are stable; adding a seventh status is a minor bump.
1249
- - The structural analyzer **directory name MUST equal manifest id** is stable; relaxing it (allowing mismatch) is a major bump.
1250
- - The cross-root id-collision analyzer (both sides blocked, no precedence) is stable; introducing precedence (e.g. project root wins over global) is a major bump.
1251
- - The optional `applicableKinds` field on the Extractor manifest is stable as introduced. Adding a wildcard syntax (`'*'`) is a minor bump (additive, the existing "absent = all kinds" semantics keeps holding); changing the default away from "applies to every kind" or making the field required is a major bump. Promoting the unknown-kinds doctor warning to a hard load error is a major bump (today's contract is "load OK, surface as warning").
1252
- - The recommended `specCompat` strategy is descriptive prose; revising the recommendation does not require a spec bump as long as the schema stays unchanged.
1253
- - The example code blocks track the public TypeScript surface of `@skill-map/cli`; bumping their imports follows the cli's own semver.
747
+ - Document status: **descriptive prose**, tracks the manifest schemas. It does not freeze an independent contract; the schemas under [`schemas/`](./schemas/) and [`versioning.md`](./versioning.md) own stability.
748
+ - The seven plugin statuses (`loaded` / `disabled` / `incompatible-spec` / `incompatible-catalog` / `invalid-manifest` / `load-error` / `id-collision`) are the current load-status surface.
749
+ - Structure-as-truth invariants (directory name IS the plugin id; kind from the folder; Provider kind catalog on disk) and the cross-root id-collision rule (both sides blocked, no precedence) are settled; relaxing any of them is a breaking change per [`versioning.md`](./versioning.md).
750
+ - The example code blocks track the public TypeScript surface of `@skill-map/cli`; bumping their imports follows the CLI's own semver.