@skill-map/spec 0.19.0 → 0.20.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.
Files changed (36) hide show
  1. package/CHANGELOG.md +347 -6
  2. package/README.md +6 -6
  3. package/architecture.md +62 -55
  4. package/cli-contract.md +35 -14
  5. package/conformance/README.md +2 -2
  6. package/conformance/cases/kernel-empty-boot.json +2 -2
  7. package/conformance/cases/sidecar-end-to-end.json +3 -3
  8. package/conformance/coverage.md +5 -5
  9. package/conformance/fixtures/sidecar-end-to-end/.claude/agents/orphan.sm +1 -1
  10. package/conformance/fixtures/sidecar-example/agent-example.md +1 -1
  11. package/db-schema.md +18 -17
  12. package/index.json +36 -36
  13. package/interfaces/security-scanner.md +2 -2
  14. package/job-events.md +12 -12
  15. package/job-lifecycle.md +1 -1
  16. package/package.json +1 -1
  17. package/plugin-author-guide.md +112 -81
  18. package/plugin-kv-api.md +5 -5
  19. package/prompt-preamble.md +1 -1
  20. package/schemas/annotations.schema.json +4 -4
  21. package/schemas/api/rest-envelope.schema.json +4 -4
  22. package/schemas/conformance-case.schema.json +2 -2
  23. package/schemas/extensions/analyzer.schema.json +43 -0
  24. package/schemas/extensions/base.schema.json +5 -5
  25. package/schemas/extensions/extractor.schema.json +1 -1
  26. package/schemas/extensions/hook.schema.json +6 -4
  27. package/schemas/issue.schema.json +6 -6
  28. package/schemas/link.schema.json +2 -2
  29. package/schemas/plugins-registry.schema.json +1 -1
  30. package/schemas/project-config.schema.json +15 -1
  31. package/schemas/sidecar.schema.json +2 -2
  32. package/schemas/summaries/agent.schema.json +1 -1
  33. package/schemas/summaries/command.schema.json +1 -1
  34. package/schemas/summaries/hook.schema.json +1 -1
  35. package/schemas/{view-contracts.schema.json → view-slots.schema.json} +91 -54
  36. package/schemas/extensions/rule.schema.json +0 -43
package/CHANGELOG.md CHANGED
@@ -1,5 +1,346 @@
1
1
  # Spec changelog
2
2
 
