@skill-map/spec 0.36.0 → 0.38.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,149 @@
1
1
  # Spec changelog
2
2
 
3
+ ## 0.38.0
4
+
5
+ ### Minor Changes
6
+
7
+ - d3c47b2: Adds a hard cap on the number of files `sm scan` and `sm watch` accept after `.skillmapignore` filtering, plus a persistent UI banner that fires when the graph crosses the recommended limit. Default cap is **256 nodes**. Override per invocation with `--max-nodes <N>` (bidirectional: raises OR lowers the cap).
8
+
9
+ **Spec (`spec/schemas/project-config.schema.json`)**: new `scan.maxNodes` integer field (default 256, minimum 1). Documented in `spec/cli-contract.md` §Scan / Node cap.
10
+
11
+ **Spec (`spec/schemas/scan-result.schema.json`)**: ScanResult envelope gains two optional fields, `recommendedNodeLimit` (effective cap that produced this scan) and `overrideMaxNodes` (per-invocation override or `null`). Absent on legacy / synthetic fixtures.
12
+
13
+ **Kernel walker (`src/kernel/orchestrator/walk.ts`)**: `walkAndExtract` accepts `recommendedNodeLimit` + `overrideMaxNodes` and stops accepting classified nodes once `accum.nodes.length >= effectiveLimit`. Result envelope echoes both values plus a `capReached: boolean` derived signal so callers can phrase a "scan capped" notice without re-deriving it.
14
+
15
+ **DB schema (`src/migrations/001_initial.sql`)**: `scan_meta` gains two columns, `recommended_node_limit` and `override_max_nodes` (nullable). Edited inline per the project's greenfield rule, no new migration file. The persistence layer (`scan-persistence.ts`, `scan-load.ts`) serialises / deserialises both columns; synthetic envelopes default `recommendedNodeLimit` to 256.
16
+
17
+ **CLI surface (`src/cli/commands/scan.ts`, `src/cli/commands/watch.ts`, `src/core/runtime/scan-runner.ts`, `src/core/watcher/runtime.ts`)**: new `--max-nodes <N>` flag on `sm scan` and `sm watch` (and the alias `sm scan --watch`). Validates integer ≥ 1, anything else exits 2 with a §3.1b two-line block. When a real scan caps, the CLI prints a yellow notice naming both escape routes the user has: edit `.skillmapignore` (preferred) or re-run with a higher `--max-nodes`. `sm refresh` operates on a single already-classified node, so the cap does not apply there.
18
+
19
+ **BFF (`src/server/routes/scan.ts`, `src/kernel/adapters/sqlite/scan-load.ts`)**: `GET /api/scan` and `POST /api/scan` propagate the two new fields verbatim from `scan_meta`. The empty-DB fallback returns the design default (256) and a `null` override so the SPA reads the same field shape on cold boot as on populated DBs.
20
+
21
+ **SPA (`ui/src/app/components/oversized-banner/`)**: new `<sm-oversized-banner>` component mounted in the shell next to `<sm-demo-banner>`. Visibility is purely derived from the loaded `ScanResult`, three render modes drive the body copy:
22
+
23
+ - **capped** (red), `stats.filesWalked > effectiveLimit`. Files were dropped.
24
+ - **overLimit** (yellow), `nodesCount > recommendedNodeLimit` with an override above the recommendation. Graph is bigger than recommended, allowed through.
25
+ - **atLimit** (yellow), `nodesCount >= recommendedNodeLimit` without an override above. Soft warning at the recommended cap.
26
+
27
+ The CTA opens Settings → Project (Ignored patterns section) so the operator can trim `.skillmapignore` without leaving the SPA. No dismiss state, the banner stays until a re-scan brings the graph back under the recommended limit.
28
+
29
+ **Tests**: new unit tests for the walker cap (`walk-node-cap.spec.ts`, 4 cases covering default cap fire, override above, override below, and project-below-limit) and for the banner (`oversized-banner.spec.ts`, 6 cases covering all three modes + hide + CTA emit). Existing `buildScan` helpers in three integration specs now reset `cmd.maxNodes` so the Clipanion marker object does not leak into manually-instantiated commands.
30
+
31
+ ## User-facing
32
+
33
+ New `--max-nodes <N>` on `sm scan` / `sm watch` / `sm serve` caps how many files the walker accepts (default 256, bidirectional). Past the limit, a persistent banner links to **Settings → Project** to trim `.skillmapignore`.
34
+
35
+ ## 0.37.0
36
+
37
+ ### Minor Changes
38
+
39
+ - d852217: Eliminate the bundle-level toggle entirely. Every plugin extension is now independently toggle-able by its qualified `<bundle>/<ext>` id; the bundle itself is a presentational grouping only.
40
+
41
+ **What changed**
42
+
43
+ - **Manifest schema**: `granularity` is removed from `spec/schemas/plugins-registry.schema.json`. A user plugin manifest that still declares it is rejected as `invalid-manifest` via AJV's `additionalProperties: false`. Built-in `plugin.json` files dropped the field; the generator (`scripts/generate-built-ins.js`) no longer emits it.
44
+ - **Kernel**: `TGranularity` deleted from `src/kernel/types/plugin.ts`; `IDiscoveredPlugin.granularity` and `IPluginManifest.granularity` gone. The runtime resolver (`src/core/runtime/plugin-runtime/resolver.ts`) keys every gate on the qualified extension id.
45
+ - **CLI** (`src/cli/commands/plugins/`): the bare bundle id (`sm plugins disable claude`) is now a **macro** that fans the toggle out across every extension inside the bundle. Single-extension bundles (`openai`, `antigravity`, `agent-skills`) apply without prompting. Multi-extension bundles (`claude`, `core`, multi-extension user plugins) require `--yes` OR an interactive TTY confirm; non-TTY contexts must pass `--yes` or the verb refuses with a directed message and the list of affected extensions. `--all` cascades through every bundle's extensions under the same gate. Qualified-id form (`sm plugins disable claude/at-directive`) toggles exactly that extension with no prompt. The `--yes` / `-y` flag is added to `enable` and `disable`. Granularity-mismatch error messages (`'claude' has granularity=bundle`, `'core' has granularity=extension`) are removed; the new error path is "unknown id" / "macro requires confirmation". `sm plugins doctor` summary reverts to `N enabled extensions · …` and counts every extension independently (built-ins and loaded user plugins alike).
46
+ - **BFF** (`src/server/routes/plugins.ts`): `PATCH /api/plugins/:id` becomes the **cascade endpoint** that persists one `config_plugins` row per child extension. Granularity-mismatch rejections are gone. The qualified-id sibling (`PATCH /api/plugins/:bundleId/extensions/:extensionId`) is unchanged and remains the canonical per-extension surface. `PATCH /api/plugins` (bulk) accepts bare bundle ids and qualified ids in the same batch; bare entries cascade at write time. The `granularity` field is removed from `IPluginListItem` on the wire; the bundle row's `status` aggregates child enablement (`enabled` when ≥1 extension is enabled).
47
+ - **SPA** (`ui/src/app/components/settings-modal/`): the bundle-level `<p-toggleswitch>` is removed (`canToggleBundle()` and `onBundleToggle()` deleted); bundle rows render as labelled headers with their per-extension list underneath. The "kind filter" chip now narrows the extensions array universally (it used to leave bundle-granularity bundles unfiltered, leaking extractors / analyzers when the user clicked "provider"). `IPluginItemApi.granularity` and `TPluginGranularityApi` removed from `ui/src/models/api.ts`.
48
+ - **Spec prose**: `spec/architecture.md` §Plugin loader, `spec/cli-contract.md` §Endpoints + §Error code sources, `spec/plugin-author-guide.md` §Toggle model (replacing the old §Granularity), `spec/db-schema.md` §scan_contributions are all rewritten to reflect the per-extension toggle model and the macro form on bare ids.
49
+
50
+ **Tests + fixtures**
51
+
52
+ - Plugin-loader spec asserts the field is now rejected via `additionalProperties`.
53
+ - CLI plugins spec rewrites the granularity describe block as bundle-macro semantics (`--yes` required for multi-extension bundles, single-extension bundles apply directly, qualified-id form flips exactly that extension).
54
+ - SPA settings-plugins spec updates the helper to call `onExtensionToggle` (the bundle has no toggle method anymore) and asserts the kind filter narrows extensions.
55
+ - Conformance fixture (`spec/conformance/fixtures/plugin-missing-ui/.skill-map/plugins/bad-provider/plugin.json`) drops the field.
56
+
57
+ **Drive-by**
58
+
59
+ `sm plugins doctor` summary reverts to a plain `N enabled extensions` form (the `4 bundles + 27 extensions` breakdown shipped in `d66bc71` was meaningful when bundles had their own toggle axis; with the unified per-extension model the split is no longer informative). The new `countByStatus` walks every extension uniformly across built-ins and user plugins.
60
+
61
+ ## User-facing
62
+
63
+ Plugins no longer have a bundle-level switch; each extension toggles on its own. `sm plugins disable <bundle>` cascades across the bundle's extensions (multi-extension bundles need `--yes`). The kind filter narrows extensions inside matched bundles instead of leaking siblings.
64
+
65
+ ### Patch Changes
66
+
67
+ - f66dbfe: Decouple built-in extensions from per-extension semver. Built-ins ship inside the CLI bundle, so authors no longer declare a `version` literal in each `<plugin>/<kind>s/<name>/index.ts` manifest under `src/plugins/`. The codegen at `scripts/generate-built-ins.js` now reads the CLI version from `src/package.json` and stamps it onto every built-in (alongside the existing `pluginId` stamp) when emitting `src/plugins/built-ins.ts`. The resulting runtime objects still satisfy the full kind interface (`IAnalyzer`, `IExtractor`, ...) and every downstream consumer continues to see `ext.version: string`, so `state_executions.extension_version` keeps recording a meaningful value (= CLI version) for reproducibility.
68
+
69
+ New kernel type `IBuiltInManifest<T extends IExtensionBase> = Omit<T, 'version'>` exported from `kernel/extensions/index.js`. Built-in manifest authors type their export as `IBuiltInManifest<I<Kind>>` and omit `version`; the codegen does the rest. The 34 built-in extensions across the 5 first-party bundles (`core`, `claude`, `antigravity`, `openai`, `agent-skills`) were migrated in-tree, removing 32 cargo-cult `'1.0.0'` literals and the 2 `'0.0.0'` "stub" sentinels.
70
+
71
+ External (user-authored) plugins are unaffected: the AJV check at load time still requires `version` on every extension manifest per `spec/schemas/extensions/base.schema.json#/required`. The schema's `required` list is unchanged; only the `version` field description was updated to document the built-in / external asymmetry.
72
+
73
+ Two kernel API signatures widen from `IProvider` to `IBuiltInManifest<IProvider>` so test files that import raw built-in manifests directly (bypassing the codegen) keep type-checking without a runtime workaround. The widened functions are `resolveProviderWalk` and `buildProviderFrontmatterValidator` (plus the `IProviderFrontmatterValidator.validate` method shape); both only read `id` / `kinds` / `walk` / `read` / `schemas` and never touch `version`, so the widening is structurally safe and production callers passing fully-loaded `IProvider` values continue to type-check (subtype-passes-supertype).
74
+
75
+ The AGENTS.md "stub extensions ship as `version: '0.0.0'`" convention is retired (the chip it surfaced was hidden from the UI / CLI in the previous release). If we later want a visible placeholder signal we'll add a dedicated `stability: 'stub'` field instead of overloading `version`.
76
+
77
+ - 457a60d: Reserve the `graph.node.alert` slot for special-case signals; disconnect every built-in core analyzer from it. Define the **chip-vs-issue policy** for plugin authors and align `reference-broken` to it. The corner badge on the NE tip of each graph card is no longer a generic "this node has a problem" surface. Routine findings (`reference-broken`, `annotation-field-unknown`, `schema-violation`) now ship only as `card.footer.right` chips, the slot's natural home for paired-icon-and-count signals.
78
+
79
+ **What changed**
80
+
81
+ - **Analyzer manifests + emit**: `reference-broken`, `annotation-field-unknown`, and `schema-violation` (under `src/plugins/core/analyzers/`) dropped their `alert` ui declaration and the matching `ctx.emitContribution(nodePath, 'alert', ...)` call. Each analyzer keeps its `card.footer.right` chip with the same tooltip + severity + count semantics. `schema-violation`'s severity-from-worst-finding logic stays (introduced in this same change set), now applied to the chip exclusively (`warn` for missing base fields, `danger` as soon as one schema check returns error).
82
+ - **Icon swaps**: `schema-violation` chip moved from `fa-solid fa-triangle-exclamation` to `fa-solid fa-circle-exclamation`. `reference-broken` chip moved from `fa-regular fa-circle-xmark` to `fa-solid fa-circle-xmark` (the outlined regular variant existed in FA Free for `circle-xmark` but the chip lost its alert sibling that motivated the visual contrast). Both choices documented inline next to the manifest entry: in FA Free `circle-exclamation` ships only in `solid` (`icons.yml`, `styles: [solid]`), so a `fa-regular` declaration would render as a missing-glyph tofu.
83
+ - **Slot kept in the catalog**: `graph.node.alert` stays in `view-slots.schema.json`, `kernel/types/view-catalog.ts`, `ui/src/app/slots/slot-config.ts`, and the renderer map. The mount in `graph-view.html` and the `NodeAlert` renderer are untouched. The slot is now reserved for genuinely independent signals (a future plugin that wants a corner decoration tied to a one-off condition); the slot-config comment documents the bar.
84
+ - **`sm plugins slots list` summary**: the `graph.node.alert` row in `src/cli/commands/plugins/slots-catalog.ts` now reads "Reserved corner badge ... special-case signals only" so plugin authors browsing the catalog see the policy without digging.
85
+ - **Drive-by, `sm init` warning formatting**: `activeProviderNoMarkerWarning` (under `src/core/runtime/i18n/scan-runner.texts.ts`) used to glue itself onto the next stderr line because the catalog string had no trailing newline and the message ran as a single sentence wall. Refactored to the §3.1b "glyph + dim hint" two-line block (mirrors the drift warn next door): yellow `⚠` headline + dim hint indented at column 3. `active-provider-bootstrap.ts` threads `opts.style.warnGlyph` + `opts.style.dim` through `tx(...)` like the drift path already did.
86
+
87
+ **Tests**
88
+
89
+ - Each affected analyzer's spec asserts `chip only, no alert` and lists the surviving slot via `deepStrictEqual(analyzer.ui, { chip: { slot: 'card.footer.right', ... } })`. Locks the new shape and fails fast if a future refactor re-wires the corner.
90
+ - `schema-violation.spec.ts` adds an "escalates severity to danger as soon as one finding is error-level" case that asserts the chip's severity follows the worst underlying finding.
91
+ - The existing `active-provider-bootstrap.spec.ts` test that regex-matched `/no provider markers detected/i` still passes against the new two-line block (the substring is preserved).
92
+ - `e2e/live-bff/` gains a `graph-node-alert.spec.ts` regression: with a fixture node carrying a broken `@mention` (would have triggered the `reference-broken` corner badge under the prior contract), the SPA must render zero `[data-testid="renderer-node-alert"]` elements while still surfacing the footer chip. The fixture (`e2e/live-bff/fixture.ts`) was extended with the broken-ref body line; the bump happy-path spec is unaffected (stale-badge state + version increment do not depend on link findings).
93
+
94
+ **`reference-broken` Issue severity raised from `warn` to `error`**
95
+
96
+ Per the chip-vs-issue policy below, a `danger` chip MUST be backed by an `error` Issue for the same node. `reference-broken` was emitting chip `danger` (red) + Issue `warn`, the only mismatch in the built-in catalog. Bumping the Issue aligns the visual signal with the exit code: any unresolved `@` / `/` link or markdown reference now escalates `sm scan` to exit 1 by default (was exit 0 with a yellow finding). CI pipelines that ran `sm scan` and treated exit 0 as "clean" will now see broken-ref runs fail, the operator was already seeing the red chip on the card; the change makes the exit code match.
97
+
98
+ `scan-readers.spec.ts` gains a `plantWarnOnlyFixture` helper (stale-sidecar based) for the "no error-severity → exit 0" contract tests that previously relied on `reference-broken` being a warn-level finding.
99
+
100
+ **Chip-vs-issue policy (new doc)**
101
+
102
+ Two new sections, one in `context/view-slots.md` ("Chip vs Issue, what counts and what only shows") and a shorter mirror in `spec/plugin-author-guide.md`, articulate the two-channel model:
103
+
104
+ - An `Issue` returned by `evaluate(ctx)` feeds the card's aggregated stats AND the scan / check exit code.
105
+ - A view contribution to `card.footer.right` is purely presentational, its `severity` controls only the chip's own colour.
106
+
107
+ The two channels are independent. The doc lists the 4 combinations (issue × chip) and codifies the colour rule: a chip MAY paint `warn` (yellow) or `danger` (red) only when the same analyzer emits a matching Issue at the same level. Decorative chips use `info`, `success`, or omit the severity field (neutral). Compliance audited across the built-in catalog: every analyzer now follows the rule.
108
+
109
+ **Drive-by, view-slots annex**
110
+
111
+ `context/view-slots.md` table row for `graph.node.alert` now flags the slot as Reserved with a pointer to the policy comment in `slot-config.ts`. The new chip-vs-issue section sits next to it as the cross-channel policy for the rest of the card surface.
112
+
113
+ `spec/index.json` regenerated for the prose addition (no schema changes, just the guide).
114
+
115
+ ## User-facing
116
+
117
+ Graph cards drop the corner badge for routine warnings; count + tooltip stay on the footer chip. Broken refs now escalate `sm scan` to exit 1 (were exit 0). `sm init` prints the "no provider markers" advisory as a two-line yellow `⚠` block.
118
+
119
+ - d66bc71: Three findings from a second `sm-tutorial` external-tester session (Adolfo, 2026-05-25).
120
+
121
+ **Finding 1, `sm check --analyzers` silently accepts unknown ids** (`src/cli/commands/check.ts`)
122
+
123
+ `parseAnalyzersFlag` trimmed tokens and dropped empties, then `matchesAnalyzerFilter` compared them against the persisted `analyzerId` set. A typo like `broken-ref` (the real id is `core/reference-broken`, short form `reference-broken`) matched nothing, the verb returned `✓ No issues.` in green with exit 0, identical to a clean run; the planted broken-reference warning was invisible. The tutorial copy itself used `broken-ref` in the example commands, so following the walkthrough verbatim hid the fixture.
124
+
125
+ Fix: load the live Analyzer catalog when `--analyzers` is set, validate every token against both qualified (`core/reference-broken`) and short (`reference-broken`) forms, and on the first unknown id exit `ExitCode.Error` (2) with a stderr message naming the unknown id(s) and listing every valid qualified id. The catalog load is shared with the existing `--include-prob` path so the verb still pays for the runtime exactly once when both flags are present. Tutorial `.claude/skills/sm-tutorial/SKILL.md` updated to use the real ids (`reference-broken`, `core/name-reserved`, `core/link-self-loop`, `core/reference-redundant`).
126
+
127
+ **Finding 2, trigger-style links from universal-provider bodies never resolved** (`src/kernel/orchestrator/lift-resolved-link-confidence.ts`, `spec/architecture.md`)
128
+
129
+ The extractor gate already keys on the **active provider lens** (§Universal extractors and per-provider extractors): `claude/slash-command` under the `claude` lens emits `/handle` links from every node, including `notes/todo.md` classified by `core/markdown`. But the post-walk confidence-lift transform keyed on the **source node's provider id** (`markdown`), which declares no `resolution` map; the lookup short-circuited, the link stayed at `confidence: 0.8`, and `link.resolvedTarget` never got populated. Effect: even after the prior denormalised-`linksInCount` fix (be116dd) read `resolvedTarget ?? target`, markdown-sourced trigger links still incremented `linksInCount` against the authored trigger string (`/demo-command`) instead of the resolved node, and `sm list` IN stayed at 0 for the resolved command / skill node. The UI drew the arrow correctly (it walks `scan_links` directly), so the inconsistency surfaced as "arrow lands but IN=0".
130
+
131
+ Fix: align resolver authority with extractor authority by keying the `resolution` lookup on `ctx.activeProvider` instead of `sourceNode.provider`. `IPostWalkTransformCtx` gains a new `activeProvider: string | null` field; `buildPostWalkTransformCtx` in the orchestrator threads the lens through from `RunScanOptions.activeProvider`. `spec/architecture.md` §Provider · resolution rules updated to match (the prior wording was internally inconsistent with §Universal extractors and per-provider extractors, which already established the lens-driven principle). Existing test that asserted the old behaviour inverted to assert the new contract; a regression test for the exact sm-tutorial fixture (`/demo-command` from `notes/todo.md` under `claude` lens) and a complementary unlensed-project case (`activeProvider === null` short-circuits the name path) added.
132
+
133
+ **Finding 3, `sm plugins doctor` summary count looked off-by-N against `sm plugins list`** (`src/cli/commands/plugins/doctor.ts`)
134
+
135
+ Doctor's `enabled` count adds bundle-granularity bundles (count once) + extension-granularity extensions (count per extension). With a fresh install that totals 4 + 27 = 31. `sm plugins list` lists every individual extension under each bundle, so its surface count is 33 (3 claude + 1 antigravity + 1 openai + 1 agent-skills + 27 core). The two numbers were correct but unexplained; the tester read the doctor header `31 enabled` and the list count `33` and assumed a bug.
136
+
137
+ Fix: extend the doctor summary line to spell out the math: `plugins doctor: 31 enabled (4 bundles + 27 extensions) · 0 issues · 0 warnings`. New `countEnabledByGranularity` helper walks the same shape as `countByStatus` but tracks bundles and extensions separately so the breakdown reflects the project's actual granularity mix.
138
+
139
+ **Drive-by: tutorial wrap-up safer cleanup** (`.claude/skills/sm-tutorial/SKILL.md`)
140
+
141
+ The wrap-up advised `cd ~ && rm -rf <cwd>` with a single "if the cwd was a dedicated dir" caveat. The tester ran the tutorial in their day-to-day work dir; the bulk command would have nuked unrelated files. Wrap-up now branches on whether the cwd looks dedicated and surfaces the explicit per-file list (same shape as the "start over" branch already uses) when it does not.
142
+
143
+ ## User-facing
144
+
145
+ `sm check --analyzers <id>` now errors with the valid id list when mistyped, instead of silently saying "No issues." `/invoke` and `@mention` links from any markdown body now contribute to the target's `IN`. `sm plugins doctor` summary spells out its bundle + extension split.
146
+
3
147
  ## 0.36.0
