@skill-map/spec 0.26.0 → 0.28.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 (28) hide show
  1. package/CHANGELOG.md +334 -0
  2. package/architecture.md +29 -30
  3. package/cli-contract.md +55 -20
  4. package/conformance/README.md +4 -0
  5. package/conformance/cases/no-global-scope.json +13 -0
  6. package/conformance/cases/sidecar-end-to-end.json +4 -4
  7. package/conformance/coverage.md +7 -4
  8. package/conformance/fixtures/plugin-missing-ui/.skill-map/plugins/bad-provider/kinds/markdown/kind.json +3 -0
  9. package/conformance/fixtures/plugin-missing-ui/.skill-map/plugins/bad-provider/kinds/markdown/schema.json +6 -0
  10. package/conformance/fixtures/plugin-missing-ui/.skill-map/plugins/bad-provider/plugin.json +3 -2
  11. package/conformance/fixtures/plugin-missing-ui/.skill-map/plugins/bad-provider/providers/bad-provider/index.js +17 -0
  12. package/db-schema.md +7 -7
  13. package/index.json +26 -21
  14. package/package.json +1 -1
  15. package/plugin-author-guide.md +94 -47
  16. package/schemas/extensions/action.schema.json +28 -44
  17. package/schemas/extensions/analyzer.schema.json +34 -32
  18. package/schemas/extensions/base.schema.json +26 -55
  19. package/schemas/extensions/extractor.schema.json +35 -22
  20. package/schemas/extensions/formatter.schema.json +4 -14
  21. package/schemas/extensions/hook.schema.json +2 -9
  22. package/schemas/extensions/provider-kind.schema.json +71 -0
  23. package/schemas/extensions/provider.schema.json +3 -90
  24. package/schemas/plugins-registry.schema.json +11 -27
  25. package/schemas/project-config.schema.json +0 -11
  26. package/schemas/scan-result.schema.json +1 -6
  27. package/schemas/user-settings.schema.json +39 -0
  28. package/conformance/fixtures/plugin-missing-ui/.skill-map/plugins/bad-provider/provider.js +0 -30
@@ -2,40 +2,53 @@
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 Analyzers. 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.",
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 the global closed enum of link kinds before persistence; per-extractor whitelisting was retired with structure-as-truth, the global enum is the contract), `ctx.enrichNode(partial)` merges author-canonical properties into the kernel's enrichment layer (separate from the author-supplied frontmatter), `ctx.emitContribution(id, payload)` emits per-node view contributions validated against the slot payload schema, 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 Analyzers. 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.",
6
6
  "allOf": [
7
7
  { "$ref": "base.schema.json" }
8
8
  ],
9
9
  "type": "object",
10
- "required": ["id", "kind", "version", "emitsLinkKinds", "defaultConfidence"],
10
+ "required": ["version", "description"],
11
11
  "unevaluatedProperties": false,
12
12
  "properties": {
13
- "kind": { "const": "extractor" },
14
- "emitsLinkKinds": {
15
- "type": "array",
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: { ... } }`).",
17
- "items": {
18
- "type": "string",
19
- "enum": ["invokes", "references", "mentions", "supersedes"]
20
- }
21
- },
22
- "defaultConfidence": {
23
- "type": "string",
24
- "enum": ["high", "medium", "low"],
25
- "description": "Confidence attached to emitted links by default. Extractors MAY override per-link at emission time."
26
- },
27
13
  "scope": {
28
14
  "type": "string",
29
15
  "enum": ["frontmatter", "body", "both"],
30
16
  "default": "both",
31
17
  "description": "Which part of the node this extractor consumes. The kernel passes only the declared scope to the extractor, a `frontmatter` extractor that tries to read `body` receives an empty string."
32
18
  },
33
- "applicableKinds": {
34
- "type": "array",
35
- "minItems": 1,
36
- "items": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" },
37
- "uniqueItems": true,
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."
19
+ "precondition": {
20
+ "type": "object",
21
+ "additionalProperties": false,
22
+ "description": "Optional declarative filter. The extractor runs only on nodes that satisfy every declared sub-filter. Same shape used by Analyzer and Action so the kernel ships a single matcher.",
23
+ "properties": {
24
+ "kind": {
25
+ "type": "array",
26
+ "minItems": 1,
27
+ "uniqueItems": true,
28
+ "items": {
29
+ "type": "string",
30
+ "pattern": "^[a-z][a-z0-9-]*/[a-z][a-zA-Z0-9]*$"
31
+ },
32
+ "description": "Qualified node kinds the extractor accepts, written as `<provider-plugin>/<kindName>` (e.g. `claude/agent`). Qualified by design so two providers declaring the same kind name never collide. Unknown qualified kinds (no provider declares them) load OK but emit a `precondition-kind-unknown` warning in `sm plugins doctor`."
33
+ },
34
+ "provider": {
35
+ "type": "array",
36
+ "minItems": 1,
37
+ "uniqueItems": true,
38
+ "items": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" },
39
+ "description": "Provider ids whose nodes the extractor accepts. Coarser than `kind`. Useful when an extractor applies to every kind a given provider declares."
40
+ }
41
+ }
42
+ },
43
+ "ui": {
44
+ "type": "object",
45
+ "additionalProperties": {
46
+ "$ref": "../view-slots.schema.json#/$defs/IViewContribution"
47
+ },
48
+ "propertyNames": {
49
+ "pattern": "^[a-z][a-z0-9]*(-[a-z0-9]+)*$"
50
+ },
51
+ "description": "Plugin-contributed view contributions. Each entry declares one rendering surface in the UI by picking a `slot` name from the closed catalog at `view-slots.schema.json#/$defs/SlotName`. The kernel validates the manifest at load (`invalid-manifest` on unknown slot); the extractor emits per-node payloads via `ctx.emitContribution(<contributionId>, payload)` during scan; the runtime validates payloads against the slot's payload schema in `view-slots.schema.json#/$defs/payloads/<slot>`; off-shape 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`. Only `extractor` and `analyzer` kinds may declare this field. Renamed from `viewContributions` with the structure-as-truth refactor."
39
52
  }
