@skill-map/spec 0.17.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/CHANGELOG.md +672 -0
  2. package/README.md +1 -1
  3. package/architecture.md +281 -16
  4. package/cli-contract.md +122 -6
  5. package/conformance/cases/orphan-markdown-fallback.json +22 -0
  6. package/conformance/cases/plugin-missing-ui-rejected.json +4 -1
  7. package/conformance/cases/sidecar-end-to-end.json +25 -0
  8. package/conformance/coverage.md +9 -3
  9. package/conformance/fixtures/orphan-markdown/.claude/agents/reviewer.md +6 -0
  10. package/conformance/fixtures/orphan-markdown/ARCHITECTURE.md +10 -0
  11. package/conformance/fixtures/plugin-missing-ui/.skill-map/plugins/bad-provider/provider.js +6 -6
  12. package/conformance/fixtures/sidecar-end-to-end/.claude/agents/orphan.sm +12 -0
  13. package/conformance/fixtures/sidecar-end-to-end/.claude/agents/stale.md +8 -0
  14. package/conformance/fixtures/sidecar-end-to-end/.claude/agents/stale.sm +20 -0
  15. package/conformance/fixtures/sidecar-example/agent-example.md +17 -0
  16. package/conformance/fixtures/sidecar-example/agent-example.sm +53 -0
  17. package/db-schema.md +73 -15
  18. package/index.json +42 -19
  19. package/package.json +1 -1
  20. package/plugin-author-guide.md +426 -27
  21. package/schemas/annotations.schema.json +75 -0
  22. package/schemas/api/rest-envelope.schema.json +159 -46
  23. package/schemas/bump-report.schema.json +29 -0
  24. package/schemas/extensions/base.schema.json +36 -1
  25. package/schemas/extensions/extractor.schema.json +3 -10
  26. package/schemas/extensions/provider.schema.json +23 -1
  27. package/schemas/frontmatter/base.schema.json +6 -1
  28. package/schemas/input-types.schema.json +260 -0
  29. package/schemas/node.schema.json +36 -23
  30. package/schemas/plugins-registry.schema.json +14 -2
  31. package/schemas/project-config.schema.json +11 -0
  32. package/schemas/report-base-deterministic.schema.json +15 -0
  33. package/schemas/sidecar.schema.json +96 -0
  34. package/schemas/summaries/{note.schema.json → markdown.schema.json} +5 -5
  35. package/schemas/view-contracts.schema.json +298 -0
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "https://skill-map.dev/spec/v0/api/rest-envelope.schema.json",
4
4
  "title": "RestEnvelope",
5
- "description": "Wrapper shape for REST responses under `/api/*` (Step 14.2). Three variants distinguished by the `kind` discriminator and which payload field is present (`items` for list kinds, `item` for single-resource kinds, `value` for `kind: 'config'`). The `/api/scan` and `/api/health` responses are exempt — they carry the underlying `ScanResult` / `IHealthResponse` shape directly. The `/api/graph` response is also exempt — it returns the formatter's native textual output (text/plain or text/markdown). Step 14.5.d adds the required `kindRegistry` field on every payload-bearing variant so the UI can render Provider-declared kinds (label, color, icon) without hardcoding visuals; sentinel kinds (`health`, `scan`, `graph`) stay exempt because they don't carry an envelope payload. The change keeps `schemaVersion` at `'1'` — the BFF is greenfield (no released consumers run against `'1'` without `kindRegistry`), so a versioned migration buys nothing.",
5
+ "description": "Wrapper shape for REST responses under `/api/*` (Step 14.2). Five variants distinguished by the `kind` discriminator and which payload field is present (`items` for list kinds AND `'annotations.registered'`, `item` for single-resource kinds, `value` for `kind: 'config'` and `'sidecar.bumped'`). The `/api/scan` and `/api/health` responses are exempt — they carry the underlying `ScanResult` / `IHealthResponse` shape directly. The `/api/graph` response is also exempt — it returns the formatter's native textual output (text/plain or text/markdown). Step 14.5.d adds the required `kindRegistry` field on every payload-bearing list / single / config variant so the UI can render Provider-declared kinds (label, color, icon) without hardcoding visuals; sentinel kinds (`health`, `scan`, `graph`) stay exempt because they don't carry an envelope payload. Step 9.6 closes the `'sidecar.bumped'` (R7) and `'annotations.registered'` (R7) gaps — both are payload-bearing but carry their own variant shapes (sidecar.bumped: `value` + `elapsedMs`, no `filters`/`counts`/`kindRegistry`; annotations.registered: `items` + `counts.total`, no `filters`/`kindRegistry`) because they project read-only kernel surfaces orthogonal to the kindRegistry. The change keeps `schemaVersion` at `'1'` — the BFF is greenfield (no released consumers depend on the prior shape), so a versioned migration buys nothing.",
6
6
  "type": "object",
7
7
  "required": ["schemaVersion", "kind"],
8
8
  "properties": {
@@ -13,8 +13,21 @@
13
13
  },
