@skill-map/spec 0.27.0 → 0.29.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`.
162
203
 
163
- | `applicableKinds` | Behaviour |
204
+ ```ts
205
+ precondition?: {
206
+ kind?: string[]; // qualified `<plugin>/<kindName>` ids
207
+ provider?: string[]; // plugin ids
208
+ };
209
+ ```
210
+
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.
@@ -286,7 +337,13 @@ Extractors are deterministic-only and never see `ctx.runner`. If an Extractor ne
286
337
 
287
338
  You can read `ctx.node.sidecar.*` freely, the kernel's per-`(node, extractor)` cache hashes the sidecar `annotations` block alongside the `.md` body, so a `.sm`-only edit invalidates the cached run automatically. No manifest flag, no opt-in: just read what you need.
288
339
 
289
- > **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.
340
+ > **Pick a syntax that doesn't collide with built-ins.** The built-in `at-directive` and `slash` extractors claim the `@` and `/` prefixes with LLM-aligned semantics:
341
+ >
342
+ > - **`core/at-directive`**: bare handles (`@team-lead`) and namespaced agents (`@my-plugin/foo-extractor`, `@skill-map:explore`) emit `mentions` links; file-flavoured tokens (`@docs/api/v1.md`, `@./readme.md`, `@../parent.md`, `@/abs/path.md`) emit `references` links so the graph treats them as file pointers, not entity mentions, the same way Claude Code / Gemini CLI / Cursor would resolve them. The kind dispatch keys on (a) an explicit relative / absolute path prefix or (b) a known file extension at the tail.
343
+ > - **`core/slash`**: bare commands (`/scan`, `/skill-map:explore`) emit `invokes`; tokens whose next character is another `/` or any other identifier char are dropped as path segments (`/Volumes/disk`, `/api/v1/items`).
344
+ > - **Both extractors strip fenced code blocks and inline backticks before matching**, so author-marked literal payload never registers as invocation surface.
345
+ >
346
+ > 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.
290
347
 
291
348
  ```javascript
292
349
  import { normalizeTrigger } from '@skill-map/cli';
@@ -660,7 +717,7 @@ import { test } from 'node:test';
660
717
  import { strictEqual } from 'node:assert';
661
718
  import { runExtractorOnFixture, node } from '@skill-map/testkit';
662
719
 
663
- import extractor from '../extensions/extractor.js';
720
+ import extractor from '../extractors/my-extractor/index.js';
664
721
 
665
722
  test('emits one reference per [[ref:<name>]] token', async () => {
666
723
  const { links } = await runExtractorOnFixture(extractor, {
@@ -700,7 +757,7 @@ Full surface in `@skill-map/testkit/index.ts`.
700
757
  | `incompatible-spec` | manifest parsed but `semver.satisfies` failed against the installed spec. | Plugin built against an older / newer spec. |
701
758
  | `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
759
  | `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. |
760
+ | `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
761
 
705
762
  `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
763
 
@@ -715,7 +772,7 @@ Full surface in `@skill-map/testkit/index.ts`.
715
772
  `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
773
 
717
774
  ```js
718
- // my-plugin/extensions/extractor.js
775
+ // my-plugin/extractors/my-extractor/index.js
719
776
  export default {
720
777
  id: 'my-extractor',
721
778
  kind: 'extractor',
@@ -765,7 +822,7 @@ auditor:
765
822
  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
823
 
767
824
  ```js
768
- // compliance-plugin/extensions/analyzer.js
825
+ // compliance-plugin/analyzers/compliance-checker/index.js
769
826
  export default {
770
827
  id: 'compliance-checker',
771
828
  kind: 'analyzer',
@@ -1036,9 +1093,10 @@ Full plugin walkthrough:
1036
1093
 
1037
1094
  ```
1038
1095
  plugins/acme-keyword-finder/
1039
- ├── plugin.json ← manifest with settings + catalogCompat
1040
- └── extensions/
1041
- └── extractor.js ← extract() with ctx.emitContribution
1096
+ ├── plugin.json ← manifest with settings + catalogCompat
1097
+ └── extractors/
1098
+ └── keyword-finder/
1099
+ └── index.js ← extract() with ctx.emitContribution
1042
1100
  ```
1043
1101
 
1044
1102
  `plugin.json`:
@@ -1049,7 +1107,7 @@ plugins/acme-keyword-finder/
1049
1107
  "version": "1.0.0",
1050
1108
  "specCompat": "^0.20.0",
1051
1109
  "catalogCompat": "^1.0.0",
1052
- "extensions": ["./extensions/extractor.js"],
1110
+ "granularity": "bundle",
1053
1111
  "settings": {
1054
1112
  "keywords": {
1055
1113
  "type": "string-list",
@@ -1061,17 +1119,15 @@ plugins/acme-keyword-finder/
1061
1119
  }
1062
1120
  ```
1063
1121
 
1064
- `extensions/extractor.js`:
1122
+ `extractors/keyword-finder/index.js`:
1065
1123
 
1066
1124
  ```js
1067
- export const extractor = {
1125
+ export default {
1068
1126
  id: 'keyword-finder',
1069
- pluginId: 'acme-keyword-finder',
1070
1127
  kind: 'extractor',
1071
1128
  version: '1.0.0',
1072
1129
  description: 'Counts configured keywords per node.',
1073
1130
  stability: 'stable',
1074
- mode: 'deterministic',
1075
1131
  emitsLinkKinds: [],
1076
1132
  defaultConfidence: 'high',
1077
1133
  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
  }