@skill-map/spec 0.18.0 → 0.20.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 (47) hide show
  1. package/CHANGELOG.md +680 -1
  2. package/README.md +6 -6
  3. package/architecture.md +244 -41
  4. package/cli-contract.md +48 -20
  5. package/conformance/README.md +2 -2
  6. package/conformance/cases/kernel-empty-boot.json +2 -2
  7. package/conformance/cases/orphan-markdown-fallback.json +22 -0
  8. package/conformance/cases/plugin-missing-ui-rejected.json +2 -1
  9. package/conformance/cases/sidecar-end-to-end.json +3 -4
  10. package/conformance/coverage.md +8 -6
  11. package/conformance/fixtures/orphan-markdown/.claude/agents/reviewer.md +6 -0
  12. package/conformance/fixtures/orphan-markdown/ARCHITECTURE.md +10 -0
  13. package/conformance/fixtures/sidecar-end-to-end/.claude/agents/orphan.sm +2 -2
  14. package/conformance/fixtures/sidecar-end-to-end/.claude/agents/stale.sm +1 -1
  15. package/conformance/fixtures/sidecar-example/agent-example.md +1 -1
  16. package/conformance/fixtures/sidecar-example/agent-example.sm +1 -1
  17. package/db-schema.md +68 -23
  18. package/index.json +47 -42
  19. package/interfaces/security-scanner.md +2 -2
  20. package/job-events.md +12 -12
  21. package/job-lifecycle.md +1 -1
  22. package/package.json +1 -1
  23. package/plugin-author-guide.md +374 -69
  24. package/plugin-kv-api.md +5 -5
  25. package/prompt-preamble.md +1 -1
  26. package/schemas/annotations.schema.json +5 -9
  27. package/schemas/api/rest-envelope.schema.json +55 -11
  28. package/schemas/conformance-case.schema.json +2 -2
  29. package/schemas/extensions/analyzer.schema.json +43 -0
  30. package/schemas/extensions/base.schema.json +14 -4
  31. package/schemas/extensions/extractor.schema.json +3 -10
  32. package/schemas/extensions/hook.schema.json +6 -4
  33. package/schemas/extensions/provider.schema.json +1 -1
  34. package/schemas/frontmatter/base.schema.json +6 -1
  35. package/schemas/input-types.schema.json +260 -0
  36. package/schemas/issue.schema.json +6 -6
  37. package/schemas/link.schema.json +2 -2
  38. package/schemas/node.schema.json +1 -19
  39. package/schemas/plugins-registry.schema.json +14 -2
  40. package/schemas/project-config.schema.json +25 -0
  41. package/schemas/sidecar.schema.json +6 -6
  42. package/schemas/summaries/agent.schema.json +1 -1
  43. package/schemas/summaries/command.schema.json +1 -1
  44. package/schemas/summaries/hook.schema.json +1 -1
  45. package/schemas/summaries/markdown.schema.json +1 -1
  46. package/schemas/view-slots.schema.json +335 -0
  47. package/schemas/extensions/rule.schema.json +0 -43
@@ -69,8 +69,8 @@ After every change to the `plugins/` folder, run `sm plugins list` to see the lo
69
69
 
70
70
  The `id` declared in `plugin.json` is **globally unique** across every active discovery root. The kernel enforces this in two places:
71
71
 
72
- 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.
72
+ 1. **Directory name MUST equal manifest id.** A plugin lives at `<root>/<id>/plugin.json`. If `basename(<plugin-dir>) !== manifest.id`, discovery surfaces the plugin with status `invalid-manifest` and a reason naming both names. This analyzer eliminates same-root collisions by construction (a filesystem cannot host two siblings with the same name).
73
+ 2. **Cross-root id collisions are blocked, both sides.** If two plugins from different roots (project + global, or any combination of `--plugin-dir`) declare the same `id`, **both** receive status `id-collision`. There is no precedence analyzer — neither plugin loads its extensions; the user resolves the conflict by renaming one and rerunning. Coherent with the spec analyzer that no extension is privileged.
74
74
 
75
75
  `sm plugins list` shows the conflict; `sm plugins doctor` exits `1` whenever any `id-collision` is present.
76
76
 
@@ -83,19 +83,20 @@ Concrete examples for the reference impl's bundled extensions:
83
83
  | Extension | Short id (in the file) | Qualified id (in the registry) |
84
84
  |---|---|---|
85
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` |
86
+ | Annotations extractor | `annotations` | `core/annotations` |
87
+ | Slash extractor | `slash` | `core/slash` |
88
+ | At-directive extractor | `at-directive` | `core/at-directive` |
89
+ | Markdown-link extractor | `markdown-link` | `core/markdown-link` |
89
90
  | 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` |
91
+ | Broken-ref analyzer | `broken-ref` | `core/broken-ref` |
92
+ | Trigger-collision analyzer | `trigger-collision` | `core/trigger-collision` |
92
93
  | ASCII formatter | `ascii` | `core/ascii` |
93
- | Validate-all rule | `validate-all` | `core/validate-all` |
94
+ | Validate-all analyzer | `validate-all` | `core/validate-all` |
94
95
 
95
- Two namespaces are convention for built-ins:
96
+ Built-ins split between two namespaces:
96
97
 
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).
98
+ - **`core/`** — kernel-internal primitives, platform-agnostic. Owns every built-in analyzer (including `validate-all`), the ASCII formatter, and the cross-vendor extractors (`annotations`, `slash`, `at-directive`, `markdown-link`, `external-url-counter`) any Provider can rely on.
99
+ - **`claude/`** — the Claude Code Provider bundle: the Provider that classifies `.claude/{agents,commands,skills}` paths and parses their frontmatter. Vendor-specific bundles (`gemini`, `agent-skills`) follow the same shape — Provider only — since the syntax their nodes use is shared with Claude and lives in `core`.
99
100
 
100
101
  For your own plugin, the `id` you declare in `plugin.json` is the namespace for every extension the plugin contains. If your manifest declares `id: "my-plugin"` and your extension file declares `id: "foo-extractor"`, the kernel registers it as `my-plugin/foo-extractor`. You do **not** write the qualifier yourself — the loader injects it.
101
102
 
@@ -110,7 +111,7 @@ What this means in practice:
110
111
  The kernel guards against two foot-guns:
