@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.
- package/CHANGELOG.md +55 -2307
- package/README.md +8 -11
- package/architecture.md +74 -51
- package/cli-contract.md +38 -9
- package/conformance/README.md +1 -1
- package/conformance/cases/extractor-emits-signal.json +1 -1
- package/conformance/cases/kernel-empty-boot.json +1 -1
- package/conformance/cases/no-global-scope.json +1 -1
- package/conformance/cases/orphan-markdown-fallback.json +1 -1
- package/conformance/cases/plugin-missing-ui-rejected.json +1 -1
- package/conformance/cases/sidecar-end-to-end.json +1 -1
- package/conformance/cases/signal-collision-detection.json +1 -1
- package/conformance/coverage.md +1 -1
- package/conformance/fixtures/sidecar-example/agent-example.sm +3 -3
- package/db-schema.md +21 -7
- package/index.json +56 -55
- package/package.json +3 -2
- package/plugin-author-guide.md +273 -776
- package/schemas/annotations.schema.json +2 -2
- package/schemas/api/rest-envelope.schema.json +1 -1
- package/schemas/bump-report.schema.json +1 -1
- package/schemas/conformance-case.schema.json +1 -1
- package/schemas/conformance-result.schema.json +1 -1
- package/schemas/execution-record.schema.json +1 -1
- package/schemas/extensions/action.schema.json +1 -1
- package/schemas/extensions/analyzer.schema.json +1 -1
- package/schemas/extensions/base.schema.json +1 -1
- package/schemas/extensions/extractor.schema.json +1 -1
- package/schemas/extensions/formatter.schema.json +1 -1
- package/schemas/extensions/hook.schema.json +1 -1
- package/schemas/extensions/provider-kind.schema.json +1 -1
- package/schemas/extensions/provider.schema.json +1 -1
- package/schemas/frontmatter/base.schema.json +2 -7
- package/schemas/history-stats.schema.json +1 -1
- package/schemas/input-types.schema.json +1 -1
- package/schemas/issue.schema.json +1 -1
- package/schemas/job.schema.json +1 -1
- package/schemas/link.schema.json +1 -1
- package/schemas/node.schema.json +1 -1
- package/schemas/plugins-doctor.schema.json +1 -1
- package/schemas/plugins-registry.schema.json +1 -1
- package/schemas/project-config.schema.json +1 -1
- package/schemas/refresh-report.schema.json +1 -1
- package/schemas/report-base-deterministic.schema.json +1 -1
- package/schemas/report-base.schema.json +1 -1
- package/schemas/scan-result.schema.json +1 -1
- package/schemas/sidecar.schema.json +1 -1
- package/schemas/signal.schema.json +1 -1
- package/schemas/summaries/agent.schema.json +1 -1
- package/schemas/summaries/command.schema.json +1 -1
- package/schemas/summaries/hook.schema.json +1 -1
- package/schemas/summaries/markdown.schema.json +1 -1
- package/schemas/summaries/skill.schema.json +1 -1
- package/schemas/user-settings.schema.json +32 -1
- package/schemas/view-slots.schema.json +1 -1
- package/telemetry.md +294 -0
- package/versioning.md +2 -2
package/plugin-author-guide.md
CHANGED
|
@@ -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
|
|
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.**
|
|
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
|
|
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
|
|
27
|
-
top-level dir, kind from the subfolder name, extension id from the
|
|
28
|
-
extension folder name. The manifest
|
|
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
|
|
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
|
|
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
|
|
43
|
-
(`plugins/**/*.test.ts`)
|
|
40
|
+
the colocated test suite, picked up by the workspace's test glob
|
|
41
|
+
(`plugins/**/*.test.ts`).
|
|
44
42
|
|
|
45
|
-
Both
|
|
46
|
-
`index.{js,mjs,ts}`, so future per-extension fixtures
|
|
47
|
-
|
|
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": "^
|
|
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
|
|
63
|
-
kind
|
|
64
|
-
version
|
|
65
|
-
|
|
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`
|
|
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
|
|
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
|
|
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
|
|
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`
|
|
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
|
|
103
|
-
2. **Cross-root id collisions are blocked, both sides.** If two plugins from different roots (project +
|
|
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
|
|
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 (
|
|
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
|
|
129
|
-
- **`claude/`**, the Claude Code Provider bundle: the Provider
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
162
|
-
-
|
|
163
|
-
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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[];
|
|
211
|
-
provider?: string[];
|
|
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.**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
**
|
|
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
|
-
|
|
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 (
|
|
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
|
|
283
|
-
| `catalogCompat` | semver range |
|
|
284
|
-
| `description` | string |
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
321
|
-
| `extractor` | `extract(ctx)` | one node + body + frontmatter + callbacks | `void` (
|
|
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 |
|
|
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` (
|
|
240
|
+
| `hook` | `on(ctx)` | a curated lifecycle event payload | `void` (side effects) | **deterministic only** |
|
|
326
241
|
|
|
327
|
-
The runtime instance you `export default`
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.**
|
|
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
|
|
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
|
-
|
|
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
|
|
290
|
+
const inbound = new Map();
|
|
395
291
|
for (const link of ctx.links) {
|
|
396
|
-
|
|
292
|
+
inbound.set(link.target, (inbound.get(link.target) ?? 0) + 1);
|
|
397
293
|
}
|
|
398
294
|
return ctx.nodes
|
|
399
|
-
.filter((n) => n.kind === 'skill' && (
|
|
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
|
-
>
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
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:
|
|
467
|
-
//
|
|
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
|
|
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
|
|
357
|
+
### Providers
|
|
490
358
|
|
|
491
|
-
|
|
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
|
-
|
|
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
|
-
|
|
496
|
-
|
|
497
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 |
|
|
382
|
+
| Tier | Mechanism | Behaviour on unknown / non-conforming fields |
|
|
541
383
|
|---|---|---|
|
|
542
|
-
| **0, Default permissive** | `additionalProperties: true` on `base
|
|
543
|
-
| **1, Built-in `unknown-field` analyzer** | Deterministic
|
|
544
|
-
| **2, Strict mode** |
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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"
|
|
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
|
|
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
|
-
|
|
422
|
+
### Opt-in write validation
|
|
680
423
|
|
|
681
|
-
|
|
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
|
-
**
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
441
|
+
## Annotation contribution
|
|
774
442
|
|
|
775
|
-
>
|
|
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
|
-
`
|
|
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/
|
|
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
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
821
|
-
|
|
822
|
-
|
|
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
|
-
|
|
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
|
|
492
|
+
// compliance-plugin/analyzers/compliance/index.js → contributes root key `compliance`
|
|
833
493
|
export default {
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
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.
|
|
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
|
-
|
|
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
|
-
>
|
|
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
|
|
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
|
-
"
|
|
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
|
|
947
|
-
| `label` | no | Short human-readable label. English-only
|
|
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` |
|
|
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
|
|
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
|
-
|
|
574
|
+
Prefix-discriminated by the UI resolver:
|
|
956
575
|
|
|
957
576
|
```jsonc
|
|
958
|
-
{ "icon": "🔍" }
|
|
959
|
-
{ "icon": "pi-search" }
|
|
960
|
-
{ "icon": "pi pi-search" }
|
|
961
|
-
{ "icon": "fa-solid fa-magnifying-glass" } // FontAwesome, explicit family
|
|
962
|
-
{ "icon": "fa-regular fa-star" }
|
|
963
|
-
{ "icon": "fa-brands fa-github" }
|
|
964
|
-
{ "icon": "fa-magnifying-glass" }
|
|
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
|
-
|
|
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
|
-
|
|
588
|
+
### Slot catalog (closed, 14 slots)
|
|
991
589
|
|
|
992
|
-
|
|
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
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
|
1002
|
-
|
|
1003
|
-
|
|
|
1004
|
-
|
|
|
1005
|
-
|
|
|
1006
|
-
|
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
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.
|
|
1018
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
628
|
+
---
|
|
1057
629
|
|
|
1058
|
-
|
|
630
|
+
## Settings
|
|
1059
631
|
|
|
1060
|
-
User-configurable settings live
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
660
|
+
---
|
|
1116
661
|
|
|
1117
|
-
|
|
662
|
+
## Testing your plugin
|
|
1118
663
|
|
|
1119
|
-
|
|
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
|
-
|
|
1123
|
-
|
|
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
|
-
|
|
670
|
+
import extractor from '../extractors/my-extractor/index.js';
|
|
1130
671
|
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
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
|
-
`
|
|
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
|
-
|
|
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
|
-
|
|
1192
|
-
ctx.emitContribution('total', { value: total });
|
|
1193
|
-
}
|
|
1194
|
-
},
|
|
1195
|
-
};
|
|
692
|
+
## Diagnostics
|
|
1196
693
|
|
|
1197
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
708
|
+
---
|
|
1208
709
|
|
|
1209
|
-
|
|
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
|
|
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
|
|
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.**
|
|
1228
|
-
- **Don't write JSON Schema
|
|
1229
|
-
- **Don't mutate payloads after emission
|
|
1230
|
-
- **Don't emit HTML.** `
|
|
1231
|
-
- **Don't
|
|
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
|
-
- [`
|
|
1239
|
-
- [`
|
|
1240
|
-
- [`
|
|
1241
|
-
- [`
|
|
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: **
|
|
1248
|
-
- The
|
|
1249
|
-
-
|
|
1250
|
-
- The
|
|
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.
|