@skill-map/spec 0.7.1 → 0.8.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 +676 -3
- package/README.md +11 -13
- package/architecture.md +118 -35
- package/cli-contract.md +38 -25
- package/conformance/README.md +42 -13
- package/conformance/cases/kernel-empty-boot.json +2 -2
- package/conformance/coverage.md +19 -23
- package/db-schema.md +61 -6
- package/index.json +34 -76
- package/job-events.md +75 -1
- package/package.json +1 -1
- package/plugin-author-guide.md +409 -51
- package/schemas/conformance-case.schema.json +3 -3
- package/schemas/execution-record.schema.json +8 -8
- package/schemas/extensions/action.schema.json +2 -2
- package/schemas/extensions/base.schema.json +5 -5
- package/schemas/extensions/extractor.schema.json +48 -0
- package/schemas/extensions/formatter.schema.json +29 -0
- package/schemas/extensions/hook.schema.json +44 -0
- package/schemas/extensions/provider.schema.json +51 -0
- package/schemas/extensions/rule.schema.json +1 -1
- package/schemas/frontmatter/base.schema.json +2 -2
- package/schemas/link.schema.json +4 -4
- package/schemas/node.schema.json +4 -4
- package/schemas/plugins-registry.schema.json +19 -4
- package/schemas/project-config.schema.json +2 -2
- package/schemas/scan-result.schema.json +3 -3
- package/conformance/cases/basic-scan.json +0 -17
- package/conformance/cases/orphan-detection.json +0 -22
- package/conformance/cases/rename-high.json +0 -21
- package/conformance/fixtures/minimal-claude/agents/reviewer.md +0 -16
- package/conformance/fixtures/minimal-claude/commands/status.md +0 -17
- package/conformance/fixtures/minimal-claude/hooks/pre-commit.md +0 -13
- package/conformance/fixtures/minimal-claude/notes/architecture.md +0 -11
- package/conformance/fixtures/minimal-claude/skills/hello.md +0 -22
- package/conformance/fixtures/orphan-after/skills/keep.md +0 -13
- package/conformance/fixtures/orphan-before/skills/keep.md +0 -13
- package/conformance/fixtures/orphan-before/skills/lonely.md +0 -13
- package/conformance/fixtures/rename-high-after/skills/bar.md +0 -14
- package/conformance/fixtures/rename-high-before/skills/foo.md +0 -14
- package/schemas/extensions/adapter.schema.json +0 -40
- package/schemas/extensions/audit.schema.json +0 -47
- package/schemas/extensions/detector.schema.json +0 -41
- package/schemas/extensions/renderer.schema.json +0 -29
- package/schemas/frontmatter/agent.schema.json +0 -17
- package/schemas/frontmatter/command.schema.json +0 -39
- package/schemas/frontmatter/hook.schema.json +0 -29
- package/schemas/frontmatter/note.schema.json +0 -11
- package/schemas/frontmatter/skill.schema.json +0 -37
package/plugin-author-guide.md
CHANGED
|
@@ -14,7 +14,7 @@ This guide is **descriptive prose**, not the normative contract. The normative p
|
|
|
14
14
|
my-plugin/
|
|
15
15
|
├── plugin.json ← manifest (required)
|
|
16
16
|
└── extensions/
|
|
17
|
-
└──
|
|
17
|
+
└── extractor.mjs ← one file per declared extension
|
|
18
18
|
```
|
|
19
19
|
|
|
20
20
|
```jsonc
|
|
@@ -23,22 +23,29 @@ my-plugin/
|
|
|
23
23
|
"id": "my-plugin",
|
|
24
24
|
"version": "1.0.0",
|
|
25
25
|
"specCompat": "^1.0.0",
|
|
26
|
-
"extensions": ["./extensions/
|
|
26
|
+
"extensions": ["./extensions/extractor.mjs"]
|
|
27
27
|
}
|
|
28
28
|
```
|
|
29
29
|
|
|
30
30
|
```javascript
|
|
31
|
-
// my-plugin/extensions/
|
|
31
|
+
// my-plugin/extensions/extractor.mjs
|
|
32
32
|
export default {
|
|
33
|
-
id: 'my-
|
|
34
|
-
kind: '
|
|
33
|
+
id: 'my-extractor',
|
|
34
|
+
kind: 'extractor',
|
|
35
35
|
version: '1.0.0',
|
|
36
36
|
emitsLinkKinds: ['references'],
|
|
37
37
|
defaultConfidence: 'high',
|
|
38
38
|
scope: 'body',
|
|
39
|
-
|
|
40
|
-
// ctx.node, ctx.body, ctx.frontmatter
|
|
41
|
-
|
|
39
|
+
extract(ctx) {
|
|
40
|
+
// ctx.node, ctx.body, ctx.frontmatter, ctx.emitLink, ctx.enrichNode
|
|
41
|
+
// Output flows through the callbacks; the method returns void.
|
|
42
|
+
ctx.emitLink({
|
|
43
|
+
source: ctx.node.path,
|
|
44
|
+
target: 'something.md',
|
|
45
|
+
kind: 'references',
|
|
46
|
+
confidence: 'high',
|
|
47
|
+
sources: ['my-extractor'],
|
|
48
|
+
});
|
|
42
49
|
},
|
|
43
50
|
};
|
|
44
51
|
```
|
|
@@ -56,7 +63,145 @@ The kernel scans two roots, in this order:
|
|
|
56
63
|
|
|
57
64
|
A plugin is any direct child directory containing a `plugin.json`. Nested directories are not searched recursively. Pass `--plugin-dir <path>` to override both roots (mostly for testing).
|
|
58
65
|
|
|
59
|
-
After every change to the `plugins/` folder, run `sm plugins list` to see the load status of each. The
|
|
66
|
+
After every change to the `plugins/` folder, run `sm plugins list` to see the load status of each. The six statuses are documented under [Diagnostics](#diagnostics) below.
|
|
67
|
+
|
|
68
|
+
### Plugin id uniqueness
|
|
69
|
+
|
|
70
|
+
The `id` declared in `plugin.json` is **globally unique** across every active discovery root. The kernel enforces this in two places:
|
|
71
|
+
|
|
72
|
+
1. **Directory name MUST equal manifest id.** A plugin lives at `<root>/<id>/plugin.json`. If `basename(<plugin-dir>) !== manifest.id`, discovery surfaces the plugin with status `invalid-manifest` and a reason naming both names. This rule eliminates same-root collisions by construction (a filesystem cannot host two siblings with the same name).
|
|
73
|
+
2. **Cross-root id collisions are blocked, both sides.** If two plugins from different roots (project + global, or any combination of `--plugin-dir`) declare the same `id`, **both** receive status `id-collision`. There is no precedence rule — neither plugin loads its extensions; the user resolves the conflict by renaming one and rerunning. Coherent with the spec rule that no extension is privileged.
|
|
74
|
+
|
|
75
|
+
`sm plugins list` shows the conflict; `sm plugins doctor` exits `1` whenever any `id-collision` is present.
|
|
76
|
+
|
|
77
|
+
### Qualified extension ids
|
|
78
|
+
|
|
79
|
+
Every extension is identified in the registry — and in any cross-extension reference — by its **qualified id** `<plugin-id>/<extension-id>`. The plugin's manifest `id` is therefore not just a discovery key: it doubles as the **namespace** for every extension the plugin ships.
|
|
80
|
+
|
|
81
|
+
Concrete examples for the reference impl's bundled extensions:
|
|
82
|
+
|
|
83
|
+
| Extension | Short id (in the file) | Qualified id (in the registry) |
|
|
84
|
+
|---|---|---|
|
|
85
|
+
| Claude Provider | `claude` | `claude/claude` |
|
|
86
|
+
| Frontmatter extractor | `frontmatter` | `claude/frontmatter` |
|
|
87
|
+
| Slash extractor | `slash` | `claude/slash` |
|
|
88
|
+
| At-directive extractor | `at-directive` | `claude/at-directive` |
|
|
89
|
+
| External-URL counter | `external-url-counter` | `core/external-url-counter` |
|
|
90
|
+
| Broken-ref rule | `broken-ref` | `core/broken-ref` |
|
|
91
|
+
| Trigger-collision rule | `trigger-collision` | `core/trigger-collision` |
|
|
92
|
+
| ASCII formatter | `ascii` | `core/ascii` |
|
|
93
|
+
| Validate-all rule | `validate-all` | `core/validate-all` |
|
|
94
|
+
|
|
95
|
+
Two namespaces are convention for built-ins:
|
|
96
|
+
|
|
97
|
+
- **`core/`** — kernel-internal primitives (every built-in rule including `validate-all`, the ASCII formatter, the external-URL counter extractor). Platform-agnostic.
|
|
98
|
+
- **`claude/`** — the Claude Code Provider bundle (the Provider plus the three extractors that decode Claude-specific syntax: frontmatter, slash, `@`-directive).
|
|
99
|
+
|
|
100
|
+
For your own plugin, the `id` you declare in `plugin.json` is the namespace for every extension the plugin contains. If your manifest declares `id: "my-plugin"` and your extension file declares `id: "foo-extractor"`, the kernel registers it as `my-plugin/foo-extractor`. You do **not** write the qualifier yourself — the loader injects it.
|
|
101
|
+
|
|
102
|
+
What this means in practice:
|
|
103
|
+
|
|
104
|
+
- **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).
|
|
105
|
+
- **In the manifest's `extensions[]`**, list relative paths to extension files as before — nothing changes.
|
|
106
|
+
- **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>`.
|
|
107
|
+
- **`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).
|
|
108
|
+
- **`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.
|
|
109
|
+
|
|
110
|
+
The kernel guards against two foot-guns:
|
|
111
|
+
|
|
112
|
+
- If the extension file injects a `pluginId` field that doesn't match `plugin.json#/id`, the loader emits `invalid-manifest` with a directed reason. The composed qualifier MUST come from `plugin.json` — there is no second source of truth.
|
|
113
|
+
- The kebab-case pattern on the extension `id` deliberately forbids `/`. This keeps the rule "the qualifier always lives in the plugin id, never in the extension id" enforced by AJV.
|
|
114
|
+
|
|
115
|
+
For built-ins, the reference impl's `src/extensions/built-ins.ts` declares each extension's `pluginId` (`core` or `claude`) explicitly — built-ins do not have a `plugin.json`, so the bundle declaration IS the source of truth for their namespace.
|
|
116
|
+
|
|
117
|
+
### Granularity — bundle vs extension
|
|
118
|
+
|
|
119
|
+
Every plugin and every built-in bundle declares a **granularity** that controls how its extensions are toggled by `sm plugins enable / disable` and by `config_plugins` / `settings.json`. Two modes:
|
|
120
|
+
|
|
121
|
+
| Granularity | Toggle key | When to use |
|
|
122
|
+
|---|---|---|
|
|
123
|
+
| `bundle` (default) | the bundle id alone (e.g. `my-plugin`, `claude`) | The plugin's extensions form a coherent product (e.g. a Provider and the extractors that decode its native syntax). The user wants one switch. **95% of plugins.** |
|
|
124
|
+
| `extension` | the qualified extension id (`<bundle>/<ext-id>`, e.g. `core/superseded`, `my-plugin/orphan-skill`) | The plugin ships several orthogonal capabilities a user might reasonably want piecemeal. **Built-in `core` is the canonical example** — the spec promises every kernel built-in is removable, so each one toggles independently. |
|
|
125
|
+
|
|
126
|
+
Built-in mapping:
|
|
127
|
+
|
|
128
|
+
- **`claude`** — `granularity: 'bundle'`. `sm plugins disable claude` flips the Provider and the three Claude-specific extractors at once.
|
|
129
|
+
- **`core`** — `granularity: 'extension'`. `sm plugins disable core/superseded` flips just the supersession rule; the other six core extensions (the four other rules, the ASCII formatter, the external-URL counter extractor) stay live.
|
|
130
|
+
|
|
131
|
+
Per-verb behaviour:
|
|
132
|
+
|
|
133
|
+
| Command | Bundle granularity | Extension granularity |
|
|
134
|
+
|---|---|---|
|
|
135
|
+
| `sm plugins enable claude` | OK — flips the bundle. | Rejected: `'core' has granularity=extension; use sm plugins enable core/<ext-id>`. |
|
|
136
|
+
| `sm plugins enable claude/slash` | Rejected: `'claude' has granularity=bundle; use sm plugins enable claude`. | n/a (no bundle of granularity=bundle accepts qualified ids) |
|
|
137
|
+
| `sm plugins disable core` | n/a | Rejected: same directed message as the bundle row above. |
|
|
138
|
+
| `sm plugins disable core/superseded` | n/a | OK — persists `config_plugins['core/superseded'].enabled = 0`. |
|
|
139
|
+
|
|
140
|
+
Resolution order is the same as for plugin enabled-state: DB override (`config_plugins`) > settings.json (`#/plugins/<id>/enabled`) > installed default (`true`). For granularity=extension bundles the row key is the qualified id; for granularity=bundle bundles the row key is the bundle id. `settings.json#/plugins` keys are arbitrary strings (no AJV pattern), so both forms are accepted there too.
|
|
141
|
+
|
|
142
|
+
`sm plugins enable/disable --all` operates only on top-level bundle ids (the default-enabled set every user can see); it never expands to qualified `<bundle>/<ext>` keys. The "disable every kernel built-in at once" intent is served by `--no-built-ins` on `sm scan` and friends; `--all` is the macro on user-toggle-able units, not on every individual extension.
|
|
143
|
+
|
|
144
|
+
In your own plugin's `plugin.json`, set `granularity` only when you opt into the per-extension form:
|
|
145
|
+
|
|
146
|
+
```jsonc
|
|
147
|
+
{
|
|
148
|
+
"id": "my-multi-tool",
|
|
149
|
+
"version": "1.0.0",
|
|
150
|
+
"specCompat": "^1.0.0",
|
|
151
|
+
"granularity": "extension",
|
|
152
|
+
"extensions": [
|
|
153
|
+
"./extensions/orphan-skill-rule.mjs",
|
|
154
|
+
"./extensions/csv-formatter.mjs"
|
|
155
|
+
]
|
|
156
|
+
}
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
The default (`'bundle'`) is the right answer for almost every plugin — keep the manifest minimal until the plugin actually ships several independent capabilities.
|
|
160
|
+
|
|
161
|
+
### Extractor `applicableKinds` — narrow the pipeline
|
|
162
|
+
|
|
163
|
+
An `Extractor` extension MAY declare an `applicableKinds` array on its manifest. When declared, the kernel runs the extractor **only** against nodes whose `kind` is in the list — the filter is fail-fast (no extractor context, no method call) so a probabilistic extractor wastes zero LLM cost (and a deterministic extractor zero CPU) on nodes it cannot meaningfully process.
|
|
164
|
+
|
|
165
|
+
| `applicableKinds` | Behaviour |
|
|
166
|
+
|---|---|
|
|
167
|
+
| Absent (`undefined`) | **Default.** The extractor runs on every kind the loaded Providers emit. |
|
|
168
|
+
| `['skill']` | Runs only on skill nodes. |
|
|
169
|
+
| `['skill', 'agent']` | Runs on skills + agents. Hooks, commands, notes are skipped. |
|
|
170
|
+
| `[]` | **Invalid.** AJV rejects the manifest at load time (`minItems: 1`). The absence of the field already means "every kind"; an empty array is reserved for "this is a typo". |
|
|
171
|
+
|
|
172
|
+
There is no wildcard syntax (no `'*'`) — omitting the field IS the wildcard. The pattern is intentional: a literal absence is unambiguous, a string sentinel would invite typos that silently disable the extractor.
|
|
173
|
+
|
|
174
|
+
Use case — a probabilistic tag-inferrer that only makes sense for skills:
|
|
175
|
+
|
|
176
|
+
```javascript
|
|
177
|
+
export default {
|
|
178
|
+
id: 'tag-inferrer',
|
|
179
|
+
kind: 'extractor',
|
|
180
|
+
mode: 'probabilistic',
|
|
181
|
+
version: '1.0.0',
|
|
182
|
+
description: 'LLM-derived tag links for skill nodes.',
|
|
183
|
+
emitsLinkKinds: ['references'],
|
|
184
|
+
defaultConfidence: 'medium',
|
|
185
|
+
scope: 'body',
|
|
186
|
+
applicableKinds: ['skill'],
|
|
187
|
+
async extract(ctx) {
|
|
188
|
+
// Never invoked for agents, commands, hooks, or notes — the kernel
|
|
189
|
+
// skipped this node before reaching us.
|
|
190
|
+
const tags = await ctx.runner.invoke({ /* prompt … */ });
|
|
191
|
+
for (const t of tags) {
|
|
192
|
+
ctx.emitLink({
|
|
193
|
+
source: ctx.node.path,
|
|
194
|
+
target: t.path,
|
|
195
|
+
kind: 'references',
|
|
196
|
+
confidence: 'medium',
|
|
197
|
+
sources: ['tag-inferrer'],
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
**Unknown kinds are non-blocking.** An extractor that lists a kind no installed Provider declares (typo, missing Provider plugin) still loads with status `loaded`; `sm plugins doctor` surfaces an informational warning so the author sees the mismatch. The exit code of `doctor` is NOT promoted to 1 by this warning — the corresponding Provider may legitimately arrive later (e.g. when the user installs the matching plugin), and the load contract favours forward compatibility over rigid checks. The full set of "known kinds" is the union of every installed Provider's `defaultRefreshAction` keys.
|
|
60
205
|
|
|
61
206
|
---
|
|
62
207
|
|
|
@@ -76,6 +221,7 @@ Optional fields:
|
|
|
76
221
|
| Field | Type | Notes |
|
|
77
222
|
|---|---|---|
|
|
78
223
|
| `description` | string | One-line summary shown in `sm plugins list`. |
|
|
224
|
+
| `granularity` | `'bundle' \| 'extension'` | Controls how `sm plugins enable / disable` operates on this plugin. Default `'bundle'`. See [Granularity — bundle vs extension](#granularity--bundle-vs-extension). |
|
|
79
225
|
| `storage` | object | `{ "mode": "kv" }` or `{ "mode": "dedicated", "tables": [...], "migrations": [...] }`. Absent means the plugin does not persist state. |
|
|
80
226
|
| `author` | string | Free-form. |
|
|
81
227
|
| `license` | string | SPDX identifier. |
|
|
@@ -102,50 +248,65 @@ The kernel knows six categories. Four are dual-mode (deterministic or probabilis
|
|
|
102
248
|
|
|
103
249
|
| Kind | Method | Receives | Returns | Mode |
|
|
104
250
|
|---|---|---|---|---|
|
|
105
|
-
| `
|
|
106
|
-
| `
|
|
251
|
+
| `provider` | `walk(roots, opts)` | filesystem roots | `IRawNode[]` | deterministic only |
|
|
252
|
+
| `extractor` | `extract(ctx)` | one node + body + frontmatter + callbacks | `void` (output via `ctx.emitLink` / `ctx.enrichNode` / `ctx.store`) | dual-mode |
|
|
107
253
|
| `rule` | `evaluate(ctx)` | full graph | `Issue[]` | dual-mode |
|
|
108
254
|
| `action` | `run(ctx)` | one or more nodes | execution record | dual-mode |
|
|
109
|
-
| `
|
|
110
|
-
| `
|
|
255
|
+
| `formatter` | `format(ctx)` | full graph | `string` | deterministic only |
|
|
256
|
+
| `hook` | `on(ctx)` | a curated lifecycle event payload | `void` (reactions are side effects) | dual-mode |
|
|
257
|
+
|
|
258
|
+
The runtime instance you `export default` from an extension file MUST include both the manifest fields (id, kind, version, plus kind-specific metadata) AND the runtime method. The kernel strips function-typed properties before AJV-validating the manifest shape, so `extract` / `evaluate` / etc. live alongside metadata without confusing the schema.
|
|
259
|
+
|
|
260
|
+
### Extractors
|
|
111
261
|
|
|
112
|
-
|
|
262
|
+
Pure single-node analysis. **Never** read another node, the graph, or the database — cross-node reasoning is for rules. Spec at [`schemas/extensions/extractor.schema.json`](./schemas/extensions/extractor.schema.json).
|
|
113
263
|
|
|
114
|
-
|
|
264
|
+
The runtime method is `extract(ctx) → void`. Output flows through three callbacks the kernel binds onto the context:
|
|
115
265
|
|
|
116
|
-
|
|
266
|
+
- **`ctx.emitLink(link)`** — append a `Link` to the kernel's `links` table. The kernel validates against the extractor's declared `emitsLinkKinds` before persistence; off-contract kinds are dropped and surface as `extension.error` events. URL-shaped targets are partitioned into `node.externalRefsCount` and never persisted.
|
|
267
|
+
- **`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 from probabilistic extractors). Probabilistic enrichments track `body_hash_at_enrichment`; when the scan loop sees a body change, those rows are flagged `stale = 1` (NOT deleted, preserving the LLM cost paid to produce them) and surface for refresh via `sm refresh <node>` or `sm refresh --stale`. Deterministic enrichments simply pisar via PRIMARY KEY conflict on the next re-extract through the A.9 cache and are never stale-flagged.
|
|
268
|
+
- **`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).
|
|
117
269
|
|
|
118
|
-
|
|
270
|
+
A probabilistic extractor additionally receives `ctx.runner` (the `RunnerPort`) for LLM dispatch.
|
|
271
|
+
|
|
272
|
+
> **Pick a syntax that doesn't collide with built-ins.** The built-in `at-directive` extractor fires on any `@token`; the built-in `slash` extractor fires on any `/token`. A new extractor that also matches one of those prefixes will likely fire on the same input, and if the two emit different `target` shapes the kernel raises a `trigger-collision` error. The example below uses a wikilink-style `[[ref:<name>]]` pattern to side-step this; reserve `@` and `/` for the built-ins.
|
|
119
273
|
|
|
120
274
|
```javascript
|
|
121
275
|
import { normalizeTrigger } from '@skill-map/cli';
|
|
122
276
|
|
|
123
277
|
export default {
|
|
124
|
-
id: 'ref-
|
|
125
|
-
kind: '
|
|
278
|
+
id: 'ref-extractor',
|
|
279
|
+
kind: 'extractor',
|
|
126
280
|
version: '1.0.0',
|
|
127
|
-
description: '
|
|
281
|
+
description: 'Extracts [[ref:<name>]] tokens from the body.',
|
|
128
282
|
stability: 'experimental',
|
|
129
283
|
emitsLinkKinds: ['references'],
|
|
130
284
|
defaultConfidence: 'medium',
|
|
131
285
|
scope: 'body',
|
|
132
|
-
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
286
|
+
extract(ctx) {
|
|
287
|
+
for (const m of ctx.body.matchAll(/\[\[ref:([a-z0-9-]+)\]\]/gi)) {
|
|
288
|
+
ctx.emitLink({
|
|
289
|
+
source: ctx.node.path,
|
|
290
|
+
target: m[1],
|
|
291
|
+
kind: 'references',
|
|
292
|
+
confidence: 'medium',
|
|
293
|
+
sources: ['ref-extractor'],
|
|
294
|
+
trigger: { originalTrigger: m[0], normalizedTrigger: m[0].toLowerCase() },
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
// Optional: emit a canonical title onto the enrichment layer.
|
|
298
|
+
// ctx.enrichNode({ title: 'Computed title' });
|
|
142
299
|
},
|
|
143
300
|
};
|
|
144
301
|
```
|
|
145
302
|
|
|
303
|
+
> **Migration note (spec 0.8.x).** This kind was previously named `Detector` with a `detect(ctx) → Link[]` signature. The rename to `Extractor` and the move to callback-based output landed as a single breaking minor in the pre-1.0 line. The mechanical migration: rename `kind: 'detector'` → `kind: 'extractor'`, rename `detect` → `extract`, replace `return links` with `for (const l of links) ctx.emitLink(l)`. The `applicableKinds`, `emitsLinkKinds`, `defaultConfidence`, and `scope` fields are unchanged.
|
|
304
|
+
|
|
146
305
|
### Rules
|
|
147
306
|
|
|
148
|
-
Cross-node reasoning over the merged graph. Run after every
|
|
307
|
+
Cross-node reasoning over the merged graph. Run after every Provider and extractor has completed. Spec at [`schemas/extensions/rule.schema.json`](./schemas/extensions/rule.schema.json).
|
|
308
|
+
|
|
309
|
+
Rules are dual-mode (`deterministic` default; `probabilistic` opt-in via the manifest). Deterministic rules run synchronously inside `sm scan` / `sm check` — same CI-safe baseline as today. Probabilistic rules are dispatched as queued jobs via the kernel's `RunnerPort`; they NEVER participate in the deterministic scan-time pipeline. Until the job subsystem ships at Step 10 the dispatch is stubbed: `sm scan` always skips probabilistic rules silently, and `sm check` exposes them via the opt-in `--include-prob` flag — the verb loads the plugin runtime, finds the registered prob rules (filtered by `--rules` and `-n` if set), and emits a stderr advisory naming them. The flag default is unchanged: deterministic-only, CI-safe. The `--async` companion is reserved for the future encoding (returns job ids without waiting once jobs land); today it is a no-op the advisory simply mentions. The flag does NOT extend to `sm scan` or `sm list`.
|
|
149
310
|
|
|
150
311
|
```javascript
|
|
151
312
|
export default {
|
|
@@ -170,18 +331,20 @@ export default {
|
|
|
170
331
|
};
|
|
171
332
|
```
|
|
172
333
|
|
|
173
|
-
###
|
|
334
|
+
### Formatters
|
|
335
|
+
|
|
336
|
+
Graph-to-string serializers. Invoked by `sm graph --format <name>`. Output **MUST** be byte-deterministic for the same input graph (the snapshot-test suite relies on this). Spec at [`schemas/extensions/formatter.schema.json`](./schemas/extensions/formatter.schema.json).
|
|
174
337
|
|
|
175
|
-
|
|
338
|
+
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.
|
|
176
339
|
|
|
177
340
|
```javascript
|
|
178
341
|
export default {
|
|
179
|
-
id: 'csv-
|
|
180
|
-
kind: '
|
|
342
|
+
id: 'csv-formatter',
|
|
343
|
+
kind: 'formatter',
|
|
181
344
|
version: '1.0.0',
|
|
182
|
-
|
|
345
|
+
formatId: 'csv',
|
|
183
346
|
contentType: 'text/csv',
|
|
184
|
-
|
|
347
|
+
format(ctx) {
|
|
185
348
|
const rows = ['source,target,kind,confidence'];
|
|
186
349
|
for (const link of ctx.links) {
|
|
187
350
|
rows.push([link.source, link.target, link.kind, link.confidence].join(','));
|
|
@@ -191,10 +354,155 @@ export default {
|
|
|
191
354
|
};
|
|
192
355
|
```
|
|
193
356
|
|
|
194
|
-
###
|
|
357
|
+
### Hooks
|
|
358
|
+
|
|
359
|
+
Declarative subscribers to a curated set of kernel lifecycle events. Use case: notification (Slack on `job.completed`), integration glue (CI webhook on `job.failed`), and bookkeeping (per-extractor metrics). Spec at [`schemas/extensions/hook.schema.json`](./schemas/extensions/hook.schema.json) and the trigger semantics at [`architecture.md` §Hook · curated trigger set](./architecture.md#hook--curated-trigger-set).
|
|
360
|
+
|
|
361
|
+
The runtime method is `on(ctx) → void`. The hook reacts to events; it cannot mutate the pipeline or alter outputs. Errors are caught by the kernel's dispatcher (logged as `extension.error` with `kind: 'hook-error'`) and NEVER block the main flow — a buggy hook degrades gracefully.
|
|
362
|
+
|
|
363
|
+
The eight hookable triggers (declaring any other event yields `invalid-manifest` at load time):
|
|
364
|
+
|
|
365
|
+
1. `scan.started` — pre-scan setup (one per scan).
|
|
366
|
+
2. `scan.completed` — post-scan reaction (one per scan).
|
|
367
|
+
3. `extractor.completed` — aggregated per-Extractor outputs.
|
|
368
|
+
4. `rule.completed` — aggregated per-Rule outputs.
|
|
369
|
+
5. `action.completed` — Action executed on a node.
|
|
370
|
+
6. `job.spawning` — pre-spawn of runner subprocess (Step 10).
|
|
371
|
+
7. `job.completed` — most common trigger (Step 10).
|
|
372
|
+
8. `job.failed` — alerts, retry triggers (Step 10).
|
|
373
|
+
|
|
374
|
+
```javascript
|
|
375
|
+
export default {
|
|
376
|
+
id: 'slack-notifier',
|
|
377
|
+
kind: 'hook',
|
|
378
|
+
version: '1.0.0',
|
|
379
|
+
description: 'Posts to Slack when a scan completes with issues.',
|
|
380
|
+
triggers: ['scan.completed'],
|
|
381
|
+
// Optional: only fire when the scan actually surfaced issues.
|
|
382
|
+
// Filter keys are top-level event.data fields; values are literal matches.
|
|
383
|
+
// filter: { issuesCount: 0 } — example only; this hook fires on every scan.
|
|
384
|
+
async on(ctx) {
|
|
385
|
+
const stats = ctx.event.data?.stats;
|
|
386
|
+
if (!stats || stats.issuesCount === 0) return;
|
|
387
|
+
await fetch(process.env.SLACK_WEBHOOK_URL, {
|
|
388
|
+
method: 'POST',
|
|
389
|
+
headers: { 'content-type': 'application/json' },
|
|
390
|
+
body: JSON.stringify({
|
|
391
|
+
text: `skill-map scan finished with ${stats.issuesCount} issue(s) in ${stats.durationMs} ms.`,
|
|
392
|
+
}),
|
|
393
|
+
});
|
|
394
|
+
},
|
|
395
|
+
};
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
> **Filter narrows fan-out, not the trigger enum.** `filter` is a runtime predicate over the event payload — it does NOT extend the hookable trigger set. Declaring `triggers: ['scan.progress']` is rejected at load time regardless of any filter, because `scan.progress` is intentionally non-hookable (per-node fan-out is too verbose for a reactive surface).
|
|
399
|
+
|
|
400
|
+
> **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.
|
|
401
|
+
|
|
402
|
+
> **What hooks CANNOT do.** Hooks REACT to events; they cannot block emission, mutate the graph, alter Extractor / Rule output, or enrich nodes. For graph mutations use `extractor.enrichNode`; for graph reasoning use a Rule; 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.
|
|
403
|
+
|
|
404
|
+
### Providers / Actions
|
|
195
405
|
|
|
196
406
|
These ship later in the v1.x line as bundled built-ins; the spec already pins their manifest shapes. Until the testkit grows full helpers for them (planned alongside Step 10), authors are encouraged to test them with a live kernel via `sm scan` against a fixture directory rather than in unit tests.
|
|
197
407
|
|
|
408
|
+
#### Provider — `kinds` catalog and `explorationDir`
|
|
409
|
+
|
|
410
|
+
Every Provider declares two required fields beyond the manifest base.
|
|
411
|
+
|
|
412
|
+
**`kinds` catalog.** Maps each kind the Provider emits to its frontmatter schema (path relative to the Provider's package directory) and its qualified `defaultRefreshAction`. The catalog is the single source of truth for "which kinds does this Provider emit"; the kernel derives the supported kind set from `Object.keys(kinds)`. The schema MUST extend the spec's universal [`schemas/frontmatter/base.schema.json`](./schemas/frontmatter/base.schema.json) via `allOf` + `$ref` to base's `$id`, so cross-package resolution works without copying base into every Provider.
|
|
413
|
+
|
|
414
|
+
**`explorationDir`.** Filesystem directory the kernel walks at boot/scan time to discover candidate files; `sm doctor` checks the resolved path exists and emits a non-blocking warning when it does not — the user may legitimately install the matching platform later.
|
|
415
|
+
|
|
416
|
+
```jsonc
|
|
417
|
+
{
|
|
418
|
+
"id": "cursor",
|
|
419
|
+
"kind": "provider",
|
|
420
|
+
"version": "1.0.0",
|
|
421
|
+
"explorationDir": "~/.cursor",
|
|
422
|
+
"kinds": {
|
|
423
|
+
"skill": {
|
|
424
|
+
"schema": "./schemas/skill.schema.json",
|
|
425
|
+
"defaultRefreshAction": "cursor/summarize-skill"
|
|
426
|
+
},
|
|
427
|
+
"command": {
|
|
428
|
+
"schema": "./schemas/command.schema.json",
|
|
429
|
+
"defaultRefreshAction": "cursor/summarize-command"
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
Bare `~` and `~/...` prefixes in `explorationDir` resolve against the current user's home (the same convention the shell applies); relative paths fall back to the cwd. Keep `explorationDir` short and platform-canonical; the doctor warning is the only place the user sees the field, so misleading values create confusion later.
|
|
436
|
+
|
|
437
|
+
> **Migration note (spec 0.8.x).** Pre-0.8 Providers declared two separate fields, `emits: string[]` and a flat `defaultRefreshAction: { <kind>: actionId }`. Both collapsed into the `kinds` map in 0.8.0 (Phase 3 of plug-in model overhaul); the per-kind frontmatter schema (which previously lived under `spec/schemas/frontmatter/<kind>.schema.json`) joined the same map entry. Migration: drop `emits` (replaced by `Object.keys(kinds)`); move each `defaultRefreshAction[<kind>]` value into `kinds[<kind>].defaultRefreshAction`; ship your per-kind schemas inside the plugin package and reference them via `kinds[<kind>].schema`.
|
|
438
|
+
|
|
439
|
+
---
|
|
440
|
+
|
|
441
|
+
## Frontmatter validation — three-tier model
|
|
442
|
+
|
|
443
|
+
The kernel validates frontmatter on a graduated dial; tighter is opt-in. The model is normative — every conforming implementation MUST honour the three tiers — but the policy lives in **rules**, not the JSON Schemas. The schemas stay shape-only ([`schemas/frontmatter/base.schema.json`](./schemas/frontmatter/base.schema.json) declares `additionalProperties: true` deliberately) so that authors can extend their own nodes without forking the spec. Per-kind frontmatter schemas live with the **Provider** that emits the kind (declared via `provider.kinds[<kind>].schema`); spec only ships the universal `base`.
|
|
444
|
+
|
|
445
|
+
| Tier | Mechanism | Behavior on unknown / non-conforming fields |
|
|
446
|
+
|---|---|---|
|
|
447
|
+
| **0 — Default permissive** | `additionalProperties: true` on `base.schema.json` and on every per-kind frontmatter schema declared by an installed Provider. | Field passes silently, persists in `node.frontmatter`, and is available to every extension (extractors, rules, actions, formatters). |
|
|
448
|
+
| **1 — Built-in `unknown-field` rule** | Deterministic Rule shipped with the kernel. Always active. | Emits an Issue with `severity: 'warn'` for every key outside the documented catalog (base + the matched kind's schema). |
|
|
449
|
+
| **2 — Strict mode** | [`schemas/project-config.schema.json`](./schemas/project-config.schema.json) `scan.strict: true` (team default in `settings.json`); also via `--strict` on `sm scan`. | Promotes **all** frontmatter warnings to `severity: 'error'`. They persist in the DB; `sm check` then exits `1` on the next read. CI fails. |
|
|
450
|
+
|
|
451
|
+
> Tier 1 is normative behavior — the kernel ships the rule 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).
|
|
452
|
+
|
|
453
|
+
### Worked example — same node, three tiers
|
|
454
|
+
|
|
455
|
+
Starting frontmatter on a skill node:
|
|
456
|
+
|
|
457
|
+
```yaml
|
|
458
|
+
---
|
|
459
|
+
name: code-reviewer
|
|
460
|
+
description: Reviews diffs against repo conventions.
|
|
461
|
+
metadata:
|
|
462
|
+
version: 1.0.0
|
|
463
|
+
priority: high # ← author-defined, not in any schema
|
|
464
|
+
---
|
|
465
|
+
```
|
|
466
|
+
|
|
467
|
+
**Tier 0 (default permissive — no project config, default scan).** The field validates fine. `node.frontmatter.priority === 'high'` for any extractor / rule / action that reads the node. No issues raised by the schema itself.
|
|
468
|
+
|
|
469
|
+
**Tier 1 (always-active `unknown-field` rule).** After `sm scan`, the rule emits:
|
|
470
|
+
|
|
471
|
+
```jsonc
|
|
472
|
+
{
|
|
473
|
+
"ruleId": "unknown-field",
|
|
474
|
+
"severity": "warn",
|
|
475
|
+
"message": "Unknown frontmatter field 'priority' on skill node 'code-reviewer'. Add it to a custom rule or move it under metadata.* if intentional.",
|
|
476
|
+
"nodeIds": ["code-reviewer.md"]
|
|
477
|
+
}
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
`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` rule does not match — or accept the persistent warning and add a Rule that consumes `priority` for whatever cross-node logic motivated the field.
|
|
481
|
+
|
|
482
|
+
**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.
|
|
483
|
+
|
|
484
|
+
```jsonc
|
|
485
|
+
// .skill-map/settings.json
|
|
486
|
+
{
|
|
487
|
+
"schemaVersion": 1,
|
|
488
|
+
"scan": { "strict": true }
|
|
489
|
+
}
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
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.
|
|
493
|
+
|
|
494
|
+
### Why no "schema-extender" plugin kind
|
|
495
|
+
|
|
496
|
+
A reasonable next thought is: "I want my plugin to widen the frontmatter schema so my custom keys are first-class." The spec deliberately rejects that route. The accepted path is to write a deterministic **Rule** that:
|
|
497
|
+
|
|
498
|
+
1. Reads the candidate keys from `node.frontmatter` (which Tier 0 already exposes).
|
|
499
|
+
2. Validates them against whatever shape your domain expects (regex, enum, cross-node consistency).
|
|
500
|
+
3. Emits Issues for violations.
|
|
501
|
+
|
|
502
|
+
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 Rule-driven approach keeps the kernel's parser one-pass and the validation surface composable — the union of every author's rules is the project's policy.
|
|
503
|
+
|
|
504
|
+
If the rule needs to be CI-blocking, the rule 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 rules pick their own severity directly.
|
|
505
|
+
|
|
198
506
|
---
|
|
199
507
|
|
|
200
508
|
## Storage
|
|
@@ -237,15 +545,60 @@ Every DDL or DML object a plugin migration creates / alters / drops MUST live in
|
|
|
237
545
|
|
|
238
546
|
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.
|
|
239
547
|
|
|
548
|
+
### `outputSchema` — opt-in correctness for custom storage writes
|
|
549
|
+
|
|
550
|
+
`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.
|
|
551
|
+
|
|
552
|
+
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.
|
|
553
|
+
|
|
554
|
+
**Mode A (`kv`) — single value-shape schema.**
|
|
555
|
+
|
|
556
|
+
```jsonc
|
|
557
|
+
{
|
|
558
|
+
"storage": {
|
|
559
|
+
"mode": "kv",
|
|
560
|
+
"schema": "./schemas/kv-value.schema.json"
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
```
|
|
564
|
+
|
|
565
|
+
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).
|
|
566
|
+
|
|
567
|
+
**Mode B (`dedicated`) — per-table schemas.**
|
|
568
|
+
|
|
569
|
+
```jsonc
|
|
570
|
+
{
|
|
571
|
+
"storage": {
|
|
572
|
+
"mode": "dedicated",
|
|
573
|
+
"tables": ["items", "history"],
|
|
574
|
+
"migrations": ["./migrations/001_init.sql"],
|
|
575
|
+
"schemas": {
|
|
576
|
+
"items": "./schemas/items-row.schema.json"
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
```
|
|
581
|
+
|
|
582
|
+
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.
|
|
583
|
+
|
|
584
|
+
**Failure modes.**
|
|
585
|
+
|
|
586
|
+
- A schema file missing on disk OR unparseable as JSON OR rejected by AJV's compiler at load time → the plugin's status flips to `load-error` and its extensions are NOT registered. The diagnostic names the offending plugin, table (Mode B), and schema path.
|
|
587
|
+
- 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.
|
|
588
|
+
|
|
589
|
+
**When to use.** Opt in for tables / KV namespaces whose shape is part of the plugin's contract with downstream consumers (e.g. another extension that joins on the row, the UI inspector that renders the value). Skip for tables with free-form payloads (cache rows, observability counters) where validation is friction with no payoff.
|
|
590
|
+
|
|
591
|
+
`emitLink` and `enrichNode` keep their universal validation regardless of the `outputSchema` opt-in — those go through the kernel's own `link.schema.json` / `node.schema.json` validators, not the per-plugin map.
|
|
592
|
+
|
|
240
593
|
---
|
|
241
594
|
|
|
242
595
|
## Execution modes
|
|
243
596
|
|
|
244
|
-
|
|
597
|
+
Extractor / Rule / Action declare `mode` in the manifest with default `deterministic`. Provider / Formatter must NOT declare `mode`.
|
|
245
598
|
|
|
246
599
|
```jsonc
|
|
247
|
-
// deterministic
|
|
248
|
-
{ "kind": "
|
|
600
|
+
// deterministic extractor — default, runs in sm scan
|
|
601
|
+
{ "kind": "extractor", "id": "my-extractor", "mode": "deterministic", ... }
|
|
249
602
|
```
|
|
250
603
|
|
|
251
604
|
```jsonc
|
|
@@ -265,17 +618,17 @@ The full per-kind capability matrix lives in [`architecture.md` §Execution mode
|
|
|
265
618
|
npm install --save-dev @skill-map/testkit
|
|
266
619
|
```
|
|
267
620
|
|
|
268
|
-
The testkit ships builders, per-kind context factories, in-memory KV / runner fakes, and high-level `
|
|
621
|
+
The testkit ships builders, per-kind context factories, in-memory KV / runner fakes, and high-level `runExtractorOnFixture` / `runRuleOnGraph` / `runFormatterOnGraph` helpers. Most plugin tests reduce to one line per assertion.
|
|
269
622
|
|
|
270
623
|
```javascript
|
|
271
624
|
import { test } from 'node:test';
|
|
272
625
|
import { strictEqual } from 'node:assert';
|
|
273
|
-
import {
|
|
626
|
+
import { runExtractorOnFixture, node } from '@skill-map/testkit';
|
|
274
627
|
|
|
275
|
-
import
|
|
628
|
+
import extractor from '../extensions/extractor.mjs';
|
|
276
629
|
|
|
277
630
|
test('emits one reference per [[ref:<name>]] token', async () => {
|
|
278
|
-
const links = await
|
|
631
|
+
const { links } = await runExtractorOnFixture(extractor, {
|
|
279
632
|
body: 'Talk to [[ref:architect]] or [[ref:sre]].',
|
|
280
633
|
context: { node: node({ path: 'a.md' }) },
|
|
281
634
|
});
|
|
@@ -284,7 +637,7 @@ test('emits one reference per [[ref:<name>]] token', async () => {
|
|
|
284
637
|
});
|
|
285
638
|
```
|
|
286
639
|
|
|
287
|
-
For rule tests, `runRuleOnGraph(rule, { context: { nodes, links } })` returns the issue array. For
|
|
640
|
+
For rule tests, `runRuleOnGraph(rule, { context: { nodes, links } })` returns the issue array. For formatter tests, `runFormatterOnGraph(formatter, { context: { nodes, links, issues } })` returns the formatted string.
|
|
288
641
|
|
|
289
642
|
For probabilistic extensions, `makeFakeRunner()` queues canned responses and records every call:
|
|
290
643
|
|
|
@@ -303,17 +656,18 @@ Full surface in `@skill-map/testkit/index.ts`.
|
|
|
303
656
|
|
|
304
657
|
## Diagnostics
|
|
305
658
|
|
|
306
|
-
`sm plugins list` shows every discovered plugin with one of
|
|
659
|
+
`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.
|
|
307
660
|
|
|
308
661
|
| Status | Meaning | Common cause |
|
|
309
662
|
|---|---|---|
|
|
310
663
|
| `loaded` | manifest valid, specCompat satisfied, every extension imported and validated. | — |
|
|
311
664
|
| `disabled` | user toggled it off via `sm plugins disable` or `settings.json#/plugins/<id>/enabled`. Manifest parsed; extensions not imported. | Intentional. |
|
|
312
665
|
| `incompatible-spec` | manifest parsed but `semver.satisfies` failed against the installed spec. | Plugin built against an older / newer spec. |
|
|
313
|
-
| `invalid-manifest` | `plugin.json` missing, unparseable,
|
|
666
|
+
| `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. |
|
|
314
667
|
| `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. |
|
|
668
|
+
| `id-collision` | two plugins reachable from different roots declared the same `id`. Both collided plugins receive this status; no precedence rule applies. | Project-local plugin and a user-global plugin (or two `--plugin-dir` plugins) sharing an id. Rename one and rerun. |
|
|
315
669
|
|
|
316
|
-
`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 to catch breakage early.
|
|
670
|
+
`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.
|
|
317
671
|
|
|
318
672
|
---
|
|
319
673
|
|
|
@@ -329,7 +683,11 @@ Full surface in `@skill-map/testkit/index.ts`.
|
|
|
329
683
|
|
|
330
684
|
## Stability
|
|
331
685
|
|
|
332
|
-
- Document status: **stable** as of spec v1.0.0. Future minor revisions add new sections (e.g. richer testkit coverage when actions
|
|
333
|
-
- The
|
|
686
|
+
- Document status: **stable** as of spec v1.0.0. Future minor revisions add new sections (e.g. richer testkit coverage when actions gain helpers); breaking edits to the documented surface require a major bump per [`versioning.md`](./versioning.md).
|
|
687
|
+
- The six plugin statuses (`loaded` / `disabled` / `incompatible-spec` / `invalid-manifest` / `load-error` / `id-collision`) are stable; adding a seventh status is a minor bump.
|
|
688
|
+
- The structural rule **directory name MUST equal manifest id** is stable; relaxing it (allowing mismatch) is a major bump.
|
|
689
|
+
- The cross-root id-collision rule (both sides blocked, no precedence) is stable; introducing precedence (e.g. project root wins over global) is a major bump.
|
|
690
|
+
- The `granularity` field on `PluginManifest` is stable as introduced. The two values (`bundle` / `extension`) are stable. Adding a third value is a minor bump; changing the default away from `bundle` is a major bump (every existing plugin manifest would silently flip toggle semantics).
|
|
691
|
+
- 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").
|
|
334
692
|
- The recommended `specCompat` strategy is descriptive prose; revising the recommendation does not require a spec bump as long as the schema stays unchanged.
|
|
335
693
|
- The example code blocks track the public TypeScript surface of `@skill-map/cli`; bumping their imports follows the cli's own semver.
|
|
@@ -31,12 +31,12 @@
|
|
|
31
31
|
"description": "Pre-invocation toggles and ordered staging steps. All toggles default to `false`. `priorScans`, when present, run BEFORE the main `invoke` so a case can establish a prior snapshot the heuristic-driven verbs (e.g. `sm scan` rename detection) can react to.",
|
|
32
32
|
"additionalProperties": false,
|
|
33
33
|
"properties": {
|
|
34
|
-
"
|
|
34
|
+
"disableAllProviders": { "type": "boolean" },
|
|
35
35
|
"disableAllDetectors": { "type": "boolean" },
|
|
36
36
|
"disableAllRules": { "type": "boolean" },
|
|
37
37
|
"priorScans": {
|
|
38
38
|
"type": "array",
|
|
39
|
-
"description": "Ordered staging scans, each running before the next. For step N, the runner first replaces the scope's
|
|
39
|
+
"description": "Ordered staging scans, each running before the next. For step N, the runner first replaces the scope's Provider content with `priorScans[N].fixture`, then invokes `sm scan` with the supplied flags. After the last step, the runner copies the top-level `fixture` (overwriting again) and runs the main `invoke`. The DB persists across all steps because `.skill-map/` is preserved between fixture swaps.",
|
|
40
40
|
"items": {
|
|
41
41
|
"type": "object",
|
|
42
42
|
"required": ["fixture"],
|
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
"properties": {
|
|
64
64
|
"verb": {
|
|
65
65
|
"type": "string",
|
|
66
|
-
"description": "First-level CLI verb (`scan`, `list`, `show`, `check`, `findings`, `graph`, `export`, `
|
|
66
|
+
"description": "First-level CLI verb (`scan`, `list`, `show`, `check`, `findings`, `graph`, `export`, `job`, `record`, …)."
|
|
67
67
|
},
|
|
68
68
|
"sub": {
|
|
69
69
|
"type": "string",
|