111
112
 
112
113
  - If the extension file injects a `pluginId` field that doesn't match `plugin.json#/id`, the loader emits `invalid-manifest` with a directed reason. The composed qualifier MUST come from `plugin.json` — 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
+ - The kebab-case pattern on the extension `id` deliberately forbids `/`. This keeps the analyzer "the qualifier always lives in the plugin id, never in the extension id" enforced by AJV.
114
115
 
115
116
  For built-ins, the reference impl's `src/extensions/built-ins.ts` declares each extension's `pluginId` (`core` or `claude`) explicitly — built-ins do not have a `plugin.json`, so the bundle declaration IS the source of truth for their namespace.
116
117
 
@@ -125,15 +126,15 @@ Every plugin and every built-in bundle declares a **granularity** that controls
125
126
 
126
127
  Built-in mapping:
127
128
 
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.
129
+ - **`claude`** / **`gemini`** / **`agent-skills`** — `granularity: 'bundle'`. Each vendor Provider bundle is enabled or disabled as a whole; today every such bundle ships only its Provider, so the toggle flips classification + frontmatter parsing for that platform.
130
+ - **`core`** — `granularity: 'extension'`. `sm plugins disable core/superseded` flips just the supersession analyzer; every other core extension (every other analyzer, the ASCII formatter, the cross-vendor extractors) stays live.
130
131
 
131
132
  Per-verb behaviour:
132
133
 
133
134
  | Command | Bundle granularity | Extension granularity |
134
135
  |---|---|---|
135
136
  | `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 enable claude/claude` | Rejected: `'claude' has granularity=bundle; use sm plugins enable claude`. | n/a (no bundle of granularity=bundle accepts qualified ids) |
137
138
  | `sm plugins disable core` | n/a | Rejected: same directed message as the bundle row above. |
138
139
  | `sm plugins disable core/superseded` | n/a | OK — persists `config_plugins['core/superseded'].enabled = 0`. |
139
140
 
@@ -150,7 +151,7 @@ In your own plugin's `plugin.json`, set `granularity` only when you opt into the
150
151
  "specCompat": "^1.0.0",
151
152
  "granularity": "extension",
152
153
  "extensions": [
153
- "./extensions/orphan-skill-rule.js",
154
+ "./extensions/orphan-skill-analyzer.js",
154
155
  "./extensions/csv-formatter.js"
155
156
  ]
156
157
  }
@@ -160,7 +161,7 @@ The default (`'bundle'`) is the right answer for almost every plugin — keep th
160
161
 
161
162
  ### Extractor `applicableKinds` — narrow the pipeline
162
163
 
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
+ An `Extractor` extension MAY declare an `applicableKinds` array on its manifest. When declared, the kernel runs the extractor **only** against nodes whose `kind` is in the list — the filter is fail-fast (no extractor context, no method call) so the extractor wastes zero CPU on nodes it cannot meaningfully process.
164
165
 
165
166
  | `applicableKinds` | Behaviour |
166
167
  |---|---|
@@ -171,36 +172,37 @@ An `Extractor` extension MAY declare an `applicableKinds` array on its manifest.
171
172
 
172
173
  There is no wildcard syntax (no `'*'`) — omitting the field IS the wildcard. The pattern is intentional: a literal absence is unambiguous, a string sentinel would invite typos that silently disable the extractor.
173
174
 
174
- Use case — a probabilistic tag-inferrer that only makes sense for skills:
175
+ Use case — a deterministic frontmatter-tag extractor that only makes sense for skills:
175
176
 
176
177
  ```javascript
177
178
  export default {
178
- id: 'tag-inferrer',
179
+ id: 'tag-extractor',
179
180
  kind: 'extractor',
180
- mode: 'probabilistic',
181
181
  version: '1.0.0',
182
- description: 'LLM-derived tag links for skill nodes.',
182
+ description: 'Lifts the `tags:` frontmatter array into `references` links for skill nodes.',
183
183
  emitsLinkKinds: ['references'],
184
- defaultConfidence: 'medium',
185
- scope: 'body',
184
+ defaultConfidence: 'high',
185
+ scope: 'frontmatter',
186
186
  applicableKinds: ['skill'],
187
187
  async extract(ctx) {
188
188
  // Never invoked for agents, commands, hooks, or notes — the kernel
189
189
  // skipped this node before reaching us.
190
- const tags = await ctx.runner.invoke({ /* prompt */ });
190
+ const tags = Array.isArray(ctx.frontmatter.tags) ? ctx.frontmatter.tags : [];
191
191
  for (const t of tags) {
192
192
  ctx.emitLink({
193
193
  source: ctx.node.path,
194
- target: t.path,
194
+ target: t,
195
195
  kind: 'references',
196
- confidence: 'medium',
197
- sources: ['tag-inferrer'],
196
+ confidence: 'high',
197
+ sources: ['tag-extractor'],
198
198
  });
199
199
  }
200
200
  },
201
201
  };
202
202
  ```
203
203
 
204
+ > **Why no `mode` field?** Extractors are deterministic-only — they sit on `sm scan`'s synchronous loop, and the loop must stay fast and reproducible. If you need an LLM to infer something about a node (tags, summaries, suspicious imports), write an `Action` instead and let the user dispatch it via `sm job submit action:<id>`. The Action's report flows back through the job lifecycle, not through the Extractor pipeline.
205
+
204
206
  **Unknown kinds are non-blocking.** An extractor that lists a kind no installed Provider declares (typo, missing Provider plugin) still loads with status `loaded`; `sm plugins doctor` surfaces an informational warning so the author sees the mismatch. The exit code of `doctor` is NOT promoted to 1 by this warning — the corresponding Provider may legitimately arrive later (e.g. when the user installs the matching plugin), and the load contract favours forward compatibility over rigid checks. The full set of "known kinds" is the union of every installed Provider's `defaultRefreshAction` keys.
205
207
 
206
208
  ---
@@ -244,13 +246,13 @@ Authors who explicitly review each minor's changelog **MAY** widen across the ne
244
246
 
245
247
  ## The six extension kinds
246
248
 