14
14
  "kind": {
15
15
  "type": "string",
16
- "enum": ["nodes", "links", "issues", "plugins", "config", "graph", "node", "health", "scan"],
17
- "description": "Discriminator. List kinds (`nodes`, `links`, `issues`, `plugins`) carry `items`. The `node` kind carries `item`. The `config` kind carries `value`. The `health` / `scan` / `graph` values are reserved for documentation parity with the routes that DON'T use this envelope."
16
+ "enum": [
17
+ "nodes",
18
+ "links",
19
+ "issues",
20
+ "plugins",
21
+ "config",
22
+ "graph",
23
+ "node",
24
+ "health",
25
+ "scan",
26
+ "sidecar.bumped",
27
+ "annotations.registered",
28
+ "contributions.registered"
29
+ ],
30
+ "description": "Discriminator. List kinds (`nodes`, `links`, `issues`, `plugins`) carry `items` + `filters` + `counts` + `kindRegistry`. The `node` kind carries `item` + `kindRegistry`. The `config` kind carries `value` + `kindRegistry`. The `sidecar.bumped` kind (Step 9.6.5, BFF half) carries `value` + `elapsedMs` (no `filters` / `counts` / `kindRegistry` — it's an action-result projection orthogonal to the kindRegistry surface). The `annotations.registered` kind (Step 9.6.6, BFF half) carries `items` + `counts.total` (no `filters` / `kindRegistry` — pure read-only catalog projection). The `health` / `scan` / `graph` values are reserved for documentation parity with the routes that DON'T use this envelope."
18
31
  },
19
32
  "items": {
20
33
  "type": "array",
@@ -26,7 +39,12 @@
26
39
  },
27
40
  "value": {
28
41
  "type": "object",
29
- "description": "Present when `kind` is `'config'`. Carries the merged effective config object."
42
+ "description": "Present when `kind` is `'config'` or `'sidecar.bumped'`. For `'config'`, carries the merged effective config object. For `'sidecar.bumped'`, carries `{ nodePath, version, status }` (the Action-result payload from `POST /api/sidecar/bump`)."
43
+ },
44
+ "elapsedMs": {
45
+ "type": "integer",
46
+ "minimum": 0,
47
+ "description": "Wall-clock milliseconds the BFF spent serving the request. Present on action-result envelopes (`kind: 'sidecar.bumped'`); absent elsewhere."
30
48
  },
31
49
  "filters": {
32
50
  "type": "object",
@@ -34,49 +52,81 @@
34
52
  },