40
53
  }
41
54
  }
@@ -2,28 +2,18 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "https://skill-map.dev/spec/v0/extensions/formatter.schema.json",
4
4
  "title": "ExtensionFormatter",
5
- "description": "Manifest shape for a `Formatter` extension. A formatter serializes the graph (or a filtered subgraph) into a string in a declared format. Formatters are invoked by `sm graph --format <format>` and `sm export`. Formatters are deterministic-only, they sit at the graph-to-string boundary and their output MUST be byte-deterministic for the same input graph (the snapshot-test suite relies on this). The `mode` field MUST NOT appear in formatter manifests. Probabilistic narrators of the graph are a valid product but they live in jobs and emit Findings, not in formatters.",
5
+ "description": "Manifest shape for a `Formatter` extension. A formatter serializes the graph (or a filtered subgraph) into a string in a declared format. The format id comes from the formatter's folder name (structure-as-truth, `<plugin>/formatters/<formatId>/index.ts`), it is NOT a manifest field. Invoked by `sm graph --format <formatId>` and `sm export`. Formatters are deterministic-only, they sit at the graph-to-string boundary and their output MUST be byte-deterministic for the same input graph (the snapshot-test suite relies on this). The `mode` field MUST NOT appear in formatter manifests. Probabilistic narrators of the graph are a valid product but they live in jobs and emit Findings, not in formatters. All formatters accept the `--filter` expression; opting out is no longer supported.",
6
6
  "allOf": [
7
7
  { "$ref": "base.schema.json" }
8
8
  ],
9
9
  "type": "object",
10
- "required": ["id", "kind", "version", "formatId"],
10
+ "required": ["version", "description"],
11
11
  "unevaluatedProperties": false,
12
12
  "properties": {
13
- "kind": { "const": "formatter" },
14
- "formatId": {
15
- "type": "string",
16
- "description": "Format identifier consumed by `sm graph --format <name>`. Built-in set: `ascii`, `mermaid`, `dot`, `json`. Third-party formatters MAY register new format ids; collisions are a load-time error. Distinct from the runtime method `format(ctx)` which produces the serialized output."
17
- },
18
13
  "contentType": {
19
14
  "type": "string",
20
- "description": "MIME-like hint used by the Server when streaming formatted output over HTTP (e.g. `text/plain`, `image/svg+xml`, `application/json`). Advisory.",
21
- "default": "text/plain"
22
- },
23
- "supportsFilter": {
24
- "type": "boolean",
25
- "default": true,
26
- "description": "If true, the formatter accepts the `--filter` expression used by `sm export`. If false, `sm export --format <this>` rejects `--filter` with exit 2."
15
+ "default": "text/plain",
16
+ "description": "MIME-like hint used by the BFF when streaming formatted output over HTTP (e.g. `text/plain`, `image/svg+xml`, `application/json`). Advisory."
27
17
  }
28
18
  }
29
19
  }
@@ -2,18 +2,11 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "https://skill-map.dev/spec/v0/extensions/hook.schema.json",
4
4
  "title": "ExtensionHook",
