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