35
53
  "kindRegistry": {
36
54
  "type": "object",
37
- "description": "Catalog of node kinds active in the current scope, keyed by kind name. Built once per server boot from every enabled Provider's `kinds` map and embedded into every payload-bearing envelope so the UI can render kind tags / palette swatches / graph nodes against Provider-declared visuals (label, color, icon) without ever hardcoding a closed kind enum. Sentinel envelopes (`health`, `scan`, `graph`) are exempt.",
55
+ "description": "Catalog of node kinds active in the current scope, keyed by kind name. Built once per server boot from every enabled Provider's `kinds` map and embedded into every payload-bearing envelope so the UI can render kind tags / palette swatches / graph nodes against Provider-declared visuals (label, color, icon) without ever hardcoding a closed kind enum. Sentinel envelopes (`health`, `scan`, `graph`) are exempt. Each entry MAY carry contributions from multiple Providers when several declare the same kind name (e.g. Claude `agent` and Gemini `agent`); the `providers` map keeps every contribution and `primaryProviderId` points at the one whose visuals drive the kind's primary CSS var. The kernel separately surfaces `provider-ambiguous` issues for files matched by more than one Provider; the UI may still receive the merged registry during the conflict window.",
38
56
  "additionalProperties": {
39
57
  "type": "object",
40
- "required": ["providerId", "label", "color"],
58
+ "required": ["primaryProviderId", "providers"],
41
59
  "additionalProperties": false,
42
60
  "properties": {
43
- "providerId": {
61
+ "primaryProviderId": {
44
62
  "type": "string",
45
63
  "minLength": 1,
46
- "description": "Id of the Provider that contributed this kind. Lets the UI scope kind ownership and disambiguate when two Providers happen to declare the same kind name (kernel surfaces this as `provider-ambiguous` but the UI may still receive the merged registry during the conflict window)."
64
+ "description": "Id of the Provider whose visuals drive the kind's primary CSS var (`--sm-kind-<kind>`). Set on first registration (first Provider in iteration order); subsequent contributors append to `providers` without overwriting the primary."
47
65
  },
48
- "label": { "type": "string", "minLength": 1 },
49
- "color": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$" },
50
- "colorDark": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$" },
51
- "emoji": { "type": "string", "minLength": 1, "maxLength": 8 },
52
- "icon": {
53
- "oneOf": [
54
- {
55
- "type": "object",
56
- "required": ["kind", "id"],
57
- "additionalProperties": false,
58
- "properties": {
59
- "kind": { "const": "pi" },
60
- "id": { "type": "string", "pattern": "^pi-[a-z0-9]+(-[a-z0-9]+)*$" }
61
- }
62
- },
63
- {
64
- "type": "object",
65
- "required": ["kind", "path"],
66
- "additionalProperties": false,
67
- "properties": {
68
- "kind": { "const": "svg" },
69
- "path": { "type": "string", "minLength": 1 }
66
+ "providers": {
67
+ "type": "object",
68
+ "minProperties": 1,
69
+ "description": "Per-provider visuals for this kind name. Keyed by Provider id (e.g. `'claude'`, `'gemini'`). Lets the UI render a node painted with its own Provider's color via `entry.providers[node.provider]` when the kind name is shared across Providers.",
70
+ "additionalProperties": {
71
+ "type": "object",
72
+ "required": ["label", "color"],
73
+ "additionalProperties": false,
74
+ "properties": {
75
+ "label": { "type": "string", "minLength": 1 },
76
+ "color": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$" },
77
+ "colorDark": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$" },
78
+ "emoji": { "type": "string", "minLength": 1, "maxLength": 8 },
79
+ "icon": {
80
+ "oneOf": [
81
+ {
82
+ "type": "object",
83
+ "required": ["kind", "id"],
84
+ "additionalProperties": false,
85
+ "properties": {
86
+ "kind": { "const": "pi" },
87
+ "id": { "type": "string", "pattern": "^pi-[a-z0-9]+(-[a-z0-9]+)*$" }
88
+ }
89
+ },
90
+ {
91
+ "type": "object",
92
+ "required": ["kind", "path"],
93
+ "additionalProperties": false,
94
+ "properties": {
95
+ "kind": { "const": "svg" },
96
+ "path": { "type": "string", "minLength": 1 }
97
+ }
98
+ }
99
+ ]
70
100
  }
71
101
  }
72
- ]
102
+ }
73
103
  }
74
104
  }
75
105
  }
76
106
  },
107
+ "contributionsRegistry": {
108
+ "type": "object",
109
+ "description": "Catalog of registered view contributions active in the current scope, keyed by qualified contribution id `<pluginId>/<extensionId>/<contributionId>`. Built once per server boot from every enabled extension's `viewContributions` map and embedded into every payload-bearing envelope so the UI can fetch the catalog without a separate request. Sentinel envelopes (`health`, `scan`, `graph`), action-result envelopes (`sidecar.bumped`), and the catalog envelopes themselves (`annotations.registered`, `contributions.registered`) are exempt. Mirror of `kindRegistry`, parallel surface. Each entry references a contract by name from the closed catalog at `view-contracts.schema.json#/$defs/ContractName`; the UI consults its own contract→slot mapping (slots are UI-only) to render. Per-node payloads are delivered separately on `node` envelopes (single via `item.contributions`) or list envelopes (`items[].contributions`, only when `limit ≤ bff.maxBulkContributions`, default 200).",
110
+ "additionalProperties": {
111
+ "type": "object",
112
+ "required": ["pluginId", "extensionId", "contributionId", "contract"],
113
+ "additionalProperties": false,
114
+ "properties": {
115
+ "pluginId": { "type": "string", "minLength": 1 },
116
+ "extensionId": { "type": "string", "minLength": 1 },
117
+ "contributionId": { "type": "string", "minLength": 1 },
118
+ "contract": { "$ref": "../view-contracts.schema.json#/$defs/ContractName" },
119
+ "label": { "type": "string", "maxLength": 64 },
120
+ "tooltip": { "type": "string", "maxLength": 256 },
121
+ "icon": { "type": "string", "maxLength": 64 },
122
+ "emptyText": { "type": "string", "maxLength": 128 },
123
+ "emitWhenEmpty": { "type": "boolean", "default": false }
124
+ }
125
+ }
126
+ },
77
127
  "counts": {
78
128
  "type": "object",
79
- "required": ["total", "returned"],
129
+ "required": ["total"],
80
130
  "properties": {
81
131
  "total": {
82
132
  "type": "integer",
@@ -86,7 +136,7 @@
86
136
  "returned": {
87
137
  "type": "integer",
88
138
  "minimum": 0,
89
- "description": "Rows actually carried in `items` (≤ `limit`)."
139
+ "description": "Rows actually carried in `items` (≤ `limit`). Present on list-kind envelopes (`nodes`, `links`, `issues`, `plugins`); absent on `'annotations.registered'` (no pagination, no filters — the catalog ships in its entirety in every response)."
90
140
  },
91
141
  "page": {
92
142
  "type": "object",
@@ -108,51 +158,112 @@
108
158
  }
109
159
  },
110
160
  "additionalProperties": false,
111
- "description": "Tally + paging info. Present on every list / single envelope; absent on `health` / `scan` / `graph` responses (which don't use this envelope)."
161
+ "description": "Tally + paging info. Present on every list / single envelope; absent on `health` / `scan` / `graph` responses (which don't use this envelope). The list variants additionally require `returned`; the `'annotations.registered'` variant only requires `total`."
112
162
  }
113
163
  },
114
164
  "oneOf": [
115
165
  {
116
- "description": "List envelope — `items` payload + `counts` + `kindRegistry`.",
117
- "required": ["items", "counts", "filters", "kindRegistry"],
166
+ "description": "List envelope — `items` payload + `filters` + `counts` (with `returned`) + `kindRegistry` + `contributionsRegistry`. Used by `/api/nodes`, `/api/links`, `/api/issues`, `/api/plugins`.",
167
+ "required": ["items", "counts", "filters", "kindRegistry", "contributionsRegistry"],
118
168
  "properties": {
119
- "kind": { "enum": ["nodes", "links", "issues", "plugins"] }
169
+ "kind": { "enum": ["nodes", "links", "issues", "plugins"] },
170
+ "counts": { "required": ["total", "returned"] }
120
171
  },
121
172
  "not": {
122
173
  "anyOf": [
123
174
  { "required": ["item"] },
124
- { "required": ["value"] }
175
+ { "required": ["value"] },
176
+ { "required": ["elapsedMs"] }
125
177
  ]
126
178
  }
127
179
  },
128
180
  {
129
- "description": "Single-resource envelope — `item` payload + `kindRegistry`, no `counts` / `filters`.",
130
- "required": ["item", "kindRegistry"],
181
+ "description": "Single-resource envelope — `item` payload + `kindRegistry` + `contributionsRegistry`, no `counts` / `filters`. Used by `/api/nodes/:pathB64`.",
182
+ "required": ["item", "kindRegistry", "contributionsRegistry"],
131
183
  "properties": {
132
184
  "kind": { "const": "node" }
133
185
  },
134
186
  "not": {
135
187
  "anyOf": [
136
188
  { "required": ["items"] },
137
- { "required": ["value"] }
189
+ { "required": ["value"] },
190
+ { "required": ["elapsedMs"] }
138
191
  ]
139
192
  }
140
193
  },
141
194
  {
142
- "description": "Value envelope — `value` payload + `kindRegistry`, no `counts` / `filters`.",
143
- "required": ["value", "kindRegistry"],
195
+ "description": "Value envelope — `value` payload + `kindRegistry` + `contributionsRegistry`, no `counts` / `filters`. Used by `/api/config`.",
196
+ "required": ["value", "kindRegistry", "contributionsRegistry"],
144
197
  "properties": {
145
198
  "kind": { "const": "config" }
146
199
  },
147
200
  "not": {
148
201
  "anyOf": [
149
202
  { "required": ["items"] },
150
- { "required": ["item"] }
203
+ { "required": ["item"] },
204
+ { "required": ["elapsedMs"] }
205
+ ]
206
+ }
207
+ },
208
+ {
209
+ "description": "Action-result envelope — `value` + `elapsedMs` siblings, no `filters` / `counts` / `kindRegistry` / `contributionsRegistry`. Used by `POST /api/sidecar/bump` (Step 9.6.5, BFF half) where the response carries the bump report (`{ nodePath, version, status }`) plus the wall-clock duration. The registries are intentionally absent — the action result is orthogonal to both catalogs and the SPA already has them cached from a prior list call.",
210
+ "required": ["value", "elapsedMs"],
211
+ "properties": {
212
+ "kind": { "const": "sidecar.bumped" }
213
+ },
214
+ "not": {
215
+ "anyOf": [
216
+ { "required": ["items"] },
217
+ { "required": ["item"] },
218
+ { "required": ["filters"] },
219
+ { "required": ["counts"] },
220
+ { "required": ["kindRegistry"] },
221
+ { "required": ["contributionsRegistry"] }
222
+ ]
223
+ }
224
+ },
225
+ {
226
+ "description": "Annotation-catalog envelope — `items` + `counts.total` only, no `filters` / `kindRegistry` / `contributionsRegistry` / `returned`. Used by `GET /api/annotations/registered` (Step 9.6.6, BFF half). The catalog is small (typically 0–50 entries), ships in its entirety on every response, and does not paginate; `counts.total` doubles as `items.length`.",
227
+ "required": ["items", "counts"],
228
+ "properties": {
229
+ "kind": { "const": "annotations.registered" },
230
+ "counts": {
231
+ "not": { "required": ["returned"] }
232
+ }
233
+ },
234
+ "not": {
235
+ "anyOf": [
236
+ { "required": ["item"] },
237
+ { "required": ["value"] },
238
+ { "required": ["filters"] },
239
+ { "required": ["kindRegistry"] },
240
+ { "required": ["contributionsRegistry"] },
241
+ { "required": ["elapsedMs"] }
242
+ ]
243
+ }
244
+ },
245
+ {
246
+ "description": "View-contributions-catalog envelope — `items` + `counts.total` only, no `filters` / `kindRegistry` / `contributionsRegistry` / `returned`. Used by `GET /api/contributions/registered`. Mirror of `annotations.registered`. The catalog ships in entirety; `counts.total` doubles as `items.length`. Each item is an `IRegisteredViewContribution` shape: `{ pluginId, extensionId, contributionId, contract, label?, tooltip?, icon?, emptyText?, emitWhenEmpty? }`.",
247
+ "required": ["items", "counts"],
248
+ "properties": {
249
+ "kind": { "const": "contributions.registered" },
250
+ "counts": {
251
+ "not": { "required": ["returned"] }
252
+ }
253
+ },
254
+ "not": {
255
+ "anyOf": [
256
+ { "required": ["item"] },
257
+ { "required": ["value"] },
258
+ { "required": ["filters"] },
259
+ { "required": ["kindRegistry"] },
260
+ { "required": ["contributionsRegistry"] },
261
+ { "required": ["elapsedMs"] }
151
262
  ]
152
263
  }
153
264
  },
154
265
  {
155
- "description": "Sentinel kinds — reserved for routes that do NOT carry an envelope payload at the wire level (`health`, `scan`, `graph`). They do not carry `kindRegistry` either; clients that need it must call any payload-bearing endpoint at boot.",
266
+ "description": "Sentinel kinds — reserved for routes that do NOT carry an envelope payload at the wire level (`health`, `scan`, `graph`). They do not carry `kindRegistry` or `contributionsRegistry` either; clients that need either must call any payload-bearing endpoint at boot.",
156
267
  "properties": {
157
268
  "kind": { "enum": ["health", "scan", "graph"] }
158
269
  },
@@ -161,7 +272,9 @@
161
272
  { "required": ["items"] },
162
273
  { "required": ["item"] },
163
274
  { "required": ["value"] },
164
- { "required": ["kindRegistry"] }
275
+ { "required": ["kindRegistry"] },
276
+ { "required": ["contributionsRegistry"] },
277
+ { "required": ["elapsedMs"] }
165
278
  ]
166
279
  }
167
280
  }
@@ -0,0 +1,29 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://skill-map.dev/spec/v0/bump-report.schema.json",
4
+ "title": "BumpReport",
5
+ "description": "Report shape produced by the built-in deterministic `bump` Action (Step 9.6.3, Decision #125). Extends `report-base-deterministic.schema.json` (the deterministic counterpart to `report-base.schema.json`, which carries the LLM-only `confidence` + `safety` fields). The bump Action returns one of three concrete shapes, distinguished by `ok` / `noop` / `reason`: success-with-write (`{ ok: true, version }`), silent-no-op under `force` (`{ ok: true, noop: true }`), or refusal (`{ ok: false, reason: 'fresh' }`).",
6
+ "allOf": [{ "$ref": "report-base-deterministic.schema.json" }],
7
+ "type": "object",
8
+ "additionalProperties": true,
9
+ "properties": {
10
+ "noop": {
11
+ "type": "boolean",
12
+ "description": "True when the Action accepted the request but did not produce a write (the node was already fresh and `force: true` opted into the silent no-op path). Mutually exclusive with `version`."
13
+ },
14
+ "reason": {
15
+ "type": "string",
16
+ "enum": ["fresh"],
17
+ "description": "Refusal reason, present only when `ok: false`. `fresh` = the node has no drift versus its sidecar and the caller did not pass `force: true`."
18
+ },
19
+ "version": {
20
+ "type": "integer",
21
+ "minimum": 1,
22
+ "description": "The new `annotations.version` value the Action wrote. Present only when `ok: true` and `noop` is absent."
23
+ },
24
+ "createdSidecar": {
25
+ "type": "boolean",
26
+ "description": "True when this bump created the `.sm` file (no sidecar existed on disk before). Triggers the `audit.createdAt` / `audit.createdBy` fill-in."
27
+ }
28
+ }
29
+ }
@@ -9,7 +9,7 @@
9
9
  "id": {
10
10
  "type": "string",
11
11
  "pattern": "^[a-z][a-z0-9]*(-[a-z0-9]+)*$",
12
- "description": "Kebab-case identifier unique within the extension's kind across the same plugin. The kernel registers every extension under the **qualified** id `<plugin-id>/<id>` (e.g. `core/frontmatter`, `claude/slash`, `hello-world/greet`) — see `architecture.md` §Extension kinds and `plugin-author-guide.md` §Qualified extension ids. Authors declare only the short id here; the qualifier is composed by the loader from the manifest's `id` (or, for built-ins, from the bundle declaration in `built-ins.ts`). The pattern is intentionally constrained to a single kebab-case segment without the `/` separator: the qualifier always lives in the plugin id, never in the extension id."
12
+ "description": "Kebab-case identifier unique within the extension's kind across the same plugin. The kernel registers every extension under the **qualified** id `<plugin-id>/<id>` (e.g. `core/annotations`, `core/slash`, `hello-world/greet`) — see `architecture.md` §Extension kinds and `plugin-author-guide.md` §Qualified extension ids. Authors declare only the short id here; the qualifier is composed by the loader from the manifest's `id` (or, for built-ins, from the bundle declaration in `built-ins.ts`). The pattern is intentionally constrained to a single kebab-case segment without the `/` separator: the qualifier always lives in the plugin id, never in the extension id."
13
13
  },
14
14
  "kind": {
15
15
  "type": "string",
@@ -38,6 +38,41 @@
38
38
  "entry": {
39
39
  "type": "string",
40
40
  "description": "Optional path to the module exporting this extension (relative to the plugin root). Absent → the kernel uses the path listed in the plugin manifest's `extensions[]` array."
41
+ },
42
+ "annotationContributions": {
43
+ "type": "object",
44
+ "additionalProperties": {
45
+ "type": "object",
46
+ "required": ["schema"],
47
+ "additionalProperties": false,
48
+ "properties": {
49
+ "schema": {
50
+ "type": "object",
51
+ "description": "Inline JSON Schema for this contribution's value. Validated when the kernel routes a sidecar write through the extension's namespace (or root key, see `location`). Must be a valid JSON Schema document; an invalid `schema` rejects the extension at load with `invalid-manifest`."
52
+ },
53
+ "ownership": {
54
+ "enum": ["exclusive", "shared"],
55
+ "default": "shared",
56
+ "description": "Conflict policy for this key. `shared` (default) — the key is namespaced by default; multiple plugins MAY contribute to the same key, last-write-wins per the SidecarStore's deep-merge semantics. `exclusive` — only this plugin may write the key. REQUIRED when `location: 'root'` (a top-level reserved key cannot be silently shared between plugins)."
57
+ },
58
+ "location": {
59
+ "enum": ["namespaced", "root"],
60
+ "default": "namespaced",
61
+ "description": "Where the key lands inside the sidecar. `namespaced` (default) — written under the plugin's `<plugin-id>:` block at the sidecar root. `root` — written as a top-level key alongside the reserved blocks (`for`, `annotations`, `settings`, `audit`); requires `ownership: 'exclusive'` and is treated as elevated trust (two plugins claiming the same root key with `exclusive` is a hard-fail at orchestrator init). See `plugin-author-guide.md` §Annotation contributions."
62
+ }
63
+ }
64
+ },
65
+ "description": "Plugin-contributed annotation keys. Each entry declares an inline JSON Schema for the value the extension writes into a sidecar. Keys default to the plugin's `<plugin-id>:` namespace; opt-in to top-level via `location: 'root'` (requires `ownership: 'exclusive'`). The kernel exposes the runtime catalog via `kernel.getRegisteredAnnotationKeys()` for UI autocomplete; the built-in `core/unknown-field` Rule emits a warning on truly unrecognized keys (typo guard). See `plugin-author-guide.md` §Annotation contributions for the worked examples."
66
+ },
67
+ "viewContributions": {
68
+ "type": "object",
69
+ "additionalProperties": {
70
+ "$ref": "../view-contracts.schema.json#/$defs/IViewContribution"
71
+ },
72
+ "propertyNames": {
73
+ "pattern": "^[a-z][a-z0-9]*(-[a-z0-9]+)*$"
74
+ },
75
+ "description": "Plugin-contributed view contributions. Each entry declares one rendering surface in the UI by picking a `contract` name from the closed catalog at `view-contracts.schema.json#/$defs/ContractName`. The kernel validates the manifest at load (`invalid-manifest` on unknown contract); the plugin emits per-node payloads via `ctx.emitContribution(<contributionId>, payload)` during scan; the runtime validates payloads against the contract's payload schema in `view-contracts.schema.json#/$defs/payloads/<contract>`; off-contract payloads emit `extension.error` and drop silently (mirror of `emitLink` off-contract drop). The kernel exposes the runtime catalog via `kernel.getRegisteredViewContributions()`; the BFF surfaces it at `GET /api/contributions/registered`. The plugin author NEVER picks a slot — slot mapping is a UI decision (see `ROADMAP.md` §UI contribution system). See `plugin-author-guide.md` §View contributions for worked examples."
41
76
  }
42
77
  }
43
78
  }
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "https://skill-map.dev/spec/v0/extensions/extractor.schema.json",
4
4
  "title": "ExtensionExtractor",
5
- "description": "Manifest shape for an `Extractor` extension. An extractor consumes a parsed node (frontmatter + body) and emits output through three context-supplied callbacks rather than returning a value: `ctx.emitLink(link)` writes to the kernel's `links` table (validated against `emitsLinkKinds` before persistence), `ctx.enrichNode(partial)` merges author-canonical properties into the kernel's enrichment layer (separate from the author-supplied frontmatter), and `ctx.store` persists into the plugin's own KV namespace or dedicated tables. The runtime method is `extract(ctx) → void`. Extractors run in isolation: they MUST NOT read other nodes, the graph, or the DB. Cross-node reasoning lives in Rules. Extractors are dual-mode: `deterministic` extractors run synchronously inside `sm scan`; `probabilistic` extractors invoke an LLM through the kernel's `RunnerPort` (exposed as `ctx.runner`) and execute only as queued jobs (never during scan). See `architecture.md` §Execution modes for the full contract. Renamed from `detector` in spec 0.8.x — the FindBugs/SpotBugs lineage of \"detector\" connoted bug finding, while this kind extracts signals (relations, enrichments, custom data); ENRE (Entity Relationship Extractor) is the closer precedent.",
5
+ "description": "Manifest shape for an `Extractor` extension. An extractor consumes a parsed node (frontmatter + body) and emits output through three context-supplied callbacks rather than returning a value: `ctx.emitLink(link)` writes to the kernel's `links` table (validated against `emitsLinkKinds` before persistence), `ctx.enrichNode(partial)` merges author-canonical properties into the kernel's enrichment layer (separate from the author-supplied frontmatter), and `ctx.store` persists into the plugin's own KV namespace or dedicated tables. The runtime method is `extract(ctx) → void`. Extractors run in isolation: they MUST NOT read other nodes, the graph, or the DB. Cross-node reasoning lives in Rules. Extractors are deterministic-only: pure code, runs synchronously inside `sm scan`, same input same output every run. LLM-driven enrichment of a node is an Action concern (queued as a job), not an Extractor concern. See `architecture.md` §Execution modes for the full contract. Renamed from `detector` in spec 0.8.x — the FindBugs/SpotBugs lineage of \"detector\" connoted bug finding, while this kind extracts signals (relations, enrichments, custom data); ENRE (Entity Relationship Extractor) is the closer precedent.",
6
6
  "allOf": [
7
7
  { "$ref": "base.schema.json" }
8
8
  ],
@@ -11,16 +11,9 @@
11
11
  "unevaluatedProperties": false,
12
12
  "properties": {
13
13
  "kind": { "const": "extractor" },
14
- "mode": {
15
- "type": "string",
16
- "enum": ["deterministic", "probabilistic"],
17
- "default": "deterministic",
18
- "description": "`deterministic` (default): pure code, runs synchronously during `sm scan`. Same input → same output, every run. `probabilistic`: invokes an LLM via `ctx.runner` and runs only as a queued job (`sm job submit extractor:<id>`); never participates in `sm scan`. The kernel rejects probabilistic extractors that try to register scan-time hooks at load time. Omitting the field is equivalent to declaring `deterministic`."
19
- },
20
14
  "emitsLinkKinds": {
21
15
  "type": "array",
22
- "description": "Subset of `Link.kind` values this extractor is allowed to emit through `ctx.emitLink(...)`. Emitting an unlisted kind at runtime → kernel rejects the link and logs `extractor-kind-violation`.",
23
- "minItems": 1,
16
+ "description": "Subset of `Link.kind` values this extractor is allowed to emit through `ctx.emitLink(...)`. Emitting an unlisted kind at runtime → kernel rejects the link and logs `extractor-kind-violation`. Empty array (`[]`) is the honest declaration for pure-contributions extractors that emit only via `ctx.emitContribution(...)` and never call `ctx.emitLink(...)` — Phase 3 of the View contribution system relaxed `minItems: 1` to admit this case (`{ kind: 'extractor', emitsLinkKinds: [], viewContributions: { ... } }`).",
24
17
  "items": {
25
18
  "type": "string",
26
19
  "enum": ["invokes", "references", "mentions", "supersedes"]
@@ -42,7 +35,7 @@
42
35
  "minItems": 1,
43
36
  "items": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" },
44
37
  "uniqueItems": true,
45
- "description": "Optional opt-in filter. If declared, the extractor runs only on nodes whose kind is in this list. Absent = applies to all kinds (default). No wildcards — the absence of the field already means \"every kind\". Empty array is invalid (`minItems: 1`). Unknown kinds (not declared by any installed Provider) load OK but emit a warning in `sm plugins doctor` — the Provider may arrive later. The kernel filters fail-fast: nodes whose kind is excluded never see `extract()`, so a probabilistic extractor wastes zero LLM cost on inapplicable nodes."
38
+ "description": "Optional opt-in filter. If declared, the extractor runs only on nodes whose kind is in this list. Absent = applies to all kinds (default). No wildcards — the absence of the field already means \"every kind\". Empty array is invalid (`minItems: 1`). Unknown kinds (not declared by any installed Provider) load OK but emit a warning in `sm plugins doctor` — the Provider may arrive later. The kernel filters fail-fast: nodes whose kind is excluded never see `extract()`, so an extractor wastes zero CPU on inapplicable nodes."
46
39
  }
47
40
  }
48
41
  }
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "https://skill-map.dev/spec/v0/extensions/provider.schema.json",
4
4
  "title": "ExtensionProvider",
5
- "description": "Manifest shape for a `Provider` extension. A Provider declares its own universe: the platform it recognises (Claude Code, Codex, Gemini, Obsidian vault, generic MD), the catalog of node `kind`s it emits, the per-kind frontmatter schema each kind follows, and the filesystem directory (`explorationDir`) where its content lives. The catalog lives in the `kinds` map, keyed by kind name. Each map entry declares the relative path to the kind's frontmatter schema (resolved against the Provider's directory) and the qualified `defaultRefreshAction` id the UI's probabilistic-refresh surface dispatches for that kind. Spec only ships `frontmatter/base.schema.json` (universal); per-kind schemas live with their owning Provider so that adding a new platform is purely additive — no spec bump needed to introduce kinds. Exactly zero or one Provider MUST match any given file; multiple matches → the kernel emits an issue `provider-ambiguous` and the file is left unclassified. Providers are deterministic-only — they sit at the filesystem boundary and run during boot; probabilistic classification would make boot slow, costly, and non-reproducible. The `mode` field MUST NOT appear in Provider manifests. If you need LLM-assisted classification, write a probabilistic Extractor that emits classification hints via `ctx.emitLink`. Distinct from the **hexagonal-architecture** 'adapter' (`RunnerPort.adapter`, `StoragePort.adapter`, etc.), which is an internal driven-adapter implementing a port — Providers live in the extension surface, hexagonal adapters live in `src/kernel/adapters/`. Stability: stable as of spec v1.0.0 except where noted.",
5
+ "description": "Manifest shape for a `Provider` extension. A Provider declares its own universe: the platform it recognises (Claude Code, Codex, Gemini, Obsidian vault, generic MD), the catalog of node `kind`s it emits, the per-kind frontmatter schema each kind follows, and the filesystem directory (`explorationDir`) where its content lives. The catalog lives in the `kinds` map, keyed by kind name. Each map entry declares the relative path to the kind's frontmatter schema (resolved against the Provider's directory) and the qualified `defaultRefreshAction` id the UI's probabilistic-refresh surface dispatches for that kind. Spec only ships `frontmatter/base.schema.json` (universal); per-kind schemas live with their owning Provider so that adding a new platform is purely additive — no spec bump needed to introduce kinds. Exactly zero or one Provider MUST match any given file; multiple matches → the kernel emits an issue `provider-ambiguous` and the file is left unclassified. Providers are deterministic-only — they sit at the filesystem boundary and run during boot; probabilistic classification would make boot slow, costly, and non-reproducible. The `mode` field MUST NOT appear in Provider manifests. If you need LLM-assisted classification, write a probabilistic Action that runs as a queued job and writes back through the enrichment layer; Extractors are deterministic-only and Providers stay on the deterministic boot path. Distinct from the **hexagonal-architecture** 'adapter' (`RunnerPort.adapter`, `StoragePort.adapter`, etc.), which is an internal driven-adapter implementing a port — Providers live in the extension surface, hexagonal adapters live in `src/kernel/adapters/`. Stability: stable as of spec v1.0.0 except where noted.",
6
6
  "allOf": [
7
7
  { "$ref": "base.schema.json" }
8
8
  ],
@@ -21,6 +21,28 @@
21
21
  "description": "Path globs (relative to scope root) that this Provider SHOULD be consulted for. Advisory — the kernel walks all roots and consults every Provider regardless, but this field lets `sm doctor` warn when no file matched a specific Provider (i.e. the Provider was loaded for a platform that isn't in this scope).",
22
22
  "items": { "type": "string" }
23
23
  },