5
- "description": "Manifest shape for a `Hook` extension. Subscribes declaratively to a curated set of kernel lifecycle events. Dual-mode (deterministic / probabilistic). Hooks react to events; they cannot block or alter the main pipeline. Probabilistic hooks are deferred to the job subsystem and never run in-scan. The set of hookable triggers is intentionally small, ten events out of the full job-events catalog. Eight are pipeline-driven (emitted from inside `runScan`); two (`boot`, `shutdown`) are CLI-process-driven (emitted by the driving binary before / after the verb runs, fire-and-forget so `process.exit` is never blocked). Other events (per-node `scan.progress`, `model.delta`, `run.*`, internal job lifecycle) are deliberately not hookable: too verbose for a reactive surface, internal to the runner, or covered elsewhere. Declaring a trigger outside the hookable set yields `invalid-manifest` at load time. See `architecture.md` §Hook · curated trigger set for the per-trigger payload contracts.",
5
+ "description": "Manifest shape for a `Hook` extension. Subscribes declaratively to a curated set of kernel lifecycle events. **Hooks are deterministic-only** since the structure-as-truth refactor: the `mode` field was removed; LLM-dependent lifecycle behaviour is modeled as a deterministic hook that enqueues a probabilistic Action via `ctx.queue('<plugin>/<action>', payload)`. Hooks react to events; they cannot block or alter the main pipeline. The set of hookable triggers is intentionally small, ten events out of the full job-events catalog. Eight are pipeline-driven (emitted from inside `runScan`); two (`boot`, `shutdown`) are CLI-process-driven (emitted by the driving binary before / after the verb runs, fire-and-forget so `process.exit` is never blocked). Other events (per-node `scan.progress`, `model.delta`, `run.*`, internal job lifecycle) are deliberately not hookable: too verbose for a reactive surface, internal to the runner, or covered elsewhere. Declaring a trigger outside the hookable set yields `invalid-manifest` at load time.",
6
6
  "type": "object",
7
- "required": ["id", "kind", "version", "triggers"],
7
+ "required": ["version", "description", "triggers"],
8
8
  "unevaluatedProperties": false,
