@skill-map/spec 0.29.0 → 0.31.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,220 @@
1
1
  # Spec changelog
2
2
 
3
+ ## 0.31.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 29fb253: Active-provider lens model, Signal IR scaffold, numeric `Confidence`, MCP virtual nodes, OpenAI Codex provider, and the Phase 4b extractor mudanza in one coherent migration.
8
+
9
+ **Spec foundation**
10
+
11
+ - New `activeProvider` field on `project-config.schema.json` (per-project lens id, drives the new drop+rescan lifecycle and the per-extension `precondition.provider` gating).
12
+ - New `signal.schema.json` defining the multi-candidate `Signal` IR emitted by extractors via `ctx.emitSignal()`.
13
+ - `Node.virtual` (boolean) and `Node.derivedFrom` (string[]) on `node.schema.json` so synthetic / derived entities (MCP servers, future Codex AGENTS.md cascade) coexist with filesystem-backed nodes.
14
+ - `link.confidence` migrates from the `'high' | 'medium' | 'low'` string union to `number` in `[0..1]`. The SQL column flips to `REAL` with a range check.
15
+ - `spec/architecture.md` gets a top-level **Active Provider Lens** section and an **Extractor · Signal IR (opt-in)** subsection. `spec/cli-contract.md` documents the lens-change UX. `spec/db-schema.md` notes the scan-zone drop on lens change. Coverage matrix gains a row for the Signal schema.
16
+
17
+ **Kernel**
18
+
19
+ - `Signal` / `SignalCandidate` / `SignalRange` / `SignalContext` types alongside `Link` in `kernel/types.ts`. `ConfidenceTier` constants (`HIGH = 0.9`, `MEDIUM = 0.6`, `LOW = 0.3`) preserve bucket-thinking call sites.
20
+ - New `IExtractorCallbacks.emitSignal` and `IExtractorCallbacks.emitNode` plus the `IEmittedNode` payload. Orchestrator wires both with validators that drop off-spec emissions via `extension.error` events; the walker merges virtual nodes into the accumulator with first-wins dedup.
21
+ - New `orchestrator/resolver.ts` scaffold (first-candidate-wins today; provider `resolverRules` land later).
22
+ - `frontmatter-toml` parser registered in the kernel parser registry (`smol-toml@1.6.1` pinned).
23
+ - `precondition.provider` is now enforced in `computeCacheDecision`: an extractor whose manifest declares `['claude']` only runs on nodes the `claude` provider classified.
24
+
25
+ **Lens model wiring**
26
+
27
+ - `src/core/config/active-provider.ts`: `resolveActiveProvider(cwd)` with filesystem auto-detect (`.claude/` / `.gemini/` / `.codex/` / `AGENTS.md` / `.cursor/`) and `isExtensionActiveUnderLens` predicate.
28
+ - `src/cli/util/scan-zone-drop.ts`: atomic `DELETE FROM scan_*` helper, shared by `sm db reset` and the new `sm config set activeProvider` hook.
29
+ - `sm config set activeProvider <id>` drops the scan zone and prints the lens-switch receipt with a hint to rescan.
30
+ - BFF: `GET / PATCH /api/active-provider` with the same drop-on-switch semantic, registered before the catch-all.
31
+ - Settings UI: new "Active provider" row in the Project tab (now at the top of the section), confirm dialog before the destructive switch, post-switch announcement.
32
+
33
+ **Numeric `Confidence` migration**
34
+
35
+ Atomic across spec / kernel / SQL / extractors / tests:
36
+
37
+ - `Confidence = number` in `kernel/types.ts`; `enum-parsers.ts` range-checks `[0..1]`.
38
+ - `validateLink` rejects out-of-range values with a `link-confidence-out-of-range` extension.error.
39
+ - The 5 universal extractors emit calibrated numeric values (`annotations` 1.0, `markdown-link` 0.95, `external-url-counter` 0.3, `at-directive` 0.85/0.5, `slash` 0.8).
40
+ - `sm show` renders confidence as a percent (`85%`).
41
+ - `link-conflict` `rankConfidence` reduces to identity; resolver's `bucketConfidence` bridge removed.
42
+ - Rename heuristic uses `ConfidenceTier.HIGH/MEDIUM` and exposes `renameTierLabel` for the analyzer-id mapping.
43
+ - DB column flips from `TEXT` (enum check) to `REAL` (range check) in `001_initial.sql`.
44
+
45
+ **MCP virtual nodes**
46
+
47
+ - New `core/mcp-tools` extractor: detects `tools: [mcp__<server>__<tool>]` in agent/skill/command frontmatter and emits one virtual MCP node (`mcp://<server>`, `kind: 'mcp'`, `virtual: true`, `derivedFrom: [source]`) plus a `references` link from the source. Idempotent: N skills referencing the same server collapse into one node.
48
+ - Claude provider registers the `mcp` kind in its catalog (violet, server icon).
49
+
50
+ **OpenAI Codex provider (MVP)**
51
+
52
+ - New `src/plugins/openai/` bundle: declarative `read: { extensions: ['.toml'], parser: 'toml' }`, classifies `.codex/agents/*.toml` as `agent`. Schema mirrors Codex sub-agent fields (`name`, `description`, `model`, `instructions`, `tools`, `mcp_servers`, `approval_policy`, `sandbox_mode`).
53
+ - `BUNDLE_ORDER` extended in the built-ins generator. 5 bundles, 29 built-in extensions total.
54
+
55
+ **Phase 4b extractor mudanza**
56
+
57
+ - `at-directive` and `slash` move from `src/plugins/core/extractors/` to `src/plugins/claude/extractors/`. Both declare `pluginId: 'claude'` and `precondition: { provider: ['claude'] }`. Universal extractors (markdown-link, external-url-counter, annotations, tools-count, mcp-tools) stay in `core/`.
58
+
59
+ **Settings UI polish**
60
+
61
+ - `<sm-settings-project>` reorders to put the Active provider selector first.
62
+ - `<sm-kind-palette>` filters kinds with zero nodes; the left palette no longer shows always-empty buttons for kinds the loaded set has no instances of. New `__tests__/kind-palette.spec.ts` regression-guards the filter.
63
+
64
+ ## User-facing
65
+
66
+ Settings → Project: new Active provider dropdown switches the runtime lens (Claude / Gemini / Codex / Cursor). Tools matching `mcp__name__*` surface MCP nodes in the graph. Codex `.toml` sub-agents under `.codex/agents/` are classified. Link confidence renders as percent.
67
+
68
+ ## 0.30.0
69
+
70
+ ### Minor Changes
71
+
72
+ - 5f4b181: Remove `@skill-map/testkit` and `examples/hello-world` from the monorepo.
73
+ The packaged plugin-author helper layer is retired. Plugin authors test
74
+ extensions by building fake `ctx` literals against the public types
75
+ re-exported from `@skill-map/cli` (`IExtractor`, `IAnalyzer`,
76
+ `IFormatter`, the matching `*Context` shapes, `Node`, `Link`, `Issue`).
77
+ Reason: zero downstream consumers in the public ecosystem after Step
78
+ 9.3; the maintenance cost of an independently-versioned npm package +
79
+ its own changesets, validate phases, and narrative outweighed the value
80
+ of a thin packaged helper layer.
81
+
82
+ **`spec/plugin-author-guide.md`:**
83
+
84
+ - §Testing rewritten as "Testing your plugin": shows the fake-`ctx`
85
+ pattern inline (extractor + analyzer + formatter + probabilistic
86
+ runner), with the public types coming from `@skill-map/cli`.
87
+ - §Stability footer updated to reference Step 10 for future
88
+ Action / Hook testing patterns instead of testkit coverage.
89
+ - §Providers / Actions advisory wording no longer references the
90
+ testkit roadmap.
91
+
92
+ **`spec/architecture.md`:**
93
+
94
+ - `src/` directory tree drops the `testkit/` row.
95
+ - Qualified-id example list swaps `hello-world/greet` for the
96
+ generic `my-plugin/my-extractor`.
97
+
98
+ **Monorepo plumbing** (no end-user impact):
99
+
100
+ - `pnpm-workspace.yaml`, root `package.json`, `Dockerfile`, and
101
+ `scripts/check-changeset.js` drop the `testkit/` and
102
+ `examples/hello-world/` entries.
103
+ - `context/scripts.md`, `context/kernel.md`, `context/notebooklm.md`,
104
+ `ROADMAP.md`, `CONTRIBUTING.md`, `AGENTS.md`, `.claude/agents/commit.md`,
105
+ and `scripts/build-user-changelog.js` updated to reflect the
106
+ two-public-package surface (`@skill-map/spec` + `@skill-map/cli`).
107
+ - `src/__tests__/integration/dockerfile-demo-assets.spec.ts` drops
108
+ the obsolete `COPY` assertions for both removed workspaces.
109
+ - JSDoc in `src/kernel/registry.ts` replaces the `hello-world/greet`
110
+ example with `my-plugin/my-extractor`.
111
+
112
+ **`web/modules/roadmap.js`:**
113
+
114
+ - Step 9 card (EN + ES, release tag + brief) drops the
115
+ `@skill-map/testkit` mention.
116
+
117
+ **Post-merge action required**: run
118
+ `/usr/bin/npm deprecate "@skill-map/testkit@*" "Subsumed: plugin authors
119
+ test against @skill-map/cli types directly. See
120
+ https://github.com/crystian/skill-map/blob/main/spec/plugin-author-guide.md."`
121
+ against the real `npm` binary (NOT the `pnpm`-aliased `npm` in the
122
+ maintainer's shell, which fails with `ERR_PNPM_REGISTRY_ERROR: 404 Not
123
+ Found` on the deprecate endpoint). `/usr/bin/` bypasses the zsh alias;
124
+ `command npm` and `\npm` are equivalent escapes. Latest published
125
+ version is `0.5.2`; the wildcard range covers every prior tag so anyone
126
+ with the package pinned sees the deprecation notice.
127
+
128
+ - d95e5b8: Remove the `scan.extraFolders` config key. Project-local persistent
129
+ extension of the indexed scan no longer exists; to walk a directory
130
+ outside the project root pass it as a positional argument to
131
+ `sm scan [roots...]` (per-invocation, not persisted). The narrower
132
+ `scan.referencePaths` key (validate links against on-disk files
133
+ without indexing them) is unaffected.
134
+
135
+ **Spec (`spec/`):**
136
+
137
+ - `spec/schemas/project-config.schema.json`: `extraFolders` block
138
+ deleted. `scan.referencePaths` description trimmed of cross-
139
+ references and now reads stand-alone.
140
+ - `spec/architecture.md` §Config layering: `PROJECT_LOCAL_ONLY_KEYS`
141
+ catalogue drops `scan.extraFolders`.
142
+ - `spec/plugin-author-guide.md`: the "the only way to scan paths
143
+ outside the project is `scan.extraFolders`" sentence rewrites to
144
+ point at positional roots.
145
+ - `spec/index.json` regenerated.
146
+
147
+ **Kernel + config (`src/kernel/`, `src/config/`, `src/core/config/`):**
148
+
149
+ - `IScanConfig` drops `extraFolders: string[]`.
150
+ - `PROJECT_LOCAL_ONLY_KEYS` and `PRIVACY_SENSITIVE_KEYS` lose the
151
+ entry.
152
+ - `projectPathExposure` collapses the two-branch list-check to one.
153
+ - `defaults.json` drops the `extraFolders: []` line.
154
+
155
+ **Runtime (`src/core/runtime/`):**
156
+
157
+ - `resolveScanRoots(inputs)` simplifies to `{ positionalRoots } =>
158
+ string[]`; no more `IScanRootsInputs.extraFolders`,
159
+ `IScanRootsResolution.fromExtra`, or `emitRootsAdvisory()`.
160
+ - The `includingExtraFoldersAdvisory` text catalog entry is removed.
161
+
162
+ **CLI (`src/cli/`):**
163
+
164
+ - `sm scan` help text loses the extraFolders sentence; positional
165
+ roots are now the documented way to extend the scan.
166
+ - `sm serve` boot banner reads only `scan.referencePaths` from the
167
+ effective config; the banner row labelled `Extras` (and the
168
+ matching shape on `IBannerInput` / `IFigletInput`) is removed.
169
+ `Refs` stays.
170
+ - `sm config set --yes` description trimmed to reflect the single
171
+ privacy-sensitive key remaining.
172
+
173
+ **Server (`src/server/`):**
174
+
175
+ - `WatcherService` no longer reads config to compute roots; it walks
176
+ `['.']` unconditionally. `loadConfig` and `resolveScanRoots`
177
+ imports drop. `restart()` is still useful (and still wired by
178
+ `PATCH /api/project-preferences`) so the side-set walk picks up
179
+ fresh `scan.referencePaths` on the next batch.
180
+ - `PATCH /api/project-preferences`: AJV body schema, `IPatchBody`,
181
+ `IProjectPreferencesEnvelope`, `IPlannedWrite.key`, `collectWrites`
182
+ all collapse to a single `referencePaths` branch.
183
+ - Catalog strings adjusted (the `extraFolders` example dropped from
184
+ `projectPrefsScanNotObject` etc).
185
+
186
+ **UI (`ui/src/`):**
187
+
188
+ - Settings → Project drops the entire `extraFolders` row (HTML, TS
189
+ signal + computed + add/remove handlers, i18n strings, mocks).
190
+ - `IProjectPreferencesApi` and `IProjectPreferencesPatchApi` lose
191
+ `extraFolders`.
192
+ - Test mocks (`app.spec.ts`, `graph-view.spec.ts`,
193
+ `inspector-view.spec.ts`) updated.
194
+
195
+ **Tests:**
196
+
197
+ - `server/routes/__tests__/project-preferences-route.spec.ts`: 5
198
+ PATCH cases remapped from `extraFolders` to `referencePaths`.
199
+ - `kernel/config/__tests__/config-loader.spec.ts`: strip-test
200
+ renamed and split.
201
+ - `core/runtime/__tests__/scan-roots.spec.ts`: drops 3 cases that
202
+ passed `extraFolders`; keeps the positional + default cases.
203
+ - `core/config/__tests__/config-helper.spec.ts`:
204
+ `PROJECT_LOCAL_ONLY_KEYS` catalogue assertion narrowed; the
205
+ `target=project` rejection test now targets `scan.referencePaths`.
206
+
207
+ **Backward compatibility note**: existing `settings.local.json` files
208
+ that still carry `scan.extraFolders` keep loading without error. The
209
+ loader's per-key resilience drops the unknown key with a generic
210
+ "unknown key ignored" warning; nothing crashes, the rest of the file
211
+ takes effect. Operators who relied on the key should switch to
212
+ positional roots on `sm scan`.
213
+
214
+ ## User-facing
215
+
216
+ We removed `scan.extraFolders`. To extend the scan beyond the project root, pass folders as positional arguments to `sm scan [roots...]`. The `scan.referencePaths` key (validates links against on-disk files without indexing) is unchanged. Existing entries are silently ignored.
217
+
3
218
  ## 0.29.0
4
219
 
5
220
  ### Minor Changes
package/architecture.md CHANGED
@@ -39,6 +39,32 @@ Any conforming implementation, reference or third-party, MUST respect these boun
39
39
 
40
40
  ---
41
41
 
42
+ ## Active Provider Lens
43
+
44
+ A skill-map project sees its filesystem through exactly one **active provider lens** at any time. The lens is the provider whose extractors, classifiers, and resolution rules apply to the whole project during a scan. All other enabled providers stay registered but their provider-specific extractors are skipped.
45
+
46
+ The lens is project-scope state. It lives in `.skill-map/settings.json` as the `activeProvider` key (see [`project-config.schema.json`](./schemas/project-config.schema.json#/properties/activeProvider)). When absent, the kernel auto-detects on first scan from filesystem heuristics (`.claude/` → `claude`, `.gemini/` → `gemini`, `.codex/` or root `AGENTS.md` → `openai`, etc.) and persists the result; if the heuristic is ambiguous or yields no result, the CLI and UI prompt the user to pick one of the enabled providers.
47
+
48
+ ### Consequence: one graph per project at a time
49
+
50
+ The persisted scan graph (`scan_*` zone) reflects the project as the active lens sees it. No cross-provider merging happens at storage time. A repo with both `.claude/` and `.gemini/` does NOT show "everyone's nodes at once"; it shows the active lens's view.
51
+
52
+ ### Consequence: lens change is destructive of the scan zone
53
+
54
+ Switching the active provider drops the `scan_*` zone atomically (nodes, links, issues, scan-result meta) and triggers a fresh scan under the new lens. The `state_*` zone (jobs, executions, summaries, enrichments, plugin KV, favorites) and the `config_*` zone survive untouched. Annotations (`.sm` sidecars on disk) are filesystem state and are also unaffected; the next scan re-derives the in-DB overlay from them.
55
+
56
+ This is a deliberate trade-off. Keeping two scan graphs simultaneously persisted (one per lens) would re-introduce the cross-provider coordination complexity the lens model exists to avoid. The drop+rescan UX is honest: changing lens means changing the world the graph represents, and the graph regenerates from the source of truth (the filesystem) under the new rules.
57
+
58
+ ### Cross-provider read at the provider level
59
+
60
+ A provider plugin MAY declare it reads source files belonging to ANOTHER provider's territory. The canonical example: Cursor's runtime consumes `.claude/skills/` and `.codex/skills/` natively; a Cursor provider in skill-map can therefore claim those paths from its own classifier so that under the Cursor lens, those files appear as Cursor-managed nodes with Cursor's interpretation rules. This is provider-internal logic, not a kernel feature; the lens model neither encourages nor prevents it.
61
+
62
+ ### Universal extractors and per-provider extractors
63
+
64
+ The lens does NOT gate the universal extractors that ship under `core/` (markdown links, external URLs, sidecar annotations). Those run regardless of the active provider because their semantics are provider-agnostic. Provider-specific extractors (Claude's `@`-directive parser, Gemini's three-surface `@`-parsers, Cursor's picker-derived references, the future Codex AGENTS.md walker) declare `precondition: { provider: '<id>' }` on their manifest; the orchestrator only invokes them when the node's provider matches AND the provider is the active lens.
65
+
66
+ ---
67
+
42
68
  ## Ports
43
69
 
44
70
  An implementation MUST expose these five ports. Each is an interface (TypeScript, in the reference impl; equivalent in other languages).
@@ -72,7 +98,7 @@ The loader enforces two id-uniqueness analyzers during discovery (see [`plugin-a
72
98
  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.
73
99
  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.
74
100
 
75
- 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`, `hello-world/greet`). 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.
101
+ 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.
76
102
 
77
103
  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`, `gemini`, `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.
78
104
 
@@ -238,6 +264,19 @@ The `Extractor` runtime contract is `extract(ctx) → void`. The extractor emits
238
264
 
239
265
  Extractors are deterministic-only; `ctx.runner` is NOT exposed on the Extractor context. LLM-driven enrichment of a node is an Action concern (queued as a job), not an Extractor concern.
240
266
 
267
+ ### Extractor · Signal IR (opt-in)
268
+
269
+ In addition to the `emitLink` path, Extractors MAY emit **Signals** via `ctx.emitSignal(signal)`. A Signal is a candidate detection: one or many alternative interpretations of the same body or frontmatter location, each carrying its own kind, target, confidence, and rationale. See [`signal.schema.json`](./schemas/signal.schema.json) for the full contract. The Signal IR is opt-in; an extractor whose detection is unambiguous (sidecar `supersedes`, `[text](file.md)` markdown links, plain `https://…` URLs) is encouraged to keep emitting Links directly with `ctx.emitLink`. Signals exist for the cases the resolver actually helps: detections where a single body token can plausibly mean several things and the active provider's rules need to decide.
270
+
271
+ The kernel's **resolver phase** runs after extraction completes and before analysis starts. For each Signal, the resolver:
272
+
273
+ 1. Filters candidates whose `extractorId` is not enabled (per `plugins.<id>.extensions.<extId>.enabled` overrides).
274
+ 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.
275
+ 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.
276
+ 4. Rejects all candidates and emits no Link if every interpretation has confidence below the configured floor.
277
+
278
+ 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.
279
+
241
280
  ### Extractor · enrichment layer
242
281
 
243
282
  `ctx.enrichNode(partial)` is the only writable surface the Extractor pipeline has on a node. The author's frontmatter on `scan_nodes.frontmatter_json` is read-only from any Extractor. Implementations MUST:
@@ -408,7 +447,6 @@ src/
408
447
  ├── kernel/ Registry, Orchestrator, domain types, use cases, port interfaces
409
448
  ├── cli/ Clipanion commands, thin wrappers over kernel
410
449
  ├── server/ Hono + WebSocket, thin wrapper over kernel
411
- ├── testkit/ Kernel mocks for plugin authors
412
450
  └── adapters/
413
451
  ├── sqlite/ node:sqlite + Kysely + CamelCasePlugin (StoragePort)
414
452
  ├── filesystem/ real fs (FilesystemPort)
@@ -459,10 +497,9 @@ One locality class constrains which layers a given key MAY live in. It is enforc
459
497
 
460
498
  Members:
461
499
  - `allowEditSmFiles`, per-project consent to create / modify `.sm` sidecars.
462
- - `scan.extraFolders`, additional scan paths (the ONLY way to extend the scan beyond the project root).
463
500
  - `scan.referencePaths`, additional link-validation paths.
464
501
 
465
- All three describe disk access the local operator opted into; sharing them via the repo would silently expand every collaborator's scan surface to paths that only make sense on the original author's machine.
502
+ Both describe disk access the local operator opted into; sharing them via the repo would silently expand every collaborator's scan surface to paths that only make sense on the original author's machine.
466
503
 
467
504
  Adding a new entry is a behaviour change for older installs that wrote the key into a committed file, the value gets stripped at read time. The changeset that adds the entry MUST document the migration.
468
505
 
package/cli-contract.md CHANGED
@@ -76,6 +76,16 @@ etc.). Constraints:
76
76
 