24
+ "read": {
25
+ "type": "object",
26
+ "required": ["extensions", "parser"],
27
+ "additionalProperties": false,
28
+ "description": "Declarative file-discovery config consumed by the kernel walker. When present, the kernel walks every root, includes files whose extension matches `extensions`, parses each with the parser registered as `parser`, and yields raw nodes the orchestrator consumes. When absent, the kernel applies the default `{ extensions: ['.md'], parser: 'frontmatter-yaml' }` so the most common Provider shape needs no configuration. When a Provider also declares the runtime `walk()` method (TypeScript-only — never appears in this manifest), `walk()` wins and `read` is ignored: the runtime field is the escape hatch for Providers with non-standard discovery requirements. Built-in parsers ship with the kernel (`frontmatter-yaml`, `plain`); the set is closed by design and user plugins cannot register their own.",
29
+ "properties": {
30
+ "extensions": {
31
+ "type": "array",
32
+ "minItems": 1,
33
+ "description": "File extensions the walker yields. Strings include the leading dot. Lowercase-only by convention — match is case-sensitive and Providers MUST list every casing they want recognised.",
34
+ "items": {
35
+ "type": "string",
36
+ "pattern": "^\\.[a-z0-9]+$"
37
+ }
38
+ },
39
+ "parser": {
40
+ "type": "string",
41
+ "minLength": 1,
42
+ "description": "Identifier of a parser registered in the kernel-internal registry. Built-ins: `frontmatter-yaml` (markdown with `--- … ---` YAML frontmatter, prototype-pollution-safe, `js-yaml` JSON_SCHEMA-pinned), `plain` (entire body, empty frontmatter — for files carrying no frontmatter convention; the Provider derives `name` from the path inside `classify()`). Unknown ids surface as `UnknownParserError` from the walker; the orchestrator translates the error into a Provider issue with status `invalid-manifest`."
43
+ }
44
+ }
45
+ },
24
46
  "kinds": {
25
47
  "type": "object",
26
48
  "description": "Catalog of node kinds this Provider emits. Keyed by kind name (e.g. `skill`, `agent`, `note`). Each entry declares the relative path to the kind's frontmatter schema (the kernel resolves it against the Provider's package directory) and the qualified `defaultRefreshAction` id the UI dispatches when the user requests a probabilistic refresh on a node of that kind. The map MUST be non-empty: a Provider that emits no kinds is meaningless. Kind names follow camelCase / lowerCase convention; the spec does not constrain the value space (a Cursor Provider could declare `rule`, an Obsidian Provider could declare `daily`).",
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "https://skill-map.dev/spec/v0/frontmatter/base.schema.json",
4
4
  "title": "FrontmatterBase",
5
- "description": "Truly universal frontmatter shape `name` + `description` are the only fields every node, on every Provider, MUST carry. Per-vendor schemas (Anthropic Claude, Cursor, Obsidian, …) live in the Provider that emits the kind (declared via `provider.kinds[<kind>].schema`) and extend this base via `allOf` + `$ref` to its `$id`. `additionalProperties: true` is intentional: skill-map AGGREGATES vendor specs, it does not curate them. Vendor-specific fields (`tools`, `allowedTools`, `model`, `metadata`, etc.) flow through validation silently because the per-kind extension declares them. The future home for skill-map-only annotation fields (provenance, cross-vendor metadata) is a deferred decision — tracked separately, not in this schema.",
5
+ "description": "Universal frontmatter shape every Provider's per-kind schema extends via `allOf` + `$ref` to this `$id`. `name` and `description` are the two universally required fields; `tags` is a universally accepted optional field for author-supplied taxonomy. Per-vendor schemas (Anthropic Claude, Cursor, Obsidian, …) declare additional fields on top via the per-kind extension. `additionalProperties: true` is intentional: skill-map AGGREGATES vendor specs, it does not curate them. Vendor-specific fields (`tools`, `allowedTools`, `model`, etc.) flow through validation silently because the per-kind extension declares them.",
6
6
  "type": "object",
7
7
  "required": ["name", "description"],
8
8
  "additionalProperties": true,
@@ -16,6 +16,11 @@
16
16
  "type": "string",
17
17
  "minLength": 1,
18
18
  "description": "One-to-three-sentence description. REQUIRED."
19
+ },
20
+ "tags": {
21
+ "type": "array",
22
+ "items": { "type": "string", "minLength": 1 },
23
+ "description": "**Author-supplied** taxonomy tags written in the markdown frontmatter — what the file's author considers the node's intrinsic categories. Skill-map's tag system is **dual-source**: this field carries author tags; `sidecar.annotations.tags` carries user tags (post-hoc, written by whoever curates the project from their `.sm` sidecar). Both surfaces are first-class — searches and listings (`sm list --tag`, UI faceted search) match the union and the UI distinguishes them visually so the attribution stays explicit. Empty array and missing field are equivalent."
19
24
  }
20
25
  }
21
26
  }