@skill-map/spec 0.27.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.
@@ -12,23 +12,53 @@ This guide is **descriptive prose**, not the normative contract. The normative p
12
12
 
13
13
  ```text
14
14
  my-plugin/
15
- ├── plugin.json manifest (required)
16
- └── extensions/
17
- └── extractor.js ← one file per declared extension
15
+ ├── plugin.json bundle metadata (required)
16
+ └── extractors/ ← one folder per extension kind
17
+ └── my-extractor/
18
+ ├── index.js ← extension entry (required)
19
+ ├── text.ts ← user-facing strings (optional, see below)
20
+ └── my-extractor.test.ts ← tests live next to the code (optional)
18
21
  ```
19
22
 
23
+ The kernel auto-discovers extensions by walking
24
+ `<plugin-dir>/<kind>s/<name>/index.{js,mjs,ts}` for each known kind
25
+ (`providers`, `extractors`, `analyzers`, `actions`, `formatters`,
26
+ `hooks`). The folder layout IS the source of truth: bundle from the
27
+ top-level dir, kind from the subfolder name, extension id from the
28
+ extension folder name. The manifest no longer declares an
29
+ `extensions[]` array.
30
+
31
+ **Co-located files convention**: any siblings of `index.{js,mjs,ts}`
32
+ that the kernel does NOT recognise as an entry point are author
33
+ files (texts, tests, schemas, fixtures). Two names are blessed by
34
+ convention so consumers know where to look without grepping:
35
+
36
+ - **`text.ts`** holds the extension's externalised user-facing
37
+ strings (the `tx()`-fed templates, error messages, glyph labels).
38
+ One per extension; imported by `index.ts` as `./text.js`. Keeps
39
+ copy out of the code path and makes the surface review-friendly.
40
+ Plain TS module, no schema, no codegen.
41
+ - **`<extension-name>.test.ts`** (or `.test.mjs` / `.test.js`) is
42
+ the colocated test suite. Picked up by the workspace's test glob
43
+ (`plugins/**/*.test.ts`); no separate test directory.
44
+
45
+ Both files are optional. The kernel ignores everything that isn't
46
+ `index.{js,mjs,ts}`, so future per-extension fixtures, schemas, or
47
+ conformance scopes can live in the same folder without manifest
48
+ plumbing.
49
+
20
50
  ```jsonc
21
51
  // my-plugin/plugin.json
22
52
  {
23
53
  "id": "my-plugin",
24
54
  "version": "1.0.0",
25
55
  "specCompat": "^1.0.0",
26
- "extensions": ["./extensions/extractor.js"]
56
+ "granularity": "bundle"
27
57
  }
28
58
  ```
29
59
 
30
60
  ```javascript
31
- // my-plugin/extensions/extractor.js
61
+ // my-plugin/extractors/my-extractor/index.js
32
62
  export default {
33
63
  id: 'my-extractor',
34
64
  kind: 'extractor',
@@ -50,7 +80,11 @@ export default {
50
80
  };
51
81
  ```
52
82
 
53
- Drop the directory under one of the discovery roots and `sm plugins list` will pick it up.
83
+ Drop the directory under `<cwd>/.skill-map/plugins/` and
84
+ `sm plugins list` will pick it up. The kernel injects `pluginId`
85
+ from `plugin.json#/id` at load time; do NOT hardcode it in the
86
+ extension export. A folder/kind mismatch (e.g. an extractor placed
87
+ under `analyzers/`) surfaces as `invalid-manifest`.
54
88
 
55
89
  ---
56
90
 
@@ -110,7 +144,7 @@ The kernel guards against two foot-guns:
110
144
  - 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.
111
145
  - 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.
112
146
 
113
- 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.
147
+ For built-ins, the reference impl's `src/plugins/<bundle>/plugin.json` provides the bundle's `id` and the codegen at `scripts/generate-built-ins.js` inlines the `pluginId` injection at build time (the resulting `src/plugins/built-ins.ts` is auto-generated and committed). Authors never hardcode `pluginId` on the extension export.
114
148
 
115
149
  ### Granularity, bundle vs extension
116
150
 