4
148
 
5
149
  ### Minor Changes
package/architecture.md CHANGED
@@ -130,7 +130,7 @@ The loader enforces two id-uniqueness analyzers during discovery (see [`plugin-a
130
130
 
131
131
  In addition, the loader **qualifies every extension** with its owning plugin id before registering it. The registry stores extensions under the qualified id `<plugin-id>/<extension-id>` (e.g. `core/slash-command`, `core/reference-broken`, `my-plugin/my-extractor`). Authors continue to declare the short `id` in each extension manifest; the loader composes the qualified form from `manifest.id` at load time. Built-in extensions bundled with the reference impl declare their `pluginId` directly in `built-ins.ts`, `core/` for kernel-internal primitives (every analyzer, the formatter, the cross-vendor extractors `annotations` / `slash` / `at-directive` / `markdown-link` / `external-url-counter` / `stability`) and vendor-specific bundles such as `claude/` (the Claude provider) for Provider integrations whose territory is platform-bound. If a plugin author injects a `pluginId` field on an extension that disagrees with `plugin.json`'s `id`, the loader emits `invalid-manifest` with a directed reason.
132
132
 
133
- Each plugin (and each built-in bundle) declares a **granularity** that controls how its extensions are toggled. `granularity: 'bundle'` (the default) means the plugin id is the only enable/disable key; `granularity: 'extension'` means each extension is independently toggle-able under its qualified id. The loader's pre-import `resolveEnabled(pluginId)` short-circuit is always coarse (bundle level), when a granularity=`extension` bundle is partially enabled, the import work proceeds and the runtime composer (the CLI's `composeScanExtensions` / `composeFormatters` in `src/cli/util/plugin-runtime.ts`) drops the disabled extensions before they reach the orchestrator. Vendor Provider bundles (`claude`, `antigravity`, `openai`, `agent-skills`) ship as granularity=`bundle` (the platform integration is on or off as a whole); the `core` bundle is granularity=`extension` (every kernel built-in is removable, satisfying §Boot invariant: "no extension is privileged"). See [`plugin-author-guide.md` §Granularity, bundle vs extension](./plugin-author-guide.md#granularity--bundle-vs-extension) for the author-facing summary.
133
+ Every extension (built-in or drop-in) is independently toggle-able by its qualified id `<bundle>/<ext-id>`. The bundle is a presentational grouping, not a toggle target. The loader's pre-import `resolveEnabled(pluginId)` short-circuit only fires when EVERY extension of the bundle is disabled (the bundle is "starts as disabled"); partial enables let the imports proceed and the runtime composer (`composeScanExtensions` / `composeFormatters` in `src/core/runtime/plugin-runtime/composer.ts`) drops the per-extension disabled rows before they reach the orchestrator. The `core` bundle exercises the per-extension axis explicitly (every kernel built-in is removable, satisfying §Boot invariant: "no extension is privileged"); vendor Provider bundles (`claude`, `antigravity`, `openai`, `agent-skills`) currently have most operators leaving every extension enabled, but the same per-extension toggle surface applies. See [`plugin-author-guide.md` §Toggle model](./plugin-author-guide.md#toggle-model) for the author-facing summary.
134
134
 
135
135
  ### `RunnerPort`
136
136
 
@@ -280,7 +280,7 @@ The kernel ships every Provider's `ui` block to the BFF at boot; the BFF aggrega
280
280
  The dispatch contract has two consequences implementations MUST honour:
281
281
 
282
282
  1. **First-claim-wins**. A vendor Provider that classifies a file inside its territory (e.g. claude's `.claude/agents/foo.md` → `agent`) is authoritative; later Providers cannot reclassify it to a different kind. This locks vendor ownership of vendor paths and removes the historical `provider-ambiguous` failure mode for non-overlapping territories.
283
- 2. **`core/markdown` is the universal fallback for unclaimed `.md` files**. The built-in `core/markdown` Provider's `classify` returns `'markdown'` unconditionally (it does NOT inspect the path). Combined with the dedup guarantee above and its terminal position in the iteration order, it picks up exactly the `.md` files no vendor Provider claimed, a `.md` at the project root, under `.claude/hooks/`, `notes/`, `CLAUDE.md`, `GEMINI.md`, or anywhere else outside a known vendor territory. The fallback is **not privileged kernel code**: it ships as a regular built-in Provider under the `core` bundle (`granularity: 'extension'`), so a user who explicitly does not want it can disable it via `sm plugins disable core/markdown` and the scan reverts to "vendor-only", orphan `.md` files become silently invisible, matching pre-spec-0.9.0 behaviour.
283
+ 2. **`core/markdown` is the universal fallback for unclaimed `.md` files**. The built-in `core/markdown` Provider's `classify` returns `'markdown'` unconditionally (it does NOT inspect the path). Combined with the dedup guarantee above and its terminal position in the iteration order, it picks up exactly the `.md` files no vendor Provider claimed, a `.md` at the project root, under `.claude/hooks/`, `notes/`, `CLAUDE.md`, `GEMINI.md`, or anywhere else outside a known vendor territory. The fallback is **not privileged kernel code**: it ships as a regular built-in Provider under the `core` bundle, so a user who explicitly does not want it can disable it via `sm plugins disable core/markdown` and the scan reverts to "vendor-only", orphan `.md` files become silently invisible, matching pre-spec-0.9.0 behaviour.
284
284
 
285
285
  The fallback exists because the format-named generic kind `markdown` is provider-agnostic: no vendor owns the universal markdown format. Keeping the fallback as a Provider (rather than a kernel-level special case) preserves the boot invariant that no extension is privileged, when a future vendor Provider (Codex, Cursor, Roo) lands, it slots into the iteration order before `core/markdown` and the fallback semantics stay invariant.
286
286
 
@@ -308,11 +308,11 @@ The transform runs after `dedupeLinks` and before the analyzer pipeline. For eac
308
308
 
309
309
  1. **Path match (universal)**: if `link.target` equals some node's `path`, confidence is bumped to `1.0`. Applies to every link.kind, ignores the `resolution` map. Drives resolved markdown / at-directive references and `core/mcp-tools` synthetic edges.
310
310
 
311
- 2. **Name match (links carrying a `trigger.normalizedTrigger`)**: strip the leading `@` / `/` sigil, look up the resulting handle in the cross-kind name index built from every node's declared `identifiers` (see §Provider · kind identifiers). The lookup keys on the LINK SOURCE node's Provider id: `resolution = providers[sourceNode.provider].resolution`. If `resolution[link.kind]` exists AND any candidate node's kind appears in it, confidence is bumped to `1.0`. Otherwise the link stays at its extractor-emitted confidence.
311
+ 2. **Name match (links carrying a `trigger.normalizedTrigger`)**: strip the leading `@` / `/` sigil, look up the resulting handle in the cross-kind name index built from every node's declared `identifiers` (see §Provider · kind identifiers). The lookup keys on the ACTIVE PROVIDER LENS: `resolution = providers[activeProvider].resolution`. If `resolution[link.kind]` exists AND any candidate node's kind appears in it, confidence is bumped to `1.0`. Otherwise the link stays at its extractor-emitted confidence.
312
312
 
313
313
  The matrix is **per-link-kind, per-Provider**, strict: a `claude` Provider that declares `resolution: { mentions: ['agent'], invokes: ['command', 'skill'] }` does NOT bump a `/foo` slash matching an agent named `foo` (slash → agent is a kind mismatch surfaced by `link-conflict` / `kind-mismatch` analyzers, not silently treated as a resolution). The strictness is the load-bearing difference from the kind-agnostic `core/reference-broken` analyzer: `broken-ref`'s scope is "the name exists somewhere" (a name-only resolution is enough to clear the broken flag), the post-walk bump is "the name exists AS A VALID resolution for this link.kind". The `not-broken` + `not-bumped` combination is a documented edge case: the trigger resolves to a real node but the link's kind cannot legitimately point there.
314
314
 
315
- The lookup uses the SOURCE node's Provider id deliberately: a Provider rules over the link.kinds its Extractors emit, regardless of where the resolution candidates physically live in the graph. A `claude` agent that mentions an `openai` agent (cross-provider mention) still follows claude's `resolution.mentions = ['agent']` rule. When the source node belongs to a Provider without `resolution` (e.g. a `CLAUDE.md` classified by `core/markdown`), the name path short-circuits, the path-match rule still applies.
315
+ The lookup uses the ACTIVE PROVIDER LENS deliberately, mirroring the extractor gate (§Universal extractors and per-provider extractors). The lens represents the runtime grammar the operator is authoring under, and that grammar applies across the project's surface, not only to files the matching `classify()` claimed. A `@handle` in `notes/todo.md` (classified by `core/markdown`) under the `claude` lens still parses as a claude mention (the extractor gate authorises it) and resolves against claude's `resolution.mentions` (the resolver gate now mirrors the same authority). The same body under the `openai` lens follows openai's resolution map, or short-circuits if openai declares no entry for that `link.kind`. When `activeProvider === null` (unlensed project: no setting, no filesystem signal), the name path short-circuits uniformly; the path-match rule still applies.
316
316
 
317
317
  **Distinct from the Signal IR `resolverRules` (§Resolver phase).** `resolverRules` rank candidates INSIDE a Signal (Phase 3+, no Provider declares it today); `resolution` runs against the merged Link graph post-walk and is the contract Extractors EMITTING Links rely on. The two surfaces share no mechanism and intentionally do not compose; when a Signal IR materialises into a Link, the `resolution` matrix runs unchanged against the resulting Link.
318
318
 
package/cli-contract.md CHANGED
@@ -283,6 +283,8 @@ The three privacy-sensitive keys above PLUS `allowEditSmFiles` are members of `P
283
283
 
284
284
  The watcher subscribes to the same roots that `sm scan` walks and respects `.skillmapignore` plus `config.ignore` exactly as the one-shot scan does. Filesystem events are grouped using `scan.watch.debounceMs` (default 300ms) before the watcher re-runs the incremental scan and persists. `SIGINT` / `SIGTERM` close the watcher cleanly. Exit code on clean shutdown is 0.
285
285
 
286
+ **Node cap** (`--max-nodes <N>`): on `sm scan` and `sm watch` (alias `sm scan --watch`), a hard cap on the number of files the walker accepts after `.skillmapignore` filtering, before extractors run. Default comes from `scan.maxNodes` (default 256). The flag is a full override of the setting and is **bidirectional**: it can raise the cap (`--max-nodes 1000` on a 312-file repo) or lower it (`--max-nodes 100` cuts deeper than the default). When the walker reaches the cap, additional files are dropped in stable provider-walker order and the scan is marked oversized in `scan_meta` (columns `recommended_node_limit` and `override_max_nodes`), the resulting `ScanResult` envelope carries `recommendedNodeLimit` and `overrideMaxNodes` so the UI raises a persistent banner pointing at the `.skillmapignore` editor in Settings → Project. The CLI prints a human-mode notice naming both escapes: edit `.skillmapignore` (preferred, trims permanently) or re-run with `--max-nodes <N>` (force, graph quality may degrade past the recommended limit). `sm refresh` operates on a single already-classified node, so the cap does not apply there. Validation: integer ≥ 1, anything else exits `2` operational.
287
+
286
288
  Exit: 0 on clean (or clean watcher shutdown), 1 if error-severity issues exist (one-shot scan only, the watcher does not flip exit code based on per-batch issues), 2 on operational error.
287
289
 
288
290
  ---
@@ -567,10 +569,10 @@ The reference implementation ships a Hono BFF rooted at `src/server/`. One Node
567
569
  | `GET /api/issues?severity=&analyzerId=&node=` | implemented | `RestEnvelope` (`kind: 'issues'`), list of issues. Filters: `severity` (CSV from `error\|warn\|info`), `analyzerId` (CSV; qualified or short suffix per `sm check --analyzers`), `node` (filter to issues whose `nodeIds` includes the path). No pagination at v14.2. |
568
570
  | `GET /api/graph?format=ascii\|json\|md` | implemented | formatter-rendered graph. `Content-Type` per format: `text/plain` (ascii), `application/json` (json), `text/markdown` (md / mermaid). Default `format=ascii`. Unknown format → 400 `bad-query`. |
569
571
  | `GET /api/config` | implemented | `RestEnvelope` (`kind: 'config'`), merged effective config (defaults → user → user-local → project → project-local → override). |
570
- | `GET /api/plugins` | implemented | `RestEnvelope` (`kind: 'plugins'`), list of installed plugins (built-in + drop-in) with status. Item shape: `{ id, version, kinds, status, reason, source: 'built-in'\|'project', granularity: 'bundle'\|'extension', description?: string, locked?: boolean, startsAsDisabled?: boolean, extensions?: Array<{ id, kind, version, enabled, description?: string, locked?: boolean }> }`. The `granularity` field reflects the manifest declaration (built-ins: hardcoded per `built-in-plugins/built-ins.ts`; drop-ins: from `plugin.json#/granularity`, default `'bundle'`). The `description` field on the bundle item carries the manifest-declared description (built-ins: hardcoded on `IBuiltInBundle`; drop-ins: `plugin.json#/description`); each `extensions[]` entry carries its extension manifest's `description` per `IExtensionBase` (`extensions/base.schema.json#/properties/description`). The SPA's Settings list renders the descriptions as muted secondary text and includes them in its substring-search index alongside the ids. The `extensions` array is present whenever the plugin declares any extension AND the plugin loaded successfully, regardless of granularity. Each entry's `enabled` reflects the per-extension override resolution (DB > settings.json > installed default), this is the **preference** state and is independent of the bundle row. For `granularity: 'bundle'` plugins the runtime layers the two: the bundle id is the coarse kill-switch (when its row resolves to `false`, every extension is disabled regardless of per-extension overrides); when the bundle row resolves to `true`, each extension respects its qualified-id override. The Settings UI exposes per-extension toggles for both granularities so the operator can refine individual extractors inside a bundle without dropping it; `sm plugins enable/disable <bare-id>` still rejects qualified ids against bundle granularity, the per-extension axis is reserved for the UI and direct config edits. The optional `locked: true` flag is stamped when the bundle id (or qualified extension id) appears in the host's lock-list (`src/server/locked-plugins.ts`); locked items render the toggle disabled in the SPA and any `PATCH` against them returns `403 locked`. The flag is omitted when false. The optional `startsAsDisabled: true` flag is stamped on drop-in plugins (never built-ins) whose discovery-time `status` was `'disabled'`, that is, the user had them disabled in `config_plugins` / `settings.json` at `sm serve` boot, so their handlers were never bucketed into the runtime. The SPA renders a per-row hint when this flag is set AND the user toggles the row back on, since re-enabling them requires `sm serve` restart (the rest of the toggle pipeline applies live). The flag is omitted when false. |
571
- | `PATCH /api/plugins/:id` | implemented | Toggle one plugin's user override. `:id` MUST be a top-level bundle id; qualified-id form (`bundle/extension`) is the sibling route below. Body `{ enabled: boolean }` (JSON). Persists to `config_plugins` via `IConfigPluginsPort.set`, same path the CLI's `sm plugins enable\|disable` uses. Response is the canonical `{ id, version, kinds, status, reason, source }` row for the affected plugin (post-write `status` reflects the new override resolution). **Granularity**, rejected with 400 `bad-query` when the target bundle declares `granularity: 'extension'` (only the qualified-id form is toggle-able for those). **Lock**, rejected with 403 `locked` when the bundle id is in the host lock-list (`src/server/locked-plugins.ts`). **Apply window**, the override applies on the next scan (manual via `POST /api/scan` or `sm scan`, automatic via watcher batch); both the BFF and the watcher build a fresh resolver from `config_plugins` before composing extensions, so the toggle is honoured without restarting `sm serve`. The endpoint purges `scan_contributions` rows for the plugin immediately on disable so the UI stops rendering its chips before the next scan. **Exception**, drop-in plugins whose discovery-time `status` was `'disabled'` (carried as `startsAsDisabled: true` on the read shape) are NOT in the runtime extension buckets; re-enabling them via PATCH persists the override but requires `sm serve` restart for the handlers to be loaded. The SPA surfaces this per-row when the user re-enables such a plugin. The endpoint does NOT broadcast a WS event today. |
572
- | `PATCH /api/plugins/:bundleId/extensions/:extensionId` | implemented | Qualified-id form, accepted for ANY granularity (Phase 4b follow-up, commit `e45d2fd`): extension granularity to flip individual extensions, bundle granularity to refine one extension while leaving the rest of the bundle live (the bundle row is the coarse kill-switch). Body `{ enabled: boolean }`. Both segments are URL-path-segment-encoded (no slash inside `:bundleId` or `:extensionId`). 404 `not-found` when the bundle id is unknown or the extension id does not belong to that bundle. **Lock**, rejected with 403 `locked` when either the bundle id or the qualified `bundleId/extensionId` appears in the host lock-list. Same persistence + apply-window semantics as the bundle form (including the `startsAsDisabled` exception). The CLI `sm plugins enable/disable <bundle>/<ext>` still rejects against bundle-granularity targets, the qualified axis is reserved for the UI and direct config edits. |
573
- | `PATCH /api/plugins` | implemented | Bulk toggle. Body `{ "changes": Array<{ "id": string, "enabled": boolean }> }` where each `id` is either a bare bundle id or a qualified `<bundle>/<extension>` id (the dispatcher branches on the slash exactly like the single-id routes above). Empty `changes` array is accepted as a no-op (returns the current `GET /api/plugins` envelope). The route validates the **entire batch** before writing, any invalid entry (`unknown-plugin` / `granularity-mismatch` / `locked`) rejects the whole request with the offending id in `error.details.id`, and the DB is not touched. Valid batches are applied in **one SQLite transaction**: `IConfigPluginsPort.set` per entry, then one grouped `scan_contributions` purge per disabled plugin (enables skip the purge). Response is the same `RestEnvelope` (`kind: 'plugins'`) shape as `GET /api/plugins`, reflecting the post-write state, the SPA replaces its modal state from this envelope. Apply-window and `startsAsDisabled` exception semantics match the per-id routes. The single-id `PATCH /api/plugins/:id` and qualified-id sibling stay available for CLI / external automation; the bulk variant exists so the SPA can stage edits in a buffered modal and ship the final delta atomically. |
572
+ | `GET /api/plugins` | implemented | `RestEnvelope` (`kind: 'plugins'`), list of installed plugins (built-in + drop-in) with status. Item shape: `{ id, version, kinds, status, reason, source: 'built-in'\|'project', description?: string, locked?: boolean, startsAsDisabled?: boolean, extensions?: Array<{ id, kind, version, enabled, description?: string, locked?: boolean }> }`. The bundle itself has no toggle axis; the row's `status` aggregates the children (`'enabled'` when at least one extension is enabled, `'disabled'` otherwise). The `description` field on the bundle item carries the manifest-declared description (built-ins: hardcoded on `IBuiltInBundle`; drop-ins: `plugin.json#/description`); each `extensions[]` entry carries its extension manifest's `description` per `IExtensionBase` (`extensions/base.schema.json#/properties/description`). The SPA's Settings list renders the descriptions as muted secondary text and includes them in its substring-search index alongside the ids. The `extensions` array is present whenever the plugin declares any extension AND the plugin loaded successfully. Each entry's `enabled` reflects the per-extension override resolution (DB > settings.json > installed default). The optional `locked: true` flag is stamped when the bundle id (or qualified extension id) appears in the host's lock-list (`src/server/locked-plugins.ts`); locked items render the toggle disabled in the SPA and any `PATCH` against them returns `403 locked`. The flag is omitted when false. The optional `startsAsDisabled: true` flag is stamped on drop-in plugins (never built-ins) whose discovery-time `status` was `'disabled'`, that is, every extension of the plugin was disabled in `config_plugins` / `settings.json` at `sm serve` boot, so the handlers were never bucketed into the runtime. The SPA renders a per-row hint when this flag is set AND the user re-enables at least one of the bundle's extensions in the buffered state, since re-enabling them requires `sm serve` restart (the rest of the toggle pipeline applies live). The flag is omitted when false. |
573
+ | `PATCH /api/plugins/:id` | implemented | **Bundle macro endpoint**: fans the toggle out across every extension inside the bundle. `:id` MUST be a top-level bundle id (no slash); qualified-id form is the sibling route below. Body `{ enabled: boolean }` (JSON). Persists one `config_plugins` row per child extension (`<bundle>/<ext>`) inside a single transaction; locked children are silently dropped, mirroring the CLI's bulk-mode lock semantics. Response is the canonical `RestEnvelope` (`kind: 'plugins'`) reflecting the post-write state. **Lock**, rejected with 403 `locked` when the bundle id itself is in the host lock-list (`src/server/locked-plugins.ts`). **Apply window**, the override applies on the next scan; both the BFF and the watcher build a fresh resolver from `config_plugins` before composing extensions, so the toggle is honoured without restarting `sm serve`. The endpoint purges `scan_contributions` rows for each disabled extension immediately so the UI stops rendering its chips before the next scan. **Exception**, drop-in plugins whose discovery-time `status` was `'disabled'` (carried as `startsAsDisabled: true` on the read shape) are NOT in the runtime extension buckets; re-enabling them via PATCH persists the override but requires `sm serve` restart for the handlers to be loaded. The SPA surfaces this per-row when the user re-enables such a plugin. The endpoint does NOT broadcast a WS event today. |
574
+ | `PATCH /api/plugins/:bundleId/extensions/:extensionId` | implemented | Canonical per-extension toggle. Body `{ enabled: boolean }`. Both segments are URL-path-segment-encoded (no slash inside `:bundleId` or `:extensionId`). 404 `not-found` when the bundle id is unknown or the extension id does not belong to that bundle. **Lock**, rejected with 403 `locked` when either the bundle id or the qualified `bundleId/extensionId` appears in the host lock-list. Same persistence + apply-window semantics as the bundle macro form (including the `startsAsDisabled` exception). The SPA's buffered Settings modal posts here for every per-row flip. |
575
+ | `PATCH /api/plugins` | implemented | Bulk toggle. Body `{ "changes": Array<{ "id": string, "enabled": boolean }> }` where each `id` is either a bare bundle id (cascade macro, same semantics as `PATCH /api/plugins/:id`) or a qualified `<bundle>/<extension>` id. Empty `changes` array is accepted as a no-op (returns the current `GET /api/plugins` envelope). The route validates the **entire batch** before writing, any invalid entry (`unknown-plugin` / `locked`) rejects the whole request with the offending id in `error.details.id`, and the DB is not touched. Valid batches are applied in **one SQLite transaction**: bare bundle ids expand to their child qualified ids before persistence; `IConfigPluginsPort.set` is called per resulting key, then one grouped `scan_contributions` purge per disabled extension (enables skip the purge). Response is the same `RestEnvelope` (`kind: 'plugins'`) shape as `GET /api/plugins`, reflecting the post-write state, the SPA replaces its modal state from this envelope. Apply-window and `startsAsDisabled` exception semantics match the per-id routes. The single-id `PATCH /api/plugins/:id` and qualified-id sibling stay available for CLI / external automation; the bulk variant exists so the SPA can stage edits in a buffered modal and ship the final delta atomically. |
574
576
  | `ALL /api/*` (other) | reserved | structured 404 envelope (see below); future endpoints land in subsequent sub-steps. |
575
577
  | `GET /ws` | implemented (v14.4.a) | accepts WebSocket upgrade and registers the client with the BFF broadcaster. Server-push only, the server fans `scan.*` (and forthcoming `issue.*`) events to every connected client. See **WebSocket protocol** below. |
576
578
  | `GET *` | implemented | static asset from the resolved UI bundle, falling back to `index.html` for SPA deep links. |
@@ -603,7 +605,7 @@ Error code sources at v14.2:
603
605
  - `internal` (500), uncaught error during a request (e.g. config-load failure, DB corruption surfacing through `loadScanResult`).
604
606
  - `db-missing` (500), emitted by mutation endpoints (`PATCH /api/plugins/:id`, `PATCH /api/plugins/:bundleId/extensions/:extensionId`, `PATCH /api/plugins`) when the project DB is absent. Read-side routes uniformly degrade to the empty shape (`/api/scan`) or zero items (list endpoints) so they do not emit this code; mutation endpoints cannot persist without a DB so they fail fast instead of silently dropping the write.
605
607
  - `not-found` (404) on `PATCH /api/plugins/:id`, unknown plugin id (no built-in bundle, no discovered drop-in matches). The qualified-id form returns the same code when either segment misses. The bulk `PATCH /api/plugins` returns the same code with `error.details.id` set to the first offending id; the batch is rejected before any DB write.
606
- - `bad-query` (400) on `PATCH /api/plugins/:id`, granularity mismatch (bundle-level call against a `granularity: 'extension'` bundle, the bare-id form is reserved for bundle granularity), malformed body (missing `enabled`, wrong type). The qualified-id sibling `PATCH /api/plugins/:bundleId/extensions/:extensionId` accepts any granularity and returns 404 `not-found` for an unknown extension id. The bulk `PATCH /api/plugins` returns 400 for the same per-entry conditions (granularity mismatch on a bare-id entry, missing/typeless `enabled`, malformed `changes` array), with `error.details.id` set to the first offending entry's id.
608
+ - `bad-query` (400) on `PATCH /api/plugins/:id`, malformed body (missing `enabled`, wrong type), or `:id` contains a slash (the qualified-id sibling is `PATCH /api/plugins/:bundleId/extensions/:extensionId`). The qualified-id sibling returns 404 `not-found` for an unknown bundle or extension id. The bulk `PATCH /api/plugins` returns 400 for malformed `changes` array or missing/typeless `enabled`, with `error.details.id` set to the first offending entry's id.
607
609
  - `locked` (403) on `PATCH /api/plugins/:id` and the qualified-id sibling, the target bundle id or qualified extension id is in the host lock-list (`src/server/locked-plugins.ts`). The list is hardcoded, host-only, and not user-editable; `GET /api/plugins` mirrors the same analyzer by stamping `locked: true` on the affected items. The bulk `PATCH /api/plugins` returns the same code with `error.details.id` set to the first locked entry; the batch is rejected before any DB write.
608
610
  - `bad-query` (400) on `POST /api/scan`, the server was started with `--no-built-ins` or `--no-plugins` (partial pipeline would persist a misleading DB).
609
611
  - `scan-busy` (409) on `POST /api/scan`, another scan (a watcher batch or another POST) is already in flight. Retry once the in-flight scan resolves; the WS `scan.completed` envelope is the unambiguous "now safe" signal.
@@ -2,6 +2,5 @@
2
2
  "version": "0.1.0",
3
3
  "specCompat": "*",
4
4
  "catalogCompat": "*",
5
- "description": "Conformance fixture: provider missing `ui` block.",
6
- "granularity": "bundle"
5
+ "description": "Conformance fixture: provider missing `ui` block."
7
6
  }
package/db-schema.md CHANGED
@@ -242,7 +242,7 @@ Cached nodes' rows survive untouched, they're neither orphaned (still in the liv
242
242
 
243
243
  NOT analogous to `state_plugin_kvs` (which is plugin-managed). Belongs to the `scan_*` family, sweep semantics replace pure replace-all but the data is still scan-derived.
244
244
 
245
- **Eager purge on disable.** `sm plugins disable <id>` calls `StoragePort.contributions.purgeByPlugin(pluginId, extensionId?)` immediately after persisting `config_plugins[<id>].enabled = false`. `extensionId` is omitted for bundle-granularity ids (e.g. `claude`) and supplied for qualified ids (e.g. `core/slash-command`), mirroring how the catalog sweep groups rows. The eager purge avoids the "I disabled the plugin but its chips are still rendered in the UI until I re-scan" gap. Re-enabling (`sm plugins enable <id>`) does NOT restore the rows, the next scan re-emits them, same as a cold start. Contributions are scan-derived, so this is cheap; for plugin-managed state (`state_plugin_kvs`, dedicated tables) the opposite policy holds, see `plugin-kv-api.md` § "disable does not drop data".
245
+ **Eager purge on disable.** `sm plugins disable <id>` calls `StoragePort.contributions.purgeByPlugin(pluginId, extensionId)` immediately after persisting `config_plugins[<id>].enabled = false`. Every persisted toggle key is the qualified `<bundle>/<ext>` shape (the CLI's macro form and the BFF's cascade endpoint expand bare bundle ids before persistence), so the purge always receives both segments. The eager purge avoids the "I disabled the extension but its chips are still rendered in the UI until I re-scan" gap. Re-enabling (`sm plugins enable <id>`) does NOT restore the rows, the next scan re-emits them, same as a cold start. Contributions are scan-derived, so this is cheap; for plugin-managed state (`state_plugin_kvs`, dedicated tables) the opposite policy holds, see `plugin-kv-api.md` § "disable does not drop data".
246
246
 
247
247
  ### `scan_node_tags`
248
248
 
package/index.json CHANGED
@@ -174,14 +174,14 @@
174
174
  }
175
175
  ]
