@skill-map/spec 0.11.0 → 0.13.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,547 @@
1
1
  # Spec changelog
2
2
 
3
+ ## 0.13.0
4
+
5
+ ### Minor Changes
6
+
7
+ - e0fb57e: Step 14.2 — REST read-side endpoints + DataSource contract
8
+
9
+ Fills the `### Server` subsection's endpoint catalogue from the v14.1 stub
10
+ (`/api/health` real, `/api/*` 404) to the eight read-side endpoints the
11
+ Angular SPA at 14.3 will consume. New `spec/schemas/api/rest-envelope.schema.json`
12
+ formalises the list-envelope shape. Test totals 764 → 832 (+68).
13
+
14
+ **Files added (server)**
15
+
16
+ - `src/server/path-codec.ts` — `encodeNodePath` / `decodeNodePath`. Base64url (RFC 4648 §5, no padding). Mirrored at `ui/src/services/data-source/path-codec.ts` in 14.3.
17
+ - `src/server/envelope.ts` — list / single / value envelope builders. `REST_ENVELOPE_SCHEMA_VERSION = '1'`. Hardcoded to track `spec/schemas/api/rest-envelope.schema.json#/properties/schemaVersion/const`.
18
+ - `src/server/query-adapter.ts` — `urlParamsToExportQuery(params)` lifts URL search params into the kernel's `IExportQuery` via `parseExportQuery` (one grammar, two transports). `filterNodesWithoutIssues` post-filter handles `hasIssues=false` (the one filter the kernel grammar can't express).
19
+ - `src/server/routes/deps.ts` — shared `IRouteDeps` bag (`options`, `runtimeContext`).
20
+ - `src/server/routes/health.ts` — extracted from `app.ts` for symmetry with the other routes (no behavior change).
21
+ - `src/server/routes/scan.ts` — `/api/scan` + `/api/scan?fresh=1`. DB absent → returns the empty `ScanResult` shape (matches the `loadScanResult` synthetic fallback). `?fresh=1` rejects when the server was started with `--no-built-ins` or `--no-plugins`.
22
+ - `src/server/routes/nodes.ts` — `/api/nodes/:pathB64` (single) registered BEFORE `/api/nodes` (list) so the param doesn't shadow the literal prefix. Pagination defaults `offset=0`, `limit=100`; max `limit=1000`.
23
+ - `src/server/routes/links.ts` — `/api/links?kind=&from=&to=`.
24
+ - `src/server/routes/issues.ts` — `/api/issues?severity=&ruleId=&node=`. `ruleId` filter mirrors `sm check`'s qualified-or-suffix match.
25
+ - `src/server/routes/graph.ts` — `/api/graph?format=ascii|json|md`. Per-format content-type. Unknown format → `bad-query` 400.
26
+ - `src/server/routes/config.ts` — `/api/config`. Wraps `loadConfig` from the kernel. Layered-loader warnings forwarded to `process.stderr`.
27
+ - `src/server/routes/plugins.ts` — `/api/plugins`. Built-ins (gated by `noBuiltIns`) + drop-ins (gated by `noPlugins`). `source: 'built-in' | 'project' | 'global'` derived from the plugin's filesystem path against `defaultProjectPluginsDir`.
28
+
29
+ **Files edited (server)**
30
+
31
+ - `src/server/app.ts` — `IAppDeps` gains `runtimeContext` (mandatory). Routes registered via the new `routes/*` registrars BEFORE the `/api/*` 404 catch-all. `app.onError` extended to map `ExportQueryError` → 400 `bad-query` (alongside the existing HTTPException + uncaught-Error branches).
32
+ - `src/server/index.ts` — `createServer(options, extra?)` accepts an optional `extra.runtimeContext` so tests can drive against a tempdir scope; production callers (the `sm serve` verb) leave it undefined and the composition root falls back to `defaultRuntimeContext()`.
33
+ - `src/server/i18n/server.texts.ts` — adds error message templates: `dbMissingHint`, `freshScanRequiresPipeline`, `graphUnknownFormat`, `paginationLimitTooLarge`, `paginationInvalidInteger`, `nodeNotFound`, `pathB64Malformed`.
34
+
35
+ **Tests added (68)**
36
+
37
+ - `src/test/server-endpoints.test.ts` (24) — happy + error path per endpoint. Uses real `runScan` + `persistScanResult` against a `mkdtempSync` fixture (no `:memory:` per `feedback_sqlite_in_memory_workaround.md`).
38
+ - `src/test/server-pagination.test.ts` (10) — default page caps at 100, `?limit=1000` accepted, `?limit=1001` rejected, offset/limit boundaries, `?offset=-1` and `?offset=foo` rejected, offset past total returns empty + preserves total.
39
+ - `src/test/server-errors.test.ts` (8) — every `code` value maps to the documented HTTP status; canonical envelope shape on every error response.
40
+ - `src/test/server-query-adapter.test.ts` (16) — URL-param → IExportQuery matrix; `filterNodesWithoutIssues` post-filter behaviour.
41
+ - `src/test/server-path-codec.test.ts` (10) — round-trip on POSIX / unicode / spaces / very long paths; rejection of empty, non-alphabet, single-char inputs; uniqueness for distinct inputs.
42
+
43
+ **Spec**
44
+
45
+ - `spec/schemas/api/rest-envelope.schema.json` — new schema. `$id: https://skill-map.dev/spec/v0/api/rest-envelope.schema.json`. `oneOf` enforces that an envelope carries exactly one of `items` / `item` / `value` per kind (with sentinel kinds `health` / `scan` / `graph` reserved for routes that don't use the envelope).
46
+ - `spec/cli-contract.md` `### Server` — endpoint table expanded from 4 rows (v14.1 surface) to 12 rows (v14.2 surface) with full filters / status / shape per row. Error code source enumeration added (`not-found` / `bad-query` / `internal` / reserved `db-missing`). Stability stays `experimental — locks at v0.6.0`.
47
+ - `spec/CHANGELOG.md` `[Unreleased]` `### Minor` — entry for BFF endpoints + envelope schema.
48
+ - `spec/conformance/coverage.md` — row 25 added for `api/rest-envelope.schema.json` (status: 🔴 missing — implementation-side coverage exists in `src/test/server-endpoints.test.ts`; a kernel-agnostic conformance case is still required before v1.0.0 ships).
49
+ - `spec/index.json` — regenerated (40 → 41 files hashed).
50
+
51
+ **Decisions during implementation (flag for orchestrator)**
52
+
53
+ - The `db-missing` error code is kept in the documented enum but no v14.2 route currently emits it — `/api/scan` returns the empty `ScanResult` when the DB is absent, list routes return zero items, and `/api/health` already advertises `db: 'missing'`. Documented in the spec as "reserved for future endpoints (post-v0.6.0 mutations) where degradation is not safe". Removing the code would be a breaking change to the envelope contract; keeping it costs nothing.
54
+ - `ExportQueryError` from `parseExportQuery` is funneled to `bad-query` 400 via a new branch in `app.onError`. The brief listed it as a route-level concern; centralising in the global handler means future routes that go through the kernel grammar (e.g. a future `/api/export?q=...`) inherit the same envelope mapping for free.
55
+ - `urlParamsToExportQuery` builds a canonical raw query string and re-parses it through `parseExportQuery` instead of constructing `IExportQuery` directly. The extra parse is microseconds and guarantees the BFF and `sm export` can never drift on what counts as a valid filter token. When the grammar grows (e.g. `has=findings` post-Step 11), only `parseExportQuery` changes.
56
+ - `/api/scan?fresh=1` rejection on `--no-built-ins` / `--no-plugins` matches Decision §14.1's intent: the BFF surface should not silently produce empty results that look indistinguishable from "your project has no nodes". The `bad-query` envelope tells the operator they're holding a knife by the blade.
57
+ - Tests use `noPlugins: true` by default to keep them deterministic against `process.cwd()` — `loadPluginRuntime` walks the live cwd's plugins dir, which would surface ambient plugins from the test runner's host (none in CI today, but a developer running tests locally with their own plugins installed would see flake).
58
+ - The route registration order in `app.ts` is documented in the file's header comment. `/api/nodes/:pathB64` MUST register before `/api/nodes` (Hono matches in declaration order; the literal prefix wins otherwise).
59
+
60
+ - d5488bf: Step 14.4.a — BFF WS broadcaster + chokidar wiring + scan event emission
61
+
62
+ First half of Step 14.4 lands. The BFF's `/ws` endpoint flips from
63
+ "upgrade-only stub" to a real broadcaster fed by a chokidar
64
+ filesystem watcher: every debounced batch runs the same
65
+ `runScanWithRenames` + persistence pipeline `sm watch` uses, and the
66
+ kernel's `ProgressEmitterPort` is bridged directly to the broadcaster
67
+ so `scan.*` / `extractor.completed` / `rule.completed` / `extension.error`
68
+ events reach every connected client verbatim — no envelope
69
+ construction in the BFF for the routine cases. Tests 832 → 854 (+22).
70
+
71
+ The UI-side consumer (`WsEventStreamService`) ships separately as
72
+ 14.4.b.
73
+
74
+ **Files added (server)**
75
+
76
+ - `src/server/broadcaster.ts` — `WsBroadcaster` class. Owns the
77
+ connected-clients Set, fans `JSON.stringify(envelope)` once across
78
+ every open socket, evicts on backpressure (`bufferedAmount > 4 MiB`
79
+ → close 1009 + unregister), drains every client with code 1001 +
80
+ reason `'server shutdown'` on `shutdown()`. `IBroadcasterClient`
81
+ interface is structural so unit tests inject fakes without a real
82
+ `WebSocket`.
83
+ - `src/server/watcher.ts` — `createWatcherService(deps)` factory.
84
+ Wraps `createChokidarWatcher` with `scan.watch.debounceMs` from
85
+ config (override via `--watcher-debounce-ms`), runs the kernel scan
86
+ pipeline per debounced batch, persists via `withSqlite(...).scans.persist(...)`.
87
+ The per-batch `ProgressEmitterPort` bridges every event the kernel
88
+ orchestrator emits during the scan to `broadcaster.broadcast(envelope)`.
89
+ Per-batch failures log + continue (transient FS errors must not
90
+ kill the broadcaster); chokidar instance errors broadcast a
91
+ `watcher.error` advisory.
92
+ - `src/server/events.ts` — envelope helpers (`IWsEventEnvelope` shape,
93
+ `buildWatcherStartedEvent`, `buildWatcherErrorEvent`). The
94
+ `watcher.*` events are BFF-internal advisories — non-normative,
95
+ prefixed with `watcher.` to flag their non-spec status. Spec-mandated
96
+ shapes (`scan.*`, `extractor.completed`, `rule.completed`) are
97
+ forwarded verbatim from the kernel emitter, so this file does not
98
+ build them.
99
+
100
+ **Files added (tests)**
101
+
102
+ - `src/test/server-ws-broadcaster.test.ts` (15 tests) — broadcaster
103
+ unit tests against fake `IBroadcasterClient` instances. Coverage:
104
+ register/unregister/clientCount accounting, broadcast fan-out + JSON
105
+ stringify, readyState filter (skip closing/closed), per-client
106
+ `send()` failure isolation, backpressure eviction at the documented
107
+ threshold (`WS_BACKPRESSURE_BYTES = 4 MiB`), shutdown idempotency
108
+ - close-code/reason assertions, post-shutdown register immediate
109
+ close, post-shutdown broadcast no-op, circular-envelope serialization
110
+ failure handling.
111
+ - `src/test/server-ws-integration.test.ts` (7 tests) — end-to-end
112
+ against a real server. Boots `createServer({...})` with
113
+ `noWatcher: false`, watches a `mkdtempSync` cwd via the
114
+ `runtimeContext` override (production callers' cwd would point at the
115
+ test runner's repo root). Exercises: initial-batch `scan.completed`
116
+ observed by a connected client; multi-client fan-out (one batch fires
117
+ to two open clients); `clientCount` decrement on disconnect;
118
+ `handle.close()` shuts the watcher cleanly under 2s;
119
+ `validateServerOptions` rejects `--no-built-ins + watcher on`;
120
+ `--no-watcher` confirms no `scan.*` events fire.
121
+
122
+ **Files edited (server)**
123
+
124
+ - `src/server/ws.ts` — `noopWebSocketRoute(app)` deleted, replaced
125
+ with `attachBroadcasterRoute(app, broadcaster)`. Pulls the underlying
126
+ `ws` library `WebSocket` off `WSContext.raw` and registers it on
127
+ `onOpen`; unregisters on `onClose` / `onError`. Server-push only —
128
+ `onMessage` intentionally not registered at v14.4.a.
129
+ - `src/server/index.ts` — `createServer` composition root grows the
130
+ broadcaster + watcher lifecycle: instantiate `WsBroadcaster` →
131
+ build app (broadcaster threaded into `IAppDeps`) → bind listener →
132
+ start watcher (unless `--no-watcher`); `handle.close()` shuts in
133
+ order: `watcherService.stop()` → `broadcaster.shutdown()` → http
134
+ close → `wss.close()`. `ServerHandle` exposes the `broadcaster`
135
+ field for tests asserting `clientCount`.
136
+ - `src/server/app.ts` — `IAppDeps.attachWs: TWsRegistrar` removed;
137
+ replaced with `IAppDeps.broadcaster: WsBroadcaster`. The BFF wires
138
+ `attachBroadcasterRoute` directly inside `createApp` now (route
139
+ registrar pattern was the v14.1 scaffolding to allow swap-in at
140
+ v14.4 — that work is done, no need for the indirection).
141
+ - `src/server/options.ts` — adds `noWatcher: boolean` (default `false`
142
+ per Decision #121: a server with stale DB is a footgun) and
143
+ `watcherDebounceMs?: number` (override the config value).
144
+ Validator gains `watcher-requires-pipeline` (rejects
145
+ `--no-built-ins + watcher on` — would persist empty scans on every
146
+ batch) and `watcher-debounce-invalid` (non-integer / negative).
147
+ - `src/server/i18n/server.texts.ts` — eight new keys for watcher /
148
+ broadcaster lifecycle log lines.
149
+
150
+ **Files edited (CLI)**
151
+
152
+ - `src/cli/commands/serve.ts` — plumbs `--no-watcher` (documented) +
153
+ hidden `--watcher-debounce-ms` flag through to `IServerOptionsInput`.
154
+ - `src/cli/i18n/serve.texts.ts` — two new keys
155
+ (`watcherRequiresPipeline`, `watcherDebounceInvalid`).
156
+
157
+ **Files edited (tests)**
158
+
159
+ - `src/test/server-boot.test.ts` — the no-broadcaster-yet
160
+ close-1000-on-`onOpen` assertion is replaced with a "connection
161
+ stays open + registers" assertion. Default options grow
162
+ `noWatcher: true` (the watcher is exercised in the dedicated
163
+ integration file).
164
+ - `src/test/server-{db-missing,endpoints,errors,pagination}.test.ts`
165
+ — default options grow `noWatcher: true` so chokidar doesn't
166
+ subscribe to the test runner's cwd. No behavior change for these
167
+ tests; they exercise the REST surface, not the watcher.
168
+
169
+ **Spec**
170
+
171
+ - `spec/cli-contract.md` `### Server` — new **WebSocket protocol**
172
+ subsection. Documents the wire envelope (delegated to
173
+ `job-events.md` §Common envelope), the v14.4.a event catalog
174
+ (`scan.started` / `scan.progress` / `scan.completed` plus the
175
+ side-effect events `extractor.completed` / `rule.completed` /
176
+ `extension.error`, plus the BFF-internal advisories
177
+ `watcher.started` / `watcher.error`), the connection lifecycle
178
+ (no state push on connect; client polls `/api/scan` to seed; close
179
+ codes 1000 / 1001 / 1009), the backpressure rule, and the
180
+ loopback-only assumption (no per-connection auth through v0.6.0
181
+ per Decision #119). The endpoint table flips `GET /ws` from
182
+ `upgrade-only` to `implemented (v14.4.a)`. The `sm serve` flag
183
+ table grows `--no-watcher`. The verb-catalog row for `sm serve`
184
+ mirrors the new flag.
185
+ - `spec/CHANGELOG.md` `[Unreleased]` `### Minor` entry.
186
+ - `spec/index.json` — regenerated (41 files hashed; no schema added).
187
+
188
+ **ROADMAP.md** — bumped `Last updated`, marked Step 14.4.a landed
189
+ (14.4 carries an explicit (a/b) split now), 14.4.b still owes the
190
+ UI-side consumer. Earlier 14.3 prose pushed to "Earlier prose".
191
+
192
+ **Decisions taken inline (flag for orchestrator)**
193
+
194
+ - `issue.added` / `issue.resolved` (per `spec/job-events.md` §Issue
195
+ events line 446) **deferred to a follow-up**. The diff requires
196
+ comparing the new `ScanResult.issues` set against the prior
197
+ persisted snapshot; the watcher already loads the prior for the
198
+ rename heuristic, so the data is at hand, but the diff plumbing
199
+ (key derivation, set comparison, two emit calls per delta) is
200
+ enough material that it deserves its own brief. The 14.4.a surface
201
+ fans out exactly what the kernel emitter already produces.
202
+ - `scan.failed` **deferred to a follow-up**. The shape is not yet
203
+ locked in `spec/job-events.md` and would need a normative
204
+ addition. For 14.4.a, per-batch failures log via the kernel logger
205
+ and the watcher loop continues — same behavior as `sm watch`'s
206
+ `WATCH_TEXTS.batchFailed`.
207
+ - `scan.progress` **emitted, not throttled**. The kernel
208
+ orchestrator emits one event per node walked; on a small workspace
209
+ this is a handful of events per batch, on a large workspace it's
210
+ hundreds. The brief flagged throttling as optional at 14.4.a; the
211
+ bridge forwards verbatim today. The integration test observed 13
212
+ `scan.progress` events for a 4-file fixture, which is fine. A
213
+ throttle (250ms aggregation) is the obvious 14.6 polish if the
214
+ bundle / perf pass shows the fan-out swamping the channel.
215
+ - `watcher.started` / `watcher.error` BFF-internal advisories
216
+ **emitted** rather than silent. They give the SPA event-log a
217
+ clear "armed" signal and a surface for chokidar errors that don't
218
+ fit the spec's `scan.*` shape. Prefix marks them as non-normative;
219
+ consumers that follow the spec's "ignore unknown event types"
220
+ rule will not break.
221
+ - `IHealthResponse.watcher: 'on' | 'off'` **NOT added**. Keeping
222
+ the v14.2 health response shape stable was preferable to adding
223
+ one field for what tests / `--no-watcher` already cover. The
224
+ broadcaster's `clientCount` is exposed on `ServerHandle.broadcaster`
225
+ for test introspection without polluting the public health surface.
226
+ - The validator rejects `--no-built-ins + watcher on` because the
227
+ watcher would persist empty scans on every batch, silently wiping
228
+ the DB. `--no-plugins + watcher on` is OK (the built-in pipeline
229
+ is still complete on its own).
230
+ - `attachBroadcasterRoute` does NOT register `onMessage`. v14.4.a
231
+ is server-push only. A future client-initiated heartbeat / filter
232
+ request lands at 14.4.b or later.
233
+ - `WsBroadcaster` is a class (not a factory) per AGENTS.md
234
+ §Adapter wiring rule 5: factories scope to "adapters consumed via
235
+ ports", and the broadcaster is a plain BFF helper with no kernel
236
+ port to satisfy. The class is grandfathered no-`I*`-prefix per
237
+ §Type naming convention category 4.
238
+
239
+ **Smoke (live BFF, one-shot per AGENTS.md)**
240
+
241
+ The integration tests cover the live boot + WS upgrade + chokidar
242
+ batch + broadcast end-to-end against a `mkdtempSync` scope. The
243
+ diagnostic line `ws events received: scan.started, scan.progress
244
+ × 13, extractor.completed × 4, rule.completed × 5, scan.completed`
245
+ confirms the full event sequence reaches a connected client during
246
+ a real scan against a 4-file fixture.
247
+
248
+ - 4ff3f38: Step 14.5.d — Provider-driven kind presentation + envelope kindRegistry
249
+
250
+ Pre-1.0 minor breaking per `versioning.md` § Pre-1.0.
251
+
252
+ The Provider extension surface gains the required `kinds[*].ui` field
253
+ so each kind a Provider declares carries the presentation metadata the
254
+ UI needs to render it (label, base color, optional dark-theme color,
255
+ optional emoji, optional icon). The icon is a discriminated union —
256
+ `{ kind: 'pi'; id: 'pi-…' }` for PrimeIcons or `{ kind: 'svg'; path:
257
+ '…' }` for raw SVG path data. The UI derives `bg` / `fg` tints from
258
+ `color` per theme via a deterministic helper, so the Provider declares
259
+ one base color per theme rather than four hex values.
260
+
261
+ The REST envelope shape (`spec/schemas/api/rest-envelope.schema.json`)
262
+ gains a new required `kindRegistry` field on every payload-bearing
263
+ variant (`nodes` / `links` / `issues` / `plugins` / `node` / `config`);
264
+ sentinel envelopes (`health` / `scan` / `graph`) stay exempt. The
265
+ registry is keyed by kind name and carries `{ providerId, label,
266
+ color, colorDark?, emoji?, icon? }` — the BFF assembles it once at
267
+ boot from every enabled Provider and attaches it to every applicable
268
+ response so the UI can render Provider-declared kinds (built-in and
269
+ user-plugin alike) without hardcoding a closed kind enum. The change
270
+ keeps `schemaVersion` at `'1'` (greenfield — no released consumers
271
+ depend on the prior shape).
272
+
273
+ **Files edited (spec)**
274
+
275
+ - `spec/schemas/extensions/provider.schema.json` — adds `ui` to the
276
+ required field set on each `kinds[*]` entry, with discriminated
277
+ `oneOf` for `icon`.
278
+ - `spec/schemas/api/rest-envelope.schema.json` — new `kindRegistry`
279
+ definition; required on every payload-bearing variant; sentinel
280
+ variants explicitly forbid the field via `not.anyOf`. Version stays
281
+ at `'1'` (greenfield).
282
+ - `spec/CHANGELOG.md` — `[Unreleased]` `### Minor` entry.
283
+
284
+ **Files edited (kernel + built-in)**
285
+
286
+ - `src/kernel/extensions/provider.ts` — adds `IProviderKindUi` and
287
+ `IProviderKindIcon`; `ui` becomes required on `IProviderKind`.
288
+ - `src/built-in-plugins/providers/claude/index.ts` — every kind
289
+ (skill / agent / command / hook / note) declares its `ui` block
290
+ reusing the colors / labels / icons previously hardcoded in
291
+ `ui/src/styles.css`, `ui/src/i18n/kinds.texts.ts`, and
292
+ `ui/src/app/components/kind-icon/kind-icon.html`.
293
+ - `src/built-in-plugins/providers/claude/claude.test.ts` — new test
294
+ asserts every kind declares a well-formed `ui` block.
295
+ - `src/test/external-provider-kind.test.ts` — three mock providers
296
+ updated to declare `ui` on their `cursorRule` kinds.
297
+ - `src/test/plugins-cli.test.ts` — `dropMockProvider` helper template
298
+ declares `ui` on the inline mock `note` kind.
299
+
300
+ **Files added (conformance)**
301
+
302
+ - `spec/conformance/fixtures/plugin-missing-ui/` — drop-in Provider
303
+ fixture whose `kinds[*]` omits `ui` (plus a trivial `notes/example.md`
304
+ for the built-in Claude scan to grab).
305
+ - `spec/conformance/cases/plugin-missing-ui-rejected.json` — locks the
306
+ loader contract: `sm scan --json` exits 0, stderr matches
307
+ `plugin bad-provider:.*invalid.*must have required property 'ui'`,
308
+ the envelope still contains the built-in Claude provider, and the
309
+ one fixture node still gets scanned (one bad plugin does not take
310
+ down the scan).
311
+
312
+ **Decisions taken inline (flag for orchestrator)**
313
+
314
+ - `ui` is required, not optional — making it optional reintroduces the
315
+ pre-14.5.d trap of silently collapsing unknown kinds to `'note'`.
316
+ The cost (one object per kind in the manifest) is small.
317
+ - Icon is a discriminated union (`oneOf` with `kind` discriminator)
318
+ rather than two optional fields. Keeps the UI dispatch exhaustive
319
+ and AJV validates each variant cleanly.
320
+ - `schemaVersion` stays at `'1'` despite the required-field add.
321
+ Greenfield — no released consumers; a versioned migration buys
322
+ nothing today. Bumps the day a third-party consumer ships against
323
+ the wire.
324
+ - Severity (PrimeNG `<p-tag>` `severity` enum) is NOT declared by the
325
+ Provider. The UI tints kind tags with the registry's `color`
326
+ directly, avoiding a Provider-side dependency on a UI-framework
327
+ enum.
328
+ - BFF + UI sub-steps land in follow-up commits (14.5.d.iii / .iv /
329
+ .v) — the spec + kernel + built-in surface ship first so the
330
+ contract is visible before consumers wire up.
331
+
332
+ - de20bc2: Step 14.5 (a + b) — Inspector polish: markdown body opt-in + linked-nodes panel + dead-link verify hybrid
333
+
334
+ Two sub-steps land together as a single feature unit. The Inspector
335
+ view (UI workspace) gains a real markdown body card, a dedicated
336
+ linked-nodes panel fed by the BFF's `/api/links` endpoint, and a
337
+ hybrid dead-link checker that combines the in-memory heuristic with
338
+ on-demand BFF verification. The spec + server side ships the minimal
339
+ contract the new UI surface depends on: an opt-in `?include=body`
340
+ parameter on `GET /api/nodes/:pathB64`, plus a corrected single-node
341
+ response shape. Tests 854 → 868 (+14 server) and UI 113 → 138 (+25
342
+ inspector / linked-nodes specs).
343
+
344
+ **Why on-demand body reads instead of persisting bodies in the DB**:
345
+ the kernel persists `body_hash` only (per `db-schema.md` §scan_nodes)
346
+ — the body itself is human content, not machine state, and
347
+ duplicating it in SQLite would inflate the DB without serving any
348
+ read-side query the kernel cares about. Inspector cards that DO want
349
+ to render the body (markdown preview at Step 14.5) opt into the
350
+ filesystem re-read; the list / graph / kind-palette views never need
351
+ it.
352
+
353
+ **Files added (server)**
354
+
355
+ - `src/server/node-body.ts` — on-demand body reader. Exports
356
+ `readNodeBody(cwd, relPath)` (returns `string | null`; `null` on
357
+ ENOENT / EACCES / EISDIR / ENOTDIR) and `stripFrontmatter(body)`
358
+ (drops the leading `---\n…\n---\n` block when present, leaves
359
+ fences in mid-document untouched). Path-traversal hardened: refuses
360
+ absolute paths and any relative path that resolves outside `cwd`.
361
+ - `src/test/server-node-body.test.ts` (11 unit cases) — covers
362
+ `stripFrontmatter` edge cases (empty, no frontmatter, missing
363
+ closing fence, fence in mid-document) and `readNodeBody` traversal
364
+ rejection + the four `null`-returning errno branches.
365
+
366
+ **Files edited (server)**
367
+
368
+ - `src/server/routes/nodes.ts` — `GET /api/nodes/:pathB64` extends
369
+ with `?include=body` opt-in (CSV-tolerant via the new
370
+ `parseIncludes` helper, so `?include=body,future-extension` reads
371
+ cleanly the day a second include lands). Same handler also FIXES a
372
+ long-standing shape bug: was emitting `{ item: { node, linksOut,
373
+ linksIn, issues } }` (raw `INodeBundle` pass-through), now emits
374
+ the documented `{ item: Node, links: { incoming, outgoing },
375
+ issues }` that the UI's `INodeDetailApi` and `StaticDataSource`
376
+ already expected. No prod consumer ran against the legacy shape
377
+ (the UI was internally branching on the legacy shape before the
378
+ REST adapter landed at 14.3.a), so the corrected shape ships as a
379
+ minor.
380
+ - `src/test/server-endpoints.test.ts` — assertions corrected to the
381
+ documented shape; 2 new cases for `?include=body` (returns body
382
+ on present file, returns `null` when the file is missing).
383
+
384
+ **Files added (UI)**
385
+
386
+ - `ui/src/app/components/linked-nodes-panel/{ts,html,css,spec.ts}`
387
+ — standalone Angular component. Inputs: `path`. Outputs:
388
+ `openPath`. Internally fires `dataSource.listLinks({from})` +
389
+ `listLinks({to})` in parallel; state machine
390
+ `idle/loading/ready/error`. Subscribes to `events()` filtered on
391
+ `scan.completed` for reactive refresh, plus a manual refresh
392
+ button in the card header. Token guard handles rapid path
393
+ changes. Renders rows with kind tag + clickable path +
394
+ confidence chip + sources. 10 spec cases.
395
+ - `ui/src/i18n/linked-nodes-panel.texts.ts` — i18n catalog.
396
+ - `ui/src/app/views/inspector-view/inspector-view.spec.ts` (15
397
+ cases) — first inspector-view spec. Covers empty / loading /
398
+ body-card states, stale-fetch token guard, kind-card smoke,
399
+ dead-link verify icon flow (heuristic-dead renders icon,
400
+ click → 404 confirms, click → 200 flips to live).
401
+
402
+ **Files edited (UI)**
403
+
404
+ - `ui/src/models/api.ts` — `INodeApi.body?: string | null` added.
405
+ - `ui/src/services/data-source/data-source.port.ts` —
406
+ `IDataSourcePort.getNode(path, opts?: {includeBody?: boolean})`.
407
+ - `ui/src/services/data-source/rest-data-source.ts` — propagates
408
+ `includeBody` to `?include=body`.
409
+ - `ui/src/services/data-source/static-data-source.ts` — ignores
410
+ the flag (demo bundle ships bodies inline; see
411
+ `scripts/build-demo-dataset.js` below).
412
+ - `ui/src/services/collection-loader.ts` — minor signature touch
413
+ for the `getNode` opts pass-through.
414
+ - `ui/src/models/node.ts` — `INodeView` loses three fields:
415
+ `body`, `raw`, `mockSummary`. The "Summary" mock card is
416
+ retired (description already lives in `inspector__desc`).
417
+ - `ui/src/app/views/inspector-view/inspector-view.ts` — body card
418
+ switches from `<pre>{{ n.body }}</pre>` to a `@switch` over a
419
+ `bodyState` signal (idle / loading / empty / unavailable /
420
+ error / ready) with token-guarded fetch via `effect()` keyed on
421
+ `path()`; markdown rendered via `MarkdownRenderer` and
422
+ `[innerHTML]`. Mounts `<sm-linked-nodes-panel>` as a separate
423
+ card between Relations and Body. Dead-link verify hybrid: the
424
+ Relations card chips (`supersededBy` / `supersedes` / `requires`
425
+ / `related`) keep the in-memory heuristic but now carry a verify
426
+ icon (`pi-question-circle`) that fires `getNode(path)` against
427
+ the BFF; three visual states `live` / `dead-confirmed` (404 → red
428
+ dashed border + `pi-times-circle`) / `dead-heuristic` (not in
429
+ scope, not yet verified). Per-node signals
430
+ `verifiedAlive` / `verifiedDead` / `verifyInFlight` reset on
431
+ `path()` change. Template refactor consolidates 4 inline
432
+ duplicated chip blocks into a single `<ng-template #pathChip>`
433
+ shared via `*ngTemplateOutlet`.
434
+ - `ui/src/app/views/inspector-view/inspector-view.{html,css}` —
435
+ templates + styles for the new body / verify states.
436
+ - `ui/src/i18n/inspector-view.texts.ts` — drops `summary*`, adds
437
+ `body.*` (loading / empty / unavailable / renderError),
438
+ `relations.verifyHint`, `relations.deadConfirmed`. `body: 'Body'`
439
+ (was `'Body (raw markdown)'`).
440
+
441
+ **Files edited (build pipeline)**
442
+
443
+ - `scripts/build-demo-dataset.js` — new `embedBodies(scan,
444
+ fixtureDir)` post-processor reads each fixture's body from disk,
445
+ strips frontmatter, attaches to the demo `data.json` so the
446
+ demo experience matches the live BFF (~40 KB extra for 21
447
+ fixtures; bodies-on-bundle is the explicit demo-mode tradeoff).
448
+
449
+ **Spec**
450
+
451
+ - `spec/cli-contract.md` `### Server` — `/api/nodes/:pathB64` row
452
+ flips its shape column from the legacy bundle to the documented
453
+ `{ item, links: { incoming, outgoing }, issues }` and gains the
454
+ `?include=body` filter column.
455
+ - `spec/CHANGELOG.md` `[Unreleased]` `### Minor` — entry covering
456
+ the `?include=body` opt-in, the corrected response shape, and
457
+ the path-traversal defense.
458
+ - `spec/index.json` — regenerated (41 files hashed; no schema
459
+ added).
460
+
461
+ **ROADMAP** — `Last updated` bumped, "YOU ARE HERE" updated,
462
+ completeness marker now lists 14.5.a + 14.5.b as complete; "Next"
463
+ points at 14.5.c.
464
+
465
+ **Decisions taken inline (flag for orchestrator)**
466
+
467
+ - The corrected single-node shape ships as a minor (additive on
468
+ the contract surface) rather than a major. Rationale: no public
469
+ consumer ran against the legacy shape; the UI was decoding the
470
+ legacy shape internally before the REST adapter at 14.3.a
471
+ introduced the documented shape; and the spec table already
472
+ documented the new shape (the bug was in the implementation,
473
+ not the spec). Keeping the bump minor avoids burning a major
474
+ on a never-shipped wire format.
475
+ - `parseIncludes` is CSV-tolerant from day one (`?include=body`
476
+ and `?include=body,foo` both parse) so the second include can
477
+ land without a parser refactor. Unknown include values are
478
+ silently ignored — the BFF surface mirrors the spec's
479
+ "ignore unknown event types" rule for forward compatibility.
480
+ - Bodies are fetched per-node on inspector open, not pre-fetched
481
+ in the list endpoint. Keeps the list `/api/nodes` response
482
+ small (the list view never renders bodies) and matches the
483
+ read-side hot path: most nodes are listed but few are inspected.
484
+ - The dead-link verify is opt-in per chip click, not auto-fired
485
+ on inspector open. Heuristic-dead nodes are common in scoped
486
+ scans (a workspace that scans `docs/` but references `src/`);
487
+ auto-firing would burn one BFF round-trip per such reference.
488
+ - Per-node verification signals reset on `path()` change to avoid
489
+ stale state bleeding between inspector navigations. The signals
490
+ are scoped to the component instance; no global cache (the
491
+ cost is one BFF call per re-verify on revisit, which the user
492
+ triggers intentionally by clicking the icon).
493
+
494
+ ## 0.12.0
495
+
496
+ ### Minor Changes
497
+
498
+ - 68c5e28: Step 14.1 — `sm serve` + Hono BFF skeleton
499
+
500
+ Adds `src/server/` Hono workspace with single-port wiring (`/api/health` real,
501
+ `/api/*` 404 stubs, `/ws` no-op upgrade, `serveStatic` + SPA fallback). Real
502
+ `ServeCommand` extracted from stub at `cli/commands/stubs.ts` to dedicated
503
+ `cli/commands/serve.ts` extending `SmCommand`. Loopback-only through v0.6.0
504
+ (Decision #119). Boot resilient to missing DB — `/api/health` reports
505
+ `db: 'missing'`. Spec `cli-contract.md` `sm serve` row updated to full flag
506
+ set; new `### Server` subsection (skeleton — endpoints fill at 14.2).
507
+
508
+ **Files added (server)**
509
+
510
+ - `src/server/index.ts` — `createServer(opts)` factory returning `ServerHandle` (`{ address, close }`); resolves spec version, builds the Hono app, instantiates a `WebSocketServer({ noServer: true })`, hands both to `@hono/node-server`'s `serve({ websocket: { server: wss } })`. Closing the http server tears down the WSS automatically (node-server registers the `'close'` hook internally); `close()` calls `wss.close()` defensively for forward-compatibility.
511
+ - `src/server/app.ts` — Hono app construction. Routes registered in single-port order: `GET /api/health` → real, `ALL /api/*` → structured 404, `GET /ws` via the injected `attachWs` registrar, static handler + SPA fallback. Global `app.onError` formats every uncaught throw into the error envelope.
512
+ - `src/server/options.ts` — `IServerOptions` + `validateServerOptions(input)`. Loopback-only check for `--dev-cors`; port range check `[0, 65535]`; scope validation.
513
+ - `src/server/paths.ts` — `resolveDefaultUiDist(ctx)` walks upwards from cwd looking for `ui/dist/browser/index.html`; `resolveExplicitUiDist(ctx, raw)` honours absolute paths for `--ui-dist`.
514
+ - `src/server/static.ts` — wraps `@hono/node-server`'s `serveStatic` middleware with the SPA-fallback layer (`serveStatic` does not do SPA fallback — it `next()`s on miss, which is exactly the seam we hook into). Absolute `root` paths work on POSIX in node-server@2.0.1 (verified runtime probe — implementation is `path.join(root, filename)`); the `.d.ts` "Absolute paths are not supported" string is stale (upstream issue honojs/node-server#187 still open). When the bundle is missing (`uiDist === null`), a tiny placeholder middleware serves the boot-without-bundle hint at `/`.
515
+ - `src/server/ws.ts` — `noopWebSocketRoute(app)` registers `GET /ws` via the official `upgradeWebSocket` re-exported from `@hono/node-server@2.x`. The 14.1 handler closes the connection in `onOpen` with code 1000 + reason `'no broadcaster yet'`. 14.4 swaps this registrar for the chokidar-fed broadcaster — one-line change in `index.ts`, `app.ts` untouched.
516
+ - `src/server/health.ts` — `buildHealth(deps)` synchronous; `resolveSpecVersion()` async, called once at boot.
517
+ - `src/server/i18n/server.texts.ts` — `SERVER_TEXTS` catalog.
518
+
519
+ **Files added (CLI)**
520
+
521
+ - `src/cli/commands/serve.ts` — `ServeCommand extends SmCommand`. Parses flags, validates, calls `createServer`, registers SIGINT/SIGTERM handlers, awaits shutdown. `protected emitElapsed = false` (long-running daemon).
522
+ - `src/cli/i18n/serve.texts.ts` — `SERVE_TEXTS` catalog.
523
+
524
+ **Tests added (15)**
525
+
526
+ - `src/test/server-boot.test.ts` (7) — boot/listen/health JSON, custom port, db state present/missing, structured 404, /ws upgrade closes with code 1000 + reason 'no broadcaster yet' (uses real `WebSocket` client from `ws`), shutdown < 1s + idempotent close, inline placeholder when uiDist null.
527
+ - `src/test/server-flags.test.ts` (6) — host non-loopback + dev-cors rejection, port out-of-range, port non-numeric, scope invalid, ui-dist missing, ui-dist with valid bundle.
528
+ - `src/test/server-db-missing.test.ts` (2) — `--db <missing>` exits 5, default boots cleanly with db:missing.
529
+
530
+ **Files edited**
531
+
532
+ - `src/cli/commands/stubs.ts` — `ServeCommand` removed; replaced with a comment pointer.
533
+ - `src/cli/entry.ts` — registers the new `ServeCommand`.
534
+ - `src/package.json` — adds `hono@4.12.16`, `@hono/node-server@2.0.1`, `ws@8.20.0` (deps); `@types/ws@8.18.1` (dev). All exact-pinned per AGENTS.md.
535
+ - `spec/cli-contract.md` — `sm serve` row replaced with the full 14.1 flag set; new `#### Server` subsection (stability: experimental).
536
+ - `spec/CHANGELOG.md` — `[Unreleased]` `### Minor` entry for the spec change.
537
+ - `spec/index.json` — regenerated (40 files hashed; previous head was 215 lines).
538
+
539
+ **Decisions during implementation (flag for orchestrator)**
540
+
541
+ - WebSocket support uses `@hono/node-server@2.x`'s built-in `upgradeWebSocket` plus the canonical `ws@8.20.0` Node WebSocket library, per the official README pattern. The previously-published `@hono/node-ws` adapter was deprecated when node-server@2.0 absorbed WebSocket support natively (PR honojs/node-server#328). The 14.4 broadcaster will replace `noopWebSocketRoute` with its own one-line registrar — no API churn between 14.1 and 14.4.
542
+ - The `/api/*` catch-all is wired with `app.all('/api/*', ...)` BEFORE the `/ws` registrar and the static handler so neither a `serveStatic` filesystem hit nor the SPA fallback can shadow API endpoints. `/ws` is registered BEFORE the static handler so a literal `/ws` path on disk inside `uiDist` cannot accidentally shadow the upgrade route.
543
+ - `serveStatic` from `@hono/node-server/serve-static` accepts absolute root paths at runtime on POSIX (its implementation is `path.join(root, filename)`); the `.d.ts` string saying otherwise is documentation drift, not a runtime contract. Verified with a runtime probe and cross-referenced against the open upstream issue (honojs/node-server#187). Documented in `src/server/static.ts` so future contributors don't re-investigate.
544
+
3
545
  ## 0.11.0
4
546
 
5
547
  ### Minor Changes
@@ -172,6 +714,155 @@ list`, `sm plugins doctor`, `sm db prune` plugin filter, runtime
172
714
 
173
715
  ## [Unreleased]
174
716
 
717
+ ### Minor
718
+
719
+ - **Provider-driven kind presentation + envelope `kindRegistry`** —
720
+ Step 14.5.d. The Provider extension surface gains the required
721
+ `kinds[*].ui` field (label, color, optional dark-theme color, optional
722
+ emoji, optional icon) so each kind a Provider declares carries the
723
+ presentation metadata the UI needs to render it. The icon is a
724
+ discriminated union — `{ kind: 'pi'; id: 'pi-…' }` for PrimeIcons or
725
+ `{ kind: 'svg'; path: '…' }` for raw SVG path data wrapped in a
726
+ `viewBox="0 0 24 24"` tinted with `currentColor`. The UI derives bg /
727
+ fg tints from `color` per theme via a deterministic helper, so the
728
+ Provider declares one base color per theme rather than four hex
729
+ values.
730
+
731
+ The REST envelope shape (`schemas/api/rest-envelope.schema.json`)
732
+ gains a new required `kindRegistry` field on every payload-bearing
733
+ variant (`nodes` / `links` / `issues` / `plugins` lists, the `node`
734
+ single, and the `config` value envelope); sentinel envelopes
735
+ (`health` / `scan` / `graph`) stay exempt because they don't carry a
736
+ payload at the wire level either. The registry is keyed by kind
737
+ name and carries `{ providerId, label, color, colorDark?, emoji?,
738
+ icon? }` — the BFF assembles it once at boot from every enabled
739
+ Provider and attaches it to every applicable response so the UI can
740
+ render Provider-declared kinds (built-in and user-plugin alike)
741
+ without hardcoding a closed kind enum.
742
+
743
+ **Why required, not optional**: making `ui` optional reintroduces the
744
+ trap the UI had pre-14.5.d (silently collapsing unknown kinds to
745
+ `'note'`). Forcing every Provider to declare presentation up-front
746
+ means the UI never has to invent visuals; the cost is one small
747
+ object per kind in the manifest.
748
+
749
+ **Why discriminated icon instead of two optional fields**: the
750
+ `oneOf` shape (with `kind: 'pi' | 'svg'` discriminator) keeps the UI
751
+ dispatch exhaustive without string-sniffing the payload, and AJV
752
+ validates each variant cleanly — a manifest cannot ship both `id` and
753
+ `path` simultaneously.
754
+
755
+ **Why `schemaVersion` stays at `'1'`**: the BFF is greenfield — no
756
+ released consumers depend on the previous (kindRegistry-less)
757
+ shape, so a versioned migration would only add ceremony. The
758
+ shape change is documented under this changelog entry; the version
759
+ bumps the day a third-party consumer ships against the wire.
760
+
761
+ Pre-1.0 minor breaking per `versioning.md` § Pre-1.0. The built-in
762
+ Claude Provider migrates in the same step (every kind declares its
763
+ `ui` block reusing the visuals previously hardcoded in the UI).
764
+
765
+ Conformance: new case `plugin-missing-ui-rejected` (with fixture
766
+ `plugin-missing-ui/`) locks the loader's behaviour against a drop-in
767
+ Provider that omits `ui` — `sm scan --json` exits 0, stderr matches
768
+ the canonical `must have required property 'ui'` diagnostic, and the
769
+ rest of the pipeline (built-in Claude) keeps running. Suite total:
770
+ 5/5 passing across 2 scopes.
771
+
772
+ - **BFF `/api/nodes/:pathB64` body opt-in** — Step 14.5.a extends the
773
+ single-node detail endpoint with the optional `?include=body` query
774
+ parameter. When set, the response's `item` carries `body: string |
775
+ null` (the post-frontmatter file content read from disk on demand);
776
+ `null` indicates the source file was missing or unreadable when the
777
+ request landed. Without the flag, `item.body` stays `undefined` and
778
+ the handler does not touch the filesystem. The single-node response
779
+ shape is also corrected to the documented `{ schemaVersion, kind:
780
+ 'node', item: Node, links: { incoming: Link[], outgoing: Link[] },
781
+ issues: Issue[] }` (the `### Server` table previously declared the
782
+ shape as the legacy `{ node, linksOut, linksIn, issues }` bundle —
783
+ see prose for the bug-fix rationale). No prod consumer ran against
784
+ the legacy shape, so the corrected shape ships as a minor.
785
+
786
+ **Why on-demand instead of persisting bodies in `scan_nodes`**: the
787
+ kernel persists `body_hash` only (per `db-schema.md` §scan_nodes) —
788
+ the body itself is human content, not machine state, and duplicating
789
+ it in SQLite would inflate the DB without serving any read-side
790
+ query the kernel cares about. Inspector cards that DO want to render
791
+ the body (markdown preview at Step 14.5) opt into the filesystem
792
+ re-read; the list / graph / kind-palette views never need it.
793
+
794
+ **Path-traversal defense**: the body reader (`src/server/node-body.ts`)
795
+ refuses absolute paths and any relative path that resolves outside
796
+ the scope root. A corrupted DB row or a future Provider that
797
+ forgets to sanitise its node paths cannot use this endpoint to leak
798
+ arbitrary files.
799
+
800
+ - **BFF `/ws` protocol + watcher contract** — Step 14.4.a documents
801
+ the WebSocket surface. The `### Server` subsection grows a
802
+ **WebSocket protocol** block enumerating the wire envelope (delegated
803
+ to `job-events.md` §Common envelope), the v14.4.a event catalog
804
+ (`scan.started` / `scan.progress` / `scan.completed` plus the
805
+ side-effect events `extractor.completed` / `rule.completed` /
806
+ `extension.error`, plus the BFF-internal advisories
807
+ `watcher.started` / `watcher.error`), the connection lifecycle (no
808
+ state push on connect; client polls `/api/scan` to seed; close codes
809
+ used: 1000 / 1001 / 1009), the backpressure rule (4 MiB `bufferedAmount`
810
+ → close 1009 + unregister), and the loopback-only assumption (no
811
+ per-connection auth through v0.6.0 per Decision #119). The endpoint
812
+ table flips `GET /ws` from `upgrade-only` to `implemented (v14.4.a)`.
813
+ The `sm serve` flag table grows `--no-watcher` (default off — watcher
814
+ on per Decision #121); combining `--no-watcher` is documented;
815
+ combining `--no-built-ins` with the watcher is rejected (would
816
+ persist empty scans on every batch). The verb-catalog row for
817
+ `sm serve` mirrors the new flag.
818
+
819
+ `issue.added` / `issue.resolved` (the diff-based events from
820
+ `job-events.md` §Issue events) and `scan.failed` are explicitly
821
+ flagged as deferred — the 14.4.a surface fans out only what the
822
+ kernel emitter already produces; per-batch failure events and the
823
+ diff-based issue stream require additional plumbing in the BFF
824
+ watcher loop and land in a follow-up.
825
+
826
+ Additive minor per `versioning.md` § Pre-1.0; no breaking change to
827
+ any prior `/ws` behavior (the v14.1 / v14.2 / v14.3 surfaces all had
828
+ `/ws` documented as "upgrade-only — broadcaster lands at v14.4").
829
+
830
+ - **BFF read-side endpoints** — Step 14.2 fills the `### Server`
831
+ subsection's endpoint catalogue from the v14.1 stub
832
+ (`GET /api/health` real, `ALL /api/*` 404) to the eight read-side
833
+ endpoints the Web UI consumes:
834
+ `GET /api/scan` (latest persisted `ScanResult`; `?fresh=1` for an
835
+ in-memory scan), `GET /api/nodes` (paginated, filtered list — same
836
+ query grammar as `sm export`), `GET /api/nodes/:pathB64` (single-node
837
+ bundle; `pathB64` is base64url of `node.path`), `GET /api/links`,
838
+ `GET /api/issues`, `GET /api/graph?format=...` (formatter rendering),
839
+ `GET /api/config`, `GET /api/plugins`. New
840
+ [`schemas/api/rest-envelope.schema.json`](schemas/api/rest-envelope.schema.json)
841
+ formalises the list-envelope shape (`{ schemaVersion, kind, items \|
842
+ item \| value, filters, counts }`). The error envelope subsection
843
+ enumerates the v14.2 sources for each `code` value (`not-found` /
844
+ `bad-query` / `internal` / reserved `db-missing`). Stability stays
845
+ `experimental — locks at v0.6.0` (no change). Additive minor per
846
+ `versioning.md` § Pre-1.0; no breaking change — the v14.1 endpoint
847
+ catalogue was a stub explicitly slated to fill at v14.2.
848
+
849
+ - **`sm serve` row + `### Server` subsection** in `cli-contract.md` —
850
+ Step 14.1 promotes `sm serve` from an implementation-defined stub to a
851
+ documented surface. The verb row at `§Verb catalog` › `### Server`
852
+ expands the flag set to the full 14.1 contract: `--port` (default
853
+ `4242`), `--host` (default `127.0.0.1`, loopback-only through v0.6.0),
854
+ `--scope project|global`, `--db <path>`, `--no-built-ins`,
855
+ `--no-plugins`, `--open` / `--no-open`, `--dev-cors`, `--ui-dist
856
+ <path>` (hidden). New `#### Server` subsection documents the
857
+ single-port mandate, the boot-with-missing-DB resilience contract
858
+ (`/api/health` returns `db: 'missing'`), the v14.1 endpoint surface
859
+ (`GET /api/health` real, `ALL /api/*` 404 stubs, `GET /ws` upgrade-only,
860
+ static + SPA fallback), the structured error envelope shape, and the
861
+ flag table. Marked `*(Stability: experimental — locks at v0.6.0.)*` —
862
+ endpoints fill at v14.2, broadcaster at v14.4. Additive minor per
863
+ `versioning.md` § Pre-1.0 (no breaking change to the existing row's
864
+ semantics; the old wording was strictly less specific).
865
+
175
866
  ### Minor (breaking, pre-1.0)
176
867
 
177
868
  - **`Node.kind` opens to any non-empty string (was the closed enum
@@ -1159,9 +1850,9 @@ kind, normalizedTrigger)` and prints one row per group with the
1159
1850
  (`Links out (12, 9 unique)`). When N > 1 detector emits the same
1160
1851
  logical link, the row also gets a `(×N)` suffix.
1161
1852
 
1162
- `--json` output is byte-identical to before — raw rows, no merge.
1163
- Storage is byte-identical to before. The grouping is purely a
1164
- read-time presentation choice for human eyes.
1853
+ `--json` output is byte-identical to before — raw rows, no merge.
1854
+ Storage is byte-identical to before. The grouping is purely a
1855
+ read-time presentation choice for human eyes.
1165
1856
 
1166
1857
  **Spec changes (patch)**:
1167
1858
 
package/cli-contract.md CHANGED
@@ -316,7 +316,97 @@ Destructive verbs (`reset --state`, `reset --hard`, `restore`) require interacti
316
316
 
317
317
  | Command | Purpose |
318
318
  |---|---|
319
- | `sm serve [--port N] [--host ...] [--no-open]` | Start Hono + WebSocket for the Web UI. Default port is implementation-defined but MUST be the same across runs. Implementations MUST NOT bind 0.0.0.0 by default. |
319
+ | `sm serve [--port N] [--host ...] [--scope project\|global] [--db <path>] [--no-built-ins] [--no-plugins] [--open\|--no-open] [--dev-cors] [--ui-dist <path>] [--no-watcher]` | Start Hono + WebSocket for the Web UI. Single-port mandate: SPA + REST + WS under one listener. Default port 4242, default host 127.0.0.1 (loopback-only through v0.6.0; multi-host deferred — see §Server). The watcher is on by default (Decision #121: a server with stale DB is a footgun); pass `--no-watcher` for CI / read-only deployments. |
320
+
321
+ #### Server
322
+
323
+ *(Stability: experimental — locks at v0.6.0.)*
324
+
325
+ The reference implementation ships a Hono BFF rooted at `src/server/`. One Node process serves the Angular SPA, the REST API under `/api/*`, and the WebSocket at `/ws` — single-port mandate, no proxy. Loopback-only assumption through v0.6.0: no per-connection auth on `/ws`; combining `--dev-cors` with a non-loopback `--host` is rejected (exit 2).
326
+
327
+ **Boot resilience**: `sm serve` boots even when the project DB is missing. `/api/health` reports `db: 'missing'` so the SPA can render an empty-state CTA instead of failing the connection. Explicit `--db <path>` that doesn't exist is the exception — that exits 5 (NotFound) per `§Exit codes`.
328
+
329
+ **Endpoints (v14.2 surface)**:
330
+
331
+ | Path | Status | Shape |
332
+ |---|---|---|
333
+ | `GET /api/health` | implemented | `{ ok: true, schemaVersion, specVersion, implVersion, scope: 'project'\|'global', db: 'present'\|'missing' }` |
334
+ | `GET /api/scan` | implemented | latest persisted `ScanResult` (1:1 with `scan-result.schema.json`; byte-equal to `sm scan --json` modulo whitespace). DB absent → empty `ScanResult` shape (zero `nodes` / `links` / `issues`). |
335
+ | `GET /api/scan?fresh=1` | implemented | runs an in-memory scan and returns the produced `ScanResult` without persistence. Rejects with `bad-query` (400) when the server was started with `--no-built-ins` or `--no-plugins` (would yield empty / partial results). |
336
+ | `GET /api/nodes?kind=&hasIssues=&path=&limit=&offset=` | implemented | `RestEnvelope` (`kind: 'nodes'`) — paginated, filtered list. Filters share the `kind=` / `has=issues` / `path=<glob>` grammar with `sm export`. `hasIssues=false` is a server-side post-filter (not representable in the kernel grammar). Pagination defaults `offset=0`, `limit=100`; max `limit=1000`. |
337
+ | `GET /api/nodes/:pathB64[?include=body]` | implemented | Single-node detail envelope: `{ schemaVersion, kind: 'node', item: Node, links: { incoming: Link[], outgoing: Link[] }, issues: Issue[] }`. `:pathB64` is base64url (RFC 4648 §5, no padding) of `node.path`. Missing node or malformed `pathB64` → 404 `not-found`. **`?include=body`** (Step 14.5.a) — opt-in flag that adds `item.body: string \| null` to the response. The body is read from disk on demand at request time (the kernel persists `bodyHash` only). `null` indicates the source file was missing / unreadable when the request landed (the watcher will re-emit `scan.completed` when it catches up). Without the flag, `item.body` is `undefined` and the handler does not touch the filesystem. |
338
+ | `GET /api/links?kind=&from=&to=` | implemented | `RestEnvelope` (`kind: 'links'`) — list of links. Filters: `kind` (CSV whitelist of `link.kind`), `from` (exact match on `link.source`), `to` (exact match on `link.target`). No pagination at v14.2. |
339
+ | `GET /api/issues?severity=&ruleId=&node=` | implemented | `RestEnvelope` (`kind: 'issues'`) — list of issues. Filters: `severity` (CSV from `error\|warn\|info`), `ruleId` (CSV; qualified or short suffix per `sm check --rules`), `node` (filter to issues whose `nodeIds` includes the path). No pagination at v14.2. |
340
+ | `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`. |
341
+ | `GET /api/config` | implemented | `RestEnvelope` (`kind: 'config'`) — merged effective config (defaults → user → user-local → project → project-local → override). |
342
+ | `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'\|'global' }`. |
343
+ | `ALL /api/*` (other) | reserved | structured 404 envelope (see below); future endpoints land in subsequent sub-steps. |
344
+ | `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. |
345
+ | `GET *` | implemented | static asset from the resolved UI bundle, falling back to `index.html` for SPA deep links. |
346
+
347
+ List endpoints conform to [`schemas/api/rest-envelope.schema.json`](schemas/api/rest-envelope.schema.json). The `/api/scan` and `/api/health` responses carry their underlying `ScanResult` / `IHealthResponse` shapes directly (no envelope wrap). The `/api/graph` response carries the formatter's native textual output.
348
+
349
+ **Error envelope** (mirrors `§Machine-readable output rules`):
350
+
351
+ ```json
352
+ {
353
+ "ok": false,
354
+ "error": {
355
+ "code": "not-found" | "bad-query" | "db-missing" | "internal",
356
+ "message": "<human-readable>",
357
+ "details": { ... } | null
358
+ }
359
+ }
360
+ ```
361
+
362
+ HTTP status mapping: `400` → `bad-query`, `404` → `not-found`, `500` → `internal` / `db-missing`.
363
+
364
+ Error code sources at v14.2:
365
+
366
+ - `not-found` (404) — unknown `/api/*` path; missing node on `/api/nodes/:pathB64`; malformed `pathB64` (treated as "no such node" so the client UX is uniform).
367
+ - `bad-query` (400) — `ExportQueryError` from `parseExportQuery`; pagination beyond `limit ≤ 1000`; non-integer / negative `limit` / `offset`; unknown formatter on `/api/graph`; `?fresh=1` when the server started with `--no-built-ins` or `--no-plugins`.
368
+ - `internal` (500) — uncaught error during a request (e.g. config-load failure, DB corruption surfacing through `loadScanResult`).
369
+ - `db-missing` (500) — reserved for endpoints that cannot degrade to an empty result. The v14.2 routes uniformly degrade (`/api/scan` returns the empty shape; list endpoints return zero items) so this code is not currently emitted by any handler — it is documented for future endpoints (post-v0.6.0 mutations) where degradation is not safe.
370
+
371
+ **Flag surface**:
372
+
373
+ | Flag | Default | Purpose |
374
+ |---|---|---|
375
+ | `--port N` | `4242` | Listening port. `0` = OS-assigned (handle reports the bound port). |
376
+ | `--host <ip>` | `127.0.0.1` | Listening host. Implementations MUST NOT bind `0.0.0.0` by default. |
377
+ | `--scope project\|global` | `project` | Effective scope for `/api/*` reads. Alias for `-g/--global`. |
378
+ | `--db <path>` | resolved per spec § Global flags | Override the DB file location. Missing explicit `--db` exits 5. |
379
+ | `--no-built-ins` | off | Skip built-in plugin registration (parity with `sm scan --no-built-ins`). |
380
+ | `--no-plugins` | off | Skip drop-in plugin discovery. |
381
+ | `--open` / `--no-open` | `--open` | Auto-open the SPA in the user's default browser after listen. |
382
+ | `--dev-cors` | off | Enable permissive CORS for the Angular dev-server proxy workflow. Loopback-only when set. |
383
+ | `--ui-dist <path>` | auto | Override the UI bundle directory. Hidden flag — used by the demo build pipeline + tests; everyday users never need it. |
384
+ | `--no-watcher` | off | Disable the chokidar-fed scan-and-broadcast loop. Use only for CI / read-only deployments — without the watcher, `/ws` stays open but no `scan.*` events ever fire. Combining with `--no-built-ins` is rejected (the watcher cannot run with an empty pipeline; would persist empty scans on every batch). |
385
+
386
+ **WebSocket protocol** *(Stability: experimental — locks at v0.6.0)*:
387
+
388
+ The `/ws` endpoint is the live-events channel for the SPA. Clients connect once at bootstrap, the server pushes events as they happen, and the SPA reconciles its in-memory store against the deltas. The wire envelope and `scan.*` payload shapes are normative in [`job-events.md`](./job-events.md) — the BFF emits them verbatim.
389
+
390
+ - **Wire format**: each event is a single WebSocket text frame carrying one JSON object that conforms to `job-events.md` §Common envelope (`type`, `timestamp`, `runId?`, `jobId? | null`, `data`).
391
+ - **Event catalog at v14.4.a**:
392
+ - `scan.started` (per `job-events.md` §Scan events line 325).
393
+ - `scan.progress` (per `job-events.md` line 345 — emitted by the kernel orchestrator at every node; throttling deferred to a follow-up).
394
+ - `scan.completed` (per `job-events.md` line 363).
395
+ - `extractor.completed` (per `job-events.md` line 384) and `rule.completed` (per `job-events.md` line 404) ride along as side effects of the same emitter bridge.
396
+ - `extension.error` (kernel-internal — emitted when an extension violates its declared contract; the BFF forwards verbatim).
397
+ - `watcher.started` and `watcher.error` — BFF-internal advisories. Non-normative; consumers MUST ignore unknown event types per the forward-compatibility rule.
398
+ - **Deferred to a follow-up**: `issue.added` / `issue.resolved` (per `job-events.md` §Issue events line 446) and `scan.failed`. The 14.4.a surface fans out only the events the kernel emitter already produces; the diff-based issue events and a dedicated batch-failure event require additional plumbing inside the BFF watcher loop.
399
+ - **Connection lifecycle**:
400
+ 1. Client opens `ws://<host>:<port>/ws`. The server completes the WS handshake and registers the underlying socket with the broadcaster.
401
+ 2. Server pushes events. The client sends nothing at v14.4.a — `onMessage` is intentionally not registered. A future heartbeat / subscribe / filter request lands in a follow-up.
402
+ 3. Server has NO state push on connect (no replay of last events). The client SHOULD poll `/api/scan` once on connect to seed initial state, then rely on `/ws` for deltas.
403
+ 4. On normal disconnect: client closes with code 1000 ('normal closure') or 1001 ('going away'). The broadcaster unregisters silently.
404
+ 5. On server shutdown (SIGINT / SIGTERM): the broadcaster sends close code 1001 + reason `'server shutdown'` to every client, then closes the http listener.
405
+ 6. **Backpressure**: if a client's outbound buffer (`bufferedAmount`) exceeds an implementation-defined threshold (the reference impl uses 4 MiB), the broadcaster closes that client with code 1009 ('message too big') and unregisters it. Clients SHOULD reconnect after backpressure eviction with a fresh `/api/scan` poll.
406
+ 7. **Reconnect responsibility**: the server does NOT reconnect on the client's behalf and does NOT replay missed events on reconnect. The client SHOULD treat `/ws` as a best-effort delta channel and re-seed via `/api/scan` whenever the connection drops.
407
+ - **Loopback-only assumption (Decision #119)**: no per-connection authentication on `/ws` through v0.6.0. The transport security boundary is the `--host` flag (defaults to `127.0.0.1`); the server rejects `--dev-cors` combined with a non-loopback `--host` precisely because that combination would expose `/ws` over the network without auth. Multi-host serve and an auth model re-open post-v0.6.0.
408
+
409
+ **Graceful shutdown**: SIGINT / SIGTERM trigger a graceful close; the verb returns exit 0 on clean shutdown. Bind failure (port in use, EACCES) returns exit 2. The shutdown sequence drains the in-flight watcher batch (if any), closes every WS client with code 1001, then closes the http listener.
320
410
 
321
411
  ---
322
412
 
@@ -0,0 +1,17 @@
1
+ {
2
+ "$schema": "https://skill-map.dev/spec/v0/conformance-case.schema.json",
3
+ "id": "plugin-missing-ui-rejected",
4
+ "description": "A drop-in Provider plugin whose `kinds[*]` entry omits the required `ui` block (Step 14.5.d) MUST be rejected by the loader with a clear `missing required property 'ui'` diagnostic on stderr, AND `sm scan` MUST exit cleanly with the rest of the pipeline (built-in Claude Provider) still running. Locks the contract that one bad plugin does not take down the scan.",
5
+ "fixture": "plugin-missing-ui",
6
+ "invoke": {
7
+ "verb": "scan",
8
+ "flags": ["--json"]
9
+ },
10
+ "assertions": [
11
+ { "type": "exit-code", "value": 0 },
12
+ { "type": "stderr-matches", "pattern": "plugin bad-provider:.*invalid.*must have required property 'ui'" },
13
+ { "type": "json-path", "path": "$.providers.length", "equals": 1 },
14
+ { "type": "json-path", "path": "$.providers[0]", "equals": "claude" },
15
+ { "type": "json-path", "path": "$.nodes.length", "equals": 1 }
16
+ ]
17
+ }
@@ -32,8 +32,9 @@ This file is hand-maintained. A CI check before spec release compares the schema
32
32
  | 22 | `extensions/formatter.schema.json` | — | 🔴 missing | Case: `ascii` formatter manifest validates. |
33
33
  | 23 | `history-stats.schema.json` | — | 🔴 missing | Blocked by Step 5 (history). Case: seed `state_executions` with a deterministic fixture, run `sm history stats --json --since <T0> --until <T1> --period month --top 5`, assert the document validates and that `totals.executionsCount == sum(perAction.executionsCount)` and `errorRates.global == totals.failedCount / totals.executionsCount`. Percentiles (`p95`/`p99`) intentionally omitted in v1 — add later as a minor bump without breaking consumers. |
34
34
  | 24 | `extensions/hook.schema.json` | — | 🔴 missing | Case: a `deterministic` hook manifest with `triggers: ['scan.completed']` validates; a hook declaring an unknown trigger (e.g. `scan.progress`) fails with `invalid-manifest` at load time. |
35
+ | 25 | `api/rest-envelope.schema.json` | — | 🔴 missing | Step 14.2 BFF list-envelope shape (`{ schemaVersion, kind, items \| item \| value, filters, counts }`). Case: hit `GET /api/nodes` against a primed scope, validate the response against the schema; assert the `oneOf` rejects an envelope that carries both `items` and `item`. Implementation-side coverage exists today (`src/test/server-endpoints.test.ts`) but a kernel-agnostic conformance case is required before v1.0.0 ships. |
35
36
 
36
- > **Note on Provider-owned schemas.** Per spec 0.8.0 Phase 3, the per-kind frontmatter schemas (`skill`, `agent`, `command`, `hook`, `note`) live with the Provider that emits them — for the built-in Claude Provider, that is `src/extensions/providers/claude/schemas/`. Those schemas are NOT counted in the spec's coverage matrix above; they belong to the Provider's own conformance suite (Phase 5 / A.13 — `src/extensions/providers/claude/conformance/coverage.md`). Phase 5 / A.13 also relocated the cases that exercised them (`basic-scan`, `rename-high`, `orphan-detection`) to the Provider's own `cases/` directory. The matrix shrinks from 28 to 23 rows accordingly. The Hook kind (A.11) brings it back up to 24.
37
+ > **Note on Provider-owned schemas.** Per spec 0.8.0 Phase 3, the per-kind frontmatter schemas (`skill`, `agent`, `command`, `hook`, `note`) live with the Provider that emits them — for the built-in Claude Provider, that is `src/extensions/providers/claude/schemas/`. Those schemas are NOT counted in the spec's coverage matrix above; they belong to the Provider's own conformance suite (Phase 5 / A.13 — `src/extensions/providers/claude/conformance/coverage.md`). Phase 5 / A.13 also relocated the cases that exercised them (`basic-scan`, `rename-high`, `orphan-detection`) to the Provider's own `cases/` directory. The matrix shrinks from 28 to 23 rows accordingly. The Hook kind (A.11) brings it back up to 24, and Step 14.2's BFF envelope brings it to 25.
37
38
 
38
39
  Status legend: 🟢 covered (at least one case asserts the schema end-to-end) · 🟡 partial (covered only indirectly or via a sub-shape) · 🔴 missing.
39
40
 
@@ -0,0 +1,6 @@
1
+ {
2
+ "id": "bad-provider",
3
+ "version": "0.1.0",
4
+ "specCompat": "*",
5
+ "extensions": ["provider.js"]
6
+ }
@@ -0,0 +1,31 @@
1
+ // Conformance fixture: provider whose `kinds[*]` entry deliberately
2
+ // omits the required `ui` block (Step 14.5.d). The plugin loader MUST
3
+ // reject this manifest with a clear "missing required property 'ui'"
4
+ // diagnostic and the plugin MUST end up in `invalid-manifest` status.
5
+ // The companion case `plugin-missing-ui-rejected.json` asserts the
6
+ // stderr text and that `sm scan` survives (the loader degrades the
7
+ // bad plugin and lets the rest of the pipeline continue).
8
+ export default {
9
+ kind: 'provider',
10
+ id: 'bad-provider-provider',
11
+ version: '0.1.0',
12
+ description: 'provider whose note kind is missing the ui block',
13
+ stability: 'experimental',
14
+ explorationDir: '~/.bad',
15
+ kinds: {
16
+ note: {
17
+ schema: './schemas/note.schema.json',
18
+ schemaJson: {
19
+ $id: 'urn:test:bad-provider/note',
20
+ type: 'object',
21
+ additionalProperties: true,
22
+ },
23
+ defaultRefreshAction: 'bad-provider/summarize-note',
24
+ // NOTE: deliberately no `ui` — this is what the case asserts.
25
+ },
26
+ },
27
+ async *walk() {},
28
+ classify() {
29
+ return 'note';
30
+ },
31
+ };
@@ -0,0 +1,8 @@
1
+ ---
2
+ name: example
3
+ description: Trivial markdown so the Claude built-in has something to scan.
4
+ metadata:
5
+ version: 1.0.0
6
+ ---
7
+
8
+ Body.
package/index.json CHANGED
@@ -166,17 +166,21 @@
166
166
  }
167
167
  ]
168
168
  },
169
- "specPackageVersion": "0.11.0",
169
+ "specPackageVersion": "0.13.0",
170
170
  "integrity": {
171
171
  "algorithm": "sha256",
172
172
  "files": {
173
- "CHANGELOG.md": "11a95c44a9dd97180d2e281ba652b53e530020c592763113874a8c8bbd65ece8",
173
+ "CHANGELOG.md": "3e4ba3c64fdd33110465741fb697234a5cec0b5b604caa6721ee872aaf6df177",
174
174
  "README.md": "bd30780525e75379eaeb5f8a903bdc601daf3862f3ec69dffc96c437e8d476fc",
175
175
  "architecture.md": "9a6d96d150af60ed8d476af572d07dcce605f116fde720bebb2662b11250bf4b",
176
- "cli-contract.md": "62a0f89f4da6207499b26c4ca8e35f3d59054a7521834698e5007f697b514af2",
176
+ "cli-contract.md": "45c2c72a8ff9266fb67593ad5f4e86e806d58cec48a1844da02ad1aaa36b8085",
177
177
  "conformance/README.md": "838b1247e1ffb402d96bd8a0fe9c1c0f4a99ed0fbc4bf8156f7a58330cac27f5",
178
178
  "conformance/cases/kernel-empty-boot.json": "ad4bbe9d637537625025c8bdb61285b1433568a2544b1ce0248f304ccff8c350",
179
- "conformance/coverage.md": "4df23b78ec44887dc355e0622b9008bb2514f3b8e9c302db18eb51532fda5275",
179
+ "conformance/cases/plugin-missing-ui-rejected.json": "c6ce8f62a430d662aea33dec8ebf6493be6455037be3114e0d93d0eb57777287",
180
+ "conformance/coverage.md": "bddece19aa42297e75b2b1f6595bc9bb72fc849332a421b099fbdd1e8789e77d",
181
+ "conformance/fixtures/plugin-missing-ui/.skill-map/plugins/bad-provider/plugin.json": "4d78af6f12faa9d131e2a19f1dbb8f250baacc525978f3a8c858932b95da4ff6",
182
+ "conformance/fixtures/plugin-missing-ui/.skill-map/plugins/bad-provider/provider.js": "da40b134d70f8bc8175cfa9c380ecb55d26b2240c8b467f22f3fcfab750c8747",
183
+ "conformance/fixtures/plugin-missing-ui/notes/example.md": "55767f0aa1b6774546a99f28c58e7b732aa9cfa5dfce8d0326470f7f622f577e",
180
184
  "conformance/fixtures/preamble-v1.txt": "1e0aeef224b64477bdc13a949c3ad402e68249caf499ecdba1302371677c068b",
181
185
  "db-schema.md": "cbd2d3395ca4f01065d6f15405c7442d59a468b15448377d6f9373fa6aeff334",
182
186
  "interfaces/security-scanner.md": "4a982667008f233656f44c61ce9948e062432d3debdcbf7a134da03bd4139d7d",
@@ -185,6 +189,7 @@
185
189
  "plugin-author-guide.md": "2dcdf8c570342d94c2c8f8d47594715254e3956d7939443023f1d6420e2b30d0",
186
190
  "plugin-kv-api.md": "04b2178f46fb88adeae9240df9c9e1761b660396072001dac32cd402e11a2d7d",
187
191
  "prompt-preamble.md": "fb40ab510234383326f198dec82cd6d744f28b7432eebac6cbfbb7ca1c483b7d",
192
+ "schemas/api/rest-envelope.schema.json": "7d9d74bcb2158019cb6e30306d40b9c7ffc67e9d202fb8210fe4e4a9e8fa4dab",
188
193
  "schemas/conformance-case.schema.json": "7cd0f3aae5124f24be57cddb213d002d0466f79d06fd3da896075c8b28650410",
189
194
  "schemas/execution-record.schema.json": "9628fa557cb856402f3a5f1d1167c609e46a197c850fe8171abfddd46c1028a8",
190
195
  "schemas/extensions/action.schema.json": "262272175c06a2e33c08f819a45c3ef8260276c91a9d0542fdffc932aeb32db7",
@@ -192,7 +197,7 @@
192
197
  "schemas/extensions/extractor.schema.json": "122d3f81ef91edcde9798e7dc8fcbf442a2996deea65aa4b03c9d5cb01ba2519",
193
198
  "schemas/extensions/formatter.schema.json": "2ab092aa37ae349c69b93071ed4f0e131affb7bb5799516ca82c721262631b36",
194
199
  "schemas/extensions/hook.schema.json": "7465c38e0765edf23e49d4f96c539d04323f1cf564af1c60ee637c79a6d39239",
195
- "schemas/extensions/provider.schema.json": "27c627151fb98cf60763aaa122d807bbf007317f06bd31e92a2fca43c100e4b8",
200
+ "schemas/extensions/provider.schema.json": "518d7666841cfef8eb28aab788a6e6dfabf9e12f3f06de1c81be915cbe6c1088",
196
201
  "schemas/extensions/rule.schema.json": "8ff420bde498f50db114c352305d487c71aef2dd746fd0c24976ff6a09865c22",
197
202
  "schemas/frontmatter/base.schema.json": "dfee192458765b8cb872ef9e7145ec31b9e07ceb19ee44be48af2172329e7a38",
198
203
  "schemas/history-stats.schema.json": "23f472d1de06d23fc775aabba821f8375f347af4dc8d89ba567980d61a11f9de",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skill-map/spec",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "description": "JSON Schemas, prose contracts, and conformance suite for the skill-map specification.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,170 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://skill-map.dev/spec/v0/api/rest-envelope.schema.json",
4
+ "title": "RestEnvelope",
5
+ "description": "Wrapper shape for REST responses under `/api/*` (Step 14.2). Three variants distinguished by the `kind` discriminator and which payload field is present (`items` for list kinds, `item` for single-resource kinds, `value` for `kind: 'config'`). The `/api/scan` and `/api/health` responses are exempt — they carry the underlying `ScanResult` / `IHealthResponse` shape directly. The `/api/graph` response is also exempt — it returns the formatter's native textual output (text/plain or text/markdown). Step 14.5.d adds the required `kindRegistry` field on every payload-bearing variant so the UI can render Provider-declared kinds (label, color, icon) without hardcoding visuals; sentinel kinds (`health`, `scan`, `graph`) stay exempt because they don't carry an envelope payload. The change keeps `schemaVersion` at `'1'` — the BFF is greenfield (no released consumers run against `'1'` without `kindRegistry`), so a versioned migration buys nothing.",
6
+ "type": "object",
7
+ "required": ["schemaVersion", "kind"],
8
+ "properties": {
9
+ "schemaVersion": {
10
+ "type": "string",
11
+ "const": "1",
12
+ "description": "Envelope shape version. Bumped only on breaking changes. The Step 14.5.d addition of `kindRegistry` keeps the version at `'1'` because there are no released consumers in the wild."
13
+ },
14
+ "kind": {
15
+ "type": "string",
16
+ "enum": ["nodes", "links", "issues", "plugins", "config", "graph", "node", "health", "scan"],
17
+ "description": "Discriminator. List kinds (`nodes`, `links`, `issues`, `plugins`) carry `items`. The `node` kind carries `item`. The `config` kind carries `value`. The `health` / `scan` / `graph` values are reserved for documentation parity with the routes that DON'T use this envelope."
18
+ },
19
+ "items": {
20
+ "type": "array",
21
+ "description": "Present when `kind` is one of the list kinds (`nodes`, `links`, `issues`, `plugins`). Empty array is valid and means the filter matched zero rows."
22
+ },
23
+ "item": {
24
+ "type": "object",
25
+ "description": "Present when `kind` is `'node'`. Single-resource envelope payload."
26
+ },
27
+ "value": {
28
+ "type": "object",
29
+ "description": "Present when `kind` is `'config'`. Carries the merged effective config object."
30
+ },
31
+ "filters": {
32
+ "type": "object",
33
+ "description": "Echo of the URL filters the server applied, normalized into a JSON-friendly shape (arrays for multi-value filters, `null` for absent ones). Helps the client correlate the response with the request."
34
+ },
35
+ "kindRegistry": {
36
+ "type": "object",
37
+ "description": "Catalog of node kinds active in the current scope, keyed by kind name. Built once per server boot from every enabled Provider's `kinds` map and embedded into every payload-bearing envelope so the UI can render kind tags / palette swatches / graph nodes against Provider-declared visuals (label, color, icon) without ever hardcoding a closed kind enum. Sentinel envelopes (`health`, `scan`, `graph`) are exempt.",
38
+ "additionalProperties": {
39
+ "type": "object",
40
+ "required": ["providerId", "label", "color"],
41
+ "additionalProperties": false,
42
+ "properties": {
43
+ "providerId": {
44
+ "type": "string",
45
+ "minLength": 1,
46
+ "description": "Id of the Provider that contributed this kind. Lets the UI scope kind ownership and disambiguate when two Providers happen to declare the same kind name (kernel surfaces this as `provider-ambiguous` but the UI may still receive the merged registry during the conflict window)."
47
+ },
48
+ "label": { "type": "string", "minLength": 1 },
49
+ "color": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$" },
50
+ "colorDark": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$" },
51
+ "emoji": { "type": "string", "minLength": 1, "maxLength": 8 },
52
+ "icon": {
53
+ "oneOf": [
54
+ {
55
+ "type": "object",
56
+ "required": ["kind", "id"],
57
+ "additionalProperties": false,
58
+ "properties": {
59
+ "kind": { "const": "pi" },
60
+ "id": { "type": "string", "pattern": "^pi-[a-z0-9]+(-[a-z0-9]+)*$" }
61
+ }
62
+ },
63
+ {
64
+ "type": "object",
65
+ "required": ["kind", "path"],
66
+ "additionalProperties": false,
67
+ "properties": {
68
+ "kind": { "const": "svg" },
69
+ "path": { "type": "string", "minLength": 1 }
70
+ }
71
+ }
72
+ ]
73
+ }
74
+ }
75
+ }
76
+ },
77
+ "counts": {
78
+ "type": "object",
79
+ "required": ["total", "returned"],
80
+ "properties": {
81
+ "total": {
82
+ "type": "integer",
83
+ "minimum": 0,
84
+ "description": "Total rows after filtering, before pagination is applied."
85
+ },
86
+ "returned": {
87
+ "type": "integer",
88
+ "minimum": 0,
89
+ "description": "Rows actually carried in `items` (≤ `limit`)."
90
+ },
91
+ "page": {
92
+ "type": "object",
93
+ "required": ["offset", "limit"],
94
+ "properties": {
95
+ "offset": {
96
+ "type": "integer",
97
+ "minimum": 0,
98
+ "description": "Pagination window offset (zero-based)."
99
+ },
100
+ "limit": {
101
+ "type": "integer",
102
+ "minimum": 0,
103
+ "description": "Maximum items the page may carry."
104
+ }
105
+ },
106
+ "additionalProperties": false,
107
+ "description": "Pagination window. Present when the endpoint paginates (today: `/api/nodes` only)."
108
+ }
109
+ },
110
+ "additionalProperties": false,
111
+ "description": "Tally + paging info. Present on every list / single envelope; absent on `health` / `scan` / `graph` responses (which don't use this envelope)."
112
+ }
113
+ },
114
+ "oneOf": [
115
+ {
116
+ "description": "List envelope — `items` payload + `counts` + `kindRegistry`.",
117
+ "required": ["items", "counts", "filters", "kindRegistry"],
118
+ "properties": {
119
+ "kind": { "enum": ["nodes", "links", "issues", "plugins"] }
120
+ },
121
+ "not": {
122
+ "anyOf": [
123
+ { "required": ["item"] },
124
+ { "required": ["value"] }
125
+ ]
126
+ }
127
+ },
128
+ {
129
+ "description": "Single-resource envelope — `item` payload + `kindRegistry`, no `counts` / `filters`.",
130
+ "required": ["item", "kindRegistry"],
131
+ "properties": {
132
+ "kind": { "const": "node" }
133
+ },
134
+ "not": {
135
+ "anyOf": [
136
+ { "required": ["items"] },
137
+ { "required": ["value"] }
138
+ ]
139
+ }
140
+ },
141
+ {
142
+ "description": "Value envelope — `value` payload + `kindRegistry`, no `counts` / `filters`.",
143
+ "required": ["value", "kindRegistry"],
144
+ "properties": {
145
+ "kind": { "const": "config" }
146
+ },
147
+ "not": {
148
+ "anyOf": [
149
+ { "required": ["items"] },
150
+ { "required": ["item"] }
151
+ ]
152
+ }
153
+ },
154
+ {
155
+ "description": "Sentinel kinds — reserved for routes that do NOT carry an envelope payload at the wire level (`health`, `scan`, `graph`). They do not carry `kindRegistry` either; clients that need it must call any payload-bearing endpoint at boot.",
156
+ "properties": {
157
+ "kind": { "enum": ["health", "scan", "graph"] }
158
+ },
159
+ "not": {
160
+ "anyOf": [
161
+ { "required": ["items"] },
162
+ { "required": ["item"] },
163
+ { "required": ["value"] },
164
+ { "required": ["kindRegistry"] }
165
+ ]
166
+ }
167
+ }
168
+ ],
169
+ "additionalProperties": false
170
+ }
@@ -31,7 +31,7 @@
31
31
  },
32
32
  "additionalProperties": {
33
33
  "type": "object",
34
- "required": ["schema", "defaultRefreshAction"],
34
+ "required": ["schema", "defaultRefreshAction", "ui"],
35
35
  "additionalProperties": false,
36
36
  "properties": {
37
37
  "schema": {
@@ -43,6 +43,66 @@
43
43
  "type": "string",
44
44
  "pattern": "^[a-z][a-z0-9]*(-[a-z0-9]+)*/[a-z][a-z0-9]*(-[a-z0-9]+)*$",
45
45
  "description": "Qualified action id (`<plugin-id>/<action-id>`) the UI's probabilistic-refresh surface (`🧠 prob`) dispatches for nodes of this kind. The action MUST exist in the registry by the time the graph is queried; a dangling reference disables the Provider with status `invalid-manifest`."
46
+ },
47
+ "ui": {
48
+ "type": "object",
49
+ "required": ["label", "color"],
50
+ "additionalProperties": false,
51
+ "description": "Presentation metadata the UI uses to render nodes of this kind (palette swatches, list tags, graph nodes, filter chips). Required so the UI never has to invent visuals for a kind a Provider declares. The Provider declares intent (label + base color, optional dark variant + emoji + icon); the UI derives bg/fg tints from `color` per theme via a deterministic helper. Reaches the UI via the `kindRegistry` field embedded in REST envelopes (`api/rest-envelope.schema.json`).",
52
+ "properties": {
53
+ "label": {
54
+ "type": "string",
55
+ "minLength": 1,
56
+ "description": "Plural human-readable label for groups of this kind (e.g. `'Skills'`, `'Agents'`, `'Cursor Rules'`). Used in filter dropdowns, palette tooltips, and any list grouping."
57
+ },
58
+ "color": {
59
+ "type": "string",
60
+ "pattern": "^#[0-9a-fA-F]{6}$",
61
+ "description": "Base hex color (light theme). The UI derives `bg` and `fg` tints from this value at runtime; declaring a single base value (instead of three) keeps the manifest small and lets the UI control accessibility-driven contrast."
62
+ },
63
+ "colorDark": {
64
+ "type": "string",
65
+ "pattern": "^#[0-9a-fA-F]{6}$",
66
+ "description": "Optional dark-theme variant of `color`. When absent, the UI falls back to `color`. Declared explicitly because a luminosity flip rarely matches the brand intent for kinds that should stand out in dark mode."
67
+ },
68
+ "emoji": {
69
+ "type": "string",
70
+ "minLength": 1,
71
+ "maxLength": 8,
72
+ "description": "Optional decorative emoji used as a fallback when `icon` is absent or fails to render. Bound to a small length so the UI can lay it out predictably alongside text."
73
+ },
74
+ "icon": {
75
+ "description": "Optional discriminated icon descriptor. The UI prefers `icon` over `emoji`; when both are absent, the UI falls back to the first letter of `label` colored with `color`.",
76
+ "oneOf": [
77
+ {
78
+ "type": "object",
79
+ "required": ["kind", "id"],
80
+ "additionalProperties": false,
81
+ "properties": {
82
+ "kind": { "const": "pi" },
83
+ "id": {
84
+ "type": "string",
85
+ "pattern": "^pi-[a-z0-9]+(-[a-z0-9]+)*$",
86
+ "description": "PrimeIcons identifier (e.g. `pi-cog`, `pi-bolt`). Matched verbatim against the `pi pi-<id>` class the UI emits."
87
+ }
88
+ }
89
+ },
90
+ {
91
+ "type": "object",
92
+ "required": ["kind", "path"],
93
+ "additionalProperties": false,
94
+ "properties": {
95
+ "kind": { "const": "svg" },
96
+ "path": {
97
+ "type": "string",
98
+ "minLength": 1,
99
+ "description": "Raw SVG path data (the `d` attribute of one or more `<path>` elements, joined). The UI wraps it in `<svg viewBox=\"0 0 24 24\"><path d=\"...\"/></svg>` and tints it with `currentColor`."
100
+ }
101
+ }
102
+ }
103
+ ]
104
+ }
105
+ }
46
106
  }
47
107
  }
48
108
  }