3
+ ## 0.20.0
4
+
5
+ ### Minor Changes
6
+
7
+ - a1bfe15: Eliminate the view-contribution `contract` abstraction — plugin authors now pick `slot` directly.
8
+
9
+ The previous model exposed two layers to the plugin author: a closed catalog of 11 "contracts" (`node-counter`, `node-tag`, `node-breakdown`, ...) plus an internal UI map from contract → N compatible slots. Picking a contract caused the same data to render in EVERY compatible slot (e.g. `node-counter` broadcast to four surfaces simultaneously). The 2026-05-10 collapse drops the contract layer: the plugin author picks ONE slot from a closed catalog of 14 slots; the slot fixes both the renderer and the payload shape; nothing renders implicitly. Smaller mental model, no surprise duplication, slot ids that map 1:1 to a payload.
10
+
11
+ **Spec changes** (`@skill-map/spec`):
12
+
13
+ - `spec/schemas/view-contracts.schema.json` renamed to `spec/schemas/view-slots.schema.json`. `$defs.ContractName` (11-entry closed enum) replaced by `$defs.SlotName` (14-entry closed enum). `$defs.IViewContribution.contract` field renamed to `slot`. `$defs.payloads` re-keyed by slot id; slots that share a payload shape (`card.subtitle.left`, `card.footer.right`, `card.footer.left.counter`, `inspector.header.badge.counter` all use the counter shape) `$ref` a shared internal definition. The conditional `allOf` discriminators that mandated `icon` on `node-counter` and `node-icon` now mandate `icon` on every counter slot and on `card.title.right`.
14
+ - The three previously-polymorphic slots are split via dotted suffix:
15
+ - `card.footer.left` → `card.footer.left.counter` (single sub-slot — the `card.footer.left.tag` sub-slot was considered and dropped: the counter sub-slot is multi-element, no built-in adopter wanted a tag here, and the `inspector.header.badge.tag` slot covers the remaining tag-shaped use case)
16
+ - `inspector.header.badge` → `inspector.header.badge.counter`, `inspector.header.badge.tag`
17
+ - `inspector.body.panel` → `inspector.body.panel.breakdown`, `.records`, `.tree`, `.key-values`, `.link-list`, `.markdown` (one per shape, narrative order in the inspector body)
18
+ - The five monomorphic slots (`card.title.right`, `card.subtitle.left`, `card.footer.right`, `graph.node.alert`, `topbar.actions.indicator`) keep their ids unchanged.
19
+ - `spec/view-contracts.md` renamed to `spec/view-slots.md` and rewritten as a 14-slot catalog (one section per slot: payload shape, manifest declaration, emit example, where it renders).
20
+ - `spec/architecture.md` § View contribution system: rewritten to reflect the two-layer model. The "Plugin author NEVER picks a slot" guidance is inverted; the comparison table's "Plugin author writes" row now says "`slot` name from a closed catalog"; the "Surfaces in" row now says "fixed renderer per slot, mounted at exactly the slot the author declared".
21
+ - `spec/plugin-author-guide.md` § View contributions: rewritten tutorial. Manifest example uses `slot:`; the slot-catalog table replaces the contract-catalog table; new "Multi-slot rendering" sub-section explains that the same data in two surfaces requires two declarations (intentional).
22
+ - `spec/db-schema.md` § `scan_contributions`: column `contract TEXT NOT NULL` renamed to `slot TEXT NOT NULL`; comment now references `view-slots.schema.json#/$defs/SlotName`.
23
+ - `spec/schemas/extensions/base.schema.json`, `spec/schemas/api/rest-envelope.schema.json`, `spec/schemas/plugins-registry.schema.json`: `contract` field references swept to `slot`; doc strings re-pointed at `view-slots.schema.json`. `contributionsRegistry` envelope entries now carry `slot` (not `contract`).
24
+ - `spec/conformance/coverage.md` row 30 re-pointed at `view-slots.schema.json` and the renamed conformance case.
25
+
26
+ **Implementation changes** (`@skill-map/cli`):
27
+
28
+ - `src/kernel/types/view-catalog.ts`: `TContractName` (11 entries) renamed to `TSlotName` (14 entries). `IViewContribution.contract` and `IRegisteredViewContribution.contract` renamed to `slot`.
29
+ - `src/kernel/orchestrator.ts`: extractor + rule emit paths read `declared.slot`, validate via `validateContributionPayload(declared.slot, payload)`, persist with `slot:` field. Also threads a new `freshlyRunTuples` set down through `walkAndExtract` → `runScanInternal` → caller (see Persistence-fix block below).
30
+ - `src/kernel/adapters/schema-validators.ts`: `SUPPORTING_SCHEMAS` reads `view-slots.schema.json`. `validateContributionPayload(slot, payload)` keys validators by slot id (14 keys); error code renamed from `'unknown-contract'` to `'unknown-slot'`. The validator filters out internal `$ref` targets (`_counter`, `_tag`, `_TreeNode`) so they cannot be queried by accident.
31
+ - `src/migrations/001_initial.sql`: `scan_contributions.contract` column renamed to `slot`. No migration script — pre-1.0 greenfield, fixtures purge on next scan.
32
+ - `src/kernel/adapters/sqlite/contributions.ts`, `src/kernel/adapters/sqlite/schema.ts`: field rename in record types and SQL queries.
33
+ - `src/built-in-plugins/extractors/external-url-counter/index.ts`: `contract: 'node-counter'` → `slot: 'card.footer.right'`.
34
+ - `src/built-in-plugins/extractors/at-directive/index.ts`: `contract: 'node-counter'` → `slot: 'card.footer.left.counter'`.
35
+ - `src/built-in-plugins/rules/link-counts/index.ts`: `linksOut.contract` → `slot: 'card.footer.right'`; `linksIn.contract` → `slot: 'card.footer.left.counter'`.
36
+ - `src/built-in-plugins/rules/unknown-contract/` renamed (via `git mv`) to `src/built-in-plugins/rules/unknown-slot/`. Export `unknownContractRule` → `unknownSlotRule`. Internal id `'unknown-contract'` → `'unknown-slot'`. Message "declares unknown contract" → "declares unknown slot". `KNOWN_CONTRACTS` set replaced by `KNOWN_SLOTS` (14 entries).
37
+ - `src/built-in-plugins/rules/link-counts/index.ts`: rule paused — view-contributions block stripped, `evaluate()` is now a no-op `return []`. The `linksOut` chip duplicated the per-extractor counters living next to it (`@N` from at-directive, `📎N` from markdown-link, `/N` from slash); `linksIn` was unique but kept here for symmetry. Rule remains registered (no-op) so re-enabling is a single-file change.
38
+ - `src/built-in-plugins/extractors/markdown-link/index.ts`, `src/built-in-plugins/extractors/slash/index.ts`: gain a `card.footer.left.counter` view contribution each (`📎N` and `/N` chips), aligning with `at-directive`'s existing `@N` chip and removing the rationale for the paused `link-counts` `linksOut`.
39
+ - `src/built-in-plugins/built-ins.ts`: import path updated.
40
+ - `src/cli/commands/plugins.ts`: `VIEW_CONTRACTS_CATALOG` (11 entries) renamed to `VIEW_SLOTS_CATALOG` (14 entries with summaries derived from `view-slots.md`). `PluginsContractsListCommand` renamed to `PluginsSlotsListCommand`; verb path `['plugins', 'contracts', 'list']` → `['plugins', 'slots', 'list']`. `PluginsCreateCommand` scaffolder emits manifest stubs with `slot:` (default `card.footer.left.counter`); help text and tip lines now reference `sm plugins slots list`. `plugins show` qualifies extension names with `<bundleId>/<extensionId>` for `granularity=extension` so shadowed siblings stay distinguishable in the listing.
41
+ - `src/server/contributions-registry.ts`, `src/server/routes/contributions.ts`, `src/server/envelope.ts`: registry entries and lookup items use `slot:` field.
42
+ - `src/core/runtime/plugin-runtime.ts`: `collectViewContributions` reads `entry.slot` and pushes `slot: entry.slot as TSlotName`.
43
+ - `context/cli-reference.md` regenerated to absorb the verb rename.
44
+
45
+ **Persistence fix — per-tuple sweep on `scan_contributions`** (`@skill-map/cli`):
46
+
47
+ The pre-fix persist layer ran three passes (orphan → catalog → upsert) keyed at the `(plugin, extension, node, contributionId)` level, and that wasn't enough to catch the case "extractor used to emit for node X, body change removes the trigger, prior row stays stale". A 4th pass — a per-tuple sweep keyed by `(pluginId, extensionId, nodePath)` — now drops rows whose key is absent from the current scan's contribution buffer, but ONLY for tuples that actually ran this scan.
48
+
49
+ - `src/kernel/types/storage.ts`: `IPersistOptions` gains an optional `freshlyRunTuples?: ReadonlySet<string>` field (format `<pluginId>/<extensionId>/<nodePath>`). Empty / absent set = no per-tuple sweep (legacy callers preserve the pre-fix behaviour where stale rows linger).
50
+ - `src/kernel/orchestrator.ts`: `walkAndExtract` accumulates a `freshlyRunTuples: Set<string>`. Extractor + cache miss → tuple INCLUDED. Extractor + cache hit → tuple OMITTED (prior rows must survive). After `applyRules`, `runScanInternal` folds in `(rule × node)` for every rule that declares `viewContributions` (rules always run and see the full graph, no per-(rule, node) cache like extractors have). The set is returned alongside `contributions` and threaded into the persist call.
51
+ - `src/kernel/adapters/sqlite/contributions.ts` + `src/kernel/adapters/sqlite/scan-persistence.ts` + `src/kernel/adapters/sqlite/storage-adapter.ts`: persist accepts the set, runs the sweep DELETE before the upsert, scoped to keys whose `(plugin, extension, node)` is in the set but whose `(plugin, extension, node, contributionId)` is NOT in the buffer. Cached-extractor tuples remain absent from the set, so their rows are untouched.
52
+ - `src/core/runtime/scan-runner.ts` + `src/core/watcher/runtime.ts`: thread `freshlyRunTuples` from the orchestrator return into the persist call.
53
+ - Backwards-compat: the field is optional. The persist layer treats an absent / empty set as "skip the sweep", matching pre-fix behaviour bit-for-bit.
54
+
55
+ **UI changes** (private `ui/` workspace, ships bundled in `@skill-map/cli`):
56
+
57
+ - `ui/src/app/contracts/contract-renderer-map.ts` renamed (via `git mv`) to `ui/src/app/slots/slot-renderer-map.ts`. The `CONTRACT_RENDERERS` + `CONTRACT_SLOTS` two-map structure is replaced by a single `SLOT_RENDERERS: Record<TSlotId, ComponentType>` (14 entries, 1:1 slot → renderer); `isKnownContract` renamed to `isKnownSlot`.
58
+ - `ui/src/app/slots/slot-config.ts`: `TSlotId` union expanded to 14 entries; `SLOT_REGISTRY` rebuilt with sub-slots inheriting `maxItems` / `order` / `respectSeverity` from their former polymorphic parent.
59
+ - `ui/src/app/slots/icon-glyph.ts` (new): tiny shared `<sm-icon-glyph>` component that resolves a manifest-declared `icon` per spec (`Extended_Pictographic` → emoji text; otherwise → `<i class="pi pi-{icon}">`). Adopted by `node-counter`, `node-alert`, `node-icon`, `scope-stat` — fixes the regression where `arrow-up` rendered as the literal three-character string instead of the PrimeIcons class.
60
+ - `ui/src/app/components/view-contributions-host/view-contributions-host.ts`: dispatch simplified — `contractMatchesSlot(c.contract, slot)` replaced by `c.slot === slot`; renderer lookup is `SLOT_RENDERERS[slot]`.
61
+ - `ui/src/models/api.ts`: `IContributionApi.contract` and `IContributionsRegistryEntryApi.contract` renamed to `slot`.
62
+ - HTML templates: the polymorphic mounts split into per-shape hosts. `node-card.html` mounts `card.footer.left.counter` (single sub-slot, no `.tag`). `inspector-view.html` mounts `inspector.header.badge.counter` + `.tag` adjacent and the six `inspector.body.panel.*` sub-slots stacked in narrative order (breakdown → records → tree → key-values → link-list → markdown). `graph-view.html`, `app.html`, and the monomorphic mounts are unchanged.
63
+ - `ui/src/app/debug-slots.css`: 10 new entries for the sub-slots (varied hue tones for visual distinction); 3 obsolete entries removed.
64
+ - 11 renderer components had their `IRendererInputs` import path updated to the new `slots/slot-renderer-map`; doc strings refreshed.
65
+
66
+ **Tests**:
67
+
68
+ - `src/test/view-contributions.test.ts`: helper interfaces and fixtures swapped to `slot:`. Validation tests now call `validateContributionPayload(<slot-id>, ...)`. Negative test "rejects unknown contract names" renamed to "rejects unknown slot names" with assertion `result.errors === 'unknown-slot'`.
69
+ - `src/test/server-annotations-endpoint.test.ts`, `src/test/server-sidecar-endpoint.test.ts`: schema path strings updated.
70
+ - `src/test/plugin-runtime-branches.test.ts`: rule-id list assertion updated (`'unknown-contract'` → `'unknown-slot'`).
71
+ - `src/built-in-plugins/rules/link-counts/link-counts.test.ts`: manifest assertions reflect the new slot ids.
72
+
73
+ **Breaking** (per the pre-1.0 minor convention — see `CONTRIBUTING.md` / `spec/versioning.md` §Pre-1.0):
74
+
75
+ - Plugin manifests declaring `viewContributions[*].contract: 'node-counter'` (or any of the other 10 contract names) now load as `invalid-manifest`. Migration is mechanical: rename the field to `slot` and pick one of the 14 slot ids that matches the prior contract's payload shape. Recommended mapping: `node-counter` → `card.footer.right` (or another counter slot), `node-tag` → `inspector.header.badge.tag` (the only tag slot in the catalog now), `node-breakdown/records/tree/key-values/link-list/markdown` → `inspector.body.panel.<shape>`, `node-alert` → `graph.node.alert`, `node-icon` → `card.title.right`, `scope-stat` → `topbar.actions.indicator`.
76
+ - The CLI verb `sm plugins contracts list` is removed and replaced by `sm plugins slots list`.
77
+ - The built-in soft-warning rule `core/unknown-contract` is removed and replaced by `core/unknown-slot` (same semantics, slot-keyed walk).
78
+ - The database column `scan_contributions.contract` is renamed to `slot`. No migration script ships — purge fixture DBs and re-run `sm scan` after upgrading. The pre-1.0 greenfield posture (no schema versioning) holds.
79
+
80
+ ## User-facing
81
+
82
+ **The view-contribution model is simpler.** Plugin authors now pick **one slot** from a closed catalog of 14; the slot decides where the data renders, what payload shape is expected, and which renderer draws it. The previous model required learning two catalogs (contracts and slots) and accepted that the same data would broadcast to multiple surfaces automatically — that broadcast is gone.
83
+
84
+ Visible changes in the SPA:
85
+
86
+ - The URL-counter chip from `core/external-url-counter` now renders only in the card's footer-right cluster (was visible in four surfaces simultaneously).
87
+ - The `@-mention` chip from `core/at-directive`, plus new `📎` (markdown links) and `/` (slash directives) counter chips from `core/markdown-link` and `core/slash`, render only in the card's footer-left cluster.
88
+ - The `core/link-counts` rule is paused — its `linksOut` / `linksIn` chips are temporarily off the card. `linksOut` duplicated the new per-extractor counters; `linksIn` will return when the chip surface is reinstated. The rule stays registered as a no-op so re-enabling is a single-file change.
89
+ - The CLI verb to browse the catalog is now `sm plugins slots list` (was `sm plugins contracts list`).
90
+ - **Stale view contributions are cleaned up.** Editing a node so an extractor stops emitting a chip (e.g. removing the last `@mention` from a doc) now removes the chip on the next scan. Previously the chip would linger until the row was clobbered by an unrelated edit.
91
+ - Renderer icons resolve correctly across emoji and PrimeIcons names (an icon like `arrow-up` no longer leaks as the literal three-character string when the renderer expected a class name).
92
+
93
+ - 5600a60: Hook trigger set grows from 8 to 10: add CLI-process-driven `boot` and `shutdown`. First built-in concrete consumer: `core/update-check` (the once-per-day update banner moves from an inline call site to a hook subscribing to `boot`).
94
+
95
+ **Spec changes** (`@skill-map/spec`):
96
+
97
+ - `spec/schemas/extensions/hook.schema.json` — `triggers[].enum` grows from 8 to 10 entries (`boot` first, `shutdown` last). Top-level description updated to reflect the new size and the pipeline-driven vs CLI-process-driven split.
98
+ - `spec/architecture.md` § Hook · curated trigger set — table grows by two rows. `boot` documents the pre-verb dispatch (await semantics, fire-time, payload `{ argv }`); `shutdown` documents the post-verb dispatch (await semantics, payload `{ exitCode }`). The "Eight" wording flips to "ten" in the §Hook one-liner and the §Locality count of bundled built-ins (`one Provider, four extractors, five rules, one formatter, one hook` — the first built-in hook is `core/update-check`). The `## Stability and versioning` clause updates: trigger-set size goes from 8 to 10; adding an eleventh is a minor bump, removing or renaming any of the ten is a major bump.
99
+ - `spec/index.json` regenerated.
100
+
101
+ **Implementation changes** (`@skill-map/cli`):
102
+
103
+ - `src/kernel/extensions/hook.ts` — `THookTrigger` union and the frozen `HOOK_TRIGGERS` array grow from 8 to 10 entries (`boot` first, `shutdown` last so a debug log of the array reads in lifecycle order). Doc comment updated.
104
+ - `src/kernel/extensions/hook-dispatcher.ts` (new) — `IHookDispatcher`, `makeHookDispatcher`, and `makeEvent` extracted from `kernel/orchestrator.ts` so two callers can share the indexing / filter / error-handling semantics: the orchestrator for the eight pipeline-driven triggers (inside `runScan`), and `cli/entry.ts` for `boot` / `shutdown`. The orchestrator now imports the helpers; the duplicated inline definitions and `matchesFilter` / `buildHookContext` helpers are gone.
105
+ - `src/kernel/index.ts` — re-exports `makeHookDispatcher`, `makeEvent`, and `IHookDispatcher` so the CLI entry (and future drivers) can build their own dispatcher without crossing into orchestrator internals.
106
+ - `src/built-in-plugins/hooks/update-check/index.ts` (new) — first built-in concrete `IHook`. Subscribes to `boot`, deterministic mode. Imports `maybeRunUpdateCheck` from `cli/util/update-check-banner.js` and forwards the contracted `event.data: { dbPath, cwd, homedir, stderr, noColorFlag }` payload. Defensive: a `boot` event missing any contracted field is a no-op (rather than a throw), so a misconfigured driver degrades gracefully. The lint config does not restrict `built-in-plugins/**` from importing CLI helpers (built-ins are bundled in the same binary), so the cross-layer import is intentional — `cli/util/update-check-banner.ts` is the only legal home for the env / config reads (`SM_NO_UPDATE_CHECK`, `CI`, `loadConfig`, ANSI / TTY checks) per the kernel-boundary lint rules.
107
+ - `src/built-in-plugins/built-ins.ts` — imports `updateCheckHook` and pushes it into the `core` bundle (last entry). The `bucketBuiltIn` dispatch table already routed `kind: 'hook'` to `out.hooks`; no per-kind code change.
108
+ - `src/cli/entry.ts` — the inline `await maybeRunUpdateCheck(...)` post-`cli.run()` block is gone. Instead: the entry now imports `builtIns()` and `makeHookDispatcher`, builds a single dispatcher over `builtIns().hooks`, dispatches `boot` BEFORE `cli.process()` (so the banner lands above the verb's output, per the Phase 3 design call), and dispatches `shutdown` AFTER `cli.run()` and BEFORE `process.exit(exitCode)`. `boot` payload carries `{ argv, dbPath, cwd, homedir, stderr, noColorFlag }`; `shutdown` payload carries `{ exitCode }`. Both dispatches await; the dispatcher catches every hook error so a buggy hook can only delay the verb / exit, never alter the resolved exit code. User-plugin hooks subscribing to `boot` / `shutdown` are loaded but not yet dispatched on this path (built-in only) — documented as a follow-up in the README.
109
+ - `src/core/runtime/plugin-runtime.ts` — `composeScanExtensions` "kernel-empty-boot" check no longer counts hooks. A hook subscribing only to `boot` / `shutdown` (the new CLI-driven triggers) reaches the composer through the built-in bundle but the orchestrator dispatcher would never invoke it; preserving the empty-boot shape regardless of hook presence keeps the conformance case honest while letting `core/update-check` ride along for the entry-side dispatcher to pick up.
110
+ - `src/built-in-plugins/README.md` — adds the `core/update-check` row and a paragraph on the two dispatch entry points (orchestrator vs CLI entry) sharing the same dispatcher module.
111
+ - `src/test/update-check-hook.test.ts` (new) — manifest-shape assertions and defensive-payload coverage for the hook (no-op when `dbPath` / `cwd` / `homedir` / `stderr` are absent; clean forward when contracted; DB missing → silent bail). Pre-existing unit + integration tests for `maybeRunUpdateCheck` (in `src/test/update-check.test.ts`) keep covering the cache + bail + banner behaviour end-to-end — the hook is a thin wrapper.
112
+ - Two pre-existing tests updated for the new built-in count: `src/test/built-ins-modes.test.ts` (`listBuiltIns().length`: 23 → 24, comment updated to call out the new hook).
113
+
114
+ **ROADMAP changes**:
115
+
116
+ - §Plugin system · Hook trigger set — list grows from 8 to 10 entries; new paragraph documents the dispatcher module split (`kernel/extensions/hook-dispatcher.ts`) and points at `core/update-check` as the first built-in consumer.
117
+ - §Glossary · Hook — one-liner updated from "one of eight" → "one of ten" with the pipeline vs CLI-process split.
118
+
119
+ **Pre-1.0 minor bumps** per `spec/versioning.md` § Pre-1.0 — both surfaces grow additively (two new triggers, one new built-in hook, one new internal kernel module). No existing surface is removed or renamed; old hooks subscribing only to the eight pre-existing triggers keep working byte-for-byte. Pre-1.0 lets us land additive contract growth as `minor` without flipping to 1.0.0.
120
+
121
+ - 802e64f: Rename the `rule` plugin extension kind to `analyzer`.
122
+
123
+ The kind formerly known as `rule` not only finds issues but also projects findings into the UI via `viewContributions` (cards, badges, tabs). "Rule" undersold the breadth of the contract; **Analyzer** captures both axes — graph analysis and visual projection. Pre-1.0, no released consumers depend on the old name, so this ships as a sweep without compatibility shims.
124
+
125
+ **Wire format (breaking)**
126
+
127
+ - `kind` enum in `extensions/base.schema.json` now lists `analyzer` instead of `rule`.
128
+ - `extensions/rule.schema.json` is renamed to `extensions/analyzer.schema.json`.
129
+ - The const value of `kind` on the kind-specific schema is `"analyzer"`.
130
+ - The manifest array field `emitsRuleIds` is now `emitsAnalyzerIds`.
131
+
132
+ **Issue model + REST + DB (breaking)**
133
+
134
+ - `Issue.ruleId` is now `Issue.analyzerId` in the JSON wire and the TS shape.
135
+ - `GET /api/issues?ruleId=<id>` becomes `GET /api/issues?analyzerId=<id>`.
136
+ - The SQL column `scan_issues.rule_id` is now `scan_issues.analyzer_id`; the index `ix_scan_issues_rule_id` becomes `ix_scan_issues_analyzer_id`.
137
+
138
+ **Events (breaking)**
139
+
140
+ - The hook trigger `rule.completed` is now `analyzer.completed`. The payload field renames from `ruleId` to `analyzerId`.
141
+
142
+ **CLI (breaking)**
143
+
144
+ - `sm check --rules <ids>` becomes `sm check --analyzers <ids>`.
145
+ - The conformance kill-switch env var is `SKILL_MAP_DISABLE_ALL_ANALYZERS` (was `SKILL_MAP_DISABLE_ALL_RULES`); the corresponding `conformance-case.schema.json` field is `disableAllAnalyzers`.
146
+ - The advisory placeholder `{{ruleIds}}` in `--include-prob` output is now `{{analyzerIds}}`.
147
+
148
+ **Kernel + built-ins (breaking)**
149
+
150
+ - TypeScript symbols: `IRule` → `IAnalyzer`, `IRuleContext` → `IAnalyzerContext`, `IRuleOrphanSidecar` → `IAnalyzerOrphanSidecar`.
151
+ - The 11 built-in extensions previously under `src/built-in-plugins/rules/` now live under `src/built-in-plugins/analyzers/`. Each `*Rule` symbol (e.g. `triggerCollisionRule`) is renamed to its `*Analyzer` form (`triggerCollisionAnalyzer`).
152
+ - `IBuiltIns.rules` → `IBuiltIns.analyzers`; `IPluginRuntimeBundle.extensions.rules` → `analyzers`; `IScanExtensions.rules` → `analyzers`.
153
+ - The kernel filter utility `kernel/util/rule-filter.ts` (`matchesRuleFilter`) is renamed to `analyzer-filter.ts` (`matchesAnalyzerFilter`).
154
+
155
+ **Testkit (breaking, public)**
156
+
157
+ - `runRuleOnGraph` → `runAnalyzerOnGraph`.
158
+ - `makeRuleContext` → `makeAnalyzerContext`.
159
+ - `IRunRuleOptions` → `IRunAnalyzerOptions`.
160
+ - Re-exports `IAnalyzer`, `IAnalyzerContext` instead of the `IRule` variants.
161
+
162
+ **Migration**
163
+
164
+ Greenfield rename — no fallback. Existing user plugins with `kind: "rule"` and `emitsRuleIds` need to update their manifests. The scaffolder (`sm plugins create`) emits `kind: 'analyzer'` automatically; a future `sm plugins upgrade <id>` will rewrite legacy manifests.
165
+
166
+ ## User-facing
167
+
168
+ The plugin extension kind was renamed from **Rule** to **Analyzer** to better reflect what these plugins do — they analyze the graph AND project findings into the UI. End-user-visible changes:
169
+
170
+ - The CLI flag `sm check --rules <ids>` is now `sm check --analyzers <ids>`.
171
+ - The `sm check --json` output's per-issue `ruleId` field is now `analyzerId`.
172
+ - Hook triggers in plugin manifests rename from `rule.completed` to `analyzer.completed`; the event payload field `ruleId` is now `analyzerId`.
173
+ - The Settings → Plugins page lists plugins of kind "analyzer".
174
+ - The marketing site shows the satellite as "Analyzer plugin kind" instead of "Rule plugin kind".
175
+
176
+ If you maintain a custom plugin with `kind: "rule"`, update the manifest to `kind: "analyzer"`, rename `emitsRuleIds` to `emitsAnalyzerIds`, and rename any imported `IRule` / `IRuleContext` symbols to `IAnalyzer` / `IAnalyzerContext`. The directory name and `id` rules remain unchanged.
177
+
178
+ - 5600a60: Add `sm scan -g` (global scan) plus three privacy-sensitive project scan settings: `scan.includeHome`, `scan.extraRoots`, `scan.referencePaths`. Settings UI exposes them in a new "Project" section.
179
+
180
+ **Spec changes** (`@skill-map/spec`, minor):
181
+
182
+ - `spec/cli-contract.md` § Scan — `sm scan -g/--global` flag documented: with `-g` the scan walks every active Provider's `explorationDir` resolved against `~` (typically `~/.claude`, `~/.gemini`, `~/.agents`) instead of the cwd; config + DB resolve from the global scope. Mutually exclusive with positional roots (exit `2`). New §Effective roots subsection enumerates how the resolver composes `cwd` + `includeHome` + `extraRoots` + `-g`.
183
+ - `spec/cli-contract.md` § Config — `sm config set` gains an optional `--yes` flag and a new §Privacy-sensitive config subsection: writes that EXPAND disk access outside the project (toggling `scan.includeHome` `false`→`true`, adding out-of-project paths to `scan.extraRoots` / `scan.referencePaths`) require `--yes` to confirm. Writes that NARROW the surface need no flag.
184
+ - `spec/schemas/project-config.schema.json` — `scan` block grows three keys:
185
+ - `includeHome: boolean` (default `false`).
186
+ - `extraRoots: string[]` (default `[]`).
187
+ - `referencePaths: string[]` (default `[]`).
188
+ Every key carries a "privacy-sensitive" warning in its description so the schema-as-doc stays honest.
189
+ - `spec/index.json` regenerated.
190
+
191
+ **Implementation changes** (`@skill-map/cli`, minor):
192
+
193
+ - `src/config/defaults.json` — three new defaults under `scan` (`includeHome: false`, `extraRoots: []`, `referencePaths: []`).
194
+ - `src/kernel/config/loader.ts` — `IScanConfig` gains `includeHome`, `extraRoots`, `referencePaths`. Each documented inline as privacy-sensitive.
195
+ - `src/kernel/extensions/rule.ts` — `IRuleContext` gains optional `referenceablePaths?: ReadonlySet<string>` (the side index `core/broken-ref` consults) and `cwd?: string` (absolute project root, threaded so rules can resolve relative `link.target`s without heuristics).
196
+ - `src/kernel/orchestrator.ts` — `RunScanOptions.referenceablePaths?` and `RunScanOptions.cwd?` propagate through `runScanInternal` → `runRules` → per-rule `evaluate()`.
197
+ - `src/core/runtime/scan-roots.ts` (new) — `resolveScanRoots({ positionalRoots, scope, cwd, homedir, providers, includeHome, extraRoots })`. Centralises the spec's § Effective roots rules: positional roots win verbatim; otherwise compose cwd + (includeHome ? HOME provider dirs : []) + extraRoots for project scope, or HOME provider dirs only for global scope. `-g` + positional roots throws.
198
+ - `src/core/runtime/reference-paths-walker.ts` (new) — `walkReferencePaths(rawRoots, cwd, homedir)` returns `{ paths: Set<absolute>, truncated, missingRoots }`. Recursive walk that skips symlinks + `node_modules`/`.git`/`.skill-map`; capped at `REFERENCE_WALK_MAX_FILES` (50_000) for safety.
199
+ - `src/core/runtime/scan-runner.ts` — `IScanRunOpts.scope?: 'project' | 'global'` (default `'project'`). Resolves DB via `resolveDbPath({ global: scope === 'global', ... })`, `loadConfig` honours the scope, roots resolve via `resolveScanRoots`, reference paths walk via `walkReferencePaths`, and the resolved `cwd` + `referenceablePaths` thread into `RunScanOptions`. Emits stderr advisories for HOME inclusions and reference-walk truncation / missing roots. The `runOptions` assembly extracted to a `buildRunScanOptions` helper to stay under the cyclomatic-complexity cap.
200
+ - `src/core/runtime/i18n/scan-runner.texts.ts` — three new strings (`includingHomeAdvisory`, `includingExtraRootsAdvisory`, `referenceWalkTruncated`, `referenceWalkMissingRoot`).
201
+ - `src/built-in-plugins/rules/broken-ref/index.ts` — refactored to consult `ctx.referenceablePaths` after the in-graph lookup misses. A path-style link target whose absolute resolution (`resolve(ctx.cwd, link.target)`) is in the side index is treated as resolved (file exists outside the indexed graph). Trigger-style links (`/foo`, `@bar`) skip the side-index lookup. The orchestrator's helper extracted to keep the rule under the complexity cap.
202
+ - `src/cli/commands/scan.ts` — wires `-g/--global` to `runScanForCommand`'s new `scope` option. Mutex with positional roots is rejected up front with a directed message (`SCAN_TEXTS.globalWithRoots`).
203
+ - `src/cli/commands/config.ts` — `ConfigSetCommand` gains `--yes`. When the key is in `PRIVACY_SENSITIVE_KEYS` and the new value would expand the surface, the verb prints the list of paths the change would expose and exits `2` unless `--yes` is set; with `--yes` it prints the same list as a confirmation receipt.
204
+ - `src/cli/i18n/config.texts.ts` — `privacyGateRequired` / `privacyGateRequiredHint` / `privacyGateConfirmed`.
205
+ - `src/cli/i18n/scan.texts.ts` — `globalWithRoots`.
206
+ - `src/core/config/helper.ts` — adds `PRIVACY_SENSITIVE_KEYS` (a `ReadonlySet<string>`) and `projectPathExposure({ key, value, cwd, homedir })` that returns `{ expandsSurface, exposedPaths }`. Same predicate is consumed by both the CLI verb and the BFF route so the wire-side and CLI-side behaviour stay symmetric.
207
+
208
+ **BFF additions**:
209
+
210
+ - `src/server/routes/project-preferences.ts` (new) — `GET /api/project-preferences` returns `{ scan: { includeHome, extraRoots, referencePaths } }`; `PATCH /api/project-preferences` writes via `core/config/helper:writeConfigValue` with `target: 'project'`. Privacy-sensitive writes that expand the surface require `confirm: true` in the body — otherwise the route returns 412 `confirm-required` with the list of paths the change would expose.
211
+ - `src/server/i18n/server.texts.ts` — eight new strings under the project-preferences section.
212
+ - `src/server/app.ts` — registers the new route + adds `'confirm-required'` to `TErrorCode`; `codeForStatus(412)` maps to it.
213
+
214
+ **UI additions** (private `ui/` workspace):
215
+
216
+ - `ui/src/app/components/settings-modal/settings-project.{ts,html,css}` (new) — Project section. Renders the `includeHome` toggle plus two editable path lists (`extraRoots`, `referencePaths`) with add / remove controls. A `<p-confirmdialog>` enumerates the paths a privacy-sensitive change would expose; on accept the patch is re-issued with `confirm: true`.
217
+ - `ui/src/app/components/settings-modal/settings-modal.{ts,html}` — `Project` added to the sidebar between `General` and `Plugins`. New `projectVisible` computed signal mirrors the General / Plugins lifecycle.
218
+ - `ui/src/i18n/settings.texts.ts` — `sections.project` + `project: { heading, intro, includeHomeLabel, includeHomeDescription, extraRootsLabel, extraRootsDescription, extraRootsPlaceholder, referencePathsLabel, referencePathsDescription, referencePathsPlaceholder, addPathLabel, removePathLabel, confirmDialogHeader, confirmDialogIntro, confirmDialogAccept, confirmDialogReject }`.
219
+ - `ui/src/models/api.ts` — new `IProjectPreferencesApi` and `IProjectPreferencesPatchApi` types (mirroring the BFF shape).
220
+ - `ui/src/services/data-source/data-source.port.ts` — `IDataSourcePort` gains `getProjectPreferences()` / `setProjectPreferences(patch)`. `RestDataSource` and `StaticDataSource` implementations updated.
221
+ - Two pre-existing test stubs (`ui/src/app/app.spec.ts`, `ui/src/app/views/graph-view/graph-view.spec.ts`) extended with the two new methods.
222
+
223
+ **Tests**:
224
+
225
+ - New `src/test/scan-roots.test.ts` — exhaustive coverage of `resolveScanRoots` permutations (positional verbatim, `-g` mutex throw, project / global derivations, dedup).
226
+ - New `src/test/reference-paths-walker.test.ts` — recursive walk, missing roots, symlinks skipped, skip-list dirs, multi-root.
227
+ - New `src/test/project-preferences-route.test.ts` — boots `createServer()` against a tempdir cwd / homedir; covers default `GET`, the `confirm-required` 412 on expansion, the `confirm: true` round-trip, and 400 body-shape errors.
228
+
229
+ **Pre-1.0 minor bumps** per `spec/versioning.md` § Pre-1.0 — both surfaces grow additively (one new flag on `sm scan`, three new optional config keys, one new BFF route, one new UI section). Existing `scan` invocations behave identically with the new defaults (every new key defaults to the historical zero-state).
230
+
231
+ ## User-facing
232
+
233
+ **`sm scan -g` now scans your HOME directory.** Run `sm scan -g` (without positional roots) to walk every active provider's HOME dir — typically `~/.claude`, `~/.gemini`, `~/.agents` — using the global config + DB.
234
+
235
+ **Three new privacy-sensitive project settings.** Open Settings → Project to:
236
+
237
+ - **Include HOME provider directories** — when on, `sm scan` (without `-g`) also walks `~/.claude`, `~/.gemini`, `~/.agents` alongside your project content.
238
+ - **Extra scan roots** — paths you can add to the scan (indexed as nodes alongside the project root).
239
+ - **Reference paths (link validation)** — paths walked only to validate links; files there aren't indexed but `core/broken-ref` won't warn when a link target exists in one of them.
240
+
241
+ Every change that expands disk access beyond your project root requires explicit confirmation: a confirm dialog in the UI listing the paths that will be read, or `sm config set <key> <value> --yes` on the CLI. Writes that narrow the surface (toggling off, removing paths) need no confirmation.
242
+
243
+ - 825dce4: View-contribution slot expansion + new `node-icon` contract + host-enforced plugin lock.
244
+
245
+ **Spec changes** (`@skill-map/spec`):
246
+
247
+ - New contract `node-icon` in the closed catalog (`spec/view-contracts.md`, `spec/schemas/view-contracts.schema.json`). Single icon per node — small standalone marker rendered next to the card title. Manifest requires `icon`; payload optionally overrides per-node and may add `severity` (color tint, reusing the closed `Severity` palette) and `tooltip`. No counts, no labels — for chip + number use `node-counter`; for label + severity use `node-tag`; for an alert badge on the graph node corner use `node-alert`. The schema's `allOf` discriminator gains the `node-icon` branch (mirrors the existing `node-counter` rule that requires `icon`); the `Severity` `$def` description now lists `node-icon` alongside the other severity-aware contracts.
248
+ - New BFF error code `locked` (HTTP 403) on `PATCH /api/plugins/:id` and `PATCH /api/plugins/:bundleId/extensions/:extensionId` — emitted when the target id is in the host's hardcoded lock-list. `GET /api/plugins` mirrors the same rule by stamping an optional `locked: true` flag on the affected items so UIs can render the toggle disabled. The flag is omitted when false. Documented in `spec/cli-contract.md` (item shape, error code source table, restart-required note).
249
+
250
+ **Implementation changes** (`@skill-map/cli`):
251
+
252
+ - New `src/kernel/config/locked-plugins.ts` — single source of truth for the host lock-list (today: `core/markdown`). Three layers enforce it: the CLI (`sm plugins enable|disable` rejects with exit 5 + a directed message; `--all` quietly skips locked targets), the BFF (`PATCH /api/plugins/...` returns 403 `locked`), and the runtime resolver (`plugin-resolver.ts` ignores any persisted `config_plugins` row or `settings.json` entry against a locked id and returns the installed default — defense in depth so "lock" stays unbreakable regardless of stored state). Lives under `src/kernel/config/` so all three layers share the import without breaking the kernel's "no driver knows about other drivers" rule. The lock is host-only and not user-editable by design — to remove an entry, edit the file.
253
+ - `src/server/app.ts` — `TErrorCode` gains `'locked'`; `codeForStatus` maps HTTP 403 → `locked`. `src/server/i18n/server.texts.ts` — new `pluginsLocked` / `pluginsExtensionLocked` messages. `src/server/routes/plugins.ts` — `IPluginExtensionItem` and `IPluginListItem` gain optional `locked?: boolean`; both PATCH handlers reject locked targets with HTTPException 403 before the persistence step.
254
+ - `src/built-in-plugins/rules/unknown-contract/index.ts`, `src/kernel/types/view-catalog.ts`, `src/cli/commands/plugins.ts` (`VIEW_CONTRACTS_CATALOG`) — new `node-icon` entry registered in every catalog the kernel/CLI publishes. The `unknown-contract` lint rule now considers `node-icon` known (no warning).
255
+ - `src/cli/commands/plugins.ts` — bundle-detail rendering now qualifies extension names with `<bundleId>/` only when `granularity: 'extension'` (the toggle-able id surface); for `granularity: 'bundle'` the per-extension names stay bare since they are informational rather than user-tippable.
256
+ - `src/cli/i18n/plugins.texts.ts` — new `pluginLocked` / `pluginLockedHint` strings.
257
+ - `src/test/server-endpoints.test.ts` — two new cases: PATCH against `core/markdown` returns 403 `locked`, and `GET /api/plugins` stamps `locked: true` on the same row.
258
+ - `src/built-in-plugins/extractors/at-directive/index.ts` — gains a `node-counter` view contribution (`count` / icon `@` / label `mentions` / `emitWhenEmpty: false`) and a one-line `ctx.emitContribution('count', ...)` after the extractor's main loop. First built-in extractor to emit a real contribution end-to-end, exercising the new card slots without any user plugin installed.
259
+
260
+ **UI changes** (private `ui/` workspace, ships bundled in `@skill-map/cli`):
261
+
262
+ - Three new slot ids in the closed UI catalog (`ui/src/app/slots/slot-config.ts`): `card.title.right` (cap 2, sits next to the node title), `card.subtitle.left` (cap 3, sits in the date stat row), `card.footer.right` (cap 5, sits alongside the hardcoded status icons in a new `.sm-gnode__footer-right-cluster` wrapper that owns the right-alignment). All three are `multi`/`priority`/`append`/`respectSeverity: true`. The card template (`ui/src/app/components/node-card/node-card.html` + `.css`) wires the three host instances; no slot is empty-collapsed (the host stays silent when no contribution targets it).
263
+ - New `node-icon` renderer (`ui/src/app/renderers/node-icon/node-icon.ts`) — sized to match `.sm-gnode__chevron` (22×22, glyph 0.7rem) so the marker reads as a sibling of the chevron when both sit on the title row. Severity classes map to the same theme tokens the alert renderer uses.
264
+ - The `node-counter` contract now also targets `card.footer.right` and `card.subtitle.left` (its informative slot list grows), so existing `node-counter` plugins automatically light up the new card slots without manifest changes.
265
+ - `ui/src/app/components/view-contributions-host/view-contributions-host.ts` — the `DemoContributionsService` injection and "decorate" wiring are gone (the demo service was deleted; production sources only).
266
+ - `ui/src/app/services/demo-contributions.ts` — **deleted**. The synthetic chips-for-slot-validation service finished its purpose now that real contributions land in the new slots.
267
+ - `ui/src/app/views/graph-view/graph-view.{html,css,ts}` — the `.sm-gnode__marker-stub` placeholder svg + its CSS stub are dropped (the host underneath is the production surface; with `node-alert` plugins now demo-ready and `node-icon` shipping, the placeholder is redundant). The `resetLayout()` confirm dialog upgrades from `window.confirm()` to PrimeNG's `<p-confirmdialog>` (header / message / typed accept-and-reject buttons; mask gets the same global blur as the public site's cookie-consent banner).
268
+ - Settings → Plugins (`ui/src/app/components/settings-modal/settings-plugins.{ts,html,css}`) — locked rows render an amber "Locked" pill with a `pi-lock` glyph next to the existing source/version/granularity tags; the `<p-toggleswitch>` stays mounted but disabled, so the user sees the current enabled state and a tooltip explaining why it cannot move. Both bundle and extension rows participate. New helpers `bundleToggleInteractive` / `extensionToggleInteractive` gate the row-click and sub-row-click handlers.
269
+ - `ui/src/models/api.ts` — `IPluginExtensionApi` and `IPluginItemApi` gain optional `locked?: boolean` (mirrors the BFF wire shape).
270
+ - `ui/src/i18n/settings.texts.ts` — new `lockedLabel` / `lockedTooltip`. `ui/src/i18n/graph-view.texts.ts` — `resetLayoutConfirm` reshapes from a single string into `{ header, message, accept, reject }` to feed the PrimeNG dialog.
271
+ - `ui/src/app/debug-slots.css` — three new debug outline colors (orange / teal / purple) for the new slots.
272
+ - `ui/src/styles.css` — global `.p-dialog-mask` style (blur + dim) so the new `<p-confirmdialog>` and any future `<p-dialog [modal]>` get the same glass look the public site uses for its cookie-consent banner.
273
+
274
+ **Repo plumbing**:
275
+
276
+ - `package.json` — `bff:dev` gets a `prebff:dev` step (`npm run bff:scan`) that runs `sm scan` against `fixtures/local-scope` first, so the dev BFF always boots with a populated DB.
277
+ - `fixtures/local-scope/` — the curated demo-content directory shrinks to a minimal `DOC1.md` + `DOC2.md` pair (slim surface for testing the new view-contribution slots and the locked-plugin behaviour). The full curated content (claude / gemini agents, skills, commands, GEMINI.md, README.md, plus the `.gitignore` / `.skillmapignore`) is preserved as `fixtures/local-scope.full/` for cases that need the kitchen-sink fixture.
278
+
279
+ **ROADMAP changes**:
280
+
281
+ - §UI contribution system — Slot catalog list grows from 5 to 8; Contract catalog count flips from 10 to 11 with a one-line note on `node-icon`'s niche relative to `node-alert` and `node-counter`. Last-updated marker bumped to 2026-05-10.
282
+
283
+ **Pre-1.0 minor bump** per `spec/versioning.md` § Pre-1.0 — the spec change is additive (new contract entry + new optional field on the wire shape; existing `view-contracts.schema.json` consumers keep validating), the CLI change is additive (new error code, new slots, new contract, new lock surface — nothing removed), so both ride a normal minor.
284
+
285
+ ## User-facing
286
+
287
+ **Three new card slots and a small per-node icon contract.** The graph card now reserves room for plugin-emitted markers in three new spots: a tiny icon next to the node title (right side), a small chip in the date row, and an extra cluster on the right of the footer alongside the status icons. Existing `node-counter` plugins automatically light up the new footer-right and subtitle slots — no manifest changes needed. Plugin authors can also pick a new contract, `node-icon`, for a single-glyph marker (e.g. language flag, "has audio", platform badge) when a counter or tag would be too noisy. See [`spec/view-contracts.md`](https://github.com/crystian/skill-map/blob/main/spec/view-contracts.md#node-icon) for the full schema.
288
+
289
+ **Plugins can now be locked by the host.** Settings → Plugins shows a "Locked" pill on plugins that the host marks as mandatory — today only `core/markdown` (the universal `.md` fallback). The toggle stays visible but disabled so it is obvious the lock is intentional, with a tooltip explaining why. `sm plugins disable core/markdown` now rejects the call with a clear message instead of writing a no-op override.
290
+
291
+ **Reset Layout uses a proper dialog.** The "Reset all node positions" action used to fire a browser-native `confirm()` popup; it now uses the same in-app dialog style as the rest of the UI (with a destructive-styled "Reset" button and a "Cancel" escape).
292
+
293
+ ### Patch Changes
294
+
295
+ - 5600a60: Move `updateCheck.enabled` to user scope and add a reusable typed config helper. Settings UI's General section now exposes the toggle.
296
+
297
+ **Spec changes** (`@skill-map/spec`, patch):
298
+
299
+ - `spec/schemas/project-config.schema.json` — `updateCheck` description gains a "user-scope only" note: this key SHOULD live in `~/.skill-map/settings.json`; the reference implementation forces user-scope reads via `core/config/helper:USER_ONLY_KEYS` and `sm config set` rejects writes to the project layer. Project-layer entries from older installs continue to validate but are silently ignored at read time. Schema itself stays additive (no breaking change).
300
+ - `spec/index.json` regenerated.
301
+
302
+ **Implementation changes** (`@skill-map/cli`, minor):
303
+
304
+ - New `src/core/config/dot-path.ts` — promoted from `cli/commands/config.ts`. Exports `getAtPath` / `setAtPath` / `deleteAtPath` / `assertSafeSegments` / `enumerateConfigPaths` / `FORBIDDEN_SEGMENTS` / `ForbiddenSegmentError`. Same prototype-pollution guards as before.
305
+ - New `src/core/config/atomic-write.ts` — promoted `writeJsonAtomic` + `readJsonObjectOrEmpty` so any settings-mutating code path shares one implementation (atomic temp-then-rename, no half-written files on crash).
306
+ - New `src/core/config/helper.ts` — typed read / write surface composed over `loadConfig` + the promoted helpers + AJV revalidation:
307
+ - `readConfigValue<T>(key, { scope, cwd, homedir, default?, strict? })`
308
+ - `writeConfigValue(key, value, { target, cwd, homedir })` — AJV-revalidates the post-mutation file before atomic write
309
+ - `removeConfigValue(key, opts)` — returns `boolean` indicating whether a write happened
310
+ - `getValueSource(key, opts)` — wrap of `loadConfig().sources` for "who set this"
311
+ - `USER_ONLY_KEYS` — a small set (today: `updateCheck.enabled`) the helper hard-pins to the user / global layer regardless of caller intent. Reads force `scope: 'global'`; writes throw `UserOnlyKeyError` on `target: 'project'`.
312
+ - `src/cli/util/update-check-banner.ts` — `isUpdateCheckEnabled` now calls `readConfigValue<boolean>('updateCheck.enabled', { scope: 'global', ..., default: true })`. A project-layer override is silently ignored (the helper forces scope:'global' for the key); the previous "project wins by precedence" behavior is gone for this key only.
313
+ - `src/cli/commands/config.ts` — refactored to use `core/config/helper` + the promoted helpers. `ConfigSetCommand` and `ConfigResetCommand` surface `UserOnlyKeyError` and `ConfigValidationError` as exit-2 errors with directed messages (`CONFIG_TEXTS.userOnlyKeyRejection` / `userOnlyKeyRejectionHint`). ~150 lines of inlined dot-path / atomic-write / forbidden-segments code deleted.
314
+ - `src/cli/i18n/config.texts.ts` — new `userOnlyKeyRejection` / `userOnlyKeyRejectionHint` strings.
315
+
316
+ **BFF additions** (`@skill-map/cli`):
317
+
318
+ - New `src/server/routes/preferences.ts` — `GET /api/preferences` returns the user-scope envelope `{ updateCheck: { enabled: boolean } }`; `PATCH /api/preferences` accepts a partial patch and writes through `writeConfigValue` with `target: 'user'`. Manual body validation (no Zod, mirroring `routes/plugins.ts`); errors flow through `app.onError` as `HTTPException(400)` with the existing `bad-query` envelope code. Mounted in `src/server/app.ts`.
319
+ - `src/server/i18n/server.texts.ts` — six new strings for the preferences route's 400 envelopes (`preferencesBodyNotJson`, `preferencesBodyNotObject`, `preferencesBodyEmpty`, `preferencesUpdateCheckNotObject`, `preferencesUpdateCheckEnabledNotBoolean`, `preferencesPersistFailed`).
320
+
321
+ **UI additions** (private `ui/` workspace, ships bundled in `@skill-map/cli`):
322
+
323
+ - New `ui/src/app/components/settings-modal/settings-general.{ts,html,css}` — General section of the Settings modal. Today renders a single `Check for updates` toggle wired to `updateCheck.enabled`, but the component is built around a declarative `GENERAL_TOGGLES: ReadonlyArray<IGeneralToggleDef>` array — adding a future user-only preference (locale, theme, …) is one entry there plus one nested key in `SETTINGS_TEXTS.general.toggles`, no template / component change.
324
+ - `ui/src/app/components/settings-modal/settings-modal.ts` — `general` section flips from `coming-soon` placeholder to `available`; registers `SettingsGeneral` in the imports list. The modal HTML adds the corresponding `@case ('general')` branch.
325
+ - `ui/src/i18n/settings.texts.ts` — new `general` block with heading / intro / load-error / save-error prefixes + per-toggle label & description.
326
+ - `ui/src/models/api.ts` — new `IPreferencesApi` and `IPreferencesPatchApi` types mirroring the BFF wire shape.
327
+ - `ui/src/services/data-source/data-source.port.ts` — `IDataSourcePort` gains `getPreferences()` / `setPreferences(patch)`. `RestDataSource` implements them via the new BFF route; `StaticDataSource` returns the shipped default for `getPreferences()` and rejects `setPreferences()` with `code: 'demo-readonly'`.
328
+ - Two pre-existing test stubs (`ui/src/app/app.spec.ts`, `ui/src/app/views/graph-view/graph-view.spec.ts`) extended with the two new methods so the `IDataSourcePort` mock satisfies the contract.
329
+
330
+ **Tests**:
331
+
332
+ - New `src/test/config-helper.test.ts` — coverage for `readConfigValue` / `writeConfigValue` / `removeConfigValue` / `getValueSource`: regular precedence, `USER_ONLY_KEYS` ignoring project layer, `UserOnlyKeyError` rejection on project-target writes, idempotent remove, schema-violation rejection (`ConfigValidationError`), prototype-pollution guard.
333
+ - New `src/test/preferences-route.test.ts` — boots `createServer()` against a tempdir cwd / homedir; covers default `GET` envelope, `PATCH` round-trip writes to user layer (NOT project), and 400 responses for bad body / empty body / wrong type.
334
+ - `src/test/update-check.test.ts` — extended with one case asserting a project-layer `updateCheck.enabled: false` is ignored at read time (banner still prints).
335
+
336
+ **Pre-1.0 minor bump on `@skill-map/cli`** — the read-behavior change for `updateCheck.enabled` is observable to any user who previously wrote the key into a project file. Documented in the "user-facing" section below. The spec change is a doc-only patch (description text only; schema unchanged).
337
+
338
+ ## User-facing
339
+
340
+ **Update-check is now a user preference.** Whether you see "Update available" notifications no longer depends on the project you are scanning. The toggle moved to **Settings → General** in the UI; the CLI equivalent is `sm config set -g updateCheck.enabled <bool>`. `sm config set` (without `-g`) now rejects this key with a clear "rerun with -g" error so you never write it to the wrong file by accident.
341
+
342
+ If you previously had `updateCheck.enabled: false` in `<project>/.skill-map/settings.json`, that override is now **ignored** — re-set the value with `-g` (or untick the toggle in Settings → General) to make it stick across projects.
343
+
3
344
  ## 0.19.0
4
345
 
5
346
  ### Minor Changes
@@ -130,9 +471,9 @@
130
471
  - `IScanRunOpts.emitterFactory` (new optional field on `core/runtime/scan-runner.ts`) — when set, the runner threads the supplied emitter into `runScanWithRenames` instead of building a stderr-bound progress emitter. The watcher already uses the same pattern; the BFF's `POST /api/scan` route now reuses it to plug the broadcaster.
131
472
  - `buildBroadcasterEmitter` in `src/server/watcher.ts` is now exported so the new route can wire the same emitter the watcher uses.
132
473
 
133
- - 496fb72: Complete the `IRuleContext.emitContribution` runtime channel and add `core/link-counts` built-in rule.
474
+ - 496fb72: Complete the `IAnalyzerContext.emitContribution` runtime channel and add `core/link-counts` built-in rule.
134
475
 
135
- The view-contribution surface had a half-implemented seam: any extension's manifest could declare `viewContributions`, the catalog (`kernel.getRegisteredViewContributions()`) recognised Rule declarations, but `IRuleContext` had no `emitContribution` callback so a Rule's `evaluate()` had no way to actually emit. Extending `IRuleContext` with `emitContribution(nodePath, contributionId, payload)` completes the seam.
476
+ The view-contribution surface had a half-implemented seam: any extension's manifest could declare `viewContributions`, the catalog (`kernel.getRegisteredViewContributions()`) recognised Rule declarations, but `IAnalyzerContext` had no `emitContribution` callback so a Rule's `evaluate()` had no way to actually emit. Extending `IAnalyzerContext` with `emitContribution(nodePath, contributionId, payload)` completes the seam.
136
477
 
137
478
  The first adopter is `core/link-counts` — a built-in Rule that emits two `node-counter` contributions per node (`linksOut`, `linksIn`) based on the post-merge graph. The data lives on `node.linksOutCount` / `node.linksInCount` already; the Rule projects it into the view contribution system so slot-aware UI surfaces (graph cards, inspector chips) render the counts uniformly with any plugin contribution. Skips emit when count is 0 to avoid empty panels.
138
479
 
@@ -140,7 +481,7 @@
140
481
 
141
482
  **Surface changes**
142
483
 
143
- - `src/kernel/extensions/rule.ts` — `IRuleContext.emitContribution(nodePath, contributionId, payload)` added.
484
+ - `src/kernel/extensions/rule.ts` — `IAnalyzerContext.emitContribution(nodePath, contributionId, payload)` added.
144
485
  - `src/kernel/orchestrator.ts` — `runRules()` builds a per-rule emission buffer with the same validator + persist semantics as the Extractor path; `RunScanOptions` adds `viewContributions?` (parallel to `annotationContributions?`). The `readDeclaredContributions` helper is generalised from `IExtractor` to any extension that carries `viewContributions` (structural typing).
145
486
  - `src/built-in-plugins/rules/link-counts/index.ts` — new built-in.
146
487
  - `src/built-in-plugins/built-ins.ts` — `linkCountsRule` registered under `core` bundle; built-in count rises from 21 to 22 (and rules from 10 to 11).
@@ -151,7 +492,7 @@
151
492
  - `src/built-in-plugins/rules/link-counts/link-counts.test.ts` — unit tests for the rule's evaluate logic + integration test that runs the orchestrator end-to-end and asserts the persisted contribution rows.
152
493
  - `src/test/built-ins-modes.test.ts` — total built-ins count bumped 21 → 22.
153
494
  - `src/test/plugin-runtime-branches.test.ts` — composed.rules.length asserts bumped 10 → 11; rule id list updated.
154
- - `src/built-in-plugins/rules/rules.test.ts`, `src/built-in-plugins/rules/validate-all/validate-all.test.ts`, `src/test/unknown-field-rule.test.ts` — test contexts now supply a noop `emitContribution` (required field on the new `IRuleContext`).
495
+ - `src/built-in-plugins/rules/rules.test.ts`, `src/built-in-plugins/rules/validate-all/validate-all.test.ts`, `src/test/unknown-field-rule.test.ts` — test contexts now supply a noop `emitContribution` (required field on the new `IAnalyzerContext`).
155
496
 
156
497
  **Persistence**: no SQL migration. The `scan_contributions` table is agnostic to the emitting kind; Rule emissions land in the same rows as Extractor emissions. The orphan sweep + catalog sweep semantics keep working unchanged.
157
498
 
@@ -328,7 +669,7 @@
328
669
 
329
670
  **Spec additions**: `spec/view-contracts.md` + `spec/input-types.md` (catalog references); `spec/schemas/view-contracts.schema.json` + `spec/schemas/input-types.schema.json` (closed-enum AJV catalogs with per-contract payload schemas); `spec/architecture.md` § View contribution system (kernel surface, persistence semantics, BFF surface, isolation rules, soft-warning rules, catalog versioning); `spec/plugin-author-guide.md` § View contributions (tutorial); `spec/db-schema.md` § `scan_contributions` (orphan + catalog sweep + upsert semantics, NOT pure replace-all). `spec/schemas/extensions/base.schema.json` extended with `viewContributions` map; `spec/schemas/plugins-registry.schema.json` extended with manifest-root `settings` + `catalogCompat` semver field + `incompatible-catalog` plugin status; `spec/schemas/api/rest-envelope.schema.json` extended with `contributionsRegistry` field on payload-bearing variants + `contributions.registered` envelope kind. `spec/schemas/extensions/extractor.schema.json` relaxes `emitsLinkKinds` minItems so pure-contributions extractors (`emitsLinkKinds: []`) load cleanly.
330
671
 
331
- **Implementation additions** (`@skill-map/cli`): kernel surface (`IExtensionBase.viewContributions`, `IExtractorCallbacks.emitContribution`, `IRuleContext.viewContributions`, `kernel.{get,set}RegisteredViewContributions`); orchestrator emit-time wiring with AJV per-contract payload validation (off-contract → `extension.error` event + silent drop, mirror of `emitLink`); persistence layer (`scan_contributions` table in `src/migrations/001_initial.sql` per the migrations-consolidation greenfield fold, `src/kernel/adapters/sqlite/contributions.ts` adapter, sweep semantics in `replaceAllScanContributions`); BFF (3 endpoints under `/api/contributions/*`, `contributionsRegistry` on every payload-bearing envelope, `contributions[]` per node on `/api/scan` + `/api/nodes`); CLI verbs (`PluginsCreateCommand` scaffolder + `PluginsContractsListCommand` + `PluginsUpgradeCommand` migration shell); two built-in adopters (`core/annotations` — landed here as `claude/frontmatter` and renamed during the cross-vendor extractor move to `core/` — → `per-node-key-values`; `core/external-url-counter` → `per-node-counter`); two soft-warning rules (`core/unknown-contract`, `core/contribution-orphan`).
672
+ **Implementation additions** (`@skill-map/cli`): kernel surface (`IExtensionBase.viewContributions`, `IExtractorCallbacks.emitContribution`, `IAnalyzerContext.viewContributions`, `kernel.{get,set}RegisteredViewContributions`); orchestrator emit-time wiring with AJV per-contract payload validation (off-contract → `extension.error` event + silent drop, mirror of `emitLink`); persistence layer (`scan_contributions` table in `src/migrations/001_initial.sql` per the migrations-consolidation greenfield fold, `src/kernel/adapters/sqlite/contributions.ts` adapter, sweep semantics in `replaceAllScanContributions`); BFF (3 endpoints under `/api/contributions/*`, `contributionsRegistry` on every payload-bearing envelope, `contributions[]` per node on `/api/scan` + `/api/nodes`); CLI verbs (`PluginsCreateCommand` scaffolder + `PluginsContractsListCommand` + `PluginsUpgradeCommand` migration shell); two built-in adopters (`core/annotations` — landed here as `claude/frontmatter` and renamed during the cross-vendor extractor move to `core/` — → `per-node-key-values`; `core/external-url-counter` → `per-node-counter`); two soft-warning rules (`core/unknown-contract`, `core/contribution-orphan`).
332
673
 
333
674
  **UI additions** (private `ui/` workspace): closed slot catalog (`ui/src/app/slots/slot-config.ts`) + closed renderer catalog (`ui/src/app/contracts/contract-renderer-map.ts`) + 10 renderer Angular components + slot host (`<sm-view-contributions-host>`) + contributions registry service. Mounts in inspector header badge + body + node card chip slots. Data path extensions: `IContributionApi` + `IContributionsRegistryApi`; `INodeApi.contributions[]`; `INodeView.contributions[]` (projection layer); `IDataSourcePort.lookupContribution`; rest data source ingests `contributionsRegistry` on every fetch + lazy lookup endpoint.
334
675
 
@@ -588,7 +929,7 @@
588
929
 
589
930
  **Runtime catalog.** `Kernel` gains `getRegisteredAnnotationKeys(): readonly IRegisteredAnnotationKey[]`, populated once by `registerEnabledExtensions` after every plugin loads. Pure read; no side effects. Built-in catalog fields from `annotations.schema.json` are NOT included — this catalog is plugin-only. The BFF endpoint that wraps the catalog for UI autocomplete lands separately.
590
931
 
591
- **`core/unknown-field` rule.** New built-in Tier-1 typo guard (`severity: warn`). Walks parsed `.sm` sidecars and emits a warning for: (1) keys inside `annotations:` not in the curated catalog, (2) top-level keys outside the four reserved blocks that are not a registered plugin namespace nor a registered root contribution, (3) plugin-namespaced values that fail their contributing plugin's schema. The orchestrator threads parsed sidecar roots into the rule pass via `IRuleContext.sidecarRoots` plus the runtime catalog via `IRuleContext.annotationContributions`.
932
+ **`core/unknown-field` rule.** New built-in Tier-1 typo guard (`severity: warn`). Walks parsed `.sm` sidecars and emits a warning for: (1) keys inside `annotations:` not in the curated catalog, (2) top-level keys outside the four reserved blocks that are not a registered plugin namespace nor a registered root contribution, (3) plugin-namespaced values that fail their contributing plugin's schema. The orchestrator threads parsed sidecar roots into the rule pass via `IAnalyzerContext.sidecarRoots` plus the runtime catalog via `IAnalyzerContext.annotationContributions`.
592
933
 
593
934
  **Conformance.** New end-to-end case `sidecar-end-to-end` with fixture `spec/conformance/fixtures/sidecar-end-to-end/`. Flips coverage rows 26 + 27 (`sidecar.schema.json` + `annotations.schema.json`) from 🟡 partial to 🟢 covered. Asserts a populated `Node.sidecar` overlay, `status: stale-*` drift, denormalised `annotations.version`, and both `annotation-stale` + `annotation-orphan` issues from the built-in core rules.
594
935
 
package/README.md CHANGED
@@ -7,7 +7,7 @@ This document is the **source of truth**. The reference implementation under `..
7
7
  ## What this spec defines
8
8
 
9
9
  - The **domain model**: nodes, links, issues, scan results.
10
- - The **extension contract**: six extension kinds (provider, extractor, rule, action, formatter, hook) with their input/output shapes.
10
+ - The **extension contract**: six extension kinds (provider, extractor, analyzer, action, formatter, hook) with their input/output shapes.
11
11
  - The **CLI contract**: verb set, flags, exit codes, JSON introspection.
12
12
  - The **persistence contract**: table catalog owned by the kernel, plugin key-value API.
13
13
  - The **job contract**: lifecycle states, event stream, prompt preamble, submit/claim/record semantics.
@@ -36,10 +36,10 @@ These are implementation decisions. The reference impl picks them (see [`../AGEN
36
36
 
37
37
  ## Naming conventions
38
38
 
39
- Two rules govern every identifier in the spec. They are **normative**.
39
+ Two analyzers govern every identifier in the spec. They are **normative**.
40
40
 
41
- - **Filesystem artefacts use kebab-case.** Every file and directory in `spec/` (and in any conforming implementation) — `scan-result.schema.json`, `job-lifecycle.md`, `report-base.schema.json`, `auto-rename-medium` (as an `issue.ruleId` value), `direct-override` (as a `safety.injectionType` enum value), and so on — is kebab-case lowercase. Enum values and issue rule ids follow the same convention so they can be echoed back into URLs, filenames, and log keys without escaping.
42
- - **JSON content uses camelCase.** Every key inside a JSON Schema, frontmatter block, config file, plugin manifest, action manifest, job record, report, event payload, or API response is camelCase: `whatItDoes`, `injectionDetected`, `expectedTools`, `conflictsWith`, `docsUrl`, `examplesUrl`, `ttlSeconds`, `runId`, `jobId`. This matches the JS/TS ecosystem the reference impl ships in and the Kysely `CamelCasePlugin` that bridges to the `snake_case` SQL layer — but the rule is spec-level, not implementation-level: an alternative implementation in any language still exposes camelCase JSON keys.
41
+ - **Filesystem artefacts use kebab-case.** Every file and directory in `spec/` (and in any conforming implementation) — `scan-result.schema.json`, `job-lifecycle.md`, `report-base.schema.json`, `auto-rename-medium` (as an `issue.analyzerId` value), `direct-override` (as a `safety.injectionType` enum value), and so on — is kebab-case lowercase. Enum values and issue analyzer ids follow the same convention so they can be echoed back into URLs, filenames, and log keys without escaping.
42
+ - **JSON content uses camelCase.** Every key inside a JSON Schema, frontmatter block, config file, plugin manifest, action manifest, job record, report, event payload, or API response is camelCase: `whatItDoes`, `injectionDetected`, `expectedTools`, `conflictsWith`, `docsUrl`, `examplesUrl`, `ttlSeconds`, `runId`, `jobId`. This matches the JS/TS ecosystem the reference impl ships in and the Kysely `CamelCasePlugin` that bridges to the `snake_case` SQL layer — but the analyzer is spec-level, not implementation-level: an alternative implementation in any language still exposes camelCase JSON keys.
43
43
 
44
44
  The SQL persistence layer is the sole exception: tables, columns, and migration filenames use `snake_case` (see `db-schema.md`). That boundary is crossed only inside a storage adapter; nothing that leaves the kernel should ever be `snake_case`.
45
45
 
@@ -78,7 +78,7 @@ spec/ ← published as @skill-map/spec
78
78
  │ │ ├── base.schema.json ┐
79
79
  │ │ ├── provider.schema.json │
80
80
  │ │ ├── extractor.schema.json │ 6 extension schemas
81
- │ │ ├── rule.schema.json │ (base + 5 kinds)
81
+ │ │ ├── analyzer.schema.json │ (base + 5 kinds)
82
82
  │ │ ├── action.schema.json │
83
83
  │ │ └── formatter.schema.json ┘
84
84
  │ │
@@ -108,7 +108,7 @@ spec/ ← published as @skill-map/spec
108
108
  ## How to read this spec
109
109
 
110
110
  - **Building a tool or plugin that consumes skill-map output?** Start with [`schemas/scan-result.schema.json`](./schemas/scan-result.schema.json) and [`schemas/node.schema.json`](./schemas/node.schema.json).
111
- - **Building a custom extractor, rule, or formatter?** Read [`architecture.md`](./architecture.md), then the relevant schema under [`schemas/extensions/`](./schemas/extensions/).
111
+ - **Building a custom extractor, analyzer, or formatter?** Read [`architecture.md`](./architecture.md), then the relevant schema under [`schemas/extensions/`](./schemas/extensions/).
112
112
  - **Building an alternative CLI implementation?** Read [`cli-contract.md`](./cli-contract.md) and run [`conformance/`](./conformance/README.md).
113
113
  - **Integrating a new platform (adapter)?** Read [`architecture.md`](./architecture.md) §adapters, then the Claude adapter source in `../src/extensions/adapters/claude/` as a worked example.
114
114
  - **Shipping a job-running runner?** Read [`job-events.md`](./job-events.md), [`job-lifecycle.md`](./job-lifecycle.md), [`prompt-preamble.md`](./prompt-preamble.md).