176
176
  },
177
- "specPackageVersion": "0.36.0",
177
+ "specPackageVersion": "0.38.0",
178
178
  "integrity": {
179
179
  "algorithm": "sha256",
180
180
  "files": {
181
- "CHANGELOG.md": "8e2a8f2cdfbe49b8ced8bf7ca118bc10bb50b7ce78c212f936e575f3cd582b0d",
181
+ "CHANGELOG.md": "ecd667be3c62b547ac786dc864763778d7591ff43ccdf5c80d40647d299f3498",
182
182
  "README.md": "1c4b0ea58c4324f301043e9f5c36976a382d0bd2bc405a2e4e18463b0c50d946",
183
- "architecture.md": "225b6b3d2377928ffd091bed6b974136fc8a45b603b886fa636766d77ef3c3a2",
184
- "cli-contract.md": "6e43e0636b148b091ab21a7b54f271b4c6323431bfadb3eedb336a7fd339380d",
183
+ "architecture.md": "b9fba6c60ecd32ad2440c804dc9d47b7211873add6dea73401e13dc26bfee277",
184
+ "cli-contract.md": "d7d16ff61e53ba282660111c01b4d92c756c4c429944701bf79035d62408c972",
185
185
  "conformance/README.md": "6871dde25b5770ed945284c9e0f749e0768ec3f5ba4966bdb215985789e43887",
186
186
  "conformance/cases/extractor-emits-signal.json": "34b4808c232d66a0eea0f5db7632a746681432b4f0995b6bf39e8d675538451c",
187
187
  "conformance/cases/kernel-empty-boot.json": "2a5be9c93143d07a16d998df09dcc8fa4ea2d2f9a0bff6417573ed5a770352c1",
@@ -195,7 +195,7 @@
195
195
  "conformance/fixtures/orphan-markdown/ARCHITECTURE.md": "ec903666440bae65da3796b1158c92cfcdce22e0e09c3b20bb690176881a6ac4",
196
196
  "conformance/fixtures/plugin-missing-ui/.skill-map/plugins/bad-provider/kinds/markdown/kind.json": "6676a89bae5197e23cf50f1c11d596db558ac80f7334a7208fe57d8b92422251",
197
197
  "conformance/fixtures/plugin-missing-ui/.skill-map/plugins/bad-provider/kinds/markdown/schema.json": "42795e7f1759fa25115a426edf5cd1b0c91b091b408aeee3f4f9fbc8f89f32bc",
198
- "conformance/fixtures/plugin-missing-ui/.skill-map/plugins/bad-provider/plugin.json": "fb3f52f82f1635d0e5de74788eb5f640d0e36b19464a46f0b2812f6aa9db435f",
198
+ "conformance/fixtures/plugin-missing-ui/.skill-map/plugins/bad-provider/plugin.json": "15164a6bc9e3ad21cefa532af3d4edff1b10cf6140d7f576332dc38800512e35",
199
199
  "conformance/fixtures/plugin-missing-ui/.skill-map/plugins/bad-provider/providers/bad-provider/index.js": "6eac0555de4194c3266c2abef89e39d833159990e1822ae1d4895df67c31d18f",
200
200
  "conformance/fixtures/plugin-missing-ui/notes/example.md": "55767f0aa1b6774546a99f28c58e7b732aa9cfa5dfce8d0326470f7f622f577e",
201
201
  "conformance/fixtures/preamble-v1.txt": "1e0aeef224b64477bdc13a949c3ad402e68249caf499ecdba1302371677c068b",
@@ -208,11 +208,11 @@
208
208
  "conformance/fixtures/signal-ir-collision/.claude/agents/architect.md": "acc46b5b2dff73d98a354e4d53b5041164595deae466a4e2ce41d7c5a72f28fb",
209
209
  "conformance/fixtures/signal-ir-single-signal/source.md": "1eda417b4c6eed372b66870e385c8d8cd631372b77cab7e996bb711e22218f89",
210
210
  "conformance/fixtures/signal-ir-single-signal/target.md": "527137f2b4f46c0034b0edc8932cf8613d2bf22ffaaf78f01085c82a3baaebe3",
211
- "db-schema.md": "9d265dacaf30649f0b7eddb6d31d1017da05a248524acc7b08eb3735e2e603c1",
211
+ "db-schema.md": "840ed078fa86e8793738748b5f651b068b93162c942db1f341b0d61f00d18893",
212
212
  "interfaces/security-scanner.md": "e8049712b9cf7a07c786bf19f8f775f8ef9638f063f7fba5c7a8b1431b92f38e",
213
213
  "job-events.md": "9d5b35d4c451a7f8eef9915d85316d924ac52f1c026a316cdda5f1099d496854",
214
214
  "job-lifecycle.md": "9c429121f98a07c8795f8979ed1abc5e5334e3f89db51585a8da55c527ef855b",
215
- "plugin-author-guide.md": "16c61dca29460075e95c0506f0a94c057a6e07018784f26b08e41e609b01f759",
215
+ "plugin-author-guide.md": "f0039397cd19d1f0a02c4e0691f213514216188d001cf56f287e69f6631fd39e",
216
216
  "plugin-kv-api.md": "1acc69ed82433a74e35ada61d63a6d7379fb61046ff83de1e0facbe884c64704",
217
217
  "prompt-preamble.md": "9dd4f6d1bc6a425f8782fcee10cbe75909e8d64e28781fda56c2fae909b02f40",
218
218
  "schemas/annotations.schema.json": "e39990d47f53e25a1b3a5587a5714486d0b819b8eeaac10d42783a675296aee1",
@@ -223,7 +223,7 @@
223
223
  "schemas/execution-record.schema.json": "0d61e33f2dc1aaa4cc7337b5eac4ea8b9034022ce24bae9156c3c9f33204c250",
224
224
  "schemas/extensions/action.schema.json": "9f6c2427ce3f0d6fa329adf0f13129821116ab79a1d2a53f96464513ff044ebe",
225
225
  "schemas/extensions/analyzer.schema.json": "f9bed3ba1305b2b64da277dccfbe760f7c058c4bb62a2d845af9c75787f159f6",
226
- "schemas/extensions/base.schema.json": "46fbec5d305673524d7298d6374c44ebd76273d86f46a930c568d4250caaf97d",
226
+ "schemas/extensions/base.schema.json": "679d256a317f5be7f80a80dd3ec0c9098ad7d2f7b20c99c70fc70d6a1eb738a6",
227
227
  "schemas/extensions/extractor.schema.json": "5994088bf669321d2a7b8262c07cc94e05e5e2f49a235ae5389b7c66ecc1b2e1",
228
228
  "schemas/extensions/formatter.schema.json": "d6d417df20260e5ddfe71f104b11a45873869706f86372c3c3c78c583e06e8d5",
229
229
  "schemas/extensions/hook.schema.json": "76bf2c07f9e689b3fd1c67cbad4516a4df10604f07103759e82670e5213ddcdf",
@@ -236,13 +236,13 @@
236
236
  "schemas/job.schema.json": "e43e1761c99920beffe1de12ef8f32fe29f97838bd8686742b637c19c4dbb395",
237
237
  "schemas/link.schema.json": "8a01925a9a7c00bbe41cdcbf8eea9269955570d41484da93d4193232a7aa9070",
238
238
  "schemas/node.schema.json": "4d7c107ed9cd2f1b7cc4d716c547c06a00ed776bd6092d3979cac634cb5326a5",
239
- "schemas/plugins-doctor.schema.json": "c1d92f30fdb0080e8cd8f7dc5d43e01aae02a16640bc5eb04811c337a275de58",
240
- "schemas/plugins-registry.schema.json": "cca7ae65f0c22510ea27ea5ae34e0074f5beb5871a57b005b6b831e6ceaff5c0",
241
- "schemas/project-config.schema.json": "f2a38a82fe47c8a965e6b73208e9be996d3d3545fdcd92f8a136f1d1e5abd3fb",
239
+ "schemas/plugins-doctor.schema.json": "bb03eedf5b462b661938a44fe48635705ec13e30a2381ba122c60412416b507d",
240
+ "schemas/plugins-registry.schema.json": "bca763ec61419e001a5c0a4b00a2da77996bfd8113be3fcf8aaa2fa70ff65fef",
241
+ "schemas/project-config.schema.json": "253991718f714bf9409279410067b9c0c0da71e32a1b889d2f30559a0030c369",
242
242
  "schemas/refresh-report.schema.json": "54519b8caf86ba84c182f9565be9b5084bc1631ae05e9217ee18f34c0039fff3",
243
243
  "schemas/report-base-deterministic.schema.json": "9d318d0181d121097c906ef3af1c52d71c782740bd04cf23418d7627ce2c3ed5",
244
244
  "schemas/report-base.schema.json": "a1021e9a59b4df9f99cd92454d797e88469766e7d49f52d231c4645ffdfdad8f",
245
- "schemas/scan-result.schema.json": "214bc12fbb9946642cbba3b23513dade60e7d6a5b6a9ed3dd0818f135b450185",
245
+ "schemas/scan-result.schema.json": "2ac5edeb607e0f7c26036c65066effefcbaa5a6d8534b2f42fa4e44a3964a9f6",
246
246
  "schemas/sidecar.schema.json": "8856c387477340efbdd0a585d74bfb07a99ba15b9ce593cc67d9efebc67c6bfc",
247
247
  "schemas/signal.schema.json": "7a9d36f13ee6fa269da7ab97e45d9831d10e0570e3f61005617128b423a4d4d8",
248
248
  "schemas/summaries/agent.schema.json": "bf540f9a804f2b43756ab33b7deb0462620d26e88cc9379c75a5f87d3b1b47d8",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skill-map/spec",
3
- "version": "0.36.0",
3
+ "version": "0.38.0",
4
4
  "description": "JSON Schemas, prose contracts, and conformance suite for the skill-map specification.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -52,8 +52,7 @@ plumbing.
52
52
  {
53
53
  "id": "my-plugin",
54
54
  "version": "1.0.0",
55
- "specCompat": "^1.0.0",
56
- "granularity": "bundle"
55
+ "specCompat": "^1.0.0"
57
56
  }
58
57
  ```
59
58
 
@@ -152,44 +151,40 @@ The kernel guards against two foot-guns:
152
151
 
153
152
  For built-ins, the reference impl's `src/plugins/<bundle>/plugin.json` provides the bundle's `id` and the codegen at `scripts/generate-built-ins.js` inlines the `pluginId` injection at build time (the resulting `src/plugins/built-ins.ts` is auto-generated and committed). Authors never hardcode `pluginId` on the extension export.
154
153
 
155
- ### Granularity, bundle vs extension
154
+ ### Toggle model
156
155
 
157
- Every plugin and every built-in bundle declares a **granularity** that controls how its extensions are toggled by `sm plugins enable / disable` and by `config_plugins` / `settings.json`. Two modes:
156
+ Every extension is independently toggle-able by its qualified id `<bundle>/<ext-id>` (e.g. `claude/at-directive`, `core/node-superseded`, `my-plugin/orphan-skill`). The **bundle is a presentational grouping**, not a toggle target, the user sees a row per bundle in `sm plugins list` and the Settings UI, with the bundle's extensions listed underneath, each with its own enabled / disabled state.
158
157
 
159
- | Granularity | Toggle key | When to use |
160
- |---|---|---|
161
- | `bundle` (default) | the bundle id alone (e.g. `my-plugin`, `claude`) | The plugin's extensions form a coherent product (e.g. a Provider and the extractors that decode its native syntax). The user wants one switch. **95% of plugins.** |
162
- | `extension` | the qualified extension id (`<bundle>/<ext-id>`, e.g. `core/node-superseded`, `my-plugin/orphan-skill`) | The plugin ships several orthogonal capabilities a user might reasonably want piecemeal. **Built-in `core` is the canonical example**, the spec promises every kernel built-in is removable, so each one toggles independently. |
158
+ Two id shapes resolve at the toggle surface:
163
159
 
164
- Built-in mapping:
160
+ - **Qualified id** (`<bundle>/<ext-id>`): flips exactly that extension. No prompt.
161
+ - **Bare bundle id** (`claude`, `core`, `my-plugin`): the **macro form**. Fans the toggle out across every extension inside the bundle.
162
+ - Bundle with exactly one extension (`openai`, `antigravity`, `agent-skills`): applies the toggle directly. No prompt (1-1 mapping).
163
+ - Bundle with ≥2 extensions (`claude`, `core`, multi-extension user plugins): requires `--yes` OR an interactive TTY confirm. Pipe / CI contexts must pass `--yes`; without it the verb refuses and prints the list of affected extensions plus the re-run hint.
165
164
 
166
- - **`claude`** / **`antigravity`** / **`openai`** / **`agent-skills`**, `granularity: 'bundle'`. Each vendor Provider bundle is enabled or disabled as a whole; today every such bundle ships only its Provider, so the toggle flips classification + frontmatter parsing for that platform.
167
- - **`core`**, `granularity: 'extension'`. `sm plugins disable core/node-superseded` flips just the supersession analyzer; every other core extension (every other analyzer, the ASCII formatter, the cross-vendor extractors) stays live.
165
+ `--all` is the cascade variant of the macro: it expands to every extension in every discovered bundle (built-ins + user plugins) and applies the same `--yes` / TTY-confirm gate as a multi-extension bundle id.
168
166
 
169
167
  Per-verb behaviour:
170
168
 
171
- | Command | Bundle granularity | Extension granularity |
172
- |---|---|---|
173
- | `sm plugins enable claude` | OK, flips the bundle. | Rejected: `'core' has granularity=extension; use sm plugins enable core/<ext-id>`. |
174
- | `sm plugins enable claude/claude` | Rejected: `'claude' has granularity=bundle; use sm plugins enable claude`. | n/a (no bundle of granularity=bundle accepts qualified ids) |
175
- | `sm plugins disable core` | n/a | Rejected: same directed message as the bundle row above. |
176
- | `sm plugins disable core/node-superseded` | n/a | OK, persists `config_plugins['core/node-superseded'].enabled = 0`. |
177
-
178
- Resolution order per id is the same as for plugin enabled-state: DB override (`config_plugins`) > settings.json (`#/plugins/<id>/enabled`) > installed default (`true`). `settings.json#/plugins` keys are arbitrary strings (no AJV pattern), so both bare and qualified ids are accepted there. Granularity controls how the per-id results compose into the effective runtime state:
179
-
180
- - **extension granularity** (`core`): only qualified ids matter. Each extension is independently toggle-able.
181
- - **bundle granularity** (`claude`, `antigravity`, ...): the bundle id is the coarse kill-switch. When the bundle id resolves to `false`, every extension in the bundle is disabled regardless of per-extension overrides. When the bundle resolves to `true`, each extension respects its qualified-id override (default `true`). This lets the Settings UI refine individual extractors inside a bundle (e.g. disable `claude/at-directive` while keeping the provider live) without an asymmetric CLI surface, `sm plugins enable/disable <bare-id>` still rejects qualified ids against bundle granularity, the per-extension axis is reserved for the UI and direct `settings.json` / `config_plugins` edits.
169
+ | Command | Result |
170
+ |---|---|
171
+ | `sm plugins enable claude/at-directive` | OK, flips just that extension. |
172
+ | `sm plugins enable openai` | OK, single-child bundle, flips `openai/openai`. No prompt. |
173
+ | `sm plugins disable claude` | Multi-child bundle; TTY: prompts `[y/N]`; non-TTY: refuses without `--yes`. |
174
+ | `sm plugins disable claude --yes` | OK, flips every extension under `claude`. |
175
+ | `sm plugins disable core` | Multi-child bundle; same gate as `claude` above. |
176
+ | `sm plugins disable core/node-superseded` | OK, flips just that analyzer. |
177
+ | `sm plugins disable --all` | Cascades through every bundle; requires `--yes` in non-TTY. |
182
178
 
183
- `sm plugins enable/disable --all` operates only on top-level bundle ids (the default-enabled set every user can see); it never expands to qualified `<bundle>/<ext>` keys. The "disable every kernel built-in at once" intent is served by `--no-built-ins` on `sm scan` and friends; `--all` is the macro on user-toggle-able units, not on every individual extension.
179
+ Resolution order per id is the same as for plugin enabled-state: DB override (`config_plugins`) > settings.json (`#/plugins/<id>/enabled`) > installed default (`true`). `settings.json#/plugins` keys are arbitrary strings (no AJV pattern); persisted toggle keys are always qualified `<bundle>/<ext>` ids (the macro path expands at write time so the DB only ever stores per-extension rows).
184
180
 
185
- Set `granularity` in your `plugin.json`. The folder layout supplies the extensions; the kernel discovers them automatically:
181
+ Set the manifest fields in your `plugin.json`; the folder layout supplies the extensions and the kernel discovers them automatically. There is no `granularity` field anymore (a manifest that declares it fails AJV with `additionalProperties`):
186
182
 
187
183
  ```jsonc
188
184
  {
189
185
  "id": "my-multi-tool",
190
186
  "version": "1.0.0",
191
- "specCompat": "^1.0.0",
192
- "granularity": "extension"
187
+ "specCompat": "^1.0.0"
193
188
  }
194
189
  ```
195
190
 
@@ -292,7 +287,6 @@ Optional fields:
292
287
 
293
288
  | Field | Type | Notes |
294
289
  |---|---|---|
295
- | `granularity` | `'bundle' \| 'extension'` | Default `'extension'` (each extension toggleable by qualified id). Set to `'bundle'` when the plugin's extensions form a coherent unit a user would never want to toggle piecemeal. |
296
290
  | `storage` | object | `{ "mode": "kv" }` or `{ "mode": "dedicated", "tables": [...], "migrations": [...] }`. Absent means the plugin does not persist state. |
297
291
  | `author` | string | Free-form. |
298
292
  | `license` | string | SPDX identifier. |
@@ -995,6 +989,26 @@ The kernel ships exactly these 14 slots. Each slot fixes a renderer + a payload
995
989
 
996
990
  Per-slot semantics, edge cases, and exact payload schemas live in [`view-slots.md`](./view-slots.md) (catalog reference) and [`schemas/view-slots.schema.json`](./schemas/view-slots.schema.json) at `$defs/payloads/<slot>`. Read those before emitting.
997
991
 
992
+ ### Chip vs Issue, what counts and what only shows
993
+
994
+ For analyzers, the per-node card surfaces a finding through **two independent channels**:
995
+
996
+ - The `Issue` returned by `evaluate(ctx)` feeds the **aggregated stats** (`errorCount` / `warnCount` on the card) and the **scan / check exit code** (`severity: 'error'` → exit 1; `'warn'` / `'info'` → exit 0). `info` issues never appear in the card's visible issues list, only in `sm show` / `--json`.
997
+ - A view contribution to `card.footer.right` (a chip) is **purely presentational**: its `severity` controls only the chip's own colour, never the aggregated count, never the exit code.
998
+
999
+ The matrix:
1000
+
1001
+ | Goal | Issue? | Chip? | Issue severity | Chip severity |
1002
+ |---|---|---|---|---|
1003
+ | Surface a problem AND count it | yes | yes | `error` / `warn` | `danger` / `warn` (match) |
1004
+ | Show an attribute without counting | no (or `info`) | yes | `info` (or none) | `info` / `success` / none |
1005
+ | Count without a dedicated chip | yes | no | `error` / `warn` | — |
1006
+ | No surface | no | no | — | — |
1007
+
1008
+ **Colour rule.** A chip MAY paint `warn` (yellow) or `danger` (red) **only when** the same analyzer emits a matching Issue at `warn` or `error` severity for the same node. Decorative chips use `severity: 'info'`, `'success'`, or omit the field. The rule is enforced by code review (not by the manifest schema), but breaking it produces visually misleading cards: a red chip on a node that contributes zero to the error stat reads as "missed an exit-code escalation" to the operator.
1009
+
1010
+ The corner slot `graph.node.alert` is **reserved** and is NOT part of this matrix, see `view-slots.md` (and the slot's row in the catalog table) for the policy. No built-in analyzer ships an emission there; the slot is kept for genuinely independent, special-case signals (a future plugin with a one-off corner decoration).
1011
+
998
1012
  ### Emit path
999
1013
 
1000
1014
  Inside `extract(ctx)`, call:
@@ -1120,7 +1134,6 @@ plugins/acme-keyword-finder/
1120
1134
  "version": "1.0.0",
1121
1135
  "specCompat": "^0.20.0",
1122
1136
  "catalogCompat": "^1.0.0",
1123
- "granularity": "bundle",
1124
1137
  "settings": {
1125
1138
  "keywords": {
1126
1139
  "type": "string-list",
@@ -1235,7 +1248,6 @@ Companion verbs:
1235
1248
  - The six plugin statuses (`loaded` / `disabled` / `incompatible-spec` / `invalid-manifest` / `load-error` / `id-collision`) are stable; adding a seventh status is a minor bump.
1236
1249
  - The structural analyzer **directory name MUST equal manifest id** is stable; relaxing it (allowing mismatch) is a major bump.
1237
1250
  - 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.
1238
- - The `granularity` field on `PluginManifest` is stable as introduced. The two values (`bundle` / `extension`) are stable. Adding a third value is a minor bump; changing the default away from `bundle` is a major bump (every existing plugin manifest would silently flip toggle semantics).
1239
1251
  - The optional `applicableKinds` field on the Extractor manifest is stable as introduced. Adding a wildcard syntax (`'*'`) is a minor bump (additive, the existing "absent = all kinds" semantics keeps holding); changing the default away from "applies to every kind" or making the field required is a major bump. Promoting the unknown-kinds doctor warning to a hard load error is a major bump (today's contract is "load OK, surface as warning").
1240
1252
  - The recommended `specCompat` strategy is descriptive prose; revising the recommendation does not require a spec bump as long as the schema stays unchanged.
1241
1253
  - The example code blocks track the public TypeScript surface of `@skill-map/cli`; bumping their imports follows the cli's own semver.
@@ -8,7 +8,7 @@
8
8
  "properties": {
9
9
  "version": {
10
10
  "type": "string",
11
- "description": "Extension semver. Bumped independently from the plugin version; frozen into `state_executions.extension_version` on every run for reproducibility."
11
+ "description": "Extension semver. REQUIRED for external (user-authored) plugins, the AJV check at load time rejects manifests missing it. Bumped independently from the plugin's bundle version; frozen into `state_executions.extension_version` on every run for reproducibility. The reference CLI's built-in extensions under `src/plugins/` use a different authoring path (type `IBuiltInManifest<I<Kind>>` = `Omit<I<Kind>, 'version'>`); the codegen at `scripts/generate-built-ins.js` stamps the CLI version onto every built-in at build time so the runtime shape still satisfies the full interface."
12
12
  },
13
13
  "description": {
14
14
  "type": "string",
@@ -43,7 +43,7 @@
43
43
  "id": {
44
44
  "type": "string",
45
45
  "minLength": 1,
46
- "description": "Plugin id (bundle granularity) or qualified id (`<bundle>/<ext>`) for extension granularity."
46
+ "description": "Qualified extension id `<bundle>/<ext>`. The bare bundle id form is reserved for the macro path in CLI / BFF requests and never appears in persisted state."
47
47
  },
48
48
  "status": {
49
49
  "type": "string",
@@ -32,12 +32,6 @@
32
32
  "minLength": 1,
33
33
  "description": "Required short description shown in `sm plugins list` and the UI. English-only per AGENTS.md."
34
34
  },
35
- "granularity": {
36
- "type": "string",
37
- "enum": ["bundle", "extension"],
38
- "default": "extension",
39
- "description": "Toggle granularity for this plugin. `bundle`, the plugin id is the only enable/disable key; the whole set of extensions follows the toggle. `extension` (default), each extension is independently toggle-able under its qualified id `<plugin-id>/<extension-id>`. Built-in bundles use the same field: the `claude` bundle is `bundle` (the Provider and its kind-aware extractors form a coherent provider); the `core` bundle is `extension`. Plugin authors should pick `bundle` only when the plugin's extensions form a coherent unit a user would never want to toggle piecemeal."
40
- },
41
35
  "storage": {
42
36
  "type": "object",
43
37
  "description": "Persistence mode for this plugin. Absent = plugin does not persist state.",
@@ -55,6 +55,11 @@
55
55
  "minimum": 1,
56
56
  "description": "Files larger than this are skipped with an `info`-level log entry. Default 1048576 (1 MiB). Protects against scanning accidental binary drops or generated artefacts."
57
57
  },
58
+ "maxNodes": {
59
+ "type": "integer",
60
+ "minimum": 1,
61
+ "description": "Hard cap on the number of files the scan accepts after `.skillmapignore` filtering, before extractors run. Default 256. When the walker reaches the cap, additional files are dropped in stable provider-walker order and the scan is marked oversized in `scan_meta` (the UI raises a persistent banner pointing at the `.skillmapignore` editor in Settings → Project). Override per invocation with `--max-nodes N` on `sm scan` / `sm watch`, bidirectional (raises OR lowers the limit, the flag is a full override of this setting)."
62
+ },
58
63
  "watch": {
59
64
  "type": "object",
60
65
  "additionalProperties": false,
@@ -37,6 +37,16 @@
37
37
  "description": "Provider ids that participated in classification. Empty if no Provider matched.",
38
38
  "items": { "type": "string" }
39
39
  },
40
+ "recommendedNodeLimit": {
41
+ "type": "integer",
42
+ "minimum": 1,
43
+ "description": "Effective recommended cap on the number of files the walker accepted during this scan (`scan.maxNodes` from settings, default 256). Reported so the UI can decide whether to raise the persistent 'oversized graph' banner: a scan is oversized when `stats.filesWalked >= recommendedNodeLimit`. Absent on synthetic fixtures."
44
+ },
45
+ "overrideMaxNodes": {
46
+ "type": ["integer", "null"],
47
+ "minimum": 1,
48
+ "description": "Override applied via `--max-nodes <N>` on the verb that ran the scan (`sm scan`, `sm refresh`, `sm watch`), or `null` when no override was passed and the value above came from the setting. The override is bidirectional: it can raise the cap above the recommended limit (the UI banner stays visible until a re-scan lands below the recommended limit) or lower it (the banner also fires if `filesWalked` reaches the lowered override). Absent on synthetic fixtures."
49
+ },
40
50
  "nodes": {
41
51
  "type": "array",
42
52
  "items": { "$ref": "node.schema.json" }