@skill-map/spec 0.34.0 → 0.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,206 @@
1
1
  # Spec changelog
2
2
 
3
+ ## 0.36.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 8ab68ed: Rename `core/field-unknown` to `core/annotation-field-unknown` so it
8
+ groups alphabetically with the other sidecar (`.sm`) annotation rules
9
+ (`core/annotation-orphan`, `core/annotation-stale`). The rule's job has
10
+ not changed: it still flags typos / unrecognised keys in sidecars and
11
+ emits a warn issue plus the same `alert` + `chip` view contributions
12
+ on `graph.node.alert` / `card.footer.right`.
13
+
14
+ `contribution-orphan` is intentionally NOT renamed: the `contribution`
15
+ namespace refers to view-slot rows in `scan_contributions` (runtime
16
+ data the analyzers emit for the UI), not to annotation fields in
17
+ sidecars. The two namespaces are distinct.
18
+
19
+ Pre-1.0 minor per `spec/versioning.md`: breaking rename of a public
20
+ qualified id referenced from `settings.json`, `--analyzers <id>` flags,
21
+ and the `analyzerId` filter on `GET /api/issues`. No behavioural
22
+ change, no DB schema change, no event payload shape change. Persisted
23
+ scans created with the old id regenerate cleanly on the next
24
+ `sm scan`.
25
+
26
+ ## User-facing
27
+
28
+ Renamed `core/field-unknown` to `core/annotation-field-unknown` so the
29
+ sidecar typo-guard rule groups with the other `core/annotation-*`
30
+ rules. Update references in `settings.json` or
31
+ `sm check --analyzers <id>` to the new name.
32
+
33
+ - 880fe3e: Rename 14 built-in extension ids to a consistent `<domain>-<detail>` pattern. The naming was inconsistent: 10 ids already followed the "area first, attribute after" shape (e.g. `annotation-orphan`, `link-conflict`) while 14 were inverted, redundant, or vague. All built-ins now agree.
34
+
35
+ Full rename map (`old qualified id` → `new qualified id`):
36
+
37
+ | Kind | Old | New |
38
+ | --------- | --------------------------------- | -------------------------- |
39
+ | action | `core/bump` | `core/node-bump` |
40
+ | action | `core/mark-superseded` | `core/node-supersede` |
41
+ | extractor | `core/tools-count` | `core/tools-counter` |
42
+ | extractor | `claude/slash` | `claude/slash-command` |
43
+ | analyzer | `core/broken-ref` | `core/reference-broken` |
44
+ | analyzer | `core/job-orphan-file` | `core/job-file-orphan` |
45
+ | analyzer | `core/link-counts` | `core/link-counter` |
46
+ | analyzer | `core/redundant-target-reference` | `core/reference-redundant` |
47
+ | analyzer | `core/reserved-name` | `core/name-reserved` |
48
+ | analyzer | `core/self-loop` | `core/link-self-loop` |
49
+ | analyzer | `core/stability` | `core/node-stability` |
50
+ | analyzer | `core/superseded` | `core/node-superseded` |
51
+ | analyzer | `core/unknown-field` | `core/field-unknown` |
52
+ | analyzer | `core/validate-all` | `core/schema-violation` |
53
+
54
+ The convention is now documented in `spec/plugin-author-guide.md` §Extension id shape. Counter-style extensions standardise on the `-counter` suffix (`link-counter`, `tools-counter`, `external-url-counter`).
55
+
56
+ CLI verb `sm bump` is **unchanged** (it remains the user-facing verb; the internal action id is what flipped to `core/node-bump`). The `BumpReport` JSON schema title also stays as `BumpReport`, the wire shape is unchanged.
57
+
58
+ Pre-1.0 minor per `spec/versioning.md`: breaking rename of public qualified ids referenced from `settings.json`, `--analyzers <id>` flags, `core/<id>` strings in plugin manifests, and the `analyzerId` filter on `GET /api/issues`. No behavioural change, no DB schema change, no event payload shape change. Persisted scans created with the old ids regenerate cleanly on the next `sm scan`.
59
+
60
+ ## User-facing
61
+
62
+ Renamed 14 built-in extension ids to a `<area>-<detail>` shape (e.g. `core/broken-ref` is now `core/reference-broken`). If you reference these by qualified id in `settings.json` or via `sm check --analyzers <id>`, update to the new names.
63
+
64
+ - 1b6e368: Honour per-extension toggles inside bundle-granularity plugins end-to-end. Closes the Phase 4b follow-up (commit `e45d2fd`) gap: BFF + Settings UI started accepting per-extension toggles for any granularity, but three call sites still treated bundle granularity as "one knob, every extension follows", so flipping an individual extension off (e.g. `claude/at-directive`) persisted to `config_plugins` and then did nothing on the next scan.
65
+
66
+ **Runtime (`src/`)**
67
+
68
+ - `core/runtime/plugin-runtime/resolver.ts`: `isBundleEntryEnabled` (built-ins) and `isPluginExtensionEnabled` (drop-ins) now compose the bundle id as a coarse kill-switch with the per-extension override layered on top. When the bundle row resolves to `false` every extension stays disabled regardless of per-extension overrides; when the bundle row resolves to `true` each extension respects its qualified-id override (default `true`). Doc rewritten, the stale "silently ignored, the granularity says this bundle is one knob" wording was the symptom that pointed at the bug.
69
+ - `cli/commands/plugins/shared.ts`: `extensionRowFromBuiltIn` (the row builder behind `sm plugins list / show / doctor`) now reports the same composed effective state instead of mirroring the bundle's `enabled` field verbatim.
70
+ - `core/runtime/__tests__/plugin-runtime-branches.spec.ts`: two new composer cases lock the contract, `(e)` `claude` enabled + `claude/at-directive=false` drops only the extractor, and `(f)` `claude=false` overrides any per-extension `true` override.
71
+
72
+ **UI (`ui/`)**
73
+
74
+ - `app/components/settings-modal/settings-plugins.utils.ts`: `buildStateFromPlugins` now seeds extension keys for both granularities (was: bundle-only seeded the bundle id and skipped the extensions, so the per-extension toggles in the Phase 4b modal defaulted to OFF in the buffer regardless of what the wire shape said about `ext.enabled`, then reverted to OFF on every apply round-trip).
75
+ - `models/api.ts`: `IPluginItemApi.extensions` doc updated, the comment still said "only when granularity === 'extension'" which the BFF stopped honouring in commit `e45d2fd`.
76
+ - `__tests__/settings-plugins.utils.spec.ts`: five new cases cover bundle+extensions, extension-only, bundle-disabled-with-ext-enabled, and failure-row exclusion.
77
+
78
+ **Spec (`spec/`)**
79
+
80
+ - `cli-contract.md`: `GET /api/plugins` row shape doc rewritten, `extensions[]` is emitted for any granularity; the per-extension `enabled` reflects the **preference** axis (DB > settings > default true) and the runtime composition with the bundle row is documented explicitly. `PATCH /api/plugins/:bundleId/extensions/:extensionId` now accepts any granularity and returns 404 (not 400) on an unknown extension id. The 400 `bad-query` enumeration in the error-codes section narrowed to the conditions that still apply.
81
+ - `plugin-author-guide.md` § Resolution order: rewritten to describe bundle-as-kill-switch + per-extension refinement explicitly, including the deliberate asymmetry between the CLI surface (`sm plugins enable/disable <bare-id>` stays coarse) and the UI / direct config-edit surface (qualified ids accepted, refine inside a bundle).
82
+ - `index.json` regenerated.
83
+
84
+ ## User-facing
85
+
86
+ In Settings, expanding a bundle plugin (claude, antigravity, openai, agent-skills) now shows the correct per-extension state and the toggles persist, the next scan honours them. `sm plugins list` reflects effective state too.
87
+
88
+ ## 0.35.0
89
+
90
+ ### Minor Changes
91
+
92
+ - de68f09: Soft-warn drift detection for the active provider lens. When `activeProvider` is set (whether by auto-detect on first scan, the interactive prompt for ambiguous markers, or `sm config set activeProvider <id>`), the runtime now persists the set of provider markers that existed on disk at the moment of the choice as `activeProviderMarkers` in `.skill-map/settings.json`. On every subsequent scan the bootstrap re-detects markers and diffs against this snapshot; when the diff is non-empty (new markers appeared, recorded markers disappeared), it emits ONE soft warn before the scan and continues with the cached lens.
93
+
94
+ **Motivation.** Today `activeProvider` wins silently forever, even when the project grows a new provider directory (e.g. adds `.codex/` after the choice was made under `claude`) or loses one (`.cursor/` deleted in a cleanup). The operator should at least notice. The friction of a soft warn is right: it surfaces the drift, points at the fix (`sm config set activeProvider <id>`), and gets out of the way. The warn is informational and never blocks the scan.
95
+
96
+ **Spec.** `spec/schemas/project-config.schema.json` declares the new optional `activeProviderMarkers` string array as internal-state, NOT normally hand-edited. `spec/architecture.md` §"Active-lens drift detection" documents the snapshot + diff + soft-warn contract.
97
+
98
+ **Backfill.** Legacy projects (existing `activeProvider` without a snapshot) lazily backfill on the next scan: the runtime 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.
99
+
100
+ **Atomicity.** The two writes (`activeProvider` + `activeProviderMarkers`) go through the same `writeConfigValue` helper as every other config mutation; each is atomic on its own, the pair is not transactional. A failure between the two writes leaves the file in the legacy "lens but no snapshot" shape, which the lazy backfill handles cleanly on the next scan.
101
+
102
+ **Tests.** `src/core/runtime/__tests__/active-provider-bootstrap-drift.spec.ts` covers snapshot persistence on auto-detect (single + ambiguous picks), drift detection from config (no-drift, added marker, removed marker, both-direction drift), legacy backfill, snapshot stickiness on repeat drift, and the `style.warnGlyph` / `style.dim` plumbing. `src/cli/commands/__tests__/config-cli.spec.ts` adds two cases for `sm config set activeProvider`: snapshot refresh on set, and full-set capture (not just the picked id).
103
+
104
+ Pre-1.0 minor per `spec/versioning.md`: additive optional field on the project-config schema (`@skill-map/spec`) plus an additive runtime behaviour on `@skill-map/cli`. No removed surface.
105
+
106
+ ## User-facing
107
+
108
+ `sm scan` now warns once when provider markers on disk drifted since `activeProvider` was set (e.g. you added `.codex/` after picking the `claude` lens). Run `sm config set activeProvider <id>` to switch the lens, or ignore the warn and keep going, it never blocks the scan.
109
+
110
+ - a58989f: Lens-gated classification for vendor providers. Vendor Providers (`claude`, `openai`, `antigravity`) now opt into being gated by the active lens via a new `gatedByActiveLens: true` field on their manifest. The walker (`src/kernel/orchestrator/walk.ts`) pre-filters `opts.providers` before the walk loop: a gated Provider runs only when `provider.id === opts.activeProvider`, so vendor providers no longer attempt to classify files outside their lens. Universal providers (`core/markdown`, future `agent-skills` open standard) leave the flag absent / `false` and run unconditionally.
111
+
112
+ **Motivation.** The real runtimes never cross-read each other's on-disk formats: Claude Code does not consume `.codex/`, Codex CLI does not consume `.claude/`, Antigravity has no on-disk kind beyond the open standard yet. Offering every file to every provider during classification fabricated cross-vendor graph edges the runtimes themselves reject, the operator saw `openai/agent` nodes for `.codex/agents/*.toml` in a `claude`-lensed project even though Claude Code would never resolve them. The pre-filter in the walker is the cheap path: a gated-off Provider does NOT walk its territory at all, no per-file cost.
113
+
114
+ **Spec.** `spec/schemas/extensions/provider.schema.json` mirrors the new optional boolean field with the full normative description (vendor MUST opt in, universal SHOULD omit, `null` lens bypasses the gate). The matching prose lives in `spec/architecture.md` §"Active-lens scope for providers (classification gate)" (landing alongside drift-detection in a follow-up commit; the changeset for that commit owns the architecture.md prose bump).
115
+
116
+ **`null` lens semantics.** When `activeProvider === null` (a project with no provider markers, no setting), the walker bypasses the gate entirely and every Provider runs. This matches the extractor-side fallback for unlensed projects: a plain-markdown repo keeps classifying with every Provider, no gates fire.
117
+
118
+ **Backward compatibility.** Providers without the field default to `gatedByActiveLens === undefined ≡ false`, the universal behaviour. Existing third-party providers keep working unchanged; only providers that explicitly opt in change classification semantics.
119
+
120
+ **Tests.** `src/kernel/orchestrator/__tests__/walk-lens-gate.spec.ts` covers the walker filter at the unit level (3 cases: claude lens excludes openai territory, openai lens excludes claude territory, `null` lens admits both). `src/__tests__/integration/lens-gated-classification.spec.ts` covers the end-to-end shape across a 4-file fixture per lens (2 cases).
121
+
122
+ Pre-1.0 minor per `spec/versioning.md`: additive optional field on the Provider manifest schema (`@skill-map/spec`) plus an additive walker behaviour change on `@skill-map/cli`. No removed surface, no breaking change for universal providers.
123
+
124
+ ## User-facing
125
+
126
+ Cross-provider files (e.g. a `.codex/agents/*.toml` while the lens is `claude`) are no longer claimed by the foreign provider. They surface as plain markdown / unclassified instead, matching how the agent itself would see them at runtime.
127
+
128
+ - d207cfa: Observable link analysis. The link-matrix walkthrough surfaced a recurring complaint, "the inspector tells me there is an edge but not where, why, or whether it overlaps with another", and a small cluster of detection bugs that were hiding real problems and inventing fake ones. This changeset is the drain pass.
129
+
130
+ **Kernel domain shape, additive.** Three new fields on `Link` / `Node`:
131
+
132
+ - `Link.occurrences[]` (`LinkOccurrence` = `{ extractor, originalTrigger, location? }`) accumulates every syntactic site in the source body that contributed to an edge. Populated by extractors at emit time, concatenated by `dedupeLinks` across extractor merges (with `(extractor, originalTrigger, line)` dedup inside the array to defend against double-emit). Frontmatter / sidecar-derived synthetic links carry it empty.
133
+ - `Link.resolvedTarget` is the node path the post-walk `liftResolvedLinkConfidence` transform bound the link to. Equal to `target` for path-style links; differs for trigger-style links (`@foo`, `/cmd`) where `target` keeps the authored trigger and `resolvedTarget` carries the resolved node path. `null` when unresolved (broken).
134
+ - `Node.externalRefs[]` (`IExternalRef` = `{ url, line?, originalTrigger? }`) is the list of distinct http(s) URLs the body references, in extractor-order, deduped by normalised URL. Populated by `recomputeExternalRefsCount` (renamed in role from "count-only" to "count + list"); the denormalised `externalRefsCount` rides alongside and must equal the array length when both are present.
135
+
136
+ All three exported from `src/kernel/index.ts`; matching JSON-Schema additions in `spec/schemas/link.schema.json` and `spec/schemas/node.schema.json` (additive, `additionalProperties: false` preserved); `spec/index.json` regenerated.
137
+
138
+ **SQL, edited in place (greenfield rule).** `src/migrations/001_initial.sql` gains three columns: `scan_links.occurrences_json`, `scan_links.resolved_target`, `scan_nodes.external_refs_json`; one new index `ix_scan_links_resolved_target`. Matching types in `src/kernel/adapters/sqlite/schema.ts`; `linkToRow` / `rowToLink` / `nodeToRow` / `rowToNode` round-trip the new columns (round-trip tests already cover the shape).
139
+
140
+ **Two new analyzers.**
141
+
142
+ - `core/redundant-target-reference` flags `(source, resolved-target)` pairs reached via two or more syntactic surfaces, whether cross-extractor (same kind, multiple authored triggers) or cross-kind (multi-edge to one target). Walks `Link.occurrences[]` plus `Link.resolvedTarget` to detect the redundancy. Severity `warn`. Tests at `src/plugins/core/analyzers/redundant-target-reference/__tests__/redundant-target-reference.spec.ts`.
143
+ - `core/self-loop` flags links whose source is its own resolved target (a body heading like `# /deploy` inside the file that defines `/deploy`). Severity `warn`. The UI hides self-loops by default; this analyzer is the authoritative detector so the count is still visible in `sm scan` output and SARIF exports. Tests at `src/plugins/core/analyzers/self-loop/__tests__/self-loop.spec.ts`.
144
+
145
+ **Existing analyzer extended.** `core/reserved-name` now emits both target-side (the file shadowing a built-in, behaviour preserved) and source-side (one `warn` per link the lift downgraded to `RESERVED_TARGET_CONFIDENCE`). Source-side issues carry `data.target` matching the link so UIs can correlate per-row instead of bleeding "any issue on source" onto every outgoing edge.
146
+
147
+ **Extractor fixes.**
148
+
149
+ - `core/markdown-link` and `core/external-url-counter` now run their regex over `stripCodeBlocks(ctx.body)` instead of raw body, matching the guard `claude/at-directive` and `claude/slash` already had. Author-written examples like `[label](path)` or `https://example.com` inside backticks or fenced blocks stop emitting spurious `references` edges (which were feeding `core/broken-ref` false positives) and stop inflating the external-URL count. Three new test cases per extractor (inline-code, fenced, mixed).
150
+ - `claude/at-directive` and `claude/slash` extractors now track line numbers per occurrence (the `core/redundant-target-reference` analyzer needs every occurrence to know its line). Both compute `lineStarts` once per body via the new shared util `src/kernel/util/line-tracking.ts` (extracted from `markdown-link`'s previously-local helper) and attach `location: { line }` to every emit.
151
+
152
+ **BFF.** `/api/links?to=X` now matches via `target` OR `resolvedTarget`; the storage-layer companion in `getNodeBundle` does the same. Without this, a Claude `@real-agent` mention stayed invisible in the incoming list of `.claude/agents/real-agent.md` because the row's `target_path` carried the trigger, not the resolved path.
153
+
154
+ **UI overhaul, `LinkedNodesPanel`.**
155
+
156
+ - Numeric confidence value shown in the tag, was qualitative `high` / `medium` / `low`. The tier survives as the tag's tooltip and severity colour, so `0.85` and `1.00` are now visually distinguishable on the same row.
157
+ - New "Findings" section at the top of the panel, lists every issue whose `nodeIds[]` includes the focused path.
158
+ - Inline issue chip per outgoing / incoming row. Correlation rules tightened: source-side issue with `data.target` matching the link's `target` / `resolvedTarget` / current path (the original "any issue on source" fallback bled unrelated `broken-ref` findings onto every row).
159
+ - Per-row "Occurs at:" sub-list when `link.occurrences.length > 0`, shows each line + original trigger + extractor id.
160
+ - New "External references" section above Findings when `node.externalRefs` is populated, clickable URLs that open in a new tab.
161
+ - Self-loops hidden by default from outgoing + incoming via a client-side `isSelfLoop` filter. The `core/self-loop` analyzer remains the authoritative detector; the panel just respects it.
162
+ - Texts catalog (`linked-nodes-panel.texts.ts`) and CSS updated.
163
+ - `ui/src/models/api.ts` gained `ILinkOccurrenceApi`, `IExternalRefApi`, `Link.occurrences`, `Link.resolvedTarget`, `Node.externalRefs` shapes mirroring the kernel domain types.
164
+
165
+ **Plus an out-of-band AGENTS.md operating rule.** A new analyzer queues mid-execution user messages (do not abort an in-flight tool sequence to handle an interrupt unless the interrupt is an unambiguous abort verb). Lands in this commit because it surfaced during the same walkthrough.
166
+
167
+ Pre-1.0 minor on both workspaces per `spec/versioning.md` (additive shape changes, no breakage).
168
+
169
+ ## User-facing
170
+
171
+ **Inspector overhaul.** Links show numeric confidence, a Findings list, per-row issue chips, and per-site "Occurs at" lines. New "External references" section. Self-loops hidden by default. Two new analyzers flag redundant multi-form references and self-loops.
172
+
173
+ - 5a12e5c: Phase 2.D of the Signal IR migration: new `core/signal-collision` built-in analyzer surfaces resolver rejections as operator-visible `warn` issues. The analyzer reads `IAnalyzerContext.signals`, finds every Signal whose `resolution.outcome === 'rejected'`, and emits one issue per rejection naming the loser extractor + matched text + byte range, the winner extractor + range, and the tiebreak reason (`kind-priority` / `higher-confidence` / `longer-range` / `earlier-declaration`). Phase 4+ stubs (`extractorDisabled`, `belowFloor`) are handled with their own message templates so the surface stays forward-compatible.
174
+
175
+ Closes spec conformance coverage row 37 (`signal.schema.json`) with the two required cases:
176
+
177
+ - `extractor-emits-signal`: a body with a single `[text](path)` markdown link materialises as one Link via the Signal IR resolver path; `sources[0] === 'markdown-link'`.
178
+ - `signal-collision-detection`: a body with `[@./api.md](./api.md)` triggers a cross-extractor range overlap (markdown-link's range contains at-directive's range); markdown-link wins on confidence; the loser surfaces as exactly one `core/signal-collision` warn issue.
179
+
180
+ ## User-facing
181
+
182
+ `sm scan` now warns when two extractors detect overlapping byte ranges. The graph keeps the winner; the issue panel explains which detection lost and why, so a markdown link wrapping an `@`-directive no longer looks like silent disappearing intent.
183
+
184
+ - 3ca095b: Wire the Signal IR resolver end-to-end (Phase 2.A of the active-lens migration). The kernel's `resolveSignals` runs after extraction and before analysis: filters disabled extractors (Phase 4+ stub), ranks intra-Signal candidates via `IProvider.resolverRules.kindPriority` (when declared) + confidence + extractor declaration order, builds overlap clusters from body-scoped Signals sharing a source, picks a cluster winner per the four-step tiebreak chain (`kind-priority` -> `higher-confidence` -> `longer-range` -> `earlier-declaration`), materialises winners as Links indistinguishable from `emitLink`-emitted ones, and annotates each Signal's new `resolution` field with the outcome + reason. Rejected (losing) Signals remain accessible to analyzers via `IAnalyzerContext.signals` so a future `core/signal-collision` analyzer can surface them as `warn` issues naming WHO won and WHY.
185
+
186
+ Spec changes: `signal.schema.json` gains the `resolution` object property (outcome / winnerIndex / rejectedBy / phase 4+ stubs); `extensions/provider.schema.json` gains `resolverRules.kindPriority`; `architecture.md` §Resolver phase rewritten to reflect the wired contract; `conformance/coverage.md` row 37 flipped to in-progress.
187
+
188
+ Kernel changes: extend `Signal` type with `resolution?: ISignalResolution`; add `IResolverRules` + `IProvider.resolverRules`; rewrite `resolveSignals` (87-line first-candidate scaffold -> full algorithm); thread `signals` through `walkAndExtract` accumulators -> `runAnalyzers` -> per-analyzer context; export `isExternalUrlLink` for the caller's routing of materialised Links between internal / external arrays.
189
+
190
+ No extractor uses `emitSignal` yet (Phases 2.B and 2.C migrate them). With zero Signals emitted today the wiring is a no-op pass-through that returns empty arrays; 18 new resolver unit tests cover intra-Signal ranking, cross-Signal overlap, the four tiebreak reasons, kindPriority interaction, external-URL cluster skip, frontmatter / sidecar scope pass-through, and materialised Link shape parity.
191
+
192
+ ### Patch Changes
193
+
194
+ - 1362de9: Phase 2.B of the Signal IR migration: `claude/at-directive` extractor now routes through `ctx.emitSignal` instead of `ctx.emitLink`. Each `@<token>` match emits a single-candidate Signal carrying the byte range, scope (`body`), and a candidate with the same kind / target / confidence / trigger / rationale shape the extractor used to embed directly into a Link. The resolver phase materialises the winning candidate as a Link indistinguishable from the prior direct-emit shape, including `occurrences[]` round-tripping; full `pnpm validate` stays green with 1734 tests passing and zero behaviour change.
195
+
196
+ Why through Signals: byte ranges now flow into the kernel resolver, which unlocks cross-extractor range-overlap collision detection (a future `core/signal-collision` analyzer will surface losers as `warn` issues). The single-candidate shape keeps the migration narrow; multi-candidate emissions for cases of genuine intra-Signal ambiguity stay deferred until a real case demands it.
197
+
198
+ Spec: `signal.schema.json` gains an optional `range.line` field so extractors that already compute line tracking (via `computeLineStarts` / `lineFor`) thread the line number through to the materialised `Link.location.line` without the resolver re-walking the body.
199
+
200
+ Kernel: resolver's `materialise()` synthesises a one-entry `occurrences[]` from the winning candidate's trigger + range so multi-extractor `dedupeLinks` merges accumulate occurrences through the same code path as direct emissions. `extractorOrder` and `link.sources` now both use short extractor ids (e.g. `'at-directive'`) to match the cache layer's lookup contract.
201
+
202
+ Test harness: `src/plugins/core/extractors/__tests__/extractors.spec.ts` `extract()` helper auto-flushes Signals via the resolver so tests that assert on the resulting `links` array see identical shape regardless of whether the extractor went through `emitLink` directly or routed through `emitSignal`.
203
+
3
204
  ## 0.34.0
4
205
 
5
206
  ### Minor Changes
package/architecture.md CHANGED
@@ -65,6 +65,34 @@ The lens does NOT gate the universal extractors that ship under `core/` (markdow
65
65
 
66
66
  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.
67
67
 
68
+ ### Active-lens scope for providers (classification gate)
69
+
70
+ 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.
71
+
72
+ 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.
73
+
74
+ 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).
75
+
76
+ 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.
77
+
78
+ ### Active-lens drift detection
79
+
80
+ 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.
81
+
82
+ To surface this drift without being noisy, the runtime persists a snapshot of provider markers alongside `activeProvider`:
83
+
84
+ - **`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).
85
+
86
+ 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:
87
+
88
+ - **New markers in current but not in snapshot** → "New: <added>" (e.g. the operator added `.codex/` after the choice was made).
89
+ - **Markers in snapshot but no longer on disk** → "Removed: <removed>".
90
+ - **Both** → both lines, still ONE warn per scan.
91
+
92
+ 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).
93
+
94
+ 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.
95
+
68
96
  ---
69
97
 
70
98
  ## Ports
@@ -100,7 +128,7 @@ The loader enforces two id-uniqueness analyzers during discovery (see [`plugin-a
100
128
  1. **Directory name == manifest id.** A plugin lives at `<root>/<id>/plugin.json`. A mismatch surfaces as status `invalid-manifest`. This analyzer eliminates same-root collisions by construction.
101
129
  2. **Cross-root id collision blocks both sides.** Two plugins reachable from different roots (e.g. the project default `<cwd>/.skill-map/plugins/` and any `--plugin-dir` combination) that declare the same `id` BOTH receive status `id-collision`. No precedence analyzer applies, coherent with §Boot invariant ("no extension is privileged"). The user resolves by renaming one of them.
102
130
 
103
- In addition, the loader **qualifies every extension** with its owning plugin id before registering it. The registry stores extensions under the qualified id `<plugin-id>/<extension-id>` (e.g. `core/slash`, `core/broken-ref`, `my-plugin/my-extractor`). Authors continue to declare the short `id` in each extension manifest; the loader composes the qualified form from `manifest.id` at load time. Built-in extensions bundled with the reference impl declare their `pluginId` directly in `built-ins.ts`, `core/` for kernel-internal primitives (every analyzer, the formatter, the cross-vendor extractors `annotations` / `slash` / `at-directive` / `markdown-link` / `external-url-counter` / `stability`) and vendor-specific bundles such as `claude/` (the Claude provider) for Provider integrations whose territory is platform-bound. If a plugin author injects a `pluginId` field on an extension that disagrees with `plugin.json`'s `id`, the loader emits `invalid-manifest` with a directed reason.
131
+ In addition, the loader **qualifies every extension** with its owning plugin id before registering it. The registry stores extensions under the qualified id `<plugin-id>/<extension-id>` (e.g. `core/slash-command`, `core/reference-broken`, `my-plugin/my-extractor`). Authors continue to declare the short `id` in each extension manifest; the loader composes the qualified form from `manifest.id` at load time. Built-in extensions bundled with the reference impl declare their `pluginId` directly in `built-ins.ts`, `core/` for kernel-internal primitives (every analyzer, the formatter, the cross-vendor extractors `annotations` / `slash` / `at-directive` / `markdown-link` / `external-url-counter` / `stability`) and vendor-specific bundles such as `claude/` (the Claude provider) for Provider integrations whose territory is platform-bound. If a plugin author injects a `pluginId` field on an extension that disagrees with `plugin.json`'s `id`, the loader emits `invalid-manifest` with a directed reason.
104
132
 
105
133
  Each plugin (and each built-in bundle) declares a **granularity** that controls how its extensions are toggled. `granularity: 'bundle'` (the default) means the plugin id is the only enable/disable key; `granularity: 'extension'` means each extension is independently toggle-able under its qualified id. The loader's pre-import `resolveEnabled(pluginId)` short-circuit is always coarse (bundle level), when a granularity=`extension` bundle is partially enabled, the import work proceeds and the runtime composer (the CLI's `composeScanExtensions` / `composeFormatters` in `src/cli/util/plugin-runtime.ts`) drops the disabled extensions before they reach the orchestrator. Vendor Provider bundles (`claude`, `antigravity`, `openai`, `agent-skills`) ship as granularity=`bundle` (the platform integration is on or off as a whole); the `core` bundle is granularity=`extension` (every kernel built-in is removable, satisfying §Boot invariant: "no extension is privileged"). See [`plugin-author-guide.md` §Granularity, bundle vs extension](./plugin-author-guide.md#granularity--bundle-vs-extension) for the author-facing summary.
106
134
 
@@ -215,7 +243,7 @@ Extensions (Provider / Extractor / Analyzer / Action / Formatter / Hook) are **p
215
243
  The materialisation of any kernel-managed artefact (the SQLite DB at `.skill-map/skill-map.db`, the `.sm` sidecars next to source files, the job ledger at `.skill-map/jobs/`, the `scan_extractor_runs` cache, the enrichment overlay rows) is the **kernel's** responsibility, gated through the relevant Port:
216
244
 
217
245
  - Extractors persist via `ctx.emitLink` / `ctx.enrichNode` / `ctx.store`, never by writing files. `ctx.store` is plugin-scoped persistence routed through `StoragePort`; it cannot reach the project filesystem.
218
- - Actions return either a deterministic report (JSON), a rendered prompt (probabilistic), or, for the small subset of actions that legitimately mutate persisted state, an explicit `TActionWrite` discriminated union the kernel interprets. The built-in `core/bump` is the only action that returns `{ kind: 'sidecar' }` today; the kernel routes that write through `SidecarStore.applyPatch`, which is the single gated chokepoint for all `.sm` writes (see §Annotation system · Write consent).
246
+ - Actions return either a deterministic report (JSON), a rendered prompt (probabilistic), or, for the small subset of actions that legitimately mutate persisted state, an explicit `TActionWrite` discriminated union the kernel interprets. The built-in `core/node-bump` is the only action that returns `{ kind: 'sidecar' }` today; the kernel routes that write through `SidecarStore.applyPatch`, which is the single gated chokepoint for all `.sm` writes (see §Annotation system · Write consent).
219
247
  - Providers, Analyzers, Formatters, Hooks have no write surface at all.
220
248
 
221
249
  This invariant is what makes the consent gate at the kernel boundary sufficient: no extension can bypass it because no extension has the means to write in the first place. Conformance: a third-party extension that imports `node:fs` write APIs (or equivalent in another language) is non-conforming.
@@ -282,7 +310,7 @@ The transform runs after `dedupeLinks` and before the analyzer pipeline. For eac
282
310
 
283
311
  2. **Name match (links carrying a `trigger.normalizedTrigger`)**: strip the leading `@` / `/` sigil, look up the resulting handle in the cross-kind name index built from every node's declared `identifiers` (see §Provider · kind identifiers). The lookup keys on the LINK SOURCE node's Provider id: `resolution = providers[sourceNode.provider].resolution`. If `resolution[link.kind]` exists AND any candidate node's kind appears in it, confidence is bumped to `1.0`. Otherwise the link stays at its extractor-emitted confidence.
284
312
 
285
- The matrix is **per-link-kind, per-Provider**, strict: a `claude` Provider that declares `resolution: { mentions: ['agent'], invokes: ['command', 'skill'] }` does NOT bump a `/foo` slash matching an agent named `foo` (slash → agent is a kind mismatch surfaced by `link-conflict` / `kind-mismatch` analyzers, not silently treated as a resolution). The strictness is the load-bearing difference from the kind-agnostic `core/broken-ref` analyzer: `broken-ref`'s scope is "the name exists somewhere" (a name-only resolution is enough to clear the broken flag), the post-walk bump is "the name exists AS A VALID resolution for this link.kind". The `not-broken` + `not-bumped` combination is a documented edge case: the trigger resolves to a real node but the link's kind cannot legitimately point there.
313
+ The matrix is **per-link-kind, per-Provider**, strict: a `claude` Provider that declares `resolution: { mentions: ['agent'], invokes: ['command', 'skill'] }` does NOT bump a `/foo` slash matching an agent named `foo` (slash → agent is a kind mismatch surfaced by `link-conflict` / `kind-mismatch` analyzers, not silently treated as a resolution). The strictness is the load-bearing difference from the kind-agnostic `core/reference-broken` analyzer: `broken-ref`'s scope is "the name exists somewhere" (a name-only resolution is enough to clear the broken flag), the post-walk bump is "the name exists AS A VALID resolution for this link.kind". The `not-broken` + `not-bumped` combination is a documented edge case: the trigger resolves to a real node but the link's kind cannot legitimately point there.
286
314
 
287
315
  The lookup uses the SOURCE node's Provider id deliberately: a Provider rules over the link.kinds its Extractors emit, regardless of where the resolution candidates physically live in the graph. A `claude` agent that mentions an `openai` agent (cross-provider mention) still follows claude's `resolution.mentions = ['agent']` rule. When the source node belongs to a Provider without `resolution` (e.g. a `CLAUDE.md` classified by `core/markdown`), the name path short-circuits, the path-match rule still applies.
288
316
 
@@ -294,7 +322,7 @@ Each Provider MAY declare an optional `reservedNames: Record<kind, string[]>` ma
294
322
 
295
323
  The kernel intersects each Provider's `reservedNames[kind]` catalog with the scanned graph at orchestrator time: for every node, the post-walk pipeline derives its normalised identifiers via the §Provider · kind identifiers contract and asks "does any identifier fall in `reservedNames[node.kind]` for this node's Provider?". Matches land in a per-scan `Set<nodePath>` consumed by two surfaces:
296
324
 
297
- 1. **The `core/reserved-name` analyzer projects one `warn` issue per reserved-shadow node** (`severity: 'warn'`, message points at the offending file and suggests renaming). The analyzer is a pure projector, detection lives in the orchestrator so the same set drives the next surface.
325
+ 1. **The `core/name-reserved` analyzer projects one `warn` issue per reserved-shadow node** (`severity: 'warn'`, message points at the offending file and suggests renaming). The analyzer is a pure projector, detection lives in the orchestrator so the same set drives the next surface.
298
326
 
299
327
  2. **The post-walk confidence-lift transform downgrades any link that resolves to a reserved target** (by path OR by name match) to `RESERVED_TARGET_CONFIDENCE = 0.1` instead of bumping it to `1.0`. The visual weight in the graph drops well below the `0.5` / `0.8` extractor emit floors so the operator sees at a glance that the edge resolves to a file the runtime ignores. When the trigger has multiple candidates (name index collision) and the strict-kind filter accepts more than one, the resolver picks the first allowed candidate, if it is non-reserved, the link bumps to `1.0` normally; only when EVERY accepted candidate is reserved does the downgrade apply.
300
328
 
@@ -320,12 +348,15 @@ In addition to the `emitLink` path, Extractors MAY emit **Signals** via `ctx.emi
320
348
 
321
349
  The kernel's **resolver phase** runs after extraction completes and before analysis starts. For each Signal, the resolver:
322
350
 
323
- 1. Filters candidates whose `extractorId` is not enabled (per `plugins.<id>.extensions.<extId>.enabled` overrides).
324
- 2. Applies the active Provider's resolution rules (declared on `IProvider.resolverRules`) to rank surviving candidates: priority order, tie-break by confidence, then by longest range, then by `extractorId` declaration order.
325
- 3. Materialises the winning candidate as a Link (indistinguishable from a Link emitted directly via `emitLink`). The rejected candidates remain accessible to analyzers via `IAnalyzerContext.signals` for collision-detection and conflict-visualization use cases.
326
- 4. Rejects all candidates and emits no Link if every interpretation has confidence below the configured floor.
351
+ 1. (Phase 4+, not yet wired) Filters candidates whose `extractorId` is disabled via `plugins.<id>.extensions.<extId>.enabled`. When that filter empties every candidate, the Signal carries `resolution.outcome = 'rejected'` with `extractorDisabled = { extractorId }`.
352
+ 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'`.
353
+ 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).
354
+ 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.
355
+ 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.
356
+
357
+ Both materialised and rejected Signals remain on `IAnalyzerContext.signals` post-resolver. The built-in `core/signal-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.
327
358
 
328
- 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) and fragmentation detection (an authored intent split across several adjacent Signals). Both surface as analyzer issues, not silent merges.
359
+ 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.
329
360
 
330
361
  ### Extractor · enrichment layer
331
362
 
@@ -409,7 +440,7 @@ The two surfaces are deliberately split:
409
440
  - **`Action.precondition`**, declared on the Action side. Answers "which nodes does this Action apply to?". Evaluated continuously against the node the inspector is focused on, regardless of any issue.
410
441
  - **`Analyzer.recommendedActions`**, declared on the Analyzer side. Answers "when this analyzer fires, which Actions are the natural fix?". Surfaces only on nodes the analyzer emitted against.
411
442
 
412
- Each `recommendedActions` entry MUST be the qualified id of a registered Action. The kernel logs an `extension.error` event with `kind: 'recommended-action-missing'` when a referenced action is not loaded; the analyzer stays registered and continues emitting issues, only the recommendation hint is dropped. Project-level cleanup verbs (orphan file prune, contribution relink) are CLI commands, not Actions, and are NOT linked through this field. Analyzers whose issues surface deliberate user declarations rather than fixable problems (e.g. `core/superseded`) omit the field.
443
+ Each `recommendedActions` entry MUST be the qualified id of a registered Action. The kernel logs an `extension.error` event with `kind: 'recommended-action-missing'` when a referenced action is not loaded; the analyzer stays registered and continues emitting issues, only the recommendation hint is dropped. Project-level cleanup verbs (orphan file prune, contribution relink) are CLI commands, not Actions, and are NOT linked through this field. Analyzers whose issues surface deliberate user declarations rather than fixable problems (e.g. `core/node-superseded`) omit the field.
413
444
 
414
445
  ### Hook · curated trigger set
415
446
 
@@ -453,7 +484,7 @@ Hooks introduce no new persisted state and do NOT participate in the determinist
453
484
  ### Locality
454
485
 
455
486
  - **Drop-in**: extensions live inside plugins, discovered at boot from `<cwd>/.skill-map/plugins/<id>/` only. The `--plugin-dir <path>` escape hatch on the `sm plugins …` verb family loads a custom directory per invocation when the user explicitly opts in.
456
- - **Built-in**: the reference impl bundles a default extension set (one Provider, four extractors, five analyzers, one formatter, one hook). The fifth analyzer, `core/validate-all`, replays every scanned node and link through the authoritative spec schemas via AJV, the kernel-side guard against persisting non-conforming graph rows. The first built-in Hook is `core/update-check`, which subscribes to `shutdown` and runs the once-per-day "update available" probe + banner that lived on the CLI entry path before the Hook kind had concrete consumers. These are loaded from `src/extensions/` and are indistinguishable from plugin-supplied extensions from the kernel's point of view.
487
+ - **Built-in**: the reference impl bundles a default extension set (one Provider, four extractors, five analyzers, one formatter, one hook). The fifth analyzer, `core/schema-violation`, replays every scanned node and link through the authoritative spec schemas via AJV, the kernel-side guard against persisting non-conforming graph rows. The first built-in Hook is `core/update-check`, which subscribes to `shutdown` and runs the once-per-day "update available" probe + banner that lived on the CLI entry path before the Hook kind had concrete consumers. These are loaded from `src/extensions/` and are indistinguishable from plugin-supplied extensions from the kernel's point of view.
457
488
 
458
489
  ---
459
490
 
@@ -572,7 +603,7 @@ At scan time the kernel re-computes the live hashes and compares against the sto
572
603
 
573
604
  ### Bump model
574
605
 
575
- The deterministic built-in `core/bump` Action produces a sidecar patch:
606
+ The deterministic built-in `core/node-bump` Action produces a sidecar patch:
576
607
 
577
608
  - Increments `annotations.version` by 1 (or sets to `1` if missing, single integer monotonic, orthogonal to `stability`; major bumps are not a concept, the convention for breaking changes is "create a new node, supersede the old").
578
609
  - Refreshes `identity.bodyHash` and `identity.frontmatterHash` to the live values.
@@ -729,7 +760,7 @@ ctx.emitContribution(nodePath, contributionId, payload);
729
760
 
730
761
  Parallel to `ctx.emitLink(link)`. The kernel buffers the emission, validates the payload against the slot's payload schema in `$defs/payloads/<slot>` (AJV-compiled at boot), and persists the row to `scan_contributions` during `persistScanResult`. Off-shape payloads emit an `extension.error` event and drop silently, same posture as `emitLink` rejecting off-`emitsLinkKinds` links. Both Extractor and Analyzer emissions land in the same `scan_contributions` rows; the row's `extension_id` records which kind of extension produced it.
731
762
 
732
- The Extractor-emit signature binds `nodePath` implicitly (the extractor runs per-node, with `ctx.node.path` available as the only sensible target). The Analyzer-emit signature requires the analyzer to declare the target node explicitly because Analyzers see the full graph at once and may emit for any subset of nodes, the canonical use case is a analyzer that derives per-node values from cross-graph aggregations (`core/link-counts` projects `linksOutCount` / `linksInCount` this way).
763
+ The Extractor-emit signature binds `nodePath` implicitly (the extractor runs per-node, with `ctx.node.path` available as the only sensible target). The Analyzer-emit signature requires the analyzer to declare the target node explicitly because Analyzers see the full graph at once and may emit for any subset of nodes, the canonical use case is a analyzer that derives per-node values from cross-graph aggregations (`core/link-counter` projects `linksOutCount` / `linksInCount` this way).
733
764
 
734
765
  Analyzers MAY also emit scope-level contributions via `IAnalyzerContext.emitScopeContribution(contributionId, payload)` (only slots whose schema permits scope-level emission, today only `topbar.nav.start`). That signature is reserved in the spec; the runtime callback lands when the first scope-level adopter arrives.
735
766
 
@@ -800,7 +831,7 @@ Same honest-note posture as [`plugin-kv-api.md`](./plugin-kv-api.md): isolated a
800
831
 
801
832
  Two built-ins ship with the system to cover catalog evolution and rename edge cases:
802
833
 
803
- - **`core/unknown-slot`**, walks every loaded plugin's `ui[*].slot`; emits an `Issue` of severity `warn` for any slot not in the current kernel catalog. Parallel to `core/unknown-field` for annotations. Note: AJV at manifest load already rejects unknown slots as `invalid-manifest`; this analyzer covers the soft-warning path when a plugin remains loaded across a catalog version bump.
834
+ - **`core/unknown-slot`**, walks every loaded plugin's `ui[*].slot`; emits an `Issue` of severity `warn` for any slot not in the current kernel catalog. Parallel to `core/annotation-field-unknown` for annotations. Note: AJV at manifest load already rejects unknown slots as `invalid-manifest`; this analyzer covers the soft-warning path when a plugin remains loaded across a catalog version bump.
804
835
  - **`core/contribution-orphan`**, joins `scan_contributions` against the live `scan_nodes` set; emits an `Issue` of severity `warn` for emissions whose `node_path` no longer exists (post-rename heuristic miss).
805
836
 
806
837
  ### Catalog versioning
package/cli-contract.md CHANGED
@@ -279,7 +279,7 @@ The three privacy-sensitive keys above PLUS `allowEditSmFiles` are members of `P
279
279
  - `sm scan [roots...]`: when positional roots are given, they ARE the effective roots (verbatim). When omitted: the effective roots are `[cwd]` plus the appended set below.
