@skill-map/spec 0.20.0 → 0.22.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 +306 -0
- package/architecture.md +73 -13
- package/cli-contract.md +47 -25
- package/conformance/coverage.md +1 -1
- package/conformance/fixtures/plugin-missing-ui/.skill-map/plugins/bad-provider/provider.js +0 -1
- package/db-schema.md +4 -1
- package/index.json +13 -13
- package/package.json +3 -2
- package/plugin-author-guide.md +30 -13
- package/plugin-kv-api.md +1 -1
- package/schemas/api/rest-envelope.schema.json +5 -1
- package/schemas/extensions/provider.schema.json +2 -7
- package/schemas/project-config.schema.json +7 -7
- package/schemas/view-slots.schema.json +8 -7
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,311 @@
|
|
|
1
1
|
# Spec changelog
|
|
2
2
|
|
|
3
|
+
## 0.22.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 39a61e9: Remove the implicit "scan HOME" surface and consolidate every out-of-project scan path under a single, explicit `scan.extraFolders` setting. Privacy-by-default: the CLI / BFF / UI never read the user's home automatically anymore; every path outside the project root must be listed by the operator.
|
|
8
|
+
|
|
9
|
+
**Removed**
|
|
10
|
+
|
|
11
|
+
- `scan.includeHome` (project config boolean). The toggle that appended every Provider's HOME path is gone.
|
|
12
|
+
- `explorationDir` on the Provider manifest. Built-in providers (`claude`, `gemini`, `agent-skills`, `core-markdown`) no longer declare it; the field is dropped from `spec/schemas/extensions/provider.schema.json`. Each Provider's walker hardcodes the project-relative paths it cares about (e.g. `.claude/`, `.gemini/`, `.agents/`).
|
|
13
|
+
- `sm scan -g` / `sm scan --global`. The scan verb no longer accepts the global scope flag (there is no global scan surface once HOME auto-inclusion is gone). Other verbs (`config`, `db`, `plugins`, `init`, …) keep their `-g` flag — those point at `~/.skill-map/` (skill-map's own data dir), not at scanned content.
|
|
14
|
+
- `sm plugins doctor` no longer emits the `explorationDir missing` warning.
|
|
15
|
+
|
|
16
|
+
**Renamed**
|
|
17
|
+
|
|
18
|
+
- `scan.extraRoots` → `scan.extraFolders` (same shape `string[]`, same semantics — clearer name in the Settings UI and config). Privacy-sensitive: writes that add out-of-project paths still require `--yes` on the CLI and a confirm dialog in the UI.
|
|
19
|
+
|
|
20
|
+
**BFF**
|
|
21
|
+
|
|
22
|
+
- `GET /api/project-preferences` response now returns `{ scan: { extraFolders, referencePaths } }` (dropped `includeHome`, renamed `extraRoots`).
|
|
23
|
+
- `PATCH /api/project-preferences` accepts the same shape; `additionalProperties: false` still applies.
|
|
24
|
+
|
|
25
|
+
**UI**
|
|
26
|
+
|
|
27
|
+
- Settings → Project section drops the "Include HOME folders" toggle; only the "Extra folders to scan" list and "Folders for link validation" list remain.
|
|
28
|
+
|
|
29
|
+
**Greenfield migration**
|
|
30
|
+
|
|
31
|
+
No backwards-compat shim. Users with `scan.includeHome: true` or `scan.extraRoots: [...]` in `<cwd>/.skill-map/settings.local.json` (or `~/.skill-map/settings.json`) need to manually rename `extraRoots` → `extraFolders` and, if they want to keep HOME scanning, list the specific paths they care about (e.g. `~/.claude/agents`) in `scan.extraFolders` — instead of opting into "everything under HOME" at once.
|
|
32
|
+
|
|
33
|
+
## User-facing
|
|
34
|
+
|
|
35
|
+
The "include HOME" toggle is gone. To scan paths outside the project, list them in **Extra folders to scan** (renamed from _Extra roots_). If you had `scan.includeHome: true`, add the paths you actually need (e.g. `~/.claude/agents`) — not one click anymore.
|
|
36
|
+
|
|
37
|
+
### Patch Changes
|
|
38
|
+
|
|
39
|
+
- 1e48d2e: Follow-up sweep on the cli-architect spec-drift audit. Three pieces:
|
|
40
|
+
|
|
41
|
+
- **5a — plugin loader status alignment.** The loader now returns `invalid-manifest` (not `load-error`) when the exported extension shape fails its kind-specific AJV schema. Aligns with `spec/architecture.md` §Plugin discovery: "AJV rejects unknown `slot` names with `invalid-manifest`". The module imported fine; only the declared shape is wrong, so `invalid-manifest` is the semantically correct status (`load-error` is for genuine module-load failures: import threw, timeout, unknown kind). Renames `PLUGIN_LOADER_TEXTS.loadErrorManifestInvalid` → `invalidManifestExtensionShape` to match. 4 tests updated.
|
|
42
|
+
|
|
43
|
+
- **7 — `emitScopeContribution` docs alignment.** Added a "pending, not yet implemented" status note to `spec/view-slots.md` and `spec/plugin-author-guide.md`. The two author-facing docs previously showed the callback as if it existed; `spec/architecture.md` already says it's "reserved, lands when the first scope-level adopter arrives". A plugin author who copies the example now sees the caveat upfront instead of hitting `TypeError: ctx.emitScopeContribution is not a function` at runtime.
|
|
44
|
+
|
|
45
|
+
- **P2 cosmetic prose sweep.** Slot-count references corrected ("15 slots" → "14" — the closed enum has 14 entries since the topbar scope-slot rename); `IViewContribution` field count corrected ("six fields" → "seven" — `priority?` was declared in the schema since the beginning but never documented in prose). Three spec docs swept; `spec/index.json` regenerated.
|
|
46
|
+
|
|
47
|
+
`catalogCompat` (5b in the audit) — schema field declared but loader check not implemented — is deferred until catalog v2 evolution demands it. No catalog evolution is pending pre-1.0, so the gap is acceptable; flagged in audit follow-ups, not in this changeset.
|
|
48
|
+
|
|
49
|
+
## 0.21.0
|
|
50
|
+
|
|
51
|
+
### Minor Changes
|
|
52
|
+
|
|
53
|
+
- f72dbfc: Card body + topbar polish, plus catalog rename of the topbar scope slot.
|
|
54
|
+
|
|
55
|
+
**New extractor (`core/tools-count`)** — `src/built-in-plugins/extractors/tools-count/`. Reads `frontmatter.tools[]` on agent-kind nodes (Claude + Gemini share the field shape) and emits a `card.footer.left` counter chip with a wrench icon. Replaces the hardcoded wrench block previously rendered straight from `<sm-node-card>` (`toolsCount()` computed + `effectiveToolsCount` / `effectiveToolsBreakdown` helpers, all removed). `applicableKinds: ['agent']` gates the run at load time so skill / command / markdown nodes pay zero cost. Tooltip carries the joined tool names (capped at the 256-char slot limit).
|
|
56
|
+
|
|
57
|
+
**Provider kind visuals normalised** — `src/built-in-plugins/providers/gemini/index.ts` and `agent-skills/index.ts`. Every Provider that contributes `agent` / `skill` / `command` now declares the same label + color + icon as Claude. The declaration STAYS per-Provider (the shape allows divergence the day a Provider wants its own identity for a kind), but today the values mirror Claude so the visual vocabulary is uniform regardless of where a node was sourced from. `<sm-kind-icon>` gains an optional `provider` input that resolves the icon per-Provider when the call site supplies one (today a no-op, ready to diverge tomorrow).
|
|
58
|
+
|
|
59
|
+
**Slot catalog rename + relocate** — `topbar.actions.indicator` → `topbar.nav.start`. The slot moved from the topbar actions cluster (right side, between refresh / theme / settings) to the start of the topbar nav (left of the view-switcher links). The rename is a catalog-major-bump for any external plugin that emitted to the old name (pre-1.0 → ships as a `@skill-map/spec` minor per the versioning policy). Sweep covers `spec/schemas/view-slots.schema.json` (closed enum), `spec/view-slots.md`, `spec/architecture.md`, `spec/plugin-author-guide.md`, `src/kernel/types/view-catalog.ts`, `src/kernel/adapters/schema-validators.ts`, `src/built-in-plugins/analyzers/unknown-slot/index.ts`, `src/cli/commands/plugins.ts`, `ui/src/app/slots/slot-config.ts`, `ui/src/app/slots/slot-renderer-map.ts`, `ui/src/app/app.html`, `ui/src/app/renderers/scope-stat/scope-stat.ts`, `ui/src/app/debug-slots.css`, `context/view-slots.md`, `ROADMAP.md`. Spec integrity regenerated.
|
|
60
|
+
|
|
61
|
+
**View-contribution wrapper transparent to layout** — `ui/src/app/debug-slots.css`. `.sm-debug-slot` and `<sm-view-contributions-host>` are `display: contents` in production mode, so a slot that has no contributions takes zero space (no flex gap, no empty box). Debug mode flips both back to `inline-flex` for the visual ring + label.
|
|
62
|
+
|
|
63
|
+
**Provider chip in card subtitle** — `ui/src/services/provider-ui.ts` (new) + render in `<sm-node-card>`. Hardcoded chip carrying the provider's display label, color-coded per Provider so the platform a node came from reads at a glance. Unlike kind visuals (normalised), provider visuals are deliberately distinct. The `markdown` Provider is hidden (universal fallback — every generic `.md` lands there, painting the chip would be visual noise). Today the registry is a static UI-side map; promotes to a kernel-side `IProvider.ui` field the day a user-plugin Provider needs to declare its own chip.
|
|
64
|
+
|
|
65
|
+
**Path row in expanded card** — `ui/src/app/components/node-card/node-card.html`. Mono row at the top of `.sm-gnode__panel`, above the description and the LLM cluster. Subtle background, ellipsis on the leading segments (RTL trick) so the file name stays visible on long paths.
|
|
66
|
+
|
|
67
|
+
**Stat chip colors decoupled from `--sm-kind-*`** — `ui/src/styles.css` declares `--sm-stat-tokens-bg` / `--sm-stat-bytes-bg` / `--sm-stat-date-bg` (light + dark). Previously the chip backgrounds borrowed `--sm-kind-agent` / `--sm-kind-command` / `--sm-kind-skill`, which evaporate when their primary Provider plugin is disabled. Physical stats are plugin-independent — the new tokens keep the chips colored regardless of which plugins contribute kinds.
|
|
68
|
+
|
|
69
|
+
**Favorite star (was heart)** — every favorite affordance flips from `pi-heart` / `pi-heart-fill` to `pi-star` / `pi-star-fill`: `<sm-node-card>`, `<sm-inspector-view>`, `<app-kind-palette>` (favorites toggle), `<app-filter-bar>` (favorites toggle). Spec describes match updated.
|
|
70
|
+
|
|
71
|
+
**Author tag chips inherit the card's kind accent** — `node-card.css`. Outline color + text color come from `var(--accent)` (the kind's primary color, overridden per-Provider by `providerAccent`) instead of the theme's violet primary. Each card paints author tags in its own kind color.
|
|
72
|
+
|
|
73
|
+
## User-facing
|
|
74
|
+
|
|
75
|
+
Expanded node cards now show the file path above the description and a provider chip (Claude, Gemini, Open Skills). Favorite toggle uses a star instead of a heart.
|
|
76
|
+
|
|
77
|
+
- 5ed14cb: Disabling a plugin now wipes its `scan_contributions` rows immediately, instead of waiting for the next `sm scan` to sweep them. Without the eager purge, the catalog sweep documented in `db-schema.md` § scan_contributions only ran on the next scan, so the UI kept rendering the plugin's footer / card chips even though the toggle showed `enabled: false`.
|
|
78
|
+
|
|
79
|
+
Both toggle paths converge on the same purge:
|
|
80
|
+
|
|
81
|
+
- CLI — `sm plugins disable <id>` and `sm plugins disable --all` (`TogglePluginsBase.toggle` in `src/cli/commands/plugins.ts`).
|
|
82
|
+
- BFF — `PATCH /api/plugins/:id` and `PATCH /api/plugins/:bundleId/extensions/:extensionId` (the UI's Settings → Plugins toggle).
|
|
83
|
+
|
|
84
|
+
Each call to `pluginConfig.set(id, false)` is followed by `adapter.contributions.purgeByPlugin(pluginId, extensionId?)`. `extensionId` is omitted for bundle-granularity ids (`claude`) and supplied for qualified ids (`core/slash`), mirroring how the catalog sweep groups rows. Re-enabling does NOT restore the rows — the next scan re-emits them, same as a cold start.
|
|
85
|
+
|
|
86
|
+
Plugin-managed state (`state_plugin_kvs`, dedicated `plugin_<id>_*` tables) is **not** touched. The asymmetry is intentional: contributions are scan-derived (cheap to recompute, must reflect the live catalog), KV / dedicated-table state is plugin-managed and must survive toggle cycles. See `spec/plugin-kv-api.md` and `spec/db-schema.md` for the contract.
|
|
87
|
+
|
|
88
|
+
**Spec changes** (`@skill-map/spec` minor — new method on `StoragePort.contributions`):
|
|
89
|
+
|
|
90
|
+
- `spec/architecture.md` § View contribution system → Persistence — catalog sweep now narrowed to "uninstalled-on-disk plugins, removed contributions"; eager-purge-on-disable documented as the primary path for disabled bundles.
|
|
91
|
+
- `spec/db-schema.md` § `scan_contributions` — same narrowing; new "Eager purge on disable" subsection describing `purgeByPlugin(pluginId, extensionId?)`.
|
|
92
|
+
- `spec/cli-contract.md` § Plugins — `sm plugins disable` row mentions the immediate purge.
|
|
93
|
+
- `spec/plugin-author-guide.md` § Plugin states — `disabled` row mentions the immediate purge.
|
|
94
|
+
- `spec/plugin-kv-api.md` § Backup and retention — clarifies the asymmetry between `scan_contributions` (purged) and KV / dedicated tables (preserved).
|
|
95
|
+
|
|
96
|
+
**Implementation** (`@skill-map/cli` patch):
|
|
97
|
+
|
|
98
|
+
- `src/kernel/adapters/sqlite/contributions.ts` — `purgeContributionsByPlugin(db, pluginId, extensionId?)` now optionally narrows by extension.
|
|
99
|
+
- `src/kernel/ports/storage.ts` — `StoragePort.contributions.purgeByPlugin(pluginId, extensionId?)` added to the contract.
|
|
100
|
+
- `src/kernel/adapters/sqlite/storage-adapter.ts` — wires the namespace method to the helper.
|
|
101
|
+
- `src/cli/commands/plugins.ts` — toggle base class calls the purge when `enabled === false`.
|
|
102
|
+
- `src/server/routes/plugins.ts` — `persistAndProject` calls the purge when `enabled === false`.
|
|
103
|
+
- `ui/src/app/components/settings-modal/settings-plugins.ts` — after a successful UI toggle, calls `CollectionLoaderService.load()` so the cached in-memory `node.contributions[]` is refreshed against the just-purged DB and the card chips disappear without the user pressing Refresh. The loader's existing `pendingRefresh` collapsing semantics handle back-to-back toggles cheaply.
|
|
104
|
+
|
|
105
|
+
**Tests**:
|
|
106
|
+
|
|
107
|
+
- `src/test/view-contributions.test.ts` — new unit test asserting `purgeByPlugin` narrows by `extensionId` when supplied.
|
|
108
|
+
- `src/test/plugins-cli.test.ts` — new end-to-end test asserting `sm plugins disable <id>` drops the plugin's `scan_contributions` rows while leaving unrelated plugin rows untouched.
|
|
109
|
+
- `ui/src/app/components/settings-modal/settings-plugins.spec.ts` — new test asserting the toggle handler calls `CollectionLoaderService.load()` so the card chips reflect the BFF purge. (The pre-existing `settings-plugins.spec.ts` suite is currently broken on `main` for unrelated reasons — `verifySemanticsOfNgModuleDef` Angular DI failure across 24 UI test files — but the new test is correctly written and will activate once that suite is fixed.)
|
|
110
|
+
|
|
111
|
+
## User-facing
|
|
112
|
+
|
|
113
|
+
Disabling a plugin now removes its card chips from the UI immediately. Previously the chips lingered until the next `sm scan`, making the toggle look broken.
|
|
114
|
+
|
|
115
|
+
- fe13254: Tighten the manifest `icon` grammar on `viewContributions[].icon` from "single emoji-or-PrimeIcons-bare-name" to a prefix-discriminated string with four explicit shapes. Greenfield migration: no compat shim, no `catalogCompat` bump, bare names now fail at manifest load.
|
|
116
|
+
|
|
117
|
+
**Spec (`@skill-map/spec`) — `view-slots.schema.json#/$defs/IconString`**
|
|
118
|
+
|
|
119
|
+
The `IconString` `$def` gains a `pattern` enforcing the new grammar and an updated `description`:
|
|
120
|
+
|
|
121
|
+
```
|
|
122
|
+
^(?:pi pi-[a-z0-9-]+|pi-[a-z0-9-]+|fa-(?:solid|regular|brands) fa-[a-z0-9-]+|fa-[a-z0-9-]+|[^a-zA-Z].*)$
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Four valid shapes:
|
|
126
|
+
|
|
127
|
+
1. **Emoji** — any value starting with a non-ASCII-letter codepoint (`'🔍'`, `'@'`) renders as text.
|
|
128
|
+
2. **PrimeIcons** — `'pi-foo'` or `'pi pi-foo'` (both accepted) → `<i class="pi pi-foo">`.
|
|
129
|
+
3. **FontAwesome explicit family** — `'fa-solid fa-foo'` / `'fa-regular fa-foo'` / `'fa-brands fa-foo'` → pass-through.
|
|
130
|
+
4. **FontAwesome shorthand** — `'fa-foo'` → defaults to `<i class="fa-solid fa-foo">`.
|
|
131
|
+
|
|
132
|
+
Bare class names without a `pi-` / `fa-` prefix (`'star-fill'`, `'search'`, `'arrow-down'`) are **rejected at manifest load with `invalid-manifest`**. Prose contract in `spec/view-slots.md` §Icon string and `spec/plugin-author-guide.md` (icon row of the field reference table + new "Icon string forms" subsection) updated to match. `spec/index.json` regenerated.
|
|
133
|
+
|
|
134
|
+
**Greenfield path — no shim, no version flag**
|
|
135
|
+
|
|
136
|
+
Per `AGENTS.md` `Greenfield = no schema versioning`: no released external plugin uses the bare-name shape (the built-ins are the only consumers and ship in the same repo), so we tighten the contract in place. No `catalogCompat` bump on the catalog, no migration step registered in `sm plugins upgrade`. The bare-name rejection is documented inline in `IconString.description`.
|
|
137
|
+
|
|
138
|
+
**Kernel (`@skill-map/cli`) — built-in migration**
|
|
139
|
+
|
|
140
|
+
Every built-in extractor / analyzer that declared a bare-name icon is rewritten to `pi-foo` so it passes the new pattern at load:
|
|
141
|
+
|
|
142
|
+
- `src/built-in-plugins/extractors/stability/index.ts` — `bolt`/`ban` → `pi-bolt`/`pi-ban`
|
|
143
|
+
- `src/built-in-plugins/extractors/tools-count/index.ts` — `wrench` → `pi-wrench`
|
|
144
|
+
- `src/built-in-plugins/extractors/slash/index.ts` — `arrow-down` → `pi-arrow-down`
|
|
145
|
+
- `src/built-in-plugins/extractors/at-directive/index.ts` — `arrow-down` → `pi-arrow-down`
|
|
146
|
+
- `src/built-in-plugins/extractors/markdown-link/index.ts` — `arrow-down` → `pi-arrow-down`
|
|
147
|
+
- `src/built-in-plugins/extractors/external-url-counter/index.ts` — `link` → `pi-link`
|
|
148
|
+
- `src/built-in-plugins/analyzers/broken-ref/index.ts` — `times-circle` ×3 → `pi-times-circle` (manifest `alert` + `chip` + runtime payload)
|
|
149
|
+
- `src/built-in-plugins/analyzers/unknown-field/index.ts` — `info-circle` ×3 → `pi-info-circle` (same shape)
|
|
150
|
+
- `src/built-in-plugins/analyzers/annotation-stale/index.ts` — `clock` → `pi-clock`
|
|
151
|
+
|
|
152
|
+
Sibling test assertions updated in lock-step (`stability.test.ts`, `tools-count.test.ts`, `broken-ref.test.ts`, `unknown-field.test.ts`, `annotation-stale.test.ts`).
|
|
153
|
+
|
|
154
|
+
**UI — resolver + rename**
|
|
155
|
+
|
|
156
|
+
The shared icon component is renamed and the inline resolver pulled out into a pure function:
|
|
157
|
+
|
|
158
|
+
- `ui/src/app/slots/icon-glyph.ts` → DELETED.
|
|
159
|
+
- `ui/src/app/slots/icon.ts` — new file: exports `resolveIcon(raw: string | undefined): TResolvedIcon | null` (pure, no Angular deps) and the `Icon` component (`selector: 'sm-icon'`). The resolver routes on the same prefix grammar the AJV pattern enforces (emoji / `pi-foo` / `pi pi-foo` / `fa-{family} fa-foo` / `fa-foo`); unknown shapes return `null`, which renders nothing and emits a `console.warn` naming the offending value (covers runtime corruption from a legacy persisted row or a hand-edited sidecar that bypassed the load-time AJV gate). Template emits `<span>` for emoji and `<i class="<resolved cls>">` for `pi` / `fa`; the same 1px `transform: translateY` nudge from the previous `IconGlyph` survives unchanged.
|
|
160
|
+
- `ui/src/app/slots/icon.spec.ts` — new spec, 21 vitest tests over the branch matrix: empty / nullish input, emoji (single + ZWJ + ASCII punctuation), PrimeIcons shorthand + full class, FontAwesome explicit family (solid / regular / brands), FontAwesome shorthand, and rejected inputs (bare names, family-only, missing space, uppercase prefix, trim semantics). Pure function tested directly — no TestBed, because the existing TestBed setup is broken upstream of this work.
|
|
161
|
+
- `ui/src/app/renderers/{node-counter,node-icon,node-alert,scope-stat}/*.ts` — import + selector update: `IconGlyph` → `Icon`, `<sm-icon-glyph>` → `<sm-icon>`. No template logic changed.
|
|
162
|
+
|
|
163
|
+
**Why one commit**
|
|
164
|
+
|
|
165
|
+
The spec, built-ins, and UI changes form one contract change. Splitting puts the spec ahead of the built-ins (AJV would reject every built-in manifest at load) or the UI ahead of the spec (UI would resolve shapes the spec hasn't sanctioned yet). Single commit keeps the tree green at every hash.
|
|
166
|
+
|
|
167
|
+
**Verification**
|
|
168
|
+
|
|
169
|
+
- `npm test` in `src/` → 1333/1333 pass (every built-in test asserts the new `pi-foo` shape).
|
|
170
|
+
- `npx vitest run src/app/slots/icon.spec.ts` in `ui/` → 21/21 pass.
|
|
171
|
+
- `npx tsc --noEmit -p tsconfig.app.json` in `ui/` → exit 0 (renamed selector + import wired through every renderer).
|
|
172
|
+
- `npm run validate --workspace=@skill-map/spec` → spec OK, integrity OK.
|
|
173
|
+
|
|
174
|
+
## User-facing
|
|
175
|
+
|
|
176
|
+
**Plugin manifest icons are now prefix-discriminated.** Use `pi-foo` (PrimeIcons), `fa-solid fa-foo` / `fa-regular fa-foo` / `fa-brands fa-foo` (FontAwesome), `fa-foo` shorthand (defaults to solid), or any emoji. Bare names like `"search"` are rejected at load.
|
|
177
|
+
|
|
178
|
+
- 4f89a84: Plugin toggles in the Settings modal now apply at the next scan instead of needing an `sm serve` restart. The "Restart required" banner is gone for the common case; only plugins that were disabled at server boot keep a per-row warning because their handlers were never loaded into memory.
|
|
179
|
+
|
|
180
|
+
**Two issues addressed:**
|
|
181
|
+
|
|
182
|
+
1. **Latent bug — `POST /api/scan` ignored mid-session toggles.** `runScanForCommand` reused the BFF's boot-cached `pluginRuntime.resolveEnabled`. A user who disabled a plugin and pressed the topbar refresh saw the plugin's contributions reappear. The watcher had the same problem on every chokidar batch (it loads its own bundle once at boot).
|
|
183
|
+
2. **No way to cancel.** Each toggle wrote to `config_plugins` immediately and purged `scan_contributions`. Five quick toggles meant five DB round-trips and five purges even if the net state was unchanged.
|
|
184
|
+
|
|
185
|
+
**Approach** — four layered changes:
|
|
186
|
+
|
|
187
|
+
- **Fresh resolver per scan.** `composeScanExtensions` / `composeFormatters` / `registerEnabledExtensions` now accept an optional `resolveEnabled` override. The BFF's `POST /api/scan` and the watcher's per-batch loop build a fresh resolver from `config_plugins` via the shared `core/runtime/fresh-resolver.ts` helper before composing extensions, so a toggle made mid-session is honoured on the next scan without restarting the server. Plugin user extensions are now filtered by the same resolver (previously only built-ins were filtered) so disabling a previously-enabled drop-in plugin actually silences it.
|
|
188
|
+
- **Boot-time registries cover every built-in.** `kindRegistry` and `contributionsRegistry` (the catalogs embedded in every payload-bearing envelope) used to be seeded from the boot-time `composeScanExtensions(...)` result, which excluded any built-in that started disabled. Re-enabling such a built-in mid-session left its kinds / footer icons unrenderable because the UI's lookup tables never knew about them. Both registries now seed unconditionally from every built-in declaration (their module code is always in memory via `built-in-bundles.ts`); the enabled / disabled axis stays enforced at scan-time by the fresh resolver. Drop-in user plugins still respect boot-time filtering at the registry level — their modules weren't imported and aren't reachable mid-session (the `startsAsDisabled` exception below).
|
|
189
|
+
- **Bulk endpoint `PATCH /api/plugins`.** Body `{ "changes": [{ id, enabled }, ...] }`. Validates the entire batch up-front (404 / 400 / 403 with `error.details.id` pointing at the offending entry); applies in one SQLite transaction with one grouped contributions purge. The per-id `PATCH /api/plugins/:id` and qualified-id sibling stay available for CLI / external automation.
|
|
190
|
+
- **Buffered Settings modal.** Toggles mutate an in-memory `pendingState` only; rows show a dirty dot, a "N unsaved changes" banner appears above the list, and the footer exposes `[Discard] [Apply]` plus an italic warning when the dirty set re-enables a `startsAsDisabled` plugin. Closing the modal with pending edits opens a confirm dialog (`Discard` / `Keep editing` / `Apply`). Apply ships the bulk PATCH and triggers a scan via the new shared `ScanTriggerService`. A successful apply emits the panel's `applied` output, which the modal host translates into `visibleChange(false)` so the dialog closes once the work is done; a failed apply keeps the modal open with the error visible.
|
|
191
|
+
|
|
192
|
+
**`startsAsDisabled` wire flag.** `GET /api/plugins` rows now carry `startsAsDisabled?: boolean` for drop-in plugins whose discovery-time `status === 'disabled'`. The SPA renders a per-row hint when the user re-enables such a row, since those plugins' handlers were never loaded into memory at boot and re-engaging needs an `sm serve` restart. Built-ins always omit the flag (their handlers are statically known).
|
|
193
|
+
|
|
194
|
+
**Spec changes** (`@skill-map/spec` minor):
|
|
195
|
+
|
|
196
|
+
- `spec/cli-contract.md` § `GET /api/plugins` — adds `startsAsDisabled?: boolean` to the item shape.
|
|
197
|
+
- `spec/cli-contract.md` § `PATCH /api/plugins/:id` and the qualified-id sibling — "Restart required" is gone; replaced by an "Apply window" sentence documenting the per-scan-fresh-resolver behaviour and the `startsAsDisabled` exception.
|
|
198
|
+
- `spec/cli-contract.md` § Endpoints — new `PATCH /api/plugins` row documenting the bulk endpoint (body, error mapping, transactional semantics).
|
|
199
|
+
- `spec/cli-contract.md` § Error code sources — `not-found` / `bad-query` / `locked` rows updated to mention the bulk endpoint's `error.details.id` payload.
|
|
200
|
+
- `spec/cli-contract.md` § `kindRegistry` envelope field — clarifies that built-in Providers are listed unconditionally regardless of boot-time enabled state, and adds a parallel `contributionsRegistry` envelope-field section with the same discipline.
|
|
201
|
+
|
|
202
|
+
**Implementation** (`@skill-map/cli` minor):
|
|
203
|
+
|
|
204
|
+
- `src/core/runtime/fresh-resolver.ts` — **NEW**. Shared `buildFreshResolver` + `composeResolver` helpers used by `routes/plugins.ts`, `routes/scan.ts`, and `core/watcher/runtime.ts`.
|
|
205
|
+
- `src/core/runtime/plugin-runtime.ts` — `composeScanExtensions`, `composeFormatters`, `registerEnabledExtensions` accept `resolveEnabled?`; user-plugin extensions, manifests, annotation contributions, and view contributions are filtered by the resolver.
|
|
206
|
+
- `src/core/runtime/scan-runner.ts` — `IScanRunOpts.resolveEnabledOverride?` threaded into the compose call.
|
|
207
|
+
- `src/server/routes/scan.ts` — builds the fresh resolver per `POST` / `?fresh=1`.
|
|
208
|
+
- `src/server/routes/plugins.ts` — new `PATCH /api/plugins` bulk handler with `validateBulkChange` + `persistBulkAndProject`; `IPluginListItem` gains `startsAsDisabled`; `applyChangeToAdapter` shared between single-id and bulk paths.
|
|
209
|
+
- `src/server/index.ts` — `assembleBootBundle` seeds the `kindRegistry` from every built-in Provider (new `collectBuiltInProviders` helper) and `mergeBuiltInViewContributions` now walks `builtInBundles` directly instead of the composed scan extension set, so both registries cover the full built-in surface regardless of boot-time enabled state.
|
|
210
|
+
- `src/core/watcher/runtime.ts` — fresh resolver built per chokidar batch.
|
|
211
|
+
- `ui/src/app/services/scan-trigger.ts` — **NEW**. Owns the manual-scan trigger (in-flight signal, `dataSource.runScan()` + `loader.load()`). Consumed by `App` and `SettingsPlugins`.
|
|
212
|
+
- `ui/src/services/data-source/{port,rest-data-source,static-data-source}.ts` — new `applyPluginChanges(changes)` method.
|
|
213
|
+
- `ui/src/app/components/settings-modal/settings-plugins.ts/.html/.css` — buffered state (`originalState` / `pendingState`), dirty markers, `[Discard] [Apply]` footer, per-row + footer italic `startsAsDisabled` hints, removal of the persistent "Restart required" banner, `applied` output for parent-driven close. Two-zone layout (`.settings-plugins__scroll` + footer outside the scroll container) so the footer doesn't expose scroll-through gaps.
|
|
214
|
+
- `ui/src/app/components/settings-modal/settings-modal.ts/.html` — intercepts dialog close; opens `<p-confirmDialog>` with three actions when pending edits exist; bridges the panel's `applied` event to `visibleChange(false)` so footer Apply also closes.
|
|
215
|
+
|
|
216
|
+
**Tests**:
|
|
217
|
+
|
|
218
|
+
- `src/test/server-endpoints.test.ts` — new bulk PATCH suite (happy path, partial-failure, lock, body shape errors, `db-missing`) + a regression test asserting that `POST /api/scan` no longer re-populates a freshly-disabled plugin's contributions.
|
|
219
|
+
|
|
220
|
+
## User-facing
|
|
221
|
+
|
|
222
|
+
Plugin toggles in Settings now stage edits in the modal — click Apply (or confirm at close) to commit and refresh the graph; X discards. Changes apply on the next scan, no `sm serve` restart needed (except plugins disabled at boot, marked per-row).
|
|
223
|
+
|
|
224
|
+
- b840302: Rename the view slot `card.footer.left.counter` to `card.footer.left`.
|
|
225
|
+
|
|
226
|
+
After the `card.footer.left.tag` sub-slot was dropped (see prior CHANGELOGs), the counter became the only shape on the left footer of the card. The `.counter` suffix was a leftover of the dual-shape sub-slot scheme — the slot is now symmetrical with `card.footer.right` and consistent with the bare-base names used for `card.title.right` and `card.subtitle.left`.
|
|
227
|
+
|
|
228
|
+
**Wire format (breaking)**
|
|
229
|
+
|
|
230
|
+
- The `SlotName` enum in `spec/schemas/view-slots.schema.json` lists `card.footer.left` instead of `card.footer.left.counter`. The `$defs.payloads` map and the `IViewContribution.allOf` icon-required guard are updated to match.
|
|
231
|
+
- Plugin manifests that declare `viewContributions[*].slot: 'card.footer.left.counter'` need to update the literal to `'card.footer.left'`. Greenfield rename: no compatibility shim, no `catalogCompat` bump (no released external plugin uses this slot).
|
|
232
|
+
|
|
233
|
+
**Kernel + built-ins (breaking)**
|
|
234
|
+
|
|
235
|
+
- TypeScript: `TSlotName` in `src/kernel/types/view-catalog.ts` and the `KNOWN_SLOTS` set in `src/kernel/adapters/schema-validators.ts` now use `'card.footer.left'`. The `unknown-slot` analyzer's catalog mirror is updated.
|
|
236
|
+
- Built-in extractors: `at-directive`, `markdown-link`, and `slash` now declare `slot: 'card.footer.left'` in their `viewContributions.count` entry.
|
|
237
|
+
- Scaffolder: the `VIEW_SLOTS_CATALOG` array and the `plugins create` stub default in `src/cli/commands/plugins.ts` emit `card.footer.left`. Help / tip text updated.
|
|
238
|
+
|
|
239
|
+
**UI**
|
|
240
|
+
|
|
241
|
+
- `ui/src/app/slots/slot-config.ts` — `TSlotId` and `SLOT_REGISTRY` rekeyed.
|
|
242
|
+
- `ui/src/app/slots/slot-renderer-map.ts` — renderer mapping rekeyed.
|
|
243
|
+
- `ui/src/app/components/node-card/node-card.html` — debug-slot data attribute and host slot literal renamed.
|
|
244
|
+
- `ui/src/app/debug-slots.css` — debug-outline selector renamed.
|
|
245
|
+
|
|
246
|
+
**Migration**
|
|
247
|
+
|
|
248
|
+
User plugins (when any exist outside this repo) update the literal in their `plugin.json#/viewContributions[*]/slot` field. The doctor verb (`sm plugins doctor`) flags the old name as `unknown-slot` after upgrading.
|
|
249
|
+
|
|
250
|
+
## User-facing
|
|
251
|
+
|
|
252
|
+
The view slot `card.footer.left.counter` was renamed to `card.footer.left` — symmetrical with `card.footer.right`. Plugin authors using the old literal in `plugin.json` need to update it; the scaffolder emits the new name automatically.
|
|
253
|
+
|
|
254
|
+
- a96c257: Add a per-project consent gate for `.sm` sidecar writes, generalise the "privacy-sensitive, must not be committed" idea to a closed set of project-local-only keys, and cache config on the daemon so repeated reads in `sm serve` no longer re-walk six file layers.
|
|
255
|
+
|
|
256
|
+
**Per-key locality — new `PROJECT_LOCAL_ONLY_KEYS` set**
|
|
257
|
+
|
|
258
|
+
Four config keys are now classified as **project-local only**: `allowEditSmFiles` (new), `scan.includeHome`, `scan.extraRoots`, `scan.referencePaths`. Valid layers for these values are `defaults`, `user`, `user-local`, `project-local`, `override`. **The committed `project` layer (`<cwd>/.skill-map/settings.json`) is forbidden** — values found there are stripped (with a warning) at load time. `writeConfigValue(...)` with `target: 'project'` for any of the four throws `ProjectLocalOnlyKeyError`.
|
|
259
|
+
|
|
260
|
+
Sister concept to the existing `USER_ONLY_KEYS` (still scoped to `updateCheck.enabled`):
|
|
261
|
+
|
|
262
|
+
| Set | Valid layers | Forbidden layer(s) |
|
|
263
|
+
| ------------------------- | ------------------------------------------------------------- | -------------------------- |
|
|
264
|
+
| `USER_ONLY_KEYS` | `defaults`, `user`, `user-local`, `override` | `project`, `project-local` |
|
|
265
|
+
| `PROJECT_LOCAL_ONLY_KEYS` | `defaults`, `user`, `user-local`, `project-local`, `override` | `project` |
|
|
266
|
+
|
|
267
|
+
Enforcement lives in `src/kernel/config/loader.ts` (loader-side strip + warning) and `src/core/config/helper.ts` (writer-side reject). The schema stays additive — older installs that wrote one of these keys to `settings.json` keep validating; the value is silently dropped at read time and the warning surfaces via `sm config show --source`.
|
|
268
|
+
|
|
269
|
+
**Sidecar write consent (`allowEditSmFiles`)**
|
|
270
|
+
|
|
271
|
+
Every `.sm` write — scaffold (`sm sidecar annotate`), hash-only refresh (`sm sidecar refresh`), bump (`sm bump`, `POST /api/sidecar/bump`) — now flows through `FilesystemSidecarStore.applyPatch`, the **single chokepoint** for sidecar writes. `applyPatch` consults `allowEditSmFiles` (default `false`) via `ensureSidecarWritesAllowed` before touching disk:
|
|
272
|
+
|
|
273
|
+
- `true` → write proceeds.
|
|
274
|
+
- `false` AND caller passes `confirm: true` (CLI `--yes` / BFF `{ "confirm": true }` body) → kernel persists `allowEditSmFiles: true` to `.skill-map/settings.local.json` and performs the write.
|
|
275
|
+
- `false` AND no confirm → `EConsentRequiredError`. CLI on TTY prompts via the existing `confirm()` util; CLI without TTY exits 2 with a hint; BFF returns 412 `confirm-required` with `details: { key: 'allowEditSmFiles' }` so the UI can open a `ConfirmationService` dialog.
|
|
276
|
+
|
|
277
|
+
Decline never persists — the next attempt re-asks. The flag lives in `project-local` (gitignored) so each collaborator consents independently.
|
|
278
|
+
|
|
279
|
+
`sm sidecar annotate` was the one writer that bypassed the store (direct `writeFileSync`); it's now refactored to route through `FilesystemSidecarStore.applyPatch` so the gate is impossible to bypass. The "exists + !force" UX check stays at the command level (preserves the legacy refusal semantics).
|
|
280
|
+
|
|
281
|
+
**Daemon config cache (`ConfigService`)**
|
|
282
|
+
|
|
283
|
+
New `src/core/config/service.ts` exposes a lazy, reloadable wrapper around `loadConfig()`. The Hono server instantiates one at boot and threads it through `IRouteDeps`; routes consume `deps.configService.get()` / `.effective()` instead of calling `loadConfig` per request. Mutating routes (`PATCH /api/project-preferences`, future config writers) call `.reload()` after a successful write so the next read sees the new state.
|
|
284
|
+
|
|
285
|
+
The watcher already had its own per-batch reload pattern (`core/watcher/runtime.ts:320-326`); the daemon now shares the same principle via a single service. CLI verbs remain stateless (short-lived process; caching adds no value).
|
|
286
|
+
|
|
287
|
+
**`project-preferences` route persistence target switched to `project-local`**
|
|
288
|
+
|
|
289
|
+
With `scan.includeHome` / `scan.extraRoots` / `scan.referencePaths` joining `PROJECT_LOCAL_ONLY_KEYS`, the PATCH route now writes to `target: 'project-local'` (`<cwd>/.skill-map/settings.local.json`). The existing 412 `confirm-required` privacy gate (for writes that EXPAND the disk-access surface) is unchanged.
|
|
290
|
+
|
|
291
|
+
**New spec sections**
|
|
292
|
+
|
|
293
|
+
- `architecture.md` §IO discipline — plugins (Provider / Extractor / Analyzer / Action / Formatter / Hook) are pure: they consume context and emit data via returns or `ctx.*` callbacks. They MUST NOT write to the filesystem. All materialisation flows through kernel Ports. The consent gate at the kernel boundary is sufficient precisely because no extension has the means to write.
|
|
294
|
+
- `architecture.md` §Config layering — explicit table of the six layers + the two locality sets (`USER_ONLY_KEYS`, `PROJECT_LOCAL_ONLY_KEYS`) with members and enforcement semantics.
|
|
295
|
+
- `architecture.md` §Annotation system · Write consent — the consent flow normatively documented.
|
|
296
|
+
- `cli-contract.md` §`.sm` write consent — describes the CLI / BFF surfaces; `cli-contract.md` §Project-local-only config — describes `sm config set` behaviour for the four keys.
|
|
297
|
+
- `schemas/project-config.schema.json` — new `allowEditSmFiles` boolean (default `false`); the three privacy-sensitive scan keys' descriptions updated to flag PROJECT_LOCAL_ONLY membership and stripping behaviour.
|
|
298
|
+
|
|
299
|
+
**Tests**
|
|
300
|
+
|
|
301
|
+
- New: `src/test/sidecar-consent.test.ts`, `src/test/config-service.test.ts`, `ui/src/services/sidecar.spec.ts` (3 new cases), `ui/src/app/views/inspector-view/inspector-view.spec.ts` (4 new cases).
|
|
302
|
+
- Extended: `src/test/config-loader.test.ts` (locality stripping), `src/test/config-helper.test.ts` (PROJECT_LOCAL_ONLY guards), `src/test/sidecar-store.test.ts` (consent gate), `src/test/bump-action.test.ts`, `src/test/bump-cli.test.ts`, `src/test/sidecar-cli.test.ts`, `src/test/server-sidecar-endpoint.test.ts`, `src/test/project-preferences-route.test.ts`.
|
|
303
|
+
- `npm test` (src) — 1302 / 1302 green. `npm test -w ui` — 320 pass (3 pre-existing failures in `node-card.spec.ts` from a prior commit, unrelated).
|
|
304
|
+
|
|
305
|
+
## User-facing
|
|
306
|
+
|
|
307
|
+
Skill-map asks before creating `.sm` sidecars. Pass `--yes` (CLI) or accept the dialog (UI); your consent saves to `.skill-map/settings.local.json` (gitignored). Privacy scan paths (`scan.includeHome`, etc.) no longer load from committed `settings.json`.
|
|
308
|
+
|
|
3
309
|
## 0.20.0
|
|
4
310
|
|
|
5
311
|
### Minor Changes
|
package/architecture.md
CHANGED
|
@@ -72,7 +72,7 @@ The loader enforces two id-uniqueness analyzers during discovery (see [`plugin-a
|
|
|
72
72
|
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
73
|
2. **Cross-root id collision blocks both sides.** Two plugins reachable from different roots (project + global, or 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
74
|
|
|
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`) 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.
|
|
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.
|
|
76
76
|
|
|
77
77
|
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
78
|
|
|
@@ -173,13 +173,25 @@ Six kinds, all first-class, all loaded through the same registry. Each kind has
|
|
|
173
173
|
|
|
174
174
|
| Kind | Role | Input | Output |
|
|
175
175
|
|---|---|---|---|
|
|
176
|
-
| **Provider** | Recognizes a platform. Declares the catalog of node `kind`s it emits via the `kinds` map; each map entry pairs the kind's frontmatter schema (path relative to the Provider's package directory) with its `defaultRefreshAction` (a qualified action id that drives the probabilistic-refresh surface).
|
|
176
|
+
| **Provider** | Recognizes a platform. Declares the catalog of node `kind`s it emits via the `kinds` map; each map entry pairs the kind's frontmatter schema (path relative to the Provider's package directory) with its `defaultRefreshAction` (a qualified action id that drives the probabilistic-refresh surface). The Provider's walker hardcodes the paths it scans within the project (e.g. `.claude/`, `.gemini/`); it does NOT extend the scan into the user's HOME. Deterministic-only. | Filesystem walk results, candidate path. | `{ kind, provider } \| null`. |
|
|
177
177
|
| **Extractor** | Extracts signals from a node body. Deterministic-only: runs synchronously inside `sm scan`. Output flows through three context callbacks (no return value): `ctx.emitLink(link)` for the kernel's `links` table, `ctx.enrichNode(partial)` for the kernel's enrichment layer (separate from the author's frontmatter), `ctx.store` for the plugin's own KV / dedicated tables. | Parsed node (frontmatter + body) + callbacks. | `void` (output via callbacks). |
|
|
178
178
|
| **Analyzer** | Evaluates the graph. Dual-mode: `deterministic` runs in `sm check`, `probabilistic` runs in jobs. | Full graph (nodes + links). | `Issue[]`. |
|
|
179
179
|
| **Action** | Operates on one or more nodes. Dual-mode: `deterministic` (in-process code) or `probabilistic` (rendered prompt the runner executes). | Node(s), optional args. | Deterministic: report JSON. Probabilistic: rendered prompt that a runner executes. |
|
|
180
180
|
| **Formatter** | Serializes the graph. Deterministic-only. | Graph + optional filter. | String (ASCII / Mermaid / DOT / JSON / user-defined). |
|
|
181
181
|
| **Hook** | Reacts declaratively to one of ten curated lifecycle events — eight pipeline-driven (`scan.started`, `scan.completed`, `extractor.completed`, `analyzer.completed`, `action.completed`, `job.spawning`, `job.completed`, `job.failed`) plus two CLI-process-driven (`boot` before verb routing, `shutdown` after the verb's exit code resolves). Dual-mode: `deterministic` runs in-process during the dispatch, `probabilistic` is enqueued as a job. Hooks REACT to events; they cannot block, mutate, or steer the pipeline. | A curated event payload (run-scoped, scan-scoped, job-scoped, or process-scoped) plus an optional declarative `filter` map. | `void` (reactions are side effects). |
|
|
182
182
|
|
|
183
|
+
### IO discipline — extensions never write to the filesystem
|
|
184
|
+
|
|
185
|
+
Extensions (Provider / Extractor / Analyzer / Action / Formatter / Hook) are **pure**: they consume kernel-supplied context and emit data through return values or `ctx.*` callbacks. They MUST NOT perform filesystem writes directly — not via `fs.writeFile`, not via shell, not via a third-party library. Implementations MUST NOT expose any port that hands an extension a writable filesystem handle.
|
|
186
|
+
|
|
187
|
+
The materialisation of any kernel-managed artefact (the SQLite DB at `.skill-map/skill-map.db`, the `.sm` sidecars next to source files, the job ledger at `.skill-map/jobs/`, the `scan_extractor_runs` cache, the enrichment overlay rows) is the **kernel's** responsibility, gated through the relevant Port:
|
|
188
|
+
|
|
189
|
+
- Extractors persist via `ctx.emitLink` / `ctx.enrichNode` / `ctx.store` — never by writing files. `ctx.store` is plugin-scoped persistence routed through `StoragePort`; it cannot reach the project filesystem.
|
|
190
|
+
- Actions return either a deterministic report (JSON), a rendered prompt (probabilistic), or — for the small subset of actions that legitimately mutate persisted state — an explicit `TActionWrite` discriminated union the kernel interprets. The built-in `core/bump` is the only action that returns `{ kind: 'sidecar' }` today; the kernel routes that write through `SidecarStore.applyPatch`, which is the single gated chokepoint for all `.sm` writes (see §Annotation system · Write consent).
|
|
191
|
+
- Providers, Analyzers, Formatters, Hooks have no write surface at all.
|
|
192
|
+
|
|
193
|
+
This invariant is what makes the consent gate at the kernel boundary sufficient: no extension can bypass it because no extension has the means to write in the first place. Conformance: a third-party extension that imports `node:fs` write APIs (or equivalent in another language) is non-conforming.
|
|
194
|
+
|
|
183
195
|
### Provider · `kinds` catalog
|
|
184
196
|
|
|
185
197
|
Every `Provider` MUST declare a non-empty map `kinds: { <kind>: { schema, defaultRefreshAction, ui } }` covering every `kind` it classifies into. Each entry carries three required fields:
|
|
@@ -204,10 +216,6 @@ The `ui` block is required (not optional) by design: making it optional would fo
|
|
|
204
216
|
|
|
205
217
|
The kernel ships every Provider's `ui` block to the BFF at boot; the BFF aggregates them into a `kindRegistry` map and embeds it in every payload-bearing REST envelope (see [`cli-contract.md` §Server](./cli-contract.md#server)). The UI consumes `kindRegistry` directly — built-in and user-plugin kinds render identically.
|
|
206
218
|
|
|
207
|
-
### Provider · `explorationDir`
|
|
208
|
-
|
|
209
|
-
Every `Provider` extension MUST declare an `explorationDir: string` naming the filesystem directory (relative to user home or project root) where its content lives. Examples: `'~/.claude'` for the Claude Provider, `'~/.cursor'` for a hypothetical Cursor Provider. The kernel walks this directory during boot/scan to discover nodes; the Provider's `globs` (if declared) refines what to match inside. `sm doctor` (and `sm plugins doctor`) validates the directory exists; missing directory yields a non-blocking warning so the user sees the gap without the load failing — the Provider may legitimately precede installation of its platform.
|
|
210
|
-
|
|
211
219
|
### Provider · dispatch order and the universal markdown fallback
|
|
212
220
|
|
|
213
221
|
`sm scan` iterates Providers in **registration order** — vendor-specific Providers first (built-in: `claude` → `gemini` → `agent-skills`; user-installed plugins follow in load order), then the built-in `core/markdown` Provider LAST. Each Provider's walker enumerates the full project tree for its declared `read.extensions`; for every emitted file, the orchestrator calls `provider.classify(path, frontmatter)`. The kernel maintains a per-scan `Set<path>` of already-classified files so each path is offered to AT MOST one Provider's `classify`: the first Provider whose `classify` returns non-null claims the file, and subsequent Providers see the path as taken and skip.
|
|
@@ -262,7 +270,8 @@ The contract the cache MUST satisfy (engine-agnostic):
|
|
|
262
270
|
- A node-level cache hit (body+frontmatter unchanged) is upgraded to a full skip ONLY when every currently-registered Extractor that applies to the node's kind has a recorded run against the prior body hash.
|
|
263
271
|
- A new Extractor registered between scans MUST run on the cached node — its absence from the cache is the canonical signal. The rest of the cache (existing Extractors against the same body) is preserved.
|
|
264
272
|
- An Extractor uninstalled between scans MUST have its cache rows removed and its sole-source links dropped. Links whose `sources` mix the uninstalled Extractor's short id with a still-cached Extractor's short id MUST be reshaped: the obsolete short id is stripped from the array and the link survives with the cached attribution intact. The persisted audit trail therefore never references a removed contributor.
|
|
265
|
-
- The cache
|
|
273
|
+
- The cache key includes the canonical hash of `node.sidecar.annotations` alongside the body hash. A sidecar-only edit (`.sm` change without a `.md` change) invalidates the cached run for every Extractor that ran against that node. Universal invalidation is deliberate: an opt-in flag was considered and rejected because forgetting it produces a silent stale-data bug, while the cost of running every Extractor again on a `.sm` edit is negligible (sidecars change rarely, Extractors are pure-CPU). The hash uses a deterministic canonical form so a YAML re-format that does not change the annotation values does not invalidate the cache.
|
|
274
|
+
- The cache is otherwise transparent to plugin authors. An Extractor cannot opt out and cannot inspect the cache; its only obligation is to be deterministic for a given input (this is structural: every Extractor is deterministic-only, by spec).
|
|
266
275
|
|
|
267
276
|
The invariant exists to keep `sm scan --changed` cheap on real corpora: re-parsing a body that has not changed for an Extractor that has not changed is wasted work; the cache turns it into a one-row reuse. The same machinery is what will let a future Action-prob enrichment revision (see §Extractor · enrichment layer) reuse paid LLM output across unchanged bodies.
|
|
268
277
|
|
|
@@ -413,6 +422,42 @@ This is what makes "CLI-first" a coherent analyzer: every CLI verb is a kernel f
|
|
|
413
422
|
|
|
414
423
|
---
|
|
415
424
|
|
|
425
|
+
## Config layering
|
|
426
|
+
|
|
427
|
+
`.skill-map/settings.json` (and its `.local.json` partner) are loaded through a layered hierarchy. Implementations MUST evaluate the six layers in order (low → high precedence) and deep-merge per key:
|
|
428
|
+
|
|
429
|
+
| # | Layer | Source | Audience |
|
|
430
|
+
|---|---|---|---|
|
|
431
|
+
| 1 | `defaults` | Bundled `defaults.json` (ships in the CLI binary). | Every install. |
|
|
432
|
+
| 2 | `user` | `~/.skill-map/settings.json` | Per-machine, per-user; lives in `$HOME` (never in the repo). |
|
|
433
|
+
| 3 | `user-local` | `~/.skill-map/settings.local.json` | Same audience as `user`; intended for values the user might want to keep out of dotfile sync. |
|
|
434
|
+
| 4 | `project` | `<cwd>/.skill-map/settings.json` | **Committed to the repo** — values are shared with every collaborator and CI. |
|
|
435
|
+
| 5 | `project-local` | `<cwd>/.skill-map/settings.local.json` | **Gitignored** — values are per-checkout, never travel via the repo. |
|
|
436
|
+
| 6 | `override` | Caller-supplied (env vars, CLI flags). | Process-scoped, ephemeral. |
|
|
437
|
+
|
|
438
|
+
The merge is per dot-path: a value declared at a higher layer replaces the value at lower layers; objects recurse, arrays replace. The loader records which layer last wrote each key in a `sources` map so `sm config show --source` can attribute every effective value.
|
|
439
|
+
|
|
440
|
+
Layers 1, 2, 3, 5, 6 carry **per-user / per-machine state**. Only layer 4 (`project`) travels via the shared repo, so values landing in `project` are part of the contract every collaborator inherits.
|
|
441
|
+
|
|
442
|
+
### Per-key locality
|
|
443
|
+
|
|
444
|
+
Two locality classes constrain which layers a given key MAY live in. Both are enforced in code (reference impl: `core/config/helper.ts`), not in the JSON Schema — the schema stays additive so older settings files keep validating even when a key is reclassified.
|
|
445
|
+
|
|
446
|
+
- **`USER_ONLY_KEYS`** — keys describing per-user preferences that have no project meaning. Read forces `scope: 'global'` (project layers ignored); write rejects `target: 'project'` with a directed error. Today: `updateCheck.enabled`.
|
|
447
|
+
|
|
448
|
+
- **`PROJECT_LOCAL_ONLY_KEYS`** — keys describing per-user-per-project preferences. Valid in layers 1, 2, 3, 5, 6. **Stripped (with a warning) from layer 4 (`project`)** because the value is inherently per-user and must not be shared via the committed repo. Writes target `project-local` (`<cwd>/.skill-map/settings.local.json`); `sm config set` rejects `--scope project` for these keys.
|
|
449
|
+
|
|
450
|
+
Members:
|
|
451
|
+
- `allowEditSmFiles` — per-project consent to create / modify `.sm` sidecars.
|
|
452
|
+
- `scan.extraFolders` — additional scan paths (the ONLY way to extend the scan beyond the project root).
|
|
453
|
+
- `scan.referencePaths` — additional link-validation paths.
|
|
454
|
+
|
|
455
|
+
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.
|
|
456
|
+
|
|
457
|
+
Adding a new entry to either set is a behaviour change for older installs that wrote the key into a committed file — the value gets stripped (PROJECT_LOCAL_ONLY) or ignored (USER_ONLY) at read time. The changeset that adds the entry MUST document the migration.
|
|
458
|
+
|
|
459
|
+
---
|
|
460
|
+
|
|
416
461
|
## Annotation system
|
|
417
462
|
|
|
418
463
|
Skill-map's own metadata layer (versioning, supersession, provenance, taxonomy, docs) lives in **co-located YAML sidecars** with extension `.sm`, in the same directory as the markdown node they annotate. Vendor files (`.claude/agents/foo.md`, `.cursor/analyzers/bar.mdc`, …) stay untouched; the sidecar (`foo.sm` / `bar.sm`) IS skill-map's "annotations file" for that node — every key under it is, conceptually, an annotation. The YAML root organizes those annotations into structural blocks (identity, the curated annotations catalog, audit timestamps, settings, plugin namespaces); the file as a whole is the annotation surface.
|
|
@@ -446,6 +491,21 @@ The Action stays pure (no IO). The kernel materializes the patch through the `Si
|
|
|
446
491
|
- **Opt-in pre-commit hook**: `sm hooks install pre-commit-bump` writes a `.git/hooks/pre-commit` block that calls `sm bump --pending --staged --force` on commit. Idempotent reinstall via sentinel markers.
|
|
447
492
|
- **Watch mode**: never auto-bumps. Computes "stale" state on demand from hash comparison.
|
|
448
493
|
|
|
494
|
+
### Write consent
|
|
495
|
+
|
|
496
|
+
Every `.sm` write — scaffold (`sm sidecar annotate`), hash-only update (`sm sidecar refresh`), bump (`sm bump`, `POST /api/sidecar/bump`), or any future write surface — passes through `SidecarStore.applyPatch` (or, where the verb writes a fresh sidecar, the equivalent kernel-managed entry point). That single chokepoint MUST consult `allowEditSmFiles` (see §Config layering) before touching disk:
|
|
497
|
+
|
|
498
|
+
- `allowEditSmFiles === true` → write proceeds.
|
|
499
|
+
- `allowEditSmFiles === false` AND the caller passes `confirm: true` → the kernel persists `allowEditSmFiles: true` to `<cwd>/.skill-map/settings.local.json` (layer `project-local`) and then performs the write.
|
|
500
|
+
- `allowEditSmFiles === false` AND `confirm` is missing / false → the kernel raises `EConsentRequiredError`. The driving adapter MUST translate it into a surface-appropriate prompt:
|
|
501
|
+
- **CLI on a TTY**: interactive `confirm()` prompt. Accept re-invokes the verb with `confirm: true`; decline aborts without persisting the rejection.
|
|
502
|
+
- **CLI without a TTY** (CI, scripts): exit with the standard "user input required" code and a message hinting `--yes`.
|
|
503
|
+
- **BFF**: 412 `confirm-required` envelope (`{ ok: false, error: { code: 'confirm-required', message, details: { key: 'allowEditSmFiles' } } }`). The UI catches it, opens a confirm dialog, and on accept retries the original request with `{ confirm: true }`.
|
|
504
|
+
|
|
505
|
+
The rejection is **not persisted**. Declining the prompt aborts the current operation but the next attempt re-asks. This is deliberate: a "no" today should not foreclose a "yes" tomorrow without the user having to hand-edit the settings file.
|
|
506
|
+
|
|
507
|
+
The flag lives in `project-local` (gitignored) so each collaborator consents independently; a single contributor's "yes" never enrols their teammates without their knowledge.
|
|
508
|
+
|
|
449
509
|
### Plugin contributions
|
|
450
510
|
|
|
451
511
|
Plugins extend the annotation surface via the `annotationContributions` manifest field — a map of contributed key → `{ schema, ownership, location }`. Inline JSON Schema (no `$ref` to external files). Two location modes:
|
|
@@ -514,7 +574,7 @@ Sibling system to the annotation contributions above. Both let plugins extend th
|
|
|
514
574
|
|
|
515
575
|
Two schemas describe the wire shape:
|
|
516
576
|
|
|
517
|
-
- [`schemas/view-slots.schema.json`](./schemas/view-slots.schema.json) — closed catalog:
|
|
577
|
+
- [`schemas/view-slots.schema.json`](./schemas/view-slots.schema.json) — closed catalog: 14 slot names + the `IViewContribution` manifest declaration shape + per-slot payload schemas (in `$defs/payloads`) the kernel uses to validate emit-time payloads.
|
|
518
578
|
- [`schemas/input-types.schema.json`](./schemas/input-types.schema.json) — closed catalog: 10 input-type names + the `ISettingDeclaration` manifest declaration shape (discriminated by `type`).
|
|
519
579
|
|
|
520
580
|
### Identity
|
|
@@ -534,7 +594,7 @@ Each entry picks a `slot` name from the closed catalog and supplies presentation
|
|
|
534
594
|
"emptyText": "No matches."
|
|
535
595
|
},
|
|
536
596
|
"total": {
|
|
537
|
-
"slot": "card.footer.left
|
|
597
|
+
"slot": "card.footer.left",
|
|
538
598
|
"icon": "🔍",
|
|
539
599
|
"label": "kw",
|
|
540
600
|
"emitWhenEmpty": false
|
|
@@ -543,7 +603,7 @@ Each entry picks a `slot` name from the closed catalog and supplies presentation
|
|
|
543
603
|
}
|
|
544
604
|
```
|
|
545
605
|
|
|
546
|
-
The plugin author picks ONE slot per contribution; that single decision determines where the data renders, what payload shape `ctx.emitContribution(...)` must produce, and which Angular component draws it.
|
|
606
|
+
The plugin author picks ONE slot per contribution; that single decision determines where the data renders, what payload shape `ctx.emitContribution(...)` must produce, and which Angular component draws it. Seven manifest fields per contribution (`slot`, `label?`, `tooltip?`, `icon?`, `emptyText?`, `emitWhenEmpty?`, `priority?`) + the slot catalog page is the entire mental model. See [`plugin-author-guide.md`](./plugin-author-guide.md) §View contributions for worked examples.
|
|
547
607
|
|
|
548
608
|
### Settings
|
|
549
609
|
|
|
@@ -574,7 +634,7 @@ Parallel to `ctx.emitLink(link)`. The kernel buffers the emission, validates the
|
|
|
574
634
|
|
|
575
635
|
The Extractor-emit signature binds `nodePath` implicitly (the extractor runs per-node, with `ctx.node.path` available as the only sensible target). The Analyzer-emit signature requires the analyzer to declare the target node explicitly because Analyzers see the full graph at once and may emit for any subset of nodes — the canonical use case is a analyzer that derives per-node values from cross-graph aggregations (`core/link-counts` projects `linksOutCount` / `linksInCount` this way).
|
|
576
636
|
|
|
577
|
-
Analyzers MAY also emit scope-level contributions via `IAnalyzerContext.emitScopeContribution(contributionId, payload)` (only slots whose schema permits scope-level emission, today only `topbar.
|
|
637
|
+
Analyzers MAY also emit scope-level contributions via `IAnalyzerContext.emitScopeContribution(contributionId, payload)` (only slots whose schema permits scope-level emission, today only `topbar.nav.start`). That signature is reserved in the spec; the runtime callback lands when the first scope-level adopter arrives.
|
|
578
638
|
|
|
579
639
|
### Persistence
|
|
580
640
|
|
|
@@ -595,7 +655,7 @@ PK `(plugin_id, extension_id, node_path, contribution_id)` so re-emission upsert
|
|
|
595
655
|
**NOT pure replace-all** (the way `scan_links` / `scan_issues` are). The watcher's cached pass leaves the contributions buffer empty for cached nodes — the orchestrator skips `extract()` when the per-(node, extractor) cache hits, so no `emitContribution` fires. A naive wipe-all would silently drop the prior valid rows on every watcher boot. The persist runs four passes inside the same transaction:
|
|
596
656
|
|
|
597
657
|
1. **Orphan sweep** — drops every row whose `node_path` is NOT in the current live node set. Disappeared nodes lose their contributions.
|
|
598
|
-
2. **Catalog sweep** — drops every row whose qualified id `(pluginId, extensionId, contributionId)` is NOT in the registered runtime catalog (uninstalled plugins,
|
|
658
|
+
2. **Catalog sweep** — drops every row whose qualified id `(pluginId, extensionId, contributionId)` is NOT in the registered runtime catalog (uninstalled-on-disk plugins, removed contributions). Disabled bundles are normally purged eagerly by `sm plugins disable` (see `StoragePort.contributions.purgeByPlugin`); the catalog sweep here is the fallback for the rare "config flipped between scans without going through the CLI" case.
|
|
599
659
|
3. **Per-tuple sweep** — for every `(pluginId, extensionId, nodePath)` tuple where the extension actually RAN against that node in this scan (extractor cache miss, OR analyzer — analyzers always run), drop any row carrying that triple whose `contribution_id` is NOT present in the buffer for that triple. This catches the "extractor used to emit, now does not" case (e.g. a node body change that removes the trigger). Cached-extractor tuples are NOT in the set, so their rows survive untouched.
|
|
600
660
|
4. **Upsert** — `INSERT ... ON CONFLICT DO UPDATE SET payload_json = excluded.payload_json, slot = excluded.slot` for every row in the buffer. PK conflict refreshes payload + `slot` + `emitted_at`.
|
|
601
661
|
|
|
@@ -656,7 +716,7 @@ Pre-1.0 versioning analyzer (per [`AGENTS.md`](../AGENTS.md)): catalog breaking
|
|
|
656
716
|
|
|
657
717
|
The **closed catalog of view slots** is stable as of the v1 of this system: adding a new slot is a minor bump; renaming or removing one is a catalog-major bump and triggers `sm plugins upgrade` migration of every dependent plugin.
|
|
658
718
|
|
|
659
|
-
The **`IViewContribution` manifest shape** (
|
|
719
|
+
The **`IViewContribution` manifest shape** (seven fields: `slot`, `label?`, `tooltip?`, `icon?`, `emptyText?`, `emitWhenEmpty?`, `priority?`) is stable. Adding a new optional field is a minor bump; making a field required or removing one is a catalog-major bump.
|
|
660
720
|
|
|
661
721
|
The **closed catalog of input-types** is stable on the same model: adding minor, renaming/removing major.
|
|
662
722
|
|