9
9
  "properties": {
10
- "kind": { "const": "hook" },
11
- "mode": {
12
- "type": "string",
13
- "enum": ["deterministic", "probabilistic"],
14
- "default": "deterministic",
15
- "description": "`deterministic`: the hook's `on(ctx)` runs in-process during the dispatch of the matching event, synchronously between the event's emission and the next pipeline step. `probabilistic`: the hook is enqueued as a job (handled by the job subsystem; lands at Step 10). A probabilistic hook is loaded but not dispatched in-scan, the kernel surfaces a stderr advisory and skips it until the job subsystem ships."
16
- },
17
10
  "triggers": {
18
11
  "type": "array",
19
12
  "minItems": 1,
@@ -0,0 +1,71 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://skill-map.dev/spec/v0/extensions/provider-kind.schema.json",
4
+ "title": "ProviderKindMetadata",
5
+ "description": "Per-kind UI metadata written as `<plugin>/kinds/<kindName>/kind.json`. Lives next to the kind's frontmatter `schema.json` under the kind folder; together they are the structure-as-truth replacement for the old `kinds` map inside the Provider manifest. Reaches the UI via the `kindRegistry` field embedded in REST envelopes (`api/rest-envelope.schema.json`). The kind name is the folder name; it is NOT repeated as a field here.",
6
+ "type": "object",
7
+ "required": ["ui"],
8
+ "additionalProperties": false,
9
+ "properties": {
10
+ "ui": {
11
+ "type": "object",
12
+ "required": ["label", "color"],
13
+ "additionalProperties": false,
14
+ "description": "Presentation metadata the UI uses to render nodes of this kind (palette swatches, list tags, graph nodes, filter chips). Required so the UI never has to invent visuals for a kind a Provider declares. The Provider declares intent (label + base color, optional dark variant + emoji + icon); the UI derives bg/fg tints from `color` per theme via a deterministic helper.",
15
+ "properties": {
16
+ "label": {
17
+ "type": "string",
18
+ "minLength": 1,
19
+ "description": "Plural human-readable label for groups of this kind (e.g. `'Skills'`, `'Agents'`, `'Cursor Rules'`). Used in filter dropdowns, palette tooltips, and any list grouping."
20
+ },
21
+ "color": {
22
+ "type": "string",
23
+ "pattern": "^#[0-9a-fA-F]{6}$",
24
+ "description": "Base hex color (light theme). The UI derives `bg` and `fg` tints from this value at runtime; declaring a single base value (instead of three) keeps the manifest small and lets the UI control accessibility-driven contrast."
25
+ },
26
+ "colorDark": {
27
+ "type": "string",
28
+ "pattern": "^#[0-9a-fA-F]{6}$",
29
+ "description": "Optional dark-theme variant of `color`. When absent, the UI falls back to `color`. Declared explicitly because a luminosity flip rarely matches the brand intent for kinds that should stand out in dark mode."
30
+ },
31
+ "emoji": {
32
+ "type": "string",
33
+ "minLength": 1,
34
+ "maxLength": 8,
35
+ "description": "Optional decorative emoji used as a fallback when `icon` is absent or fails to render. Bound to a small length so the UI can lay it out predictably alongside text."
36
+ },
37
+ "icon": {
38
+ "description": "Optional discriminated icon descriptor. The UI prefers `icon` over `emoji`; when both are absent, the UI falls back to the first letter of `label` colored with `color`.",
39
+ "oneOf": [
40
+ {
41
+ "type": "object",
42
+ "required": ["kind", "id"],
43
+ "additionalProperties": false,
44
+ "properties": {
45
+ "kind": { "const": "pi" },
46
+ "id": {
47
+ "type": "string",
48
+ "pattern": "^pi-[a-z0-9]+(-[a-z0-9]+)*$",
49
+ "description": "PrimeIcons identifier (e.g. `pi-cog`, `pi-bolt`). Matched verbatim against the `pi pi-<id>` class the UI emits."
50
+ }
51
+ }
52
+ },
53
+ {
54
+ "type": "object",
55
+ "required": ["kind", "path"],
56
+ "additionalProperties": false,
57
+ "properties": {
58
+ "kind": { "const": "svg" },
59
+ "path": {
60
+ "type": "string",
61
+ "minLength": 1,
62
+ "description": "Raw SVG path data (the `d` attribute of one or more `<path>` elements, joined). The UI wraps it in `<svg viewBox=\"0 0 24 24\"><path d=\"...\"/></svg>` and tints it with `currentColor`."
63
+ }
64
+ }
65
+ }
66
+ ]
67
+ }
68
+ }
69
+ }
70
+ }
71
+ }
@@ -2,18 +2,17 @@
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, and the per-kind frontmatter schema each kind follows. 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.",
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, and the per-kind frontmatter schema each kind follows. **Structure-as-truth**: exactly one Provider lives in each plugin that carries one, declared as `<plugin>/provider.ts`. The kinds catalog lives as folders under `<plugin>/kinds/<kindName>/` and the loader discovers each entry by walking that directory; the manifest itself NO LONGER carries a `kinds` map. Each kind folder MUST contain `schema.json` (the kind's frontmatter JSON Schema, extending `frontmatter/base.schema.json` via `allOf` + `$ref`) and `kind.json` (UI metadata under `{ ui: {...} }`). The kernel resolves these at boot time and registers each schema with AJV for scan-time validation. Exactly zero or one Provider MUST match any given file; multiple matches → `provider-ambiguous` issue, file unclassified. **`roots` is enforcement-grade**: a Provider declaring `roots` only receives files matching at least one glob; a Provider without `roots` acts as a fallback for files unmatched by any other Provider's roots. 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/`.",
6
6
  "allOf": [
7
7
  { "$ref": "base.schema.json" }
8
8
  ],
9
9
  "type": "object",
10
- "required": ["id", "kind", "version", "kinds"],
10
+ "required": ["version", "description"],
11
11
  "unevaluatedProperties": false,
12
12
  "properties": {
13
- "kind": { "const": "provider" },
14
13
  "roots": {
15
14
  "type": "array",
16
- "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).",
15
+ "description": "Path globs (relative to scope root) that this Provider claims. **Enforcement-grade since structure-as-truth refactor**: a Provider declaring `roots` only receives files that match at least one entry of the array; a Provider without `roots` acts as a fallback and receives files unmatched by every other Provider's roots. Two Providers whose `roots` both match the same file produce a `provider-ambiguous` issue and the file stays unclassified. `sm plugins doctor` warns when no file matched a specific Provider's roots in the latest scan.",
17
16
  "items": { "type": "string" }
18
17
  },
19
18
  "read": {
@@ -37,92 +36,6 @@
37
36
  "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`."
38
37
  }
39
38
  }