280
280
  - `scan.extraFolders[]` (project-local config) is appended verbatim (entries starting with `~` resolve against the user home; relative entries resolve against the project root). This is the ONLY way to extend the scan beyond the project: there is no implicit `$HOME` walk, no opt-in global scope, and Providers cannot opt their own directory in. See §Scope is always project-local at the top of this file for the broader principle.
281
281
 
282
- **Reference paths** (`scan.referencePaths[]`): walked in parallel by the scan to collect existing absolute paths into a side set. Files there are NOT parsed and NOT indexed as nodes; the kernel passes the set to analyzers via `IAnalyzerContext.referenceablePaths` so `core/broken-ref` can resolve a link against the filesystem when the in-graph lookup misses.
282
+ **Reference paths** (`scan.referencePaths[]`): walked in parallel by the scan to collect existing absolute paths into a side set. Files there are NOT parsed and NOT indexed as nodes; the kernel passes the set to analyzers via `IAnalyzerContext.referenceablePaths` so `core/reference-broken` can resolve a link against the filesystem when the in-graph lookup misses.
283
283
 
284
284
  The watcher subscribes to the same roots that `sm scan` walks and respects `.skillmapignore` plus `config.ignore` exactly as the one-shot scan does. Filesystem events are grouped using `scan.watch.debounceMs` (default 300ms) before the watcher re-runs the incremental scan and persists. `SIGINT` / `SIGTERM` close the watcher cleanly. Exit code on clean shutdown is 0.
