@skill-map/spec 0.35.0 → 0.36.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,90 @@
1
1
  # Spec changelog
2
2
 
3
+ ## 0.36.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 8ab68ed: Rename `core/field-unknown` to `core/annotation-field-unknown` so it
8
+ groups alphabetically with the other sidecar (`.sm`) annotation rules
9
+ (`core/annotation-orphan`, `core/annotation-stale`). The rule's job has
10
+ not changed: it still flags typos / unrecognised keys in sidecars and
11
+ emits a warn issue plus the same `alert` + `chip` view contributions
12
+ on `graph.node.alert` / `card.footer.right`.
13
+
14
+ `contribution-orphan` is intentionally NOT renamed: the `contribution`
15
+ namespace refers to view-slot rows in `scan_contributions` (runtime
16
+ data the analyzers emit for the UI), not to annotation fields in
17
+ sidecars. The two namespaces are distinct.
18
+
19
+ Pre-1.0 minor per `spec/versioning.md`: breaking rename of a public
20
+ qualified id referenced from `settings.json`, `--analyzers <id>` flags,
21
+ and the `analyzerId` filter on `GET /api/issues`. No behavioural
22
+ change, no DB schema change, no event payload shape change. Persisted
23
+ scans created with the old id regenerate cleanly on the next
24
+ `sm scan`.
25
+
26
+ ## User-facing
27
+
28
+ Renamed `core/field-unknown` to `core/annotation-field-unknown` so the
29
+ sidecar typo-guard rule groups with the other `core/annotation-*`
30
+ rules. Update references in `settings.json` or
31
+ `sm check --analyzers <id>` to the new name.
32
+
33
+ - 880fe3e: Rename 14 built-in extension ids to a consistent `<domain>-<detail>` pattern. The naming was inconsistent: 10 ids already followed the "area first, attribute after" shape (e.g. `annotation-orphan`, `link-conflict`) while 14 were inverted, redundant, or vague. All built-ins now agree.
34
+
35
+ Full rename map (`old qualified id` → `new qualified id`):
36
+
37
+ | Kind | Old | New |
38
+ | --------- | --------------------------------- | -------------------------- |
39
+ | action | `core/bump` | `core/node-bump` |
40
+ | action | `core/mark-superseded` | `core/node-supersede` |
41
+ | extractor | `core/tools-count` | `core/tools-counter` |
42
+ | extractor | `claude/slash` | `claude/slash-command` |
43
+ | analyzer | `core/broken-ref` | `core/reference-broken` |
44
+ | analyzer | `core/job-orphan-file` | `core/job-file-orphan` |
45
+ | analyzer | `core/link-counts` | `core/link-counter` |
46
+ | analyzer | `core/redundant-target-reference` | `core/reference-redundant` |
47
+ | analyzer | `core/reserved-name` | `core/name-reserved` |
48
+ | analyzer | `core/self-loop` | `core/link-self-loop` |
49
+ | analyzer | `core/stability` | `core/node-stability` |
50
+ | analyzer | `core/superseded` | `core/node-superseded` |
51
+ | analyzer | `core/unknown-field` | `core/field-unknown` |
52
+ | analyzer | `core/validate-all` | `core/schema-violation` |
53
+
54
+ The convention is now documented in `spec/plugin-author-guide.md` §Extension id shape. Counter-style extensions standardise on the `-counter` suffix (`link-counter`, `tools-counter`, `external-url-counter`).
55
+
56
+ CLI verb `sm bump` is **unchanged** (it remains the user-facing verb; the internal action id is what flipped to `core/node-bump`). The `BumpReport` JSON schema title also stays as `BumpReport`, the wire shape is unchanged.
57
+
58
+ Pre-1.0 minor per `spec/versioning.md`: breaking rename of public qualified ids referenced from `settings.json`, `--analyzers <id>` flags, `core/<id>` strings in plugin manifests, and the `analyzerId` filter on `GET /api/issues`. No behavioural change, no DB schema change, no event payload shape change. Persisted scans created with the old ids regenerate cleanly on the next `sm scan`.
59
+
60
+ ## User-facing
61
+
62
+ Renamed 14 built-in extension ids to a `<area>-<detail>` shape (e.g. `core/broken-ref` is now `core/reference-broken`). If you reference these by qualified id in `settings.json` or via `sm check --analyzers <id>`, update to the new names.
63
+
64
+ - 1b6e368: Honour per-extension toggles inside bundle-granularity plugins end-to-end. Closes the Phase 4b follow-up (commit `e45d2fd`) gap: BFF + Settings UI started accepting per-extension toggles for any granularity, but three call sites still treated bundle granularity as "one knob, every extension follows", so flipping an individual extension off (e.g. `claude/at-directive`) persisted to `config_plugins` and then did nothing on the next scan.
65
+
66
+ **Runtime (`src/`)**
67
+
68
+ - `core/runtime/plugin-runtime/resolver.ts`: `isBundleEntryEnabled` (built-ins) and `isPluginExtensionEnabled` (drop-ins) now compose the bundle id as a coarse kill-switch with the per-extension override layered on top. When the bundle row resolves to `false` every extension stays disabled regardless of per-extension overrides; when the bundle row resolves to `true` each extension respects its qualified-id override (default `true`). Doc rewritten, the stale "silently ignored, the granularity says this bundle is one knob" wording was the symptom that pointed at the bug.
69
+ - `cli/commands/plugins/shared.ts`: `extensionRowFromBuiltIn` (the row builder behind `sm plugins list / show / doctor`) now reports the same composed effective state instead of mirroring the bundle's `enabled` field verbatim.
70
+ - `core/runtime/__tests__/plugin-runtime-branches.spec.ts`: two new composer cases lock the contract, `(e)` `claude` enabled + `claude/at-directive=false` drops only the extractor, and `(f)` `claude=false` overrides any per-extension `true` override.
71
+
72
+ **UI (`ui/`)**
73
+
74
+ - `app/components/settings-modal/settings-plugins.utils.ts`: `buildStateFromPlugins` now seeds extension keys for both granularities (was: bundle-only seeded the bundle id and skipped the extensions, so the per-extension toggles in the Phase 4b modal defaulted to OFF in the buffer regardless of what the wire shape said about `ext.enabled`, then reverted to OFF on every apply round-trip).
75
+ - `models/api.ts`: `IPluginItemApi.extensions` doc updated, the comment still said "only when granularity === 'extension'" which the BFF stopped honouring in commit `e45d2fd`.
76
+ - `__tests__/settings-plugins.utils.spec.ts`: five new cases cover bundle+extensions, extension-only, bundle-disabled-with-ext-enabled, and failure-row exclusion.
77
+
78
+ **Spec (`spec/`)**
79
+
80
+ - `cli-contract.md`: `GET /api/plugins` row shape doc rewritten, `extensions[]` is emitted for any granularity; the per-extension `enabled` reflects the **preference** axis (DB > settings > default true) and the runtime composition with the bundle row is documented explicitly. `PATCH /api/plugins/:bundleId/extensions/:extensionId` now accepts any granularity and returns 404 (not 400) on an unknown extension id. The 400 `bad-query` enumeration in the error-codes section narrowed to the conditions that still apply.
81
+ - `plugin-author-guide.md` § Resolution order: rewritten to describe bundle-as-kill-switch + per-extension refinement explicitly, including the deliberate asymmetry between the CLI surface (`sm plugins enable/disable <bare-id>` stays coarse) and the UI / direct config-edit surface (qualified ids accepted, refine inside a bundle).
82
+ - `index.json` regenerated.
83
+
84
+ ## User-facing
85
+
86
+ In Settings, expanding a bundle plugin (claude, antigravity, openai, agent-skills) now shows the correct per-extension state and the toggles persist, the next scan honours them. `sm plugins list` reflects effective state too.
87
+
3
88
  ## 0.35.0
4
89
 
5
90
  ### Minor Changes