40
- },
41
- "kinds": {
42
- "type": "object",
43
- "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`).",
44
- "minProperties": 1,
45
- "propertyNames": {
46
- "type": "string",
47
- "pattern": "^[a-z][a-zA-Z0-9]*$"
48
- },
49
- "additionalProperties": {
50
- "type": "object",
51
- "required": ["schema", "defaultRefreshAction", "ui"],
52
- "additionalProperties": false,
53
- "properties": {
54
- "schema": {
55
- "type": "string",
56
- "minLength": 1,
57
- "description": "Path to the kind's frontmatter JSON Schema, relative to the Provider's package directory. The schema MUST extend `frontmatter/base.schema.json` (declared in spec) via `allOf` + `$ref` to base's `$id`. The kernel resolves the path at boot time and registers the schema with AJV for validation during scan."
58
- },
59
- "defaultRefreshAction": {
60
- "type": "string",
61
- "pattern": "^[a-z][a-z0-9]*(-[a-z0-9]+)*/[a-z][a-z0-9]*(-[a-z0-9]+)*$",
62
- "description": "Qualified action id (`<plugin-id>/<action-id>`) the UI's probabilistic-refresh surface (`🧠 prob`) dispatches for nodes of this kind. The action MUST exist in the registry by the time the graph is queried; a dangling reference disables the Provider with status `invalid-manifest`."
63
- },
64
- "ui": {
65
- "type": "object",
66
- "required": ["label", "color"],
67
- "additionalProperties": false,
68
- "description": "Presentation metadata the UI uses to render nodes of this kind (palette swatches, list tags, graph nodes, filter chips). Required so the UI never has to invent visuals for a kind a Provider declares. The Provider declares intent (label + base color, optional dark variant + emoji + icon); the UI derives bg/fg tints from `color` per theme via a deterministic helper. Reaches the UI via the `kindRegistry` field embedded in REST envelopes (`api/rest-envelope.schema.json`).",
69
- "properties": {
70
- "label": {
71
- "type": "string",
72
- "minLength": 1,
73
- "description": "Plural human-readable label for groups of this kind (e.g. `'Skills'`, `'Agents'`, `'Cursor Rules'`). Used in filter dropdowns, palette tooltips, and any list grouping."
74
- },
75
- "color": {
76
- "type": "string",
77
- "pattern": "^#[0-9a-fA-F]{6}$",
78
- "description": "Base hex color (light theme). The UI derives `bg` and `fg` tints from this value at runtime; declaring a single base value (instead of three) keeps the manifest small and lets the UI control accessibility-driven contrast."
79
- },
80
- "colorDark": {
81
- "type": "string",
82
- "pattern": "^#[0-9a-fA-F]{6}$",
83
- "description": "Optional dark-theme variant of `color`. When absent, the UI falls back to `color`. Declared explicitly because a luminosity flip rarely matches the brand intent for kinds that should stand out in dark mode."
84
- },
85
- "emoji": {
86
- "type": "string",
87
- "minLength": 1,
88
- "maxLength": 8,
89
- "description": "Optional decorative emoji used as a fallback when `icon` is absent or fails to render. Bound to a small length so the UI can lay it out predictably alongside text."
90
- },
91
- "icon": {
92
- "description": "Optional discriminated icon descriptor. The UI prefers `icon` over `emoji`; when both are absent, the UI falls back to the first letter of `label` colored with `color`.",
93
- "oneOf": [
94
- {
95
- "type": "object",
96
- "required": ["kind", "id"],
97
- "additionalProperties": false,
98
- "properties": {
99
- "kind": { "const": "pi" },
100
- "id": {
101
- "type": "string",
102
- "pattern": "^pi-[a-z0-9]+(-[a-z0-9]+)*$",
103
- "description": "PrimeIcons identifier (e.g. `pi-cog`, `pi-bolt`). Matched verbatim against the `pi pi-<id>` class the UI emits."
104
- }
105
- }
106
- },
107
- {
108
- "type": "object",
109
- "required": ["kind", "path"],
110
- "additionalProperties": false,
111
- "properties": {
112
- "kind": { "const": "svg" },
113
- "path": {
114
- "type": "string",
115
- "minLength": 1,
116
- "description": "Raw SVG path data (the `d` attribute of one or more `<path>` elements, joined). The UI wraps it in `<svg viewBox=\"0 0 24 24\"><path d=\"...\"/></svg>` and tints it with `currentColor`."
117
- }
118
- }
119
- }
120
- ]
121
- }
122
- }
123
- }
124
- }
125
- }
126
39
  }
127
40
  }
128
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/plugins-registry.schema.json",
4
4
  "title": "PluginsRegistry",
5
- "description": "Two shapes in one file: (1) the per-plugin manifest that authors ship as `plugin.json` (see `$defs/PluginManifest`); (2) the aggregate registry the implementation produces on disk (e.g. `~/.skill-map/plugins.json`), which lists all discovered plugins with their compat status. Both shapes are normative. camelCase keys throughout.",
5
+ "description": "Two shapes in one file: (1) the per-plugin manifest that authors ship as `plugin.json` (see `$defs/PluginManifest`); (2) the aggregate registry the implementation produces on disk (`<cwd>/.skill-map/plugins.json`), which lists all discovered plugins with their compat status. Both shapes are normative. camelCase keys throughout.",
6
6
  "type": "object",