247
- The kernel knows six categories. Four are dual-mode (deterministic or probabilistic per [`architecture.md` §Execution modes](./architecture.md)); two are deterministic-only because they sit at the system boundaries.
249
+ The kernel knows six categories. Three are dual-mode (deterministic or probabilistic per [`architecture.md` §Execution modes](./architecture.md)); three are deterministic-only because they sit on the deterministic scan path.
248
250
 
249
251
  | Kind | Method | Receives | Returns | Mode |
250
252
  |---|---|---|---|---|
251
253
  | `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 |
253
- | `rule` | `evaluate(ctx)` | full graph | `Issue[]` | dual-mode |
254
+ | `extractor` | `extract(ctx)` | one node + body + frontmatter + callbacks | `void` (output via `ctx.emitLink` / `ctx.enrichNode` / `ctx.store`) | deterministic only |
255
+ | `analyzer` | `evaluate(ctx)` | full graph | `Issue[]` | dual-mode |
254
256
  | `action` | `run(ctx)` | one or more nodes | execution record | dual-mode |
255
257
  | `formatter` | `format(ctx)` | full graph | `string` | deterministic only |
256
258
  | `hook` | `on(ctx)` | a curated lifecycle event payload | `void` (reactions are side effects) | dual-mode |
@@ -259,15 +261,15 @@ The runtime instance you `export default` from an extension file MUST include bo
259
261
 
260
262
  ### Extractors
261
263
 
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).
264
+ Pure single-node analysis. **Never** read another node, the graph, or the database — cross-node reasoning is for analyzers. Spec at [`schemas/extensions/extractor.schema.json`](./schemas/extensions/extractor.schema.json).
263
265
 
264
266
  The runtime method is `extract(ctx) → void`. Output flows through three callbacks the kernel binds onto the context:
265
267
 
266
268
  - **`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 are simply overwritten via PRIMARY KEY conflict on the next re-extract through the A.9 cache and are never stale-flagged.
269
+ - **`ctx.enrichNode(partial)`** — merge canonical, kernel-curated properties onto the node's enrichment layer (persisted into `node_enrichments` per `db-schema.md`). **Strictly separate from the author-supplied frontmatter** — the latter is IMMUTABLE from any Extractor. Use the enrichment layer for facts the author did not write but the Extractor inferred (computed titles, summaries, signals derived from the body). Enrichment rows are overwritten via PRIMARY KEY conflict on the next re-extract through the A.9 cache and are never stale-flagged (Extractors are deterministic; re-running is free).
268
270
  - **`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).
269
271
 
270
- A probabilistic extractor additionally receives `ctx.runner` (the `RunnerPort`) for LLM dispatch.
272
+ Extractors are deterministic-only and never see `ctx.runner`. If an Extractor needs LLM-derived data on a node, that workload belongs in an Action — see [`architecture.md` §Execution modes](./architecture.md#execution-modes).
271
273
 
272
274
  > **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.
273
275
 
@@ -301,16 +303,16 @@ export default {
301
303
  ```
302
304
 
303
305
 
304
- ### Rules
306
+ ### Analyzers
305
307
 
306
- 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
+ Cross-node reasoning over the merged graph. Run after every Provider and extractor has completed. Spec at [`schemas/extensions/analyzer.schema.json`](./schemas/extensions/analyzer.schema.json).
307
309
 
308
- 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`.
310
+ Analyzers are dual-mode (`deterministic` default; `probabilistic` opt-in via the manifest). Deterministic analyzers run synchronously inside `sm scan` / `sm check` — same CI-safe baseline as today. Probabilistic analyzers are dispatched as queued jobs via the kernel's `RunnerPort`; they NEVER participate in the deterministic scan-time pipeline. Until the job subsystem ships at Step 10 the dispatch is stubbed: `sm scan` always skips probabilistic analyzers silently, and `sm check` exposes them via the opt-in `--include-prob` flag — the verb loads the plugin runtime, finds the registered prob analyzers (filtered by `--analyzers` and `-n` if set), and emits a stderr advisory naming them. The flag default is unchanged: deterministic-only, CI-safe. The `--async` companion is reserved for the future encoding (returns job ids without waiting once jobs land); today it is a no-op the advisory simply mentions. The flag does NOT extend to `sm scan` or `sm list`.
309
311
 
310
312
  ```javascript