77
77
  Everything else under `$HOME` MUST NOT be touched.
78
78
 
79
+ ### Active provider lens
80
+
81
+ The project sees its filesystem through exactly one **active provider lens** at any time. The lens is persisted as `activeProvider` in `<cwd>/.skill-map/settings.json` (see [`project-config.schema.json`](./schemas/project-config.schema.json#/properties/activeProvider) and the [`architecture.md` §Active Provider Lens](./architecture.md#active-provider-lens) section for the full architectural rationale).
82
+
83
+ CLI surfaces:
84
+
85
+ - **Auto-detect on first scan**: when `activeProvider` is absent, `sm scan` and `sm watch` run a filesystem heuristic (`.claude/` → `claude`, `.gemini/` → `gemini`, `.codex/` or root `AGENTS.md` → `openai`, etc.). On unambiguous match, the result is persisted to `settings.json` and the scan proceeds; on no match, the CLI exits non-zero with a "no provider detected, set `activeProvider` in settings or install a provider plugin" message. On ambiguous match (multiple providers detected), the CLI prompts the user interactively (or fails with exit code 2 under `--yes` if no default is configured).
86
+ - **Manual override**: `sm config set activeProvider <id>` switches the lens. The verb drops the `scan_*` zone atomically (see [`db-schema.md`](./db-schema.md#zones)) and triggers an immediate rescan under the new lens. `state_*` and `config_*` zones survive.
87
+ - **No per-scan flag**: there is no `sm scan --provider=<id>` flag. The lens is a project-level decision, not a per-invocation parameter. The drop+rescan cost makes per-invocation switching the wrong default UX.
88
+
79
89
  ---
80
90
 
81
91
  ## Targeted fan-out flags
@@ -10,11 +10,12 @@
10
10
  "assertions": [
11
11
  { "type": "exit-code", "value": 0 },
12
12
  { "type": "stderr-matches", "pattern": "plugin bad-provider:.*invalid.*must have required property 'ui'" },
13
- { "type": "json-path", "path": "$.providers.length", "equals": 4 },
13
+ { "type": "json-path", "path": "$.providers.length", "equals": 5 },
14
14
  { "type": "json-path", "path": "$.providers[0]", "equals": "claude" },
15
15
  { "type": "json-path", "path": "$.providers[1]", "equals": "gemini" },
16
- { "type": "json-path", "path": "$.providers[2]", "equals": "agent-skills" },
17
- { "type": "json-path", "path": "$.providers[3]", "equals": "markdown" },
16
+ { "type": "json-path", "path": "$.providers[2]", "equals": "openai" },
17
+ { "type": "json-path", "path": "$.providers[3]", "equals": "agent-skills" },
18
+ { "type": "json-path", "path": "$.providers[4]", "equals": "markdown" },
18
19
  { "type": "json-path", "path": "$.nodes.length", "equals": 1 }
19
20
  ]
20
21
  }
@@ -44,6 +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
48
 
48
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.
49
50
 
package/db-schema.md CHANGED
@@ -36,6 +36,8 @@ Every kernel table belongs to exactly one zone, identified by a mandatory name p
36
36
 
37
37
  `sm db reset` drops `scan_*` only (non-destructive, equivalent to forcing the next scan from a clean slate). `sm db reset --state` also drops `state_*` (destructive to operational history). `sm db reset --hard` deletes the DB file entirely. `sm db backup` preserves `state_*` + `config_*`; `scan_*` is always regenerated on demand and is never included in backups.
38
38
 
39
+ **Active-provider lens change**: switching the `activeProvider` setting (see [`cli-contract.md` §Active provider lens](./cli-contract.md#active-provider-lens) and [`architecture.md` §Active Provider Lens](./architecture.md#active-provider-lens)) drops the `scan_*` zone atomically and triggers a fresh scan under the new lens. Identical effect to `sm db reset` followed by `sm scan`, but bundled as a single transaction so the user never sees an empty graph between the two. `state_*` and `config_*` are preserved across the switch. The `config_plugins` and `config_preferences` rows survive (including the new `activeProvider` value itself).
40
+
39
41
  ---
40
42
 
41
43
  ## Naming conventions (normative)
package/index.json CHANGED
@@ -174,21 +174,21 @@
174
174
  }
175
175
  ]
176
176
  },
177
- "specPackageVersion": "0.29.0",
177
+ "specPackageVersion": "0.31.0",
178
178
  "integrity": {
179
179
  "algorithm": "sha256",
180
180
  "files": {
181
- "CHANGELOG.md": "9de333733bb26b8f1044d823568c4e7d0f92a770b9afe562f835b19a16c48e0a",
181
+ "CHANGELOG.md": "6c55bfe4d35bb532342ce36284ce682ff9610a03ccb997c90f95bd41b783ef73",
182
182
  "README.md": "54c4649fa9742bf2f74423ea78788a7474ce09649cbe1e72a270b606cf16a0a5",
183
- "architecture.md": "d40423d3df102c31744186f3b5e91446e57fb78839e67c010501fb210db6d545",
184
- "cli-contract.md": "c22f7c82d460714efaf34a04a2d2367d21eb04985100aef1291071e6726cbc64",
183
+ "architecture.md": "d7c01c6de2fb49959056bf01847fc290fafe26e094a8660690a9bb404de5694d",
184
+ "cli-contract.md": "196ad728c41ef5467ccfcdfdc983ef937d25a7aca426f622a70b300cd1d68c66",
185
185
  "conformance/README.md": "6871dde25b5770ed945284c9e0f749e0768ec3f5ba4966bdb215985789e43887",
186
186
  "conformance/cases/kernel-empty-boot.json": "2a5be9c93143d07a16d998df09dcc8fa4ea2d2f9a0bff6417573ed5a770352c1",
187
187
  "conformance/cases/no-global-scope.json": "1284763988026d924c0bd78ba8a9f417dc88f5b7e9f4c2b642ae0c447758bfd4",
188
188
  "conformance/cases/orphan-markdown-fallback.json": "8ef6e49b7e6532bd845d9f54974a16e537cf98d355f0c5e4f4fb06abac3adcc5",
189
- "conformance/cases/plugin-missing-ui-rejected.json": "bdebee810436e6be88edf2fe38ddc6939fd3f53e6a12dc1d66da051c4922f1e9",
189
+ "conformance/cases/plugin-missing-ui-rejected.json": "f2fb673ad01308b018f0a5ed0d3d2085b8ffab25230e60bf31569859c5c583cc",
190
190
  "conformance/cases/sidecar-end-to-end.json": "dbb3640f95769a36b881855a261f918481edadea13a7eb0765c6090f2417a142",
191
- "conformance/coverage.md": "7c02052750ee5fca711218cbf993313dd1ed7c68bcac000b2cb23b5f688a4a2c",
191
+ "conformance/coverage.md": "feb75cd38ddffa6252ed277dd23b8be4228b1ef2f37ce50871d00283bae4f108",
192
192
  "conformance/fixtures/orphan-markdown/.claude/agents/reviewer.md": "7f062731106f2d9811e4fffcf6ab44b8dfff4cfb16536a469514cc0664e832bf",
193
193
  "conformance/fixtures/orphan-markdown/ARCHITECTURE.md": "d6b6e18d4b963b26a292de73348c3396fd4710ab4c4bdd6cf094e581f99ec8d6",
194
194
  "conformance/fixtures/plugin-missing-ui/.skill-map/plugins/bad-provider/kinds/markdown/kind.json": "6676a89bae5197e23cf50f1c11d596db558ac80f7334a7208fe57d8b92422251",
@@ -202,11 +202,11 @@
202
202
  "conformance/fixtures/sidecar-end-to-end/.claude/agents/stale.sm": "cb04f7f3103b4218b09fd4da92f7ea429588b04c1dac6a9547ce362263b11224",
203
203
  "conformance/fixtures/sidecar-example/agent-example.md": "741131403e8c9580d0b7a8c2446cb4502d01f80053b7a2092663de92431aaa82",
204
204
  "conformance/fixtures/sidecar-example/agent-example.sm": "8329950d49c69a1199bbe6c06e32b8513973e64207b0db8756b67301e6a1f1e2",
205
- "db-schema.md": "8ff3d1fcb7b3e2e06f23fa2a65fe308c6b0876e8a028b7e19a40546d0270f6a7",
205
+ "db-schema.md": "3c34768a6ba34f6d77da84e0cdf73b977dca68ef50679decd131f2a5e20fb593",
206
206
  "interfaces/security-scanner.md": "e8049712b9cf7a07c786bf19f8f775f8ef9638f063f7fba5c7a8b1431b92f38e",
207
207
  "job-events.md": "84206168ac12b536d34470d62f8c8cba95dab181fee66d23203c2cf5dfbee716",
208
208
  "job-lifecycle.md": "9c429121f98a07c8795f8979ed1abc5e5334e3f89db51585a8da55c527ef855b",
209
- "plugin-author-guide.md": "2c5f8e563908f55a5d608d1e35b28b425ef6cd62958cfcb03dfd475c3dc9ffa4",
209
+ "plugin-author-guide.md": "bf7e01b1a36bfe0287d552f12d2211e76de33daf3cd172b841d46e49f7394878",
210
210
  "plugin-kv-api.md": "1acc69ed82433a74e35ada61d63a6d7379fb61046ff83de1e0facbe884c64704",
211
211
  "prompt-preamble.md": "9dd4f6d1bc6a425f8782fcee10cbe75909e8d64e28781fda56c2fae909b02f40",
212
212
  "schemas/annotations.schema.json": "e39990d47f53e25a1b3a5587a5714486d0b819b8eeaac10d42783a675296aee1",
@@ -228,16 +228,17 @@
228
228
  "schemas/input-types.schema.json": "c713b768d0b0e3d0c764afb401189f7fb624a82b4e988b73aab015cf9c67c01f",
229
229
  "schemas/issue.schema.json": "fa3344e75f1c3a5304291ca355bb973046552a68871ad6eb4edafca1cd9e1be8",
230
230
  "schemas/job.schema.json": "e43e1761c99920beffe1de12ef8f32fe29f97838bd8686742b637c19c4dbb395",
231
- "schemas/link.schema.json": "7fc429d03aca7e4c0b9a28241712c1aa2a5275870cea5ed938c2f97e8cccb081",
232
- "schemas/node.schema.json": "e5da06c9262cc0f2f7584d5733ebc1c08acd75487952ed7b4d6035fb417aaa4b",
231
+ "schemas/link.schema.json": "2450732829652ece58c853ca97711a8bbb64ac65e52e89e3b51024c073dddc9a",
232
+ "schemas/node.schema.json": "8d0635a80c8e6f22be7fa04071654e857fc052869de15839f4b29593aa4527a3",
233
233
  "schemas/plugins-doctor.schema.json": "c1d92f30fdb0080e8cd8f7dc5d43e01aae02a16640bc5eb04811c337a275de58",
234
234
  "schemas/plugins-registry.schema.json": "cca7ae65f0c22510ea27ea5ae34e0074f5beb5871a57b005b6b831e6ceaff5c0",
235
- "schemas/project-config.schema.json": "7bb695476015b6b43026db78208aedf67350f4bc2c796c822fa87d0c9093b13f",
235
+ "schemas/project-config.schema.json": "1357e14026b038ac097d0528ee135728b1ddeb383db6a8cb1e345804e62311e5",
236
236
  "schemas/refresh-report.schema.json": "54519b8caf86ba84c182f9565be9b5084bc1631ae05e9217ee18f34c0039fff3",
237
237
  "schemas/report-base-deterministic.schema.json": "9d318d0181d121097c906ef3af1c52d71c782740bd04cf23418d7627ce2c3ed5",
238
238
  "schemas/report-base.schema.json": "a1021e9a59b4df9f99cd92454d797e88469766e7d49f52d231c4645ffdfdad8f",
239
239
  "schemas/scan-result.schema.json": "214bc12fbb9946642cbba3b23513dade60e7d6a5b6a9ed3dd0818f135b450185",
240
240
  "schemas/sidecar.schema.json": "8856c387477340efbdd0a585d74bfb07a99ba15b9ce593cc67d9efebc67c6bfc",
241
+ "schemas/signal.schema.json": "2540c0014a78ebc902eb71b6815c35fa006c714b57d07dcb7415bd3c3da185b5",
241
242
  "schemas/summaries/agent.schema.json": "bf540f9a804f2b43756ab33b7deb0462620d26e88cc9379c75a5f87d3b1b47d8",
242
243
  "schemas/summaries/command.schema.json": "c26f6965f77c5058608feb5e7b9f807395de8e015b0dea5efcdb44cb1820551a",
243
244
  "schemas/summaries/hook.schema.json": "58420ec485e152fdd21fa3d87337ad74b0d81a48d3b83dd072d4a2d196f78573",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skill-map/spec",
3
- "version": "0.29.0",
3
+ "version": "0.31.0",
4
4
  "description": "JSON Schemas, prose contracts, and conformance suite for the skill-map specification.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  # Plugin author guide
2
2
 
3
- How to ship a third-party `skill-map` plugin: directory layout, manifest fields, the six extension kinds, storage choice, version compatibility, dual-mode posture, and how to test the result with `@skill-map/testkit`.
3
+ How to ship a third-party `skill-map` plugin: directory layout, manifest fields, the six extension kinds, storage choice, version compatibility, dual-mode posture, and how to unit-test the result against the kernel's public types.
4
4
 
5
5
  This guide is **descriptive prose**, not the normative contract. The normative pieces live in the schemas and the architecture document, every claim here is cross-linked to its source. When the two disagree, [`architecture.md`](./architecture.md) wins.
6
6
 
@@ -485,7 +485,7 @@ export default {
485
485
 
486
486
  ### Providers / Actions
487
487
 
488
- These ship later in the v1.x line as bundled built-ins; the spec already pins their manifest shapes. Until the testkit grows full helpers for them (planned alongside Step 10), authors are encouraged to test them with a live kernel via `sm scan` against a fixture directory rather than in unit tests.
488
+ These ship later in the v1.x line as bundled built-ins; the spec already pins their manifest shapes. Until Step 10 lands the job subsystem, authors are encouraged to test them with a live kernel via `sm scan` against a fixture directory rather than in unit tests.
489
489
 
490
490
  #### Provider, `kinds` catalog
491
491
 
@@ -497,7 +497,7 @@ Every Provider declares one required top-level field beyond the manifest base: `
497
497
  - **`defaultRefreshAction`**, qualified action id (`<plugin-id>/<action-id>`) the UI's `🧠 prob` button dispatches. The action MUST exist in the registry; a dangling reference disables the Provider with `invalid-manifest`.
498
498
  - **`ui`**, presentation block: `{ label, color, colorDark?, emoji?, icon? }`. The UI ships every `ui` block to the front-end via the `kindRegistry` envelope so built-in and user-plugin kinds render identically. `icon` is a discriminated union (`{ kind: 'pi'; id }` for PrimeIcons, `{ kind: 'svg'; path }` for raw SVG). The `ui` block is required (not optional) so the UI never has to invent visuals for unknown kinds. See [`architecture.md` §Provider · `ui` presentation](./architecture.md#provider--ui-presentation) for the field-by-field contract.
499
499
 
500
- The Provider's walker hardcodes the paths it scans within the project (e.g. `.claude/`, `.cursor/rules`). The kernel does NOT extend the scan into the user's HOME based on Provider hints; the only way to scan paths outside the project is `scan.extraFolders` (set by the operator), which is privacy-sensitive and gated by `--yes`.
500
+ The Provider's walker hardcodes the paths it scans within the project (e.g. `.claude/`, `.cursor/rules`). The kernel does NOT extend the scan into the user's HOME based on Provider hints; the only way to scan paths outside the project is by passing them as positional roots to `sm scan [roots...]` (per-invocation, not persisted).
501
501
 
502
502
  ```jsonc
503
503
  {
@@ -704,45 +704,49 @@ The full per-kind capability matrix lives in [`architecture.md` §Execution mode
704
704
 
705
705
  ---
706
706
 
707
- ## Testing with `@skill-map/testkit`
707
+ ## Testing your plugin
708
708
 
709
- ```bash
710
- npm install --save-dev @skill-map/testkit
711
- ```
712
-
713
- The testkit ships builders, per-kind context factories, in-memory KV / runner fakes, and high-level `runExtractorOnFixture` / `runAnalyzerOnGraph` / `runFormatterOnGraph` helpers. Most plugin tests reduce to one line per assertion.
709
+ Plugin extensions are plain ESM modules with a single entry point per kind (`extract` / `evaluate` / `format` / `run` / `on`); their inputs are well-typed context objects from `@skill-map/cli`. That makes them straightforward to unit-test without a kernel or DB: build a fake `ctx` literal, call the entry point, assert on what it captured.
714
710
 
715
711
  ```javascript
716
712
  import { test } from 'node:test';
717
713
  import { strictEqual } from 'node:assert';
718
- import { runExtractorOnFixture, node } from '@skill-map/testkit';
719
714
 
720
715
  import extractor from '../extractors/my-extractor/index.js';
721
716
 
722
717
  test('emits one reference per [[ref:<name>]] token', async () => {
723
- const { links } = await runExtractorOnFixture(extractor, {
718
+ const links = [];
719
+ await extractor.extract({
720
+ node: { path: 'a.md', kind: 'skill', provider: 'claude' },
724
721
  body: 'Talk to [[ref:architect]] or [[ref:sre]].',
725
- context: { node: node({ path: 'a.md' }) },
722
+ frontmatter: {},
723
+ settings: {},
724
+ emitLink: (link) => links.push(link),
725
+ enrichNode: () => {},
726
+ emitContribution: () => {},
726
727
  });
727
728
  strictEqual(links.length, 2);
728
729
  strictEqual(links[0].target, 'architect');
729
730
  });
730
731
  ```
731
732
 
732
- For analyzer tests, `runAnalyzerOnGraph(analyzer, { context: { nodes, links } })` returns the issue array. For formatter tests, `runFormatterOnGraph(formatter, { context: { nodes, links, issues } })` returns the formatted string.
733
+ For analyzers, the same pattern applies: build a `ctx` with `nodes`, `links`, an `emitContribution` spy if you assert on view contributions, and call `analyzer.evaluate(ctx)`, it returns the issue array. Formatters take `{ nodes, links, issues }` and return a string from `formatter.format(ctx)`.
733
734
 
734
- For probabilistic extensions, `makeFakeRunner()` queues canned responses and records every call:
735
+ For probabilistic extensions (Actions / Hooks running in `mode: 'probabilistic'`), shape a fake `ctx.runner` that records the calls your test cares about:
735
736
 
736
737
  ```javascript
737
- import { makeFakeRunner } from '@skill-map/testkit';
738
-
739
- const runner = makeFakeRunner();
740
- runner.queue({ text: '5 nodes summarized' });
741
- const result = await myAction.run({ runner, ... });
742
- strictEqual(runner.history[0].action, 'skill-summarizer');
738
+ const calls = [];
739
+ const runner = {
740
+ async run(call) {
741
+ calls.push(call);
742
+ return { text: 'mocked response' };
743
+ },
744
+ };
745
+ await myAction.run({ runner, /* … */ });
746
+ strictEqual(calls[0].action, 'skill-summarizer');
743
747
  ```
744
748
 
745
- Full surface in `@skill-map/testkit/index.ts`.
749
+ The public TypeScript types (`IExtractor`, `IAnalyzer`, `IFormatter`, `IExtractorContext`, `IAnalyzerContext`, `IFormatterContext`, `Node`, `Link`, `Issue`, …) are re-exported from `@skill-map/cli` so authors can type-check their fakes against the same surface the kernel consumes.
746
750
 
747
751
  ---
748
752
 
@@ -1218,7 +1222,7 @@ Companion verbs:
1218
1222
 
1219
1223
  ## Stability
1220
1224
 
1221
- - Document status: **stable** as of spec v1.0.0. Future minor revisions add new sections (e.g. richer testkit coverage when actions gain helpers); breaking edits to the documented surface require a major bump per [`versioning.md`](./versioning.md).
1225
+ - Document status: **stable** as of spec v1.0.0. Future minor revisions add new sections (e.g. Action / Hook testing patterns once Step 10 lands the job subsystem); breaking edits to the documented surface require a major bump per [`versioning.md`](./versioning.md).
1222
1226
  - The six plugin statuses (`loaded` / `disabled` / `incompatible-spec` / `invalid-manifest` / `load-error` / `id-collision`) are stable; adding a seventh status is a minor bump.
1223
1227
  - The structural analyzer **directory name MUST equal manifest id** is stable; relaxing it (allowing mismatch) is a major bump.
1224
1228
  - The cross-root id-collision analyzer (both sides blocked, no precedence) is stable; introducing precedence (e.g. project root wins over global) is a major bump.
@@ -21,9 +21,10 @@
21
21
  "description": "Nature of the relation. `invokes` = execution-level call (e.g. slash command). `references` = explicit link (e.g. wikilink, @-directive). `mentions` = informal textual mention. `supersedes` = replaces another node (from `metadata.supersedes`)."
22
22
  },
23
23
  "confidence": {
24
- "type": "string",
25
- "enum": ["high", "medium", "low"],
26
- "description": "Extractor's self-assessed confidence. Analyzers MAY filter by confidence."
24
+ "type": "number",
25
+ "minimum": 0,
26
+ "maximum": 1,
27
+ "description": "Extractor's self-assessed confidence `[0..1]`. Drives UI edge opacity (more confident = more opaque). Migrated from the legacy string union `'high' | 'medium' | 'low'` to a numeric range so callers can express finer granularity than three buckets. Reference scoring: `1.0` = structured input (sidecar annotation), `0.95` = unambiguous syntax (`[text](file.md)`), `0.85` = strong signal with one inference (`@file.md`), `0.5` = genuine ambiguity (`@bare-handle`). The named tiers `HIGH = 0.9`, `MEDIUM = 0.6`, `LOW = 0.3` are exposed on the kernel side as the `ConfidenceTier` constants for callers that want to think in buckets. Analyzers MAY filter by confidence threshold."
27
28
  },
28
29
  "sources": {
29
30
  "type": "array",
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "https://skill-map.dev/spec/v0/node.schema.json",
4
4
  "title": "Node",
5
- "description": "A single markdown file in the graph. Identified by its relative path from the scope root. The `kind` is whatever the classifying Provider declares, open by design; the **built-in Claude Provider** emits `skill` / `agent` / `command` / `markdown` today, but external Providers (Cursor, Obsidian, …) MAY emit their own. Format-named kinds (`markdown`, future `toml`, future `json`) are reserved for the generic fallback only, when a file matches a specific role (agent / command / skill) that classification prevails over format naming.",
5
+ "description": "A single entity in the graph. Typically a file on disk (a markdown skill, an agent, a TOML sub-agent definition, a plain-markdown note), but MAY also be a **virtual / derived** entity that lives only in memory and is reconstructed from one or more source files on every scan (e.g. an MCP server node derived from `settings.json` / `mcp.json` / `config.toml`). Virtual nodes carry `virtual: true` and use a synthetic `path` scheme (`mcp://<name>`, etc.). The `kind` is whatever the classifying Provider declares, open by design; the **built-in Claude Provider** emits `skill` / `agent` / `command` / `markdown` today, but external Providers (Cursor, Obsidian, …) MAY emit their own. Format-named kinds (`markdown`, future `toml`, future `json`) are reserved for the generic fallback only, when a file matches a specific role (agent / command / skill) that classification prevails over format naming.",
6
6
  "type": "object",
7
7
  "required": ["path", "kind", "provider", "bodyHash", "frontmatterHash", "bytes", "linksOutCount", "linksInCount", "externalRefsCount"],
8
8
  "additionalProperties": false,
@@ -66,6 +66,15 @@
66
66
  "isFavorite": {
67
67
  "type": "boolean",
68
68
  "description": "Per-node favorite flag set by the local user from the UI. Sourced from `state_node_favorites` (zone `state_`, persistent across scans). Decorated by the BFF on every `/api/nodes` response via in-memory Set lookup against the favorites table, no SQL JOIN against `scan_nodes`. Absent on emissions that don't carry per-user state (e.g. `sm export --json`); consumers that don't recognise the field MUST ignore it."
69
+ },
70
+ "virtual": {
71
+ "type": "boolean",
72
+ "description": "When `true`, this node is synthetic / derived: it does not correspond to a single file on disk. Reconstructed on every scan from the file(s) listed in `derivedFrom`. Use a synthetic `path` scheme (e.g. `mcp://github`) so the identifier is stable and visibly non-filesystem. Examples: MCP server nodes derived from `settings.json` / `mcp.json` / `config.toml`. When absent or `false`, the node is a normal filesystem-backed entity. Stability: experimental."
73
+ },
74
+ "derivedFrom": {
75
+ "type": "array",
76
+ "items": { "type": "string" },
77
+ "description": "Paths of the source files from which this node was derived. Required (and only meaningful) when `virtual: true`. Drives invalidation: if any listed source changes between scans, the virtual node's hashes change and the rename / drift machinery surfaces it. Empty / absent when the node is a regular filesystem-backed entity (the `path` itself is the source)."
69
78
  }
70
79
  },
71
80
  "$defs": {
@@ -21,9 +21,13 @@
21
21
  },
22
22
  "providers": {
23
23
  "type": "array",
24
- "description": "Provider ids to enable, in priority order when multiple match. Empty/absent = use all registered.",
24
+ "description": "Provider ids to enable, in priority order when multiple match. Empty/absent = use all registered. Note: the `activeProvider` field selects ONE of these as the project's active lens; the rest stay enabled-but-inactive until the user switches.",
25
25
  "items": { "type": "string" }
26
26
  },
27
+ "activeProvider": {
28
+ "type": "string",
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/`, `.gemini/`, `.codex/`, AGENTS.md, etc.) and prompts via the CLI / UI if the heuristic is ambiguous. Stability: experimental."
30
+ },
27
31
  "roots": {
28
32
  "type": "array",
29
33
  "description": "Directories (relative to the config file) to scan. Defaults to the scope root.",
@@ -58,15 +62,10 @@
58
62
  }
59
63
  }
60
64
  },
61
- "extraFolders": {
62
- "type": "array",
63
- "items": { "type": "string" },
64
- "description": "**Privacy-sensitive, project-local only** (per `core/config/helper:PROJECT_LOCAL_ONLY_KEYS`) when entries point outside the project, opens disk access there. Default `[]`. Additional directories appended to the scan roots; same parsing / indexing as the project root. Paths starting with `~` resolve against the user home; relative paths resolve against the project root. 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. This is the ONLY mechanism to extend the scan beyond the project root: skill-map does NOT auto-include the user's HOME based on Provider hints, every out-of-project path must be listed here explicitly."
65
- },
66
65
  "referencePaths": {
67
66
  "type": "array",
68
67
  "items": { "type": "string" },
69
- "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. Same write-gate analyzers as `extraFolders`. **Stripped with a warning when found in the committed `project` layer**."
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."
70
69
  }
71
70
  }
72
71
  },
@@ -78,7 +77,18 @@
78
77
  "additionalProperties": false,
79
78
  "properties": {
80
79
  "enabled": { "type": "boolean" },
81
- "config": { "type": "object", "description": "Plugin-specific config passed to extensions at load time. Shape defined by the plugin.", "additionalProperties": true }
80
+ "config": { "type": "object", "description": "Plugin-specific config passed to extensions at load time. Shape defined by the plugin.", "additionalProperties": true },
81
+ "extensions": {
82
+ "type": "object",
83
+ "description": "Per-extension enable/disable overrides within this plugin. Keys are extension ids (the `id` declared by the extractor / analyzer / etc. inside the plugin). Absent = use the extension's manifest default (enabled). Allows a user to keep a provider plugin active but disable an individual extractor whose output is noisy in their project, without disabling the whole bundle.",
84
+ "additionalProperties": {
85
+ "type": "object",
86
+ "additionalProperties": false,
87
+ "properties": {
88
+ "enabled": { "type": "boolean" }
89
+ }
90
+ }
91
+ }
82
92
  }
83
93
  }
84
94
  },
@@ -0,0 +1,89 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://skill-map.dev/spec/v0/signal.schema.json",
4
+ "title": "Signal",
5
+ "description": "Intermediate Representation (IR) emitted by extractors during a scan. A Signal is a *candidate* detection: zero, one, or many interpretations of the same piece of source text or structured data. The kernel's resolver phase consumes `Signal[]` and produces final `Link[]` by selecting a winning candidate per Signal (or rejecting all and emitting none) using the active Provider's resolution rules. Opt-in for plugin authors: an extractor MAY emit `Signal`s via `ctx.emitSignal()` when the detection carries genuine ambiguity (multiple plausible kinds, multiple plausible targets, byte-range awareness for collision detection), OR continue calling `ctx.emitLink()` directly when its detection is unambiguous. The two paths coexist; resolved Link rows look identical regardless of origin. Stability: experimental.",
6
+ "type": "object",
7
+ "required": ["source", "scope", "candidates"],
8
+ "additionalProperties": false,
9
+ "properties": {
10
+ "source": {
11
+ "type": "string",
12
+ "description": "`node.path` of the originating node (the file or virtual entity in which the signal was detected)."
13
+ },
14
+ "scope": {
15
+ "type": "string",
16
+ "enum": ["body", "frontmatter", "sidecar"],
17
+ "description": "Where in the source the signal was detected. `body` = markdown body or equivalent prose payload. `frontmatter` = parsed metadata block at the top of the file. `sidecar` = co-located `.sm` overlay. Extractors specialized for non-prose sources (config files, AGENTS.md cascade) emit either `body` (if they treat the whole file as prose) or `frontmatter` (if they read structured fields)."
18
+ },
19
+ "range": {
20
+ "type": ["object", "null"],
21
+ "description": "Byte-range location within the source. Required for `scope: 'body'`, optional otherwise. Powers collision detection between detectors (two extractors emitting Signals with overlapping ranges) and code-block awareness (the orchestrator can mark ranges that fall inside code spans).",
22
+ "required": ["start", "end"],
23
+ "additionalProperties": false,
24
+ "properties": {
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." }
27
+ }
28
+ },
29
+ "fieldPath": {
30
+ "type": ["array", "null"],
31
+ "description": "Structured-data location within `frontmatter` or `sidecar` scopes. Each entry is a step of the path: object keys are strings, array indices are integers serialized as strings. Example: `['tools', '0']` points to the first entry of the `tools` array. Null when the signal is body-scoped or when the extractor doesn't track field locations.",
32
+ "items": { "type": "string" }
33
+ },
34
+ "raw": {
35
+ "type": "string",
36
+ "description": "Verbatim matched text (for body scope) or stringified value (for frontmatter / sidecar scope). Used for debugging, UI tooltips, and collision-key dedup."
37
+ },
38
+ "context": {
39
+ "type": ["string", "null"],
40
+ "enum": ["code-block", "inline-code", "escaped", null],
41
+ "description": "Provider-determined surface context. `code-block` = inside a fenced code block (most providers ignore these). `inline-code` = inside backticks. `escaped` = preceded by `\\` or otherwise marked literal. Null when the signal is in normal prose or when the context concept doesn't apply (frontmatter / sidecar scopes). Drives both extraction filtering and the resolver's confidence weighting."
42
+ },
43
+ "candidates": {
44
+ "type": "array",
45
+ "minItems": 1,
46
+ "description": "One or more alternative interpretations of the same signal. The resolver picks ONE as the winner (becomes a Link) or rejects all (no Link emitted). Multiple candidates from the same `extractorId` are allowed (e.g. one detector may emit both a `references` and a `mentions` hypothesis for the same `@token` and let the resolver decide).",
47
+ "items": {
48
+ "type": "object",
49
+ "required": ["extractorId", "kind", "target", "confidence"],
50
+ "additionalProperties": false,
51
+ "properties": {
52
+ "extractorId": {
53
+ "type": "string",
54
+ "description": "Id of the extractor that contributed this candidate."
55
+ },
56
+ "kind": {
57
+ "type": "string",
58
+ "enum": ["invokes", "references", "mentions", "supersedes"],
59
+ "description": "Proposed link kind, matching `link.schema.json#/properties/kind/enum`. Closed enum in v1; provider-specific kinds wait until a concrete need emerges."
60
+ },
61
+ "target": {
62
+ "type": "string",
63
+ "description": "Proposed `node.path` of the destination. MAY refer to a missing node (the resolver does not validate existence); the `broken-ref` analyzer reports the gap downstream."
64
+ },
65
+ "confidence": {
66
+ "type": "number",
67
+ "minimum": 0,
68
+ "maximum": 1,
69
+ "description": "Extractor's self-assessed probability that this interpretation is the correct one. Reference scoring (guideline, not contract): `1.0` = structured input (sidecar annotation, parsed JSON pointer), `0.95` = unambiguous syntax (`[text](file.md)`, `https://...`), `0.85` = strong signal with one degree of inference (`@file.md` with known extension), `0.5` = genuine ambiguity (`@bare-handle` could be agent, file, or generic mention). Drives UI edge opacity downstream."
70
+ },
71
+ "rationale": {
72
+ "type": "string",
73
+ "description": "Optional human-readable explanation of WHY this candidate has the assigned confidence. Surfaced in inspector tooltips and debug output. Keep it short, e.g. `'ends in .md'`, `'no extension, no path prefix'`, `'declared in tools[] array'`."
74
+ },
75
+ "trigger": {
76
+ "type": ["object", "null"],
77
+ "description": "Trigger-style metadata when this candidate represents a textual invocation (`@x`, `/y`). Null otherwise. Mirrors `link.schema.json#/properties/trigger`.",
78
+ "required": ["originalTrigger", "normalizedTrigger"],
79
+ "additionalProperties": false,
80
+ "properties": {
81
+ "originalTrigger": { "type": "string" },
82
+ "normalizedTrigger": { "type": "string" }
83
+ }
84
+ }
85
+ }
86
+ }
87
+ }
88
+ }
89
+ }