7
7
  "oneOf": [
8
8
  { "$ref": "#/$defs/PluginsRegistry" },
@@ -11,14 +11,10 @@
11
11
  "$defs": {
12
12
  "PluginManifest": {
13
13
  "type": "object",
14
- "required": ["id", "version", "specCompat", "extensions"],
14
+ "required": ["version", "specCompat", "catalogCompat", "description"],
15
15
  "additionalProperties": false,
16
+ "description": "Plugin manifest written as `<plugin>/plugin.json`. The plugin id comes from the directory name (structure-as-truth, per `architecture.md` §Plugin discovery), it is NOT a manifest field. Manifests carrying an `id` key are rejected as `invalid-manifest`.",
16
17
  "properties": {
17
- "id": {
18
- "type": "string",
19
- "pattern": "^[a-z][a-z0-9]*(-[a-z0-9]+)*$",
20
- "description": "Kebab-case, globally unique plugin identifier."
21
- },
22
18
  "version": {
23
19
  "type": "string",
24
20
  "description": "Plugin semver."
@@ -29,22 +25,18 @@
29
25
  },
30
26
  "catalogCompat": {
31
27
  "type": "string",
32
- "description": "Optional semver range against the kernel's view-slots + input-types catalog version (e.g. `^1.0.0`). Independent from `specCompat` because the catalog evolves on its own cadence (new slots ship as minor bumps; rename/remove ships as catalog-major bumps that trigger `sm plugins upgrade`). Absent = the plugin declares no view contributions or settings AND opts out of catalog-version checking; `sm plugins doctor` will warn if such a plugin actually emits via `viewContributions` or `settings`. Mismatch surfaces as `incompatible-catalog` plugin status."
28
+ "description": "Required semver range against the kernel's view-slots + input-types catalog version (e.g. `^1.0.0`). Independent from `specCompat` because the catalog evolves on its own cadence (new slots ship as minor bumps; rename/remove ships as catalog-major bumps that trigger `sm plugins upgrade`). Mismatch surfaces as `incompatible-catalog` plugin status."
33
29
  },
34
30
  "description": {
35
- "type": "string"
36
- },
37
- "extensions": {
38
- "type": "array",
39
- "description": "Relative paths to extension files, resolved from the plugin directory.",
40
- "minItems": 1,
41
- "items": { "type": "string" }
31
+ "type": "string",
32
+ "minLength": 1,
33
+ "description": "Required short description shown in `sm plugins list` and the UI. English-only per AGENTS.md."
42
34
  },
43
35
  "granularity": {
44
36
  "type": "string",
45
37
  "enum": ["bundle", "extension"],
46
- "default": "bundle",
47
- "description": "Toggle granularity for this plugin. `bundle` (default), the plugin id is the only enable/disable key; the whole set of extensions follows the toggle. `extension`, each extension is independently toggle-able under its qualified id `<plugin-id>/<extension-id>`. Built-in bundles use the same field: the `claude` bundle is `bundle` (the Provider and its kind-aware extractors form a coherent provider); the `core` bundle is `extension` (every kernel built-in is removable per the spec promise that no extension is privileged). Plugin authors should keep the default unless their plugin ships several orthogonal capabilities a user might reasonably want piecemeal."
38
+ "default": "extension",
39
+ "description": "Toggle granularity for this plugin. `bundle`, the plugin id is the only enable/disable key; the whole set of extensions follows the toggle. `extension` (default), each extension is independently toggle-able under its qualified id `<plugin-id>/<extension-id>`. Built-in bundles use the same field: the `claude` bundle is `bundle` (the Provider and its kind-aware extractors form a coherent provider); the `core` bundle is `extension`. Plugin authors should pick `bundle` only when the plugin's extensions form a coherent unit a user would never want to toggle piecemeal."
48
40
  },
49
41
  "storage": {
50
42
  "type": "object",
@@ -77,14 +69,6 @@
77
69
  }
78
70
  ]
79
71
  },
80
- "settings": {
81
- "type": "object",
82
- "additionalProperties": { "$ref": "input-types.schema.json#/$defs/ISettingDeclaration" },
83
- "propertyNames": {
84
- "pattern": "^[a-z][a-z0-9]*(-[a-z0-9]+)*$"
85
- },
86
- "description": "Plugin user-configurable settings. Each entry picks an `input-type` from the closed catalog at `input-types.schema.json#/$defs/InputTypeName`. The plugin author NEVER writes JSON Schema for settings, they pick by `type` name and provide per-type parameters (label, default, min/max, options for enums, etc.). The kernel exposes the resolved settings to extractors via `ctx.settings.<settingId>` (or via the runtime as `IPluginSettings`); the UI generates a form per declaration; the CLI's `sm plugins config <id>` exposes the same surface. Settings are read once at extractor invocation; changing a setting requires `sm scan` to re-emit (per ROADMAP.md decision D4)."
87
- },
88
72
  "author": { "type": "string" },
89
73
  "license": { "type": "string", "description": "SPDX identifier." },
90
74
  "homepage": { "type": "string", "format": "uri" },
@@ -108,13 +92,13 @@
108
92
  "required": ["id", "path", "manifest", "status"],
109
93
  "additionalProperties": false,
110
94
  "properties": {
111
- "id": { "type": "string" },
95
+ "id": { "type": "string", "description": "Plugin id, derived from the plugin directory name (structure-as-truth). The kernel computes this at discovery; it is NOT a manifest field." },
112
96
  "path": { "type": "string", "description": "Absolute path to the plugin directory." },
113
97
  "manifest": { "$ref": "#/$defs/PluginManifest" },
114
98
  "status": {
115
99
  "type": "string",
116
100
  "enum": ["enabled", "disabled", "incompatible-spec", "incompatible-catalog", "invalid-manifest", "load-error", "id-collision"],
117
- "description": "Resolved state after discovery. `disabled` = user-disabled via config; `id-collision` = two plugins (any combination of project / global / --plugin-dir) declared the same `id`, both blocked, no precedence; `incompatible-catalog` = manifest's `catalogCompat` does not satisfy the kernel's catalog version (resolved via `sm plugins upgrade`); others = automatic."
101
+ "description": "Resolved state after discovery. `disabled` = user-disabled via config; `id-collision` = two plugin directories with the same name reachable from different roots (project + --plugin-dir combinations), both blocked, no precedence; `incompatible-catalog` = manifest's `catalogCompat` does not satisfy the kernel's catalog version (resolved via `sm plugins upgrade`); others = automatic."
118
102
  },
119
103
  "statusReason": {
120
104
  "type": ["string", "null"],
@@ -143,17 +143,6 @@
143
143
  "allowEditSmFiles": {
144
144
  "type": "boolean",
145
145
  "description": "**Project-local only** (per `core/config/helper:PROJECT_LOCAL_ONLY_KEYS`). Grants this project permission to create / modify `.sm` annotation sidecars next to source files. Default `false`. The first time a verb or BFF route attempts a `.sm` write while this is `false`, the kernel raises `EConsentRequiredError`. The CLI surfaces it as an interactive `confirm()` prompt (or `--yes` bypass); the BFF returns 412 `confirm-required` so the UI can open a `ConfirmationService` dialog. On accept the flag is persisted to `<cwd>/.skill-map/settings.local.json` (gitignored, per-checkout) and never asked again. On decline the operation aborts WITHOUT persisting the rejection, the next attempt re-asks. **Stripped with a warning when found in the committed `project` layer** (`<cwd>/.skill-map/settings.json`), each developer consents independently."
146
- },
147
- "updateCheck": {
148
- "type": "object",
149
- "additionalProperties": false,
150
- "description": "Controls the once-per-day notification when a newer @skill-map/cli release is published on npm. Disabled in CI, when SM_NO_UPDATE_CHECK=1, when stderr is not a TTY, or when the project DB is missing. **User-scope only**: this key SHOULD live in `~/.skill-map/settings.json` and the reference implementation forces user-scope reads via `core/config/helper:USER_ONLY_KEYS`, a project-layer entry from an older install continues to validate but is silently ignored at read time. `sm config set` rejects writes to the project layer for this key (rerun with `-g`); the Settings UI's General section persists toggles to the user layer.",
151
- "properties": {
152
- "enabled": {
153
- "type": "boolean",
154
- "description": "Default true. Set to false to disable both the npm registry probe and the CLI / UI banner."
155
- }
156
- }
157
146
  }
158
147
  }
159
148
  }
@@ -4,7 +4,7 @@
4
4
  "title": "ScanResult",
5
5
  "description": "Canonical output of `sm scan --json` (and the data shape sent over WebSocket scan events). Self-describing and versioned; consumers MUST check `schemaVersion` before parsing.",
6
6
  "type": "object",
7
- "required": ["schemaVersion", "scannedAt", "scope", "roots", "nodes", "links", "issues", "stats"],
7
+ "required": ["schemaVersion", "scannedAt", "roots", "nodes", "links", "issues", "stats"],
8
8
  "additionalProperties": false,
9
9
  "properties": {
10
10
  "schemaVersion": {
@@ -26,11 +26,6 @@
26
26
  "specVersion": { "type": "string", "description": "Spec version that this scan conforms to (e.g. `0.1.0`)." }
27
27
  }
28
28
  },
29
- "scope": {
30
- "type": "string",
31
- "enum": ["project", "global"],
32
- "description": "Scan scope. `project` walks the cwd repo; `global` walks user-level skill directories."
33
- },
34
29
  "roots": {
35
30
  "type": "array",
36
31
  "description": "Filesystem roots that were walked during this scan, as absolute or scope-root-relative paths.",
@@ -0,0 +1,39 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://skill-map.dev/spec/v0/user-settings.schema.json",
4
+ "title": "UserSettings",
5
+ "description": "Per-user, per-machine settings file persisted at `~/.skill-map/settings.json`. Holds the small set of preferences that genuinely belong to the operator (not to a project) plus the bookkeeping each one needs. The file is NOT part of the project config layer system (no merge, no PROJECT_LOCAL_ONLY_KEYS interaction); it is read directly by the few modules that own a user-scope feature. See `spec/cli-contract.md` §Scope is always project-local for the broader principle: skill-map never reads `$HOME` by default, this file is the narrow, documented exception. There is intentionally no `.local` partner; values here are already per-machine, so the project / project-local split would have no meaning.",
6
+ "type": "object",
7
+ "required": ["schemaVersion"],
8
+ "additionalProperties": false,
9
+ "properties": {
10
+ "schemaVersion": {
11
+ "type": "integer",
12
+ "const": 1,
13
+ "description": "Shape version of this file. Bumped only on breaking changes to the on-disk shape. Pre-1.0 the value stays `1`; future migrations land alongside the bump."
14
+ },
15
+ "updateCheck": {
16
+ "type": "object",
17
+ "additionalProperties": false,
18
+ "description": "User toggle + boot-time throttle bookkeeping for the once-per-day 'new version available' probe. The toggle is a real preference; the timestamps are opaque bookkeeping the CLI maintains so it does not spam the user. Both live in the same sub-object because they belong to the same feature.",
19
+ "properties": {
20
+ "enabled": {
21
+ "type": "boolean",
22
+ "description": "Operator opt-out toggle. Default `true` when absent. The CLI / Settings UI persists changes through `PATCH /api/preferences`."
23
+ },
24
+ "latestVersion": {
25
+ "type": ["string", "null"],
26
+ "description": "Latest @skill-map/cli version observed at the last npm-registry probe. `null` (or absent) when never probed."
27
+ },
28
+ "checkedAt": {
29
+ "type": ["integer", "null"],
30
+ "description": "Unix milliseconds of the last npm-registry probe. `null` (or absent) when never probed. Used to throttle the daily probe."
31
+ },
32
+ "shownAt": {
33
+ "type": ["integer", "null"],
34
+ "description": "Unix milliseconds of the last banner emission to stderr. `null` (or absent) when never shown. Used so a single probe does not re-emit the banner across back-to-back `sm` invocations."
35
+ }
36
+ }
37
+ }
38
+ }
39
+ }
@@ -1,30 +0,0 @@
1
- // Conformance fixture: provider whose `kinds[*]` entry deliberately
2
- // omits the required `ui` block (Step 14.5.d). The plugin loader MUST
3
- // reject this manifest with a clear "missing required property 'ui'"
4
- // diagnostic and the plugin MUST end up in `invalid-manifest` status.
5
- // The companion case `plugin-missing-ui-rejected.json` asserts the
6
- // stderr text and that `sm scan` survives (the loader degrades the
7
- // bad plugin and lets the rest of the pipeline continue).
8
- export default {
9
- kind: 'provider',
10
- id: 'bad-provider-provider',
11
- version: '0.1.0',
12
- description: 'provider whose markdown kind is missing the ui block',
13
- stability: 'experimental',
14
- kinds: {
15
- markdown: {
16
- schema: './schemas/markdown.schema.json',
17
- schemaJson: {
18
- $id: 'urn:test:bad-provider/markdown',
19
- type: 'object',
20
- additionalProperties: true,
21
- },
22
- defaultRefreshAction: 'bad-provider/summarize-markdown',
23
- // NOTE: deliberately no `ui` — this is what the case asserts.
24
- },
25
- },
26
- async *walk() {},
27
- classify() {
28
- return 'markdown';
29
- },
30
- };