311
313
  export default {
312
314
  id: 'orphan-skill',
313
- kind: 'rule',
315
+ kind: 'analyzer',
314
316
  version: '1.0.0',
315
317
  description: 'Flags skill nodes with zero inbound links.',
316
318
  evaluate(ctx) {
@@ -321,7 +323,7 @@ export default {
321
323
  return ctx.nodes
322
324
  .filter((n) => n.kind === 'skill' && (inboundCount.get(n.path) ?? 0) === 0)
323
325
  .map((n) => ({
324
- ruleId: 'orphan-skill',
326
+ analyzerId: 'orphan-skill',
325
327
  severity: 'info',
326
328
  message: `Skill ${n.path} has no inbound references.`,
327
329
  nodeIds: [n.path],
@@ -364,7 +366,7 @@ The eight hookable triggers (declaring any other event yields `invalid-manifest`
364
366
  1. `scan.started` — pre-scan setup (one per scan).
365
367
  2. `scan.completed` — post-scan reaction (one per scan).
366
368
  3. `extractor.completed` — aggregated per-Extractor outputs.
367
- 4. `rule.completed` — aggregated per-Rule outputs.
369
+ 4. `analyzer.completed` — aggregated per-Analyzer outputs.
368
370
  5. `action.completed` — Action executed on a node.
369
371
  6. `job.spawning` — pre-spawn of runner subprocess (Step 10).
370
372
  7. `job.completed` — most common trigger (Step 10).
@@ -398,7 +400,7 @@ export default {
398
400
 
399
401
  > **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.
400
402
 
401
- > **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
+ > **What hooks CANNOT do.** Hooks REACT to events; they cannot block emission, mutate the graph, alter Extractor / Analyzer output, or enrich nodes. For graph mutations use `extractor.enrichNode`; for graph reasoning use a Analyzer; for periodic background work use a probabilistic Action wrapped in a hook that submits the job. The single-responsibility split keeps the kernel's deterministic baseline stable.
402
404
 
403
405
  ### Providers / Actions
404
406
 
@@ -450,15 +452,15 @@ Every Provider declares two required top-level fields beyond the manifest base:
450
452
 
451
453
  ## Frontmatter validation — three-tier model
452
454
 
453
- 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`.
455
+ The kernel validates frontmatter on a graduated dial; tighter is opt-in. The model is normative — every conforming implementation MUST honour the three tiers — but the policy lives in **analyzers**, not the JSON Schemas. The schemas stay shape-only ([`schemas/frontmatter/base.schema.json`](./schemas/frontmatter/base.schema.json) declares `additionalProperties: true` deliberately) so that authors can extend their own nodes without forking the spec. Per-kind frontmatter schemas live with the **Provider** that emits the kind (declared via `provider.kinds[<kind>].schema`); spec only ships the universal `base`.
454
456
 
455
457
  | Tier | Mechanism | Behavior on unknown / non-conforming fields |
456
458
  |---|---|---|
457
- | **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). |
458
- | **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). |
459
+ | **0 — Default permissive** | `additionalProperties: true` on `base.schema.json` and on every per-kind frontmatter schema declared by an installed Provider. | Field passes silently, persists in `node.frontmatter`, and is available to every extension (extractors, analyzers, actions, formatters). |
460
+ | **1 — Built-in `unknown-field` analyzer** | Deterministic Analyzer shipped with the kernel. Always active. | Emits an Issue with `severity: 'warn'` for every key outside the documented catalog (base + the matched kind's schema). |
459
461
  | **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. |
460
462
 
461
- > 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).
463
+ > Tier 1 is normative behavior — the kernel ships the analyzer out-of-the-box. Disabling it is not a supported configuration; an unknown key that you want to keep is either (a) moved under `metadata.*` (the spec permits free-form keys there), or (b) carried as-is at the cost of a persistent `warn`-severity issue (informational unless you run Tier 2).
462
464
 
463
465
  ### Worked example — same node, three tiers
464
466
 
@@ -474,20 +476,20 @@ priority: high # ← author-defined, not in any schema
474
476
  ---
475
477
  ```
476
478
 
477
- **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.
479
+ **Tier 0 (default permissive — no project config, default scan).** The field validates fine. `node.frontmatter.priority === 'high'` for any extractor / analyzer / action that reads the node. No issues raised by the schema itself.
478
480
 
479
- **Tier 1 (always-active `unknown-field` rule).** After `sm scan`, the rule emits:
481
+ **Tier 1 (always-active `unknown-field` analyzer).** After `sm scan`, the analyzer emits:
480
482
 
481
483
  ```jsonc
482
484
  {
483
- "ruleId": "unknown-field",
485
+ "analyzerId": "unknown-field",
484
486
  "severity": "warn",
485
- "message": "Unknown frontmatter field 'priority' on skill node 'code-reviewer'. Add it to a custom rule or move it under metadata.* if intentional.",
487
+ "message": "Unknown frontmatter field 'priority' on skill node 'code-reviewer'. Add it to a custom analyzer or move it under metadata.* if intentional.",
486
488
  "nodeIds": ["code-reviewer.md"]
487
489
  }
488
490
  ```
489
491
 
490
- `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.
492
+ `sm scan` exits `0` (warnings do not fail the verb). The author can either move the key under `metadata.*` — where [`schemas/frontmatter/base.schema.json`](./schemas/frontmatter/base.schema.json) already permits free-form keys, so the `unknown-field` analyzer does not match — or accept the persistent warning and add a Analyzer that consumes `priority` for whatever cross-node logic motivated the field.
491
493
 
492
494
  **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.
493
495
 
@@ -503,15 +505,15 @@ The CLI flag wins when both are set (see the `--strict` description on `sm scan`
503
505
 
504
506
  ### Why no "schema-extender" plugin kind
505
507
 
506
- 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:
508
+ A reasonable next thought is: "I want my plugin to widen the frontmatter schema so my custom keys are first-class." The spec deliberately rejects that route. The accepted path is to write a deterministic **Analyzer** that:
507
509
 
508
510
  1. Reads the candidate keys from `node.frontmatter` (which Tier 0 already exposes).
509
511
  2. Validates them against whatever shape your domain expects (regex, enum, cross-node consistency).
510
512
  3. Emits Issues for violations.
511
513
 
512
- 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.
514
+ The trade-off is intentional: a "schema-extender" kind would force every consumer (the kernel, the storage layer, every other plugin, the UI) to re-resolve the active schema set per scan. A Analyzer-driven approach keeps the kernel's parser one-pass and the validation surface composable — the union of every author's analyzers is the project's policy.
513
515
 
514
- 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.
516
+ If the analyzer needs to be CI-blocking, the analyzer itself emits the Issue at `severity: 'error'`. `--strict` / `scan.strict` apply only to the kernel's own frontmatter-shape and `unknown-field` warnings; plugin-authored analyzers pick their own severity directly.
515
517
 
516
518
  ---
517
519
 
@@ -604,11 +606,11 @@ The kernel validates the row passed to `ctx.store.write(table, row)` against the
604
606
 
605
607
  ## Execution modes
606
608
 
607
- Extractor / Rule / Action declare `mode` in the manifest with default `deterministic`. Provider / Formatter must NOT declare `mode`.
609
+ Analyzer / Action / Hook declare `mode` in the manifest. Action's `mode` is required; Analyzer and Hook default to `deterministic`. Provider / Extractor / Formatter must NOT declare `mode` — they are deterministic-only by spec.
608
610
 
609
611
  ```jsonc
610
- // deterministic extractor — default, runs in sm scan
611
- { "kind": "extractor", "id": "my-extractor", "mode": "deterministic", ... }
612
+ // extractor — deterministic by spec, no mode field
613
+ { "kind": "extractor", "id": "my-extractor", ... }
612
614
  ```
613
615
 
614
616
  ```jsonc
@@ -628,7 +630,7 @@ The full per-kind capability matrix lives in [`architecture.md` §Execution mode
628
630
  npm install --save-dev @skill-map/testkit
629
631
  ```
630
632
 
631
- 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.
633
+ The testkit ships builders, per-kind context factories, in-memory KV / runner fakes, and high-level `runExtractorOnFixture` / `runAnalyzerOnGraph` / `runFormatterOnGraph` helpers. Most plugin tests reduce to one line per assertion.
632
634
 
633
635
  ```javascript
634
636
  import { test } from 'node:test';
@@ -647,7 +649,7 @@ test('emits one reference per [[ref:<name>]] token', async () => {
647
649
  });
648
650
  ```
649
651
 
650
- 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.
652
+ For analyzer tests, `runAnalyzerOnGraph(analyzer, { context: { nodes, links } })` returns the issue array. For formatter tests, `runFormatterOnGraph(formatter, { context: { nodes, links, issues } })` returns the formatted string.
651
653
 
652
654
  For probabilistic extensions, `makeFakeRunner()` queues canned responses and records every call:
653
655
 
@@ -675,7 +677,7 @@ Full surface in `@skill-map/testkit/index.ts`.
675
677
  | `incompatible-spec` | manifest parsed but `semver.satisfies` failed against the installed spec. | Plugin built against an older / newer spec. |
676
678
  | `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. |
677
679
  | `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. |
678
- | `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. |
680
+ | `id-collision` | two plugins reachable from different roots declared the same `id`. Both collided plugins receive this status; no precedence analyzer applies. | Project-local plugin and a user-global plugin (or two `--plugin-dir` plugins) sharing an id. Rename one and rerun. |
679
681
 
680
682
  `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.
681
683
 
@@ -713,7 +715,7 @@ Field-by-field:
713
715
  | `location` | `'namespaced'` \| `'root'` | `'namespaced'` | Where the key lands inside the sidecar (see below). |
714
716
  | `ownership` | `'shared'` \| `'exclusive'` | `'shared'` | Conflict policy. REQUIRED to be `'exclusive'` when `location: 'root'`. |
715
717
 
716
- The `schema` field is **inline** — an object literal in the manifest, not a `$ref` to a file. Aligns with how the extractor / rule / action schemas already declare other inline shapes; avoids an extra path-resolution step at load time.
718
+ The `schema` field is **inline** — an object literal in the manifest, not a `$ref` to a file. Aligns with how the extractor / analyzer / action schemas already declare other inline shapes; avoids an extra path-resolution step at load time.
717
719
 
718
720
  ### Namespacing default vs root opt-in
719
721
 
@@ -721,7 +723,7 @@ By default a contribution lands inside the plugin's `<plugin-id>:` block at the
721
723
 
722
724
  ```yaml
723
725
  # .claude/agents/architect.sm
724
- for:
726
+ identity:
725
727
  path: .claude/agents/architect.md
726
728
  bodyHash: ...
727
729
  frontmatterHash: ...
@@ -740,10 +742,10 @@ auditor:
740
742
  Opting into a top-level (root) key requires `location: 'root'` AND `ownership: 'exclusive'`. The pair travels together — a top-level reserved key cannot be silently shared between plugins, because `.sm` writes deep-merge per the `SidecarStore` contract and a shared root key would route non-deterministically. Use root sparingly: for every plugin that contributes a root key, the kernel reserves that name across the whole installed-plugin surface.
741
743
 
742
744
  ```js
743
- // compliance-plugin/extensions/rule.js
745
+ // compliance-plugin/extensions/analyzer.js
744
746
  export default {
745
747
  id: 'compliance-checker',
746
- kind: 'rule',
748
+ kind: 'analyzer',
747
749
  // ...
748
750
  annotationContributions: {
749
751
  compliance: {
@@ -766,13 +768,13 @@ The resulting sidecar block:
766
768
 
767
769
  ```yaml
768
770
  # .claude/agents/architect.sm
769
- for: { path: ..., bodyHash: ..., frontmatterHash: ... }
771
+ identity: { path: ..., bodyHash: ..., frontmatterHash: ... }
770
772
  compliance:
771
773
  audit: sox-2026
772
774
  dueAt: 2026-12-31T23:59:59Z
773
775
  ```
774
776
 
775
- ### Ownership rules
777
+ ### Ownership analyzers
776
778
 
777
779
  - `shared` (default) — multiple plugins MAY write the same key. Every plugin gets its own namespaced block; `last-write-wins` is per-`(plugin, key)` tuple inside `FilesystemSidecarStore.applyPatch`. Two plugins on the SAME namespaced key from the same plugin id is structurally impossible (one extension per kind per plugin id by spec), so the only collision surface is intra-extension.
778
780
  - `exclusive` — only this plugin may write the key. The kernel rejects any other plugin that tries to claim the same `(key, location: 'root')` tuple as `exclusive`. `exclusive` + `namespaced` is permitted but redundant in practice (the namespace already isolates by plugin id); use it as documentation when you want the manifest to scream "no other plugin should ever write this".
@@ -785,13 +787,13 @@ This is the only fatal path on the plugin-load surface. Every other failure mode
785
787
 
786
788
  ### Tier-1 typo guard (`core/unknown-field`)
787
789
 
788
- The built-in `core/unknown-field` Rule walks every parsed `.sm` and emits a `warn` issue per truly-unknown key. Three surfaces are checked:
790
+ The built-in `core/unknown-field` Analyzer walks every parsed `.sm` and emits a `warn` issue per truly-unknown key. Three surfaces are checked:
789
791
 
790
792
  1. Inside `annotations:` — keys not in `annotations.schema.json`'s curated catalog (the ~25 conventional fields). Plugins do NOT contribute to `annotations:`; that block is skill-map-curated.
791
793
  2. At the sidecar root — keys outside the four reserved blocks (`for`, `annotations`, `settings`, `audit`) that are also NOT a registered plugin namespace `<plugin-id>:` AND NOT a registered `location: 'root'` contribution.
792
794
  3. Inside a registered `<plugin-id>:` namespace — values that fail the schema declared by the owning plugin's `annotationContributions[<key>].schema`.
793
795
 
794
- The rule never blocks a scan; advisories surface through the standard issue channel (CLI, UI, REST). When you ship a contribution, the loader compiles your inline schema, the runtime catalog publishes it, and `core/unknown-field` automatically validates user writes against your declaration.
796
+ The analyzer never blocks a scan; advisories surface through the standard issue channel (CLI, UI, REST). When you ship a contribution, the loader compiles your inline schema, the runtime catalog publishes it, and `core/unknown-field` automatically validates user writes against your declaration.
795
797
 
796
798
  ### Runtime catalog accessor
797
799
 
@@ -806,11 +808,314 @@ Pure read; no side effects. Built-in catalog fields from `annotations.schema.jso
806
808
 
807
809
  ---
808
810
 
811
+ ## View contributions
812
+
813
+ > **Status.** Sibling system to annotation contributions, designed to let plugins surface per-node data in the UI without shipping any UI code. Plugin authors pick a **slot** by name from a closed kernel catalog; the slot fixes both the renderer and the payload shape. Authors declare per-node emissions in their extension manifest and emit payloads at scan time via `ctx.emitContribution(id, payload)`. See [`architecture.md`](./architecture.md) §View contribution system for the normative contract.
814
+
815
+ ### What it solves
816
+
817
+ Today, the only way a plugin can surface UI is implicit: extractors emit `Link` (rendered by the kernel-built `linked-nodes-panel`), analyzers emit `Issue` (rendered by the kernel-built issues panel), providers ship `kinds[*].ui` styling, and one-off plugins write into the sidecar via `annotationContributions`. The moment your extractor wants to surface anything else — a counter on each card, a stat breakdown panel in the inspector, a tree showing parsed structure, a per-node tag — there is no path. View contributions fill that gap. You declare what to surface and where; the kernel validates the payload against the slot's shape and the UI renders.
818
+
819
+ ### What you NEVER write
820
+
821
+ - HTML, CSS, JavaScript, or Angular components.
822
+ - JSON Schema for your contributions or your settings.
823
+ - The renderer component that draws your contribution.
824
+
825
+ You DO write:
826
+
827
+ - The `slot` name (one of 15 closed-catalog values). The slot you pick fixes both where the data renders and what payload shape the kernel will accept.
828
+ - Optional `label`, `tooltip`, `icon`, `emptyText`, `emitWhenEmpty` per contribution.
829
+ - The per-node payload your `extract(ctx)` emits via `ctx.emitContribution(...)`.
830
+
831
+ ### Manifest shape
832
+
833
+ Inside any extension manifest (`IExtractor`, `IAnalyzer`, ...), declare a `viewContributions` map next to `annotationContributions`. Each key is your local contribution id; the value picks a slot.
834
+
835
+ ```jsonc
836
+ {
837
+ "id": "keyword-finder",
838
+ "kind": "extractor",
839
+ "viewContributions": {
840
+ "breakdown": {
841
+ "slot": "inspector.body.panel.breakdown",
842
+ "label": "Keyword hits",
843
+ "emptyText": "No matches."
844
+ },
845
+ "total": {
846
+ "slot": "card.footer.left.counter",
847
+ "icon": "🔍",
848
+ "label": "kw",
849
+ "emitWhenEmpty": false
850
+ }
851
+ }
852
+ }
853
+ ```
854
+
855
+ Field reference (full schema in [`schemas/view-slots.schema.json`](./schemas/view-slots.schema.json) at `$defs/IViewContribution`):
856
+
857
+ | Field | Required | Notes |
858
+ |---|---|---|
859
+ | `slot` | yes | One of the 15 catalog names (see below). Unknown name → `invalid-manifest` at load. |
860
+ | `label` | no | Short human-readable label. English-only per [`AGENTS.md`](../AGENTS.md) (`Externalized texts, not internationalized`). |
861
+ | `tooltip` | no | Hover tooltip on the chip / panel header. |
862
+ | `icon` | no, but required for counter slots and `card.title.right` | Single string. If matches Unicode `\p{Extended_Pictographic}` → emoji. Otherwise → PrimeIcons name (no `pi-` prefix). |
863
+ | `emptyText` | no | Text shown when payload is empty AND `emitWhenEmpty: true`. |
864
+ | `emitWhenEmpty` | no, default `false` | When `false`, kernel drops empty payloads silently so the slot stays clean. |
865
+
866
+ ### Slot catalog (closed)
867
+
868
+ The kernel ships exactly these 15 slots. Each slot fixes a renderer + a payload shape; multiple slots may share a payload shape (e.g. all counter slots accept `{ value }`). Adding a slot requires a spec / UI / scaffolder round-trip — discuss in [`ROADMAP.md`](../ROADMAP.md) before opening a PR.
869
+
870
+ | Slot | Payload shape | Renderer |
871
+ |---|---|---|
872
+ | `card.title.right` | `{ icon?, severity?, tooltip? }` | icon marker (manifest icon required) |
873
+ | `card.subtitle.left` | `{ value: integer ≥ 0, severity?, tooltip? }` | counter chip (manifest icon required) |
874
+ | `card.footer.left.counter` | `{ value: integer ≥ 0, severity?, tooltip? }` | counter chip (manifest icon required) |
875
+ | `card.footer.right` | `{ value: integer ≥ 0, severity?, tooltip? }` | counter chip (manifest icon required) |
876
+ | `graph.node.alert` | `{ icon?, severity?, count?, tooltip? }` | graph corner badge |
877
+ | `inspector.header.badge.counter` | `{ value: integer ≥ 0, severity?, tooltip? }` | counter chip (manifest icon required) |
878
+ | `inspector.header.badge.tag` | `{ label, severity?, tooltip? }` | tag chip |
879
+ | `inspector.body.panel.breakdown` | `{ entries: Array<{ label, value, tooltip? }> }` (≤ 20) | bar chart panel |
880
+ | `inspector.body.panel.records` | `{ columns: ≤6, rows: ≤50 }` | table panel |
881
+ | `inspector.body.panel.tree` | recursive `{ label, marker?, children? }` (depth ≤ 6, total ≤ 200) | tree panel |
882
+ | `inspector.body.panel.key-values` | `{ entries: Array<{ key, value, tooltip? }> }` (≤ 50) | definition list panel |
883
+ | `inspector.body.panel.link-list` | `{ entries: Array<{ path, label?, kind? }> }` (≤ 100) | clickable list panel |
884
+ | `inspector.body.panel.markdown` | `{ markdown }` (≤ 4096 chars, sanitized) | sanitized markdown panel |
885
+ | `topbar.actions.indicator` | `{ value, label?, severity?, tooltip? }` | scope chip |
886
+
887
+ Per-slot semantics, edge cases, and exact payload schemas live in [`view-slots.md`](./view-slots.md) (catalog reference) and [`schemas/view-slots.schema.json`](./schemas/view-slots.schema.json) at `$defs/payloads/<slot>`. Read those before emitting.
888
+
889
+ ### Emit path
890
+
891
+ Inside `extract(ctx)`, call:
892
+
893
+ ```ts
894
+ ctx.emitContribution('breakdown', {
895
+ entries: Object.entries(perKeyword).map(([label, value]) => ({ label, value })),
896
+ });
897
+
898
+ ctx.emitContribution('total', { value: total });
899
+ ```
900
+
901
+ The first argument is the manifest Record key (`'breakdown'` or `'total'` above), NOT the slot name. The kernel composes the qualified id from your plugin id, extension id, and this Record key, and looks up the slot you declared in the manifest to validate the payload.
902
+
903
+ The kernel validates the payload against the slot's payload schema in `view-slots.schema.json#/$defs/payloads/<slot>`. Off-shape payloads emit an `extension.error` event and drop silently — same posture as `emitLink` rejecting links not in your `emitsLinkKinds`.
904
+
905
+ For `topbar.actions.indicator`, analyzers use `ctx.emitScopeContribution(id, payload)` (extractors do not see this method — scope-level emission lives in analyzer context).
906
+
907
+ ### Multi-slot rendering
908
+
909
+ Want the same data in two surfaces? Declare two contributions, each pointing at a different slot. There is no broadcast — the slot you pick is the slot the data renders in.
910
+
911
+ ```jsonc
912
+ "viewContributions": {
913
+ "mentionsFooter": {
914
+ "slot": "card.footer.left.counter",
915
+ "icon": "@",
916
+ "label": "mentions"
917
+ },
918
+ "mentionsBadge": {
919
+ "slot": "inspector.header.badge.counter",
920
+ "icon": "@",
921
+ "label": "mentions"
922
+ }
923
+ }
924
+ ```
925
+
926
+ Then emit twice (typically with the same value):
927
+
928
+ ```ts
929
+ ctx.emitContribution('mentionsFooter', { value: count });
930
+ ctx.emitContribution('mentionsBadge', { value: count });
931
+ ```
932
+
933
+ This is intentional: one source of truth per surface, no surprise duplication when a renderer changes its mind about which slots to draw in.
934
+
935
+ ### Settings
936
+
937
+ User-configurable settings live at the manifest root in `settings: Record<string, ISettingDeclaration>`. Each entry picks an `input-type` from a closed catalog. You NEVER write JSON Schema for settings.
938
+
939
+ ```jsonc
940
+ {
941
+ "id": "keyword-finder",
942
+ "version": "1.0.0",
943
+ "specCompat": "^0.20.0",
944
+ "catalogCompat": "^1.0.0",
945
+ "extensions": ["./extension.js"],
946
+ "settings": {
947
+ "keywords": {
948
+ "type": "string-list",
949
+ "label": "Keywords to track",
950
+ "description": "Words counted across each node's body.",
951
+ "default": ["TODO", "FIXME"],
952
+ "min": 1
953
+ },
954
+ "caseSensitive": {
955
+ "type": "boolean-flag",
956
+ "label": "Case-sensitive matching",
957
+ "default": false
958
+ }
959
+ }
960
+ }
961
+ ```
962
+
963
+ The 10 input-types:
964
+
965
+ | Type | Value at runtime | Use for |
966
+ |---|---|---|
967
+ | `string-list` | `string[]` | keyword lists, ignore patterns |
968
+ | `single-string` | `string` | URLs, names, identifiers |
969
+ | `boolean-flag` | `boolean` | toggles |
970
+ | `integer` | `number` (always integer) | counts, thresholds |
971
+ | `enum-pick` | `string` | pick one from a closed set |
972
+ | `enum-multipick` | `string[]` | pick zero or more |
973
+ | `path-glob` | `string` or `string[]` | glob patterns |
974
+ | `regex` | `string` | ECMAScript regex (body, no `/` delimiters) |
975
+ | `secret` | `string` | tokens, passwords (encrypted at rest) |
976
+ | `key-value-list` | `Array<{ key, value }>` | custom maps, alias dictionaries |
977
+
978
+ Per-type parameter schema lives in [`schemas/input-types.schema.json`](./schemas/input-types.schema.json) at `$defs/Setting_<TypeName>`.
979
+
980
+ The kernel exposes resolved settings to extractors via `ctx.settings.<settingId>`. Settings are read once at extractor invocation; **changing a setting requires `sm scan` to re-emit** affected contributions. The UI surfaces a "settings changed, rescan needed" indicator.
981
+
982
+ ### Catalog version
983
+
984
+ The catalog of slots and input-types evolves on its own cadence. Declare a semver range in your manifest:
985
+
986
+ ```jsonc
987
+ { "catalogCompat": "^1.0.0" }
988
+ ```
989
+
990
+ Independent of `specCompat` (the spec version range). Mismatch surfaces as `incompatible-catalog` plugin status; resolution is `sm plugins upgrade <id>`, which runs registered migrations from the kernel's closed migration registry. When auto-migration is impossible (a slot you used was removed entirely), the upgrade verb fails loud (CLI exit ≠ 0 + console message) and your manifest needs a manual edit.
991
+
992
+ `catalogCompat` is **optional**: omit it if your plugin declares no `viewContributions` and no `settings`. The doctor verb (`sm plugins doctor`) warns if such a plugin actually emits via `viewContributions` or declares `settings`.
993
+
994
+ ### Worked example — `acme/keyword-finder`
995
+
996
+ Full plugin walkthrough:
997
+
998
+ ```
999
+ plugins/acme-keyword-finder/
1000
+ ├── plugin.json ← manifest with settings + catalogCompat
1001
+ └── extensions/
1002
+ └── extractor.js ← extract() with ctx.emitContribution
1003
+ ```
1004
+
1005
+ `plugin.json`:
1006
+
1007
+ ```jsonc
1008
+ {
1009
+ "id": "acme-keyword-finder",
1010
+ "version": "1.0.0",
1011
+ "specCompat": "^0.20.0",
1012
+ "catalogCompat": "^1.0.0",
1013
+ "extensions": ["./extensions/extractor.js"],
1014
+ "settings": {
1015
+ "keywords": {
1016
+ "type": "string-list",
1017
+ "label": "Keywords to track",
1018
+ "default": ["TODO", "FIXME"],
1019
+ "min": 1
1020
+ }
1021
+ }
1022
+ }
1023
+ ```
1024
+
1025
+ `extensions/extractor.js`:
1026
+
1027
+ ```js
1028
+ export const extractor = {
1029
+ id: 'keyword-finder',
1030
+ pluginId: 'acme-keyword-finder',
1031
+ kind: 'extractor',
1032
+ version: '1.0.0',
1033
+ description: 'Counts configured keywords per node.',
1034
+ stability: 'stable',
1035
+ mode: 'deterministic',
1036
+ emitsLinkKinds: [],
1037
+ defaultConfidence: 'high',
1038
+ scope: 'body',
1039
+
1040
+ viewContributions: {
1041
+ breakdown: {
1042
+ slot: 'inspector.body.panel.breakdown',
1043
+ label: 'Keyword hits',
1044
+ emptyText: 'No matches.',
1045
+ },
1046
+ total: {
1047
+ slot: 'card.footer.left.counter',
1048
+ icon: '🔍',
1049
+ label: 'kw',
1050
+ emitWhenEmpty: false,
1051
+ },
1052
+ },
1053
+
1054
+ extract(ctx) {
1055
+ const keywords = ctx.settings.keywords;
1056
+ const perKeyword = Object.create(null);
1057
+ let total = 0;
1058
+
1059
+ for (const kw of keywords) {
1060
+ const re = new RegExp(`\\b${escapeRegex(kw)}\\b`, 'gi');
1061
+ const n = (ctx.body.match(re) ?? []).length;
1062
+ perKeyword[kw] = n;
1063
+ total += n;
1064
+ }
1065
+
1066
+ ctx.emitContribution('breakdown', {
1067
+ entries: Object.entries(perKeyword).map(([label, value]) => ({ label, value })),
1068
+ });
1069
+
1070
+ if (total > 0) {
1071
+ ctx.emitContribution('total', { value: total });
1072
+ }
1073
+ },
1074
+ };
1075
+
1076
+ function escapeRegex(s) {
1077
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1078
+ }
1079
+ ```
1080
+
1081
+ After `sm scan`, the UI surfaces:
1082
+
1083
+ - A `🔍 N` chip on every node's card (when `total > 0`).
1084
+ - A "Keyword hits" panel in the inspector body for every node, with a horizontal bar chart per keyword.
1085
+
1086
+ The plugin author wrote zero UI code, zero CSS, zero HTML, zero JSON Schema, and zero renderer logic.
1087
+
1088
+ ### Scaffolder
1089
+
1090
+ Hand-writing the manifest is supported but discouraged. Run:
1091
+
1092
+ ```sh
1093
+ sm plugins create
1094
+ ```
1095
+
1096
+ The scaffolder walks you through the closed catalogs (settings + view contribution slots) and emits a complete plugin directory with manifest, extension stub, test scaffold, and README. Hand-writing remains valid because the spec is the source of truth, but the scaffolder catches invalid slot picks at author time, while a hand-written manifest only fails at load time.
1097
+
1098
+ Companion verbs:
1099
+
1100
+ - `sm plugins doctor` — surfaces `incompatible-catalog`, `invalid-manifest`, deprecated-slot usage.
1101
+ - `sm plugins upgrade <id>` — applies catalog migrations registered in the kernel.
1102
+ - `sm plugins slots list` — prints the catalog (slots + input-types), flags deprecated entries.
1103
+
1104
+ ### Watch out for
1105
+
1106
+ - **Pick exactly one slot per contribution.** The slot determines both the renderer and the payload shape. If you want the same data in two surfaces (e.g. card chip + inspector badge), declare two contributions in the manifest, one per slot, and emit twice.
1107
+ - **Don't write JSON Schema.** Settings use `type` from the input-type catalog; view contributions use `slot` from the slot catalog.
1108
+ - **Don't mutate payloads after emission.** The kernel validates and serializes at emit time; a plugin holding a reference to the emitted payload and mutating it later has undefined behavior.
1109
+ - **Don't emit HTML.** `node-markdown` accepts markdown with a sanitized allow-list; `[innerHTML]` bindings in the renderer are lint-banned (see [`context/view-contributions.md`](../context/view-contributions.md)).
1110
+ - **Don't try to read another plugin's contributions.** The BFF rejects cross-plugin reads at the route level.
1111
+
1112
+ ---
1113
+
809
1114
  ## See also
810
1115
 
811
1116
  - [`architecture.md`](./architecture.md) — extension contract, ports, execution modes.
812
1117
  - [`plugin-kv-api.md`](./plugin-kv-api.md) — Storage Mode A normative API.
813
- - [`db-schema.md`](./db-schema.md) — table catalog and migration rules (Mode B).
1118
+ - [`db-schema.md`](./db-schema.md) — table catalog and migration analyzers (Mode B).
814
1119
  - [`schemas/plugins-registry.schema.json`](./schemas/plugins-registry.schema.json) — normative manifest shape.
815
1120
  - [`schemas/extensions/*.schema.json`](./schemas/extensions) — per-kind manifest schemas.
816
1121
 
@@ -820,8 +1125,8 @@ Pure read; no side effects. Built-in catalog fields from `annotations.schema.jso
820
1125
 
821
1126
  - 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).
822
1127
  - The six plugin statuses (`loaded` / `disabled` / `incompatible-spec` / `invalid-manifest` / `load-error` / `id-collision`) are stable; adding a seventh status is a minor bump.
823
- - The structural rule **directory name MUST equal manifest id** is stable; relaxing it (allowing mismatch) is a major bump.
824
- - 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.
1128
+ - The structural analyzer **directory name MUST equal manifest id** is stable; relaxing it (allowing mismatch) is a major bump.
1129
+ - The cross-root id-collision analyzer (both sides blocked, no precedence) is stable; introducing precedence (e.g. project root wins over global) is a major bump.
825
1130
  - 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).
826
1131
  - 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").
827
1132
  - The recommended `specCompat` strategy is descriptive prose; revising the recommendation does not require a spec bump as long as the schema stays unchanged.