285
285
 
@@ -314,11 +314,11 @@ Actions are not invoked via `sm actions`; invocation is via `sm job submit` (see
314
314
 
315
315
  #### Sidecar bump (Step 9.6.4)
316
316
 
317
- The built-in deterministic `core/bump` Action is the canonical write channel for `<basename>.sm` annotation sidecars; the verbs below are its CLI surface plus a few sidecar-management helpers. The `bump` verb stays top-level (high frequency, ROADMAP-named); the administrative helpers live under the `sm sidecar` sub-namespace to avoid colliding with the existing `sm refresh` (which targets the enrichment layer, not sidecars). All sidecar-touching verbs are deterministic, they invoke `core/bump` (or the `FilesystemSidecarStore` directly) in-process and never queue jobs.
317
+ The built-in deterministic `core/node-bump` Action is the canonical write channel for `<basename>.sm` annotation sidecars; the verbs below are its CLI surface plus a few sidecar-management helpers. The `bump` verb stays top-level (high frequency, ROADMAP-named); the administrative helpers live under the `sm sidecar` sub-namespace to avoid colliding with the existing `sm refresh` (which targets the enrichment layer, not sidecars). All sidecar-touching verbs are deterministic, they invoke `core/node-bump` (or the `FilesystemSidecarStore` directly) in-process and never queue jobs.
318
318
 
319
319
  | Command | Purpose |
320
320
  |---|---|
321
- | `sm bump <node.path> [--force] [--yes]` | Single-node bump. Wraps `core/bump`. Refuses on a fresh node (`{ ok: false, reason: 'fresh' }`, exit `2`) unless `--force` is passed; with `--force` on a fresh node the verb is a silent no-op (exit `0`, no stdout). On a stale node (or first-time creation) increments `annotations.version`, refreshes `for.{bodyHash, frontmatterHash}`, and stamps the audit block (`audit.lastBumpedAt` + `audit.lastBumpedBy: 'cli'`; on first creation also `audit.createdAt` + `audit.createdBy: 'cli'`). Exit `5` if the node is not in the persisted scan. `--json` emits the report shape declared by `bump-report.schema.json`. `--yes` confirms consent for `.sm` writes in this project, see §`.sm` write consent below. |
321
+ | `sm bump <node.path> [--force] [--yes]` | Single-node bump. Wraps `core/node-bump`. Refuses on a fresh node (`{ ok: false, reason: 'fresh' }`, exit `2`) unless `--force` is passed; with `--force` on a fresh node the verb is a silent no-op (exit `0`, no stdout). On a stale node (or first-time creation) increments `annotations.version`, refreshes `for.{bodyHash, frontmatterHash}`, and stamps the audit block (`audit.lastBumpedAt` + `audit.lastBumpedBy: 'cli'`; on first creation also `audit.createdAt` + `audit.createdBy: 'cli'`). Exit `5` if the node is not in the persisted scan. `--json` emits the report shape declared by `bump-report.schema.json`. `--yes` confirms consent for `.sm` writes in this project, see §`.sm` write consent below. |
322
322
  | `sm bump --pending [--staged] [--force] [--yes]` | Batch bump. Walks every node whose sidecar overlay reports drift in `node.path` ASC order and bumps each. `--staged` runs `git add <sidecar-path>` after each successful bump so the new content lands in the same commit; `git add` failure degrades to a stderr warning, the batch keeps running. Empty stale set → exit `0` with a "nothing to do" advisory. `--json` envelope: `{ bumped, refused, skipped, errors[], elapsedMs }`. Exit `0` on a clean run; `1` when at least one per-node error landed in `errors[]`. **Git error matrix for `--staged`**: not inside a git repo (no `.git/` parent of `cwd`) → exit `5`; `git` binary not on PATH (spawn ENOENT) → exit `2`. Both checks run BEFORE any sidecar write so a misconfigured environment never produces partial state. `--yes` confirms consent for `.sm` writes, see §`.sm` write consent below. |
323
323
  | `sm sidecar refresh <node.path> [--yes]` | Hash-only update on the sidecar. Refreshes `for.{bodyHash, frontmatterHash}` to match the live node WITHOUT bumping `annotations.version` and WITHOUT touching the audit block. Useful when the user knows a body change is editorial-only and doesn't want to spend a version increment. Distinct from the top-level `sm refresh` (which targets the enrichment layer at Step A.8), different storage, different concept; the sub-namespace prefix prevents the collision. Exit `5` if the node has no sidecar or is not in the persisted scan. No-op on a fresh node (informational stderr, exit `0`). `--yes` confirms consent for `.sm` writes, see §`.sm` write consent below. |
324
324
  | `sm sidecar prune [--dry-run] [--yes]` | Delete orphan `.sm` files (sidecars whose accompanying `<basename>.md` does not exist on disk). Destructive, without `--dry-run` prompts for interactive confirmation listing every file to be deleted (per the §Dry-run analyzer for destructive verbs). `--yes` (alias `--force`) bypasses the destructive-confirmation prompt for non-interactive callers (CI, the pre-commit hook, scripts), NOT to be confused with the `.sm` write-consent gate, which does not apply to prune (delete is not a write). With `--dry-run` reports what would be deleted without touching disk and never prompts. Different domain from `sm orphans`, that verb operates on the node graph (rename heuristic); this one operates on the filesystem layer. `--json` envelope: `{ deleted, wouldDelete, errors, items[], elapsedMs }`. Exit `1` when delete failures landed in `errors`. |
@@ -373,7 +373,7 @@ Tracked as **R6** in the §Step 9.6 review queue: open by design, defer the `js-
373
373
 
374
374
  ##### BFF endpoint, `POST /api/sidecar/bump` (Step 9.6.5, BFF half)
375
375
 
376
- The Hono BFF exposes the single-node bump flow over REST so the UI can drive the same Action / Store the CLI uses. Behaviour mirrors `sm bump <node.path> [--force]` 1:1: same `core/bump` Action, same `FilesystemSidecarStore`, same fresh-vs-stale refusal semantics. The only differences from the CLI verb are the invoker label (`'ui'` vs `'cli'`, Decision A5 of 9.6.4 left this as a literal) and the wire shape. Batch (`--pending`) is intentionally CLI-only at 9.6.5, surfacing it over REST needs a job-style progress channel and lands later.
376
+ The Hono BFF exposes the single-node bump flow over REST so the UI can drive the same Action / Store the CLI uses. Behaviour mirrors `sm bump <node.path> [--force]` 1:1: same `core/node-bump` Action, same `FilesystemSidecarStore`, same fresh-vs-stale refusal semantics. The only differences from the CLI verb are the invoker label (`'ui'` vs `'cli'`, Decision A5 of 9.6.4 left this as a literal) and the wire shape. Batch (`--pending`) is intentionally CLI-only at 9.6.5, surfacing it over REST needs a job-style progress channel and lands later.
377
377
 
378
378
  | Field | Value |
379
379
  |---|---|
@@ -567,9 +567,9 @@ The reference implementation ships a Hono BFF rooted at `src/server/`. One Node
567
567
  | `GET /api/issues?severity=&analyzerId=&node=` | implemented | `RestEnvelope` (`kind: 'issues'`), list of issues. Filters: `severity` (CSV from `error\|warn\|info`), `analyzerId` (CSV; qualified or short suffix per `sm check --analyzers`), `node` (filter to issues whose `nodeIds` includes the path). No pagination at v14.2. |
568
568
  | `GET /api/graph?format=ascii\|json\|md` | implemented | formatter-rendered graph. `Content-Type` per format: `text/plain` (ascii), `application/json` (json), `text/markdown` (md / mermaid). Default `format=ascii`. Unknown format → 400 `bad-query`. |
569
569
  | `GET /api/config` | implemented | `RestEnvelope` (`kind: 'config'`), merged effective config (defaults → user → user-local → project → project-local → override). |
570
- | `GET /api/plugins` | implemented | `RestEnvelope` (`kind: 'plugins'`), list of installed plugins (built-in + drop-in) with status. Item shape: `{ id, version, kinds, status, reason, source: 'built-in'\|'project', granularity: 'bundle'\|'extension', description?: string, locked?: boolean, startsAsDisabled?: boolean, extensions?: Array<{ id, kind, version, enabled, description?: string, locked?: boolean }> }`. The `granularity` field reflects the manifest declaration (built-ins: hardcoded per `built-in-plugins/built-ins.ts`; drop-ins: from `plugin.json#/granularity`, default `'bundle'`). The `description` field on the bundle item carries the manifest-declared description (built-ins: hardcoded on `IBuiltInBundle`; drop-ins: `plugin.json#/description`); each `extensions[]` entry carries its extension manifest's `description` per `IExtensionBase` (`extensions/base.schema.json#/properties/description`). The SPA's Settings list renders the descriptions as muted secondary text and includes them in its substring-search index alongside the ids. The `extensions` array is present **only** when `granularity === 'extension'` AND the plugin loaded successfully; each entry's `enabled` reflects the per-extension override resolution (DB > settings.json > installed default). For `granularity: 'bundle'` plugins the array is omitted (the bundle is the only toggle-able key). The optional `locked: true` flag is stamped when the bundle id (or qualified extension id) appears in the host's lock-list (`src/server/locked-plugins.ts`); locked items render the toggle disabled in the SPA and any `PATCH` against them returns `403 locked`. The flag is omitted when false. The optional `startsAsDisabled: true` flag is stamped on drop-in plugins (never built-ins) whose discovery-time `status` was `'disabled'`, that is, the user had them disabled in `config_plugins` / `settings.json` at `sm serve` boot, so their handlers were never bucketed into the runtime. The SPA renders a per-row hint when this flag is set AND the user toggles the row back on, since re-enabling them requires `sm serve` restart (the rest of the toggle pipeline applies live). The flag is omitted when false. |
570
+ | `GET /api/plugins` | implemented | `RestEnvelope` (`kind: 'plugins'`), list of installed plugins (built-in + drop-in) with status. Item shape: `{ id, version, kinds, status, reason, source: 'built-in'\|'project', granularity: 'bundle'\|'extension', description?: string, locked?: boolean, startsAsDisabled?: boolean, extensions?: Array<{ id, kind, version, enabled, description?: string, locked?: boolean }> }`. The `granularity` field reflects the manifest declaration (built-ins: hardcoded per `built-in-plugins/built-ins.ts`; drop-ins: from `plugin.json#/granularity`, default `'bundle'`). The `description` field on the bundle item carries the manifest-declared description (built-ins: hardcoded on `IBuiltInBundle`; drop-ins: `plugin.json#/description`); each `extensions[]` entry carries its extension manifest's `description` per `IExtensionBase` (`extensions/base.schema.json#/properties/description`). The SPA's Settings list renders the descriptions as muted secondary text and includes them in its substring-search index alongside the ids. The `extensions` array is present whenever the plugin declares any extension AND the plugin loaded successfully, regardless of granularity. Each entry's `enabled` reflects the per-extension override resolution (DB > settings.json > installed default), this is the **preference** state and is independent of the bundle row. For `granularity: 'bundle'` plugins the runtime layers the two: the bundle id is the coarse kill-switch (when its row resolves to `false`, every extension is disabled regardless of per-extension overrides); when the bundle row resolves to `true`, each extension respects its qualified-id override. The Settings UI exposes per-extension toggles for both granularities so the operator can refine individual extractors inside a bundle without dropping it; `sm plugins enable/disable <bare-id>` still rejects qualified ids against bundle granularity, the per-extension axis is reserved for the UI and direct config edits. The optional `locked: true` flag is stamped when the bundle id (or qualified extension id) appears in the host's lock-list (`src/server/locked-plugins.ts`); locked items render the toggle disabled in the SPA and any `PATCH` against them returns `403 locked`. The flag is omitted when false. The optional `startsAsDisabled: true` flag is stamped on drop-in plugins (never built-ins) whose discovery-time `status` was `'disabled'`, that is, the user had them disabled in `config_plugins` / `settings.json` at `sm serve` boot, so their handlers were never bucketed into the runtime. The SPA renders a per-row hint when this flag is set AND the user toggles the row back on, since re-enabling them requires `sm serve` restart (the rest of the toggle pipeline applies live). The flag is omitted when false. |
571
571
  | `PATCH /api/plugins/:id` | implemented | Toggle one plugin's user override. `:id` MUST be a top-level bundle id; qualified-id form (`bundle/extension`) is the sibling route below. Body `{ enabled: boolean }` (JSON). Persists to `config_plugins` via `IConfigPluginsPort.set`, same path the CLI's `sm plugins enable\|disable` uses. Response is the canonical `{ id, version, kinds, status, reason, source }` row for the affected plugin (post-write `status` reflects the new override resolution). **Granularity**, rejected with 400 `bad-query` when the target bundle declares `granularity: 'extension'` (only the qualified-id form is toggle-able for those). **Lock**, rejected with 403 `locked` when the bundle id is in the host lock-list (`src/server/locked-plugins.ts`). **Apply window**, the override applies on the next scan (manual via `POST /api/scan` or `sm scan`, automatic via watcher batch); both the BFF and the watcher build a fresh resolver from `config_plugins` before composing extensions, so the toggle is honoured without restarting `sm serve`. The endpoint purges `scan_contributions` rows for the plugin immediately on disable so the UI stops rendering its chips before the next scan. **Exception**, drop-in plugins whose discovery-time `status` was `'disabled'` (carried as `startsAsDisabled: true` on the read shape) are NOT in the runtime extension buckets; re-enabling them via PATCH persists the override but requires `sm serve` restart for the handlers to be loaded. The SPA surfaces this per-row when the user re-enables such a plugin. The endpoint does NOT broadcast a WS event today. |
572
- | `PATCH /api/plugins/:bundleId/extensions/:extensionId` | implemented | Qualified-id form for `granularity: 'extension'` bundles (today: `core` + any user plugin that opts in). Body `{ enabled: boolean }`. Both segments are URL-path-segment-encoded (no slash inside `:bundleId` or `:extensionId`). Rejected with 400 `bad-query` when the target bundle declares `granularity: 'bundle'` (use the sibling route above). **Lock**, rejected with 403 `locked` when either the bundle id or the qualified `bundleId/extensionId` appears in the host lock-list. Same persistence + apply-window semantics as the bundle form (including the `startsAsDisabled` exception). |
572
+ | `PATCH /api/plugins/:bundleId/extensions/:extensionId` | implemented | Qualified-id form, accepted for ANY granularity (Phase 4b follow-up, commit `e45d2fd`): extension granularity to flip individual extensions, bundle granularity to refine one extension while leaving the rest of the bundle live (the bundle row is the coarse kill-switch). Body `{ enabled: boolean }`. Both segments are URL-path-segment-encoded (no slash inside `:bundleId` or `:extensionId`). 404 `not-found` when the bundle id is unknown or the extension id does not belong to that bundle. **Lock**, rejected with 403 `locked` when either the bundle id or the qualified `bundleId/extensionId` appears in the host lock-list. Same persistence + apply-window semantics as the bundle form (including the `startsAsDisabled` exception). The CLI `sm plugins enable/disable <bundle>/<ext>` still rejects against bundle-granularity targets, the qualified axis is reserved for the UI and direct config edits. |
573
573
  | `PATCH /api/plugins` | implemented | Bulk toggle. Body `{ "changes": Array<{ "id": string, "enabled": boolean }> }` where each `id` is either a bare bundle id or a qualified `<bundle>/<extension>` id (the dispatcher branches on the slash exactly like the single-id routes above). Empty `changes` array is accepted as a no-op (returns the current `GET /api/plugins` envelope). The route validates the **entire batch** before writing, any invalid entry (`unknown-plugin` / `granularity-mismatch` / `locked`) rejects the whole request with the offending id in `error.details.id`, and the DB is not touched. Valid batches are applied in **one SQLite transaction**: `IConfigPluginsPort.set` per entry, then one grouped `scan_contributions` purge per disabled plugin (enables skip the purge). Response is the same `RestEnvelope` (`kind: 'plugins'`) shape as `GET /api/plugins`, reflecting the post-write state, the SPA replaces its modal state from this envelope. Apply-window and `startsAsDisabled` exception semantics match the per-id routes. The single-id `PATCH /api/plugins/:id` and qualified-id sibling stay available for CLI / external automation; the bulk variant exists so the SPA can stage edits in a buffered modal and ship the final delta atomically. |
574
574
  | `ALL /api/*` (other) | reserved | structured 404 envelope (see below); future endpoints land in subsequent sub-steps. |
575
575
  | `GET /ws` | implemented (v14.4.a) | accepts WebSocket upgrade and registers the client with the BFF broadcaster. Server-push only, the server fans `scan.*` (and forthcoming `issue.*`) events to every connected client. See **WebSocket protocol** below. |
@@ -603,7 +603,7 @@ Error code sources at v14.2:
603
603
  - `internal` (500), uncaught error during a request (e.g. config-load failure, DB corruption surfacing through `loadScanResult`).
604
604
  - `db-missing` (500), emitted by mutation endpoints (`PATCH /api/plugins/:id`, `PATCH /api/plugins/:bundleId/extensions/:extensionId`, `PATCH /api/plugins`) when the project DB is absent. Read-side routes uniformly degrade to the empty shape (`/api/scan`) or zero items (list endpoints) so they do not emit this code; mutation endpoints cannot persist without a DB so they fail fast instead of silently dropping the write.
605
605
  - `not-found` (404) on `PATCH /api/plugins/:id`, unknown plugin id (no built-in bundle, no discovered drop-in matches). The qualified-id form returns the same code when either segment misses. The bulk `PATCH /api/plugins` returns the same code with `error.details.id` set to the first offending id; the batch is rejected before any DB write.
606
- - `bad-query` (400) on `PATCH /api/plugins/:id`, granularity mismatch (bundle-level call against a `granularity: 'extension'` bundle, or qualified-id call against a `granularity: 'bundle'` bundle), malformed body (missing `enabled`, wrong type), unknown extension id under a known bundle. The bulk `PATCH /api/plugins` returns the same code for the same conditions (per-entry granularity mismatch, missing/typeless `enabled`, malformed `changes` array), with `error.details.id` set to the first offending entry's id.
606
+ - `bad-query` (400) on `PATCH /api/plugins/:id`, granularity mismatch (bundle-level call against a `granularity: 'extension'` bundle, the bare-id form is reserved for bundle granularity), malformed body (missing `enabled`, wrong type). The qualified-id sibling `PATCH /api/plugins/:bundleId/extensions/:extensionId` accepts any granularity and returns 404 `not-found` for an unknown extension id. The bulk `PATCH /api/plugins` returns 400 for the same per-entry conditions (granularity mismatch on a bare-id entry, missing/typeless `enabled`, malformed `changes` array), with `error.details.id` set to the first offending entry's id.
607
607
  - `locked` (403) on `PATCH /api/plugins/:id` and the qualified-id sibling, the target bundle id or qualified extension id is in the host lock-list (`src/server/locked-plugins.ts`). The list is hardcoded, host-only, and not user-editable; `GET /api/plugins` mirrors the same analyzer by stamping `locked: true` on the affected items. The bulk `PATCH /api/plugins` returns the same code with `error.details.id` set to the first locked entry; the batch is rejected before any DB write.
608
608
  - `bad-query` (400) on `POST /api/scan`, the server was started with `--no-built-ins` or `--no-plugins` (partial pipeline would persist a misleading DB).
609
609
  - `scan-busy` (409) on `POST /api/scan`, another scan (a watcher batch or another POST) is already in flight. Retry once the in-flight scan resolves; the WS `scan.completed` envelope is the unambiguous "now safe" signal.
@@ -0,0 +1,20 @@
1
+ {
2
+ "$schema": "https://skill-map.dev/spec/v0/conformance-case.schema.json",
3
+ "id": "extractor-emits-signal",
4
+ "description": "Signal IR resolver phase, end-to-end. A body that contains a single `[text](path)` markdown link MUST flow through the Signal IR resolver (Phase 2 of the active-lens migration): `core/markdown-link` emits a single-candidate Signal, the resolver materialises the winning candidate as a Link, and the result lands in `scan.links` with the same shape a direct `emitLink` call would have produced. Locks the contract that the Signal IR path coexists with the direct-emit path and produces indistinguishable Link rows.",
5
+ "fixture": "signal-ir-single-signal",
6
+ "invoke": {
7
+ "verb": "scan",
8
+ "flags": ["--json"]
9
+ },
10
+ "assertions": [
11
+ { "type": "exit-code", "value": 0 },
12
+ { "type": "json-path", "path": "$.schemaVersion", "equals": 1 },
13
+ { "type": "json-path", "path": "$.stats.linksCount", "equals": 1 },
14
+ { "type": "json-path", "path": "$.links[0].source", "equals": "source.md" },
15
+ { "type": "json-path", "path": "$.links[0].target", "equals": "target.md" },
16
+ { "type": "json-path", "path": "$.links[0].kind", "equals": "references" },
17
+ { "type": "json-path", "path": "$.links[0].confidence", "equals": 1.0 },
18
+ { "type": "json-path", "path": "$.links[0].sources[0]", "equals": "markdown-link" }
19
+ ]
20
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "$schema": "https://skill-map.dev/spec/v0/conformance-case.schema.json",
3
+ "id": "signal-collision-detection",
4
+ "description": "Signal IR resolver phase, range-overlap collision. A body that contains `[@./api.md](./api.md)` triggers a cross-extractor range overlap: `core/markdown-link` matches the whole bracketed-and-parenthesised span; `claude/at-directive` matches the `@./api.md` token INSIDE the bracket text. The two byte ranges overlap (the at-directive range is a strict subset of the markdown-link range). The kernel resolver picks ONE winner per the four-step tiebreak (`kind-priority` -> `higher-confidence` -> `longer-range` -> `earlier-declaration`); markdown-link wins on confidence (1.0 vs 0.85). The resolver materialises the winner as a Link, marks the loser's `resolution.outcome === 'rejected'` with `rejectedBy` naming the winner, and the built-in `core/signal-collision` analyzer surfaces the rejection as ONE `warn` issue attached to the source node. Locks the contract that range-overlap collisions surface to the operator instead of being silently merged.",
5
+ "fixture": "signal-ir-collision",
6
+ "invoke": {
7
+ "verb": "scan",
8
+ "flags": ["--json"]
9
+ },
10
+ "assertions": [
11
+ { "type": "exit-code", "value": 0 },
12
+ { "type": "json-path", "path": "$.schemaVersion", "equals": 1 },
13
+ { "type": "json-path", "path": "$.stats.linksCount", "equals": 1 },
14
+ { "type": "json-path", "path": "$.links[0].target", "equals": ".claude/agents/api.md" },
15
+ { "type": "json-path", "path": "$.links[0].sources[0]", "equals": "markdown-link" },
16
+ { "type": "json-path", "path": "$.stats.issuesCount", "equals": 1 },
17
+ { "type": "json-path", "path": "$.issues[0].analyzerId", "equals": "signal-collision" },
18
+ { "type": "json-path", "path": "$.issues[0].severity", "equals": "warn" }
19
+ ]
20
+ }
@@ -44,7 +44,7 @@ This file is hand-maintained. A CI check before spec release compares the schema
44
44
  | 33 | `plugins-doctor.schema.json` |, | 🔴 missing | Machine-readable output of `sm plugins doctor --json`. Aggregates per-status counts plus structured issue / warning lists. Direct conformance case pending: prime a scope with one healthy + one invalid-manifest drop-in plugin, run `sm plugins doctor --json`, assert the envelope validates and the invalid plugin appears under `issues[]`. Implementation tests at `src/test/plugins-cli.test.ts` cover the runtime behaviour. |
45
45
  | 34 | `conformance-result.schema.json` |, | 🔴 missing | Machine-readable output of `sm conformance run --json`. Self-referential by design (a conformance case would invoke the verb against itself); a direct case is deferred until the runner gains a meta-loopback mode. Implementation tests at `src/test/conformance-cli.test.ts` cover the envelope shape today. |
46
46
  | 35 | `user-settings.schema.json` | (indirect via `no-global-scope`) | 🟡 partial | Per-user / per-machine settings file at `~/.skill-map/settings.json` (the narrow `$HOME` exception, see `cli-contract.md` §User-settings file). Direct case is not added because alt-impls MAY choose to not ship an update-check feature, requiring them to produce this file would over-prescribe. The implementation-side AJV round-trip is covered by `src/test/user-settings-store.test.ts` (15 cases: defaults, malformed JSON, schemaVersion mismatch, wrong-type fields, unknown top-level keys, deep-merge writes, off-shape rejection). The behavioral counterpart (no global / user scope) lives at `no-global-scope` in the non-schema table below. |
47
- | 37 | `signal.schema.json` |, | 🔴 missing | Intermediate Representation (IR) emitted by extractors via `ctx.emitSignal()`; the kernel resolver phase consumes Signals and materialises Links. Opt-in: the existing `ctx.emitLink()` path coexists. Cases required (2): (a) `extractor-emits-signal`, an extractor emits a multi-candidate Signal and the resolver picks the highest-confidence candidate per the active Provider's `resolverRules`; (b) `signal-collision-detection`, two extractors emit Signals with overlapping `range` and the resolver surfaces the collision to analyzers via `IAnalyzerContext.signals`. Blocked by the kernel resolver phase landing in Phase 2 of the active-lens migration. |
47
+ | 37 | `signal.schema.json` | `extractor-emits-signal`, `signal-collision-detection` | covered | Intermediate Representation (IR) emitted by extractors via `ctx.emitSignal()`; the kernel resolver phase consumes Signals and materialises Links. Opt-in: the existing `ctx.emitLink()` path coexists. Phase 2.A wired the resolver end-to-end (filter -> rank -> overlap -> materialise + annotate); Phase 2.B + 2.C migrated all six link-emitter extractors (`claude/at-directive`, `claude/slash-command`, `core/markdown-link`, `core/annotations`, `core/mcp-tools`, `core/external-url-counter`); Phase 2.D added the `core/signal-collision` analyzer + the two cases. The cases cover (a) `extractor-emits-signal`, a markdown body with one `[text](path)` link materialises one Link via the Signal IR path; (b) `signal-collision-detection`, a body with both `[label](./api.md)` AND `@./api.md` at overlapping byte ranges triggers a cross-extractor collision, the resolver materialises ONE Link (markdown-link wins on confidence) and the loser's `resolution.rejectedBy` reaches the `core/signal-collision` analyzer which emits a `warn` issue naming WHO won, WHO lost, and WHY. |
48
48
 
49
49
  > **Note on Provider-owned schemas.** Per-kind frontmatter schemas (`skill`, `agent`, `command`, `note` for the built-in Claude Provider; other Providers MAY declare different kinds) live with the Provider that emits them, for the built-in Claude Provider, under `src/extensions/providers/claude/schemas/`. Those schemas are NOT counted in the spec's coverage matrix above; they belong to the Provider's own conformance suite at `src/extensions/providers/claude/conformance/coverage.md`. The same split applies to the cases that exercise Provider-specific kinds (`basic-scan`, `rename-high`, `orphan-detection`), they live in the Provider's `cases/` directory.
50
50
 
@@ -0,0 +1,6 @@
1
+ ---
2
+ name: api
3
+ description: Target of the architect's reference. Body content is irrelevant for the conformance assertion.
4
+ ---
5
+
6
+ API documentation.
@@ -0,0 +1,6 @@
1
+ ---
2
+ name: architect
3
+ description: Fixture for the Signal IR `signal-collision-detection` conformance case. Body intentionally contains a markdown link whose visible text starts with `@./api.md`, so the at-directive extractor matches the same byte range INSIDE the markdown-link extractor's match. Cross-extractor range overlap; the resolver picks ONE winner (markdown-link, higher confidence) and the loser surfaces as a signal-collision warn.
4
+ ---
5
+
6
+ Consult [@./api.md](./api.md) before deploying.
@@ -0,0 +1,6 @@
1
+ ---
2
+ name: source
3
+ description: Fixture for the Signal IR `extractor-emits-signal` conformance case. The single markdown link below must reach the graph as ONE Link row via the Signal IR resolver path.
4
+ ---
5
+
6
+ Read [the target file](./target.md) for more context.
@@ -0,0 +1,6 @@
1
+ ---
2
+ name: target
3
+ description: Target of the Signal IR conformance fixture. Body content is irrelevant; the assertion only checks that the link emitted by source.md materialises.
4
+ ---
5
+
6
+ Body.
package/db-schema.md CHANGED
@@ -173,7 +173,7 @@ The orchestrator consults this table on `sm scan --changed`: a node-level cache
173
173
 
174
174
  Primary key: `(node_path, extractor_id)`. Indexes: `ix_scan_extractor_runs_node`, `ix_scan_extractor_runs_extractor`.
175
175
 
176
- **Source-attribution interaction.** `scan_links.sources_json` carries the *short* extractor id the author wrote (e.g. `'slash'`); this table keys on the *qualified* form (`'core/slash'`). When a cached link is reshaped on reuse the orchestrator strips short ids whose owning Extractor is no longer registered (audit trail accuracy: a removed extractor must not stay attributed); links whose sole source is an uninstalled Extractor disappear; links whose sources include a missing-but-still-registered Extractor are dropped so the missing Extractor can re-emit fresh.
176
+ **Source-attribution interaction.** `scan_links.sources_json` carries the *short* extractor id the author wrote (e.g. `'slash'`); this table keys on the *qualified* form (`'core/slash-command'`). When a cached link is reshaped on reuse the orchestrator strips short ids whose owning Extractor is no longer registered (audit trail accuracy: a removed extractor must not stay attributed); links whose sole source is an uninstalled Extractor disappear; links whose sources include a missing-but-still-registered Extractor are dropped so the missing Extractor can re-emit fresh.
177
177
 
178
178
  ### `node_enrichments`
179
179
 
@@ -242,7 +242,7 @@ Cached nodes' rows survive untouched, they're neither orphaned (still in the liv
242
242
 
243
243
  NOT analogous to `state_plugin_kvs` (which is plugin-managed). Belongs to the `scan_*` family, sweep semantics replace pure replace-all but the data is still scan-derived.
244
244
 
245
- **Eager purge on disable.** `sm plugins disable <id>` calls `StoragePort.contributions.purgeByPlugin(pluginId, extensionId?)` immediately after persisting `config_plugins[<id>].enabled = false`. `extensionId` is omitted for bundle-granularity ids (e.g. `claude`) and supplied for qualified ids (e.g. `core/slash`), mirroring how the catalog sweep groups rows. The eager purge avoids the "I disabled the plugin but its chips are still rendered in the UI until I re-scan" gap. Re-enabling (`sm plugins enable <id>`) does NOT restore the rows, the next scan re-emits them, same as a cold start. Contributions are scan-derived, so this is cheap; for plugin-managed state (`state_plugin_kvs`, dedicated tables) the opposite policy holds, see `plugin-kv-api.md` § "disable does not drop data".
245
+ **Eager purge on disable.** `sm plugins disable <id>` calls `StoragePort.contributions.purgeByPlugin(pluginId, extensionId?)` immediately after persisting `config_plugins[<id>].enabled = false`. `extensionId` is omitted for bundle-granularity ids (e.g. `claude`) and supplied for qualified ids (e.g. `core/slash-command`), mirroring how the catalog sweep groups rows. The eager purge avoids the "I disabled the plugin but its chips are still rendered in the UI until I re-scan" gap. Re-enabling (`sm plugins enable <id>`) does NOT restore the rows, the next scan re-emits them, same as a cold start. Contributions are scan-derived, so this is cheap; for plugin-managed state (`state_plugin_kvs`, dedicated tables) the opposite policy holds, see `plugin-kv-api.md` § "disable does not drop data".
246
246
 
247
247
  ### `scan_node_tags`
248
248
 
package/index.json CHANGED
@@ -174,21 +174,23 @@
174
174
  }
175
175
  ]