package/architecture.md CHANGED
@@ -128,7 +128,7 @@ The loader enforces two id-uniqueness analyzers during discovery (see [`plugin-a
128
128
  1. **Directory name == manifest id.** A plugin lives at `<root>/<id>/plugin.json`. A mismatch surfaces as status `invalid-manifest`. This analyzer eliminates same-root collisions by construction.
129
129
  2. **Cross-root id collision blocks both sides.** Two plugins reachable from different roots (e.g. the project default `<cwd>/.skill-map/plugins/` and any `--plugin-dir` combination) that declare the same `id` BOTH receive status `id-collision`. No precedence analyzer applies, coherent with §Boot invariant ("no extension is privileged"). The user resolves by renaming one of them.
130
130
 
131
- In addition, the loader **qualifies every extension** with its owning plugin id before registering it. The registry stores extensions under the qualified id `<plugin-id>/<extension-id>` (e.g. `core/slash`, `core/broken-ref`, `my-plugin/my-extractor`). Authors continue to declare the short `id` in each extension manifest; the loader composes the qualified form from `manifest.id` at load time. Built-in extensions bundled with the reference impl declare their `pluginId` directly in `built-ins.ts`, `core/` for kernel-internal primitives (every analyzer, the formatter, the cross-vendor extractors `annotations` / `slash` / `at-directive` / `markdown-link` / `external-url-counter` / `stability`) and vendor-specific bundles such as `claude/` (the Claude provider) for Provider integrations whose territory is platform-bound. If a plugin author injects a `pluginId` field on an extension that disagrees with `plugin.json`'s `id`, the loader emits `invalid-manifest` with a directed reason.
131
+ In addition, the loader **qualifies every extension** with its owning plugin id before registering it. The registry stores extensions under the qualified id `<plugin-id>/<extension-id>` (e.g. `core/slash-command`, `core/reference-broken`, `my-plugin/my-extractor`). Authors continue to declare the short `id` in each extension manifest; the loader composes the qualified form from `manifest.id` at load time. Built-in extensions bundled with the reference impl declare their `pluginId` directly in `built-ins.ts`, `core/` for kernel-internal primitives (every analyzer, the formatter, the cross-vendor extractors `annotations` / `slash` / `at-directive` / `markdown-link` / `external-url-counter` / `stability`) and vendor-specific bundles such as `claude/` (the Claude provider) for Provider integrations whose territory is platform-bound. If a plugin author injects a `pluginId` field on an extension that disagrees with `plugin.json`'s `id`, the loader emits `invalid-manifest` with a directed reason.
132
132
 
133
133
  Each plugin (and each built-in bundle) declares a **granularity** that controls how its extensions are toggled. `granularity: 'bundle'` (the default) means the plugin id is the only enable/disable key; `granularity: 'extension'` means each extension is independently toggle-able under its qualified id. The loader's pre-import `resolveEnabled(pluginId)` short-circuit is always coarse (bundle level), when a granularity=`extension` bundle is partially enabled, the import work proceeds and the runtime composer (the CLI's `composeScanExtensions` / `composeFormatters` in `src/cli/util/plugin-runtime.ts`) drops the disabled extensions before they reach the orchestrator. Vendor Provider bundles (`claude`, `antigravity`, `openai`, `agent-skills`) ship as granularity=`bundle` (the platform integration is on or off as a whole); the `core` bundle is granularity=`extension` (every kernel built-in is removable, satisfying §Boot invariant: "no extension is privileged"). See [`plugin-author-guide.md` §Granularity, bundle vs extension](./plugin-author-guide.md#granularity--bundle-vs-extension) for the author-facing summary.
134
134
 
@@ -243,7 +243,7 @@ Extensions (Provider / Extractor / Analyzer / Action / Formatter / Hook) are **p
243
243
  The materialisation of any kernel-managed artefact (the SQLite DB at `.skill-map/skill-map.db`, the `.sm` sidecars next to source files, the job ledger at `.skill-map/jobs/`, the `scan_extractor_runs` cache, the enrichment overlay rows) is the **kernel's** responsibility, gated through the relevant Port:
244
244
 
245
245
  - Extractors persist via `ctx.emitLink` / `ctx.enrichNode` / `ctx.store`, never by writing files. `ctx.store` is plugin-scoped persistence routed through `StoragePort`; it cannot reach the project filesystem.
246
- - Actions return either a deterministic report (JSON), a rendered prompt (probabilistic), or, for the small subset of actions that legitimately mutate persisted state, an explicit `TActionWrite` discriminated union the kernel interprets. The built-in `core/bump` is the only action that returns `{ kind: 'sidecar' }` today; the kernel routes that write through `SidecarStore.applyPatch`, which is the single gated chokepoint for all `.sm` writes (see §Annotation system · Write consent).
246
+ - Actions return either a deterministic report (JSON), a rendered prompt (probabilistic), or, for the small subset of actions that legitimately mutate persisted state, an explicit `TActionWrite` discriminated union the kernel interprets. The built-in `core/node-bump` is the only action that returns `{ kind: 'sidecar' }` today; the kernel routes that write through `SidecarStore.applyPatch`, which is the single gated chokepoint for all `.sm` writes (see §Annotation system · Write consent).
247
247
  - Providers, Analyzers, Formatters, Hooks have no write surface at all.
248
248
 
249
249
  This invariant is what makes the consent gate at the kernel boundary sufficient: no extension can bypass it because no extension has the means to write in the first place. Conformance: a third-party extension that imports `node:fs` write APIs (or equivalent in another language) is non-conforming.
@@ -310,7 +310,7 @@ The transform runs after `dedupeLinks` and before the analyzer pipeline. For eac
310
310
 
311
311
  2. **Name match (links carrying a `trigger.normalizedTrigger`)**: strip the leading `@` / `/` sigil, look up the resulting handle in the cross-kind name index built from every node's declared `identifiers` (see §Provider · kind identifiers). The lookup keys on the LINK SOURCE node's Provider id: `resolution = providers[sourceNode.provider].resolution`. If `resolution[link.kind]` exists AND any candidate node's kind appears in it, confidence is bumped to `1.0`. Otherwise the link stays at its extractor-emitted confidence.
312
312
 
313
- The matrix is **per-link-kind, per-Provider**, strict: a `claude` Provider that declares `resolution: { mentions: ['agent'], invokes: ['command', 'skill'] }` does NOT bump a `/foo` slash matching an agent named `foo` (slash → agent is a kind mismatch surfaced by `link-conflict` / `kind-mismatch` analyzers, not silently treated as a resolution). The strictness is the load-bearing difference from the kind-agnostic `core/broken-ref` analyzer: `broken-ref`'s scope is "the name exists somewhere" (a name-only resolution is enough to clear the broken flag), the post-walk bump is "the name exists AS A VALID resolution for this link.kind". The `not-broken` + `not-bumped` combination is a documented edge case: the trigger resolves to a real node but the link's kind cannot legitimately point there.
313
+ The matrix is **per-link-kind, per-Provider**, strict: a `claude` Provider that declares `resolution: { mentions: ['agent'], invokes: ['command', 'skill'] }` does NOT bump a `/foo` slash matching an agent named `foo` (slash → agent is a kind mismatch surfaced by `link-conflict` / `kind-mismatch` analyzers, not silently treated as a resolution). The strictness is the load-bearing difference from the kind-agnostic `core/reference-broken` analyzer: `broken-ref`'s scope is "the name exists somewhere" (a name-only resolution is enough to clear the broken flag), the post-walk bump is "the name exists AS A VALID resolution for this link.kind". The `not-broken` + `not-bumped` combination is a documented edge case: the trigger resolves to a real node but the link's kind cannot legitimately point there.
314
314
 
315
315
  The lookup uses the SOURCE node's Provider id deliberately: a Provider rules over the link.kinds its Extractors emit, regardless of where the resolution candidates physically live in the graph. A `claude` agent that mentions an `openai` agent (cross-provider mention) still follows claude's `resolution.mentions = ['agent']` rule. When the source node belongs to a Provider without `resolution` (e.g. a `CLAUDE.md` classified by `core/markdown`), the name path short-circuits, the path-match rule still applies.
316
316
 
@@ -322,7 +322,7 @@ Each Provider MAY declare an optional `reservedNames: Record<kind, string[]>` ma
322
322
 
323
323
  The kernel intersects each Provider's `reservedNames[kind]` catalog with the scanned graph at orchestrator time: for every node, the post-walk pipeline derives its normalised identifiers via the §Provider · kind identifiers contract and asks "does any identifier fall in `reservedNames[node.kind]` for this node's Provider?". Matches land in a per-scan `Set<nodePath>` consumed by two surfaces:
324
324
 
325
- 1. **The `core/reserved-name` analyzer projects one `warn` issue per reserved-shadow node** (`severity: 'warn'`, message points at the offending file and suggests renaming). The analyzer is a pure projector, detection lives in the orchestrator so the same set drives the next surface.
325
+ 1. **The `core/name-reserved` analyzer projects one `warn` issue per reserved-shadow node** (`severity: 'warn'`, message points at the offending file and suggests renaming). The analyzer is a pure projector, detection lives in the orchestrator so the same set drives the next surface.
326
326
 
327
327
  2. **The post-walk confidence-lift transform downgrades any link that resolves to a reserved target** (by path OR by name match) to `RESERVED_TARGET_CONFIDENCE = 0.1` instead of bumping it to `1.0`. The visual weight in the graph drops well below the `0.5` / `0.8` extractor emit floors so the operator sees at a glance that the edge resolves to a file the runtime ignores. When the trigger has multiple candidates (name index collision) and the strict-kind filter accepts more than one, the resolver picks the first allowed candidate, if it is non-reserved, the link bumps to `1.0` normally; only when EVERY accepted candidate is reserved does the downgrade apply.
328
328
 
@@ -440,7 +440,7 @@ The two surfaces are deliberately split:
440
440
  - **`Action.precondition`**, declared on the Action side. Answers "which nodes does this Action apply to?". Evaluated continuously against the node the inspector is focused on, regardless of any issue.
441
441
  - **`Analyzer.recommendedActions`**, declared on the Analyzer side. Answers "when this analyzer fires, which Actions are the natural fix?". Surfaces only on nodes the analyzer emitted against.
442
442
 
443
- Each `recommendedActions` entry MUST be the qualified id of a registered Action. The kernel logs an `extension.error` event with `kind: 'recommended-action-missing'` when a referenced action is not loaded; the analyzer stays registered and continues emitting issues, only the recommendation hint is dropped. Project-level cleanup verbs (orphan file prune, contribution relink) are CLI commands, not Actions, and are NOT linked through this field. Analyzers whose issues surface deliberate user declarations rather than fixable problems (e.g. `core/superseded`) omit the field.
443
+ Each `recommendedActions` entry MUST be the qualified id of a registered Action. The kernel logs an `extension.error` event with `kind: 'recommended-action-missing'` when a referenced action is not loaded; the analyzer stays registered and continues emitting issues, only the recommendation hint is dropped. Project-level cleanup verbs (orphan file prune, contribution relink) are CLI commands, not Actions, and are NOT linked through this field. Analyzers whose issues surface deliberate user declarations rather than fixable problems (e.g. `core/node-superseded`) omit the field.
444
444
 
445
445
  ### Hook · curated trigger set
446
446
 
@@ -484,7 +484,7 @@ Hooks introduce no new persisted state and do NOT participate in the determinist
484
484
  ### Locality
485
485
 
486
486
  - **Drop-in**: extensions live inside plugins, discovered at boot from `<cwd>/.skill-map/plugins/<id>/` only. The `--plugin-dir <path>` escape hatch on the `sm plugins …` verb family loads a custom directory per invocation when the user explicitly opts in.
487
- - **Built-in**: the reference impl bundles a default extension set (one Provider, four extractors, five analyzers, one formatter, one hook). The fifth analyzer, `core/validate-all`, replays every scanned node and link through the authoritative spec schemas via AJV, the kernel-side guard against persisting non-conforming graph rows. The first built-in Hook is `core/update-check`, which subscribes to `shutdown` and runs the once-per-day "update available" probe + banner that lived on the CLI entry path before the Hook kind had concrete consumers. These are loaded from `src/extensions/` and are indistinguishable from plugin-supplied extensions from the kernel's point of view.
487
+ - **Built-in**: the reference impl bundles a default extension set (one Provider, four extractors, five analyzers, one formatter, one hook). The fifth analyzer, `core/schema-violation`, replays every scanned node and link through the authoritative spec schemas via AJV, the kernel-side guard against persisting non-conforming graph rows. The first built-in Hook is `core/update-check`, which subscribes to `shutdown` and runs the once-per-day "update available" probe + banner that lived on the CLI entry path before the Hook kind had concrete consumers. These are loaded from `src/extensions/` and are indistinguishable from plugin-supplied extensions from the kernel's point of view.
488
488
 
489
489
  ---
490
490
 
@@ -603,7 +603,7 @@ At scan time the kernel re-computes the live hashes and compares against the sto
603
603
 
604
604
  ### Bump model
605
605
 
606
- The deterministic built-in `core/bump` Action produces a sidecar patch:
606
+ The deterministic built-in `core/node-bump` Action produces a sidecar patch:
607
607
 
608
608
  - Increments `annotations.version` by 1 (or sets to `1` if missing, single integer monotonic, orthogonal to `stability`; major bumps are not a concept, the convention for breaking changes is "create a new node, supersede the old").
609
609
  - Refreshes `identity.bodyHash` and `identity.frontmatterHash` to the live values.
@@ -760,7 +760,7 @@ ctx.emitContribution(nodePath, contributionId, payload);
760
760
 
761
761
  Parallel to `ctx.emitLink(link)`. The kernel buffers the emission, validates the payload against the slot's payload schema in `$defs/payloads/<slot>` (AJV-compiled at boot), and persists the row to `scan_contributions` during `persistScanResult`. Off-shape payloads emit an `extension.error` event and drop silently, same posture as `emitLink` rejecting off-`emitsLinkKinds` links. Both Extractor and Analyzer emissions land in the same `scan_contributions` rows; the row's `extension_id` records which kind of extension produced it.
762
762
 
763
- The Extractor-emit signature binds `nodePath` implicitly (the extractor runs per-node, with `ctx.node.path` available as the only sensible target). The Analyzer-emit signature requires the analyzer to declare the target node explicitly because Analyzers see the full graph at once and may emit for any subset of nodes, the canonical use case is a analyzer that derives per-node values from cross-graph aggregations (`core/link-counts` projects `linksOutCount` / `linksInCount` this way).
763
+ The Extractor-emit signature binds `nodePath` implicitly (the extractor runs per-node, with `ctx.node.path` available as the only sensible target). The Analyzer-emit signature requires the analyzer to declare the target node explicitly because Analyzers see the full graph at once and may emit for any subset of nodes, the canonical use case is a analyzer that derives per-node values from cross-graph aggregations (`core/link-counter` projects `linksOutCount` / `linksInCount` this way).
764
764
 
765
765
  Analyzers MAY also emit scope-level contributions via `IAnalyzerContext.emitScopeContribution(contributionId, payload)` (only slots whose schema permits scope-level emission, today only `topbar.nav.start`). That signature is reserved in the spec; the runtime callback lands when the first scope-level adopter arrives.
766
766
 
@@ -831,7 +831,7 @@ Same honest-note posture as [`plugin-kv-api.md`](./plugin-kv-api.md): isolated a
831
831
 
832
832
  Two built-ins ship with the system to cover catalog evolution and rename edge cases:
833
833
 
834
- - **`core/unknown-slot`**, walks every loaded plugin's `ui[*].slot`; emits an `Issue` of severity `warn` for any slot not in the current kernel catalog. Parallel to `core/unknown-field` for annotations. Note: AJV at manifest load already rejects unknown slots as `invalid-manifest`; this analyzer covers the soft-warning path when a plugin remains loaded across a catalog version bump.
834
+ - **`core/unknown-slot`**, walks every loaded plugin's `ui[*].slot`; emits an `Issue` of severity `warn` for any slot not in the current kernel catalog. Parallel to `core/annotation-field-unknown` for annotations. Note: AJV at manifest load already rejects unknown slots as `invalid-manifest`; this analyzer covers the soft-warning path when a plugin remains loaded across a catalog version bump.
835
835
  - **`core/contribution-orphan`**, joins `scan_contributions` against the live `scan_nodes` set; emits an `Issue` of severity `warn` for emissions whose `node_path` no longer exists (post-rename heuristic miss).
836
836
 
837
837
  ### Catalog versioning
package/cli-contract.md CHANGED
@@ -279,7 +279,7 @@ The three privacy-sensitive keys above PLUS `allowEditSmFiles` are members of `P
279
279
  - `sm scan [roots...]`: when positional roots are given, they ARE the effective roots (verbatim). When omitted: the effective roots are `[cwd]` plus the appended set below.
280
280
  - `scan.extraFolders[]` (project-local config) is appended verbatim (entries starting with `~` resolve against the user home; relative entries resolve against the project root). This is the ONLY way to extend the scan beyond the project: there is no implicit `$HOME` walk, no opt-in global scope, and Providers cannot opt their own directory in. See §Scope is always project-local at the top of this file for the broader principle.
281
281
 
282
- **Reference paths** (`scan.referencePaths[]`): walked in parallel by the scan to collect existing absolute paths into a side set. Files there are NOT parsed and NOT indexed as nodes; the kernel passes the set to analyzers via `IAnalyzerContext.referenceablePaths` so `core/broken-ref` can resolve a link against the filesystem when the in-graph lookup misses.
282
+ **Reference paths** (`scan.referencePaths[]`): walked in parallel by the scan to collect existing absolute paths into a side set. Files there are NOT parsed and NOT indexed as nodes; the kernel passes the set to analyzers via `IAnalyzerContext.referenceablePaths` so `core/reference-broken` can resolve a link against the filesystem when the in-graph lookup misses.
283
283
 
284
284
  The watcher subscribes to the same roots that `sm scan` walks and respects `.skillmapignore` plus `config.ignore` exactly as the one-shot scan does. Filesystem events are grouped using `scan.watch.debounceMs` (default 300ms) before the watcher re-runs the incremental scan and persists. `SIGINT` / `SIGTERM` close the watcher cleanly. Exit code on clean shutdown is 0.
285
285
 
@@ -314,11 +314,11 @@ Actions are not invoked via `sm actions`; invocation is via `sm job submit` (see
314
314
 
315
315
  #### Sidecar bump (Step 9.6.4)
316
316
 
317
- The built-in deterministic `core/bump` Action is the canonical write channel for `<basename>.sm` annotation sidecars; the verbs below are its CLI surface plus a few sidecar-management helpers. The `bump` verb stays top-level (high frequency, ROADMAP-named); the administrative helpers live under the `sm sidecar` sub-namespace to avoid colliding with the existing `sm refresh` (which targets the enrichment layer, not sidecars). All sidecar-touching verbs are deterministic, they invoke `core/bump` (or the `FilesystemSidecarStore` directly) in-process and never queue jobs.
317
+ The built-in deterministic `core/node-bump` Action is the canonical write channel for `<basename>.sm` annotation sidecars; the verbs below are its CLI surface plus a few sidecar-management helpers. The `bump` verb stays top-level (high frequency, ROADMAP-named); the administrative helpers live under the `sm sidecar` sub-namespace to avoid colliding with the existing `sm refresh` (which targets the enrichment layer, not sidecars). All sidecar-touching verbs are deterministic, they invoke `core/node-bump` (or the `FilesystemSidecarStore` directly) in-process and never queue jobs.
318
318
 
319
319
  | Command | Purpose |
320
320
  |---|---|
321
- | `sm bump <node.path> [--force] [--yes]` | Single-node bump. Wraps `core/bump`. Refuses on a fresh node (`{ ok: false, reason: 'fresh' }`, exit `2`) unless `--force` is passed; with `--force` on a fresh node the verb is a silent no-op (exit `0`, no stdout). On a stale node (or first-time creation) increments `annotations.version`, refreshes `for.{bodyHash, frontmatterHash}`, and stamps the audit block (`audit.lastBumpedAt` + `audit.lastBumpedBy: 'cli'`; on first creation also `audit.createdAt` + `audit.createdBy: 'cli'`). Exit `5` if the node is not in the persisted scan. `--json` emits the report shape declared by `bump-report.schema.json`. `--yes` confirms consent for `.sm` writes in this project, see §`.sm` write consent below. |
321
+ | `sm bump <node.path> [--force] [--yes]` | Single-node bump. Wraps `core/node-bump`. Refuses on a fresh node (`{ ok: false, reason: 'fresh' }`, exit `2`) unless `--force` is passed; with `--force` on a fresh node the verb is a silent no-op (exit `0`, no stdout). On a stale node (or first-time creation) increments `annotations.version`, refreshes `for.{bodyHash, frontmatterHash}`, and stamps the audit block (`audit.lastBumpedAt` + `audit.lastBumpedBy: 'cli'`; on first creation also `audit.createdAt` + `audit.createdBy: 'cli'`). Exit `5` if the node is not in the persisted scan. `--json` emits the report shape declared by `bump-report.schema.json`. `--yes` confirms consent for `.sm` writes in this project, see §`.sm` write consent below. |
322
322
  | `sm bump --pending [--staged] [--force] [--yes]` | Batch bump. Walks every node whose sidecar overlay reports drift in `node.path` ASC order and bumps each. `--staged` runs `git add <sidecar-path>` after each successful bump so the new content lands in the same commit; `git add` failure degrades to a stderr warning, the batch keeps running. Empty stale set → exit `0` with a "nothing to do" advisory. `--json` envelope: `{ bumped, refused, skipped, errors[], elapsedMs }`. Exit `0` on a clean run; `1` when at least one per-node error landed in `errors[]`. **Git error matrix for `--staged`**: not inside a git repo (no `.git/` parent of `cwd`) → exit `5`; `git` binary not on PATH (spawn ENOENT) → exit `2`. Both checks run BEFORE any sidecar write so a misconfigured environment never produces partial state. `--yes` confirms consent for `.sm` writes, see §`.sm` write consent below. |
323
323
  | `sm sidecar refresh <node.path> [--yes]` | Hash-only update on the sidecar. Refreshes `for.{bodyHash, frontmatterHash}` to match the live node WITHOUT bumping `annotations.version` and WITHOUT touching the audit block. Useful when the user knows a body change is editorial-only and doesn't want to spend a version increment. Distinct from the top-level `sm refresh` (which targets the enrichment layer at Step A.8), different storage, different concept; the sub-namespace prefix prevents the collision. Exit `5` if the node has no sidecar or is not in the persisted scan. No-op on a fresh node (informational stderr, exit `0`). `--yes` confirms consent for `.sm` writes, see §`.sm` write consent below. |
324
324
  | `sm sidecar prune [--dry-run] [--yes]` | Delete orphan `.sm` files (sidecars whose accompanying `<basename>.md` does not exist on disk). Destructive, without `--dry-run` prompts for interactive confirmation listing every file to be deleted (per the §Dry-run analyzer for destructive verbs). `--yes` (alias `--force`) bypasses the destructive-confirmation prompt for non-interactive callers (CI, the pre-commit hook, scripts), NOT to be confused with the `.sm` write-consent gate, which does not apply to prune (delete is not a write). With `--dry-run` reports what would be deleted without touching disk and never prompts. Different domain from `sm orphans`, that verb operates on the node graph (rename heuristic); this one operates on the filesystem layer. `--json` envelope: `{ deleted, wouldDelete, errors, items[], elapsedMs }`. Exit `1` when delete failures landed in `errors`. |
@@ -373,7 +373,7 @@ Tracked as **R6** in the §Step 9.6 review queue: open by design, defer the `js-
373
373
 
374
374
  ##### BFF endpoint, `POST /api/sidecar/bump` (Step 9.6.5, BFF half)
375
375
 
376
- The Hono BFF exposes the single-node bump flow over REST so the UI can drive the same Action / Store the CLI uses. Behaviour mirrors `sm bump <node.path> [--force]` 1:1: same `core/bump` Action, same `FilesystemSidecarStore`, same fresh-vs-stale refusal semantics. The only differences from the CLI verb are the invoker label (`'ui'` vs `'cli'`, Decision A5 of 9.6.4 left this as a literal) and the wire shape. Batch (`--pending`) is intentionally CLI-only at 9.6.5, surfacing it over REST needs a job-style progress channel and lands later.
376
+ The Hono BFF exposes the single-node bump flow over REST so the UI can drive the same Action / Store the CLI uses. Behaviour mirrors `sm bump <node.path> [--force]` 1:1: same `core/node-bump` Action, same `FilesystemSidecarStore`, same fresh-vs-stale refusal semantics. The only differences from the CLI verb are the invoker label (`'ui'` vs `'cli'`, Decision A5 of 9.6.4 left this as a literal) and the wire shape. Batch (`--pending`) is intentionally CLI-only at 9.6.5, surfacing it over REST needs a job-style progress channel and lands later.
377
377
 
378
378
  | Field | Value |
379
379
  |---|---|
@@ -567,9 +567,9 @@ The reference implementation ships a Hono BFF rooted at `src/server/`. One Node
567
567
  | `GET /api/issues?severity=&analyzerId=&node=` | implemented | `RestEnvelope` (`kind: 'issues'`), list of issues. Filters: `severity` (CSV from `error\|warn\|info`), `analyzerId` (CSV; qualified or short suffix per `sm check --analyzers`), `node` (filter to issues whose `nodeIds` includes the path). No pagination at v14.2. |
568
568
  | `GET /api/graph?format=ascii\|json\|md` | implemented | formatter-rendered graph. `Content-Type` per format: `text/plain` (ascii), `application/json` (json), `text/markdown` (md / mermaid). Default `format=ascii`. Unknown format → 400 `bad-query`. |
569
569
  | `GET /api/config` | implemented | `RestEnvelope` (`kind: 'config'`), merged effective config (defaults → user → user-local → project → project-local → override). |
570
- | `GET /api/plugins` | implemented | `RestEnvelope` (`kind: 'plugins'`), list of installed plugins (built-in + drop-in) with status. Item shape: `{ id, version, kinds, status, reason, source: 'built-in'\|'project', granularity: 'bundle'\|'extension', description?: string, locked?: boolean, startsAsDisabled?: boolean, extensions?: Array<{ id, kind, version, enabled, description?: string, locked?: boolean }> }`. The `granularity` field reflects the manifest declaration (built-ins: hardcoded per `built-in-plugins/built-ins.ts`; drop-ins: from `plugin.json#/granularity`, default `'bundle'`). The `description` field on the bundle item carries the manifest-declared description (built-ins: hardcoded on `IBuiltInBundle`; drop-ins: `plugin.json#/description`); each `extensions[]` entry carries its extension manifest's `description` per `IExtensionBase` (`extensions/base.schema.json#/properties/description`). The SPA's Settings list renders the descriptions as muted secondary text and includes them in its substring-search index alongside the ids. The `extensions` array is present **only** when `granularity === 'extension'` AND the plugin loaded successfully; each entry's `enabled` reflects the per-extension override resolution (DB > settings.json > installed default). For `granularity: 'bundle'` plugins the array is omitted (the bundle is the only toggle-able key). The optional `locked: true` flag is stamped when the bundle id (or qualified extension id) appears in the host's lock-list (`src/server/locked-plugins.ts`); locked items render the toggle disabled in the SPA and any `PATCH` against them returns `403 locked`. The flag is omitted when false. The optional `startsAsDisabled: true` flag is stamped on drop-in plugins (never built-ins) whose discovery-time `status` was `'disabled'`, that is, the user had them disabled in `config_plugins` / `settings.json` at `sm serve` boot, so their handlers were never bucketed into the runtime. The SPA renders a per-row hint when this flag is set AND the user toggles the row back on, since re-enabling them requires `sm serve` restart (the rest of the toggle pipeline applies live). The flag is omitted when false. |
570
+ | `GET /api/plugins` | implemented | `RestEnvelope` (`kind: 'plugins'`), list of installed plugins (built-in + drop-in) with status. Item shape: `{ id, version, kinds, status, reason, source: 'built-in'\|'project', granularity: 'bundle'\|'extension', description?: string, locked?: boolean, startsAsDisabled?: boolean, extensions?: Array<{ id, kind, version, enabled, description?: string, locked?: boolean }> }`. The `granularity` field reflects the manifest declaration (built-ins: hardcoded per `built-in-plugins/built-ins.ts`; drop-ins: from `plugin.json#/granularity`, default `'bundle'`). The `description` field on the bundle item carries the manifest-declared description (built-ins: hardcoded on `IBuiltInBundle`; drop-ins: `plugin.json#/description`); each `extensions[]` entry carries its extension manifest's `description` per `IExtensionBase` (`extensions/base.schema.json#/properties/description`). The SPA's Settings list renders the descriptions as muted secondary text and includes them in its substring-search index alongside the ids. The `extensions` array is present whenever the plugin declares any extension AND the plugin loaded successfully, regardless of granularity. Each entry's `enabled` reflects the per-extension override resolution (DB > settings.json > installed default), this is the **preference** state and is independent of the bundle row. For `granularity: 'bundle'` plugins the runtime layers the two: the bundle id is the coarse kill-switch (when its row resolves to `false`, every extension is disabled regardless of per-extension overrides); when the bundle row resolves to `true`, each extension respects its qualified-id override. The Settings UI exposes per-extension toggles for both granularities so the operator can refine individual extractors inside a bundle without dropping it; `sm plugins enable/disable <bare-id>` still rejects qualified ids against bundle granularity, the per-extension axis is reserved for the UI and direct config edits. The optional `locked: true` flag is stamped when the bundle id (or qualified extension id) appears in the host's lock-list (`src/server/locked-plugins.ts`); locked items render the toggle disabled in the SPA and any `PATCH` against them returns `403 locked`. The flag is omitted when false. The optional `startsAsDisabled: true` flag is stamped on drop-in plugins (never built-ins) whose discovery-time `status` was `'disabled'`, that is, the user had them disabled in `config_plugins` / `settings.json` at `sm serve` boot, so their handlers were never bucketed into the runtime. The SPA renders a per-row hint when this flag is set AND the user toggles the row back on, since re-enabling them requires `sm serve` restart (the rest of the toggle pipeline applies live). The flag is omitted when false. |
571
571
  | `PATCH /api/plugins/:id` | implemented | Toggle one plugin's user override. `:id` MUST be a top-level bundle id; qualified-id form (`bundle/extension`) is the sibling route below. Body `{ enabled: boolean }` (JSON). Persists to `config_plugins` via `IConfigPluginsPort.set`, same path the CLI's `sm plugins enable\|disable` uses. Response is the canonical `{ id, version, kinds, status, reason, source }` row for the affected plugin (post-write `status` reflects the new override resolution). **Granularity**, rejected with 400 `bad-query` when the target bundle declares `granularity: 'extension'` (only the qualified-id form is toggle-able for those). **Lock**, rejected with 403 `locked` when the bundle id is in the host lock-list (`src/server/locked-plugins.ts`). **Apply window**, the override applies on the next scan (manual via `POST /api/scan` or `sm scan`, automatic via watcher batch); both the BFF and the watcher build a fresh resolver from `config_plugins` before composing extensions, so the toggle is honoured without restarting `sm serve`. The endpoint purges `scan_contributions` rows for the plugin immediately on disable so the UI stops rendering its chips before the next scan. **Exception**, drop-in plugins whose discovery-time `status` was `'disabled'` (carried as `startsAsDisabled: true` on the read shape) are NOT in the runtime extension buckets; re-enabling them via PATCH persists the override but requires `sm serve` restart for the handlers to be loaded. The SPA surfaces this per-row when the user re-enables such a plugin. The endpoint does NOT broadcast a WS event today. |
572
- | `PATCH /api/plugins/:bundleId/extensions/:extensionId` | implemented | Qualified-id form for `granularity: 'extension'` bundles (today: `core` + any user plugin that opts in). Body `{ enabled: boolean }`. Both segments are URL-path-segment-encoded (no slash inside `:bundleId` or `:extensionId`). Rejected with 400 `bad-query` when the target bundle declares `granularity: 'bundle'` (use the sibling route above). **Lock**, rejected with 403 `locked` when either the bundle id or the qualified `bundleId/extensionId` appears in the host lock-list. Same persistence + apply-window semantics as the bundle form (including the `startsAsDisabled` exception). |
572
+ | `PATCH /api/plugins/:bundleId/extensions/:extensionId` | implemented | Qualified-id form, accepted for ANY granularity (Phase 4b follow-up, commit `e45d2fd`): extension granularity to flip individual extensions, bundle granularity to refine one extension while leaving the rest of the bundle live (the bundle row is the coarse kill-switch). Body `{ enabled: boolean }`. Both segments are URL-path-segment-encoded (no slash inside `:bundleId` or `:extensionId`). 404 `not-found` when the bundle id is unknown or the extension id does not belong to that bundle. **Lock**, rejected with 403 `locked` when either the bundle id or the qualified `bundleId/extensionId` appears in the host lock-list. Same persistence + apply-window semantics as the bundle form (including the `startsAsDisabled` exception). The CLI `sm plugins enable/disable <bundle>/<ext>` still rejects against bundle-granularity targets, the qualified axis is reserved for the UI and direct config edits. |
573
573
  | `PATCH /api/plugins` | implemented | Bulk toggle. Body `{ "changes": Array<{ "id": string, "enabled": boolean }> }` where each `id` is either a bare bundle id or a qualified `<bundle>/<extension>` id (the dispatcher branches on the slash exactly like the single-id routes above). Empty `changes` array is accepted as a no-op (returns the current `GET /api/plugins` envelope). The route validates the **entire batch** before writing, any invalid entry (`unknown-plugin` / `granularity-mismatch` / `locked`) rejects the whole request with the offending id in `error.details.id`, and the DB is not touched. Valid batches are applied in **one SQLite transaction**: `IConfigPluginsPort.set` per entry, then one grouped `scan_contributions` purge per disabled plugin (enables skip the purge). Response is the same `RestEnvelope` (`kind: 'plugins'`) shape as `GET /api/plugins`, reflecting the post-write state, the SPA replaces its modal state from this envelope. Apply-window and `startsAsDisabled` exception semantics match the per-id routes. The single-id `PATCH /api/plugins/:id` and qualified-id sibling stay available for CLI / external automation; the bulk variant exists so the SPA can stage edits in a buffered modal and ship the final delta atomically. |
574
574
  | `ALL /api/*` (other) | reserved | structured 404 envelope (see below); future endpoints land in subsequent sub-steps. |
575
575
  | `GET /ws` | implemented (v14.4.a) | accepts WebSocket upgrade and registers the client with the BFF broadcaster. Server-push only, the server fans `scan.*` (and forthcoming `issue.*`) events to every connected client. See **WebSocket protocol** below. |
@@ -603,7 +603,7 @@ Error code sources at v14.2:
603
603
  - `internal` (500), uncaught error during a request (e.g. config-load failure, DB corruption surfacing through `loadScanResult`).
604
604
  - `db-missing` (500), emitted by mutation endpoints (`PATCH /api/plugins/:id`, `PATCH /api/plugins/:bundleId/extensions/:extensionId`, `PATCH /api/plugins`) when the project DB is absent. Read-side routes uniformly degrade to the empty shape (`/api/scan`) or zero items (list endpoints) so they do not emit this code; mutation endpoints cannot persist without a DB so they fail fast instead of silently dropping the write.
605
605
  - `not-found` (404) on `PATCH /api/plugins/:id`, unknown plugin id (no built-in bundle, no discovered drop-in matches). The qualified-id form returns the same code when either segment misses. The bulk `PATCH /api/plugins` returns the same code with `error.details.id` set to the first offending id; the batch is rejected before any DB write.
606
- - `bad-query` (400) on `PATCH /api/plugins/:id`, granularity mismatch (bundle-level call against a `granularity: 'extension'` bundle, or qualified-id call against a `granularity: 'bundle'` bundle), malformed body (missing `enabled`, wrong type), unknown extension id under a known bundle. The bulk `PATCH /api/plugins` returns the same code for the same conditions (per-entry granularity mismatch, missing/typeless `enabled`, malformed `changes` array), with `error.details.id` set to the first offending entry's id.
606
+ - `bad-query` (400) on `PATCH /api/plugins/:id`, granularity mismatch (bundle-level call against a `granularity: 'extension'` bundle, the bare-id form is reserved for bundle granularity), malformed body (missing `enabled`, wrong type). The qualified-id sibling `PATCH /api/plugins/:bundleId/extensions/:extensionId` accepts any granularity and returns 404 `not-found` for an unknown extension id. The bulk `PATCH /api/plugins` returns 400 for the same per-entry conditions (granularity mismatch on a bare-id entry, missing/typeless `enabled`, malformed `changes` array), with `error.details.id` set to the first offending entry's id.
607
607
  - `locked` (403) on `PATCH /api/plugins/:id` and the qualified-id sibling, the target bundle id or qualified extension id is in the host lock-list (`src/server/locked-plugins.ts`). The list is hardcoded, host-only, and not user-editable; `GET /api/plugins` mirrors the same analyzer by stamping `locked: true` on the affected items. The bulk `PATCH /api/plugins` returns the same code with `error.details.id` set to the first locked entry; the batch is rejected before any DB write.
608
608
  - `bad-query` (400) on `POST /api/scan`, the server was started with `--no-built-ins` or `--no-plugins` (partial pipeline would persist a misleading DB).
609
609
  - `scan-busy` (409) on `POST /api/scan`, another scan (a watcher batch or another POST) is already in flight. Retry once the in-flight scan resolves; the WS `scan.completed` envelope is the unambiguous "now safe" signal.
@@ -44,7 +44,7 @@ This file is hand-maintained. A CI check before spec release compares the schema
44
44
  | 33 | `plugins-doctor.schema.json` |, | 🔴 missing | Machine-readable output of `sm plugins doctor --json`. Aggregates per-status counts plus structured issue / warning lists. Direct conformance case pending: prime a scope with one healthy + one invalid-manifest drop-in plugin, run `sm plugins doctor --json`, assert the envelope validates and the invalid plugin appears under `issues[]`. Implementation tests at `src/test/plugins-cli.test.ts` cover the runtime behaviour. |
45
45
  | 34 | `conformance-result.schema.json` |, | 🔴 missing | Machine-readable output of `sm conformance run --json`. Self-referential by design (a conformance case would invoke the verb against itself); a direct case is deferred until the runner gains a meta-loopback mode. Implementation tests at `src/test/conformance-cli.test.ts` cover the envelope shape today. |
46
46
  | 35 | `user-settings.schema.json` | (indirect via `no-global-scope`) | 🟡 partial | Per-user / per-machine settings file at `~/.skill-map/settings.json` (the narrow `$HOME` exception, see `cli-contract.md` §User-settings file). Direct case is not added because alt-impls MAY choose to not ship an update-check feature, requiring them to produce this file would over-prescribe. The implementation-side AJV round-trip is covered by `src/test/user-settings-store.test.ts` (15 cases: defaults, malformed JSON, schemaVersion mismatch, wrong-type fields, unknown top-level keys, deep-merge writes, off-shape rejection). The behavioral counterpart (no global / user scope) lives at `no-global-scope` in the non-schema table below. |
47
- | 37 | `signal.schema.json` | `extractor-emits-signal`, `signal-collision-detection` | ✅ covered | Intermediate Representation (IR) emitted by extractors via `ctx.emitSignal()`; the kernel resolver phase consumes Signals and materialises Links. Opt-in: the existing `ctx.emitLink()` path coexists. Phase 2.A wired the resolver end-to-end (filter -> rank -> overlap -> materialise + annotate); Phase 2.B + 2.C migrated all six link-emitter extractors (`claude/at-directive`, `claude/slash`, `core/markdown-link`, `core/annotations`, `core/mcp-tools`, `core/external-url-counter`); Phase 2.D added the `core/signal-collision` analyzer + the two cases. The cases cover (a) `extractor-emits-signal`, a markdown body with one `[text](path)` link materialises one Link via the Signal IR path; (b) `signal-collision-detection`, a body with both `[label](./api.md)` AND `@./api.md` at overlapping byte ranges triggers a cross-extractor collision, the resolver materialises ONE Link (markdown-link wins on confidence) and the loser's `resolution.rejectedBy` reaches the `core/signal-collision` analyzer which emits a `warn` issue naming WHO won, WHO lost, and WHY. |
47
+ | 37 | `signal.schema.json` | `extractor-emits-signal`, `signal-collision-detection` | ✅ covered | Intermediate Representation (IR) emitted by extractors via `ctx.emitSignal()`; the kernel resolver phase consumes Signals and materialises Links. Opt-in: the existing `ctx.emitLink()` path coexists. Phase 2.A wired the resolver end-to-end (filter -> rank -> overlap -> materialise + annotate); Phase 2.B + 2.C migrated all six link-emitter extractors (`claude/at-directive`, `claude/slash-command`, `core/markdown-link`, `core/annotations`, `core/mcp-tools`, `core/external-url-counter`); Phase 2.D added the `core/signal-collision` analyzer + the two cases. The cases cover (a) `extractor-emits-signal`, a markdown body with one `[text](path)` link materialises one Link via the Signal IR path; (b) `signal-collision-detection`, a body with both `[label](./api.md)` AND `@./api.md` at overlapping byte ranges triggers a cross-extractor collision, the resolver materialises ONE Link (markdown-link wins on confidence) and the loser's `resolution.rejectedBy` reaches the `core/signal-collision` analyzer which emits a `warn` issue naming WHO won, WHO lost, and WHY. |
48
48
 
49
49
  > **Note on Provider-owned schemas.** Per-kind frontmatter schemas (`skill`, `agent`, `command`, `note` for the built-in Claude Provider; other Providers MAY declare different kinds) live with the Provider that emits them, for the built-in Claude Provider, under `src/extensions/providers/claude/schemas/`. Those schemas are NOT counted in the spec's coverage matrix above; they belong to the Provider's own conformance suite at `src/extensions/providers/claude/conformance/coverage.md`. The same split applies to the cases that exercise Provider-specific kinds (`basic-scan`, `rename-high`, `orphan-detection`), they live in the Provider's `cases/` directory.
50
50
 
package/db-schema.md CHANGED
@@ -173,7 +173,7 @@ The orchestrator consults this table on `sm scan --changed`: a node-level cache
173
173
 
174
174
  Primary key: `(node_path, extractor_id)`. Indexes: `ix_scan_extractor_runs_node`, `ix_scan_extractor_runs_extractor`.
175
175
 
176
- **Source-attribution interaction.** `scan_links.sources_json` carries the *short* extractor id the author wrote (e.g. `'slash'`); this table keys on the *qualified* form (`'core/slash'`). When a cached link is reshaped on reuse the orchestrator strips short ids whose owning Extractor is no longer registered (audit trail accuracy: a removed extractor must not stay attributed); links whose sole source is an uninstalled Extractor disappear; links whose sources include a missing-but-still-registered Extractor are dropped so the missing Extractor can re-emit fresh.
176
+ **Source-attribution interaction.** `scan_links.sources_json` carries the *short* extractor id the author wrote (e.g. `'slash'`); this table keys on the *qualified* form (`'core/slash-command'`). When a cached link is reshaped on reuse the orchestrator strips short ids whose owning Extractor is no longer registered (audit trail accuracy: a removed extractor must not stay attributed); links whose sole source is an uninstalled Extractor disappear; links whose sources include a missing-but-still-registered Extractor are dropped so the missing Extractor can re-emit fresh.
177
177
 
178
178
  ### `node_enrichments`
179
179
 
@@ -242,7 +242,7 @@ Cached nodes' rows survive untouched, they're neither orphaned (still in the liv
242
242
 
243
243
  NOT analogous to `state_plugin_kvs` (which is plugin-managed). Belongs to the `scan_*` family, sweep semantics replace pure replace-all but the data is still scan-derived.
244
244
 
245
- **Eager purge on disable.** `sm plugins disable <id>` calls `StoragePort.contributions.purgeByPlugin(pluginId, extensionId?)` immediately after persisting `config_plugins[<id>].enabled = false`. `extensionId` is omitted for bundle-granularity ids (e.g. `claude`) and supplied for qualified ids (e.g. `core/slash`), mirroring how the catalog sweep groups rows. The eager purge avoids the "I disabled the plugin but its chips are still rendered in the UI until I re-scan" gap. Re-enabling (`sm plugins enable <id>`) does NOT restore the rows, the next scan re-emits them, same as a cold start. Contributions are scan-derived, so this is cheap; for plugin-managed state (`state_plugin_kvs`, dedicated tables) the opposite policy holds, see `plugin-kv-api.md` § "disable does not drop data".
245
+ **Eager purge on disable.** `sm plugins disable <id>` calls `StoragePort.contributions.purgeByPlugin(pluginId, extensionId?)` immediately after persisting `config_plugins[<id>].enabled = false`. `extensionId` is omitted for bundle-granularity ids (e.g. `claude`) and supplied for qualified ids (e.g. `core/slash-command`), mirroring how the catalog sweep groups rows. The eager purge avoids the "I disabled the plugin but its chips are still rendered in the UI until I re-scan" gap. Re-enabling (`sm plugins enable <id>`) does NOT restore the rows, the next scan re-emits them, same as a cold start. Contributions are scan-derived, so this is cheap; for plugin-managed state (`state_plugin_kvs`, dedicated tables) the opposite policy holds, see `plugin-kv-api.md` § "disable does not drop data".
246
246
 
247
247
  ### `scan_node_tags`
248
248
 
package/index.json CHANGED
@@ -174,14 +174,14 @@
174
174
  }
175
175
  ]
176
176
  },
177
- "specPackageVersion": "0.35.0",
177
+ "specPackageVersion": "0.36.0",
178
178
  "integrity": {
179
179
  "algorithm": "sha256",
180
180
  "files": {
181
- "CHANGELOG.md": "a6cf7d366dcfe0a04fcb438beb98fd6bc393df78c7ba7cdb396607622b6a3959",
181
+ "CHANGELOG.md": "8e2a8f2cdfbe49b8ced8bf7ca118bc10bb50b7ce78c212f936e575f3cd582b0d",
182
182
  "README.md": "1c4b0ea58c4324f301043e9f5c36976a382d0bd2bc405a2e4e18463b0c50d946",
183
- "architecture.md": "e87b916c0f3e166c79667d35472efcc27fd2dacf213907518b2ec9345aae603c",
184
- "cli-contract.md": "2e20c2ac77c300b3f12759c3f36d56f4624862ff0abb34d100f1c3f00861bccc",
183
+ "architecture.md": "225b6b3d2377928ffd091bed6b974136fc8a45b603b886fa636766d77ef3c3a2",
184
+ "cli-contract.md": "6e43e0636b148b091ab21a7b54f271b4c6323431bfadb3eedb336a7fd339380d",
185
185
  "conformance/README.md": "6871dde25b5770ed945284c9e0f749e0768ec3f5ba4966bdb215985789e43887",
186
186
  "conformance/cases/extractor-emits-signal.json": "34b4808c232d66a0eea0f5db7632a746681432b4f0995b6bf39e8d675538451c",
187
187
  "conformance/cases/kernel-empty-boot.json": "2a5be9c93143d07a16d998df09dcc8fa4ea2d2f9a0bff6417573ed5a770352c1",
@@ -190,7 +190,7 @@
190
190
  "conformance/cases/plugin-missing-ui-rejected.json": "59a571a2e80c2bac2050eacbe740f4f3f125849dd242954508f011304cc3e036",
191
191
  "conformance/cases/sidecar-end-to-end.json": "dbb3640f95769a36b881855a261f918481edadea13a7eb0765c6090f2417a142",
192
192
  "conformance/cases/signal-collision-detection.json": "38c6d553c6f82c1b624fb8a8e9b4fc72034fc47bc70f7f011b3b9136817e7388",
193
- "conformance/coverage.md": "cb0e4fb73f58c28d9ec15f733c08a6ad70fedf9eb1d1b5220adb7fa52a364343",
193
+ "conformance/coverage.md": "106468a6d9a65e5fdefbf75c8de4abecf35d5c08f2b6c44423741c60b723baea",
194
194
  "conformance/fixtures/orphan-markdown/.claude/agents/reviewer.md": "7f062731106f2d9811e4fffcf6ab44b8dfff4cfb16536a469514cc0664e832bf",
195
195
  "conformance/fixtures/orphan-markdown/ARCHITECTURE.md": "ec903666440bae65da3796b1158c92cfcdce22e0e09c3b20bb690176881a6ac4",
196
196
  "conformance/fixtures/plugin-missing-ui/.skill-map/plugins/bad-provider/kinds/markdown/kind.json": "6676a89bae5197e23cf50f1c11d596db558ac80f7334a7208fe57d8b92422251",
@@ -208,22 +208,22 @@
208
208
  "conformance/fixtures/signal-ir-collision/.claude/agents/architect.md": "acc46b5b2dff73d98a354e4d53b5041164595deae466a4e2ce41d7c5a72f28fb",
209
209
  "conformance/fixtures/signal-ir-single-signal/source.md": "1eda417b4c6eed372b66870e385c8d8cd631372b77cab7e996bb711e22218f89",
210
210
  "conformance/fixtures/signal-ir-single-signal/target.md": "527137f2b4f46c0034b0edc8932cf8613d2bf22ffaaf78f01085c82a3baaebe3",
211
- "db-schema.md": "e56dab70f0469e8e6bd2440e8758c0436e710bc45c2ee812ac40a10b0c29ae77",
211
+ "db-schema.md": "9d265dacaf30649f0b7eddb6d31d1017da05a248524acc7b08eb3735e2e603c1",
212
212
  "interfaces/security-scanner.md": "e8049712b9cf7a07c786bf19f8f775f8ef9638f063f7fba5c7a8b1431b92f38e",
213
- "job-events.md": "84206168ac12b536d34470d62f8c8cba95dab181fee66d23203c2cf5dfbee716",
213
+ "job-events.md": "9d5b35d4c451a7f8eef9915d85316d924ac52f1c026a316cdda5f1099d496854",
214
214
  "job-lifecycle.md": "9c429121f98a07c8795f8979ed1abc5e5334e3f89db51585a8da55c527ef855b",
215
- "plugin-author-guide.md": "58331cdd07447f05775d2dd030f20e8276a1a227269c5f50b631230f83dec081",
215
+ "plugin-author-guide.md": "16c61dca29460075e95c0506f0a94c057a6e07018784f26b08e41e609b01f759",
216
216
  "plugin-kv-api.md": "1acc69ed82433a74e35ada61d63a6d7379fb61046ff83de1e0facbe884c64704",
217
217
  "prompt-preamble.md": "9dd4f6d1bc6a425f8782fcee10cbe75909e8d64e28781fda56c2fae909b02f40",
218
218
  "schemas/annotations.schema.json": "e39990d47f53e25a1b3a5587a5714486d0b819b8eeaac10d42783a675296aee1",
219
219
  "schemas/api/rest-envelope.schema.json": "926c63af10574599da5583ed067788b4a72be427788f0aa11b61022fb9649461",
220
- "schemas/bump-report.schema.json": "c2d853715d5f50098567bc23382a4e81baf78d589c6e1baf67d3b841e7f7d8ae",
220
+ "schemas/bump-report.schema.json": "2e6daff2435ef1a4af6c2c911fd3ce2a30ace40c8910dd6f675dd187fd40a99b",
221
221
  "schemas/conformance-case.schema.json": "f6d4c9fb92e79cb516eeeb9d042223572a3bd5ff8e7871a0becce13916f20cf6",
222
222
  "schemas/conformance-result.schema.json": "426998e4f5cb079778ca7d0233634667d4fbc5a7e399cc41211fabd768db8ee0",
223
223
  "schemas/execution-record.schema.json": "0d61e33f2dc1aaa4cc7337b5eac4ea8b9034022ce24bae9156c3c9f33204c250",
224
224
  "schemas/extensions/action.schema.json": "9f6c2427ce3f0d6fa329adf0f13129821116ab79a1d2a53f96464513ff044ebe",
225
225
  "schemas/extensions/analyzer.schema.json": "f9bed3ba1305b2b64da277dccfbe760f7c058c4bb62a2d845af9c75787f159f6",
226
- "schemas/extensions/base.schema.json": "8aaf1f8f1693d401e32feb91d4e064ff80ec7d4b0e3f15eff4202c708febaef4",
226
+ "schemas/extensions/base.schema.json": "46fbec5d305673524d7298d6374c44ebd76273d86f46a930c568d4250caaf97d",
227
227
  "schemas/extensions/extractor.schema.json": "5994088bf669321d2a7b8262c07cc94e05e5e2f49a235ae5389b7c66ecc1b2e1",
228
228
  "schemas/extensions/formatter.schema.json": "d6d417df20260e5ddfe71f104b11a45873869706f86372c3c3c78c583e06e8d5",
229
229
  "schemas/extensions/hook.schema.json": "76bf2c07f9e689b3fd1c67cbad4516a4df10604f07103759e82670e5213ddcdf",
@@ -234,11 +234,11 @@
234
234
  "schemas/input-types.schema.json": "c713b768d0b0e3d0c764afb401189f7fb624a82b4e988b73aab015cf9c67c01f",
235
235
  "schemas/issue.schema.json": "fa3344e75f1c3a5304291ca355bb973046552a68871ad6eb4edafca1cd9e1be8",
236
236
  "schemas/job.schema.json": "e43e1761c99920beffe1de12ef8f32fe29f97838bd8686742b637c19c4dbb395",
237
- "schemas/link.schema.json": "336ce710250184ffa40b5d1c3ec52a275529d969d5b400177f2e2adebc643e39",
237
+ "schemas/link.schema.json": "8a01925a9a7c00bbe41cdcbf8eea9269955570d41484da93d4193232a7aa9070",
238
238
  "schemas/node.schema.json": "4d7c107ed9cd2f1b7cc4d716c547c06a00ed776bd6092d3979cac634cb5326a5",
239
239
  "schemas/plugins-doctor.schema.json": "c1d92f30fdb0080e8cd8f7dc5d43e01aae02a16640bc5eb04811c337a275de58",
240
240
  "schemas/plugins-registry.schema.json": "cca7ae65f0c22510ea27ea5ae34e0074f5beb5871a57b005b6b831e6ceaff5c0",
241
- "schemas/project-config.schema.json": "18f2f599023d3d567576e3ac5e722430d3f076ca3b66e412fbeaee8caf6e110f",
241
+ "schemas/project-config.schema.json": "f2a38a82fe47c8a965e6b73208e9be996d3d3545fdcd92f8a136f1d1e5abd3fb",
242
242
  "schemas/refresh-report.schema.json": "54519b8caf86ba84c182f9565be9b5084bc1631ae05e9217ee18f34c0039fff3",
243
243
  "schemas/report-base-deterministic.schema.json": "9d318d0181d121097c906ef3af1c52d71c782740bd04cf23418d7627ce2c3ed5",
244
244
  "schemas/report-base.schema.json": "a1021e9a59b4df9f99cd92454d797e88469766e7d49f52d231c4645ffdfdad8f",
package/job-events.md CHANGED
@@ -412,7 +412,7 @@ Emitted once per registered Analyzer, after every issue has been validated.
412
412
  "runId": "...",
413
413
  "jobId": null,
414
414
  "data": {
415
- "analyzerId": "core/superseded"
415
+ "analyzerId": "core/node-superseded"
416
416
  }
417
417
  }
418
418
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skill-map/spec",
3
- "version": "0.35.0",
3
+ "version": "0.36.0",
4
4
  "description": "JSON Schemas, prose contracts, and conformance suite for the skill-map specification.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -115,19 +115,25 @@ Concrete examples for the reference impl's bundled extensions:
115
115
  |---|---|---|
116
116
  | Claude Provider | `claude` | `claude/claude` |
117
117
  | Annotations extractor | `annotations` | `core/annotations` |
118
- | Slash extractor | `slash` | `core/slash` |
119
- | At-directive extractor | `at-directive` | `core/at-directive` |
118
+ | Slash-command extractor | `slash-command` | `claude/slash-command` |
119
+ | At-directive extractor | `at-directive` | `claude/at-directive` |
120
120
  | Markdown-link extractor | `markdown-link` | `core/markdown-link` |
121
121
  | External-URL counter | `external-url-counter` | `core/external-url-counter` |
122
- | Broken-ref analyzer | `broken-ref` | `core/broken-ref` |
122
+ | Reference-broken analyzer | `reference-broken` | `core/reference-broken` |
123
123
  | Trigger-collision analyzer | `trigger-collision` | `core/trigger-collision` |
124
124
  | ASCII formatter | `ascii` | `core/ascii` |
125
- | Validate-all analyzer | `validate-all` | `core/validate-all` |
125
+ | Schema-violation analyzer | `schema-violation` | `core/schema-violation` |
126
126
 
127
127
  Built-ins split between two namespaces:
128
128
 
129
- - **`core/`**, kernel-internal primitives, platform-agnostic. Owns every built-in analyzer (including `validate-all`), the ASCII formatter, and the cross-vendor extractors (`annotations`, `slash`, `at-directive`, `markdown-link`, `external-url-counter`) any Provider can rely on.
130
- - **`claude/`**, the Claude Code Provider bundle: the Provider that classifies `.claude/{agents,commands,skills}` paths and parses their frontmatter. Other vendor bundles (`antigravity`, `openai`, `agent-skills`) follow the same shape, Provider only, since the syntax their nodes use is shared with Claude and lives in `core`.
129
+ - **`core/`**, kernel-internal primitives, platform-agnostic. Owns every built-in analyzer (including `schema-violation`), the ASCII formatter, and the cross-vendor extractors (`annotations`, `markdown-link`, `external-url-counter`) any Provider can rely on.
130
+ - **`claude/`**, the Claude Code Provider bundle: the Provider that classifies `.claude/{agents,commands,skills}` paths and parses their frontmatter, plus the Claude-flavoured extractors (`slash-command`, `at-directive`). Other vendor bundles (`antigravity`, `openai`, `agent-skills`) follow the same shape, Provider only, since the syntax their nodes use is shared with Claude.
131
+
132
+ ### Extension id shape
133
+
134
+ The naming convention applied to every built-in extension id is **`<domain>-<detail>`** (general to specific). The leftmost segment names the entity the extension reasons about (`node`, `link`, `annotation`, `reference`, `name`, `field`, `tools`, ...), the rest narrows the specific behaviour or signal it produces. Examples: `annotation-orphan`, `link-counter`, `node-stability`, `name-reserved`, `reference-broken`.
135
+
136
+ Authors building their own plugins are not required to follow this pattern, but doing so makes `sm plugins list` self-grouping and the qualified ids predictable. Verb-style ids (e.g. `bump`, `mark-superseded`) are deliberately avoided on built-ins: even Actions live under their entity domain (`node-bump`, `node-supersede`) so the catalog reads as a structured list rather than a mix of nouns and imperatives.
131
137
 
132
138
  For your own plugin, the `id` you declare in `plugin.json` is the namespace for every extension the plugin contains. If your manifest declares `id: "my-plugin"` and your extension file declares `id: "foo-extractor"`, the kernel registers it as `my-plugin/foo-extractor`. You do **not** write the qualifier yourself, the loader injects it.
133
139
 
@@ -153,12 +159,12 @@ Every plugin and every built-in bundle declares a **granularity** that controls
153
159
  | Granularity | Toggle key | When to use |
154
160
  |---|---|---|
155
161
  | `bundle` (default) | the bundle id alone (e.g. `my-plugin`, `claude`) | The plugin's extensions form a coherent product (e.g. a Provider and the extractors that decode its native syntax). The user wants one switch. **95% of plugins.** |
156
- | `extension` | the qualified extension id (`<bundle>/<ext-id>`, e.g. `core/superseded`, `my-plugin/orphan-skill`) | The plugin ships several orthogonal capabilities a user might reasonably want piecemeal. **Built-in `core` is the canonical example**, the spec promises every kernel built-in is removable, so each one toggles independently. |
162
+ | `extension` | the qualified extension id (`<bundle>/<ext-id>`, e.g. `core/node-superseded`, `my-plugin/orphan-skill`) | The plugin ships several orthogonal capabilities a user might reasonably want piecemeal. **Built-in `core` is the canonical example**, the spec promises every kernel built-in is removable, so each one toggles independently. |
157
163
 
158
164
  Built-in mapping:
159
165
 
160
166
  - **`claude`** / **`antigravity`** / **`openai`** / **`agent-skills`**, `granularity: 'bundle'`. Each vendor Provider bundle is enabled or disabled as a whole; today every such bundle ships only its Provider, so the toggle flips classification + frontmatter parsing for that platform.
161
- - **`core`**, `granularity: 'extension'`. `sm plugins disable core/superseded` flips just the supersession analyzer; every other core extension (every other analyzer, the ASCII formatter, the cross-vendor extractors) stays live.
167
+ - **`core`**, `granularity: 'extension'`. `sm plugins disable core/node-superseded` flips just the supersession analyzer; every other core extension (every other analyzer, the ASCII formatter, the cross-vendor extractors) stays live.
162
168
 
163
169
  Per-verb behaviour:
164
170
 
@@ -167,9 +173,12 @@ Per-verb behaviour:
167
173
  | `sm plugins enable claude` | OK, flips the bundle. | Rejected: `'core' has granularity=extension; use sm plugins enable core/<ext-id>`. |
168
174
  | `sm plugins enable claude/claude` | Rejected: `'claude' has granularity=bundle; use sm plugins enable claude`. | n/a (no bundle of granularity=bundle accepts qualified ids) |
169
175
  | `sm plugins disable core` | n/a | Rejected: same directed message as the bundle row above. |
170
- | `sm plugins disable core/superseded` | n/a | OK, persists `config_plugins['core/superseded'].enabled = 0`. |
176
+ | `sm plugins disable core/node-superseded` | n/a | OK, persists `config_plugins['core/node-superseded'].enabled = 0`. |
177
+
178
+ Resolution order per id is the same as for plugin enabled-state: DB override (`config_plugins`) > settings.json (`#/plugins/<id>/enabled`) > installed default (`true`). `settings.json#/plugins` keys are arbitrary strings (no AJV pattern), so both bare and qualified ids are accepted there. Granularity controls how the per-id results compose into the effective runtime state:
171
179
 
172
- Resolution order is the same as for plugin enabled-state: DB override (`config_plugins`) > settings.json (`#/plugins/<id>/enabled`) > installed default (`true`). For granularity=extension bundles the row key is the qualified id; for granularity=bundle bundles the row key is the bundle id. `settings.json#/plugins` keys are arbitrary strings (no AJV pattern), so both forms are accepted there too.
180
+ - **extension granularity** (`core`): only qualified ids matter. Each extension is independently toggle-able.
181
+ - **bundle granularity** (`claude`, `antigravity`, ...): the bundle id is the coarse kill-switch. When the bundle id resolves to `false`, every extension in the bundle is disabled regardless of per-extension overrides. When the bundle resolves to `true`, each extension respects its qualified-id override (default `true`). This lets the Settings UI refine individual extractors inside a bundle (e.g. disable `claude/at-directive` while keeping the provider live) without an asymmetric CLI surface, `sm plugins enable/disable <bare-id>` still rejects qualified ids against bundle granularity, the per-extension axis is reserved for the UI and direct `settings.json` / `config_plugins` edits.
173
182
 
174
183
  `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.
175
184
 
@@ -340,7 +349,7 @@ You can read `ctx.node.sidecar.*` freely, the kernel's per-`(node, extractor)` c
340
349
  > **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
350
  >
342
351
  > - **`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 / Antigravity 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`).
352
+ > - **`core/slash-command`**: 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
353
  > - **Both extractors strip fenced code blocks and inline backticks before matching**, so author-marked literal payload never registers as invocation surface.
345
354
  >
346
355
  > 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.
@@ -404,14 +413,14 @@ export default {
404
413
  };
405
414
  ```
406
415
 
407
- > **`recommendedActions`, analyzer-side hint, not a precondition.** An Analyzer MAY declare `recommendedActions: string[]` with the qualified ids (`<pluginId>/<id>`) of the per-node Actions that resolve its findings. The built-in `core/annotation-stale` analyzer declares `['core/bump']` because bumping the node refreshes the `for.*` hashes that drove the warning. The UI surfaces matching Actions in the node inspector under "Recommended for issues" alongside the always-applicable list driven by `Action.precondition`.
416
+ > **`recommendedActions`, analyzer-side hint, not a precondition.** An Analyzer MAY declare `recommendedActions: string[]` with the qualified ids (`<pluginId>/<id>`) of the per-node Actions that resolve its findings. The built-in `core/annotation-stale` analyzer declares `['core/node-bump']` because bumping the node refreshes the `for.*` hashes that drove the warning. The UI surfaces matching Actions in the node inspector under "Recommended for issues" alongside the always-applicable list driven by `Action.precondition`.
408
417
  >
409
418
  > The two surfaces are distinct:
410
419
  >
411
420
  > - **`Action.precondition`**, declared on the Action side, answers "which nodes does this Action apply to?". Always evaluated against the node the inspector is focused on.
412
421
  > - **`Analyzer.recommendedActions`**, declared on the Analyzer side, answers "which Actions are the natural fix when THIS analyzer fires?". Surfaces only when the analyzer emitted an issue against the focused node.
413
422
  >
414
- > Each entry MUST be the qualified id of a registered Action. The kernel logs `recommended-action-missing` (an `extension.error` event) when a referenced action is not loaded, and the analyzer stays registered, only the recommendation hint is dropped. Project-level cleanup verbs (orphan file prune, contribution relink) are CLI commands, not Actions, and are NOT linked through this field. Omit `recommendedActions` when the issue is a deliberate user declaration with no "fix" (e.g. `core/superseded` surfaces user-authored supersession statements).
423
+ > Each entry MUST be the qualified id of a registered Action. The kernel logs `recommended-action-missing` (an `extension.error` event) when a referenced action is not loaded, and the analyzer stays registered, only the recommendation hint is dropped. Project-level cleanup verbs (orphan file prune, contribution relink) are CLI commands, not Actions, and are NOT linked through this field. Omit `recommendedActions` when the issue is a deliberate user declaration with no "fix" (e.g. `core/node-superseded` surfaces user-authored supersession statements).
415
424
 
416
425
  ### Formatters
417
426
 
@@ -869,15 +878,15 @@ Two plugins claiming the same `(key, location: 'root', ownership: 'exclusive')`
869
878
 
870
879
  This is the only fatal path on the plugin-load surface. Every other failure mode (manifest invalid, schema invalid, dynamic-import failure, id collision) is per-plugin and the kernel keeps booting on the survivors.
871
880
 
872
- ### Tier-1 typo guard (`core/unknown-field`)
881
+ ### Tier-1 typo guard (`core/annotation-field-unknown`)
873
882
 
874
- The built-in `core/unknown-field` Analyzer walks every parsed `.sm` and emits a `warn` issue per truly-unknown key. Three surfaces are checked:
883
+ The built-in `core/annotation-field-unknown` Analyzer walks every parsed `.sm` and emits a `warn` issue per truly-unknown key. Three surfaces are checked:
875
884
 
876
885
  1. Inside `annotations:`, keys not in `annotations.schema.json`'s curated catalog (the 10 conventional fields). Plugins do NOT contribute to `annotations:`; that block is skill-map-curated.
877
886
  2. At the sidecar root, keys outside the four reserved blocks (`for`, `annotations`, `settings`, `audit`) that are also NOT a registered plugin namespace `<plugin-id>:` AND NOT a registered `location: 'root'` contribution.
878
887
  3. Inside a registered `<plugin-id>:` namespace, values that fail the schema declared by the owning plugin's `annotationContributions[<key>].schema`.
879
888
 
880
- The analyzer never blocks a scan; advisories surface through the standard issue channel (CLI, UI, REST). When you ship a contribution, the loader compiles your inline schema, the runtime catalog publishes it, and `core/unknown-field` automatically validates user writes against your declaration.
889
+ The analyzer never blocks a scan; advisories surface through the standard issue channel (CLI, UI, REST). When you ship a contribution, the loader compiles your inline schema, the runtime catalog publishes it, and `core/annotation-field-unknown` automatically validates user writes against your declaration.
881
890
 
882
891
  ### Runtime catalog accessor
883
892
 
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "https://skill-map.dev/spec/v0/bump-report.schema.json",
4
4
  "title": "BumpReport",
5
- "description": "Report shape produced by the built-in deterministic `bump` Action (Step 9.6.3, Decision #125). Extends `report-base-deterministic.schema.json` (the deterministic counterpart to `report-base.schema.json`, which carries the LLM-only `confidence` + `safety` fields). The bump Action returns one of three concrete shapes, distinguished by `ok` / `noop` / `reason`: success-with-write (`{ ok: true, version }`), silent-no-op under `force` (`{ ok: true, noop: true }`), or refusal (`{ ok: false, reason: 'fresh' }`).",
5
+ "description": "Report shape produced by the built-in deterministic `node-bump` Action (Step 9.6.3, Decision #125). Extends `report-base-deterministic.schema.json` (the deterministic counterpart to `report-base.schema.json`, which carries the LLM-only `confidence` + `safety` fields). The `node-bump` Action returns one of three concrete shapes, distinguished by `ok` / `noop` / `reason`: success-with-write (`{ ok: true, version }`), silent-no-op under `force` (`{ ok: true, noop: true }`), or refusal (`{ ok: false, reason: 'fresh' }`).",
6
6
  "allOf": [{ "$ref": "report-base-deterministic.schema.json" }],
7
7
  "type": "object",
8
8
  "additionalProperties": true,
@@ -19,7 +19,7 @@
19
19
  "type": "object",
20
20
  "required": ["schema"],
21
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.",
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/annotation-field-unknown` Analyzer emits a warning on truly unrecognized keys (typo guard). See `plugin-author-guide.md` §Annotation contribution.",
23
23
  "properties": {
24
24
  "schema": {
25
25
  "type": "object",
@@ -65,7 +65,7 @@
65
65
  },
66
66
  "occurrences": {
67
67
  "type": "array",
68
- "description": "Every syntactic site in the source body that contributed to this edge. One entry per detection. Accumulated by the post-walk dedup when two extractors converge on the same `(source, target, kind, normalizedTrigger)` key. Empty / absent for synthetic links (frontmatter / sidecar-derived). The `core/redundant-target-reference` analyzer walks this array to flag multi-form references to the same target.",
68
+ "description": "Every syntactic site in the source body that contributed to this edge. One entry per detection. Accumulated by the post-walk dedup when two extractors converge on the same `(source, target, kind, normalizedTrigger)` key. Empty / absent for synthetic links (frontmatter / sidecar-derived). The `core/reference-redundant` analyzer walks this array to flag multi-form references to the same target.",
69
69
  "items": {
70
70
  "type": "object",
71
71
  "required": ["extractor", "originalTrigger"],
@@ -70,7 +70,7 @@
70
70
  "referencePaths": {
71
71
  "type": "array",
72
72
  "items": { "type": "string" },
73
- "description": "**Privacy-sensitive, project-local only** (per `core/config/helper:PROJECT_LOCAL_ONLY_KEYS`) when entries point outside the project, opens read-only disk access for link validation only. Default `[]`. Directories walked in parallel by the scan to collect existing absolute paths into a side set; the kernel passes the set to analyzers via `IAnalyzerContext.referenceablePaths` so `core/broken-ref` can resolve a link against the filesystem when the in-graph lookup misses. Files under these paths are NOT parsed and NOT indexed as nodes, the only effect is suppressing `broken-ref` warnings for targets that exist on disk outside the scan. Reference impl gates writes that introduce out-of-project paths behind `--yes` (CLI) and a confirm dialog (UI). **Stripped with a warning when found in the committed `project` layer**, paths are inherently per-machine and must not travel via the shared repo."
73
+ "description": "**Privacy-sensitive, project-local only** (per `core/config/helper:PROJECT_LOCAL_ONLY_KEYS`) when entries point outside the project, opens read-only disk access for link validation only. Default `[]`. Directories walked in parallel by the scan to collect existing absolute paths into a side set; the kernel passes the set to analyzers via `IAnalyzerContext.referenceablePaths` so `core/reference-broken` can resolve a link against the filesystem when the in-graph lookup misses. Files under these paths are NOT parsed and NOT indexed as nodes, the only effect is suppressing `broken-ref` warnings for targets that exist on disk outside the scan. Reference impl gates writes that introduce out-of-project paths behind `--yes` (CLI) and a confirm dialog (UI). **Stripped with a warning when found in the committed `project` layer**, paths are inherently per-machine and must not travel via the shared repo."
74
74
  }
75
75
  }
76
76
  },