@@ -139,48 +173,60 @@ Resolution order is the same as for plugin enabled-state: DB override (`config_p
139
173
 
140
174
  `sm plugins enable/disable --all` operates only on top-level bundle ids (the default-enabled set every user can see); it never expands to qualified `<bundle>/<ext>` keys. The "disable every kernel built-in at once" intent is served by `--no-built-ins` on `sm scan` and friends; `--all` is the macro on user-toggle-able units, not on every individual extension.
141
175
 
142
- In your own plugin's `plugin.json`, set `granularity` only when you opt into the per-extension form:
176
+ Set `granularity` in your `plugin.json`. The folder layout supplies the extensions; the kernel discovers them automatically:
143
177
 
144
178
  ```jsonc
145
179
  {
146
180
  "id": "my-multi-tool",
147
181
  "version": "1.0.0",
148
182
  "specCompat": "^1.0.0",
149
- "granularity": "extension",
150
- "extensions": [
151
- "./extensions/orphan-skill-analyzer.js",
152
- "./extensions/csv-formatter.js"
153
- ]
183
+ "granularity": "extension"
154
184
  }
155
185
  ```
156
186
 
187
+ ```text
188
+ my-multi-tool/
189
+ ├── plugin.json
190
+ ├── analyzers/
191
+ │ └── orphan-skill/
192
+ │ └── index.js
193
+ └── formatters/
194
+ └── csv/
195
+ └── index.js
196
+ ```
197
+
157
198
  The default (`'bundle'`) is the right answer for almost every plugin, keep the manifest minimal until the plugin actually ships several independent capabilities.
158
199
 
159
- ### Extractor `applicableKinds`, narrow the pipeline
200
+ ### Extractor `precondition`, narrow the pipeline
160
201
 
161
- 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.
202
+ An `Extractor` extension MAY declare a `precondition` block on its manifest. When declared, the kernel runs the extractor **only** against nodes that satisfy every declared sub-filter, the filter is fail-fast (no extractor context, no method call) so the extractor wastes zero CPU on nodes it cannot meaningfully process. The same shape is shared by `Analyzer` and `Action`.
203
+
204
+ ```ts
205
+ precondition?: {
206
+ kind?: string[]; // qualified `<plugin>/<kindName>` ids
207
+ provider?: string[]; // plugin ids
208
+ };
209
+ ```
162
210
 
163
- | `applicableKinds` | Behaviour |
211
+ | `precondition` | Behaviour |
164
212
  |---|---|
165
213
  | Absent (`undefined`) | **Default.** The extractor runs on every kind the loaded Providers emit. |
166
- | `['skill']` | Runs only on skill nodes. |
167
- | `['skill', 'agent']` | Runs on skills + agents. Hooks, commands, notes are skipped. |
168
- | `[]` | **Invalid.** AJV rejects the manifest at load time (`minItems: 1`). The absence of the field already means "every kind"; an empty array is reserved for "this is a typo". |
214
+ | `{ kind: ['claude/skill'] }` | Runs only on skill nodes from the Claude provider. |
215
+ | `{ kind: ['claude/skill', 'gemini/skill'] }` | Runs on skills from either provider. |
216
+ | `{ provider: ['claude'] }` | Coarser: runs on every kind the `claude` plugin declares. |
217
+ | `{ kind: ['claude/skill'], provider: ['claude'] }` | Both filters apply (AND). |
169
218
 
170
- 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.
219
+ Use `precondition.kind` over `precondition.provider` when the filter is really about the kind, not the provider. There is no wildcard syntax, omitting the field IS the wildcard.
171
220
 
172
221
  Use case, a deterministic frontmatter-tag extractor that only makes sense for skills:
173
222
 
174
223
  ```javascript
175
224
  export default {
176
- id: 'tag-extractor',
177
- kind: 'extractor',
225
+ // id, kind, pluginId injected by the loader from the folder path
178
226
  version: '1.0.0',
179
227
  description: 'Lifts the `tags:` frontmatter array into `references` links for skill nodes.',
180
- emitsLinkKinds: ['references'],
181
- defaultConfidence: 'high',
182
228
  scope: 'frontmatter',
183
- applicableKinds: ['skill'],
229
+ precondition: { kind: ['claude/skill'] },
184
230
  async extract(ctx) {
185
231
  // Never invoked for agents, commands, hooks, or notes, the kernel
186
232
  // skipped this node before reaching us.
@@ -200,7 +246,9 @@ export default {
200
246
 
201
247
  > **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.
202
248
 
203
- **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.
249
+ > **Why no `emitsLinkKinds` / `defaultConfidence`?** Both fields were retired with the structure-as-truth refactor. Link kinds are constrained by the global closed enum (`invokes`, `references`, `mentions`, `supersedes`); off-enum emissions drop with `extension.error`. Confidence is declared per-emit on every `ctx.emitLink({ ..., confidence })` call (default `'medium'` if omitted).
250
+
251
+ **Unknown qualified kinds are non-blocking.** An extractor that lists a kind no installed Provider declares (typo, missing Provider plugin) still loads with status `enabled`; `sm plugins doctor` surfaces an informational warning so the author sees the mismatch. The exit code of `doctor` is NOT promoted to 1 by this warning, the corresponding Provider may legitimately arrive later (e.g. when the user installs the matching plugin), and the load contract favours forward compatibility over rigid checks.
204
252
 
205
253
  ### Module top-level side effects survive load timeouts
206
254
 
@@ -226,23 +274,26 @@ Required fields (see [`schemas/plugins-registry.schema.json#/$defs/PluginManifes
226
274
 
227
275
  | Field | Type | Notes |
228
276
  |---|---|---|
229
- | `id` | kebab-case string | Globally unique. Pattern: `^[a-z][a-z0-9]*(-[a-z0-9]+)*$`. |
230
277
  | `version` | semver | Plugin version, independent of `specCompat`. |
231
278
  | `specCompat` | semver range | Spec versions this plugin is compatible with. Checked via `semver.satisfies(specVersion, this)` at load time. |
232
- | `extensions` | string[] | Relative paths to extension files. Each file's default export is the extension's runtime instance. `minItems: 1`. |
279
+ | `catalogCompat` | semver range | Semver range against the view-slots + input-types catalog. Independent from `specCompat` because the catalog evolves on its own cadence. Required as of the structure-as-truth refactor (was optional). |
280
+ | `description` | string | Required short description shown in `sm plugins list` and the UI. |
233
281
 
234
282
  Optional fields:
235
283
 
236
284
  | Field | Type | Notes |
237
285
  |---|---|---|
238
- | `description` | string | One-line summary shown in `sm plugins list`. |
239
- | `granularity` | `'bundle' \| 'extension'` | Controls how `sm plugins enable / disable` operates on this plugin. Default `'bundle'`. See [Granularity, bundle vs extension](#granularity--bundle-vs-extension). |
286
+ | `granularity` | `'bundle' \| 'extension'` | Default `'extension'` (each extension toggleable by qualified id). Set to `'bundle'` when the plugin's extensions form a coherent unit a user would never want to toggle piecemeal. |
240
287
  | `storage` | object | `{ "mode": "kv" }` or `{ "mode": "dedicated", "tables": [...], "migrations": [...] }`. Absent means the plugin does not persist state. |
241
288
  | `author` | string | Free-form. |
242
289
  | `license` | string | SPDX identifier. |
243
290
  | `homepage` | string | URL. |
244
291
  | `repository` | string | URL. |
245
292
 
293
+ **Structure-as-truth**: the plugin id is the directory name (`<root>/<id>/plugin.json`); it is NOT a manifest field. Manifests carrying an `id` literal are rejected as `invalid-manifest`. Settings moved out of `plugin.json` into each extension's own manifest with the same refactor (see [Extension manifest](#extension-manifest)).
294
+
295
+ The manifest does NOT list extensions. The kernel discovers each extension by walking `<plugin-dir>/<kind>s/<name>/index.{js,mjs,ts}`; the path is authoritative for both the kind and the local id. A Provider's kind catalog lives on disk at `<plugin>/kinds/<kindName>/{schema.json, kind.json}` (see [Providers](#providers--actions)).
296
+
246
297
  ### `specCompat` strategy
247
298
 
248
299
  Pre-`v1.0.0` of the spec, narrow ranges are the defensive default, minor bumps **MAY** carry breaking changes per [`versioning.md`](./versioning.md). A plugin that spans minor boundaries can load successfully and crash at first use against a changed schema.
@@ -660,7 +711,7 @@ import { test } from 'node:test';
660
711
  import { strictEqual } from 'node:assert';
661
712
  import { runExtractorOnFixture, node } from '@skill-map/testkit';
662
713
 
663
- import extractor from '../extensions/extractor.js';
714
+ import extractor from '../extractors/my-extractor/index.js';
664
715
 
665
716
  test('emits one reference per [[ref:<name>]] token', async () => {
666
717
  const { links } = await runExtractorOnFixture(extractor, {
@@ -700,7 +751,7 @@ Full surface in `@skill-map/testkit/index.ts`.
700
751
  | `incompatible-spec` | manifest parsed but `semver.satisfies` failed against the installed spec. | Plugin built against an older / newer spec. |
701
752
  | `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. |
702
753
  | `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. |
703
- | `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. |
754
+ | `id-collision` | two plugins reachable from different roots declared the same `id`. Both collided plugins receive this status; no precedence analyzer applies. | A project-local plugin and a `--plugin-dir` plugin (or two `--plugin-dir` plugins) sharing an id. Rename one and rerun. |
704
755
 
705
756
  `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.
706
757
 
@@ -715,7 +766,7 @@ Full surface in `@skill-map/testkit/index.ts`.
715
766
  `annotationContributions` is an object map keyed by the annotation key the extension wants to own. Each entry declares an inline JSON Schema for the value plus two policy fields:
716
767
 
717
768
  ```js
718
- // my-plugin/extensions/extractor.js
769
+ // my-plugin/extractors/my-extractor/index.js
719
770
  export default {
720
771
  id: 'my-extractor',
721
772
  kind: 'extractor',
@@ -765,7 +816,7 @@ auditor:
765
816
  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.
766
817
 
767
818
  ```js
768
- // compliance-plugin/extensions/analyzer.js
819
+ // compliance-plugin/analyzers/compliance-checker/index.js
769
820
  export default {
770
821
  id: 'compliance-checker',
771
822
  kind: 'analyzer',
@@ -1036,9 +1087,10 @@ Full plugin walkthrough:
1036
1087
 
1037
1088
  ```
1038
1089
  plugins/acme-keyword-finder/
1039
- ├── plugin.json ← manifest with settings + catalogCompat
1040
- └── extensions/
1041
- └── extractor.js ← extract() with ctx.emitContribution
1090
+ ├── plugin.json ← manifest with settings + catalogCompat
1091
+ └── extractors/
1092
+ └── keyword-finder/
1093
+ └── index.js ← extract() with ctx.emitContribution
1042
1094
  ```
1043
1095
 
1044
1096
  `plugin.json`:
@@ -1049,7 +1101,7 @@ plugins/acme-keyword-finder/
1049
1101
  "version": "1.0.0",
1050
1102
  "specCompat": "^0.20.0",
1051
1103
  "catalogCompat": "^1.0.0",
1052
- "extensions": ["./extensions/extractor.js"],
1104
+ "granularity": "bundle",
1053
1105
  "settings": {
1054
1106
  "keywords": {
1055
1107
  "type": "string-list",
@@ -1061,17 +1113,15 @@ plugins/acme-keyword-finder/
1061
1113
  }
1062
1114
  ```
1063
1115
 
1064
- `extensions/extractor.js`:
1116
+ `extractors/keyword-finder/index.js`:
1065
1117
 
1066
1118
  ```js
1067
- export const extractor = {
1119
+ export default {
1068
1120
  id: 'keyword-finder',
1069
- pluginId: 'acme-keyword-finder',
1070
1121
  kind: 'extractor',
1071
1122
  version: '1.0.0',
1072
1123
  description: 'Counts configured keywords per node.',
1073
1124
  stability: 'stable',
1074
- mode: 'deterministic',
1075
1125
  emitsLinkKinds: [],
1076
1126
  defaultConfidence: 'high',
1077
1127
  scope: 'body',
@@ -2,78 +2,62 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "https://skill-map.dev/spec/v0/extensions/action.schema.json",
4
4
  "title": "ExtensionAction",
5
- "description": "Manifest shape for an `Action` extension. An action operates on one or more nodes in one of two modes: `deterministic` (code runs in-process, returns a report JSON directly) or `probabilistic` (kernel renders a prompt, a runner executes it against an LLM, the callback closes the job). The `mode` discriminator drives which additional fields are required. See `architecture.md` §Execution modes for the cross-extension contract.",
5
+ "description": "Manifest shape for an `Action` extension. An action operates on one or more nodes in one of two modes: `deterministic` (code runs in-process, returns a report JSON directly) or `probabilistic` (kernel renders a prompt, a runner executes it against an LLM, the callback closes the job). **Structure-as-truth files**: every Action carries `<action-dir>/report.schema.json` (the JSON Schema for the report, MUST extend `report-base.schema.json`); probabilistic Actions additionally carry `<action-dir>/prompt.md` (the prompt template). The kernel resolves both by convention; missing or mis-placed files surface as `load-error`. A deterministic Action with a `prompt.md` in its folder is also `load-error` (config inconsistent). **`prob*` prefix convention**: manifest fields that only apply when `mode=probabilistic` start with `prob`; if a deterministic-only field ever appears, it starts with `det`.",
6
6
  "type": "object",
7
- "required": ["id", "kind", "version", "mode", "reportSchemaRef"],
7
+ "required": ["version", "description"],
8
8
  "unevaluatedProperties": false,
9
9
  "properties": {
10
- "kind": { "const": "action" },
11
10
  "mode": {
12
11
  "type": "string",
13
12
  "enum": ["deterministic", "probabilistic"],
14
- "description": "`deterministic`: the plugin's code computes the report synchronously, no job file, no runner. `probabilistic`: the kernel renders a prompt + preamble into a job file; a runner executes it via `RunnerPort`; `sm record` closes the job."
13
+ "default": "deterministic",
14
+ "description": "`deterministic` (default): the plugin's code computes the report synchronously, no job file, no runner. `probabilistic`: the kernel renders `prompt.md` + preamble into a job file; a runner executes it via `RunnerPort`; `sm record` closes the job."
15
15
  },
16
- "reportSchemaRef": {
17
- "type": "string",
18
- "description": "Absolute or relative reference to the JSON Schema the report MUST validate against. MUST extend `report-base.schema.json` (directly or transitively). Validation failure → job transitions to `failed` with reason `report-invalid`."
19
- },
20
- "expectedDurationSeconds": {
16
+ "probExpectedDurationSeconds": {
21
17
  "type": "integer",
22
18
  "minimum": 1,
23
- "description": "Best-effort estimate of wall-clock duration. Drives TTL (`ttl = max(expectedDurationSeconds × graceMultiplier, minimumTtlSeconds)`). Required for `probabilistic`; advisory for `deterministic`."
24
- },
25
- "promptTemplateRef": {
26
- "type": "string",
27
- "description": "Path (relative to the extension file) to the prompt template the kernel renders at `sm job submit`. REQUIRED when `mode: probabilistic`; FORBIDDEN when `mode: deterministic`. The template MUST NOT interpolate user text outside `<user-content>` blocks (see `prompt-preamble.md`)."
19
+ "description": "Best-effort estimate of wall-clock duration when `mode=probabilistic`. Drives TTL (`ttl = max(probExpectedDurationSeconds × graceMultiplier, minimumTtlSeconds)`). Required for `probabilistic`; ignored otherwise. Renamed from `expectedDurationSeconds` with the structure-as-truth refactor, the `prob` prefix makes it clear at a glance which mode the field belongs to."
28
20
  },
29
21
  "precondition": {
30
22
  "type": "object",
31
- "description": "Declarative filter that nodes must satisfy for this action to be applicable. Consumed by `--all` fan-out, UI button gating, `sm actions show`.",
32
- "unevaluatedProperties": false,
23
+ "additionalProperties": false,
24
+ "description": "Declarative filter that nodes must satisfy for this action to be applicable. Consumed by `--all` fan-out, UI button gating, `sm actions show`. Also drives the inverse analyzer↔action relationship (Modelo B): `analyzerIds` declares which analyzers' findings this action is intended to resolve, replacing the deprecated `Analyzer.recommendedActions` map.",
33
25
  "properties": {
34
26
  "kind": {
35
27
  "type": "array",
36
- "items": { "type": "string", "minLength": 1 },
37
- "description": "Node kinds this action accepts. Open-by-design (matches `node.schema.json#/properties/kind`): an action declared with `kind: ['cursorRule']` is valid as long as some Provider classifies into `cursorRule`. Omitted → any kind."
28
+ "minItems": 1,
29
+ "uniqueItems": true,
30
+ "items": {
31
+ "type": "string",
32
+ "pattern": "^[a-z][a-z0-9-]*/[a-z][a-zA-Z0-9]*$"
33
+ },
34
+ "description": "Qualified node kinds this action accepts (`<provider-plugin>/<kindName>`)."
38
35
  },
39
36
  "provider": {
40
37
  "type": "array",
41
- "items": { "type": "string" },
42
- "description": "Provider ids whose nodes this action accepts. Omitted → any Provider."
43
- },
44
- "stability": {
45
- "type": "array",
46
- "items": { "type": "string", "enum": ["experimental", "stable", "deprecated"] },
47
- "description": "Node stability filter."
38
+ "minItems": 1,
39
+ "uniqueItems": true,
40
+ "items": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" },
41
+ "description": "Provider ids whose nodes this action accepts."
48
42
  },
49
- "custom": {
43
+ "analyzerIds": {
50
44
  "type": "array",
51
- "items": { "type": "string" },
52
- "description": "Free-form precondition strings the kernel forwards to the action for runtime evaluation. Example: `frontmatter.metadata.source != null`."
45
+ "minItems": 1,
46
+ "uniqueItems": true,
47
+ "items": {
48
+ "type": "string",
49
+ "pattern": "^[a-z][a-z0-9-]*/[a-z][a-z0-9-]*(:[a-z][a-z0-9-]*)?$"
50
+ },
51
+ "description": "Qualified analyzer ids whose findings this action is intended to resolve (Modelo B, replaces the deprecated `Analyzer.recommendedActions`). The UI surfaces matching actions in the node inspector under 'Resolve this issue' when the analyzer's id matches an entry here. Format `<plugin>/<analyzer>` or `<plugin>/<analyzer>:<sub-id>` when the analyzer emits sub-typed issues. Dangling references warn via `recommended-action-missing` in `sm plugins doctor` but do NOT block load."
53
52
  }
54
53
  }
55
- },
56
- "expectedTools": {
57
- "type": "array",
58
- "description": "Hint to a Skill agent / CLI runner about what tools the rendered prompt expects access to (`Bash`, `Read`, `WebSearch`, …). Host MAY filter or warn. No normative enforcement in v0.",
59
- "items": { "type": "string" }
60
- },
61
- "fanOutPolicy": {
62
- "type": "string",
63
- "enum": ["per-node", "batch"],
64
- "default": "per-node",
65
- "description": "`per-node` (default): `sm job submit --all` produces one job per matching node. `batch`: produces one job whose prompt template receives the full list. Batch actions tend to hit context limits; use sparingly."
66
54
  }
67
55
  },
68
56
  "allOf": [
69
57
  { "$ref": "base.schema.json" },
70
58
  {
71
59
  "if": { "properties": { "mode": { "const": "probabilistic" } } },
72
- "then": { "required": ["promptTemplateRef", "expectedDurationSeconds"] }
73
- },
74
- {
75
- "if": { "properties": { "mode": { "const": "deterministic" } } },
76
- "then": { "not": { "required": ["promptTemplateRef"] } }
60
+ "then": { "required": ["probExpectedDurationSeconds"] }
77
61
  }
78
62
  ]
79
63
  }
@@ -2,51 +2,53 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "https://skill-map.dev/spec/v0/extensions/analyzer.schema.json",
4
4
  "title": "ExtensionAnalyzer",
5
- "description": "Manifest shape for an `Analyzer` extension. An analyzer consumes the full graph (nodes + links) after all extractors have run, emits `Issue[]`, and MAY emit view contributions to project findings into the UI. Analyzers are dual-mode: `deterministic` analyzers MUST be byte-for-byte reproducible (same graph in → same issues out; time, random, and network are forbidden) and run synchronously inside `sm check` / `sm scan`. `probabilistic` analyzers invoke an LLM through the kernel's `RunnerPort` and execute only as queued jobs (`sm job submit analyzer:<id>`); their output MAY vary across runs and they NEVER participate in `sm scan`. See `architecture.md` §Execution modes for the full contract.",
5
+ "description": "Manifest shape for an `Analyzer` extension. An analyzer consumes the full graph (nodes + links) after all extractors have run, emits `Issue[]`, and MAY emit view contributions to project findings into the UI. Analyzers are dual-mode: `deterministic` analyzers MUST be byte-for-byte reproducible (same graph in → same issues out; time, random, and network are forbidden) and run synchronously inside `sm check` / `sm scan`; `probabilistic` analyzers invoke an LLM through the kernel's `RunnerPort` and execute only as queued jobs (`sm job submit analyzer:<id>`); their output MAY vary across runs and they NEVER participate in `sm scan`. Each issue emitted is tagged with `analyzer_id = <plugin-id>/<extension-id>` by default (the extension's qualified id, derived from structure); analyzers that need to discriminate sub-types append `:<sub-id>` at emit time. Severity is set per-emit (no manifest-level default).",
6
6
  "allOf": [
7
7
  { "$ref": "base.schema.json" }
8
8
  ],
9
9
  "type": "object",
10
- "required": ["id", "kind", "version", "emitsAnalyzerIds", "defaultSeverity"],
10
+ "required": ["version", "description"],
11
11
  "unevaluatedProperties": false,
12
12
  "properties": {
13
- "kind": { "const": "analyzer" },
14
13
  "mode": {
15
14
  "type": "string",
16
15
  "enum": ["deterministic", "probabilistic"],
17
16
  "default": "deterministic",
18
- "description": "`deterministic` (default): pure code, byte-for-byte reproducible, runs during `sm check` and `sm scan`. `probabilistic`: invokes an LLM via `ctx.runner` and runs only as a queued job; never participates in scan-time pipelines. The kernel rejects probabilistic analyzers that try to register scan-time hooks at load time. Omitting the field is equivalent to declaring `deterministic`."
17
+ "description": "`deterministic` (default): pure code, byte-for-byte reproducible, runs during `sm check` and `sm scan`. `probabilistic`: invokes an LLM via `ctx.runner` and runs only as a queued job; never participates in scan-time pipelines. The kernel rejects probabilistic analyzers that try to register scan-time hooks at load time."
19
18
  },
20
- "emitsAnalyzerIds": {
21
- "type": "array",
22
- "description": "List of `analyzer_id` values this analyzer may emit on issues. Typically a singleton (`trigger-collision` → emits `trigger-collision`). An analyzer emitting an `analyzer_id` not in this list → kernel logs `analyzer-id-violation` but keeps the issue (forward compatibility).",
23
- "minItems": 1,
24
- "items": { "type": "string" }
19
+ "precondition": {
20
+ "type": "object",
21
+ "additionalProperties": false,
22
+ "description": "Optional declarative filter. The analyzer runs only when the graph contains at least one node matching every declared sub-filter. Same shape used by Extractor and Action.",
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 analyzer cares about (`<provider-plugin>/<kindName>`). Unknown qualified kinds 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 analyzer cares about."
40
+ }
41
+ }
25
42
  },
26
- "defaultSeverity": {
27
- "type": "string",
28
- "enum": ["error", "warn", "info"],
29
- "description": "Severity attached by default to emitted issues. Analyzers MAY override per-issue."
30
- },
31
- "consumes": {
32
- "type": "string",
33
- "enum": ["nodes", "links", "both"],
34
- "default": "both",
35
- "description": "Which slices of the graph the analyzer reads. The kernel MAY pass a restricted view when this is a strict subset."
36
- },
37
- "configurable": {
38
- "type": "boolean",
39
- "default": false,
40
- "description": "If true, the analyzer reads its own config from `config_preferences` under the key `analyzers.<id>.<field>`. Implementations MAY surface these in `sm config`."
41
- },
42
- "recommendedActions": {
43
- "type": "array",
44
- "description": "Qualified `<pluginId>/<id>` action ids the analyzer recommends running to address its findings. Per-node Actions only (Actions are per-node by design, see `IActionPrecondition`). Distinct from `Action.precondition`: the precondition declares 'this action applies to nodes matching X'; `recommendedActions` declares 'when this analyzer fires, these are the relevant actions to resolve the finding'. The UI surfaces matching Actions in the node inspector under 'Recommended for issues' alongside the always-applicable list driven by `Action.precondition`. Project-level cleanup operations (e.g. orphan file prune, contribution relink) are CLI verbs, not Actions, and therefore are NOT linked through this field. Each entry MUST be the qualified id of a registered Action; the kernel logs `recommended-action-missing` when a referenced action is not loaded but keeps the analyzer registered.",
45
- "items": {
46
- "type": "string",
47
- "pattern": "^[a-z0-9-]+/[a-z0-9-]+$"
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]+)*$"
48
50
  },
49
- "uniqueItems": true
51
+ "description": "Plugin-contributed view contributions. Same contract as Extractor.ui (slot-driven, payload-validated). The analyzer emits per-node payloads via `ctx.emitContribution(<nodePath>, <contributionId>, payload)` during graph evaluation (signature differs from extractor: the nodePath is explicit because analyzers see the full graph, not a single node). Only `extractor` and `analyzer` kinds may declare this field. Renamed from `viewContributions` with the structure-as-truth refactor."
50
52
  }
51
53
  }
