@skill-map/spec 0.53.0 → 0.54.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/architecture.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Normative description of skill-map's internal boundaries: the **kernel**, the **ports** it exposes, the **adapters** that drive and serve it, and the six **extension kinds** that live outside the kernel.
4
4
 
5
- Any conforming implementation, reference or third-party, MUST respect these boundaries. The conformance suite under [`conformance/`](./conformance/README.md) enforces the kernel-agnostic invariants; per-Provider suites (e.g. `src/extensions/providers/claude/conformance/` for the reference impl's Claude Provider) enforce the kind-catalog cases. Both are driven via `sm conformance run`.
5
+ Any conforming implementation, reference or third-party, MUST respect these boundaries. The conformance suite under [`conformance/`](./conformance/README.md) enforces the kernel-agnostic invariants; per-Provider suites (e.g. `src/extensions/providers/claude/conformance/`) enforce the kind-catalog cases. Both are driven via `sm conformance run`.
6
6
 
7
7
  ---
8
8
 
@@ -58,67 +58,67 @@ flowchart TB
58
58
  class EXT,ANA,ACT,HOOK,FMT,PROV plugin
59
59
  ```
60
60
 
61
- The UI is **not** a driving adapter; it is an HTTP/WS client of the Server. Exactly one Provider is active per project (see §Active Provider Lens), and config layering is always project-scoped (see §Config layering).
61
+ The UI is **not** a driving adapter; it is an HTTP/WS client of the Server. Exactly one Provider is active per project (see §Active Provider Lens); config layering is always project-scoped (see §Config layering).
62
62
 
63
- - **Driving adapters** call into the kernel. The spec defines three: `CLI`, `Server`, `Skill`. A fourth driving adapter MAY be built by third parties (IDE extension, VSCode command palette, TUI) without spec changes.
64
- - **Driven adapters** implement ports the kernel declares. An implementation MUST ship adapters for every port, no port may be left unimplemented at runtime.
65
- - **Kernel** is domain-pure. It never imports a filesystem API, a database driver, or a subprocess spawner directly. All IO crosses a port.
63
+ - **Driving adapters** call into the kernel. The spec defines three: `CLI`, `Server`, `Skill`. A fourth MAY be built by third parties (IDE extension, VSCode command palette, TUI) without spec changes.
64
+ - **Driven adapters** implement ports the kernel declares. An implementation MUST ship adapters for every port; no port may be left unimplemented at runtime.
65
+ - **Kernel** is domain-pure: never imports a filesystem API, database driver, or subprocess spawner directly. All IO crosses a port.
66
66
 
67
67
  ---
68
68
 
69
69
  ## Active Provider Lens
70
70
 
71
- A skill-map project sees its filesystem through exactly one **active provider lens** at any time. The lens is the provider whose extractors, classifiers, and resolution rules apply to the whole project during a scan. All other enabled providers stay registered but their provider-specific extractors are skipped.
71
+ A skill-map project sees its filesystem through exactly one **active provider lens** at any time: the provider whose extractors, classifiers, and resolution rules apply to the whole project during a scan. All other enabled providers stay registered but their provider-specific extractors are skipped.
72
72
 
73
- The lens is project-scope state. It lives in `.skill-map/settings.json` as the `activeProvider` key (see [`project-config.schema.json`](./schemas/project-config.schema.json#/properties/activeProvider)). When absent, the kernel auto-detects on first scan from filesystem markers and persists the result; if the heuristic is ambiguous or yields no result, the CLI and UI prompt the user to pick one of the enabled providers. **The marker set is provider-owned**: each Provider declares its own detection markers in its manifest `detect.markers` block (see [`provider.schema.json`](./schemas/extensions/provider.schema.json#/properties/detect)), e.g. `claude` → `.claude/`, `openai` → `.codex/` or root `AGENTS.md`, `agent-skills` → `.agents/`. There is no central hardcoded detection table; the detectable set derives from the registered Providers, so adding a Provider with a marker makes it auto-detectable without touching the resolver. When several markers match, the resolver returns the full candidate list in Provider iteration order and the first match is the default suggestion. A Provider with no `detect` block is never auto-suggested but can still be selected manually, Google's Antigravity CLI (which replaced the retired Gemini CLI on 2026-05-19) adopted the open-standard `.agents/` rather than a vendor-specific marker, so a Google project auto-detects as the universal `agent-skills` lens and the `antigravity` lens is set manually via `sm config set activeProvider antigravity`.
73
+ The lens is project-scope state, living in `.skill-map/settings.json` as the `activeProvider` key (see [`project-config.schema.json`](./schemas/project-config.schema.json#/properties/activeProvider)). When absent, the kernel auto-detects on first scan from filesystem markers and persists the result; if the heuristic is ambiguous or empty, the CLI and UI prompt the user to pick one enabled provider. **The marker set is provider-owned**: each Provider declares its detection markers in its manifest `detect.markers` block (see [`provider.schema.json`](./schemas/extensions/provider.schema.json#/properties/detect)), e.g. `claude` → `.claude/`, `openai` → `.codex/` or root `AGENTS.md`, `agent-skills` → `.agents/`. No central hardcoded detection table; the detectable set derives from registered Providers, so adding a Provider with a marker makes it auto-detectable without touching the resolver. When several markers match, the resolver returns the full candidate list in Provider iteration order, first match the default suggestion. A Provider with no `detect` block is never auto-suggested but can be selected manually: Google's Antigravity CLI (which replaced the retired Gemini CLI on 2026-05-19) adopted the open-standard `.agents/` rather than a vendor marker, so a Google project auto-detects as the universal `agent-skills` lens and the `antigravity` lens is set manually via `sm config set activeProvider antigravity`.
74
74
 
75
75
  ### Consequence: one graph per project at a time
76
76
 
77
- The persisted scan graph (`scan_*` zone) reflects the project as the active lens sees it. No cross-provider merging happens at storage time. A repo with both `.claude/` and `.codex/` does NOT show "everyone's nodes at once"; it shows the active lens's view.
77
+ The persisted scan graph (`scan_*` zone) reflects the project as the active lens sees it; no cross-provider merging at storage time. A repo with both `.claude/` and `.codex/` does NOT show "everyone's nodes at once"; it shows the active lens's view.
78
78
 
79
79
  ### Consequence: lens change is destructive of the scan zone
80
80
 
81
- Switching the active provider drops the `scan_*` zone atomically (nodes, links, issues, scan-result meta) and triggers a fresh scan under the new lens. The `state_*` zone (jobs, executions, summaries, enrichments, plugin KV, favorites) and the `config_*` zone survive untouched. Annotations (`.sm` sidecars on disk) are filesystem state and are also unaffected; the next scan re-derives the in-DB overlay from them.
81
+ Switching the active provider drops the `scan_*` zone atomically (nodes, links, issues, scan-result meta) and triggers a fresh scan under the new lens. The `state_*` zone (jobs, executions, summaries, enrichments, plugin KV, favorites) and the `config_*` zone survive untouched. Annotations (`.sm` sidecars on disk) are filesystem state, also unaffected; the next scan re-derives the in-DB overlay from them.
82
82
 
83
- This is a deliberate trade-off. Keeping two scan graphs simultaneously persisted (one per lens) would re-introduce the cross-provider coordination complexity the lens model exists to avoid. The drop+rescan UX is honest: changing lens means changing the world the graph represents, and the graph regenerates from the source of truth (the filesystem) under the new rules.
83
+ A deliberate trade-off: keeping two scan graphs persisted (one per lens) would re-introduce the cross-provider coordination complexity the lens model exists to avoid. The drop+rescan UX is honest: changing lens means changing the world the graph represents, and the graph regenerates from the source of truth (the filesystem) under the new rules.
84
84
 
85
85
  ### Cross-provider read at the provider level
86
86
 
87
- A provider plugin MAY declare it reads source files belonging to ANOTHER provider's territory. The canonical example: Cursor's runtime consumes `.claude/skills/` and `.codex/skills/` natively; a Cursor provider in skill-map can therefore claim those paths from its own classifier so that under the Cursor lens, those files appear as Cursor-managed nodes with Cursor's interpretation rules. This is provider-internal logic, not a kernel feature; the lens model neither encourages nor prevents it.
87
+ A provider plugin MAY declare it reads source files belonging to ANOTHER provider's territory. Canonical example: Cursor's runtime consumes `.claude/skills/` and `.codex/skills/` natively, so a Cursor provider can claim those paths from its own classifier; under the Cursor lens they appear as Cursor-managed nodes with Cursor's interpretation rules. This is provider-internal logic, not a kernel feature; the lens model neither encourages nor prevents it.
88
88
 
89
89
  ### Universal extractors and per-provider extractors
90
90
 
91
- The lens does NOT gate the universal extractors that ship under `core/` (markdown links, code-region file paths, external URLs, sidecar annotations). Those run regardless of the active provider because their semantics are provider-agnostic. Provider-specific extractors (Claude's `@`-directive parser, Cursor's picker-derived references, the future Codex AGENTS.md walker, future Antigravity-specific parsers) declare `precondition: { provider: '<id>' }` on their manifest; the orchestrator invokes them on every node visited during the scan as long as the **active lens** is in the declared provider list, regardless of which provider's `classify()` claimed the node.
91
+ The lens does NOT gate the universal extractors under `core/` (markdown links, code-region file paths, external URLs, sidecar annotations); their semantics are provider-agnostic, so they run regardless of the active provider. Provider-specific extractors (Claude's `@`-directive parser, Cursor's picker-derived references, the future Codex AGENTS.md walker, future Antigravity parsers) declare `precondition: { provider: '<id>' }` on their manifest; the orchestrator invokes them on every node visited during the scan as long as the **active lens** is in the declared provider list, regardless of which provider's `classify()` claimed the node.
92
92
 
93
- The gate is the active lens, not the node's provider. A `@handle` token in `CLAUDE.md` or `notes/todo.md` (files the `claude` provider disclaims to `core/markdown`) still gets parsed by `claude/at-directive` under the `claude` lens, because the runtime grammar is what the lens represents and the runtime reads markdown across the whole project, not only the files it owns. The earlier double-check ("node's provider matches AND the lens") silently dropped that surface; dropping the node side restores it. Cross-lens isolation is preserved by the lens half alone: under `openai`, claude extractors are silent on every node (including `.claude/*`), because the lens authorisation is missing. When `activeProvider` is `null` (no setting, no filesystem marker), provider-gated extractors are skipped uniformly.
93
+ The gate is the active lens, not the node's provider. A `@handle` token in `CLAUDE.md` or `notes/todo.md` (files the `claude` provider disclaims to `core/markdown`) still gets parsed by `claude/at-directive` under the `claude` lens, because the lens represents the runtime grammar and the runtime reads markdown across the whole project, not only files it owns. The earlier double-check ("node's provider matches AND the lens") silently dropped that surface; dropping the node side restores it. Cross-lens isolation holds via the lens half alone: under `openai`, claude extractors are silent on every node (including `.claude/*`) because lens authorisation is missing. When `activeProvider` is `null`, provider-gated extractors are skipped uniformly.
94
94
 
95
95
  ### Active-lens scope for providers (classification gate)
96
96
 
97
- The active lens also gates **classification**. Each Provider declares `gatedByActiveLens` on its manifest (`extensions/provider.schema.json#/properties/gatedByActiveLens`, mirrored at `IProvider.gatedByActiveLens`). Vendor providers (`claude`, `openai`, `antigravity`) set this to `true`; their `classify()` only runs (and the walker only iterates their territory) when `provider.id === activeProvider`. Universal providers (the open-standard `agent-skills`, the markdown fallback `core/markdown`, any future format-based fallback) leave the flag `false` (the default) and run on every scan.
97
+ The active lens also gates **classification**. Each Provider declares `gatedByActiveLens` on its manifest (`extensions/provider.schema.json#/properties/gatedByActiveLens`, mirrored at `IProvider.gatedByActiveLens`). Vendor providers (`claude`, `openai`, `antigravity`) set it `true`; their `classify()` only runs (and the walker only iterates their territory) when `provider.id === activeProvider`. Universal providers (the open-standard `agent-skills`, the markdown fallback `core/markdown`, any future format-based fallback) leave the flag `false` (default) and run on every scan.
98
98
 
99
- Filtering happens in `walkAndExtract` (kernel, `src/kernel/orchestrator/walk.ts`) at the provider-iteration level: a gated-off Provider does NOT walk its territory at all, the cheap path. The predicate is: include the Provider when `!gatedByActiveLens || activeProvider === null || provider.id === activeProvider`. The `null` branch is intentional: an unlensed project (no marker, no setting) keeps the walker permissive so every Provider participates, mirroring the matching extractor-side fallback.
99
+ Filtering happens in `walkAndExtract` (kernel, `src/kernel/orchestrator/walk.ts`) at the provider-iteration level: a gated-off Provider does NOT walk its territory at all (the cheap path). The predicate: include the Provider when `!gatedByActiveLens || activeProvider === null || provider.id === activeProvider`. The `null` branch is intentional: an unlensed project keeps the walker permissive so every Provider participates, mirroring the extractor-side fallback.
100
100
 
101
- Consequence: under `activeProvider = 'claude'`, a `.codex/agents/foo.toml` file is not classified by the `openai` Provider (gated off); whether the file becomes a node depends on whether a universal Provider claims its extension. Today no universal claims `.toml`, so the file is silently absent from the graph, which matches the runtime reality (Claude Code never consumes `.codex/`). The same path under `activeProvider = 'openai'` becomes `openai/agent`. A `core/markdown` fallback continues to claim every unclaimed `.md` regardless of lens, so a `.claude/agents/foo.md` under `openai` lens reverts to `markdown` (no claude territory under that lens).
101
+ Consequence: under `activeProvider = 'claude'`, a `.codex/agents/foo.toml` is not classified by the `openai` Provider (gated off); whether it becomes a node depends on whether a universal Provider claims its extension. Today no universal claims `.toml`, so the file is silently absent, matching runtime reality (Claude Code never consumes `.codex/`). The same path under `activeProvider = 'openai'` becomes `openai/agent`. The `core/markdown` fallback claims every unclaimed `.md` regardless of lens, so a `.claude/agents/foo.md` under `openai` lens reverts to `markdown` (no claude territory under that lens).
102
102
 
103
- This gate affects **classification only**. Extractors keep filtering through their own `precondition.provider` allowlist (described in the previous section); a gated-off vendor Provider does not contribute classified nodes, but its bundled extractors still skip uniformly under the wrong lens via the extractor-side rule. The two gates are independent and complementary.
103
+ This gate affects **classification only**. Extractors keep filtering through their own `precondition.provider` allowlist (previous section); a gated-off vendor Provider contributes no classified nodes, but its bundled extractors still skip uniformly under the wrong lens via the extractor-side rule. The two gates are independent and complementary.
104
104
 
105
105
  ### Active-lens drift detection
106
106
 
107
- The lens is sticky once set, the operator chose `activeProvider` deliberately, the runtime keeps using it until the operator explicitly runs `sm config set activeProvider <id>`. But projects grow: a repo that started under `claude` may later add `.codex/`, or a `.cursor/` directory disappears in a cleanup. Without a hint, the operator would silently keep scanning under the original lens long after the on-disk reality moved.
107
+ The lens is sticky once set: the operator chose `activeProvider` deliberately, and the runtime keeps it until the operator runs `sm config set activeProvider <id>`. But projects grow: a repo started under `claude` may later add `.codex/`, or a `.cursor/` directory disappears in cleanup. Without a hint, the operator would keep scanning under the original lens long after on-disk reality moved.
108
108
 
109
- To surface this drift without being noisy, the runtime persists a snapshot of provider markers alongside `activeProvider`:
109
+ To surface this drift without noise, the runtime persists a snapshot of provider markers alongside `activeProvider`:
110
110
 
111
- - **`activeProviderMarkers`** (`project-config.schema.json#/properties/activeProviderMarkers`): the set of provider ids whose filesystem markers were present on disk at the moment `activeProvider` was set. Written by the runtime in three places: (1) auto-detect on first scan when exactly one marker is found, (2) interactive prompt when multiple markers are found and the operator picks one, (3) `sm config set activeProvider <id>` (a manual switch refreshes the snapshot to match current reality).
111
+ - **`activeProviderMarkers`** (`project-config.schema.json#/properties/activeProviderMarkers`): the set of provider ids whose filesystem markers were present when `activeProvider` was set. Written by the runtime in three places: (1) auto-detect on first scan when exactly one marker is found, (2) interactive prompt when multiple markers are found and the operator picks one, (3) `sm config set activeProvider <id>` (a manual switch refreshes the snapshot).
112
112
 
113
113
  At every subsequent scan entry, the bootstrap re-detects markers, diffs against the snapshot, and emits ONE soft warning when the diff is non-empty:
114
114
 
115
- - **New markers in current but not in snapshot** → "New: <added>" (e.g. the operator added `.codex/` after the choice was made).
115
+ - **New markers in current but not in snapshot** → "New: <added>" (e.g. operator added `.codex/` after the choice).
116
116
  - **Markers in snapshot but no longer on disk** → "Removed: <removed>".
117
117
  - **Both** → both lines, still ONE warn per scan.
118
118
 
119
- The warn is informational and never blocks the scan; the run continues with the cached lens. The snapshot is NOT refreshed automatically when drift fires, the operator chooses whether to switch the lens (`sm config set activeProvider <id>` refreshes the snapshot and atomically drops `scan_*`) or accept the drift (re-running the auto-detect by deleting the `activeProvider` key resets the snapshot).
119
+ The warn is informational and never blocks the scan; the run continues with the cached lens. The snapshot is NOT refreshed automatically on drift: the operator chooses whether to switch the lens (`sm config set activeProvider <id>` refreshes the snapshot and atomically drops `scan_*`) or accept the drift (deleting the `activeProvider` key re-runs auto-detect and resets the snapshot).
120
120
 
121
- Legacy projects (an existing `activeProvider` without a snapshot) lazily backfill: the first scan after the project upgrades writes the current detected set as the snapshot and stays silent (there is nothing to compare against the first time), so the warn only fires when markers actually drift relative to a known-good snapshot. The bookkeeping is internal-state, not normally hand-edited.
121
+ Legacy projects (an existing `activeProvider` without a snapshot) lazily backfill: the first scan after upgrade writes the current detected set as the snapshot and stays silent (nothing to compare against), so the warn only fires when markers drift relative to a known-good snapshot. The bookkeeping is internal-state, not normally hand-edited.
122
122
 
123
123
  ---
124
124
 
@@ -128,7 +128,7 @@ An implementation MUST expose these five ports. Each is an interface (TypeScript
128
128
 
129
129
  ### `StoragePort`
130
130
 
131
- Persistence for all kernel tables in all three zones (`scan_*`, `state_*`, `config_*`). Exposes typed repositories, not raw SQL. Implementations MAY back this with SQLite, Postgres, in-memory, or anything else, as long as:
131
+ Persistence for all kernel tables in all three zones (`scan_*`, `state_*`, `config_*`). Exposes typed repositories, not raw SQL. Implementations MAY back this with SQLite, Postgres, in-memory, or anything else, provided:
132
132
 
133
133
  - Transactional semantics for atomic claim (see [`job-lifecycle.md`](./job-lifecycle.md)).
134
134
  - Migration application with `PRAGMA user_version`-equivalent tracking.
@@ -138,11 +138,11 @@ The reference impl backs this with `node:sqlite` + Kysely + `CamelCasePlugin`. S
138
138
 
139
139
  ### `FilesystemPort`
140
140
 
141
- Walks roots, reads node files, reports mtime/size. Abstracts away platform-specific path handling and test fixtures.
141
+ Walks roots, reads node files, reports mtime/size. Abstracts platform-specific path handling and test fixtures.
142
142
 
143
143
  Operations: `walk(roots, ignore)`, `readNode(path)`, `stat(path)`, `writeJobFile(path, content)`, `ensureDir(path)`.
144
144
 
145
- The reference impl uses real `node:fs` in production and an in-memory fixture in tests.
145
+ Reference impl: real `node:fs` in production, an in-memory fixture in tests.
146
146
 
147
147
  ### `PluginLoaderPort`
148
148
 
@@ -152,12 +152,12 @@ Operations: `discover(scopes)`, `load(pluginPath)`, `validateManifest(json)`.
152
152
 
153
153
  The loader enforces two id-uniqueness analyzers during discovery (see [`plugin-author-guide.md` §Plugin id uniqueness](./plugin-author-guide.md#plugin-id-uniqueness) for the author-facing summary):
154
154
 
155
- 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.
156
- 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.
155
+ 1. **Directory name == manifest id.** A plugin lives at `<root>/<id>/plugin.json`. A mismatch surfaces as status `invalid-manifest`, eliminating same-root collisions by construction.
156
+ 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.
157
157
 
158
- 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` / `backtick-path` / `external-url-counter` / `stability`) and vendor-specific plugins 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.
158
+ The loader also **qualifies every extension** with its owning plugin id before registering it, storing extensions under the qualified id `<plugin-id>/<extension-id>` (e.g. `core/slash-command`, `core/reference-broken`, `my-plugin/my-extractor`). Authors declare the short `id` in each extension manifest; the loader composes the qualified form from `manifest.id` at load time. Built-in extensions 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` / `backtick-path` / `external-url-counter` / `stability`) and vendor plugins such as `claude/` for platform-bound Provider integrations. A `pluginId` field on an extension that disagrees with `plugin.json`'s `id` yields `invalid-manifest` with a directed reason.
159
159
 
160
- Every extension (built-in or drop-in) is independently toggle-able by its qualified id `<plugin>/<ext-id>`. The plugin row is a presentational grouping; the granular toggle target is the extension, while toggling a bare plugin id is the **bundle** (aggregate) macro that fans across every extension. The loader's pre-import `resolveEnabled(pluginId)` short-circuit only fires when EVERY extension of the plugin is disabled (the plugin "starts as disabled"); partial enables let the imports proceed and the runtime composer (`composeScanExtensions` / `composeFormatters` in `src/core/runtime/plugin-runtime/composer.ts`) drops the per-extension disabled rows before they reach the orchestrator. The `core` plugin exercises the per-extension axis explicitly (every kernel built-in is removable, satisfying §Boot invariant: "no extension is privileged"); vendor Provider plugins (`claude`, `antigravity`, `openai`, `agent-skills`) currently have most operators leaving every extension enabled, but the same per-extension toggle surface applies. See [`plugin-author-guide.md` §Toggle model](./plugin-author-guide.md#toggle-model) for the author-facing summary.
160
+ Every extension (built-in or drop-in) is independently toggle-able by its qualified id `<plugin>/<ext-id>`. The plugin row is a presentational grouping; the granular toggle target is the extension, while toggling a bare plugin id is the **bundle** (aggregate) macro fanning across every extension. The loader's pre-import `resolveEnabled(pluginId)` short-circuit only fires when EVERY extension of the plugin is disabled (the plugin "starts as disabled"); partial enables let imports proceed and the runtime composer (`composeScanExtensions` / `composeFormatters` in `src/core/runtime/plugin-runtime/composer.ts`) drops the per-extension disabled rows before they reach the orchestrator. The `core` plugin exercises the per-extension axis explicitly (every kernel built-in is removable, satisfying §Boot invariant); most operators leave every extension of the vendor Provider plugins (`claude`, `antigravity`, `openai`, `agent-skills`) enabled, but the same per-extension toggle surface applies. See [`plugin-author-guide.md` §Toggle model](./plugin-author-guide.md#toggle-model).
161
161
 
162
162
  ### `RunnerPort`
163
163
 
@@ -165,7 +165,7 @@ Executes an action against rendered job content. Returns the produced report (or
165
165
 
166
166
  Operations: `run(jobContent, options)` → `{ report, tokensIn, tokensOut, durationMs, exitCode } | Error`.
167
167
 
168
- `jobContent` is a string: the kernel reads `state_job_contents` for the job and passes the content directly. There is no on-disk job file as part of the contract, runners that need an actual file (the `claude -p` subprocess, for example) materialize a temporary file inside `run()` and remove it after spawn. The temp file is operational, not normative.
168
+ `jobContent` is a string: the kernel reads `state_job_contents` for the job and passes the content directly. No on-disk job file is part of the contract; runners needing one (e.g. `claude -p`) materialize a temp file inside `run()` and delete it after spawn. The temp file is operational, not normative.
169
169
 
170
170
  `report` is the parsed JSON the runner produced; the kernel ingests it into `state_executions.report_json`. Path-based reporting is not part of the port contract.
171
171
 
@@ -173,7 +173,7 @@ Two reference implementations:
173
173
  - `ClaudeCliRunner`, subprocess `claude -p` with the content piped into a temp file or stdin.
174
174
  - `MockRunner`, deterministic fake for tests.
175
175
 
176
- The **Skill agent** does NOT implement this port: it is a peer driving adapter (alongside CLI and Server) that runs inside an LLM session and consumes `sm job claim` + `sm record` as a kernel client. The name "Skill runner" is descriptive, not structural, only the `ClaudeCliRunner` (and its test fake) implement `RunnerPort`. See [`job-lifecycle.md`](./job-lifecycle.md).
176
+ The **Skill agent** does NOT implement this port: it is a peer driving adapter (alongside CLI and Server) running inside an LLM session, consuming `sm job claim` + `sm record` as a kernel client. The name "Skill runner" is descriptive, not structural; only `ClaudeCliRunner` (and its test fake) implement `RunnerPort`. See [`job-lifecycle.md`](./job-lifecycle.md).
177
177
 
178
178
  ### `ProgressEmitterPort`
179
179
 
@@ -218,7 +218,7 @@ Every analytical extension in skill-map is one of two **modes**:
218
218
  - **`deterministic`**, pure code. Same input → same output, every run.
219
219
  - **`probabilistic`**, calls an LLM through the kernel's `RunnerPort`. Output may vary across runs; cost and latency are non-trivial.
220
220
 
221
- Mode is a property of the extension as a whole, not of an individual call. **An extension is one mode or the other; it cannot switch at runtime.** If a plugin author needs both flavors of the same idea (regex-based AND LLM-based "find suspicious imports"), they ship two extensions with distinct ids.
221
+ Mode is a property of the extension as a whole, not an individual call. **An extension is one mode or the other; it cannot switch at runtime.** If a plugin author needs both flavors of the same idea (regex-based AND LLM-based "find suspicious imports"), they ship two extensions with distinct ids.
222
222
 
223
223
  ### Which kinds support which modes
224
224
 
@@ -231,9 +231,9 @@ Mode is a property of the extension as a whole, not of an individual call. **An
231
231
  | **Provider** | deterministic-only | implicit; `mode` field MUST NOT appear |
232
232
  | **Formatter** | deterministic-only | implicit; `mode` field MUST NOT appear |
233
233
 
234
- Provider, Extractor, and Formatter are locked to deterministic because they sit on the **deterministic scan path**. A Provider resolves `path → kind` during boot; probabilistic classification would make the boot phase slow, costly, and non-reproducible. An Extractor consumes a parsed node body inside `sm scan`'s synchronous loop; LLM-driven enrichment of a node is an Action concern (queued as a job and observed via the enrichment layer or sidecar writes), not an Extractor concern, the distinction matters because `sm scan` MUST be fast, free, and reproducible. A Formatter must produce diffable output (`sm scan` snapshots round-trip in CI). Probabilistic narrators of the graph are a valid product but they live in jobs and emit Findings or write to the enrichment layer through Actions, not through Extractors or Formatters.
234
+ Provider, Extractor, and Formatter are locked to deterministic because they sit on the **deterministic scan path**. A Provider resolves `path → kind` during boot; probabilistic classification would make boot slow, costly, and non-reproducible. An Extractor consumes a parsed node body inside `sm scan`'s synchronous loop; LLM-driven enrichment is an Action concern (queued as a job, observed via the enrichment layer or sidecar writes), not an Extractor concern, because `sm scan` MUST be fast, free, and reproducible. A Formatter must produce diffable output (`sm scan` snapshots round-trip in CI). Probabilistic graph narrators are a valid product but live in jobs and emit Findings or write to the enrichment layer through Actions, not through Extractors or Formatters.
235
235
 
236
- > **Naming note, `Provider` vs hexagonal `adapter`.** A `Provider` is an **extension** authored by plugins (it recognises a platform and declares its kind catalog). The hexagonal-architecture term `adapter` refers to **port implementations** internal to the kernel package, `RunnerPort.adapter`, `StoragePort.adapter`, `FilesystemPort.adapter`, `PluginLoaderPort.adapter`, and lives under `kernel/adapters/`. The two concepts share an architectural lineage (both bridge two worlds) but live in deliberately disjoint namespaces so plugin authors and impl maintainers never confuse them.
236
+ > **Naming note, `Provider` vs hexagonal `adapter`.** A `Provider` is an **extension** authored by plugins (recognises a platform, declares its kind catalog). The hexagonal term `adapter` refers to **port implementations** internal to the kernel package (`RunnerPort.adapter`, `StoragePort.adapter`, `FilesystemPort.adapter`, `PluginLoaderPort.adapter`, under `kernel/adapters/`). Both bridge two worlds but live in deliberately disjoint namespaces so plugin authors and impl maintainers never confuse them.
237
237
 
238
238
  ### When each mode runs
239
239
 
@@ -246,39 +246,39 @@ This separation is normative: a probabilistic extension cannot register a hook t
246
246
 
247
247
  The kernel exposes the LLM through the `RunnerPort` (see §Ports above). Reference impl: `ClaudeCliRunner`. Tests: `MockRunner`. Other adapters (OpenAI, local Ollama, etc.) implement the same port without spec changes.
248
248
 
249
- A probabilistic Action, Analyzer, or Hook receives the runner in its invocation context alongside `ctx.store` (Extractors are deterministic-only and never see the runner). The extension never imports a specific LLM SDK, the runner contract is what the spec normalizes; wire format and model selection are adapter concerns.
249
+ A probabilistic Action, Analyzer, or Hook receives the runner in its invocation context alongside `ctx.store` (Extractors are deterministic-only and never see the runner). The extension never imports a specific LLM SDK; the spec normalizes the runner contract, while wire format and model selection are adapter concerns.
250
250
 
251
251
  ---
252
252
 
253
253
  ## Extension kinds
254
254
 
255
- Six kinds, all first-class, all loaded through the same registry. Each kind has a JSON Schema describing its manifest shape under [`schemas/extensions/`](./schemas/extensions/). Implementations MUST validate every extension manifest against the schema for its declared kind at load time; validation failure → the extension is skipped with status `invalid-manifest`.
255
+ Six kinds, all first-class, all loaded through the same registry. Each has a JSON Schema for its manifest shape under [`schemas/extensions/`](./schemas/extensions/). Implementations MUST validate every extension manifest against the schema for its declared kind at load time; validation failure → the extension is skipped with status `invalid-manifest`.
256
256
 
257
257
  | Kind | Role | Input | Output |
258
258
  |---|---|---|---|
259
- | **Provider** | Recognizes a platform. The kind catalog lives on disk under `<plugin>/kinds/<kindName>/{schema.json, kind.json}` (structure-as-truth); the loader projects it onto the runtime descriptor. The Provider's walker hardcodes the paths it scans within the project (e.g. `.claude/`, `.codex/`); it does NOT extend the scan into the user's HOME. `Provider.roots` is enforcement-grade: a Provider with declared roots only sees matching files; a Provider without `roots` acts as the fallback. Deterministic-only. | Filesystem walk results, candidate path. | `{ kind, provider } \| null`. |
260
- | **Extractor** | Extracts signals from a node body. Deterministic-only: runs synchronously inside `sm scan`. Output flows through context callbacks (no return value): `ctx.emitLink(link)` for the kernel's `links` table (validated against the global closed enum of link kinds; per-extractor allowlist was retired with the structure-as-truth refactor), `ctx.enrichNode(partial)` for the kernel's enrichment layer (separate from the author's frontmatter), `ctx.emitContribution(id, payload)` for view contributions, `ctx.store` for the plugin's own KV / dedicated tables. | Parsed node (frontmatter + body) + callbacks. | `void` (output via callbacks). |
259
+ | **Provider** | Recognizes a platform. The kind catalog lives on disk under `<plugin>/kinds/<kindName>/{schema.json, kind.json}` (structure-as-truth); the loader projects it onto the runtime descriptor. The walker hardcodes the paths it scans within the project (e.g. `.claude/`, `.codex/`); it does NOT extend into the user's HOME. `Provider.roots` is enforcement-grade: a Provider with declared roots only sees matching files; one without `roots` is the fallback. Deterministic-only. | Filesystem walk results, candidate path. | `{ kind, provider } \| null`. |
260
+ | **Extractor** | Extracts signals from a node body. Deterministic-only: runs synchronously inside `sm scan`. Output flows through context callbacks (no return value): `ctx.emitLink(link)` for the kernel's `links` table (validated against the global closed enum of link kinds; per-extractor allowlist retired with the structure-as-truth refactor), `ctx.enrichNode(partial)` for the enrichment layer (separate from author frontmatter), `ctx.emitContribution(id, payload)` for view contributions, `ctx.store` for the plugin's own KV / dedicated tables. | Parsed node (frontmatter + body) + callbacks. | `void` (output via callbacks). |
261
261
  | **Analyzer** | Evaluates the graph. Dual-mode: `deterministic` runs in `sm check`, `probabilistic` runs in jobs. The analyzer↔action relationship is declared from the Action side via `precondition.analyzerIds` (Modelo B). | Full graph (nodes + links). | `Issue[]`. |
262
- | **Action** | Operates on one or more nodes. Two independent surfaces: **`invoke(input, ctx)`** is the on-demand executor (deterministic in-process code, or a probabilistic rendered prompt the runner executes); **`project(ctx)`** is an OPTIONAL, deterministic, side-effect-free scan-time method that runs in the contribution phase with read-only graph access (`ctx.nodes` / `ctx.links`) and emits the Action's OWN view contributions via `ctx.emitContribution(...)` (e.g. its `inspector.action.button`). `project()` is always deterministic even when `invoke` is probabilistic. Files-by-convention: every Action carries `<action-dir>/report.schema.json`; probabilistic Actions additionally carry `<action-dir>/prompt.md`. The retired `reportSchemaRef` / `promptTemplateRef` / `expectedTools` / `fanOutPolicy` manifest fields were replaced by these conventions and the simplified `precondition` block. | `project`: full graph. `invoke`: node(s) + optional args. | `project`: `void` (contributions via callback). `invoke`: deterministic report JSON or probabilistic rendered prompt. |
262
+ | **Action** | Operates on one or more nodes. Two independent surfaces: **`invoke(input, ctx)`** is the on-demand executor (deterministic in-process code, or a probabilistic rendered prompt the runner executes); **`project(ctx)`** is an OPTIONAL, deterministic, side-effect-free scan-time method running in the contribution phase with read-only graph access (`ctx.nodes` / `ctx.links`), emitting the Action's OWN view contributions via `ctx.emitContribution(...)` (e.g. its `inspector.action.button`). `project()` is always deterministic even when `invoke` is probabilistic. Files-by-convention: every Action carries `<action-dir>/report.schema.json`; probabilistic Actions also carry `<action-dir>/prompt.md`. The retired `reportSchemaRef` / `promptTemplateRef` / `expectedTools` / `fanOutPolicy` fields were replaced by these conventions and the simplified `precondition` block. | `project`: full graph. `invoke`: node(s) + optional args. | `project`: `void` (contributions via callback). `invoke`: deterministic report JSON or probabilistic rendered prompt. |
263
263
  | **Formatter** | Serializes the graph. Deterministic-only. The `formatId` consumed by `sm graph --format <name>` comes from the formatter's folder name. | Graph + optional filter. | String (ASCII / Mermaid / DOT / JSON / user-defined). |
264
- | **Hook** | Reacts declaratively to one of ten curated lifecycle events, eight pipeline-driven (`scan.started`, `scan.completed`, `extractor.completed`, `analyzer.completed`, `action.completed`, `job.spawning`, `job.completed`, `job.failed`) plus two CLI-process-driven (`boot` before verb routing, `shutdown` after the verb's exit code resolves). **Deterministic-only** since the structure-as-truth refactor: LLM-dependent reactions are modeled as a deterministic Hook that enqueues a probabilistic Action via `ctx.queue('<plugin>/<action>', payload)`. Hooks REACT to events; they cannot block, mutate, or steer the pipeline. | A curated event payload (run-scoped, scan-scoped, job-scoped, or process-scoped) plus an optional declarative `filter` map. | `void` (reactions are side effects). |
264
+ | **Hook** | Reacts declaratively to one of ten curated lifecycle events, eight pipeline-driven (`scan.started`, `scan.completed`, `extractor.completed`, `analyzer.completed`, `action.completed`, `job.spawning`, `job.completed`, `job.failed`) plus two CLI-process-driven (`boot` before verb routing, `shutdown` after the verb's exit code resolves). **Deterministic-only** since the structure-as-truth refactor: LLM-dependent reactions are a deterministic Hook enqueuing a probabilistic Action via `ctx.queue('<plugin>/<action>', payload)`. Hooks REACT to events; they cannot block, mutate, or steer the pipeline. | A curated event payload (run-, scan-, job-, or process-scoped) plus an optional declarative `filter` map. | `void` (reactions are side effects). |
265
265
 
266
266
  ### IO discipline, extensions never write to the filesystem
267
267
 
268
268
  Extensions (Provider / Extractor / Analyzer / Action / Formatter / Hook) are **pure**: they consume kernel-supplied context and emit data through return values or `ctx.*` callbacks. They MUST NOT perform filesystem writes directly, not via `fs.writeFile`, not via shell, not via a third-party library. Implementations MUST NOT expose any port that hands an extension a writable filesystem handle.
269
269
 
270
- 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:
270
+ Materialising any kernel-managed artefact (the SQLite DB at `.skill-map/skill-map.db`, the `.sm` sidecars, 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:
271
271
 
272
272
  - 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.
273
- - 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).
273
+ - Actions return a deterministic report (JSON), a rendered prompt (probabilistic), or, for the subset that legitimately mutate persisted state, an explicit `TActionWrite` discriminated union the kernel interprets. The built-in `core/node-bump`, `core/node-set-tags`, and `core/node-set-stability` return `{ kind: 'sidecar' }`; each declares the capability via `writes: ['sidecar']` on its manifest ([`schemas/extensions/action.schema.json`](./schemas/extensions/action.schema.json)), so consumers gate on the declaration without invoking the action. The kernel routes those writes through `SidecarStore.applyPatch`, the single gated chokepoint for all `.sm` writes (see §Annotation system · Write consent).
274
274
  - Providers, Formatters, Hooks have no write surface at all.
275
- - Analyzers have no FILESYSTEM write surface. They emit `Issue[]` and (via `ctx.emitContribution`) view contributions, both kernel-persisted. The single exception is the `score` phase (see §Analyzer phases): a `score`-phase analyzer MAY adjust `link.confidence` via `ctx.adjustConfidence(link, op)`. That writes a DB-persisted GRAPH value the kernel folds and clamps; it is NOT a filesystem write and does not touch `.sm` sidecars, the project tree, or any path under `.skill-map/` directly. The no-filesystem-write invariant therefore holds unchanged for every kind.
275
+ - Analyzers have no FILESYSTEM write surface. They emit `Issue[]` and (via `ctx.emitContribution`) view contributions, both kernel-persisted. The single exception is the `score` phase (see §Analyzer phases): a `score`-phase analyzer MAY adjust `link.confidence` via `ctx.adjustConfidence(link, op)`. That writes a DB-persisted GRAPH value the kernel folds and clamps; not a filesystem write, it does not touch `.sm` sidecars, the project tree, or any `.skill-map/` path directly. The no-filesystem-write invariant holds unchanged for every kind.
276
276
 
277
- 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 to the filesystem in the first place. Conformance: a third-party extension that imports `node:fs` write APIs (or equivalent in another language) is non-conforming.
277
+ This invariant makes the consent gate at the kernel boundary sufficient: no extension can bypass it, none having the means to write to the filesystem. Conformance: a third-party extension importing `node:fs` write APIs (or equivalent) is non-conforming.
278
278
 
279
279
  ### Analyzer phases
280
280
 
281
- An Analyzer declares an optional `phase` in its manifest (`analyzer.schema.json#/properties/phase`, default `detect`). The orchestrator schedules analyzers by phase, so a filesystem-sorted built-ins registry keeps its alphabetical output while the kernel applies the phase order at run time. The three phases run in this strict order:
281
+ An Analyzer declares an optional `phase` in its manifest (`analyzer.schema.json#/properties/phase`, default `detect`). The orchestrator schedules analyzers by phase, so a filesystem-sorted built-ins registry keeps its alphabetical output while the kernel applies phase order at run time. The three phases run in this strict order:
282
282
 
283
283
  1. **`score`** runs FIRST, before any read-only analyzer. It is the ONE phase permitted to WRITE: it adjusts link confidence through the `ctx.adjustConfidence(link, op)` callback (present ONLY in this phase). `op` is a `TConfidenceOp` discriminated union with four kinds:
284
284
  - `{ kind: 'set', value }`, a hard override.
@@ -286,7 +286,7 @@ An Analyzer declares an optional `phase` in its manifest (`analyzer.schema.json#
286
286
  - `{ kind: 'ceil', value }`, an upper cap (lowers only).
287
287
  - `{ kind: 'floor', value }`, a lower bound (raises only).
288
288
 
289
- The orchestrator buffers every op (attributed to the calling `pluginId` / `extensionId`, like `emitContribution`) and folds all ops for a link into the final `link.confidence` BEFORE the `detect` phase, so the read-only `detect` analyzers and the persisted `scan_links.confidence` see the final value. The kernel seeds a **1.0 baseline on every link** (the per-extractor emit value is discarded; see §Provider · resolution rules); the fold then layers the score-phase ops on top. The fold is **deterministic and order-independent across the four buckets**: starting from that baseline, `set` overrides (last in canonical order wins), `delta` sums, `floor` raises, then `ceil` caps, with a single clamp to `[0,1]` at the end (so opposing deltas round-trip without mid-fold clipping). Ops are sorted canonically by `(pluginId, extensionId)` so the `set` winner and the float sum are reproducible across runs. The kernel dogfoods this phase through TWO built-in score-phase detectors, each co-locating its penalty `delta` with the finding it reports: `core/name-reserved` (reserved → `delta -0.9` → 0.1, alongside its warns) and `core/reference-broken` (broken → `delta -0.5` → 0.5, alongside its errors); disabling a detector removes both its report and its score effect, so the link falls back to the 1.0 baseline (see §Provider · resolution rules). A clean-resolved or untouched link keeps the 1.0 baseline (no built-in op). A third-party scorer composes on top of that baseline via the same callback (it may RAISE confidence with a positive `delta` / `floor`, or lower it). Every applied op is persisted to `scan_link_scores` (see [`db-schema.md`](./db-schema.md#scan_link_scores)) as a per-op attribution audit trail. Adjusting confidence is a DB-persisted GRAPH write, NOT a filesystem write: the no-filesystem-write invariant of §IO discipline holds unchanged.
289
+ The orchestrator buffers every op (attributed to the calling `pluginId` / `extensionId`, like `emitContribution`) and folds all ops for a link into the final `link.confidence` BEFORE the `detect` phase, so the read-only `detect` analyzers and the persisted `scan_links.confidence` see the final value. The kernel seeds a **1.0 baseline on every link** (the per-extractor emit value discarded; see §Provider · resolution rules); the fold layers score-phase ops on top. The fold is **deterministic and order-independent across the four buckets**: from that baseline, `set` overrides (last in canonical order wins), `delta` sums, `floor` raises, then `ceil` caps, with a single clamp to `[0,1]` at the end (opposing deltas round-trip without mid-fold clipping). Ops are sorted canonically by `(pluginId, extensionId)` so the `set` winner and float sum are reproducible. The kernel dogfoods this phase through TWO built-in score-phase detectors, each co-locating its penalty `delta` with the finding it reports: `core/name-reserved` (reserved → `delta -0.9` → 0.1, alongside its warns) and `core/reference-broken` (broken → `delta -0.5` → 0.5, alongside its errors); disabling a detector removes both report and score effect, so the link falls back to the 1.0 baseline. A clean-resolved or untouched link keeps the 1.0 baseline (no built-in op). A third-party scorer composes on top via the same callback (may RAISE confidence with a positive `delta` / `floor`, or lower it). Every applied op is persisted to `scan_link_scores` (see [`db-schema.md`](./db-schema.md#scan_link_scores)) as a per-op attribution audit trail. Adjusting confidence is a DB-persisted GRAPH write, NOT a filesystem write: §IO discipline's invariant holds.
290
290
 
291
291
  2. **`detect`** (default) is the main read-only pass: it walks `ctx.nodes` / `ctx.links` and emits `Issue[]`. Most analyzers live here.
292
292
 
@@ -299,139 +299,139 @@ Probabilistic analyzers (`mode: 'probabilistic'`) never participate in any scan-
299
299
  Every `Provider` declares its kind catalog via the filesystem (structure-as-truth): each kind lives under `<plugin>/kinds/<kindName>/` and ships exactly two files:
300
300
 
301
301
  - **`schema.json`**, the kind's frontmatter JSON Schema. MUST extend [`frontmatter/base.schema.json`](./schemas/frontmatter/base.schema.json) via `allOf` + `$ref` to base's `$id`. The kernel reads it once at boot, registers it with AJV, and validates every node's frontmatter against the entry matching its classified kind.
302
- - **`kind.json`**, the per-kind metadata, today just `{ ui: { label, color, colorDark?, emoji?, icon? } }`. See §Provider · `ui` presentation below. Validated against [`schemas/extensions/provider-kind.schema.json`](./schemas/extensions/provider-kind.schema.json) at load time.
302
+ - **`kind.json`**, the per-kind metadata, today just `{ ui: { label, color, colorDark?, emoji?, icon? } }` (see §Provider · `ui` presentation). Validated against [`schemas/extensions/provider-kind.schema.json`](./schemas/extensions/provider-kind.schema.json) at load time.
303
303
 
304
- The loader's discovery (`discoverProviderKinds`) projects every `kinds/<kindName>/` directory into the runtime descriptor `instance.kinds[<kindName>] = { schema, schemaJson, ui }`. The `IProvider` runtime contract derives the kind set from `Object.keys(kinds)`; authors do not write the map by hand any more.
304
+ The loader's discovery (`discoverProviderKinds`) projects every `kinds/<kindName>/` directory into the runtime descriptor `instance.kinds[<kindName>] = { schema, schemaJson, ui }`. The `IProvider` runtime contract derives the kind set from `Object.keys(kinds)`; authors no longer write the map by hand.
305
305
 
306
- The retired manifest field `defaultRefreshAction` (the qualified action id the UI's `🧠 prob` button dispatched) was removed alongside the button. A replacement UX is TBD; until then, the kernel does not surface a Provider-declared "default refresh" path.
306
+ The retired manifest field `defaultRefreshAction` (the qualified action id the UI's `🧠 prob` button dispatched) was removed with the button. A replacement UX is TBD; until then the kernel surfaces no Provider-declared "default refresh" path.
307
307
 
308
308
  ### Provider · `ui` presentation
309
309
 
310
310
  Each `kinds[*].ui` entry declares how the UI renders nodes of that kind:
311
311
 
312
312
  - **`label`**, short human name (e.g. `'Skill'`, `'Agent'`). Used in palette chips, list view, inspector header.
313
- - **`color`**, base color (any CSS color string) for the kind. The UI derives bg / fg tints per theme via a deterministic helper, so the Provider declares one base color per theme rather than four hex values.
313
+ - **`color`**, base color (any CSS color string) for the kind. The UI derives bg / fg tints per theme via a deterministic helper, so the Provider declares one base color per theme, not four hex values.
314
314
  - **`colorDark?`**, optional dark-theme override. Defaults to `color` when omitted.
315
315
  - **`emoji?`**, optional single-glyph emoji rendered alongside the label.
316
- - **`icon?`**, optional discriminated union: either `{ kind: 'pi'; id: 'pi-…' }` (a PrimeIcons class id) or `{ kind: 'svg'; path: '…' }` (raw SVG path data wrapped by the UI in `viewBox="0 0 24 24"` and tinted with `currentColor`). The discriminator keeps UI dispatch exhaustive without string-sniffing; AJV validates each variant cleanly.
316
+ - **`icon?`**, optional discriminated union: either `{ kind: 'pi'; id: 'pi-…' }` (a PrimeIcons class id) or `{ kind: 'svg'; path: '…' }` (raw SVG path data wrapped by the UI in `viewBox="0 0 24 24"`, tinted with `currentColor`). The discriminator keeps UI dispatch exhaustive without string-sniffing; AJV validates each variant cleanly.
317
317
 
318
- The `ui` block is required (not optional) by design: making it optional would force the UI to invent visuals for missing entries, silently collapsing unknown kinds to a default rendering and hiding manifest gaps. Forcing the Provider to declare presentation up-front means the UI never guesses.
318
+ The `ui` block is required (not optional) by design: making it optional would force the UI to invent visuals for missing entries, silently collapsing unknown kinds to a default rendering and hiding manifest gaps. Declaring presentation up-front means the UI never guesses.
319
319
 
320
- The kernel ships every Provider's per-kind `ui` block to the BFF at boot; the BFF aggregates them into a `kindRegistry` map and embeds it in every payload-bearing REST envelope (see [`cli-contract.md` §Server](./cli-contract.md#server)). The UI consumes `kindRegistry` directly, built-in and user-plugin kinds render identically.
320
+ The kernel ships every Provider's per-kind `ui` block to the BFF at boot; the BFF aggregates them into a `kindRegistry` map embedded in every payload-bearing REST envelope (see [`cli-contract.md` §Server](./cli-contract.md#server)). The UI consumes `kindRegistry` directly; built-in and user-plugin kinds render identically.
321
321
 
322
- Each Provider ALSO declares a top-level `presentation` block (`provider.schema.json#/properties/presentation`: `label`, `color`, optional `colorDark` / `icon` / `emoji` / `hideChip`) describing the Provider's own identity, distinct from its kinds' visuals. (It is named `presentation`, not `ui`, because the shared extension `ui` key is the view-contributions map declared only by extractor / analyzer kinds.) The BFF aggregates these into a sibling `providerRegistry` map (keyed by Provider id) embedded on the same envelopes. The UI consumes `providerRegistry` to render the active-lens dropdown, the topbar lens chip, and the per-node provider chip on cards from the real registered-Provider set, never a hardcoded list. `hideChip: true` (set by the universal `markdown` fallback) suppresses only the per-card chip; the Provider still appears in the lens surfaces. Unlike kind colors (normalised across Providers so every `agent` paints the same), Provider colors are deliberately distinct so the chip tells the user at a glance which platform a node came from.
322
+ Each Provider ALSO declares a top-level `presentation` block (`provider.schema.json#/properties/presentation`: `label`, `color`, optional `colorDark` / `icon` / `emoji` / `hideChip`) describing the Provider's own identity, distinct from its kinds' visuals. (Named `presentation`, not `ui`, because the shared extension `ui` key is the view-contributions map declared only by extractor / analyzer kinds.) The BFF aggregates these into a sibling `providerRegistry` map (keyed by Provider id) on the same envelopes. The UI consumes `providerRegistry` to render the active-lens dropdown, topbar lens chip, and per-node provider chip on cards from the real registered-Provider set, never a hardcoded list. `hideChip: true` (set by the universal `markdown` fallback) suppresses only the per-card chip; the Provider still appears in lens surfaces. Unlike kind colors (normalised across Providers so every `agent` paints the same), Provider colors are deliberately distinct so the chip tells the user which platform a node came from.
323
323
 
324
324
  ### Provider · dispatch order and the universal markdown fallback
325
325
 
326
- `sm scan` iterates Providers in **registration order**, vendor-specific Providers first (built-in: `claude` → `antigravity` → `openai` → `agent-skills`; user-installed plugins follow in load order), then the built-in `core/markdown` Provider LAST. Each Provider's walker enumerates the full project tree for its declared `read.extensions`; for every emitted file, the orchestrator calls `provider.classify(path, frontmatter)`. The kernel maintains a per-scan `Set<path>` of already-classified files so each path is offered to AT MOST one Provider's `classify`: the first Provider whose `classify` returns non-null claims the file, and subsequent Providers see the path as taken and skip.
326
+ `sm scan` iterates Providers in **registration order**, vendor-specific Providers first (built-in: `claude` → `antigravity` → `openai` → `agent-skills`; user-installed plugins follow in load order), then the built-in `core/markdown` Provider LAST. Each Provider's walker enumerates the full project tree for its declared `read.extensions`; for every emitted file the orchestrator calls `provider.classify(path, frontmatter)`. The kernel maintains a per-scan `Set<path>` of already-classified files so each path is offered to AT MOST one Provider's `classify`: the first Provider whose `classify` returns non-null claims the file; subsequent Providers see the path as taken and skip.
327
327
 
328
328
  The dispatch contract has two consequences implementations MUST honour:
329
329
 
330
- 1. **First-claim-wins**. A vendor Provider that classifies a file inside its territory (e.g. claude's `.claude/agents/foo.md` → `agent`) is authoritative; later Providers cannot reclassify it to a different kind. This locks vendor ownership of vendor paths and removes the historical `provider-ambiguous` failure mode for non-overlapping territories.
331
- 2. **`core/markdown` is the universal fallback for unclaimed `.md` files**. The built-in `core/markdown` Provider's `classify` returns `'markdown'` unconditionally (it does NOT inspect the path). Combined with the dedup guarantee above and its terminal position in the iteration order, it picks up exactly the `.md` files no vendor Provider claimed, a `.md` at the project root, under `.claude/hooks/`, `notes/`, `CLAUDE.md`, `GEMINI.md`, or anywhere else outside a known vendor territory. The fallback is **not privileged kernel code**: it ships as a regular built-in Provider under the `core` plugin, so a user who explicitly does not want it can disable it via `sm plugins disable core/markdown` and the scan reverts to "vendor-only", orphan `.md` files become silently invisible, matching pre-spec-0.9.0 behaviour.
330
+ 1. **First-claim-wins**. A vendor Provider that classifies a file inside its territory (e.g. claude's `.claude/agents/foo.md` → `agent`) is authoritative; later Providers cannot reclassify it. This locks vendor ownership of vendor paths and removes the historical `provider-ambiguous` failure mode for non-overlapping territories.
331
+ 2. **`core/markdown` is the universal fallback for unclaimed `.md` files**. Its `classify` returns `'markdown'` unconditionally (it does NOT inspect the path). Combined with the dedup guarantee above and its terminal position, it picks up exactly the `.md` files no vendor Provider claimed: a `.md` at the project root, under `.claude/hooks/`, `notes/`, `CLAUDE.md`, `GEMINI.md`, or anywhere outside a known vendor territory. The fallback is **not privileged kernel code**: it ships as a regular built-in Provider under the `core` plugin, so a user can disable it via `sm plugins disable core/markdown` and the scan reverts to "vendor-only", orphan `.md` files become silently invisible (matching pre-spec-0.9.0 behaviour).
332
332
 
333
- The fallback exists because the format-named generic kind `markdown` is provider-agnostic: no vendor owns the universal markdown format. Keeping the fallback as a Provider (rather than a kernel-level special case) preserves the boot invariant that no extension is privileged, when a future vendor Provider (Codex, Cursor, Roo) lands, it slots into the iteration order before `core/markdown` and the fallback semantics stay invariant.
333
+ The fallback exists because the format-named generic kind `markdown` is provider-agnostic: no vendor owns the universal markdown format. Keeping it as a Provider (not a kernel-level special case) preserves the boot invariant that no extension is privileged; a future vendor Provider (Codex, Cursor, Roo) slots into the iteration order before `core/markdown` and the fallback semantics stay invariant.
334
334
 
335
335
  ### Provider · kind identifiers
336
336
 
337
- Each entry in a Provider's `kinds` catalog MAY declare an optional `identifiers: TIdentifierSource[]` listing, in priority order, how the kernel derives the kind's canonical invocation handle(s) for the post-walk confidence-lift transform. Absent / empty = the kind is not name-resolvable (path-based resolution still applies independently).
337
+ Each entry in a Provider's `kinds` catalog MAY declare an optional `identifiers: TIdentifierSource[]` listing, in priority order, how the kernel derives the kind's canonical invocation handle(s) for the post-walk confidence-lift transform. Absent / empty = not name-resolvable (path-based resolution still applies independently).
338
338
 
339
339
  The closed set of sources:
340
340
 
341
341
  | `TIdentifierSource` | Reads | Typical kinds |
342
342
  |---|---|---|
343
343
  | `'frontmatter.name'` | `node.frontmatter.name` | every invocable kind whose schema declares `name` as required (agents, commands, skills); the canonical source when the author set it. |
344
- | `'filename-basename'` | `basename(path)` with the extension stripped | Anthropic agents and commands, OpenAI Codex sub-agents, references at `<dir>/<name>.<ext>` resolve `@<name>` even when frontmatter is partial. |
345
- | `'dirname'` | `basename(dirname(path))` | Anthropic / agent-skills (open standard, also adopted by Google Antigravity CLI), Anthropic explicitly documents that the directory between `skills/` and `/SKILL.md` is the invocation handle, with `frontmatter.name` as an optional override (https://code.claude.com/docs/en/skills.md). |
344
+ | `'filename-basename'` | `basename(path)` with the extension stripped | Anthropic agents and commands, OpenAI Codex sub-agents; references at `<dir>/<name>.<ext>` resolve `@<name>` even when frontmatter is partial. |
345
+ | `'dirname'` | `basename(dirname(path))` | Anthropic / agent-skills (open standard, also adopted by Google Antigravity CLI); Anthropic documents the directory between `skills/` and `/SKILL.md` as the invocation handle, with `frontmatter.name` an optional override (https://code.claude.com/docs/en/skills.md). |
346
346
 
347
- Sources MAY appear together; the resolver visits each declared source per node, normalises every yielded value with the §Extractor · trigger normalization pipeline, and contributes a presence entry to the cross-kind name index. Multiple sources that produce the same normalised name collapse into one bucket entry (the dual-source `['frontmatter.name', 'filename-basename']` on a `.claude/agents/foo.md` with `name: foo` yields a single `foo` entry, not two).
347
+ Sources MAY appear together; the resolver visits each declared source per node, normalises every yielded value with the §Extractor · trigger normalization pipeline, and contributes a presence entry to the cross-kind name index. Multiple sources producing the same normalised name collapse into one bucket entry (dual-source `['frontmatter.name', 'filename-basename']` on a `.claude/agents/foo.md` with `name: foo` yields a single `foo` entry, not two).
348
348
 
349
349
  Implementations MUST treat an absent `identifiers` field exactly like `[]`: the kind contributes nothing to the name index and is reachable only via the path-match rule of §Provider · resolution rules.
350
350
 
351
351
  ### Provider · resolution rules
352
352
 
353
- Each Provider MAY declare an optional `resolution: Record<linkKind, targetKind[]>` map listing, for each `link.kind` an Extractor in this Provider's plugin emits, the set of target `node.kind` values that count as a valid resolution. Absent = no link.kind resolves under this Provider via the name path (path-match always fires).
353
+ Each Provider MAY declare an optional `resolution: Record<linkKind, targetKind[]>` map listing, for each `link.kind` an Extractor in this Provider's plugin emits, the target `node.kind` values that count as a valid resolution. Absent = no link.kind resolves under this Provider via the name path (path-match always fires).
354
354
 
355
355
  Resolution and confidence are TWO distinct steps with two distinct owners:
356
356
 
357
- - The **post-walk lift transform** (`liftResolvedLinkConfidence`) runs after `dedupeLinks` and before the analyzer pipeline. It seeds the **confidence baseline** (`link.confidence = 1.0` for EVERY link, the per-extractor emit floor is discarded) and RECORDS `link.resolvedTarget` (the node path the link resolves to). It also computes the per-link resolution facts (resolved / reserved-target / genuinely-broken) that the analyzer pass reads via `IAnalyzerContext.reservedNodePaths` and `IAnalyzerContext.brokenLinks`. The lift assigns NO penalty values; it only sets the baseline + the resolved path.
357
+ - The **post-walk lift transform** (`liftResolvedLinkConfidence`) runs after `dedupeLinks` and before the analyzer pipeline. It seeds the **confidence baseline** (`link.confidence = 1.0` for EVERY link, the per-extractor emit floor discarded) and RECORDS `link.resolvedTarget` (the node path the link resolves to). It also computes the per-link resolution facts (resolved / reserved-target / genuinely-broken) the analyzer pass reads via `IAnalyzerContext.reservedNodePaths` and `IAnalyzerContext.brokenLinks`. The lift assigns NO penalty values; it sets only the baseline + resolved path.
358
358
  - The penalty VALUES are applied by two built-in score-phase detectors (`phase: 'score'`, see §Analyzer phases) through the public `ctx.adjustConfidence(link, op)` API, each reading the lift's facts and co-locating its op with the finding it owns: `delta -0.9` (reserved → 0.1) by **`core/name-reserved`**, `delta -0.5` (broken → 0.5) by **`core/reference-broken`**. A clean-resolved or virtual-target link gets no built-in op and keeps the 1.0 baseline. Third-party `score`-phase analyzers compose `set` / `delta` / `ceil` / `floor` ops on top (a positive `delta` / `floor` may RAISE confidence), folded deterministically and clamped to `[0,1]`.
359
359
 
360
- The rules below describe both halves together. The kernel seeds `confidence: 1.0` on every link first; each rule then records resolution facts and the matching detector applies its penalty:
360
+ The rules below describe both halves together. The kernel seeds `confidence: 1.0` on every link first; each rule records resolution facts and the matching detector applies its penalty:
361
361
 
362
362
  1. **Path match (universal)**: if `link.target` equals some node's `path`, the link is resolved (`resolvedTarget` set) and keeps the 1.0 baseline. Applies to every link.kind, ignores the `resolution` map. Drives resolved markdown / at-directive references. `core/mcp-tools` synthetic edges path-match here too, recording `resolvedTarget`.
363
363
 
364
364
  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 ACTIVE PROVIDER LENS: `resolution = providers[activeProvider].resolution`. If `resolution[link.kind]` exists AND any candidate node's kind appears in it, the link resolves and keeps the 1.0 baseline.
365
365
 
366
- **Virtual target (applies to both rules above):** when the resolved target node carries `virtual: true` (a derived, in-memory entity reconstructed from frontmatter and never verified on disk, e.g. an `mcp://<server>` node emitted by `core/mcp-tools`), the link still resolves (`resolvedTarget` is set, the edge stays navigable) and keeps the 1.0 baseline like any clean resolution; no built-in penalty applies. A virtual target is never "genuinely broken" (it resolves), so rule 3 does not fire on it.
366
+ **Virtual target (applies to both rules above):** when the resolved target node carries `virtual: true` (a derived, in-memory entity reconstructed from frontmatter and never verified on disk, e.g. an `mcp://<server>` node emitted by `core/mcp-tools`), the link still resolves (`resolvedTarget` set, edge navigable) and keeps the 1.0 baseline like any clean resolution; no built-in penalty. A virtual target is never "genuinely broken" (it resolves), so rule 3 does not fire on it.
367
367
 
368
- 3. **Broken penalty (universal)**: when neither rule above resolved the link AND the link is genuinely broken, `core/reference-broken` subtracts `BROKEN_PENALTY = 0.5` via a `delta` op, folding the kernel's 1.0 baseline down to `0.5`, in the same score-phase pass as its broken-ref errors. "Genuinely broken" means `link.target` matches no node `path` AND the stripped `trigger.normalizedTrigger` matches no entry in the cross-kind name index, i.e. the same kind-agnostic notion of "the name exists nowhere" that `core/reference-broken` uses (the lift surfaces this set on `ctx.brokenLinks`). A link resolved via `scan.referencePaths` (the escape-hatch) is neither flagged NOR penalised: the penalty follows the issue. The effect is uniform across link kinds: a dangling markdown reference (`[x](missing.md)`), a `@missing.md`, and a `/no-such-command` all render at `0.5`, visibly fainter than a resolved edge at `1.0`. A link that fails the strict kind/lens resolution of rule 2 but DOES match a name in the index (the `not-broken` + `not-resolved` case documented below) is NOT broken: it keeps the 1.0 baseline, because it resolves to a real node, just not as a valid target for this `link.kind`. The broken floor sits ABOVE the reserved-target value (`0.1`, §Provider · reservedNames): a target that resolves to a real-but-runtime-ignored file is flagged more faintly than one that resolves to nothing, deliberately, the reserved shadow is the subtler trap.
368
+ 3. **Broken penalty (universal)**: when neither rule above resolved the link AND it is genuinely broken, `core/reference-broken` subtracts `BROKEN_PENALTY = 0.5` via a `delta` op, folding the 1.0 baseline to `0.5`, in the same score-phase pass as its broken-ref errors. "Genuinely broken" means `link.target` matches no node `path` AND the stripped `trigger.normalizedTrigger` matches no entry in the cross-kind name index, the kind-agnostic "the name exists nowhere" notion `core/reference-broken` uses (the lift surfaces this set on `ctx.brokenLinks`). A link resolved via `scan.referencePaths` (escape-hatch) is neither flagged NOR penalised: the penalty follows the issue. Uniform across link kinds: a dangling `[x](missing.md)`, a `@missing.md`, and a `/no-such-command` all render at `0.5`, fainter than a resolved edge at `1.0`. A link failing rule 2's strict kind/lens resolution but matching a name in the index (the `not-broken` + `not-resolved` case below) is NOT broken: it keeps the 1.0 baseline because it resolves to a real node, just not as a valid target for this `link.kind`. The broken floor sits ABOVE the reserved-target value (`0.1`, §Provider · reservedNames): deliberately, a target resolving to a real-but-runtime-ignored file is flagged more faintly than one resolving to nothing, the reserved shadow being the subtler trap.
369
369
 
370
- The matrix is **per-link-kind, per-Provider**, strict: a `claude` Provider that declares `resolution: { mentions: ['agent'], invokes: ['command', 'skill'] }` does NOT resolve a `/foo` slash matching an agent named `foo` (slash → agent is a kind mismatch surfaced by `link-kind-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 resolution is "the name exists AS A VALID resolution for this link.kind". The `not-broken` + `not-resolved` combination is a documented edge case: the trigger resolves to a real node but the link's kind cannot legitimately point there, so no built-in detector touches it and it keeps the 1.0 baseline.
370
+ The matrix is **per-link-kind, per-Provider**, strict: a `claude` Provider declaring `resolution: { mentions: ['agent'], invokes: ['command', 'skill'] }` does NOT resolve a `/foo` slash matching an agent named `foo` (slash → agent is a kind mismatch surfaced by `link-kind-conflict` / `kind-mismatch` analyzers, not silently treated as a resolution). The strictness is the load-bearing difference from the kind-agnostic `core/reference-broken`: `broken-ref`'s scope is "the name exists somewhere", post-walk resolution is "the name exists AS A VALID resolution for this link.kind". The `not-broken` + `not-resolved` combination is the documented edge case: the trigger resolves to a real node but the link's kind cannot legitimately point there, so no built-in detector touches it and it keeps the 1.0 baseline.
371
371
 
372
- The lookup uses the ACTIVE PROVIDER LENS deliberately, mirroring the extractor gate (§Universal extractors and per-provider extractors). The lens represents the runtime grammar the operator is authoring under, and that grammar applies across the project's surface, not only to files the matching `classify()` claimed. A `@handle` in `notes/todo.md` (classified by `core/markdown`) under the `claude` lens still parses as a claude mention (the extractor gate authorises it) and resolves against claude's `resolution.mentions` (the resolver gate now mirrors the same authority). The same body under the `openai` lens follows openai's resolution map, or short-circuits if openai declares no entry for that `link.kind`. When `activeProvider === null` (unlensed project: no setting, no filesystem signal), the name path short-circuits uniformly; the path-match rule still applies.
372
+ The lookup uses the ACTIVE PROVIDER LENS deliberately, mirroring the extractor gate (§Universal extractors and per-provider extractors): the lens grammar applies across the project's surface, not only files the matching `classify()` claimed. A `@handle` in `notes/todo.md` (classified by `core/markdown`) under the `claude` lens parses as a claude mention (extractor gate authorises it) and resolves against claude's `resolution.mentions` (resolver gate mirrors the authority). The same body under `openai` follows openai's resolution map, or short-circuits if openai declares no entry for that `link.kind`. When `activeProvider === null` (unlensed), the name path short-circuits uniformly; path-match still applies.
373
373
 
374
- **Distinct from the Signal IR `resolverRules` (§Resolver phase).** `resolverRules` rank candidates INSIDE a Signal (Phase 3+, no Provider declares it today); `resolution` runs against the merged Link graph post-walk and is the contract Extractors EMITTING Links rely on. The two surfaces share no mechanism and intentionally do not compose; when a Signal IR materialises into a Link, the `resolution` matrix runs unchanged against the resulting Link.
374
+ **Distinct from the Signal IR `resolverRules` (§Resolver phase).** `resolverRules` rank candidates INSIDE a Signal (Phase 3+, no Provider declares it today); `resolution` runs against the merged Link graph post-walk and is the contract Extractors EMITTING Links rely on. The two surfaces share no mechanism and do not compose; when a Signal IR materialises into a Link, the `resolution` matrix runs unchanged against the resulting Link.
375
375
 
376
376
  ### Provider · reservedNames
377
377
 
378
- Each Provider MAY declare an optional `reservedNames: Record<kind, string[]>` map listing, for each `node.kind` the runtime owns, the set of invocation names the runtime itself consumes. Anthropic's Claude CLI reserves `/help`, `/clear`, `/init`, `/agents`, `/model`, `/cost`, `/compact`, `/login`, `/logout`, … under `command`, and `general-purpose`, `output-style-setup`, `statusline-setup` under `agent`; a user-authored `.claude/commands/help.md` is silently shadowed at runtime (the built-in runs, the file is ignored).
378
+ Each Provider MAY declare an optional `reservedNames: Record<kind, string[]>` map listing, for each `node.kind` the runtime owns, the invocation names the runtime itself consumes. Anthropic's Claude CLI reserves `/help`, `/clear`, `/init`, `/agents`, `/model`, `/cost`, `/compact`, `/login`, `/logout`, … under `command`, and `general-purpose`, `output-style-setup`, `statusline-setup` under `agent`; a user-authored `.claude/commands/help.md` is silently shadowed at runtime (the built-in runs, the file is ignored).
379
379
 
380
380
  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, then tests them against a reserved set resolved under **two scopes**:
381
381
 
382
382
  1. **Self scope.** `reservedNames[node.kind]` of the node's OWN Provider (`node.provider`). The self-contained case: Claude classifies `.claude/commands/help.md` as `claude`/`command` and reserves `help` under `command`, so the file is flagged.
383
- 2. **Lens scope.** When a Provider is the active lens (`activeProvider === provider.id`) it ALSO lends its catalog to nodes that a *universal* (ungated) Provider classified, matched by `node.kind`. This is required by runtimes that adopt the open `.agents/skills/` standard instead of a vendor directory: their user invocables are owned by the neutral `agent-skills` Provider (`kind: skill`), not by the vendor Provider, so self scope alone would never reach them. Google's Antigravity is exactly this shape, it is metadata-only (classifies nothing) and reserves its `agy` built-in slash commands under `skill`; when `activeProvider === 'antigravity'`, a user `.agents/skills/goal/SKILL.md` is flagged because `/goal` is a built-in. Lens scope is skipped when `node.provider === activeProvider` (it would duplicate self scope) and when no lens is resolved (`activeProvider === null`).
383
+ 2. **Lens scope.** When a Provider is the active lens (`activeProvider === provider.id`) it ALSO lends its catalog to nodes a *universal* (ungated) Provider classified, matched by `node.kind`. Required by runtimes adopting the open `.agents/skills/` standard instead of a vendor directory: their user invocables are owned by the neutral `agent-skills` Provider (`kind: skill`), not the vendor Provider, so self scope alone would never reach them. Google's Antigravity is exactly this shape: metadata-only (classifies nothing), reserving its `agy` built-in slash commands under `skill`; when `activeProvider === 'antigravity'`, a user `.agents/skills/goal/SKILL.md` is flagged because `/goal` is a built-in. Lens scope is skipped when `node.provider === activeProvider` (it would duplicate self scope) and when no lens is resolved (`activeProvider === null`).
384
384
 
385
385
  A node's identifiers are always derived from its OWN kind contract; only the reserved-set lookup widens under the lens. A node landing in either scope's set joins a per-scan `Set<nodePath>` consumed by the score-phase `core/name-reserved` analyzer, which co-locates two effects in one pass (detection still lives in the orchestrator, so the same set drives both):
386
386
 
387
387
  1. **It projects one `warn` issue per reserved-shadow node** (`severity: 'warn'`, message points at the offending file and suggests renaming).
388
388
 
389
- 2. **It downgrades any link that resolves to a reserved target** (by path OR by name match) by subtracting `RESERVED_PENALTY = 0.9` (a `delta` op) from the kernel's 1.0 baseline, folding it to `RESERVED_TARGET = 0.1`, emitting the `delta -0.9` in the same score-phase pass as its reserved warns. The reserved-target set is computed by the post-walk lift and surfaced to the detector via `ctx.reservedNodePaths`. The visual weight in the graph drops well below the broken floor (`0.5`) 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 keeps the 1.0 baseline (no penalty); only when EVERY accepted candidate is reserved does the penalty apply. With `core/name-reserved` disabled, a reserved-resolving link gets no `delta -0.9` and no warn, so the link falls back to the kernel's 1.0 baseline (symmetric disable).
389
+ 2. **It downgrades any link resolving to a reserved target** (by path OR name match) by subtracting `RESERVED_PENALTY = 0.9` (a `delta` op) from the 1.0 baseline, folding it to `RESERVED_TARGET = 0.1`, emitting the `delta -0.9` in the same score-phase pass as its reserved warns. The reserved-target set is computed by the post-walk lift and surfaced via `ctx.reservedNodePaths`. The visual weight drops well below the broken floor (`0.5`) so the operator sees 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 non-reserved, the link keeps the 1.0 baseline, and only when EVERY accepted candidate is reserved does the penalty apply. With `core/name-reserved` disabled, a reserved-resolving link gets no `delta -0.9` and no warn, falling back to the 1.0 baseline (symmetric disable).
390
390
 
391
- The lookup normalises both sides through the §Extractor · trigger normalization pipeline, so a literal `Init-Project` in the manifest still matches a user `name: init project` or filename `Init-Project.md`. The catalog is intentionally per-kind, not global: a name reserved for commands (`/help`) MAY legitimately appear as a skill (a "help" skill triggered through a non-command channel). Lens scope respects the same per-kind boundary: Antigravity declares its reserved names under `skill` (not `command`) precisely because the invocable they shadow is a skill file, so only `skill`-kind nodes are tested against it.
391
+ The lookup normalises both sides through the §Extractor · trigger normalization pipeline, so a literal `Init-Project` in the manifest still matches a user `name: init project` or filename `Init-Project.md`. The catalog is intentionally per-kind, not global: a name reserved for commands (`/help`) MAY legitimately appear as a skill (a "help" skill triggered through a non-command channel). Lens scope respects the same per-kind boundary: Antigravity declares its reserved names under `skill` (not `command`) because the invocable they shadow is a skill file, so only `skill`-kind nodes are tested.
392
392
 
393
- **Update policy.** Built-in catalogs drift as vendor runtimes evolve. Each catalog change ships as a kernel patch with a changeset entry; the catalog is considered API surface that users rely on the analyzer to reflect. User-installed Providers MAY declare their own `reservedNames` with the same shape; the analyzer and the penalty run uniformly across built-in and user-installed Providers.
393
+ **Update policy.** Built-in catalogs drift as vendor runtimes evolve. Each catalog change ships as a kernel patch with a changeset entry; the catalog is API surface users rely on the analyzer to reflect. User-installed Providers MAY declare their own `reservedNames` with the same shape; the analyzer and penalty run uniformly across built-in and user-installed Providers.
394
394
 
395
- Default `undefined` ≡ empty map ≡ no reserved names. Links to non-reserved targets are unaffected (they keep the kernel's 1.0 baseline), the penalty only fires when the resolved target is in the reserved set and `core/name-reserved` is enabled.
395
+ Default `undefined` ≡ empty map ≡ no reserved names. Links to non-reserved targets keep the 1.0 baseline.
396
396
 
397
397
  ### Extractor · output callbacks
398
398
 
399
399
  The `Extractor` runtime contract is `extract(ctx) → void`. The extractor emits its work through three callbacks the kernel binds onto `ctx`:
400
400
 
401
401
  - `ctx.emitLink(link)`, append a `Link` to the kernel's `links` table. The kernel validates `link.kind` against the **global closed enum** of link kinds (`invokes`, `references`, `mentions`, `points`) before persistence; off-enum links are dropped and surface as `extension.error` events (the per-extractor `emitsLinkKinds` allowlist was retired with the structure-as-truth refactor; confidence is declared per emit, default `'medium'`). URL-shaped targets (`http(s)://…`) are partitioned out into `node.externalRefsCount` and never persisted.
402
- - `ctx.enrichNode(partial)`, merge canonical, kernel-curated properties onto the current node's enrichment layer (persisted into [`node_enrichments`](./db-schema.md#node_enrichments)). **Strictly separate from the author-supplied frontmatter** (the latter remains immutable across scans). The enrichment layer is the right home for kernel-derived facts (computed titles, summaries, signals an Extractor inferred from the body) without polluting what the user wrote on disk. See §Enrichment layer below for the full lifecycle (per-extractor attribution, refresh verbs).
403
- - `ctx.store`, plugin-scoped persistence. Optional, present only when the plugin declares `storage.mode` in `plugin.json`. Shape depends on the mode (`KvStore` for mode A, scoped `Database` for mode B). See [`plugin-kv-api.md`](./plugin-kv-api.md). The plugin author MAY opt into shape validation for their own writes by declaring `storage.schema` (Mode A) or `storage.schemas` (Mode B) in the manifest, JSON Schemas the kernel AJV-compiles at load time and runs against every `ctx.store.set(key, value)` / `ctx.store.write(table, row)` call. Absent = permissive (status quo). `emitLink` and `enrichNode` keep their universal validation against `link.schema.json` / `node.schema.json` regardless of this opt-in. See [`plugin-author-guide.md` §`outputSchema`](./plugin-author-guide.md#outputschema--opt-in-correctness-for-custom-storage-writes).
402
+ - `ctx.enrichNode(partial)`, merge canonical kernel-curated properties onto the current node's enrichment layer (persisted into [`node_enrichments`](./db-schema.md#node_enrichments)). **Strictly separate from the author-supplied frontmatter** (which stays immutable across scans). The enrichment layer holds kernel-derived facts (computed titles, summaries, signals an Extractor inferred from the body) without polluting what the user wrote on disk. See §Enrichment layer for the full lifecycle (per-extractor attribution, refresh verbs).
403
+ - `ctx.store`, plugin-scoped persistence. Optional, present only when the plugin declares `storage.mode` in `plugin.json`. Shape depends on the mode (`KvStore` for mode A, scoped `Database` for mode B). See [`plugin-kv-api.md`](./plugin-kv-api.md). The plugin author MAY opt into shape validation by declaring `storage.schema` (Mode A) or `storage.schemas` (Mode B) in the manifest, JSON Schemas the kernel AJV-compiles at load time and runs against every `ctx.store.set(key, value)` / `ctx.store.write(table, row)` call. Absent = permissive (status quo). `emitLink` and `enrichNode` keep their universal validation against `link.schema.json` / `node.schema.json` regardless. See [`plugin-author-guide.md` §`outputSchema`](./plugin-author-guide.md#outputschema--opt-in-correctness-for-custom-storage-writes).
404
404
 
405
- Extractors are deterministic-only; `ctx.runner` is NOT exposed on the Extractor context. LLM-driven enrichment of a node is an Action concern (queued as a job), not an Extractor concern.
405
+ Extractors are deterministic-only; `ctx.runner` is NOT exposed on the Extractor context. LLM-driven enrichment is an Action concern (queued as a job), not an Extractor concern.
406
406
 
407
407
  ### Extractor · Signal IR (opt-in)
408
408
 
409
- In addition to the `emitLink` path, Extractors MAY emit **Signals** via `ctx.emitSignal(signal)`. A Signal is a candidate detection: one or many alternative interpretations of the same body or frontmatter location, each carrying its own kind, target, confidence, and rationale. See [`signal.schema.json`](./schemas/signal.schema.json) for the full contract. The Signal IR is opt-in; an extractor whose detection is unambiguous (`[text](file.md)` markdown links, plain `https://…` URLs) is encouraged to keep emitting Links directly with `ctx.emitLink`. Signals exist for the cases the resolver actually helps: detections where a single body token can plausibly mean several things and the active provider's rules need to decide.
409
+ In addition to the `emitLink` path, Extractors MAY emit **Signals** via `ctx.emitSignal(signal)`. A Signal is a candidate detection: one or many alternative interpretations of the same body or frontmatter location, each carrying its own kind, target, confidence, and rationale. See [`signal.schema.json`](./schemas/signal.schema.json) for the full contract. The Signal IR is opt-in; an extractor whose detection is unambiguous (`[text](file.md)` markdown links, plain `https://…` URLs) is encouraged to emit Links directly with `ctx.emitLink`. Signals exist for the cases the resolver helps: a single body token can plausibly mean several things and the active provider's rules must decide.
410
410
 
411
411
  The kernel's **resolver phase** runs after extraction completes and before analysis starts. For each Signal, the resolver:
412
412
 
413
413
  1. (Phase 4+, not yet wired) Filters candidates whose `extractorId` is disabled by a per-extension enable filter. The config surface that toggles individual extensions is not defined yet (the earlier `plugins.<id>.extensions.<extId>.enabled` placeholder was removed); it will be specified when this filter lands. When the filter empties every candidate, the Signal carries `resolution.outcome = 'rejected'` with `extractorDisabled = { extractorId }`.
414
- 2. Ranks the surviving candidates inside the Signal by the active Provider's `resolverRules.kindPriority` (when declared), then `confidence` DESC, then `range` length (`end - start`) DESC, then `extractorId` declaration order. The chosen index is recorded as `resolution.winnerIndex` and (provisionally) `resolution.outcome = 'materialised'`.
415
- 3. For body-scoped Signals with a `range`, the resolver builds overlap clusters per source (transitive closure of range intersection). Clusters of size 1 keep their winner. For clusters of size 2+, the resolver re-applies the same four-step tiebreak to each Signal's winning candidate to pick a cluster winner. Losers flip to `resolution.outcome = 'rejected'` with `rejectedBy = { source, range, extractorId, reason }`, where `reason` names the tiebreak step that decided it: `kind-priority`, `higher-confidence`, `longer-range`, or `earlier-declaration`. External pseudo-link clusters (every member targets `http://` / `https://`) skip cross-cluster ranking, every member materialises (URL-targeted Signals can never conflict with internal-target Signals or with each other because they leave the local graph).
416
- 4. Materialises every Signal whose final `outcome === 'materialised'` as a Link, identical in shape to a Link emitted directly via `emitLink`. The materialised Link's `sources[]` carries the winning candidate's `extractorId` so attribution survives the resolver.
417
- 5. (Phase 4+, not yet wired) Rejects a whole Signal when every candidate's `confidence` falls below the configured floor: `resolution.outcome = 'rejected'` with `belowFloor = { threshold }`. Today the resolver materialises every Signal that survives overlap regardless of confidence.
414
+ 2. Ranks surviving candidates inside the Signal by the active Provider's `resolverRules.kindPriority` (when declared), then `confidence` DESC, then `range` length (`end - start`) DESC, then `extractorId` declaration order. The chosen index is recorded as `resolution.winnerIndex` and (provisionally) `resolution.outcome = 'materialised'`.
415
+ 3. For body-scoped Signals with a `range`, the resolver builds overlap clusters per source (transitive closure of range intersection). Size-1 clusters keep their winner. For size 2+ clusters, the resolver re-applies the same four-step tiebreak to each Signal's winning candidate to pick a cluster winner. Losers flip to `resolution.outcome = 'rejected'` with `rejectedBy = { source, range, extractorId, reason }`, where `reason` names the deciding tiebreak step: `kind-priority`, `higher-confidence`, `longer-range`, or `earlier-declaration`. External pseudo-link clusters (every member targets `http://` / `https://`) skip cross-cluster ranking, every member materialises (URL-targeted Signals never conflict with internal-target Signals or each other because they leave the local graph).
416
+ 4. Materialises every Signal whose final `outcome === 'materialised'` as a Link, identical in shape to one emitted directly via `emitLink`. The materialised Link's `sources[]` carries the winning candidate's `extractorId` so attribution survives.
417
+ 5. (Phase 4+, not yet wired) Rejects a whole Signal when every candidate's `confidence` falls below the configured floor: `resolution.outcome = 'rejected'` with `belowFloor = { threshold }`. Today the resolver materialises every Signal surviving overlap regardless of confidence.
418
418
 
419
419
  Both materialised and rejected Signals remain on `IAnalyzerContext.signals` post-resolver. The built-in `core/extractor-collision` analyzer reads this buffer and emits one `warn` issue per rejected Signal so the operator sees WHICH extractor lost, against WHO, and WHY. Rejected Signals never enter the graph as Links, but their existence is visible end-to-end through the issue surface.
420
420
 
421
- The Signal's `range` field (byte offsets in the source) powers two cross-extractor analyses no Link can support today: collision detection (two extractors emitting Signals with overlapping ranges, contract above) and fragmentation detection (an authored intent split across several adjacent Signals, deferred to Phase 5+). Both surface as analyzer issues, not silent merges.
421
+ The Signal's `range` field (byte offsets in the source) powers two cross-extractor analyses no Link can support today: collision detection (two extractors emitting Signals with overlapping ranges, contract above) and fragmentation detection (an authored intent split across adjacent Signals, deferred to Phase 5+). Both surface as analyzer issues, never silent merges.
422
422
 
423
423
  ### Extractor · code-region file references (`core/backtick-path`)
424
424
 
425
- Every body extractor strips fenced code blocks and inline code spans before matching (the code-strip policy): invocation tokens (`@handle`, `/command`, URLs) inside backticks are literal payload the runtime never follows. **Relative file paths are the documented exception.** The Agent Skills open standard mandates that a skill references its bundled resources by relative path and that "agents load these on demand"; prose like ``Read `references/rules.md` `` is an instruction the consuming LLM runtime does follow. The `core/backtick-path` extractor exists to surface exactly that class of references, and ONLY inside code regions, the precise complement of the code-strip policy, so it can never collide with the prose-side extractors.
425
+ Every body extractor strips fenced code blocks and inline code spans before matching (the code-strip policy): invocation tokens (`@handle`, `/command`, URLs) inside backticks are literal payload the runtime never follows. **Relative file paths are the documented exception.** The Agent Skills open standard mandates that a skill references its bundled resources by relative path and that "agents load these on demand"; prose like ``Read `references/rules.md` `` is an instruction the consuming LLM runtime follows. The `core/backtick-path` extractor surfaces exactly that class of references, ONLY inside code regions, the precise complement of the code-strip policy, so it can never collide with the prose-side extractors.
426
426
 
427
427
  The contract:
428
428
 
429
429
  - **Domain**: the extractor matches exclusively inside fenced code blocks and inline code spans, over the *inverse mask* of the code-strip transform: same-length text where code-region characters survive and everything else is blanked. Same-length masking keeps byte offsets and line numbers valid against the original body.
430
- - **Token grammar** (pinned; implementations MUST match it exactly): `/(?<![\w/:.-])(?:\.{1,2}\/)?[\w][\w.-]*(?:\/[\w.-]+)*\.md\b(?![\w/])/g`. In words: an optional `./` or `../` prefix, a first segment that MUST start with a word character, zero or more `/` separators, a `.md` suffix at a word boundary. A bare filename (`algo4.md`) matches, the same way the consuming runtime follows it: a skill body's `lee el archivo: ` + "`algo4.md`" is an instruction the LLM resolves against the skill directory (verified empirically, every tested model reads the bare-referenced sibling), so the graph models the edge. The character classes and guards still reject, by construction: URL interiors (`https://example.com/docs/x.md` cannot match at any start position because of the lookbehind), template placeholders and globs (`{PROJECT}-x.md`, `*-S.md`, the leading `{` / `*` is outside the segment class AND the word-character anchor refuses the `-x.md` tail that would otherwise leak once the `/` separator became optional), near-miss suffixes (`.mdx`, `.md_var`), and absolute paths (a leading `/` fails the lookbehind). Slashless convention filenames (`SKILL.md`, `README.md`) now match too: a self-referential `SKILL.md` resolves to the node's own sibling and surfaces as a self-loop (excluded from card chips by `core/link-self-loop`), and any other unresolved bare filename is flagged by `core/reference-broken`, so the relaxed recall does not corrupt the graph.
430
+ - **Token grammar** (pinned; implementations MUST match it exactly): `/(?<![\w/:.-])(?:\.{1,2}\/)?[\w][\w.-]*(?:\/[\w.-]+)*\.md\b(?![\w/])/g`. In words: an optional `./` or `../` prefix, a first segment that MUST start with a word character, zero or more `/` separators, a `.md` suffix at a word boundary. A bare filename (`algo4.md`) matches, as the consuming runtime follows it: a skill body's `lee el archivo: ` + "`algo4.md`" is an instruction the LLM resolves against the skill directory (verified empirically, every tested model reads the bare-referenced sibling), so the graph models the edge. The character classes and guards still reject, by construction: URL interiors (`https://example.com/docs/x.md` cannot match at any start position due to the lookbehind), template placeholders and globs (`{PROJECT}-x.md`, `*-S.md`: the leading `{` / `*` is outside the segment class AND the word-character anchor refuses the `-x.md` tail that would leak once the `/` separator became optional), near-miss suffixes (`.mdx`, `.md_var`), and absolute paths (a leading `/` fails the lookbehind). Slashless convention filenames (`SKILL.md`, `README.md`) now match too: a self-referential `SKILL.md` resolves to the node's own sibling and surfaces as a self-loop (excluded from card chips by `core/link-self-loop`), and any other unresolved bare filename is flagged by `core/reference-broken`, so the relaxed recall does not corrupt the graph.
431
431
  - **Targets**: `.md` only. Markdown files are the one class with a guaranteed node on the scan side (the `core/markdown` fallback), so every resolvable token has a target to land on.
432
432
  - **Resolution**: identical to `core/markdown-link`, POSIX-normalised against `dirname(node.path)`. Per-node dedup on the resolved target, first occurrence wins.
433
- - **Emission**: one single-candidate Signal per distinct resolved target, `kind: 'points'` (the code-region path pointer kind, distinct from `references` so the two surfaces stay visually and semantically separable), confidence `0.85` (same value and rationale as a path-style at-directive: a strong file signal with one degree of inference, the author wrote a path but not an explicit link syntax). The candidate's `normalizedTrigger` is the resolved target so resolution and the confidence lift behave exactly like `markdown-link`. A prose `[x](references/a.md)` (`references`) and a backticked `` `references/a.md` `` (`points`) targeting the same file COEXIST as two Link rows: the post-resolver dedup keys on `kind`, so the rows never merge, and `core/link-kind-conflict` excludes `points` from disagreement detection (two complementary authoring surfaces, not two detectors disputing one meaning).
434
- - **Unresolved targets are NOT suppressed.** The extractor emits unconditionally, mirroring `markdown-link`; `core/reference-broken` flags targets that resolve to no node. This is deliberate: a backticked path pointing at a deleted or misspelled bundled doc is a real authoring bug (the standard's progressive disclosure breaks at runtime). Out-of-scope paths that the consuming runtime resolves against a different root (a target workspace, a generated tree) are silenced through the existing `scan.referencePaths` escape hatch, not by weakening the extractor.
433
+ - **Emission**: one single-candidate Signal per distinct resolved target, `kind: 'points'` (the code-region path pointer kind, distinct from `references` so the two surfaces stay separable), confidence `0.85` (same value and rationale as a path-style at-directive: a strong file signal with one degree of inference, the author wrote a path but not explicit link syntax). The candidate's `normalizedTrigger` is the resolved target so resolution and the confidence lift behave exactly like `markdown-link`. A prose `[x](references/a.md)` (`references`) and a backticked `` `references/a.md` `` (`points`) targeting the same file COEXIST as two Link rows: post-resolver dedup keys on `kind` so the rows never merge, and `core/link-kind-conflict` excludes `points` from disagreement detection (two complementary authoring surfaces, not two detectors disputing one meaning).
434
+ - **Unresolved targets are NOT suppressed.** The extractor emits unconditionally, mirroring `markdown-link`; `core/reference-broken` flags targets that resolve to no node. Deliberate: a backticked path pointing at a deleted or misspelled bundled doc is a real authoring bug (the standard's progressive disclosure breaks at runtime). Out-of-scope paths the consuming runtime resolves against a different root (a target workspace, a generated tree) are silenced through the existing `scan.referencePaths` escape hatch, not by weakening the extractor.
435
435
 
436
436
  A path written in prose without any wrapping (neither backticks nor markdown-link syntax) stays invisible in this revision; the code-region domain is the verified, bounded surface.
437
437
 
@@ -441,9 +441,9 @@ A path written in prose without any wrapping (neither backticks nor markdown-lin
441
441
 
442
442
  - Persist enrichments into a per-`(node, extractor)` table (the reference impl uses [`node_enrichments`](./db-schema.md#node_enrichments)) so attribution survives across scans.
443
443
  - Preserve the author frontmatter byte-for-byte through every scan and refresh; the enrichment overlay is a SEPARATE store.
444
- - Regenerate enrichments through the §Extractor · fine-grained scan cache contract: an unchanged body hash + same registered Extractor reuses the prior row; a changed body re-runs `extract()` and overwrites the row via the PRIMARY KEY conflict. Extractors are deterministic, so a stale-flag is unnecessary, re-running is free and reproducible.
444
+ - Regenerate enrichments through the §Extractor · fine-grained scan cache contract: an unchanged body hash + same registered Extractor reuses the prior row; a changed body re-runs `extract()` and overwrites the row via the PRIMARY KEY conflict. Extractors are deterministic, so a stale-flag is unnecessary: re-running is free and reproducible.
445
445
 
446
- > **Reserved columns**, `node_enrichments.is_probabilistic`, `body_hash_at_enrichment`, and `stale` are persisted but inert in this revision: every Extractor write sets `is_probabilistic = 0` and `stale = 0`, with `body_hash_at_enrichment` always equal to the current body hash. The columns are reserved for a future revision where Action-issued enrichments (queued probabilistic jobs writing back through the enrichment layer) will need stale tracking to preserve LLM cost across body changes. Until that revision lands, readers MAY assume `stale = 0` and the merge helper's `includeStale: true` flag is a no-op.
446
+ > **Reserved columns**, `node_enrichments.is_probabilistic`, `body_hash_at_enrichment`, and `stale` are persisted but inert in this revision: every Extractor write sets `is_probabilistic = 0` and `stale = 0`, with `body_hash_at_enrichment` always equal to the current body hash. They are reserved for a future revision where Action-issued enrichments (queued probabilistic jobs writing back through the enrichment layer) need stale tracking to preserve LLM cost across body changes. Until then, readers MAY assume `stale = 0` and the merge helper's `includeStale: true` flag is a no-op.
447
447
 
448
448
  Read-side merge (`mergeNodeWithEnrichments` in the reference impl):
449
449
 
@@ -457,7 +457,7 @@ Refresh verbs (`sm refresh <node>` and `sm refresh --stale`) re-run the Extracto
457
457
 
458
458
  ### Extractor · `precondition` filter
459
459
 
460
- Extractors MAY declare an optional `precondition` block (`{ kind?: string[]; provider?: string[] }`, the same shape Analyzers and Actions share). When declared, the kernel filters fail-fast: `extract()` is invoked **only** for nodes that satisfy every declared sub-filter (`kind` lists qualified `<plugin>/<kindName>` ids; `provider` lists plugin ids; both apply as AND). The skip happens BEFORE the extractor context is built so the extractor wastes zero CPU on inapplicable nodes. Absent (`undefined`) is the default and means "applies to every kind"; there is no wildcard syntax. Unknown qualified kinds (no installed Provider declares them) are non-blocking: the extractor keeps `loaded` status and `sm plugins doctor` surfaces an informational `precondition-kind-unknown` warning so the author sees typos and missing-Provider cases, but the doctor's exit code is NOT promoted by this warning. See [`plugin-author-guide.md` §`precondition`](./plugin-author-guide.md#extractor--analyzer--action-precondition-narrow-the-pipeline) for the full author-side contract.
460
+ Extractors MAY declare an optional `precondition` block (`{ kind?: string[]; provider?: string[] }`, the shape Analyzers and Actions share). When declared, the kernel filters fail-fast: `extract()` is invoked **only** for nodes satisfying every declared sub-filter (`kind` lists qualified `<plugin>/<kindName>` ids; `provider` lists plugin ids; both apply as AND). The skip happens BEFORE the extractor context is built, so the extractor wastes zero CPU on inapplicable nodes. Absent (`undefined`) is the default, meaning "applies to every kind"; there is no wildcard syntax. Unknown qualified kinds (no installed Provider declares them) are non-blocking: the extractor keeps `loaded` status and `sm plugins doctor` surfaces an informational `precondition-kind-unknown` warning so the author sees typos and missing-Provider cases, but the doctor's exit code is NOT promoted by this warning. See [`plugin-author-guide.md` §`precondition`](./plugin-author-guide.md#extractor--analyzer--action-precondition-narrow-the-pipeline).
461
461
 
462
462
  ### Extractor · fine-grained scan cache
463
463
 
@@ -468,10 +468,10 @@ The contract the cache MUST satisfy (engine-agnostic):
468
468
  - A node-level cache hit (body+frontmatter unchanged) is upgraded to a full skip ONLY when every currently-registered Extractor that applies to the node's kind has a recorded run against the prior body hash.
469
469
  - A new Extractor registered between scans MUST run on the cached node, its absence from the cache is the canonical signal. The rest of the cache (existing Extractors against the same body) is preserved.
470
470
  - An Extractor uninstalled between scans MUST have its cache rows removed and its sole-source links dropped. Links whose `sources` mix the uninstalled Extractor's short id with a still-cached Extractor's short id MUST be reshaped: the obsolete short id is stripped from the array and the link survives with the cached attribution intact. The persisted audit trail therefore never references a removed contributor.
471
- - The cache key includes the canonical hash of `node.sidecar.annotations` alongside the body hash. A sidecar-only edit (`.sm` change without a `.md` change) invalidates the cached run for every Extractor that ran against that node. Universal invalidation is deliberate: an opt-in flag was considered and rejected because forgetting it produces a silent stale-data bug, while the cost of running every Extractor again on a `.sm` edit is negligible (sidecars change rarely, Extractors are pure-CPU). The hash uses a deterministic canonical form so a YAML re-format that does not change the annotation values does not invalidate the cache.
472
- - The cache is otherwise transparent to plugin authors. An Extractor cannot opt out and cannot inspect the cache; its only obligation is to be deterministic for a given input (this is structural: every Extractor is deterministic-only, by spec).
471
+ - The cache key includes the canonical hash of `node.sidecar.annotations` alongside the body hash. A sidecar-only edit (`.sm` change without a `.md` change) invalidates the cached run for every Extractor that ran against that node. Universal invalidation is deliberate: an opt-in flag was rejected because forgetting it produces a silent stale-data bug, while re-running every Extractor on a `.sm` edit costs little (sidecars change rarely, Extractors are pure-CPU). The hash uses a deterministic canonical form so a YAML re-format that does not change annotation values does not invalidate the cache.
472
+ - The cache is otherwise transparent to plugin authors. An Extractor cannot opt out and cannot inspect the cache; its only obligation is to be deterministic for a given input (structural: every Extractor is deterministic-only, by spec).
473
473
 
474
- The invariant exists to keep `sm scan --changed` cheap on real corpora: re-parsing a body that has not changed for an Extractor that has not changed is wasted work; the cache turns it into a one-row reuse. The same machinery is what will let a future Action-prob enrichment revision (see §Extractor · enrichment layer) reuse paid LLM output across unchanged bodies.
474
+ The invariant keeps `sm scan --changed` cheap on real corpora: re-parsing an unchanged body for an unchanged Extractor is wasted work; the cache turns it into a one-row reuse. The same machinery will let a future Action-prob enrichment revision (see §Extractor · enrichment layer) reuse paid LLM output across unchanged bodies.
475
475
 
476
476
  ### Extractor · trigger normalization
477
477
 
@@ -493,7 +493,7 @@ Applied in exactly this order:
493
493
  5. **Collapse whitespace**, runs of two or more spaces become one.
494
494
  6. **Trim**, strip leading and trailing whitespace.
495
495
 
496
- Characters outside the separator set that are not letters or digits (e.g. `/`, `@`, `:`, `.`) are **preserved**. Stripping them is the extractor's concern, not the normalizer's, the normalizer operates on whatever the extractor classifies as "the trigger text". This keeps namespaced invocations like `/skill-map:explore` or `@my-plugin/foo` comparable in their intended form.
496
+ Characters outside the separator set that are not letters or digits (e.g. `/`, `@`, `:`, `.`) are **preserved**. Stripping them is the extractor's concern, not the normalizer's; the normalizer operates on whatever the extractor classifies as "the trigger text". This keeps namespaced invocations like `/skill-map:explore` or `@my-plugin/foo` comparable in intended form.
497
497
 
498
498
  #### Examples
499
499
 
@@ -510,26 +510,26 @@ Characters outside the separator set that are not letters or digits (e.g. `/`, `
510
510
 
511
511
  ### Analyzer ↔ Action relationship (Modelo B)
512
512
 
513
- The "which Action resolves this analyzer's findings?" relationship is declared from the **Action** side, not the Analyzer side (the `Analyzer.recommendedActions` map was retired with the structure-as-truth refactor). An Action's `precondition.analyzerIds: string[]` lists the qualified ids of the analyzers whose findings it is intended to resolve. The UI joins on this field: when an analyzer emitted against the focused node, the inspector surfaces every Action whose `precondition.analyzerIds` includes that analyzer, under "Recommended for issues", alongside the always-applicable list driven by the rest of the Action's `precondition`.
513
+ The "which Action resolves this analyzer's findings?" relationship is declared from the **Action** side, not the Analyzer side (the `Analyzer.recommendedActions` map was retired with the structure-as-truth refactor). An Action's `precondition.analyzerIds: string[]` lists the qualified ids of the analyzers whose findings it resolves. The UI joins on this field: when an analyzer emitted against the focused node, the inspector surfaces every Action whose `precondition.analyzerIds` includes that analyzer, under "Recommended for issues", alongside the always-applicable list driven by the rest of the Action's `precondition`.
514
514
 
515
- The two surfaces stay distinct: the `kind` / `provider` sub-filters answer "which nodes does this Action apply to?" (evaluated continuously against the focused node); `analyzerIds` answers "when which analyzer fires is this Action the natural fix?" (surfaces only on nodes the named analyzer emitted against). Project-level cleanup verbs (orphan file prune, contribution relink) are CLI commands, not Actions, and are NOT linked through this field. Actions resolving deliberate user declarations rather than fixable problems omit `analyzerIds`.
515
+ The two surfaces stay distinct: `kind` / `provider` sub-filters answer "which nodes does this Action apply to?" (evaluated continuously against the focused node); `analyzerIds` answers "when which analyzer fires is this Action the natural fix?" (surfaces only on nodes the named analyzer emitted against). Project-level cleanup verbs (orphan file prune, contribution relink) are CLI commands, not Actions, and are NOT linked through this field. Actions that resolve deliberate user declarations rather than fixable problems omit `analyzerIds`.
516
516
 
517
517
  ### Hook · curated trigger set
518
518
 
519
- Hooks subscribe declaratively to a curated set of kernel lifecycle events and react to them. Reaction-only by design: a hook cannot mutate the pipeline, block emission, or alter outputs. The hookable trigger set is intentionally small, ten events out of the full [`job-events.md`](./job-events.md) catalog. Eight are pipeline-driven (emitted from inside `runScan`); two (`boot`, `shutdown`) are CLI-process-driven (emitted by the driving binary before / after the verb runs, fire-and-forget so `process.exit` is never blocked). Other events (per-node `scan.progress`, `model.delta`, `run.*`, `job.claimed`, `job.callback.received`) are deliberately NOT hookable: too verbose for a reactive surface, internal to the runner, or covered elsewhere. Declaring a trigger outside the curated set yields `invalid-manifest` at load time.
519
+ Hooks subscribe declaratively to a curated set of kernel lifecycle events and react. Reaction-only by design: a hook cannot mutate the pipeline, block emission, or alter outputs. The hookable trigger set is intentionally small, ten events out of the full [`job-events.md`](./job-events.md) catalog. Eight are pipeline-driven (emitted from inside `runScan`); two (`boot`, `shutdown`) are CLI-process-driven (emitted by the driving binary before / after the verb runs, fire-and-forget so `process.exit` is never blocked). Other events (per-node `scan.progress`, `model.delta`, `run.*`, `job.claimed`, `job.callback.received`) are deliberately NOT hookable: too verbose for a reactive surface, internal to the runner, or covered elsewhere. A trigger outside the curated set yields `invalid-manifest` at load time.
520
520
 
521
521
  | Trigger | When it fires | Payload (key fields) | Hook scope |
522
522
  |---|---|---|---|
523
- | `boot` | Once per CLI process invocation, BEFORE the verb routes. The dispatcher AWAITS subscribed hooks so anything they print lands above the verb's output (the `core/update-check` banner relies on this); a slow hook therefore delays the first verb paint. The dispatcher catches every hook error so a buggy hook never prevents the verb from running, it can only delay it. Use sparingly. | `argv: string[]` (the routed argv slice the CLI is about to parse). | Boot-time output that must appear above the verb (the `core/update-check` banner), pre-flight checks, telemetry warm-up. |
523
+ | `boot` | Once per CLI process invocation, BEFORE the verb routes. The dispatcher AWAITS subscribed hooks so anything they print lands above the verb's output (the `core/update-check` banner relies on this); a slow hook delays the first verb paint. Errors are caught so a buggy hook never prevents the verb from running, only delays it. Use sparingly. | `argv: string[]` (the routed argv slice the CLI is about to parse). | Boot-time output that must appear above the verb (the `core/update-check` banner), pre-flight checks, telemetry warm-up. |
524
524
  | `scan.started` | Once at the start of every `sm scan` invocation. | `roots: string[]`. | Pre-scan setup (cache warm-up, telemetry init). |
525
525
  | `scan.completed` | Once at the end of every `sm scan` invocation. | `stats: { filesWalked, nodesCount, linksCount, issuesCount, durationMs }`. | Post-scan reaction (Slack notification, CI gate, summary). |
526
- | `extractor.completed` | Once per registered Extractor, after the full walk completes. Aggregated, NOT per-node. | `extractorId: string` (qualified). | Per-Extractor metrics, audit. |
527
- | `analyzer.completed` | Once per Analyzer, after every issue has been validated. | `analyzerId: string` (qualified). | Per-Analyzer alerting, downstream tooling. |
528
- | `action.completed` | Once per Action invocation, after the report has been recorded. | `actionId: string` (qualified), `node`, `jobResult`. | Per-Action notification, integration glue. |
526
+ | `extractor.completed` | Once per registered Extractor, after the full walk. Aggregated, NOT per-node. | `extractorId: string` (qualified). | Per-Extractor metrics, audit. |
527
+ | `analyzer.completed` | Once per Analyzer, after every issue is validated. | `analyzerId: string` (qualified). | Per-Analyzer alerting, downstream tooling. |
528
+ | `action.completed` | Once per Action invocation, after the report is recorded. | `actionId: string` (qualified), `node`, `jobResult`. | Per-Action notification, integration glue. |
529
529
  | `job.spawning` | Pre-spawn of a runner subprocess (job subsystem; Step 10). | `jobId`, `actionId`, spawn metadata. | Pre-flight checks, audit logging. |
530
530
  | `job.completed` | Once per job that finishes successfully (job subsystem; Step 10). Same payload shape as the [`job-events.md`](./job-events.md) entry of the same name. | See [`job-events.md` §Event catalog](./job-events.md#event-catalog). | Most common Hook surface (notifications, retries, billing). |
531
531
  | `job.failed` | Once per job that fails (job subsystem; Step 10). Same payload shape as the [`job-events.md`](./job-events.md) entry of the same name. | See [`job-events.md` §Event catalog](./job-events.md#event-catalog). | Alerting, retry triggers. |
532
- | `shutdown` | Once per CLI process invocation, AFTER the verb returns its exit code and BEFORE `process.exit`. The dispatcher awaits subscribed hooks so they finish before the process terminates, but every hook MUST be fast (the user already saw the verb's output and is waiting for the prompt back). The dispatcher catches every hook error so a buggy hook never alters the verb's exit code; it can only delay the exit. | `exitCode: number` (the verb's resolved exit code, `0..5`). | Cleanup, post-run telemetry, the `core/update-check` banner. |
532
+ | `shutdown` | Once per CLI process invocation, AFTER the verb returns its exit code and BEFORE `process.exit`. The dispatcher awaits subscribed hooks so they finish before the process terminates, but every hook MUST be fast (the user already saw the verb's output and waits for the prompt back). Errors are caught so a buggy hook never alters the verb's exit code, only delays the exit. | `exitCode: number` (the verb's resolved exit code, `0..5`). | Cleanup, post-run telemetry, the `core/update-check` banner. |
533
533
 
534
534
  A hook MAY narrow further with an optional declarative `filter` map: keys are payload field paths (top-level only in v0.x); values are the literal expected match. The dispatcher walks `event.data` for each declared key and short-circuits the invocation when any value disagrees. Examples:
535
535
 
@@ -539,12 +539,12 @@ A hook MAY narrow further with an optional declarative `filter` map: keys are pa
539
539
 
540
540
  #### Mode semantics
541
541
 
542
- - **Deterministic** (default): the hook's `on(ctx)` runs in-process during the dispatch of the matching event, synchronously between the event's emission and the next pipeline step. Errors are caught by the dispatcher (logged through a synthetic `extension.error` event with kind `hook-error`) and NEVER block the main pipeline. A buggy hook degrades gracefully, the scan continues.
542
+ - **Deterministic** (default): the hook's `on(ctx)` runs in-process during dispatch of the matching event, synchronously between the event's emission and the next pipeline step. Errors are caught by the dispatcher (logged through a synthetic `extension.error` event with kind `hook-error`) and NEVER block the main pipeline. A buggy hook degrades gracefully and the scan continues.
543
543
  - **Probabilistic**: the hook is enqueued as a job. Until the job subsystem ships at Step 10, probabilistic hooks load but skip dispatch with a stderr advisory. The hook still surfaces in `sm plugins list` / `sm plugins doctor`; it just does not fire today.
544
544
 
545
545
  #### Cross-extension impact
546
546
 
547
- Hooks introduce no new persisted state and do NOT participate in the deterministic scan cache (A.9). A scan that re-runs against an unchanged corpus dispatches `scan.started` / `scan.completed` exactly as before; subscribed hooks fire on every scan regardless of cache hit / miss. Hooks that need cache-aware behaviour MUST inspect their own state via `ctx.store` (declared in their plugin's manifest).
547
+ Hooks introduce no new persisted state and do NOT participate in the deterministic scan cache (A.9). A re-scan against an unchanged corpus dispatches `scan.started` / `scan.completed` as before; subscribed hooks fire on every scan regardless of cache hit / miss. Hooks needing cache-aware behaviour MUST inspect their own state via `ctx.store` (declared in the plugin's manifest).
548
548
 
549
549
  ### Contract analyzers
550
550
 
@@ -557,7 +557,7 @@ Hooks introduce no new persisted state and do NOT participate in the determinist
557
557
  ### Locality
558
558
 
559
559
  - **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.
560
- - **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.
560
+ - **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`, subscribing to `shutdown` to run the once-per-day "update available" probe + banner that lived on the CLI entry path before the Hook kind had concrete consumers. Loaded from `src/extensions/`, these are indistinguishable from plugin-supplied extensions to the kernel.
561
561
 
562
562
  ---
563
563
 
@@ -620,9 +620,9 @@ The CLI, Server, and Skill driving adapters are **peers**. None depends on anoth
620
620
  - The Skill agent MUST NOT depend on the Server (it can be used offline).
621
621
  - The CLI MUST NOT embed HTTP logic.
622
622
 
623
- All three consume the same kernel API. Any use case a driving adapter needs MUST be available as a kernel function, if it isn't, the gap is a kernel bug, not a driving-adapter workaround.
623
+ All three consume the same kernel API. Any use case a driving adapter needs MUST be available as a kernel function; if it isn't, the gap is a kernel bug, not a driving-adapter workaround.
624
624
 
625
- This is what makes "CLI-first" a coherent analyzer: every CLI verb is a kernel function call. The UI does not reimplement business logic; it calls the same functions.
625
+ This makes "CLI-first" coherent: every CLI verb is a kernel function call. The UI does not reimplement business logic; it calls the same functions.
626
626
 
627
627
  ---
628
628
 
@@ -637,15 +637,15 @@ This is what makes "CLI-first" a coherent analyzer: every CLI verb is a kernel f
637
637
  | 3 | `project-local` | `<cwd>/.skill-map/settings.local.json` | **Gitignored**, values are per-checkout, never travel via the repo. |
638
638
  | 4 | `override` | Caller-supplied (env vars, CLI flags). | Process-scoped, ephemeral. |
639
639
 
640
- The merge is per dot-path: a value declared at a higher layer replaces the value at lower layers; objects recurse, arrays replace. The loader records which layer last wrote each key in a `sources` map so `sm config show --source` can attribute every effective value.
640
+ The merge is per dot-path: a value at a higher layer replaces the value at lower layers; objects recurse, arrays replace. The loader records which layer last wrote each key in a `sources` map so `sm config show --source` can attribute every effective value.
641
641
 
642
642
  Only layer 2 (`project`) travels via the shared repo, so values landing in `project` are part of the contract every collaborator inherits. Layers 1, 3, 4 carry **per-machine / per-checkout state** that never leaves the project.
643
643
 
644
- Skill-map deliberately has **no user-scope config layer**: there is no merge of `$HOME` state on top of the project. The CLI honours the principle "never read `$HOME` by default" (see `cli-contract.md` §Scope is always project-local). The narrow exception, `~/.skill-map/settings.json`, holds genuinely per-machine preferences (the update-check toggle + its throttle bookkeeping today; future locale / theme) but is **NOT** part of the config layer system: it is read directly by the module that owns the feature, never merged into the project layers above. See `cli-contract.md` §User-settings file for the contract.
644
+ Skill-map deliberately has **no user-scope config layer**: no `$HOME` state merges on top of the project. The CLI honours "never read `$HOME` by default" (see `cli-contract.md` §Scope is always project-local). The narrow exception, `~/.skill-map/settings.json`, holds genuinely per-machine preferences (the update-check toggle + its throttle bookkeeping today; future locale / theme) but is **NOT** part of the config layer system: it is read directly by the module that owns the feature, never merged into the project layers. See `cli-contract.md` §User-settings file.
645
645
 
646
646
  ### Per-key locality
647
647
 
648
- One locality class constrains which layers a given key MAY live in. It is enforced in code (reference impl: `core/config/helper.ts`), not in the JSON Schema, the schema stays additive so older settings files keep validating even when a key is reclassified.
648
+ One locality class constrains which layers a given key MAY live in. Enforced in code (reference impl: `core/config/helper.ts`), not in the JSON Schema; the schema stays additive so older settings files keep validating even when a key is reclassified.
649
649
 
650
650
  - **`PROJECT_LOCAL_ONLY_KEYS`**, keys describing per-user-per-project preferences. Valid in layers 1, 3, 4. **Stripped (with a warning) from layer 2 (`project`)** because the value is inherently per-user and must not be shared via the committed repo. Writes target `project-local` (`<cwd>/.skill-map/settings.local.json`); `sm config set` rejects writes to `project` for these keys with a directed error.
651
651
 
@@ -655,17 +655,17 @@ One locality class constrains which layers a given key MAY live in. It is enforc
655
655
 
656
656
  Both describe disk access the local operator opted into; sharing them via the repo would silently expand every collaborator's scan surface to paths that only make sense on the original author's machine.
657
657
 
658
- Adding a new entry is a behaviour change for older installs that wrote the key into a committed file, the value gets stripped at read time. The changeset that adds the entry MUST document the migration.
658
+ Adding a new entry is a behaviour change for older installs that wrote the key into a committed file: the value gets stripped at read time. The changeset adding the entry MUST document the migration.
659
659
 
660
660
  ### Extension settings resolution
661
661
 
662
- Plugin extensions declare user-configurable `settings` in their manifest (per-extension, see `plugin-author-guide.md` §Settings); the operator's values for them live in the config tree under `plugins.<pluginId>.extensions.<extId>.settings.<settingId>` and therefore flow through the same four-layer merge as any other key. The kernel's settings resolver runs once per scan, while composing the enabled extensions: for each declared setting it takes the manifest `default`, overlays the merged config value, and validates the result against the input-type's value schema (`input-types.schema.json#/$defs/ISettingDeclaration`); a value that fails validation falls back to the default with a warning, so the scan never aborts on a bad setting. The resolved object reaches the extension's runtime methods as `ctx.settings.<settingId>`. The `project-config.schema.json` keeps the `settings` object permissive (`additionalProperties: true`) on purpose: the static schema cannot know which input-type a given `settingId` picked, so per-value validation is the resolver's responsibility, not AJV's. `secret`-typed settings are still config-layer values, but the kernel forces them into the project-local layer (`settings.local.json`, gitignored), never the committed `settings.json`, the dynamic equivalent of `PROJECT_LOCAL_ONLY_KEYS` (the destination follows the declared type, not a fixed key list). There is no encryption in v1: the protection is that the value never travels via the shared repo (see `input-types.schema.json#/$defs/Setting_Secret`).
662
+ Plugin extensions declare user-configurable `settings` in their manifest (per-extension, see `plugin-author-guide.md` §Settings); the operator's values live in the config tree under `plugins.<pluginId>.extensions.<extId>.settings.<settingId>` and flow through the same four-layer merge as any other key. The kernel's settings resolver runs once per scan while composing the enabled extensions: for each declared setting it takes the manifest `default`, overlays the merged config value, and validates the result against the input-type's value schema (`input-types.schema.json#/$defs/ISettingDeclaration`); a value that fails falls back to the default with a warning, so the scan never aborts on a bad setting. The resolved object reaches the extension's runtime methods as `ctx.settings.<settingId>`. `project-config.schema.json` keeps the `settings` object permissive (`additionalProperties: true`) on purpose: the static schema cannot know which input-type a given `settingId` picked, so per-value validation is the resolver's responsibility, not AJV's. `secret`-typed settings are config-layer values, but the kernel forces them into the project-local layer (`settings.local.json`, gitignored), never the committed `settings.json`, the dynamic equivalent of `PROJECT_LOCAL_ONLY_KEYS` (destination follows the declared type, not a fixed key list). No encryption in v1: the protection is the value never travels via the shared repo (see `input-types.schema.json#/$defs/Setting_Secret`).
663
663
 
664
664
  ---
665
665
 
666
666
  ## Annotation system
667
667
 
668
- Skill-map's own metadata layer (versioning, supersession, provenance, taxonomy, docs) lives in **co-located YAML sidecars** with extension `.sm`, in the same directory as the markdown node they annotate. Vendor files (`.claude/agents/foo.md`, `.cursor/analyzers/bar.mdc`, …) stay untouched; the sidecar (`foo.sm` / `bar.sm`) IS skill-map's "annotations file" for that node, every key under it is, conceptually, an annotation. The YAML root organizes those annotations into structural blocks (identity, the curated annotations catalog, audit timestamps, settings, plugin namespaces); the file as a whole is the annotation surface.
668
+ Skill-map's own metadata layer (versioning, supersession, provenance, taxonomy, docs) lives in **co-located YAML sidecars** with extension `.sm`, in the same directory as the markdown node they annotate. Vendor files (`.claude/agents/foo.md`, `.cursor/analyzers/bar.mdc`, …) stay untouched; the sidecar (`foo.sm` / `bar.sm`) IS skill-map's "annotations file" for that node, every key under it conceptually an annotation. The YAML root organizes them into structural blocks (identity, the curated annotations catalog, audit timestamps, settings, plugin namespaces); the file as a whole is the annotation surface.
669
669
 
670
670
  Two schemas describe the wire shape:
671
671
 
@@ -674,9 +674,9 @@ Two schemas describe the wire shape:
674
674
 
675
675
  ### Identity and drift
676
676
 
677
- `identity` carries `path` (scope-root-relative, matches the canonical Node identifier in [`schemas/node.schema.json`](./schemas/node.schema.json)) plus `bodyHash` and `frontmatterHash`. Both hashes are sha256 over the kernel's canonical form of the markdown body (post-frontmatter bytes) and frontmatter (YAML re-emitted via `js-yaml dump` with `sortKeys: true`, `lineWidth: -1`, `noRefs: true`, `noCompatMode: true`); each sidecar captures the values the kernel saw at the moment it was last written.
677
+ `identity` carries `path` (scope-root-relative, matches the canonical Node identifier in [`schemas/node.schema.json`](./schemas/node.schema.json)) plus `bodyHash` and `frontmatterHash`. Both hashes are sha256 over the kernel's canonical form of the markdown body (post-frontmatter bytes) and frontmatter (YAML re-emitted via `js-yaml dump` with `sortKeys: true`, `lineWidth: -1`, `noRefs: true`, `noCompatMode: true`); each sidecar captures the values the kernel saw when last written.
678
678
 
679
- At scan time the kernel re-computes the live hashes and compares against the stored ones. Mismatch in either is **drift**, surfaced via the built-in `annotation-stale` analyzer (severity `info`, never blocking, soft mode by design: drift is informational, the footer chip is a neutral clock). A `.sm` whose `identity.path` no longer points at an existing `.md` is **orphan**, surfaced via the built-in `annotation-orphan` analyzer (also `warning`). Drift state is **derived**, never stored, pure function over existing data, no flag to drift between flag and reality.
679
+ At scan time the kernel re-computes the live hashes and compares against the stored ones. Mismatch in either is **drift**, surfaced via the built-in `annotation-stale` analyzer (severity `info`, never blocking, soft mode by design: drift is informational, the footer chip is a neutral clock). A `.sm` whose `identity.path` no longer points at an existing `.md` is **orphan**, surfaced via the built-in `annotation-orphan` analyzer (also `warning`). Drift state is **derived**, never stored, a pure function over existing data, so no flag can diverge from reality.
680
680
 
681
681
  ### Bump model
682
682
 
@@ -698,26 +698,30 @@ The Action stays pure (no IO). The kernel materializes the patch through the `Si
698
698
 
699
699
  ### Write consent
700
700
 
701
- Every `.sm` write, scaffold (`sm sidecar annotate`), hash-only update (`sm sidecar refresh`), bump (`sm bump`, `POST /api/sidecar/bump`), action dispatch (`POST /api/actions/:id` for any `.sm`-writing Action), or any future write surface, passes through `SidecarStore.applyPatch` (or, where the verb writes a fresh sidecar, the equivalent kernel-managed entry point). That single chokepoint MUST consult `allowEditSmFiles` (see §Config layering) before touching disk. Every write asks unless `allowEditSmFiles === true`; the dispatch / bump body carries two orthogonal consent fields, `confirm` (one-shot grant) and `always` (persist the grant):
701
+ Every `.sm` write, scaffold (`sm sidecar annotate`), hash-only update (`sm sidecar refresh`), bump (`sm bump`, `POST /api/sidecar/bump`), action dispatch (`POST /api/actions/:id` for any `.sm`-writing Action), or any future write surface, passes through `SidecarStore.applyPatch` (or, where the verb writes a fresh sidecar, the equivalent kernel-managed entry point).
702
+
703
+ **Project policy gate (evaluated first).** Before the consent ladder, the chokepoint consults the committed `allowSidecarWriters` policy (see §Config layering; default `true`, lives in the team-shared `project` layer). When `allowSidecarWriters === false` the kernel raises `ESidecarWritersForbiddenError` and refuses the write outright, regardless of `allowEditSmFiles` or any `confirm` / `always` signal: a team policy forbidding sidecar writers is a HARD gate a per-machine consent cannot override, and `--yes` does not bypass it. The same policy drops every Action declaring `writes: ['sidecar']` from the scan composer, so those Actions never project their `inspector.action.button` and the chokepoint deny is only a backstop. The CLI surfaces the error as a terminal message naming the policy; the BFF maps it to `403 sidecar-writers-forbidden`. The consent ladder below applies only when the policy permits writers (`allowSidecarWriters !== false`).
704
+
705
+ That single chokepoint MUST consult `allowEditSmFiles` (see §Config layering) before touching disk. Every write asks unless `allowEditSmFiles === true`; the dispatch / bump body carries two orthogonal consent fields, `confirm` (one-shot grant) and `always` (persist the grant):
702
706
 
703
707
  - `allowEditSmFiles === true` → write proceeds, no prompt (consent already persisted).
704
- - `allowEditSmFiles === false` AND the caller passes `always: true` → the kernel persists `allowEditSmFiles: true` to `<cwd>/.skill-map/settings.local.json` (layer `project-local`) and then performs the write. `always` **implies** `confirm`: the grant authorises this write too, so a body carrying `always: true` need not also set `confirm`.
705
- - `allowEditSmFiles === false` AND `confirm: true` (without `always`) → a **one-shot** grant. The kernel performs this write but persists **nothing**; the next write re-asks. Use this for "yes, just this once".
708
+ - `allowEditSmFiles === false` AND the caller passes `always: true` → the kernel persists `allowEditSmFiles: true` to `<cwd>/.skill-map/settings.local.json` (layer `project-local`), then performs the write. `always` **implies** `confirm`: the grant authorises this write too, so a body with `always: true` need not also set `confirm`.
709
+ - `allowEditSmFiles === false` AND `confirm: true` (without `always`) → a **one-shot** grant. The kernel performs this write but persists **nothing**; the next write re-asks. For "yes, just this once".
706
710
  - `allowEditSmFiles === false` AND both `confirm` and `always` missing / false → the kernel raises `EConsentRequiredError`. The driving adapter MUST translate it into a surface-appropriate prompt:
707
711
  - **CLI on a TTY**: interactive `confirm()` prompt offering "just this once" (re-invokes with `confirm: true`) vs. "always for this project" (re-invokes with `always: true`). Decline aborts without persisting the rejection.
708
712
  - **CLI without a TTY** (CI, scripts): exit with the standard "user input required" code and a message hinting `--yes`.
709
713
  - **BFF**: 412 `confirm-required` envelope (`{ ok: false, error: { code: 'confirm-required', message, details: { key: 'allowEditSmFiles' } } }`). The UI catches it, opens a confirm dialog with the same two choices, and on accept retries the original request with `{ confirm: true }` or `{ always: true }`.
710
714
 
711
- Declining the prompt persists **nothing**, neither a grant nor a rejection. It aborts the current operation but the next attempt re-asks. This is deliberate: a "no" today should not foreclose a "yes" tomorrow without the user having to hand-edit the settings file, and a one-shot `confirm` never silently enrols the project into unconditional writes.
715
+ Declining persists **nothing**, neither a grant nor a rejection. It aborts the current operation but the next attempt re-asks. Deliberate: a "no" today should not foreclose a "yes" tomorrow without hand-editing the settings file, and a one-shot `confirm` never silently enrols the project into unconditional writes.
712
716
 
713
- The flag lives in `project-local` (gitignored) so each collaborator consents independently; a single contributor's `always` never enrols their teammates without their knowledge.
717
+ The flag lives in `project-local` (gitignored) so each collaborator consents independently; a single contributor's `always` never enrols teammates without their knowledge.
714
718
 
715
719
  ### Plugin contributions
716
720
 
717
- Plugins extend the annotation surface via the optional `annotation` block on an extension manifest (`{ schema, ownership?, location? }`, inline JSON Schema, no `$ref` to external files). It is a **single** declaration per extension and **the contributed key is the extension's id** (its folder name); an extension that needs several keys splits into several extensions, one per key. Two location modes:
721
+ Plugins extend the annotation surface via the optional `annotation` block on an extension manifest (`{ schema, ownership?, location? }`, inline JSON Schema, no `$ref` to external files). It is a **single** declaration per extension and **the contributed key is the extension's id** (its folder name); an extension needing several keys splits into several extensions, one per key. Two location modes:
718
722
 
719
723
  - `location: 'namespaced'` (default), writes go to the plugin's `<plugin-id>:` block at the sidecar root. Default `ownership: 'shared'`. Plugins write to their own namespace without coordination; AJV validates the contributed value against the extension's declared schema.
720
- - `location: 'root'`, writes go to a top-level key of the sidecar (alongside `identity` / `annotations` / `settings` / `audit`). Requires `ownership: 'exclusive'` (claiming a root key is elevated trust). Two plugins claiming the same root key with `exclusive` is a **hard fatal** at orchestrator startup, the kernel refuses to boot rather than route writes ambiguously.
724
+ - `location: 'root'`, writes go to a top-level key (alongside `identity` / `annotations` / `settings` / `audit`). Requires `ownership: 'exclusive'` (claiming a root key is elevated trust). Two plugins claiming the same root key with `exclusive` is a **hard fatal** at orchestrator startup; the kernel refuses to boot rather than route writes ambiguously.
721
725
 
722
726
  The kernel exposes a runtime catalog (`Kernel.getRegisteredAnnotationKeys()`) listing every plugin-contributed key with its `pluginId`, `location`, `ownership`, and `schema`, consumed by the BFF (`GET /api/annotations/registered`) for UI autocomplete.
723
727
 
@@ -728,19 +732,19 @@ Two columns on `scan_nodes` source from the sidecar's `annotations:` block when
728
732
  - `scan_nodes.stability` ← `annotations.stability`
729
733
  - `scan_nodes.version` ← `annotations.version` (integer)
730
734
 
731
- A `scan_nodes.annotations_json` column carries the full parsed `annotations:` block; `sidecar_present` and `sidecar_status` carry the drift-detection state. The full sidecar overlay (parsed `annotations`, `status`, `present`) is exposed on `Node.sidecar` so REST and UI consumers see it as part of the canonical wire shape.
735
+ A `scan_nodes.annotations_json` column carries the full parsed `annotations:` block; `sidecar_present` and `sidecar_status` carry the drift-detection state. The full sidecar overlay (parsed `annotations`, `status`, `present`) is exposed on `Node.sidecar` as part of the canonical wire shape.
732
736
 
733
737
  ### Tags
734
738
 
735
- Tags are a **skill-map concept**, not a vendor field: no agent format (Claude, Cursor, Obsidian, the Agent Skills open standard, …) carries `tags` in its frontmatter, so skill-map keeps them where it owns the surface, the `.sm` sidecar.
739
+ Tags are a **skill-map concept**, not a vendor field: no agent format (Claude, Cursor, Obsidian, the Agent Skills open standard, …) carries `tags` in frontmatter, so skill-map keeps them where it owns the surface, the `.sm` sidecar.
736
740
 
737
741
  - **Tags** live in `sidecar.annotations.tags` (in the `.sm`). Curated annotation field declared on [`schemas/annotations.schema.json`](./schemas/annotations.schema.json). These are the tags whoever curates the project assigned to the node from their sidecar.
738
742
 
739
743
  Search and listings (`sm list --tag <name>`, UI faceted search) match this field: a hit returns the node. The UI renders them as chips on the node card and in the inspector.
740
744
 
741
- Persistence layer projects rows into a normalized [`scan_node_tags`](./db-schema.md#scan_node_tags) table at write time, one row per `(node_path, tag)` pair, so SQL queries can index on `(tag)` for `O(log n)` lookup. Replace-all per scan keeps the table in sync with the live sidecar state; deleting a tag from a sidecar removes its row on the next scan.
745
+ Persistence projects rows into a normalized [`scan_node_tags`](./db-schema.md#scan_node_tags) table at write time, one row per `(node_path, tag)` pair, so SQL queries index on `(tag)` for `O(log n)` lookup. Replace-all per scan keeps the table in sync with the live sidecar state; deleting a tag from a sidecar removes its row on the next scan.
742
746
 
743
- The wire shape (`/api/nodes` and `/api/nodes/:pathB64`) projects `node.tags = string[]`. The kernel `Node` interface (TypeScript) does NOT carry `tags`, consumers that walk the canonical source read `node.sidecar.annotations.tags` directly (consistent with the post-decision-#2 posture of "no Node-level denormalisations").
747
+ The wire shape (`/api/nodes` and `/api/nodes/:pathB64`) projects `node.tags = string[]`. The kernel `Node` interface (TypeScript) does NOT carry `tags`; consumers walking the canonical source read `node.sidecar.annotations.tags` directly (consistent with the post-decision-#2 posture of "no Node-level denormalisations").
744
748
 
745
749
  ### Stability
746
750
 
@@ -762,7 +766,7 @@ The **`null`-as-delete sentinel** in `SidecarStore.applyPatch` is an internal co
762
766
 
763
767
  ## View contribution system
764
768
 
765
- Sibling system to the annotation contributions above. Both let plugins extend the surface the kernel exposes; the difference is **where the data lives and what it drives**.
769
+ Sibling system to the annotation contributions above. Both let plugins extend the surface the kernel exposes; the difference is **where the data lives and what it drives**:
766
770
 
767
771
  | | Annotation contributions | View contributions |
768
772
  |---|---|---|
@@ -780,11 +784,11 @@ Two schemas describe the wire shape:
780
784
 
781
785
  ### Identity
782
786
 
783
- Each view contribution is identified by the qualified id `<pluginId>/<extensionId>/<contributionId>`. The plugin author declares contributions in the extension manifest under `ui: Record<string, IViewContribution>` (renamed from `viewContributions` with the structure-as-truth refactor); the loader composes the qualified id from the plugin id, the extension id, and the Record key. The runtime catalog aggregated by `Kernel.getRegisteredViewContributions()` keeps the original `viewContributions` name, only the manifest-side field changed.
787
+ Each view contribution is identified by the qualified id `<pluginId>/<extensionId>/<contributionId>`. The plugin author declares contributions in the extension manifest under `ui: Record<string, IViewContribution>` (renamed from `viewContributions` with the structure-as-truth refactor); the loader composes the qualified id from the plugin id, extension id, and Record key. The runtime catalog aggregated by `Kernel.getRegisteredViewContributions()` keeps the original `viewContributions` name; only the manifest-side field changed.
784
788
 
785
789
  ### Manifest
786
790
 
787
- Each entry picks a `slot` name from the closed catalog and supplies presentation tuning. The slot fixes both the renderer and the payload shape, there is no separate "contract" abstraction:
791
+ Each entry picks a `slot` name from the closed catalog and supplies presentation tuning. The slot fixes both the renderer and the payload shape; there is no separate "contract" abstraction:
788
792
 
789
793
  ```jsonc
790
794
  {
@@ -804,19 +808,19 @@ Each entry picks a `slot` name from the closed catalog and supplies presentation
804
808
  }
805
809
  ```
806
810
 
807
- The plugin author picks ONE slot per contribution; that single decision determines where the data renders, what payload shape `ctx.emitContribution(...)` must produce, and which Angular component draws it. Seven manifest fields per contribution (`slot`, `label?`, `tooltip?`, `icon?`, `emptyText?`, `emitWhenEmpty?`, `priority?`) + the slot catalog page is the entire mental model. See [`plugin-author-guide.md`](./plugin-author-guide.md) §View contributions for worked examples.
811
+ The plugin author picks ONE slot per contribution; that single decision determines where the data renders, what payload shape `ctx.emitContribution(...)` must produce, and which Angular component draws it. Seven manifest fields per contribution (`slot`, `label?`, `tooltip?`, `icon?`, `emptyText?`, `emitWhenEmpty?`, `priority?`) plus the slot catalog page is the entire mental model. See [`plugin-author-guide.md`](./plugin-author-guide.md) §View contributions for worked examples.
808
812
 
809
- The six `inspector.body.panel.*` slots render grouped **one collapsible section per plugin** in the inspector body (titled by the trusted `pluginId`, collapsed by default); a plugin's bricks never land in another plugin's section. Two optional inspector-only ordering hints drive layout: a plugin-level `order` in `plugin.json` sorts the sections, an extension-level `order` (base extension manifest) sorts the bricks within a section. Both default to 100 and never affect execution order. They are denormalised onto each `contributionsRegistry` entry (`pluginOrder` / `extensionOrder`) so the UI applies them without a second round-trip.
813
+ The six `inspector.body.panel.*` slots render grouped **one collapsible section per plugin** in the inspector body (titled by the trusted `pluginId`, collapsed by default); a plugin's bricks never land in another plugin's section. Two optional inspector-only ordering hints drive layout: a plugin-level `order` in `plugin.json` sorts sections, an extension-level `order` (base extension manifest) sorts bricks within a section. Both default to 100 and never affect execution order. They are denormalised onto each `contributionsRegistry` entry (`pluginOrder` / `extensionOrder`) so the UI applies them without a second round-trip.
810
814
 
811
815
  ### Settings
812
816
 
813
- Plugin user-configurable settings live **on each extension's manifest** (structure-as-truth) in `settings: Record<string, ISettingDeclaration>` (see [`schemas/extensions/base.schema.json`](./schemas/extensions/base.schema.json) and [`schemas/input-types.schema.json`](./schemas/input-types.schema.json)). Each setting picks an input-type from the closed catalog (`string-list`, `single-string`, `boolean-flag`, `integer`, `enum-pick`, `enum-multipick`, `path-glob`, `regex`, `secret`, `key-value-list`). The kernel exposes resolved settings via `ctx.settings.<settingId>` to the extension's runtime methods (`extract`, `evaluate`, `invoke`, etc.); the UI generates a form per declaration; the CLI's `sm plugins config <plugin>/<extension>` exposes the same surface. Plugin-level settings are no longer supported; the field was moved from `plugin.json` to each extension that consumes it.
817
+ Plugin user-configurable settings live **on each extension's manifest** (structure-as-truth) in `settings: Record<string, ISettingDeclaration>` (see [`schemas/extensions/base.schema.json`](./schemas/extensions/base.schema.json) and [`schemas/input-types.schema.json`](./schemas/input-types.schema.json)). Each setting picks an input-type from the closed catalog (`string-list`, `single-string`, `boolean-flag`, `integer`, `enum-pick`, `enum-multipick`, `path-glob`, `regex`, `secret`, `key-value-list`). The kernel exposes resolved settings via `ctx.settings.<settingId>` to the extension's runtime methods (`extract`, `evaluate`, `invoke`, etc.); the UI generates a form per declaration; the CLI's `sm plugins config <plugin>/<extension>` exposes the same surface. Plugin-level settings are no longer supported; the field moved from `plugin.json` to each extension that consumes it.
814
818
 
815
- Settings are read once at extension invocation; changing a setting requires `sm scan` to re-emit affected contributions. The UI surfaces a "settings changed, rescan needed" indicator when the manifest detects mismatch; live re-emission is explicitly out of scope (rescan-required is a stability decision per `ROADMAP.md` §UI contribution system D4).
819
+ Settings are read once at extension invocation; changing one requires `sm scan` to re-emit affected contributions. The UI surfaces a "settings changed, rescan needed" indicator on mismatch; live re-emission is explicitly out of scope (a stability decision per `ROADMAP.md` §UI contribution system D4).
816
820
 
817
821
  ### Runtime catalog
818
822
 
819
- The kernel exposes a runtime catalog (`Kernel.getRegisteredViewContributions()`) listing every plugin-contributed view contribution with its `pluginId`, `extensionId`, `contributionId`, `slot`, and the manifest-declared `label` / `tooltip` / `icon` / `emptyText` / `emitWhenEmpty`. The catalog is built once at boot from every loaded extension's `ui` map (renamed from `viewContributions` with the structure-as-truth refactor), AJV-validated, and frozen, same lifecycle as `getRegisteredAnnotationKeys()`.
823
+ The kernel exposes a runtime catalog (`Kernel.getRegisteredViewContributions()`) listing every plugin-contributed view contribution with its `pluginId`, `extensionId`, `contributionId`, `slot`, and the manifest-declared `label` / `tooltip` / `icon` / `emptyText` / `emitWhenEmpty`. Built once at boot from every loaded extension's `ui` map, AJV-validated, and frozen, same lifecycle as `getRegisteredAnnotationKeys()`.
820
824
 
821
825
  Analyzers see the catalog through `IAnalyzerContext.viewContributions` so cross-cutting checks (`core/unknown-slot`, `core/contribution-orphan`) can reason about emissions.
822
826
 
@@ -833,9 +837,9 @@ ctx.emitContribution(contributionId, payload);
833
837
  ctx.emitContribution(nodePath, contributionId, payload);
834
838
  ```
835
839
 
836
- 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-enum link kinds. Both Extractor and Analyzer emissions land in the same `scan_contributions` rows; the row's `extension_id` records which kind of extension produced it.
840
+ 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-enum link kinds. Both Extractor and Analyzer emissions land in the same `scan_contributions` rows; the row's `extension_id` records which kind produced it.
837
841
 
838
- 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).
842
+ The Extractor-emit signature binds `nodePath` implicitly (the extractor runs per-node, `ctx.node.path` the only sensible target). The Analyzer-emit signature requires the analyzer to declare the target node explicitly because Analyzers see the full graph and may emit for any subset of nodes; the canonical use case is an analyzer deriving per-node values from cross-graph aggregations (`core/link-counter` projects `linksOutCount` / `linksInCount` this way).
839
843
 
840
844
  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.
841
845
 
@@ -855,16 +859,16 @@ A new table `scan_contributions` (see [`db-schema.md`](./db-schema.md) §scan_co
855
859
 
856
860
  PK `(plugin_id, extension_id, node_path, contribution_id)` so re-emission upserts. Index on `node_path` (inspector lazy-fetch + orphan sweep) and on `plugin_id` (catalog sweep + `purgeByPlugin`).
857
861
 
858
- **NOT pure replace-all** (the way `scan_links` / `scan_issues` are). The watcher's cached pass leaves the contributions buffer empty for cached nodes, the orchestrator skips `extract()` when the per-(node, extractor) cache hits, so no `emitContribution` fires. A naive wipe-all would silently drop the prior valid rows on every watcher boot. The persist runs four passes inside the same transaction:
862
+ **NOT pure replace-all** (the way `scan_links` / `scan_issues` are). The watcher's cached pass leaves the contributions buffer empty for cached nodes: the orchestrator skips `extract()` on a per-(node, extractor) cache hit, so no `emitContribution` fires. A naive wipe-all would silently drop the prior valid rows on every watcher boot. The persist runs four passes inside the same transaction:
859
863
 
860
864
  1. **Orphan sweep**, drops every row whose `node_path` is NOT in the current live node set. Disappeared nodes lose their contributions.
861
- 2. **Catalog sweep**, drops every row whose qualified id `(pluginId, extensionId, contributionId)` is NOT in the registered runtime catalog (uninstalled-on-disk plugins, removed contributions). Disabled plugins are normally purged eagerly by `sm plugins disable` (see `StoragePort.contributions.purgeByPlugin`); the catalog sweep here is the fallback for the rare "config flipped between scans without going through the CLI" case.
862
- 3. **Per-tuple sweep**, for every `(pluginId, extensionId, nodePath)` tuple where the extension actually RAN against that node in this scan (extractor cache miss, OR analyzer, analyzers always run), drop any row carrying that triple whose `contribution_id` is NOT present in the buffer for that triple. This catches the "extractor used to emit, now does not" case (e.g. a node body change that removes the trigger). Cached-extractor tuples are NOT in the set, so their rows survive untouched.
865
+ 2. **Catalog sweep**, drops every row whose qualified id `(pluginId, extensionId, contributionId)` is NOT in the registered runtime catalog (uninstalled-on-disk plugins, removed contributions). Disabled plugins are normally purged eagerly by `sm plugins disable` (see `StoragePort.contributions.purgeByPlugin`); this sweep is the fallback for the rare "config flipped between scans without going through the CLI" case.
866
+ 3. **Per-tuple sweep**, for every `(pluginId, extensionId, nodePath)` tuple where the extension actually RAN against that node this scan (extractor cache miss, OR analyzer, analyzers always run), drop any row carrying that triple whose `contribution_id` is NOT in the buffer for that triple. This catches the "extractor used to emit, now does not" case (e.g. a body change that removes the trigger). Cached-extractor tuples are NOT in the set, so their rows survive untouched.
863
867
  4. **Upsert**, `INSERT ... ON CONFLICT DO UPDATE SET payload_json = excluded.payload_json, slot = excluded.slot` for every row in the buffer. PK conflict refreshes payload + `slot` + `emitted_at`.
864
868
 
865
- Cached nodes' rows survive untouched (still in the live set, still in the catalog, the (plugin, extension, node) tuple is not in the freshly-run set, no buffer hit). The next time the body changes, the orchestrator re-runs the extractor, the tuple lands in the freshly-run set, and either the upsert refreshes the row OR the per-tuple sweep drops it (when the extractor no longer emits for that node).
869
+ Cached nodes' rows survive untouched (still in the live set and catalog, the (plugin, extension, node) tuple not in the freshly-run set, no buffer hit). When the body next changes, the orchestrator re-runs the extractor, the tuple lands in the freshly-run set, and either the upsert refreshes the row OR the per-tuple sweep drops it (when the extractor no longer emits for that node).
866
870
 
867
- Empty buffer + non-empty live set = the cached-pass case (no-op). Empty buffer + empty live set = legacy fallback to wipe-all (cold start). Three `IPersistOptions` fields control which sweeps activate, absent values fall back to legacy behaviour (sweep skipped) so older callers keep working:
871
+ Empty buffer + non-empty live set = cached-pass (no-op). Empty buffer + empty live set = legacy wipe-all (cold start). Three `IPersistOptions` fields control which sweeps activate; absent values fall back to legacy behaviour (sweep skipped) so older callers keep working:
868
872
 
869
873
  - `livePaths?: ReadonlySet<string>`, gates the orphan sweep (1).
870
874
  - `registeredContributionKeys?: ReadonlySet<string>`, gates the catalog sweep (2). Element format: qualified id `<pluginId>/<extensionId>/<contributionId>`.
@@ -881,17 +885,17 @@ Endpoints under `/api/contributions/*`:
881
885
 
882
886
  The `inspector.action.button` contribution is **self-projected by the dispatching Action's own `project(ctx)`** (scan-time, deterministic), not by a separate projector Analyzer. The Action computes the per-node `enabled` / `disabledReason` and the prompt `options` / `defaultValue` from the live graph it receives, emits the button, and is itself the dispatch target. (This reverses the earlier "an Analyzer projects the button" shape; the projector Analyzer `core/tags` was removed and `core/annotation-stale` keeps only its badge + issue.) The slot dispatches to a generic Action endpoint, sibling of the single-node `POST /api/sidecar/bump`:
883
887
 
884
- - `POST /api/actions/:id`, dispatch a kernel Action by qualified id (`:id` is the `<plugin>/<action>` from the button payload's `actionId`). Body carries the target `nodePath`, the optional reserved `input` object (Steps 2+), and the consent fields `confirm` / `always` (see §Annotation system → Write consent) for `.sm`-writing Actions. The kernel resolves the Action in its registry (unknown id → 404), runs it against the node, and answers the action-result envelope `kind: 'action.applied'` (`{ value: { actionId, nodePath, report }, elapsedMs }`, see [`schemas/api/rest-envelope.schema.json`](./schemas/api/rest-envelope.schema.json)). `POST /api/sidecar/bump` remains the dedicated single-purpose route for `core/node-bump` (`kind: 'sidecar.bumped'`); the generic dispatch route shares the same action-result envelope variant.
888
+ - `POST /api/actions/:id`, dispatch a kernel Action by qualified id (`:id` is the `<plugin>/<action>` from the button payload's `actionId`). Body carries the target `nodePath`, the optional reserved `input` object (Steps 2+), and the consent fields `confirm` / `always` (see §Annotation system → Write consent) for `.sm`-writing Actions. The kernel resolves the Action (unknown id → 404), runs it against the node, and answers the action-result envelope `kind: 'action.applied'` (`{ value: { actionId, nodePath, report }, elapsedMs }`, see [`schemas/api/rest-envelope.schema.json`](./schemas/api/rest-envelope.schema.json)). `POST /api/sidecar/bump` remains the dedicated route for `core/node-bump` (`kind: 'sidecar.bumped'`); the generic dispatch route shares the same action-result envelope variant.
885
889
 
886
890
  Plus catalog embedding into every payload-bearing envelope:
887
891
 
888
- - `kindRegistry`, `providerRegistry`, and `contributionsRegistry` are siblings on the envelope (see schema). Built once per server boot, embedded into list (`nodes` / `links` / `issues` / `plugins`), single (`node`), and value (`config`) envelopes. Sentinel envelopes (`health` / `scan` / `graph`) and action-result envelopes (`sidecar.bumped`) and the catalog envelopes themselves (`annotations.registered` / `contributions.registered`) carry none of them. `providerRegistry` is the static boot catalog of registered Providers' identity; the dynamic active lens (current value + filesystem-detected candidates + the enabled `selectable` set) is served separately by `GET /api/active-provider`.
892
+ - `kindRegistry`, `providerRegistry`, and `contributionsRegistry` are siblings on the envelope (see schema). Built once per server boot, embedded into list (`nodes` / `links` / `issues` / `plugins`), single (`node`), and value (`config`) envelopes. Sentinel envelopes (`health` / `scan` / `graph`), action-result envelopes (`sidecar.bumped`), and the catalog envelopes themselves (`annotations.registered` / `contributions.registered`) carry none. `providerRegistry` is the static boot catalog of registered Providers' identity; the dynamic active lens (current value + filesystem-detected candidates + the enabled `selectable` set) is served separately by `GET /api/active-provider`.
889
893
 
890
894
  Plus per-node embedding on node responses:
891
895
 
892
896
  - `GET /api/nodes/:pathB64`, single-node `item.contributions[]` carries every emission for that node, regardless of `bff.maxBulkContributions`.
893
897
  - `GET /api/nodes` (bulk list), `items[].contributions[]` carries emissions for the page slice **only when** `limit ≤ bff.maxBulkContributions` (default and hard upper bound 200). When the page exceeds the cap, `items[].contributions` is omitted and `meta.contributionsOmitted: true` is set so the UI can lazy-fetch per node. The cap is documented but not promoted; tuning above 200 is unsupported.
894
- - `GET /api/scan`, the SPA's `CollectionLoaderService` hydrates from this endpoint on F5 / cold boot (single-fetch ScanResult); it MUST embed `contributions[]` per node alongside the standard fields, otherwise the inspector / card slot hosts have nothing to render until the next per-node fetch. Decoration is a single bulk `port.contributions.listForPaths(...)` round-trip after `scans.load()`, sibling of the per-node `isFavorite` decoration on the same route.
898
+ - `GET /api/scan`, the SPA's `CollectionLoaderService` hydrates from this endpoint on F5 / cold boot (single-fetch ScanResult); it MUST embed `contributions[]` per node alongside the standard fields, else the inspector / card slot hosts have nothing to render until the next per-node fetch. Decoration is a single bulk `port.contributions.listForPaths(...)` round-trip after `scans.load()`, sibling of the per-node `isFavorite` decoration on the same route.
895
899
 
896
900
  ### Isolation
897
901
 
@@ -910,14 +914,14 @@ Same honest-note posture as [`plugin-kv-api.md`](./plugin-kv-api.md): isolated a
910
914
 
911
915
  Two built-ins ship with the system to cover catalog evolution and rename edge cases:
912
916
 
913
- - **`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.
917
+ - **`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. AJV at manifest load already rejects unknown slots as `invalid-manifest`; this analyzer covers the soft-warning path when a plugin stays loaded across a catalog version bump.
914
918
  - **`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).
915
919
 
916
920
  ### Catalog versioning
917
921
 
918
922
  The catalog of slots and input-types evolves on its own cadence, independent of the spec version. Plugin manifests carry an optional `catalogCompat: string` (semver range) field at the root, parallel to `specCompat`. The kernel checks `semver.satisfies(catalogVersion, plugin.catalogCompat)` at load. Mismatch surfaces as `incompatible-catalog` plugin status (new entry in the load-status enum). Resolution: `sm plugins upgrade <id>` runs registered migrations from a closed kernel-side registry of `{ from, to, transform }` triples; auto-migration impossible → CLI exit ≠ 0 + UI dialog naming the offending slot / input-type.
919
923
 
920
- Pre-1.0 versioning analyzer (per [`AGENTS.md`](../AGENTS.md)): catalog breaking changes ship as minor bumps while in `0.y.z`; the first `1.0.0` is a deliberate stabilization moment, not a side-effect.
924
+ Pre-1.0 versioning analyzer (per [`AGENTS.md`](../AGENTS.md)): catalog breaking changes ship as minor bumps while in `0.y.z`; the first `1.0.0` is a deliberate stabilization moment, not a side effect.
921
925
 
922
926
  ### Stability
923
927
 
@@ -931,7 +935,7 @@ The **`ctx.emitContribution(id, payload)` signature** is stable. Adding new cont
931
935
 
932
936
  The **persistence shape** (`scan_contributions` columns) is stable; column additions are minor bumps. Renames or removals trigger a kernel migration.
933
937
 
934
- The **slot catalog ownership** is now spec-level (kernel + spec own the catalog jointly); the UI implementation may rearrange visual placement WITHOUT renaming a slot, the slot id is the public handle, the visual surface beneath it can evolve. Different driving adapters (UI, future TUI, `sm show --json`) MUST honour the same slot vocabulary; surface-level rendering policy stays adapter-specific (e.g. a TUI may render `card.title.right` as a prefix glyph instead of a right-side marker).
938
+ The **slot catalog ownership** is spec-level (kernel + spec own it jointly); the UI may rearrange visual placement WITHOUT renaming a slot, the slot id being the public handle while the visual surface beneath evolves. Different driving adapters (UI, future TUI, `sm show --json`) MUST honour the same slot vocabulary; surface-level rendering policy stays adapter-specific (e.g. a TUI may render `card.title.right` as a prefix glyph instead of a right-side marker).
935
939
 
936
940
  The **isolation honest-note** (accidents, not hostile code) is the same posture as [`plugin-kv-api.md`](./plugin-kv-api.md) and migrates together when worker-thread / iframe sandbox lands post-v1.0.
937
941