176
176
  },
177
- "specPackageVersion": "0.34.0",
177
+ "specPackageVersion": "0.36.0",
178
178
  "integrity": {
179
179
  "algorithm": "sha256",
180
180
  "files": {
181
- "CHANGELOG.md": "50d7896784f0b209617e956bfe0dcbcc5af35d2dd6d76f5a0afdd96475fc4b27",
181
+ "CHANGELOG.md": "8e2a8f2cdfbe49b8ced8bf7ca118bc10bb50b7ce78c212f936e575f3cd582b0d",
182
182
  "README.md": "1c4b0ea58c4324f301043e9f5c36976a382d0bd2bc405a2e4e18463b0c50d946",
183
- "architecture.md": "b38c5281acaaab57fbe4869780fb0a09712dd6927ec9bdaee961061d34f3525a",
184
- "cli-contract.md": "2e20c2ac77c300b3f12759c3f36d56f4624862ff0abb34d100f1c3f00861bccc",
183
+ "architecture.md": "225b6b3d2377928ffd091bed6b974136fc8a45b603b886fa636766d77ef3c3a2",
184
+ "cli-contract.md": "6e43e0636b148b091ab21a7b54f271b4c6323431bfadb3eedb336a7fd339380d",
185
185
  "conformance/README.md": "6871dde25b5770ed945284c9e0f749e0768ec3f5ba4966bdb215985789e43887",
186
+ "conformance/cases/extractor-emits-signal.json": "34b4808c232d66a0eea0f5db7632a746681432b4f0995b6bf39e8d675538451c",
186
187
  "conformance/cases/kernel-empty-boot.json": "2a5be9c93143d07a16d998df09dcc8fa4ea2d2f9a0bff6417573ed5a770352c1",
187
188
  "conformance/cases/no-global-scope.json": "1284763988026d924c0bd78ba8a9f417dc88f5b7e9f4c2b642ae0c447758bfd4",
188
189
  "conformance/cases/orphan-markdown-fallback.json": "8ef6e49b7e6532bd845d9f54974a16e537cf98d355f0c5e4f4fb06abac3adcc5",
189
190
  "conformance/cases/plugin-missing-ui-rejected.json": "59a571a2e80c2bac2050eacbe740f4f3f125849dd242954508f011304cc3e036",
190
191
  "conformance/cases/sidecar-end-to-end.json": "dbb3640f95769a36b881855a261f918481edadea13a7eb0765c6090f2417a142",
191
- "conformance/coverage.md": "26aebe674304dca0d0173b1ed4af80be1f9384c18eb4d87574fc6021eee2e746",
192
+ "conformance/cases/signal-collision-detection.json": "38c6d553c6f82c1b624fb8a8e9b4fc72034fc47bc70f7f011b3b9136817e7388",
193
+ "conformance/coverage.md": "106468a6d9a65e5fdefbf75c8de4abecf35d5c08f2b6c44423741c60b723baea",
192
194
  "conformance/fixtures/orphan-markdown/.claude/agents/reviewer.md": "7f062731106f2d9811e4fffcf6ab44b8dfff4cfb16536a469514cc0664e832bf",
193
195
  "conformance/fixtures/orphan-markdown/ARCHITECTURE.md": "ec903666440bae65da3796b1158c92cfcdce22e0e09c3b20bb690176881a6ac4",
194
196
  "conformance/fixtures/plugin-missing-ui/.skill-map/plugins/bad-provider/kinds/markdown/kind.json": "6676a89bae5197e23cf50f1c11d596db558ac80f7334a7208fe57d8b92422251",
@@ -202,43 +204,47 @@
202
204
  "conformance/fixtures/sidecar-end-to-end/.claude/agents/stale.sm": "cb04f7f3103b4218b09fd4da92f7ea429588b04c1dac6a9547ce362263b11224",
203
205
  "conformance/fixtures/sidecar-example/agent-example.md": "741131403e8c9580d0b7a8c2446cb4502d01f80053b7a2092663de92431aaa82",
204
206
  "conformance/fixtures/sidecar-example/agent-example.sm": "8329950d49c69a1199bbe6c06e32b8513973e64207b0db8756b67301e6a1f1e2",
205
- "db-schema.md": "e56dab70f0469e8e6bd2440e8758c0436e710bc45c2ee812ac40a10b0c29ae77",
207
+ "conformance/fixtures/signal-ir-collision/.claude/agents/api.md": "7bdd260d82c2bf1ffc3324820e1b806684674981f9234f7c9f4f6aa61dd1cec5",
208
+ "conformance/fixtures/signal-ir-collision/.claude/agents/architect.md": "acc46b5b2dff73d98a354e4d53b5041164595deae466a4e2ce41d7c5a72f28fb",
209
+ "conformance/fixtures/signal-ir-single-signal/source.md": "1eda417b4c6eed372b66870e385c8d8cd631372b77cab7e996bb711e22218f89",
210
+ "conformance/fixtures/signal-ir-single-signal/target.md": "527137f2b4f46c0034b0edc8932cf8613d2bf22ffaaf78f01085c82a3baaebe3",
211
+ "db-schema.md": "9d265dacaf30649f0b7eddb6d31d1017da05a248524acc7b08eb3735e2e603c1",
206
212
  "interfaces/security-scanner.md": "e8049712b9cf7a07c786bf19f8f775f8ef9638f063f7fba5c7a8b1431b92f38e",
207
- "job-events.md": "84206168ac12b536d34470d62f8c8cba95dab181fee66d23203c2cf5dfbee716",
213
+ "job-events.md": "9d5b35d4c451a7f8eef9915d85316d924ac52f1c026a316cdda5f1099d496854",
208
214
  "job-lifecycle.md": "9c429121f98a07c8795f8979ed1abc5e5334e3f89db51585a8da55c527ef855b",
209
- "plugin-author-guide.md": "58331cdd07447f05775d2dd030f20e8276a1a227269c5f50b631230f83dec081",
215
+ "plugin-author-guide.md": "16c61dca29460075e95c0506f0a94c057a6e07018784f26b08e41e609b01f759",
210
216
  "plugin-kv-api.md": "1acc69ed82433a74e35ada61d63a6d7379fb61046ff83de1e0facbe884c64704",
211
217
  "prompt-preamble.md": "9dd4f6d1bc6a425f8782fcee10cbe75909e8d64e28781fda56c2fae909b02f40",
212
218
  "schemas/annotations.schema.json": "e39990d47f53e25a1b3a5587a5714486d0b819b8eeaac10d42783a675296aee1",
213
219
  "schemas/api/rest-envelope.schema.json": "926c63af10574599da5583ed067788b4a72be427788f0aa11b61022fb9649461",
214
- "schemas/bump-report.schema.json": "c2d853715d5f50098567bc23382a4e81baf78d589c6e1baf67d3b841e7f7d8ae",
220
+ "schemas/bump-report.schema.json": "2e6daff2435ef1a4af6c2c911fd3ce2a30ace40c8910dd6f675dd187fd40a99b",
215
221
  "schemas/conformance-case.schema.json": "f6d4c9fb92e79cb516eeeb9d042223572a3bd5ff8e7871a0becce13916f20cf6",
216
222
  "schemas/conformance-result.schema.json": "426998e4f5cb079778ca7d0233634667d4fbc5a7e399cc41211fabd768db8ee0",
217
223
  "schemas/execution-record.schema.json": "0d61e33f2dc1aaa4cc7337b5eac4ea8b9034022ce24bae9156c3c9f33204c250",
218
224
  "schemas/extensions/action.schema.json": "9f6c2427ce3f0d6fa329adf0f13129821116ab79a1d2a53f96464513ff044ebe",
219
225
  "schemas/extensions/analyzer.schema.json": "f9bed3ba1305b2b64da277dccfbe760f7c058c4bb62a2d845af9c75787f159f6",
220
- "schemas/extensions/base.schema.json": "8aaf1f8f1693d401e32feb91d4e064ff80ec7d4b0e3f15eff4202c708febaef4",
226
+ "schemas/extensions/base.schema.json": "46fbec5d305673524d7298d6374c44ebd76273d86f46a930c568d4250caaf97d",
221
227
  "schemas/extensions/extractor.schema.json": "5994088bf669321d2a7b8262c07cc94e05e5e2f49a235ae5389b7c66ecc1b2e1",
222
228
  "schemas/extensions/formatter.schema.json": "d6d417df20260e5ddfe71f104b11a45873869706f86372c3c3c78c583e06e8d5",
223
229
  "schemas/extensions/hook.schema.json": "76bf2c07f9e689b3fd1c67cbad4516a4df10604f07103759e82670e5213ddcdf",
224
230
  "schemas/extensions/provider-kind.schema.json": "add3c5648721e67887eb971a76b39319628effac6315cffd51f7dcf679810740",
225
- "schemas/extensions/provider.schema.json": "5fd8f0db17b3d4d23930cbba6f7dbc61feea0ea856fb720ccb9f07a544d18495",
231
+ "schemas/extensions/provider.schema.json": "ebf137271d46f7100c8c520b6aa1851b131a3192a2dea43a17fe82b790d263fb",
226
232
  "schemas/frontmatter/base.schema.json": "df0056a9478514a0db7a705e59868fa4f67673ac1cc9c9da979de4237cdd62a1",
227
233
  "schemas/history-stats.schema.json": "5170dec0299f3d04382a38079a27b1f26300a6b95fdb1ea0fae11050ad9f0574",
228
234
  "schemas/input-types.schema.json": "c713b768d0b0e3d0c764afb401189f7fb624a82b4e988b73aab015cf9c67c01f",
229
235
  "schemas/issue.schema.json": "fa3344e75f1c3a5304291ca355bb973046552a68871ad6eb4edafca1cd9e1be8",
230
236
  "schemas/job.schema.json": "e43e1761c99920beffe1de12ef8f32fe29f97838bd8686742b637c19c4dbb395",
231
- "schemas/link.schema.json": "2450732829652ece58c853ca97711a8bbb64ac65e52e89e3b51024c073dddc9a",
232
- "schemas/node.schema.json": "8d0635a80c8e6f22be7fa04071654e857fc052869de15839f4b29593aa4527a3",
237
+ "schemas/link.schema.json": "8a01925a9a7c00bbe41cdcbf8eea9269955570d41484da93d4193232a7aa9070",
238
+ "schemas/node.schema.json": "4d7c107ed9cd2f1b7cc4d716c547c06a00ed776bd6092d3979cac634cb5326a5",
233
239
  "schemas/plugins-doctor.schema.json": "c1d92f30fdb0080e8cd8f7dc5d43e01aae02a16640bc5eb04811c337a275de58",
234
240
  "schemas/plugins-registry.schema.json": "cca7ae65f0c22510ea27ea5ae34e0074f5beb5871a57b005b6b831e6ceaff5c0",
235
- "schemas/project-config.schema.json": "e613d302a763e8815863ae58ebfe997b18a3aa0c329f85d9554dfa8d97fdd326",
241
+ "schemas/project-config.schema.json": "f2a38a82fe47c8a965e6b73208e9be996d3d3545fdcd92f8a136f1d1e5abd3fb",
236
242
  "schemas/refresh-report.schema.json": "54519b8caf86ba84c182f9565be9b5084bc1631ae05e9217ee18f34c0039fff3",
237
243
  "schemas/report-base-deterministic.schema.json": "9d318d0181d121097c906ef3af1c52d71c782740bd04cf23418d7627ce2c3ed5",
238
244
  "schemas/report-base.schema.json": "a1021e9a59b4df9f99cd92454d797e88469766e7d49f52d231c4645ffdfdad8f",
239
245
  "schemas/scan-result.schema.json": "214bc12fbb9946642cbba3b23513dade60e7d6a5b6a9ed3dd0818f135b450185",
240
246
  "schemas/sidecar.schema.json": "8856c387477340efbdd0a585d74bfb07a99ba15b9ce593cc67d9efebc67c6bfc",
241
- "schemas/signal.schema.json": "2540c0014a78ebc902eb71b6815c35fa006c714b57d07dcb7415bd3c3da185b5",
247
+ "schemas/signal.schema.json": "7a9d36f13ee6fa269da7ab97e45d9831d10e0570e3f61005617128b423a4d4d8",
242
248
  "schemas/summaries/agent.schema.json": "bf540f9a804f2b43756ab33b7deb0462620d26e88cc9379c75a5f87d3b1b47d8",
243
249
  "schemas/summaries/command.schema.json": "c26f6965f77c5058608feb5e7b9f807395de8e015b0dea5efcdb44cb1820551a",
244
250
  "schemas/summaries/hook.schema.json": "58420ec485e152fdd21fa3d87337ad74b0d81a48d3b83dd072d4a2d196f78573",
package/job-events.md CHANGED
@@ -412,7 +412,7 @@ Emitted once per registered Analyzer, after every issue has been validated.
412
412
  "runId": "...",
413
413
  "jobId": null,
414
414
  "data": {
415
- "analyzerId": "core/superseded"
415
+ "analyzerId": "core/node-superseded"
416
416
  }
417
417
  }
418
418
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skill-map/spec",
3
- "version": "0.34.0",
3
+ "version": "0.36.0",
4
4
  "description": "JSON Schemas, prose contracts, and conformance suite for the skill-map specification.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -115,19 +115,25 @@ Concrete examples for the reference impl's bundled extensions:
115
115
  |---|---|---|
116
116
  | Claude Provider | `claude` | `claude/claude` |
117
117
  | Annotations extractor | `annotations` | `core/annotations` |
118
- | Slash extractor | `slash` | `core/slash` |
119
- | At-directive extractor | `at-directive` | `core/at-directive` |
118
+ | Slash-command extractor | `slash-command` | `claude/slash-command` |
119
+ | At-directive extractor | `at-directive` | `claude/at-directive` |
120
120
  | Markdown-link extractor | `markdown-link` | `core/markdown-link` |
121
121
  | External-URL counter | `external-url-counter` | `core/external-url-counter` |
122
- | Broken-ref analyzer | `broken-ref` | `core/broken-ref` |
122
+ | Reference-broken analyzer | `reference-broken` | `core/reference-broken` |
123
123
  | Trigger-collision analyzer | `trigger-collision` | `core/trigger-collision` |
124
124
  | ASCII formatter | `ascii` | `core/ascii` |
125
- | Validate-all analyzer | `validate-all` | `core/validate-all` |
125
+ | Schema-violation analyzer | `schema-violation` | `core/schema-violation` |
126
126
 
127
127
  Built-ins split between two namespaces:
128
128
 
129
- - **`core/`**, kernel-internal primitives, platform-agnostic. Owns every built-in analyzer (including `validate-all`), the ASCII formatter, and the cross-vendor extractors (`annotations`, `slash`, `at-directive`, `markdown-link`, `external-url-counter`) any Provider can rely on.
130
- - **`claude/`**, the Claude Code Provider bundle: the Provider that classifies `.claude/{agents,commands,skills}` paths and parses their frontmatter. Other vendor bundles (`antigravity`, `openai`, `agent-skills`) follow the same shape, Provider only, since the syntax their nodes use is shared with Claude and lives in `core`.
129
+ - **`core/`**, kernel-internal primitives, platform-agnostic. Owns every built-in analyzer (including `schema-violation`), the ASCII formatter, and the cross-vendor extractors (`annotations`, `markdown-link`, `external-url-counter`) any Provider can rely on.
130
+ - **`claude/`**, the Claude Code Provider bundle: the Provider that classifies `.claude/{agents,commands,skills}` paths and parses their frontmatter, plus the Claude-flavoured extractors (`slash-command`, `at-directive`). Other vendor bundles (`antigravity`, `openai`, `agent-skills`) follow the same shape, Provider only, since the syntax their nodes use is shared with Claude.
131
+
132
+ ### Extension id shape
133
+
134
+ The naming convention applied to every built-in extension id is **`<domain>-<detail>`** (general to specific). The leftmost segment names the entity the extension reasons about (`node`, `link`, `annotation`, `reference`, `name`, `field`, `tools`, ...), the rest narrows the specific behaviour or signal it produces. Examples: `annotation-orphan`, `link-counter`, `node-stability`, `name-reserved`, `reference-broken`.
135
+
136
+ Authors building their own plugins are not required to follow this pattern, but doing so makes `sm plugins list` self-grouping and the qualified ids predictable. Verb-style ids (e.g. `bump`, `mark-superseded`) are deliberately avoided on built-ins: even Actions live under their entity domain (`node-bump`, `node-supersede`) so the catalog reads as a structured list rather than a mix of nouns and imperatives.
131
137
 
132
138
  For your own plugin, the `id` you declare in `plugin.json` is the namespace for every extension the plugin contains. If your manifest declares `id: "my-plugin"` and your extension file declares `id: "foo-extractor"`, the kernel registers it as `my-plugin/foo-extractor`. You do **not** write the qualifier yourself, the loader injects it.
133
139
 
@@ -153,12 +159,12 @@ Every plugin and every built-in bundle declares a **granularity** that controls
153
159
  | Granularity | Toggle key | When to use |
154
160
  |---|---|---|
155
161
  | `bundle` (default) | the bundle id alone (e.g. `my-plugin`, `claude`) | The plugin's extensions form a coherent product (e.g. a Provider and the extractors that decode its native syntax). The user wants one switch. **95% of plugins.** |
156
- | `extension` | the qualified extension id (`<bundle>/<ext-id>`, e.g. `core/superseded`, `my-plugin/orphan-skill`) | The plugin ships several orthogonal capabilities a user might reasonably want piecemeal. **Built-in `core` is the canonical example**, the spec promises every kernel built-in is removable, so each one toggles independently. |
162
+ | `extension` | the qualified extension id (`<bundle>/<ext-id>`, e.g. `core/node-superseded`, `my-plugin/orphan-skill`) | The plugin ships several orthogonal capabilities a user might reasonably want piecemeal. **Built-in `core` is the canonical example**, the spec promises every kernel built-in is removable, so each one toggles independently. |
157
163
 
158
164
  Built-in mapping:
159
165
 
160
166
  - **`claude`** / **`antigravity`** / **`openai`** / **`agent-skills`**, `granularity: 'bundle'`. Each vendor Provider bundle is enabled or disabled as a whole; today every such bundle ships only its Provider, so the toggle flips classification + frontmatter parsing for that platform.
161
- - **`core`**, `granularity: 'extension'`. `sm plugins disable core/superseded` flips just the supersession analyzer; every other core extension (every other analyzer, the ASCII formatter, the cross-vendor extractors) stays live.
167
+ - **`core`**, `granularity: 'extension'`. `sm plugins disable core/node-superseded` flips just the supersession analyzer; every other core extension (every other analyzer, the ASCII formatter, the cross-vendor extractors) stays live.
162
168
 
163
169
  Per-verb behaviour:
164
170
 
@@ -167,9 +173,12 @@ Per-verb behaviour:
167
173
  | `sm plugins enable claude` | OK, flips the bundle. | Rejected: `'core' has granularity=extension; use sm plugins enable core/<ext-id>`. |
168
174
  | `sm plugins enable claude/claude` | Rejected: `'claude' has granularity=bundle; use sm plugins enable claude`. | n/a (no bundle of granularity=bundle accepts qualified ids) |
169
175
  | `sm plugins disable core` | n/a | Rejected: same directed message as the bundle row above. |
170
- | `sm plugins disable core/superseded` | n/a | OK, persists `config_plugins['core/superseded'].enabled = 0`. |
176
+ | `sm plugins disable core/node-superseded` | n/a | OK, persists `config_plugins['core/node-superseded'].enabled = 0`. |
177
+
178
+ Resolution order per id is the same as for plugin enabled-state: DB override (`config_plugins`) > settings.json (`#/plugins/<id>/enabled`) > installed default (`true`). `settings.json#/plugins` keys are arbitrary strings (no AJV pattern), so both bare and qualified ids are accepted there. Granularity controls how the per-id results compose into the effective runtime state:
171
179
 
172
- Resolution order is the same as for plugin enabled-state: DB override (`config_plugins`) > settings.json (`#/plugins/<id>/enabled`) > installed default (`true`). For granularity=extension bundles the row key is the qualified id; for granularity=bundle bundles the row key is the bundle id. `settings.json#/plugins` keys are arbitrary strings (no AJV pattern), so both forms are accepted there too.
180
+ - **extension granularity** (`core`): only qualified ids matter. Each extension is independently toggle-able.
181
+ - **bundle granularity** (`claude`, `antigravity`, ...): the bundle id is the coarse kill-switch. When the bundle id resolves to `false`, every extension in the bundle is disabled regardless of per-extension overrides. When the bundle resolves to `true`, each extension respects its qualified-id override (default `true`). This lets the Settings UI refine individual extractors inside a bundle (e.g. disable `claude/at-directive` while keeping the provider live) without an asymmetric CLI surface, `sm plugins enable/disable <bare-id>` still rejects qualified ids against bundle granularity, the per-extension axis is reserved for the UI and direct `settings.json` / `config_plugins` edits.
173
182
 
174
183
  `sm plugins enable/disable --all` operates only on top-level bundle ids (the default-enabled set every user can see); it never expands to qualified `<bundle>/<ext>` keys. The "disable every kernel built-in at once" intent is served by `--no-built-ins` on `sm scan` and friends; `--all` is the macro on user-toggle-able units, not on every individual extension.
175
184
 
@@ -340,7 +349,7 @@ You can read `ctx.node.sidecar.*` freely, the kernel's per-`(node, extractor)` c
340
349
  > **Pick a syntax that doesn't collide with built-ins.** The built-in `at-directive` and `slash` extractors claim the `@` and `/` prefixes with LLM-aligned semantics:
341
350
  >
342
351
  > - **`core/at-directive`**: bare handles (`@team-lead`) and namespaced agents (`@my-plugin/foo-extractor`, `@skill-map:explore`) emit `mentions` links; file-flavoured tokens (`@docs/api/v1.md`, `@./readme.md`, `@../parent.md`, `@/abs/path.md`) emit `references` links so the graph treats them as file pointers, not entity mentions, the same way Claude Code / Antigravity CLI / Cursor would resolve them. The kind dispatch keys on (a) an explicit relative / absolute path prefix or (b) a known file extension at the tail.
343
- > - **`core/slash`**: bare commands (`/scan`, `/skill-map:explore`) emit `invokes`; tokens whose next character is another `/` or any other identifier char are dropped as path segments (`/Volumes/disk`, `/api/v1/items`).
352
+ > - **`core/slash-command`**: bare commands (`/scan`, `/skill-map:explore`) emit `invokes`; tokens whose next character is another `/` or any other identifier char are dropped as path segments (`/Volumes/disk`, `/api/v1/items`).
344
353
  > - **Both extractors strip fenced code blocks and inline backticks before matching**, so author-marked literal payload never registers as invocation surface.
345
354
  >
346
355
  > A new extractor that also matches one of those prefixes will likely fire on the same input, and if the two emit different `target` shapes the kernel raises a `trigger-collision` error. The example below uses a wikilink-style `[[ref:<name>]]` pattern to side-step this; reserve `@` and `/` for the built-ins.
@@ -404,14 +413,14 @@ export default {
404
413
  };
405
414
  ```
406
415
 
407
- > **`recommendedActions`, analyzer-side hint, not a precondition.** An Analyzer MAY declare `recommendedActions: string[]` with the qualified ids (`<pluginId>/<id>`) of the per-node Actions that resolve its findings. The built-in `core/annotation-stale` analyzer declares `['core/bump']` because bumping the node refreshes the `for.*` hashes that drove the warning. The UI surfaces matching Actions in the node inspector under "Recommended for issues" alongside the always-applicable list driven by `Action.precondition`.
416
+ > **`recommendedActions`, analyzer-side hint, not a precondition.** An Analyzer MAY declare `recommendedActions: string[]` with the qualified ids (`<pluginId>/<id>`) of the per-node Actions that resolve its findings. The built-in `core/annotation-stale` analyzer declares `['core/node-bump']` because bumping the node refreshes the `for.*` hashes that drove the warning. The UI surfaces matching Actions in the node inspector under "Recommended for issues" alongside the always-applicable list driven by `Action.precondition`.
408
417
  >
409
418
  > The two surfaces are distinct:
410
419
  >
411
420
  > - **`Action.precondition`**, declared on the Action side, answers "which nodes does this Action apply to?". Always evaluated against the node the inspector is focused on.
412
421
  > - **`Analyzer.recommendedActions`**, declared on the Analyzer side, answers "which Actions are the natural fix when THIS analyzer fires?". Surfaces only when the analyzer emitted an issue against the focused node.
413
422
  >
414
- > Each entry MUST be the qualified id of a registered Action. The kernel logs `recommended-action-missing` (an `extension.error` event) when a referenced action is not loaded, and the analyzer stays registered, only the recommendation hint is dropped. Project-level cleanup verbs (orphan file prune, contribution relink) are CLI commands, not Actions, and are NOT linked through this field. Omit `recommendedActions` when the issue is a deliberate user declaration with no "fix" (e.g. `core/superseded` surfaces user-authored supersession statements).
423
+ > Each entry MUST be the qualified id of a registered Action. The kernel logs `recommended-action-missing` (an `extension.error` event) when a referenced action is not loaded, and the analyzer stays registered, only the recommendation hint is dropped. Project-level cleanup verbs (orphan file prune, contribution relink) are CLI commands, not Actions, and are NOT linked through this field. Omit `recommendedActions` when the issue is a deliberate user declaration with no "fix" (e.g. `core/node-superseded` surfaces user-authored supersession statements).
415
424
 
416
425
  ### Formatters
417
426
 
@@ -869,15 +878,15 @@ Two plugins claiming the same `(key, location: 'root', ownership: 'exclusive')`
869
878
 
870
879
  This is the only fatal path on the plugin-load surface. Every other failure mode (manifest invalid, schema invalid, dynamic-import failure, id collision) is per-plugin and the kernel keeps booting on the survivors.
871
880
 
872
- ### Tier-1 typo guard (`core/unknown-field`)
881
+ ### Tier-1 typo guard (`core/annotation-field-unknown`)
873
882
 
874
- The built-in `core/unknown-field` Analyzer walks every parsed `.sm` and emits a `warn` issue per truly-unknown key. Three surfaces are checked:
883
+ The built-in `core/annotation-field-unknown` Analyzer walks every parsed `.sm` and emits a `warn` issue per truly-unknown key. Three surfaces are checked:
875
884
 
876
885
  1. Inside `annotations:`, keys not in `annotations.schema.json`'s curated catalog (the 10 conventional fields). Plugins do NOT contribute to `annotations:`; that block is skill-map-curated.
877
886
  2. At the sidecar root, keys outside the four reserved blocks (`for`, `annotations`, `settings`, `audit`) that are also NOT a registered plugin namespace `<plugin-id>:` AND NOT a registered `location: 'root'` contribution.
878
887
  3. Inside a registered `<plugin-id>:` namespace, values that fail the schema declared by the owning plugin's `annotationContributions[<key>].schema`.
879
888
 
880
- The analyzer never blocks a scan; advisories surface through the standard issue channel (CLI, UI, REST). When you ship a contribution, the loader compiles your inline schema, the runtime catalog publishes it, and `core/unknown-field` automatically validates user writes against your declaration.
889
+ The analyzer never blocks a scan; advisories surface through the standard issue channel (CLI, UI, REST). When you ship a contribution, the loader compiles your inline schema, the runtime catalog publishes it, and `core/annotation-field-unknown` automatically validates user writes against your declaration.
881
890
 
882
891
  ### Runtime catalog accessor
883
892
 
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "https://skill-map.dev/spec/v0/bump-report.schema.json",
4
4
  "title": "BumpReport",
5
- "description": "Report shape produced by the built-in deterministic `bump` Action (Step 9.6.3, Decision #125). Extends `report-base-deterministic.schema.json` (the deterministic counterpart to `report-base.schema.json`, which carries the LLM-only `confidence` + `safety` fields). The bump Action returns one of three concrete shapes, distinguished by `ok` / `noop` / `reason`: success-with-write (`{ ok: true, version }`), silent-no-op under `force` (`{ ok: true, noop: true }`), or refusal (`{ ok: false, reason: 'fresh' }`).",
5
+ "description": "Report shape produced by the built-in deterministic `node-bump` Action (Step 9.6.3, Decision #125). Extends `report-base-deterministic.schema.json` (the deterministic counterpart to `report-base.schema.json`, which carries the LLM-only `confidence` + `safety` fields). The `node-bump` Action returns one of three concrete shapes, distinguished by `ok` / `noop` / `reason`: success-with-write (`{ ok: true, version }`), silent-no-op under `force` (`{ ok: true, noop: true }`), or refusal (`{ ok: false, reason: 'fresh' }`).",
6
6
  "allOf": [{ "$ref": "report-base-deterministic.schema.json" }],
7
7
  "type": "object",
8
8
  "additionalProperties": true,
@@ -19,7 +19,7 @@
19
19
  "type": "object",
20
20
  "required": ["schema"],
21
21
  "additionalProperties": false,
22
- "description": "Optional, opt-in declaration of a single sidecar annotation key contributed by this extension. The key is the extension's id (i.e. the leaf folder name). Extensions that need multiple annotation keys split into multiple extensions. The runtime exposes the registered catalog via `kernel.getRegisteredAnnotationKeys()` for UI autocomplete; the built-in `core/unknown-field` Analyzer emits a warning on truly unrecognized keys (typo guard). See `plugin-author-guide.md` §Annotation contribution.",
22
+ "description": "Optional, opt-in declaration of a single sidecar annotation key contributed by this extension. The key is the extension's id (i.e. the leaf folder name). Extensions that need multiple annotation keys split into multiple extensions. The runtime exposes the registered catalog via `kernel.getRegisteredAnnotationKeys()` for UI autocomplete; the built-in `core/annotation-field-unknown` Analyzer emits a warning on truly unrecognized keys (typo guard). See `plugin-author-guide.md` §Annotation contribution.",
23
23
  "properties": {
24
24
  "schema": {
25
25
  "type": "object",
@@ -15,6 +15,25 @@
15
15
  "description": "Path globs (relative to scope root) that this Provider claims. **Enforcement-grade since structure-as-truth refactor**: a Provider declaring `roots` only receives files that match at least one entry of the array; a Provider without `roots` acts as a fallback and receives files unmatched by every other Provider's roots. Two Providers whose `roots` both match the same file produce a `provider-ambiguous` issue and the file stays unclassified. `sm plugins doctor` warns when no file matched a specific Provider's roots in the latest scan.",
16
16
  "items": { "type": "string" }
17
17
  },
18
+ "gatedByActiveLens": {
19
+ "type": "boolean",
20
+ "description": "Lens gating flag for vendor providers. When `true`, this Provider's `classify()` only runs (and the walker only iterates its territory) if `provider.id === activeProvider` (the project's active lens). When `false` or omitted (default), the Provider is universal and classifies unconditionally. Vendor providers (`claude`, `openai`, `antigravity`) MUST set this to `true`: the actual runtimes never read each other's on-disk formats (Claude Code does not consume `.codex/`; Codex CLI does not consume `.claude/`), and offering every file to every provider fabricates cross-vendor graph edges the runtimes themselves reject. Universal providers (open-standard `agent-skills`, markdown fallback `core/markdown`, any future format-based fallback) keep this `false` so their territory is consumed by every vendor and they run on every scan. When `activeProvider === null` (no lens resolved), the walker bypasses the gate entirely and every gated Provider runs, mirroring the permissive extractor-side fallback for unlensed projects. Affects classification ONLY; extractors continue to filter via their own `precondition.provider` allowlist."
21
+ },
22
+ "resolverRules": {
23
+ "type": "object",
24
+ "description": "Per-provider ranking hints consumed by the Signal IR resolver phase. Drives intra-Signal candidate ranking AND cross-Signal range-overlap tiebreaks. Optional; absent means the resolver uses the default tiebreak chain (confidence DESC -> range length DESC -> extractor registration order). Distinct from the post-walk `resolution` confidence-lift matrix on Link (which runs on already-emitted edges, not Signal candidates): `resolverRules` decides which candidate becomes a Link in the first place; `resolution` lifts confidence on links that survived. The two surfaces share no mechanism and intentionally do not compose.",
25
+ "additionalProperties": false,
26
+ "properties": {
27
+ "kindPriority": {
28
+ "type": "array",
29
+ "description": "When present, the resolver ranks candidates whose `kind` appears earlier in this array ABOVE candidates whose `kind` appears later. Candidates whose `kind` is absent from the array drop to the end (after every listed kind). Example: a Provider that wants `invokes` edges to win against `mentions` and `references` of the same range declares `['invokes', 'references', 'mentions']`. Ties inside the same `kindPriority` bucket fall through to the confidence -> range length -> declaration order tiebreaks.",
30
+ "items": {
31
+ "type": "string",
32
+ "enum": ["invokes", "references", "mentions", "supersedes"]
33
+ }
34
+ }
35
+ }
36
+ },
18
37
  "read": {
19
38
  "type": "object",
20
39
  "required": ["extensions", "parser"],
@@ -62,6 +62,40 @@
62
62
  "raw": {
63
63
  "type": ["string", "null"],
64
64
  "description": "Verbatim matched substring from the source body. Optional, for debugging and UI display."
65
+ },
66
+ "occurrences": {
67
+ "type": "array",
68
+ "description": "Every syntactic site in the source body that contributed to this edge. One entry per detection. Accumulated by the post-walk dedup when two extractors converge on the same `(source, target, kind, normalizedTrigger)` key. Empty / absent for synthetic links (frontmatter / sidecar-derived). The `core/reference-redundant` analyzer walks this array to flag multi-form references to the same target.",
69
+ "items": {
70
+ "type": "object",
71
+ "required": ["extractor", "originalTrigger"],
72
+ "additionalProperties": false,
73
+ "properties": {
74
+ "extractor": {
75
+ "type": "string",
76
+ "description": "Extractor id that observed this occurrence. Matches an entry in the parent link's `sources[]`."
77
+ },
78
+ "originalTrigger": {
79
+ "type": "string",
80
+ "description": "Verbatim author substring (sigil included)."
81
+ },
82
+ "location": {
83
+ "type": ["object", "null"],
84
+ "description": "Position of the occurrence in the body, when the extractor records it.",
85
+ "required": ["line"],
86
+ "additionalProperties": false,
87
+ "properties": {
88
+ "line": { "type": "integer", "minimum": 1 },
89
+ "column": { "type": "integer", "minimum": 1 },
90
+ "offset": { "type": "integer", "minimum": 0 }
91
+ }
92
+ }
93
+ }
94
+ }
95
+ },
96
+ "resolvedTarget": {
97
+ "type": ["string", "null"],
98
+ "description": "Node path the link resolved to, per the post-walk `liftResolvedLinkConfidence` transform. Equal to `target` for path-style links; differs for trigger-style links (`@foo`, `/cmd`) where `target` keeps the authored trigger and `resolvedTarget` carries the resolved node path. Absent / null when the link is unresolved (broken)."
65
99
  }
66
100
  }
67
101
  }
@@ -59,6 +59,30 @@
59
59
  "minimum": 0,
60
60
  "description": "http/https URLs in the body after normalization and exact-match dedup."
61
61
  },
62
+ "externalRefs": {
63
+ "type": "array",
64
+ "description": "Distinct external URLs (http/https) the body references, in extractor-order (first-seen wins, dedup is by normalised URL). The denormalised `externalRefsCount` rides alongside and MUST equal `externalRefs.length` when both are present. Surfaced via `/api/nodes` so the inspector can list every external URL without a second round-trip.",
65
+ "items": {
66
+ "type": "object",
67
+ "required": ["url"],
68
+ "additionalProperties": false,
69
+ "properties": {
70
+ "url": {
71
+ "type": "string",
72
+ "description": "Normalised URL (lowercased host, fragment stripped)."
73
+ },
74
+ "line": {
75
+ "type": "integer",
76
+ "minimum": 1,
77
+ "description": "1-indexed line of the occurrence in the source body, when known."
78
+ },
79
+ "originalTrigger": {
80
+ "type": "string",
81
+ "description": "Author substring (almost always equals `url`)."
82
+ }
83
+ }
84
+ }
85
+ },
62
86
  "sidecar": {
63
87
  "$ref": "#/$defs/sidecarOverlay",
64
88
  "description": "Step 9.6.2, co-located `.sm` sidecar overlay. Carries presence flag, drift status (null when no sidecar), and the parsed `annotations:` block (null when absent or empty). The kernel re-derives `status` on every scan from the live hashes; clients should treat it as authoritative for the snapshot but never persist it across scans."
@@ -28,6 +28,11 @@
28
28
  "type": "string",
29
29
  "description": "The active provider lens for this project. Exactly one provider id (from the enabled `providers` list) sees the project at any time. All extractors, classifiers, and resolution rules belonging to other providers are skipped during scan. Changing this triggers an atomic drop of the `scan_*` DB zone followed by a fresh scan under the new lens; `state_*` and `config_*` zones survive the switch. When absent on a fresh project, the kernel auto-detects from filesystem (presence of `.claude/`, `.codex/`, AGENTS.md, `.cursor/`, etc.) and prompts via the CLI / UI if the heuristic is ambiguous. Google's Antigravity CLI has no vendor-specific marker and is selected manually. Stability: experimental."
30
30
  },
31
+ "activeProviderMarkers": {
32
+ "type": "array",
33
+ "items": { "type": "string" },
34
+ "description": "Internal-state snapshot, NOT normally hand-edited. The set of provider ids whose filesystem markers were present on disk at the moment `activeProvider` was set (whether by auto-detect, the interactive prompt, or `sm config set activeProvider <id>`). On every subsequent scan the runtime re-detects markers and compares against this snapshot; when the diff is non-empty (new markers appeared, or recorded ones disappeared) it emits ONE soft warning before the scan and continues with the cached lens. The warn is informational and never blocks the scan. Absent on legacy projects, the runtime backfills the snapshot lazily on the next scan without warning. Stability: experimental."
35
+ },
31
36
  "roots": {
32
37
  "type": "array",
33
38
  "description": "Directories (relative to the config file) to scan. Defaults to the scope root.",
@@ -65,7 +70,7 @@
65
70
  "referencePaths": {
66
71
  "type": "array",
67
72
  "items": { "type": "string" },
68
- "description": "**Privacy-sensitive, project-local only** (per `core/config/helper:PROJECT_LOCAL_ONLY_KEYS`) when entries point outside the project, opens read-only disk access for link validation only. Default `[]`. Directories walked in parallel by the scan to collect existing absolute paths into a side set; the kernel passes the set to analyzers via `IAnalyzerContext.referenceablePaths` so `core/broken-ref` can resolve a link against the filesystem when the in-graph lookup misses. Files under these paths are NOT parsed and NOT indexed as nodes, the only effect is suppressing `broken-ref` warnings for targets that exist on disk outside the scan. Reference impl gates writes that introduce out-of-project paths behind `--yes` (CLI) and a confirm dialog (UI). **Stripped with a warning when found in the committed `project` layer**, paths are inherently per-machine and must not travel via the shared repo."
73
+ "description": "**Privacy-sensitive, project-local only** (per `core/config/helper:PROJECT_LOCAL_ONLY_KEYS`) when entries point outside the project, opens read-only disk access for link validation only. Default `[]`. Directories walked in parallel by the scan to collect existing absolute paths into a side set; the kernel passes the set to analyzers via `IAnalyzerContext.referenceablePaths` so `core/reference-broken` can resolve a link against the filesystem when the in-graph lookup misses. Files under these paths are NOT parsed and NOT indexed as nodes, the only effect is suppressing `broken-ref` warnings for targets that exist on disk outside the scan. Reference impl gates writes that introduce out-of-project paths behind `--yes` (CLI) and a confirm dialog (UI). **Stripped with a warning when found in the committed `project` layer**, paths are inherently per-machine and must not travel via the shared repo."
69
74
  }
70
75
  }
71
76
  },
@@ -23,7 +23,8 @@
23
23
  "additionalProperties": false,
24
24
  "properties": {
25
25
  "start": { "type": "integer", "minimum": 0, "description": "Inclusive byte offset of the first character." },
26
- "end": { "type": "integer", "minimum": 0, "description": "Exclusive byte offset one past the last character." }
26
+ "end": { "type": "integer", "minimum": 0, "description": "Exclusive byte offset one past the last character." },
27
+ "line": { "type": "integer", "minimum": 1, "description": "Optional 1-indexed line number containing `start`. Extractors that already compute line tracking (via `computeLineStarts` / `lineFor`) populate this so the resolver's materialised Link can preserve `link.location.line` without re-walking the body. Absent when the extractor does not track lines, the resolver falls back to `1`." }
27
28
  }
28
29
  },
29
30
  "fieldPath": {
@@ -84,6 +85,77 @@
84
85
  }
85
86
  }
86
87
  }
88
+ },
89
+ "resolution": {
90
+ "type": "object",
91
+ "description": "Resolver outcome annotation, populated by the kernel resolver phase after `resolveSignals` runs. Absent before the resolver fires (raw extractor output). When `outcome` is `materialised`, `winnerIndex` points into `candidates[]` and a corresponding `Link` was emitted. When `outcome` is `rejected`, one of `rejectedBy` / `extractorDisabled` / `belowFloor` carries the reason. Both materialised and rejected Signals remain accessible to analyzers via `IAnalyzerContext.signals` so the `core/signal-collision` analyzer can surface losers as `warn` issues. Phase 4+ adds the `extractorDisabled` and `belowFloor` paths; today only the `rejectedBy` / range-overlap path populates rejection state.",
92
+ "required": ["outcome"],
93
+ "additionalProperties": false,
94
+ "properties": {
95
+ "outcome": {
96
+ "type": "string",
97
+ "enum": ["materialised", "rejected"],
98
+ "description": "Whether the resolver materialised this Signal's winning candidate as a `Link` (`materialised`) or rejected the whole Signal (`rejected`)."
99
+ },
100
+ "winnerIndex": {
101
+ "type": "integer",
102
+ "minimum": 0,
103
+ "description": "Index into `candidates[]` of the winning candidate when `outcome === 'materialised'`. Absent on rejection."
104
+ },
105
+ "rejectedBy": {
106
+ "type": "object",
107
+ "description": "Set when the Signal lost a cross-extractor range-overlap collision against another Signal at the same source. Names the winner so an analyzer (or the operator drilling into the sidecar) can see WHO won and WHY.",
108
+ "required": ["source", "range", "extractorId", "reason"],
109
+ "additionalProperties": false,
110
+ "properties": {
111
+ "source": {
112
+ "type": "string",
113
+ "description": "`node.path` of the winning Signal. Always equal to this Signal's `source` today, the field is explicit so future cross-node collision detection can populate it without a schema migration."
114
+ },
115
+ "range": {
116
+ "type": "object",
117
+ "description": "Byte-range of the winning Signal. Mirrors the shape of `Signal.range`.",
118
+ "required": ["start", "end"],
119
+ "additionalProperties": false,
120
+ "properties": {
121
+ "start": { "type": "integer", "minimum": 0 },
122
+ "end": { "type": "integer", "minimum": 0 }
123
+ }
124
+ },
125
+ "extractorId": {
126
+ "type": "string",
127
+ "description": "Qualified id (`<plugin>/<extractor>`) of the winning candidate's extractor."
128
+ },
129
+ "reason": {
130
+ "type": "string",
131
+ "enum": ["kind-priority", "higher-confidence", "longer-range", "earlier-declaration"],
132
+ "description": "Which tiebreak rule decided the winner. The four rules apply in this order: 1) `kind-priority` (provider `resolverRules.kindPriority`), 2) `higher-confidence` (numeric confidence DESC), 3) `longer-range` (`end - start` DESC), 4) `earlier-declaration` (extractor registration order)."
133
+ }
134
+ }
135
+ },
136
+ "extractorDisabled": {
137
+ "type": "object",
138
+ "description": "Reserved for Phase 4+: populated when every candidate of this Signal came from an extractor that the operator has disabled via `plugins.<id>.extensions.<extId>.enabled`. Today the resolver never sets this; the field is documented so the analyzer / UI surface can be built once the filter lands.",
139
+ "required": ["extractorId"],
140
+ "additionalProperties": false,
141
+ "properties": {
142
+ "extractorId": { "type": "string" }
143
+ }
144
+ },
145
+ "belowFloor": {
146
+ "type": "object",
147
+ "description": "Reserved for Phase 4+: populated when every candidate's `confidence` fell below the configured floor. Today the resolver materialises every Signal that survives overlap, regardless of confidence; the field is documented so the analyzer / UI surface can be built once the floor lands.",
148
+ "required": ["threshold"],
149
+ "additionalProperties": false,
150
+ "properties": {
151
+ "threshold": {
152
+ "type": "number",
153
+ "minimum": 0,
154
+ "maximum": 1
155
+ }
156
+ }
157
+ }
158
+ }
87
159
  }
88
160
  }
89
161
  }