52
54
  }
@@ -2,77 +2,48 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "https://skill-map.dev/spec/v0/extensions/base.schema.json",
4
4
  "title": "ExtensionBase",
5
- "description": "Base manifest shape common to every extension kind. Kind-specific schemas (`provider`, `extractor`, `analyzer`, `action`, `formatter`, `hook`) extend this via `allOf` and add a discriminant `kind` literal plus kind-specific fields. camelCase keys throughout. Closed-content enforcement (unknown keys = bug) lives on the kind schemas via `unevaluatedProperties: false`; those see base's evaluated keys through the `allOf` composition. Adding closure here too would force every kind schema to re-list every base key, which is the footgun the spec used to trip on before 2026-04-22.",
5
+ "description": "Base manifest shape common to every extension kind. Kind-specific schemas (`provider`, `extractor`, `analyzer`, `action`, `formatter`, `hook`) extend this via `allOf` and add a kind-specific shape. Both `id` and `kind` are derived from the filesystem structure (`<plugin>/<kind-plural>/<id>/index.ts`, where the parent folder dictates the kind and the leaf folder dictates the id), so they are NOT manifest fields. Manifests carrying `id` or `kind` are rejected as `invalid-manifest`. Closed-content enforcement (unknown keys = bug) lives on the kind schemas via `unevaluatedProperties: false`; those see base's evaluated keys through the `allOf` composition.",
6
6
  "type": "object",
7
- "required": ["id", "kind", "version"],
7
+ "required": ["version", "description"],
8
8
  "properties": {
9
- "id": {
10
- "type": "string",
11
- "pattern": "^[a-z][a-z0-9]*(-[a-z0-9]+)*$",
12
- "description": "Kebab-case identifier unique within the extension's kind across the same plugin. The kernel registers every extension under the **qualified** id `<plugin-id>/<id>` (e.g. `core/annotations`, `core/slash`, `hello-world/greet`), see `architecture.md` §Extension kinds and `plugin-author-guide.md` §Qualified extension ids. Authors declare only the short id here; the qualifier is composed by the loader from the manifest's `id` (or, for built-ins, from the bundle declaration in `built-ins.ts`). The pattern is intentionally constrained to a single kebab-case segment without the `/` separator: the qualifier always lives in the plugin id, never in the extension id."
13
- },
14
- "kind": {
15
- "type": "string",
16
- "enum": ["provider", "extractor", "analyzer", "action", "formatter", "hook"],
17
- "description": "Discriminant. MUST match the file exporting this manifest; kind mismatch → load-error."
18
- },
19
9
  "version": {
20
10
  "type": "string",
21
11
  "description": "Extension semver. Bumped independently from the plugin version; frozen into `state_executions.extension_version` on every run for reproducibility."
22
12
  },
23
13
  "description": {
24
14
  "type": "string",
25
- "description": "One-to-three sentences explaining what the extension does. Rendered by `sm <kind>s list`."
26
- },
27
- "stability": {
28
- "type": "string",
29
- "enum": ["experimental", "stable", "deprecated"],
30
- "default": "stable",
31
- "description": "Maturity tag. The kernel MAY warn when an `experimental` extension is used in CI (`--strict`)."
15
+ "minLength": 1,
16
+ "description": "Required short description (1-3 sentences) shown by `sm <kind>s list` and the UI inspector. English-only per AGENTS.md."
32
17
  },
33
- "preconditions": {
34
- "type": "array",
35
- "description": "Free-form predicates the kernel evaluates before offering this extension. Canonical keys: `kind=<node-kind>`, `provider=<provider-id>`, `frontmatter.<path>=<value>`. Unknown keys are skipped with a warning, not rejected.",
36
- "items": { "type": "string" }
37
- },
38
- "entry": {
39
- "type": "string",
40
- "description": "Optional path to the module exporting this extension (relative to the plugin root). Absent → the kernel uses the path listed in the plugin manifest's `extensions[]` array."
41
- },
42
- "annotationContributions": {
18
+ "annotation": {
43
19
  "type": "object",
44
- "additionalProperties": {
45
- "type": "object",
46
- "required": ["schema"],
47
- "additionalProperties": false,
48
- "properties": {
49
- "schema": {
50
- "type": "object",
51
- "description": "Inline JSON Schema for this contribution's value. Validated when the kernel routes a sidecar write through the extension's namespace (or root key, see `location`). Must be a valid JSON Schema document; an invalid `schema` rejects the extension at load with `invalid-manifest`."
52
- },
53
- "ownership": {
54
- "enum": ["exclusive", "shared"],
55
- "default": "shared",
56
- "description": "Conflict policy for this key. `shared` (default), the key is namespaced by default; multiple plugins MAY contribute to the same key, last-write-wins per the SidecarStore's deep-merge semantics. `exclusive`, only this plugin may write the key. REQUIRED when `location: 'root'` (a top-level reserved key cannot be silently shared between plugins)."
57
- },
58
- "location": {
59
- "enum": ["namespaced", "root"],
60
- "default": "namespaced",
61
- "description": "Where the key lands inside the sidecar. `namespaced` (default), written under the plugin's `<plugin-id>:` block at the sidecar root. `root`, written as a top-level key alongside the reserved blocks (`for`, `annotations`, `settings`, `audit`); requires `ownership: 'exclusive'` and is treated as elevated trust (two plugins claiming the same root key with `exclusive` is a hard-fail at orchestrator init). See `plugin-author-guide.md` §Annotation contributions."
62
- }
20
+ "required": ["schema"],
21
+ "additionalProperties": false,
22
+ "description": "Optional, opt-in declaration of a single sidecar annotation key contributed by this extension. The key is the extension's id (i.e. the leaf folder name). Extensions that need multiple annotation keys split into multiple extensions. The runtime exposes the registered catalog via `kernel.getRegisteredAnnotationKeys()` for UI autocomplete; the built-in `core/unknown-field` Analyzer emits a warning on truly unrecognized keys (typo guard). See `plugin-author-guide.md` §Annotation contribution.",
23
+ "properties": {
24
+ "schema": {
25
+ "type": "object",
26
+ "description": "Inline JSON Schema for this contribution's value. The value can be a scalar, an array, or a rich object; the schema is full JSON Schema. Validated when the kernel routes a sidecar write through this extension's namespace (or root key, see `location`). An invalid schema rejects the extension at load with `invalid-manifest`."
27
+ },
28
+ "ownership": {
29
+ "enum": ["exclusive", "shared"],
30
+ "default": "shared",
31
+ "description": "Conflict policy for this key. `shared` (default): the key is namespaced by default; multiple plugins MAY contribute to the same key, last-write-wins per the SidecarStore's deep-merge semantics. `exclusive`: only this plugin may write the key. REQUIRED when `location: 'root'` (a top-level reserved key cannot be silently shared between plugins)."
32
+ },
33
+ "location": {
34
+ "enum": ["namespaced", "root"],
35
+ "default": "namespaced",
36
+ "description": "Where the key lands inside the sidecar. `namespaced` (default): written under the plugin's `<plugin-id>:` block at the sidecar root. `root`: written as a top-level key alongside the reserved blocks (`for`, `annotations`, `settings`, `audit`); requires `ownership: 'exclusive'` and is treated as elevated trust (two plugins claiming the same root key with `exclusive` is a hard-fail at orchestrator init)."
63
37
  }
64
- },
65
- "description": "Plugin-contributed annotation keys. Each entry declares an inline JSON Schema for the value the extension writes into a sidecar. Keys default to the plugin's `<plugin-id>:` namespace; opt-in to top-level via `location: 'root'` (requires `ownership: 'exclusive'`). The kernel exposes the runtime catalog via `kernel.getRegisteredAnnotationKeys()` for UI autocomplete; the built-in `core/unknown-field` Analyzer emits a warning on truly unrecognized keys (typo guard). See `plugin-author-guide.md` §Annotation contributions for the worked examples."
38
+ }
66
39
  },
67
- "viewContributions": {
40
+ "settings": {
68
41
  "type": "object",
69
- "additionalProperties": {
70
- "$ref": "../view-slots.schema.json#/$defs/IViewContribution"
71
- },
42
+ "additionalProperties": { "$ref": "../input-types.schema.json#/$defs/ISettingDeclaration" },
72
43
  "propertyNames": {
73
44
  "pattern": "^[a-z][a-z0-9]*(-[a-z0-9]+)*$"
74
45
  },
75
- "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 plugin 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`. The plugin author picks ONE slot, that is the only choice; the slot fixes both the renderer and the payload shape. See `plugin-author-guide.md` §View contributions for worked examples."
46
+ "description": "Extension user-configurable settings. Each entry picks an `input-type` from the closed catalog at `input-types.schema.json#/$defs/InputTypeName`. The extension 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 via `ctx.settings.<settingId>` to the extension's runtime methods (`extract`, `evaluate`, `invoke`, etc.); the UI generates a form per declaration; the CLI's `sm plugins config <plugin>/<extension>` exposes the same surface. Settings are read once at extension invocation; changing a setting requires `sm scan` to re-emit (per ROADMAP.md decision D4). Settings live per-extension (not per-plugin) since the structure-as-truth refactor."
76
47
  }
77
48
  }
78
49
  }