# skill-map — execution history

> Roadmap-history record. Tracks how each shipped Step actually landed
> (sub-step prose, test counts, bug fixes, review-queue closures).
>
> Workspace npm releases are in their own changelogs:
> `spec/CHANGELOG.md`, `src/CHANGELOG.md`, `testkit/CHANGELOG.md`.
>
> Forward-looking plan: see `ROADMAP.md`.

## v0.6.0 — deterministic kernel + CLI + Web UI (2026-05-09)

### Step 14 — Full Web UI

> Step 14 ships v0.6.0 (deterministic + Web UI) before wave 2 resumes (Decision #118). Loopback-only through 14.x; multi-host serve + auth deferred (Decision #119).

Foundational invariants (locked at the pivot, hold across all sub-steps):

- **Hono** BFF with WebSocket `/ws` — thin proxy over the kernel, no domain logic. Pinned exact version per AGENTS.md dep-pinning rule.
- **Single-port mandate**: Hono serves the Angular SPA (`serveStatic` over `ui/dist/ui/browser/` in dev, or the package-bundled `<package>/dist/ui/` in installed mode), the REST endpoints, and the WS under one listener. Dev uses Angular dev server + `proxy.conf.json` pointing to Hono for `/api` and `/ws`.
- `sm serve --port N` is the single entry point: one process, one port, one command. Default port `4242`, default host `127.0.0.1` (never `0.0.0.0`).
- UI consumes real kernel via a `DataSourcePort` abstraction with two impls (`RestDataSource` for live mode, `StaticDataSource` for the demo).
- **Demo mode is a first-class output**: the Angular bundle ships under `web/demo/` for the public site, reading a precomputed JSON dataset (no backend, no `sm` install). Mode discriminator at build time via `<meta name="skill-map-mode" content="live|demo">`.
- BFF lives at `src/server/` (peer of `src/cli/`, not under `src/cli/adapters/` — Hono is a driver, not a kernel port impl). Same kernel-boundary rules apply (no `console.*`, no `process.cwd / homedir`, all i18n via `tx()`).

The work split into seven sub-steps that shipped as separate changesets:

- **14.1 — `sm serve` + Hono BFF skeleton**. New `src/server/` (`index.ts`, `app.ts`, `static.ts`, `ws.ts`, `options.ts`, `paths.ts`) plus `src/server/i18n/server.texts.ts`. Move `ServeCommand` from `src/cli/commands/stubs.ts:294` to a real `src/cli/commands/serve.ts`. Flag surface: `--port` (default `4242`), `--host` (default `127.0.0.1`, refuses non-loopback combined with `--dev-cors`), `--scope project|global`, `--db <path>`, `--no-built-ins`, `--no-plugins`, `--open` / `--no-open`, `--dev-cors`, `--ui-dist <path>` (hidden, used by demo build + tests). Single-port wiring order: `/api/*` (skeleton) → `/ws` (no-op handler — broadcaster lands at 14.4) → `serveStatic` at `/*` rooted at `resolveDefaultUiDist(runtimeCtx)` (three-branch resolver: explicit `--ui-dist` → package-bundled `<package>/dist/ui/` for installed mode → upward walk for `ui/dist/ui/browser/` in monorepo dev) → SPA fallback. Graceful shutdown on SIGINT/SIGTERM. Exit codes: 0 clean shutdown, 2 bind failure / missing UI bundle / bad flag, 5 `--db` not found. Boot succeeds even when the DB is missing — `/api/health` reports `db: missing` so the SPA renders an empty-state CTA instead of failing the connection. Spec edit: `spec/cli-contract.md` `sm serve` row extended with the new flag set + new `### Server` subsection skeleton (filled at 14.2).
- **14.2 — REST read-side endpoints + DataSource contract**. Endpoint catalogue: `GET /api/health` (`{ok, schemaVersion, specVersion, implVersion, scope, db: 'present'|'missing'}`), `GET /api/scan` (latest persisted ScanResult; `?fresh=1` runs in-memory scan without persisting), `GET /api/nodes?kind=&hasIssues=&path=&limit=&offset=`, `GET /api/nodes/:pathB64` (base64url-encoded path; helper `encodeNodePath` / `decodeNodePath`), `GET /api/links?kind=&from=&to=`, `GET /api/issues?severity=&ruleId=&node=`, `GET /api/graph?format=ascii|json|md`, `GET /api/config`, `GET /api/plugins`. All read-only at 14.2; mutations come post-v0.6.0. Wire schema: `/api/scan` returns `ScanResult` 1:1 with `scan-result.schema.json` (byte-equal to `sm scan --json`); list endpoints use a thin envelope `{schemaVersion, kind, items, filters, counts}`. New schema `spec/schemas/api/rest-envelope.schema.json` (additive minor for spec). Query adapter `src/server/query-adapter.ts` reuses `parseExportQuery` from `src/kernel/scan/query.ts` — one grammar, two transports (URL params + `sm export` mini-query). Error envelope mirrors `cli-contract.md --json` shape: `{ok: false, error: {code, message, details}}` with codes `not-found` / `bad-query` / `db-missing` / `internal`. HTTP mapping: 400 `bad-query`, 404 `not-found`, 500 `internal` / `db-missing`. Hono `onError` funnels through `formatErrorMessage` from `src/cli/util/error-reporter.ts`.
- **14.3 — UI vertical slice (Flavor B) + DataSourcePort + demo build pipeline**. Angular DataSource abstraction at `ui/src/services/data-source/`: `data-source.port.ts` (interface with `health` / `loadScan` / `listNodes` / `getNode` / `listLinks` / `listIssues` / `loadGraph` / optional `events()`), `rest-data-source.ts` (live mode), `static-data-source.ts` (demo mode), `data-source.factory.ts` (switches on injected `MODE` token), `path-codec.ts` (base64url mirror of the server helper). Mode discriminator: `<meta name="skill-map-mode" content="live|demo">` read once at bootstrap by `runtime-mode.ts`; default `live` in `ui/src/index.html`, patched to `demo` by the demo build script. `CollectionLoaderService` migrates to consume `DataSourcePort`. **Files dying at 14.3** (per Step 0c throwaway markers): `event-bus.ts`, `scan-simulator.ts`, `mock-links.ts`, `mock-summary.ts`. **Survives + adapts**: `FilterStoreService` (gains URL sync via new `FilterUrlSyncService` — closes the open pick from §1699), `ThemeService` (untouched). Inspector gains three `<sm-empty-state>` placeholders for enrichment / summary / findings with copy "Available in v0.8.0". MD renderer pick: **`markdown-it@14.x` + DOMPurify@3.x** (pinned exact); reasoning recorded in Decision #120. New `ui/src/services/markdown-renderer.ts` runs DOMPurify before `bypassSecurityTrustHtml`. Lazy-load the graph view (Foblex Flow + dagre is the heaviest chunk) and `markdown-it` on first inspector render to keep bundle headroom; the 500 KB warning threshold flip is deferred to 14.6 explicitly to avoid blocking 14.3 on a tree-shake side-quest. **Demo build pipeline** (cross-cutting): relocate `ui/mock-collection/` → `ui/fixtures/demo-scope/` (clearer naming — no longer a runtime data source). New `web/scripts/build-demo-dataset.js` runs `sm scan --json` over the fixture, emits `web/demo/data.json` (full ScanResult) + `web/demo/data.meta.json` (pre-derived per-endpoint envelopes so the StaticDataSource never re-runs `applyExportQuery` in the browser). New `scripts/patch-demo-mode.js` rewrites `<meta name="skill-map-mode">` and `<base href="/demo/">` (hardcoded sub-path; configurability deferred until a second deployment forces it). Top-level `npm run demo:build` orchestrates: `npm run build --workspace=ui` → `node web/scripts/build-demo-dataset.js` → `cp -R ui/dist/browser/. web/demo/` → `node scripts/patch-demo-mode.js`. `web/scripts/build-site.js` gains a dependency on `demo:build` so the public site always ships a fresh demo. Demo banner copy: *"You are viewing a static demo of skill-map's UI. Run `npx @skill-map/cli serve` for the full experience."* (dismissible). Event log component renders empty state in demo mode (no canned events at 14.3 — the `EventStreamPort` is wired at 14.4).
- **14.4 — WS broadcaster + chokidar wiring + live events**. `WsEventStreamService` connects to `/ws` and pushes scan events live. The chokidar watcher from Step 7.1 plugs into the broadcaster: each debounced batch runs `runScanWithRenames` + `persistScanResult` (server-side persistence, **same behavior as `sm watch` — Decision #121: a server with stale DB is a footgun**) and fans out the resulting `scan.*` events over WS. WS auth: **loopback-only assumption** (Decision #119) — no nonce per-connection through 14.x. Multi-host + auth design re-opens post-v0.6.0 alongside the dashboard / non-loopback story. Splits into 14.4.a (BFF: broadcaster + watcher + composition-root lifecycle + `--no-watcher` flag + spec `WebSocket protocol` subsection) and 14.4.b (UI: `WsEventStreamService` consuming `/ws` + reconnect / re-seed flow + event-log integration).
- **14.5 — Inspector polish**. Anything from the original Inspector spec that didn't fit into 14.3 (relation chips upgraded to consume real `scan_links` aggregation per Step 7.2, kind-specific cards rendering server-validated frontmatter, dead-link indicators wired to `/api/nodes/:pathB64` 404 responses).
- **14.6 — Foblex strict types + dark-mode tri-state + bundle-budget tightening**. Three landings: (a) Foblex Flow strict-typing pass — `<f-connection [fType]>` / `[fBehavior]` and `<f-connection-marker-arrow [type]>` switched from string literals to property bindings against `EFConnectionType.SEGMENT` / `EFConnectionBehavior.FIXED` / `EFMarkerType.END_ALL_STATES`, so a future enum rename surfaces at compile time. (b) Dark-mode grows into a system-preference-aware tri-state (`auto` / `light` / `dark`) with `(prefers-color-scheme: dark)` listened live in `auto` mode; the user-chosen mode (not the resolved theme) persists to localStorage. App-shell theme toggle cycles `auto → light → dark → auto` with `pi-desktop` / `pi-sun` / `pi-moon` icons. (c) Bundle-budget tightening — `angular.json` warning lowered from 600 kB → 650 kB AND error from 1 MB → 800 kB (defensive, ~56 kB headroom over today's 594 kB initial). New `analyze` configuration in `angular.json` enables non-hidden vendor sourcemaps without touching `production`; root `npm run bundle-analyze --workspace=ui` script runs `source-map-explorer` against the analyze build for reproducible composition reports. Source-map-explorer added as exact-pinned root devDep (`source-map-explorer@2.5.3`). The original "≤ 500 kB hard cut" target moved to 14.7: investigation traced 110 kB of the 417 kB eager chunk to PrimeNG/Aura provider wiring (`providePrimeNG` core 56 kB + `@primeuix/themes/aura` preset 54 kB) and 275 kB to baseline `@angular`; closing the 95 kB gap requires `provideAppInitializer`-driven lazy preset loading + a native theme-toggle button + lazy `primeng/config` — invasive bootstrap surgery that warrants its own iteration rather than blocking the 14.6 polish landings.
- **14.7 — Responsive scope + production polish + bundle hard cut (✅ early) + `web/` demo publish + smoke test**. Bundle hard cut **landed first** and unblocked the rest: `providePrimeNG({})` is now empty at bootstrap, `provideAppInitializer` dynamic-imports `@primeuix/themes/aura` and feeds it through `PrimeNG.setThemeConfig({ theme: { preset, options: { darkModeSelector: '.app-dark' } } })` before first render. Angular awaits the initializer promise during bootstrap so there is no flash of unstyled content. Single-change delta: −108 kB on the initial total (594 kB → 485 kB). The `angular.json` initial budget tightened from 650 kB to **500 kB warning / 650 kB error** to match the roadmap target. The originally-planned (b) native `<button>` theme toggle and (c) lazy `primeng/config` core sub-cuts were dropped — (a) alone cleared the gap, and dropping them keeps the visual contract of the shell unchanged. Reproducible composition reports remain available via `npm run bundle-analyze --workspace=ui` (added in 14.6). Remaining work: responsive scope decision (whether to support tablet / mobile beyond the current ≥1024px assumption), demo smoke test (Playwright pick + script that loads `web/demo/index.html`, asserts `MODE === 'demo'`, exercises list / inspector / graph / filter, fails if any UI surface tries to fetch `/api/...`), wire `npm run demo:build` into the CI / publish flow, and the public site update.

#### Completeness marker (2026-05-03)

Steps **0a–9**, **14.1–14.4**, **14.5 (a + b + c + d)**, and **14.6** are complete; **14.7** is in flight, with the bundle hard cut, responsive-scope decision, and the demo smoke test already landed. Remaining 14.7 work: CI wiring for `npm run demo:build` and the public-site update. 14.6 shipped the Foblex Flow strict-types pass (connection `fType` / `fBehavior` / marker `type` migrated from string literals to the `EFConnectionType` / `EFConnectionBehavior` / `EFMarkerType` enums), the dark-mode tri-state (`auto` / `light` / `dark` with live `prefers-color-scheme` listening, persisted via a single `localStorage` slot), and a defensive bundle-budget tightening (warning 650 kB) before 14.7 dropped the threshold to the roadmap target (warning **500 kB** / error **650 kB**). The 14.7 bundle hard cut moved the `@primeuix/themes/aura` preset out of the eager chunk via `provideAppInitializer` (`providePrimeNG({})` empty + dynamic-imported preset applied via `PrimeNG.setThemeConfig` before first render); single-landing impact was −108 kB on the initial total (594 kB → **485 kB**), so the speculative (b) "native theme-toggle button" and (c) "lazy `primeng/config` core" sub-tasks were dropped. The Playwright initializer revealed an NG0203 trap — `inject()` MUST be called synchronously before any `await` inside a `provideAppInitializer` factory; the post-await call loses Angular's injection context and the app silently never boots. The factory now captures `inject(PrimeNG)` first, then awaits the dynamic import. Responsive scope landed as a **mobile guard below 768px**: a CSS-only full-screen overlay (`pi-desktop` icon, friendly "Looks like you're on a small screen" copy) replaces the shell entirely below the breakpoint — the broken desktop layout is never shown, since the previous sticky-banner-over-broken-shell shape was rejected after testing. Between 768px and 1023px the desktop shell renders as-is (compact topbar, may look tight); ≥1024px is the design target. Demo smoke test ships as a new `e2e/` private workspace (Playwright 1.59.1, Chromium-only, deps-free Node static server under `/demo/`); three specs cover: boots-without-console-errors-in-demo-mode, never-fetches-`/api/*` (the regression guard the roadmap asks for explicitly), and the three-views routing. **Next**: public-site update (deferred to the Step 15 release pipeline). The CI wiring landed earlier via the root `validate` orchestrator (e2e workspace runs `demo:build` + Playwright through its `prevalidate` + `validate` scripts). Together with 14.6, 14.7 closes `v0.6.0` (deterministic kernel + CLI + Web UI). Wave 2 (job subsystem + LLM layer) resumes after Step 14 closes and ships `v0.8.0`.

### Step 9.6 — Annotation system (sidecar `.sm` files) — ✅ shipped 2026-05-07

> ✅ **Done.** Closes the deferred decision from Step 9.5. Skill-map's annotation layer (today's `metadata` block plus `type` / `author` / `authors` / `license` — none of which Anthropic owns) lives in **co-located YAML sidecars with extension `.sm`** in the same directory as the markdown node they annotate. Architectural consensus reached in conversation 2026-05-05; sixteen conceptual decisions captured in `memory/project_annotation_architecture.md` and formally recorded in Decision #125. Shipped ahead of Step 10 so the `skill-summarizer` pipeline writes against a stable annotation surface — same gating rationale as Step 9.5.
>
> **Status (2026-05-07):** Sub-steps 9.6.1 → 9.6.6 ✅ (both halves of 9.6.5 — CLI + BFF — and the 9.6.6 BFF endpoint included); review-queue cleanup pass 9.6.7 ✅ closed R7 (envelope `kind` enum gap) and R9 (WS event shape asymmetry). Review queue R1–R15: **all 15 ✅ closed**. R6 closed by-design (no `js-yaml` swap; contract is "bump rewrites the file; narrative in `.md` body"). R15 closed by extending `Node.sidecar` overlay with `root` so the BFF ships the full parsed `.sm` payload to the UI inspector. Loop-closure pass on top of catalog curation: UI inspector tiering applied (4-tier vendor display, 5 categorised annotations sub-sections, plugin contributions surface, debug panel); `report-base-deterministic.schema.json` introduced; live-BFF Playwright harness wired. Plus a separate retrospective fix pass (B1–B3 + S1–S3) closed three real bugs and three spec gaps the medium-effort orchestration missed.

#### Architecture summary (the decisions, abridged)

The full rationale lives in the auto-memory file. Quick reference:

- **Layout**: vendor file `.claude/agents/code-reviewer.md` stays untouched; sidecar `.claude/agents/code-reviewer.sm` carries skill-map annotations. Co-located, not mirror tree.
- **Format**: YAML, extension `.sm` (not `.md.sm`).
- **Top-level reserved blocks**: `for` (identity link), `annotations` (the catalog), `settings` (future), `audit` (future). Plugins write to their `<plugin-id>:` block by default; opt-in to root via manifest with exclusive ownership.
- **Identity**: `for: { path, bodyHash, frontmatterHash }`. Both hashes per existing `node.schema.json`. Drift = mismatch in either; soft-mode warning, not blocking.
- **Annotations catalog**: 15 typed fields (curated 2026-05-07 from an initial 31-field surface) covering versioning, supersession, provenance, lifecycle, taxonomy, display, documentation. Schema is `additionalProperties: true` so plugin / user extensions ride freely; Tier 1 `unknown-field` rule warns on truly unrecognized keys.
- **Version**: single integer monotonic, orthogonal to `stability`. Major bumps mean "create a new node", not increment.
- **Bump triggers**: manual UI button (gated by drift), `sm bump` CLI, opt-in pre-commit hook (`sm hooks install pre-commit-bump`). Watch mode never auto-bumps; computes "stale" state on demand from hash comparison.
- **Migration**: greenfield — no automatic migration of pre-9.6 `metadata: {}` blocks. Optional CLI helper for users who want to port their existing metadata.
- **Hook bridge to vendor runtime** (e.g., warn-on-deprecated): NOT in Step 9.6 scope. Will land post-v1.0 as Action with extended manifest, not as a 7th plugin kind.

#### Sub-step plan

- **9.6.1 — Sidecar schema spec** ✅ (2026-05-05). Shipped `spec/schemas/sidecar.schema.json` (root shape with reserved blocks `for` / `annotations` / `settings` / `audit` + `additionalProperties: true`) and `spec/schemas/annotations.schema.json` (25-field catalog). Conformance fixture `spec/conformance/fixtures/sidecar-example/` carries a sample `.md` + matching `.sm`; coverage matrix has rows 26 + 27 marked 🟠 deferred (cases land in 9.6.6). `spec/index.json` regenerated. Changeset for `@skill-map/spec` minor. No code changes — shape locked.

- **9.6.2 — Kernel sidecar reader + drift detection** ✅ (2026-05-05). Walker reads `<basename>.sm` next to every `<basename>.md` and validates via the kernel AJV stack against `sidecar.schema.json` + `annotations.schema.json`. New module `src/kernel/sidecar/{parse,drift,discover-orphans,index}.ts` carries the surface; the orchestrator threads results into a new `Node.sidecar` overlay (typed `{ present, status, annotations }`) and a list of orphan `.sm` paths surfaced to rules. Migration `002_sidecar_columns.sql` extends `scan_nodes` with `sidecar_present` / `sidecar_status` / `annotations_json` (Decision #3 — option (a), extend the existing table). The kernel hard-cuts `stability` / `version` / `author` source from `frontmatter.metadata.*` to sidecar `annotations.{stability, version, author}`; `Node.version` flips from `string` semver to `integer` monotonic per Decision #125 (greenfield migration — no semver→integer auto-conversion). Two new built-in Rules: `core/annotation-stale` (`warn`) emits per stale node; `core/annotation-orphan` (`warn`) emits per `.sm` whose `.md` is missing. Schema-invalid / malformed-YAML sidecars produce `invalid-sidecar` warnings without crashing the scan. Tests at `src/test/sidecar-reader.test.ts` cover fresh / stale-body / stale-frontmatter / orphan / malformed-YAML / schema-invalid / unknown-key / persistence-round-trip. Coverage rows 26 + 27 flip from 🟠 deferred to 🟡 partial — direct end-to-end conformance case still lands in 9.6.6.

- **9.6.3 — Bump Action + sidecar write channel** ✅ (2026-05-05). Shipped the additive `IAction` runtime extension (optional `invoke()` returning `{ report, writes? }` where `writes` is a discriminated union, today only `{ kind: 'sidecar', path, changes }`), the `ISidecarStore` port + `FilesystemSidecarStore` impl (path-keyed in-process mutex, deep-merge that recurses into objects and replaces arrays, AJV revalidation of the merged result, atomic `.tmp + rename` write), and the built-in deterministic `bump` Action under `core/bump`. Behaviour: stale node (or first-time creation) → patch increments `annotations.version`, refreshes `for.{bodyHash, frontmatterHash}`, fills `audit.lastBumpedAt` + `lastBumpedBy` (and `createdAt` + `createdBy` on first creation); fresh node without `force` → refusal (`{ ok: false, reason: 'fresh' }`, no writes); fresh node with `force: true` → silent no-op. The `audit:` sub-shape is now formalised in `sidecar.schema.json` (`lastBumpedAt` / `lastBumpedBy` / `bumpReason` / `createdAt` / `createdBy`, all optional at the property level, `additionalProperties: true`). New `bump-report.schema.json` carries the deterministic report shape (out-of-band from `report-base.schema.json` which is LLM-specific). `IActionContext` introduced (`node`, `nodeAbsolutePath`, `invoker`, `now`) — Actions stay pure; the kernel materialises returned writes through `ISidecarStore` after the call returns. Tests: `src/test/sidecar-store.test.ts` (create / merge-preserving-plugin-namespace / concurrent-serialisation / schema-invalid-rollback / no-tmp-leftover) and `src/test/bump-action.test.ts` (refusal / silent-noop / stale-bump-with-reason / first-time-creation / round-trip-through-store-preserves-plugin-namespace). Coverage matrix row 26 stays 🟡 partial; row 28 (`bump-report.schema.json`) lands as 🔴 missing — direct conformance case ships with `sm bump --json` in 9.6.4.

- **9.6.4 — CLI verbs** ✅ (2026-05-06). Shipped six verbs split between the top-level `sm bump` (high-frequency, ROADMAP-named — Decision #125) and the `sm sidecar` sub-namespace for administrative helpers (so `sm refresh`, the existing Step A.8 enrichment-layer verb, stays untouched). Single-node `sm bump <node-path> [--force]` wraps `core/bump`: refusal on a fresh node (exit 2) unless `--force`, silent no-op with `--force` on a fresh node, real bump on a stale or first-time node (writes through `FilesystemSidecarStore`). Batch `sm bump --pending [--staged] [--force]` walks every node whose sidecar overlay reports drift in `node.path` ASC order; the `--json` envelope is `{ bumped, refused, skipped, errors[], elapsedMs }`. `--staged` runs `git add` per successful bump; preflight enforces the spec error matrix — not in a git repo → exit 5, `git` binary missing on PATH → exit 2 (Decision A6). `sm sidecar refresh` syncs `for.{bodyHash, frontmatterHash}` only (no version bump, no audit touch); `sm sidecar prune [--dry-run]` deletes orphan `.sm` files (different domain from `sm orphans`); `sm sidecar annotate [--force]` is a pure scaffolder — the legacy `--from-frontmatter` migration helper is deferred (Decision A4). `sm hooks install pre-commit-bump [--dry-run]` installs (or chains into) a git pre-commit hook running `sm bump --pending --staged`; idempotent re-install via the embedded marker. Per Decision A5 the verbs always pass `invoker: 'cli'` (literal — no per-verb granularity). Per Decision A3 the `js-yaml` round-trip drops comments + key order; documented verbatim in `spec/cli-contract.md` ("`.sm` files are managed artifacts; comments and key order are not preserved on round-trip"). Tests at `src/test/{bump-cli,sidecar-cli,hooks-cli}.test.ts` cover the refusal / first-time-creation / batch / staged-with-real-git / dry-run / chained-hook / idempotent-reinstall / scaffold paths. CLI reference regenerated; changeset for `@skill-map/cli` + `@skill-map/spec` minor.

- **9.6.5 — UI integration: stale + bump** ✅ (2026-05-06). Card receives stale-badge surface (orange icon) when reader reports drift on the node. Inspector shows current annotations as readable surface. Bump button in inspector — disabled when no drift, dispatched as Action call when clicked, live-updates via WS. List view filter `--stale`. Tests: card render with/without drift; inspector bump button enable state; e2e bump flow. **BFF surface (shipped 2026-05-06)**: `POST /api/sidecar/bump` mirrors the `sm bump` verb 1:1 (same `core/bump` Action, same `FilesystemSidecarStore`, same fresh-vs-stale refusal semantics; only differences are `invoker: 'ui'` vs `'cli'` and the wire shape); 200 envelope kind `'sidecar.bumped'` with `{ value: { nodePath, version, status: 'fresh' }, elapsedMs }`; 409 `'sidecar-fresh'` on refusal; 404 on unknown path; 400 on malformed body. Successful bumps fan out a `sidecar.bumped` WS event over `/ws` (flat `{ type, nodePath, version, status }`); force-on-fresh no-op responses do NOT broadcast (decision: no-op = no event). Tests at `src/test/server-sidecar-endpoint.test.ts`. Spec contract documented in `spec/cli-contract.md` §Sidecar bump → BFF endpoint subsection. Two new review-queue items surfaced — see R7 (envelope kind enum) and R8 (force-on-fresh broadcast policy) below. **UI half (shipped 2026-05-06)**: card stale badge (orange `pi-clock` icon) renders in the footer status cluster when `node.sidecar.status ∈ {'stale-body', 'stale-frontmatter', 'stale-both'}`; tooltip spells out which side drifted. New `<sm-annotations-panel>` reusable component renders the sidecar `annotations:` block as categorised read-only sections (Lifecycle / Supersession / Provenance / Taxonomy / Display / Docs); empty sections collapse, path-typed fields render as clickable chips routed through `(openPath)`, `source` / `docsUrl` open in `target=_blank rel=noopener`, `stability` renders as a coloured `p-tag`. Inspector view embeds the panel inside a new `inspector-card-annotations` card (gated on `node.sidecar?.present`) and adds a "Bump version" button to the header action cluster — disabled when `status === 'fresh'`, enabled when stale OR when no sidecar exists (first-time creation), tooltip explains the disabled-state. New `SidecarService` (`ui/src/services/sidecar.ts`) wraps `POST /api/sidecar/bump` (uses Angular's `HttpClient`, no new deps) and subscribes once at construction to the WS event stream — every `sidecar.bumped` frame patches the in-memory node store via a new `CollectionLoaderService.patchSidecarFromBump` mutator, so the card and inspector re-render without a graph refetch. The flat `sidecar.bumped` event shape (no `timestamp` / `data` envelope) required relaxing the `isWsEvent` runtime guard; a dedicated `isSidecarBumpedEvent` type guard validates the flat siblings. Filter store gains a `staleOnly` signal mirrored to the URL via `FilterUrlSyncService` (new `?staleOnly=true` query param); filter bar gets a `Stale only` toggle button (`pi-clock` icon, `data-testid="filter-stale-only"`). UI tests at `ui/src/app/components/node-card/node-card.spec.ts` (badge gating across the four overlay states), `ui/src/app/components/annotations-panel/annotations-panel.spec.ts` (empty-state branches, section-level rendering, openPath emission), `ui/src/services/sidecar.spec.ts` (POST body shape, error code translation, WS subscription patches the store), plus 9 new cases appended to `ui/src/app/views/inspector-view/inspector-view.spec.ts` (bump button enable matrix, click → service invocation, error banner on `sidecar-fresh` / `not-found`, annotations card visibility). 236 UI tests pass. e2e at `e2e/smoke/sidecar.spec.ts`: 4 cases — `Stale only` filter chip presence, URL flag round-trip, bump button rendering on a selected node, annotations card hidden for nodes without a sidecar overlay. Happy-path bump → badge clears flow stays in unit tests because the e2e harness is demo-bundle-only (no live BFF). Two architectural decisions surfaced: the BFF's flat WS event shape diverges from the `IWsEvent` envelope contract (handled by relaxing the guard — flagged for the review queue alongside R7), and the `INodeApi` / `INodeView` types now publicly carry `sidecar` (consistent with R1 — keep public). No changes to `web/` (the public site does not consume the bump surface). Changeset for `@skill-map/cli` minor (UI ships bundled — `ui/` stays private per project policy).

- **9.6.6 — Plugin annotation contributions** ✅ (2026-05-06). Extended `spec/schemas/extensions/base.schema.json` with the optional `annotationContributions: { <key>: { schema: <inline JSON Schema>, ownership: 'exclusive' | 'shared', location: 'namespaced' | 'root' } }` map (default `location: 'namespaced'` writes to the plugin's `<plugin-id>:` block; `location: 'root'` is elevated trust and REQUIRES `ownership: 'exclusive'`). Per-extension validation in the kernel plugin loader rejects (a) `location: 'root'` with non-`exclusive` ownership and (b) inline schemas that fail to AJV-compile, both as `invalid-manifest`. Cross-plugin collision detection in the runtime composer (`core/runtime/plugin-runtime.ts:loadPluginRuntime`) collects every loaded extension's contributions and **hard-fails** when two plugins claim the same `(key, location: 'root', ownership: 'exclusive')` tuple — `loadPluginRuntime` throws `AnnotationContributionConflictError` and the kernel does NOT boot (the host CLI / BFF surfaces the error and exits non-zero). The kernel exposes the runtime catalog via `kernel.getRegisteredAnnotationKeys(): readonly IRegisteredAnnotationKey[]`, populated once by `registerEnabledExtensions` after every plugin loads; the BFF wraps it via `GET /api/annotations/registered` (read-only, no filters, no pagination — pure projection of the boot-time view, envelope `{ schemaVersion: '1', kind: 'annotations.registered', items, counts: { total } }`; the new `kind` value joins R7 alongside `sidecar.bumped` as the canonical `rest-envelope.schema.json` enum gap to close in one batch). The composition root (`server/index.ts`) instantiates a kernel at boot, stamps the catalog via `setRegisteredAnnotationKeys(pluginRuntime.annotationContributions)`, and threads it through `IAppDeps.kernel` to the route factory; tests at `src/test/server-annotations-endpoint.test.ts` cover empty / populated / mutation-guard cases (3 cases pass). New built-in `core/unknown-field` Tier-1 Rule (`severity: warn`) walks parsed `.sm` sidecars and emits a warning for: (1) keys inside `annotations:` not in the `annotations.schema.json` curated catalog, (2) top-level keys outside the four reserved blocks (`for` / `annotations` / `settings` / `audit`) that are not a registered plugin namespace nor a registered root contribution, and (3) plugin-namespaced values that fail their contributing plugin's schema. The orchestrator threads parsed sidecar roots through to the rule pass via a new `IRuleContext.sidecarRoots: ReadonlyMap<string, Record<string, unknown>>` field plus the catalog itself via `IRuleContext.annotationContributions`. End-to-end conformance case `sidecar-end-to-end` (fixture under `spec/conformance/fixtures/sidecar-end-to-end/`) flips coverage rows 26 + 27 (`sidecar.schema.json` + `annotations.schema.json`) from 🟡 partial to 🟢 covered: a stale-`.sm` + orphan-`.sm` corpus produces a populated `Node.sidecar` overlay with `status: stale-*`, denormalises `annotations.version: 7` into the node row, and emits both `annotation-stale` and `annotation-orphan` issues. Tests: `src/test/unknown-field-rule.test.ts` (7 cases — fresh / typo / unknown root / namespaced-invalid / root-registered / namespaced-valid / no-roots) and `src/test/annotation-contributions.test.ts` (6 cases — namespaced-ok / root-exclusive-ok / root-shared-rejected / invalid-schema-rejected / cross-plugin-conflict-fatal / shared-namespaced-non-conflict). Side-fix: `core/annotation-orphan` now emits `nodeIds: [<expectedMd>]` (relative path) instead of an empty array — closes the pre-existing `issue.schema.json#/properties/nodeIds/minItems: 1` violation that was latent until the conformance fixture exercised it. New section `## Annotation contributions` in `spec/plugin-author-guide.md` covers the manifest shape, namespacing default vs root opt-in, ownership rules, hard-fail collision behaviour, the Tier-1 typo guard, and the runtime catalog accessor with worked examples. Changeset for `@skill-map/cli` + `@skill-map/spec` minor.

- **9.6.7 — Wire-shape cleanup (R7 + R9)** ✅ (2026-05-07). Closed two review-queue items inherited from 9.6.5 / 9.6.6 in a single batch because both touched the BFF wire shape. **R7 (REST envelope `kind` enum gap)**: `spec/schemas/api/rest-envelope.schema.json` grew from four `oneOf` variants to six — added `'sidecar.bumped'` (action-result variant: `value` + `elapsedMs`, no `filters` / `counts` / `kindRegistry`) and `'annotations.registered'` (catalog variant: `items` + `counts.total` only, no `filters` / `kindRegistry` / `returned`). The list variant re-imposes `counts.required: ['total', 'returned']` via per-variant override so its tally shape stays strict. `elapsedMs` is now a top-level optional integer property, present only on action-result envelopes. **R9 (WS event shape asymmetry)**: `server/routes/sidecar.ts` now wraps the `sidecar.bumped` payload in the canonical `IWsEventEnvelope` shape `{ type, timestamp, data: { nodePath, version, status } }` (matches every other kernel→broadcaster bridge — `scan.*`, `watcher.*`). `timestamp` is an ISO 8601 string from `new Date().toISOString()`, matching the kernel orchestrator's `makeEvent`. Two new cross-cutting tests (one per route) compile + AJV-validate the live 200 responses against `rest-envelope.schema.json` so any future drift fails immediately. The `server-sidecar-endpoint.test.ts` broadcaster-receipt assertion now checks the canonical envelope shape (timestamp ISO regex, `data.{nodePath,version,status}`, no flat siblings). Spec doc `spec/cli-contract.md` (`POST /api/sidecar/bump` + `GET /api/annotations/registered` subsections) updated; `spec/index.json` regenerated. Changeset for `@skill-map/cli` + `@skill-map/spec` minor (`.changeset/sidecar-wire-shape.md`). Out of scope: tightening the UI's relaxed `isWsEvent` runtime guard (lives in `ui/`) — the orchestrator handles that follow-up after this lands.

#### Acceptance for full Step 9.6

- `spec/schemas/sidecar.schema.json` and `annotations.schema.json` present, declared in `spec/index.json`.
- Walker reads `.sm` co-located alongside `.md`, validates against schema, computes hashes, detects drift.
- Built-in `bump` Action + sidecar write channel functional; `sm bump` CLI verbs work; pre-commit hook installs and bumps batched drift on commit.
- UI card surfaces stale state; bump button works.
- Plugin manifests declare `annotationContributions`, kernel validates writes against declared schemas.
- Conformance suite extended: at least one fixture exercises a `.sm` sidecar end-to-end (scan → annotation queryable → drift detection → bump).
- Changesets present for `@skill-map/spec` and `@skill-map/cli`.
- `npm run validate` exits 0 across all workspaces.
- §Frontmatter standard in this ROADMAP rewritten to describe the sidecar shape and reference `memory/project_annotation_architecture.md`.

#### Review queue — revisit before closing Step 9.6

Sub-step-level decisions taken by implementing agents inside their briefs that the orchestrator wants to re-examine once the whole annotation system is in place. Visit one-by-one before marking Step 9.6 ✅.

- **R1 — `Node.sidecar` overlay as public API.** ✅ **Closed 2026-05-07** — confirmed public. Step 9.6.2 published the sidecar overlay in `node.schema.json` (rather than treating it as transient kernel state) so the `validate-all` Rule could re-validate persisted nodes without `additionalProperties: false` rejections. The UI already consumes it for the inspector + bump-button gating (9.6.5) and the BFF threads it through unchanged. Reopen if a future read-side surface objects to carrying the overlay; until then, this is the canonical Node wire shape.
- **R2 — `invalid-sidecar` issues kernel-emitted vs Rule-emitted.** ✅ **Closed 2026-05-07** — confirmed kernel-emitted. `invalid-sidecar` (malformed YAML, schema violation) and the pre-existing `frontmatter-invalid` / `frontmatter-malformed` are all kernel-emitted because they are **structural** failures (the file did not parse) — they cannot be expressed as a Rule extension because there is no parsed object for the Rule to evaluate. Boundary documented: **kernel emits structural issues (parse / read failures); Rules emit semantic issues (the parse succeeded but the content disagrees with policy).** This rule lives now at `spec/architecture.md` §Annotation system and is the official position for any future structural-vs-semantic split.
- **R3 — Sidecar read-on-every-scan vs caching.** ✅ **Closed 2026-05-07** — confirmed read-on-every-scan, no cache. Argument: the sidecar can change independently of the body / frontmatter hashes (a user edits `.sm` directly without touching `.md`), so a body-hash-keyed cache would miss those changes; an mtime cache would help only when the sidecar is unchanged, and the cost it saves (one `existsSync` + small YAML read per cached node) is dominated by the existing walker IO. Re-bench when scope sizes routinely exceed 10k nodes or watch-mode latency complaints surface. Until then, simplicity wins.
- **R4 — `bump-report.schema.json` vs shared deterministic report base.** ✅ **Closed 2026-05-07** — shared base shipped. **Originally:** deferred to pre-Step-10 — introduce `spec/schemas/report-base-deterministic.schema.json` (no `confidence` / `safety` fields, just `ok` + free-form action-specific keys) BEFORE Step 10 ships its first probabilistic Action, so the deterministic / probabilistic split is symmetric across the report-base hierarchy. `bump-report.schema.json` migrates to extend it. **Resolution:** new `spec/schemas/report-base-deterministic.schema.json` declares the universal deterministic report shape — `ok` (boolean) + `additionalProperties: true` for action-specific keys. `bump-report.schema.json` now extends it via `allOf` + relative `$ref` (per `context/spec.md` rule 7) and drops the redundant inline declaration of `ok`. The `report-base.schema.json` ↔ `report-base-deterministic.schema.json` pair makes the deterministic / probabilistic axis explicit at the schema level — every future Action's report extends one of the two bases per its declared `mode`. Coverage matrix gains row 29 (🟡 partial — covered indirectly via row 28; flips 🟢 when the first conformance case validates a deterministic report against this base directly). `spec/index.json` regenerated. Greenfield-permitted breaking surface (no released consumers depend on the prior shape) shipping as a `@skill-map/spec` minor per the pre-1.0 rule. No source changes — the bump Action's report shape is unchanged at the runtime contract level.
- **R5 — `IActionContext` shape lock.** ✅ **Closed 2026-05-07** — confirmed locked. The four-field shape (`node`, `nodeAbsolutePath`, `invoker`, `now`) is minimal and additive-friendly; new fields land via minor bump without breaking existing Actions. The `<TInput, TReport>` generic on `invoke()` with consumer-side narrowing is the canonical pattern for new Actions. Step 10's job subsystem extends the context (likely `runner` for probabilistic Actions) on top of this base — additive, not replacement.
- **R6 — YAML serializer drops comments + key order on round-trip.** ✅ **Closed 2026-05-07** — decided not to swap. **Originally:** `FilesystemSidecarStore.applyPatch` re-serializes via `js-yaml dump` with `sortKeys: true`, so a user-authored `.sm` with comments or custom key order loses both on the first bump. The available fix is swapping `js-yaml` for `yaml` (eemeli) which supports `Document`-aware round-trip with comments preserved (one new dep + ~150 lines of merge-via-AST rewrite). **Resolution:** keep `js-yaml` permanently; the contract is **"bump rewrites the file; narrative goes in the `.md` body, which is never touched"**. Mitigations shipped: (a) `sm sidecar annotate` scaffold prints a banner explaining the round-trip contract on first creation; (b) `spec/cli-contract.md` carries a worked before/after example. If a real user complaint surfaces in the future, opening a fresh issue is the right path — this entry stays closed because the design intent is firm: `.sm` is a managed artifact, not a hand-curated YAML document. Authors put rationale in `.md` body and per-bump notes in `audit.*` (or in commit messages); they do not put it in `.sm` comments.
- **R7 — REST envelope `kind` enum gap (`sidecar.bumped` + `annotations.registered`).** ✅ **Closed 2026-05-07** — option (a) shipped. `Originally:` Step 9.6.5 (BFF half) returns `{ "schemaVersion": "1", "kind": "sidecar.bumped", "value": { ... }, "elapsedMs": <int> }` from `POST /api/sidecar/bump`, AND Step 9.6.6 (BFF half) returns `{ "schemaVersion": "1", "kind": "annotations.registered", "items": [...], "counts": { "total": <int> } }` from `GET /api/annotations/registered`. Neither value was in `spec/schemas/api/rest-envelope.schema.json#/properties/kind/enum` and neither variant was in the `oneOf`. **Resolution:** added both `kind` values to the enum and grew the `oneOf` from four variants to six: a fourth "action-result" variant (`value` + `elapsedMs`, no `filters` / `counts` / `kindRegistry`) covers `'sidecar.bumped'`; a fifth "catalog" variant (`items` + `counts.total` only, no `filters` / `kindRegistry` / `returned`) covers `'annotations.registered'`. The list variant re-imposes `counts.required: ['total', 'returned']` via the per-variant override so its tally shape stays strict. Cross-cutting tests (`server-sidecar-endpoint.test.ts`, `server-annotations-endpoint.test.ts`) compile + AJV-validate the live 200 responses against `rest-envelope.schema.json` so any future drift in route or schema fails immediately. Spec doc (`spec/cli-contract.md`) updated; `spec/index.json` regenerated.
- **R8 — Force-on-fresh broadcast policy.** ✅ **Closed 2026-05-07** — confirmed "no-op = no event". `force: true` on a fresh node returns a 200 silent no-op and does NOT emit a `sidecar.bumped` WS event. Symmetric with the CLI's silent no-op (no stdout). Rationale: nothing changed on disk; broadcasting would tell every connected UI to refresh state that hasn't moved, which is misleading. Aligned with the Action's `{ ok: true, noop: true }` semantics. UIs that want to surface "you re-confirmed v7" feedback can do it locally from the 200 response — no server-side broadcast needed.
- **R9 — WS event shape asymmetry.** ✅ **Closed 2026-05-07** — option (a) shipped. `Originally:` `sidecar.bumped` was broadcast as a flat envelope `{ type: 'sidecar.bumped', nodePath, version, status }`, while every other WS event emitted by the kernel→broadcaster bridge today carries the `{ type, timestamp, data }` shape per the broadcaster's existing convention. Step 9.6.5 (UI half) had to relax `isWsEvent`'s runtime guard to accept both shapes. **Resolution:** the BFF-side `sidecar.bumped` event now wraps its payload in `{ type, timestamp, data: { nodePath, version, status } }` (canonical `IWsEventEnvelope` per `server/events.ts`). `timestamp` is an ISO 8601 string from `new Date().toISOString()`, matching the kernel orchestrator's `makeEvent`. `server/routes/sidecar.ts` now constructs the envelope around the existing `eventData` payload; `server-sidecar-endpoint.test.ts` asserts the canonical shape (`type` / `timestamp` ISO regex / `data.{nodePath,version,status}`) and that no flat siblings leak onto the envelope. The UI's `isWsEvent` guard relaxation is now obsolete and will be tightened by the orchestrator in `ui/` after this lands.
- **R10 — E2E coverage of the bump flow.** ✅ **Closed 2026-05-07** — live-BFF Playwright harness shipped. **Originally:** Live-BFF e2e infrastructure (boot `sm serve` against a Playwright fixture scope, hit real `/api/*` routes, assert on real WS events) is its own discrete piece of work — 1–2 days of harness setup that also benefits Step 10+ probabilistic-flow tests. Today's coverage (happy / error / edge as Karma unit tests against mocked `HttpClient` + `WsService`) IS real coverage at the component level; the gap is the integration layer. **Resolution:** the `e2e/` workspace gains a second Playwright project `live-bff` alongside the existing `smoke` (static-bundle) project. New `e2e/live-bff/` carries the harness: `fixture.ts` materialises a kernel scope under `<repoRoot>/.tmp/e2e-live-bff-<ts>/` with one stale `.md` + sentinel-hash `.sm`; `server.ts` picks a free ephemeral port via `net.createServer().listen(0)`, spawns `node --import tsx src/cli/entry.ts serve --no-open --port <free>` in one-shot mode (no `--watch` per AGENTS.md §"Smoke-testing live servers from an agent"), polls `/api/health` until 200; `global-setup.ts` / `global-teardown.ts` are Playwright lifecycle hooks that stash the dynamic base URL on `process.env.LIVE_BFF_URL`. The happy-path test `live-bff/specs/bump.spec.ts` asserts: stale badge visible → click bump → annotations panel `version` increments 3 → 4 → stale badge clears (via the WS `sidecar.bumped` event). `npm run validate -w e2e` continues to run only the static `smoke` project (CI does not require the live-BFF infra by default); `npm run test:live-bff -w e2e` is the opt-in. The harness is greppable via `--project=live-bff` so users can run either mode in isolation. README updated with a §Live-BFF mode section. Out of scope for this closure (and explicitly noted in the brief): live coverage of any other flow, refactoring `sidecar.spec.ts`, every WS event type. Follow-up step lands when probabilistic-flow tests need the same harness.
- **R11 — `core/annotation-orphan` `nodeIds` shape.** ✅ **Closed 2026-05-07** — confirmed current fix. The rule emits `nodeIds: [<expectedMdRelative>]` (the relative path of the missing `.md` the orphan sidecar was anchored to). The path identifies a node that doesn't exist, but that is informative — the rule's accompanying `data.{sidecarPath, expectedMdPath}` lets consumers disambiguate `node-was-deleted` vs `node-never-existed`. Relaxing `issue.schema.json#/properties/nodeIds/minItems` to 0 was rejected because every other Issue in the system attributes to at least one live node; introducing a "graph-level orphan issue" category for a single rule is over-abstraction.
- **R12 — `IRuleContext.sidecarRoots` as canonical raw-sidecar access channel.** ✅ **Closed 2026-05-07** — confirmed YAGNI-keep. Threading `ReadonlyMap<nodePath, rawRoot>` through `IRuleContext` keeps the public `Node` surface clean (R1 stays as-is) and gives the `unknown-field` rule the raw access it needs. If a third rule eventually needs cross-cutting data not on `Node`, that is the trigger to abstract into a more general "scan context"; until then, one shape per use case beats premature abstraction.
- **R13 — Kernel instantiation in the BFF composition root.** ✅ **Closed 2026-05-07** — confirmed keep. The kernel surface is the canonical read API; any future BFF route that exposes computed-from-runtime data benefits from the same plumbing. The cost of carrying a `Kernel` instance in `IAppDeps` is one field; collapsing to a direct `pluginRuntime` read would save indirection but couples every route to internal runtime layout. Re-evaluate only if the BFF grows multiple computed-data routes that don't share a kernel-shape.
- **R14 — `loadPluginRuntime` ignores BFF `runtimeContext` override.** ✅ **Closed 2026-05-07** — bias-aligned threading shipped. `Originally:` Step 9.6.6's BFF test for the populated catalog had to bypass `createServer()` and call `createApp()` directly with a hand-stamped kernel because `loadPluginRuntime` resolved plugin search paths via `defaultRuntimeContext()` (which reads `process.cwd()` verbatim) — the BFF's `runtimeContext` option never reached plugin discovery. **Resolution:** `ILoadPluginRuntimeOptions` grows an optional `runtimeContext?: IRuntimeContext` field; when provided, `loadPluginRuntime` threads it through both `resolveSearchPaths` (project + user plugin dirs) and `buildEnabledResolver` (config + DB plugin overrides) instead of fabricating a fresh `defaultRuntimeContext()` per helper. Backward compatible — pre-existing callers passing `{ scope, pluginDir? }` keep working; absent `runtimeContext` falls back to `defaultRuntimeContext()` exactly as today. The BFF composition root (`server/index.ts:assembleBootBundle`) now forwards the already-resolved `runtimeContext` to `loadPluginRuntime`, so a `createServer()` boot with `runtimeContext: { cwd: <tempdir>, ... }` actually steers plugin discovery into that tempdir's `.skill-map/plugins/`. The `server-annotations-endpoint.test.ts` populated case unwound the `createApp()` bypass and now boots through the real composition root against a tempdir whose `.skill-map/plugins/` carries two synthetic contribution-bearing plugins; the empty-catalog and mutation-guard cases migrated too. CLI verbs that call `loadPluginRuntime` are unchanged — they still pass `{ scope }` only and pick up the default context.
- **R15 — BFF `Node.sidecar` overlay does not ship the parsed `.sm` root.** ✅ **Closed 2026-05-07** — overlay extended end-to-end. **Originally:** the BFF's `GET /api/nodes` and `GET /api/graph` envelopes serialized `node.sidecar` as `{ present, status, annotations }` only — the full parsed sidecar object (`for`, `audit`, `settings`, plugin `<plugin-id>:` namespaces) was dropped at the wire boundary, so the inspector debug / audit / plugin-contributions panels (forward-compat-ready since the UI inspector tiering pass) could not render their full surface. **Resolution:** `spec/schemas/node.schema.json#/$defs/sidecarOverlay` gains an optional `root` (`type: ['object', 'null']`, `additionalProperties: true`). The kernel `ISidecarOverlay` carries the same field; the orchestrator's `resolveAndApplySidecar` site stamps `root: result.parsed.raw` (re-using the full parsed root that `parseSidecar()` already builds for the rule pass — no extra YAML reads). Persistence option (b): additive sibling column `scan_nodes.sidecar_root_json` (migration `004_sidecar_root_json.sql`) stores the JSON-encoded root alongside the existing `annotations_json`; `scan-persistence.ts` writes it, `scan-load.ts` rehydrates it. The BFF routes (`/api/nodes/:pathB64`, `/api/nodes`, `/api/graph`) are pass-through — they serialize whatever the kernel hands them, so the new field flows through automatically without route changes. The UI's `ISidecarOverlayApi` (wire model) gains `root?: Record<string, unknown> | null`; the existing api-to-view spreader (`projectNode`) propagates the field unchanged into `INodeView.sidecar.root`; the WS bump-patcher (`patchSidecarFromBump`) preserves `root` across the bump-driven re-render. Tests: `sidecar-reader.test.ts` extended (root populated on fresh parse, null/absent when no `.sm`, round-trips through the `sidecar_root_json` column); `server-endpoints.test.ts` plants a `.sm` next to `architect.md` in the fixture and asserts `node.sidecar.root.{for,audit}` ride the single-node response. Backward-compat: existing `sidecar.annotations` field stays — duplicates `root.annotations` intentionally; pre-R15 consumers reading `annotations` keep working unchanged. `spec/index.json` regenerated; changeset `.changeset/sidecar-root-on-overlay.md` for `@skill-map/spec` + `@skill-map/cli` minor.

**Catalog curation 2026-05-07**: 16 annotations dropped from the catalog after UX review (orphan capability tokens, redundant taxonomy, social-profile fields out of skill-map domain). `audit.bumpReason` rolled back. The kept set was the load-bearing 15: `version`, `stability`, `supersedes`, `supersededBy`, `requires`, `conflictsWith`, `related`, `authors`, `license`, `source`, `sourceVersion`, `released`, `tags`, `hidden`, `docsUrl`. Dropped: `provides`, `type`, `author`, `created`, `updated`, `category`, `keywords`, `icon`, `color`, `priority`, `readme`, `examplesUrl`, `github`, `homepage`, `linkedin`, `twitter`. Cascade: `--reason` flag dropped from `sm bump`; `reason` field dropped from `POST /api/sidecar/bump` body; `audit.bumpReason` removed from `sidecar.schema.json`. `additionalProperties: true` stays everywhere so legacy / opaque keys still ride through; the `unknown-field` rule warns on any post-curation typo. R6's mitigation set updates: `audit.bumpReason` is no longer the canonical per-bump rationale surface — the contract is now "bump rewrites the file; narrative goes in the `.md` body, which is never touched". The deepMerge null-as-delete primitive in `kernel/sidecar/store.ts` is retained without a current caller (architecturally sound for future per-write erase semantics).

**Catalog refinement 2026-05-07 — `released` dropped**: the catalog now stands at **14 fields**. `released` was redundant with `audit.lastBumpedAt` for this project's flow — the spec doesn't distinguish "official release" from "bump", so a separate lifecycle field added confusion without unique semantics. Activity timestamp now lives exclusively in the reserved `audit:` block (`audit.lastBumpedAt`, written by every `bump`). Cascade: `spec/schemas/annotations.schema.json` drops the field; `spec/architecture.md` listing updates; UI card / inspector / annotations-panel switch to `sidecar.root.audit.lastBumpedAt` for the "days ago" chip (greenfield-permitted breaking surface, ships as `@skill-map/spec` minor pre-1.0).

- Follow-up 2026-05-07: `Node.author` denormalisation removed (vestigial after `annotations.author` drop) + `kitchen-sink.md` body resynced. Migration `003_drop_node_author.sql` drops the column; spec `node.schema.json` + `db-schema.md` no longer document it; CLI `sm show` no longer renders the `author:` row; `SHOW_TEXTS.nodeFieldAuthor` removed; `validate-all` rule's wire shape no longer copies it. The earlier curation note's "Kernel `Node.author` denormalization stays untouched" is reversed — keeping a denorm path for a field the catalog no longer documents was inconsistent.

#### Deferred (post-Step 9.6)

- **Hook bridge Action manifest extension** — vendor-runtime callback installation. Per conversation 2026-05-05, the implementation pattern is Action with `installable: { vendors, events, invocation }` manifest field, paired with `sm hooks install <plugin-id>`. Targets post-v1.0 because of security implications (`sm` runs in every tool-use hook chain) and per-machine setup overhead.
- **Legacy-frontmatter sidecar migration helper (`sm sidecar annotate --from-frontmatter <node-path>`)** — Step 9.6.4 ships `sm sidecar annotate` as a pure scaffold (empty `for:` + `annotations: {}`). The optional helper that imports a node's legacy `metadata: {}` frontmatter block into the new sidecar shape was scoped out (Decision A4 of Step 9.6.4). Rationale: no released consumer demands it, the project is greenfield (Decision #125 explicitly rejected automatic semver→integer conversion), and shipping the helper without a real migration target risks freezing a heuristic that doesn't match the eventual user-supplied data. Ship when the first user asks; the wrapper around `FilesystemSidecarStore.applyPatch` is small.
- **Auto-bump watch-debounced mode** — Pattern D from conversation. No compelling use case identified; kept on backlog only.
- **Drift detector vs vendor docs** — periodic CI workflow that compares the Claude Provider's per-kind schemas against the upstream Anthropic docs. Step 9.5 deferred this; Step 9.6 does not change the deferral.
- **Plugin author guide rewrite** — current guide grew incrementally; rewrite for agent-first readability so any agent (LLM or human) can produce a working plugin from the guide alone. Cost: 1–2 days of writing. Triggers when a user wants to onboard their first plugin author.
- **Sidecar-as-canonical-with-DB-cache vs DB-as-canonical-with-sidecar-as-export** debate — closed in favor of "sidecar is canonical, DB caches the parsed form for fast queries". Remote DB stays as a viable post-v1.0 pluggable backend (Decision #117 — "Storage as pluggable driven adapter") but not for annotations specifically; remote DB's drift problem with diverging local FS state was the deciding argument.

### Step 9.5 — Spec base cleanup: absorb provider verbatim — ✅ complete

> ✅ **Pre-wave-2 prerequisite — closed 2026-05-04.** Realigned `spec/schemas/frontmatter/base.schema.json` and the Claude Provider's per-kind schemas with the architectural principle "skill-map aggregates, does not curate vendor specs". The previous base universal carried Claude-specific fields (`tools`, `allowedTools` — Decision #55, now superseded by #124) and the Claude Provider's `agent.schema.json` captured only `model` of Anthropic's documented 16 agent fields. Both inversions of the right ownership boundary, fixed in this Step. Closed before Step 10 starts so the `skill-summarizer` pipeline ships against a stable annotation shape. Decision surfaced in conversation 2026-05-04 — full reasoning informed by cross-vendor research (Cursor, Continue, Aider, Copilot, Windsurf, Cline, Roo: no shared vocabulary exists; `description` is the only universal field) and Anthropic stability scan (~1.3 fields/month growth, no documented deprecations, `additionalProperties: true` keeps absorption resilient).

#### Scope

1. **Trim `base.schema.json`** to `name` (required) + `description` (required), `additionalProperties: true`. The previously-promoted fields (`type`, `author`, `authors`, `license`, `tools`, `allowedTools`, `metadata`) leave the formal base. They are NOT removed from existing files; they pass through validation silently because of `additionalProperties: true`.
2. **Absorb Anthropic's agent verbatim** into `src/built-in-plugins/providers/claude/schemas/agent.schema.json`. The 11 missing fields (`tools`, `disallowedTools`, `permissionMode`, `maxTurns`, `skills`, `mcpServers`, `hooks`, `memory`, `background`, `effort`, `isolation`, `color`, `initialPrompt`) join the existing `model`. Schema stays `additionalProperties: true` so future Anthropic additions do not break consumers.
3. **Same pattern for skill / command / hook**: `claude/skill.schema.json` declares `tools` plus `allowed-tools` (hyphenated, per Anthropic's skill spec) at root in addition to its existing `inputs` / `outputs`. Command and hook absorb whatever Anthropic documents on their respective pages.
4. **Update TS types** in `ui/src/models/node.ts` and any kernel-side mirror.
5. **Update fixtures + tests**. Conformance fixtures (`minimal-claude`) keep the same content; their field placement changes only if any field was relying on base validation that no longer happens.
6. **Regenerate `spec/index.json`** (`npm run spec --workspace=@skill-map/spec`). `context/cli-reference.md` should not change (no CLI surface affected).
7. **Changesets** for `@skill-map/spec` and `@skill-map/cli` — both minor bumps (greenfield breaking permitted pre-1.0 per `spec/versioning.md` §Pre-1.0).
8. **Decision #55 superseded.** The "top-level on purpose — mirrors Claude Code's own frontmatter shape" rationale is reversed: cross-vendor research showed Claude Code is an **aggressive superset**, not a shared standard — 11 of its 16 agent fields have no analog in any other vendor researched. Mirror was the wrong motivation. New decision (next number in the table) records the inversion + the absorb-verbatim principle. The §Frontmatter standard section (above) is rewritten as part of this Step to reflect the new shape.

#### Deferred — closed in Step 9.6

Where the skill-map annotation block lives was deliberately deferred at the close of Step 9.5. **Resolved 2026-05-05 (conversation): co-located `.sm` YAML sidecars.** The full execution plan and architectural reasoning live in §Step 9.6 below; the conceptual decisions are recorded in `memory/project_annotation_architecture.md` and formally in Decision #125. Until Step 9.6 lands, the orphan fields keep riding on `additionalProperties: true` with no formal validation.

#### Why before Step 10

Step 10 (`skill-summarizer` and the Job subsystem) emits per-kind summaries against the shapes that exist at scan time. Today the kernel's denormalizations point at `metadata.{stability, version, author}`; if the annotation home moves mid-Step-10 the summarizers ship against a shape that is not final. Closing the cleanup before LLM work starts avoids re-targeting the summarizer pipeline.

#### Acceptance

- `base.schema.json` declares only `name` + `description` as required, `additionalProperties: true`, no `type` / `author` / `authors` / `license` / `tools` / `allowedTools` / `metadata` properties.
- `claude/agent.schema.json` declares all 16 Anthropic agent fields with appropriate types/enums, `additionalProperties: true`.
- `claude/skill.schema.json` declares `tools` and `allowed-tools` (hyphenated, per Anthropic's skill spec) plus the existing kind-specifics.
- `npm run validate` (root orchestrator) passes.
- Conformance fixtures still produce identical scan output (frontmatter validation is permissive, so no regression on existing files).
- Changesets present for `@skill-map/spec` and `@skill-map/cli`.
- §Frontmatter standard section in this ROADMAP rewritten to reflect the new base + provider boundary.
- Decision #55 marked superseded with a forward link to the new decision.

## v0.5.0 — deterministic kernel + CLI (offline, zero LLM) (2026-05)

### Step 9 — Plugin author UX

The last deterministic-half step before wave 2 begins. Drop-in plugin discovery already exists from Step 1b/6.6 (the `sm plugins` introspection verbs); Step 9 turns plugins into first-class participants of the read-side pipeline, ships a testkit so authors can unit-test their extensions in isolation, documents the contract, and lights up plugin migrations with the triple-protection rule. Sub-steps:

- **9.1 — Plugin runtime wiring** ✅ — drop-in plugins discovered under `<scope>/.skill-map/plugins/<id>/` now participate in the analysis pipeline. New helper `loadPluginRuntime(opts)` at `src/cli/util/plugin-runtime.ts` centralises discovery, layers the enabled-resolver (settings.json baseline + DB override `config_plugins`), buckets loaded extensions into the per-kind shape the orchestrator + graph Formatter registry consume, and turns failure modes into stderr-ready diagnostic strings. `sm scan`, `sm watch` (and the `sm scan --watch` alias), `sm scan --compare-with`, and `sm graph` each gained a symmetric `--no-plugins` flag for kernel-empty-boot parity. Failed plugins (`incompatible-spec` / `invalid-manifest` / `load-error`) emit one stderr warning each; the kernel keeps booting on a bad plugin. Disabled plugins drop out silently (intent already covered by `sm plugins list`). Plugin loader bug fixed: the AJV validator now strips function-typed properties from a plugin's runtime export before checking the extension-kind schema, because the kind schemas use `unevaluatedProperties: false` and would otherwise reject every real plugin shipping `extract` / `format` / `evaluate` methods (built-ins were unaffected — they never traverse the loader). `sm export --format` deliberately left out of 9.1: its formats (`json`, `md`, `mermaid`) are hand-rolled today, not Formatter-backed; flipping it to consult the Formatter registry is a future enhancement, not on the Step 9 critical path. **5 new tests at `src/test/plugin-runtime.test.ts`** cover Extractor contribution, `--no-plugins` opt-out on both scan and graph, broken-manifest tolerance, and plugin-Formatter selection. Test count 389 → **394 of 394 tests pass**.
- **9.2 — Plugin migrations + `sm db migrate --kernel-only` / `--plugin <id>`** ✅ — implements the long-deferred flags from `spec/cli-contract.md:304` and `spec/db-schema.md:321`. Plugins declaring `storage.mode === 'dedicated'` ship migrations under `<plugin-dir>/migrations/NNN_<name>.sql` (same convention as kernel migrations); the runner records them in `config_schema_versions` under `(scope='plugin', owner_id=<plugin-id>)`. Each migration runs inside its own transaction, ledger insert in the same transaction so partial failure rolls back cleanly. Triple protection: pragmatic regex validator rejects any DDL or DML whose target name doesn't match `plugin_<normalizedId>_*`. Whitelist of allowed statements (`CREATE` / `DROP` / `ALTER` over `TABLE` / `INDEX` / `TRIGGER` / `VIEW`, plus `INSERT` / `UPDATE` / `DELETE` on prefixed objects); forbidden keywords (`BEGIN` / `COMMIT` / `ROLLBACK` / `PRAGMA` / `ATTACH` / `DETACH` / `VACUUM` / `REINDEX` / `ANALYZE`) abort validation; schema qualifiers other than `main.` are rejected; comments stripped first so `-- CREATE TABLE evil;` and `/* … */` blocks can't smuggle hidden DDL. Layer 1 validates every pending file before anything runs, Layer 2 re-validates immediately before each apply (TOCTOU defense), Layer 3 sweeps `sqlite_master` after each plugin's batch and reports objects outside the prefix as intrusions (exit 2; ledger advances for clean migrations so the breach is loud, not silent). New modules: `src/kernel/adapters/sqlite/plugin-migrations-validator.ts` (pure, no IO) and `src/kernel/adapters/sqlite/plugin-migrations.ts` (runner mirroring the kernel shape). `DbMigrateCommand` learns `--kernel-only` (skip plugin pass) and `--plugin <id>` (run only that plugin, skip kernel pass), mutually exclusive. `--status` summary now lists kernel + per-plugin ledgers. Plugin discovery reuses `loadPluginRuntime` from 9.1 so the enabled-resolver layering (settings.json + DB override) stays in lock-step with `sm plugins list`. 43 new tests across `plugin-migrations-validator.test.ts` (34 unit cases over normalization, comment stripping, statement splitting, prefix enforcement, intrusion detection) and `plugin-migrations.test.ts` (9 integration cases over green-path apply, Layer 1 abort, idempotent re-run, dry-run, `--kernel-only`, `--plugin <id>`, missing-id exit 5, mutual exclusion, `--status` formatting). Test count 394 → **437 of 437 tests pass**.
- **9.3 — `@skill-map/testkit`** ✅ — landed as a separate workspace + npm package (Arquitecto's pick: independent versioning over subpath). Surface: `node` / `link` / `issue` / `scanResult` builders (spec-aligned defaults, override per field); `makeDetectContext` / `makeRuleContext` / `makeRenderContext` / `detectContextFromBody` per-kind context factories; `makeFakeStorage` (in-memory KV stand-in matching the Storage Mode A `ctx.store` surface) and `makeFakeRunner` (queue + history `RunnerPort` stand-in for probabilistic extensions, marked `experimental` until Step 10 finalizes the contract); `runDetectorOnFixture` / `runRuleOnGraph` / `runRendererOnGraph` high-level helpers (most plugin tests reduce to one line). Collateral on `@skill-map/cli`: `src/kernel/index.ts` re-exports the extension-kind interfaces (`IDetector`, `IRule`, `IRenderer`, `IAdapter`, `IAudit` and their context shapes) so plugin authors can type-check against the same surface the kernel consumes. Workspace ships its own `tsup` build (5 KB runtime + 10 KB types) and pins every dep at exact versions; `@skill-map/cli` is marked external so testkit stays a thin layer over the user's installed cli version. Independent test runner (`npm test --workspace=@skill-map/testkit`). 30 new tests cover builder defaults + overrides, context shapes, KV stand-in semantics, fake-runner queueing / history / reset, and the three run helpers. Total project tests 437 → **467 of 467** (437 cli + 30 testkit).
- **9.4 — Plugin author guide + reference plugin + diagnostics polish** ✅ — closes Step 9. New `spec/plugin-author-guide.md` (prose, no schema) covering discovery roots, manifest anatomy, the six extension kinds with worked examples (Extractor / Rule / Formatter in full; Provider / Action / Hook flagged for Step 10 expansion), `kv` vs `dedicated` storage with cross-links to `plugin-kv-api.md` + the 9.2 triple-protection rule, `specCompat` strategy (narrow pre-1.0, `^1.0.0` post-1.0), dual-mode posture, testkit usage, the five plugin statuses, Stability section. `spec/package.json#files` updated; `spec/index.json` regenerated (57 → 58 hashed files). Reference plugin under `examples/hello-world/` (Arquitecto's pick: in the principal repo) — `plugin.json` + `extensions/greet-detector.js` (one Extractor emitting `references` links per `[[greet:<name>]]` token; legacy `greet-detector.js` filename pending the code-side rename PR) + README with three-step "try it locally" recipe + `test/greet-detector.test.js` (assertions using `@skill-map/testkit`, runnable via `node --test` without a build step). Verified end-to-end: the plugin loads cleanly under `sm plugins list`, contributes links to the persisted scan, and the testkit-based test passes. Diagnostics polish on `PluginLoader.reason`: each failure-mode message now carries an actionable hint — `invalid-manifest` names the manifest path + points at the schema; `incompatible-spec` suggests two remediations; `load-error` (file not found) includes the absolute resolved path; `load-error` (unknown kind / missing kind) lists the valid kinds; `load-error` (extension schema fails) names the per-kind schema file. **6 new tests** under `test/plugin-loader.test.ts` (`Step 9.4 diagnostics polish` describe block) assert each hint shape is present without pinning the full wording. Step 9 closed: 437 → **443 cli + 30 testkit = 473 of 473 tests pass**. Step 9 (in total) shipped 4 sub-steps and turned `skill-map` plugins from "discovered but inert" into a first-class authoring surface with documentation, tests, and a working reference plugin.

### Step 8 — Diff + export

Sub-stepped: 8.1 `sm graph`, 8.2 `sm scan --compare-with`, 8.3 `sm export`.

- **8.1 — `sm graph [--format <name>]`** ✅ — replaces the long-standing stub. Reads the persisted graph through `loadScanResult` and renders via any registered Formatter (built-ins only at v0.5.0; plugin Formatters plug in at Step 9). Default `--format ascii`; `mermaid` / `dot` deferred to Step 12 and surface here automatically once they ship as built-ins. Exit 5 on unknown format or missing DB; exit 0 on the empty-DB zero-graph case (graph is a read-side reporter, not a guard). Trailing newline normalisation makes the verb safe to pipe.
- **8.2 — `sm scan --compare-with <path>`** ✅ — new flag on `sm scan`. Loads + AJV-validates a saved `ScanResult` dump, runs a fresh scan in memory using the same wiring (built-ins, layered config, ignore filter, strict mode), computes a delta via the new `computeScanDelta` kernel helper, emits pretty (default) or `--json`. Identity contract recorded in `src/kernel/scan/delta.ts`: nodes by `path`, links by `(source, target, kind, normalizedTrigger)` (mirrors `sm show` aggregation + Step 7.2 `link-conflict`), issues by `(ruleId, sorted nodeIds, message)` (mirrors `spec/job-events.md` §issue.* diff key). Nodes get a `changed` bucket annotated with `'body'` / `'frontmatter'` / `'both'`; links and issues only have `added` / `removed` because identity already covers semantic change. Exit 0 on empty delta, 1 on non-empty (CI-friendly), 2 on dump load / validation errors. Combo rejections: `--changed`, `--no-built-ins`, `--allow-empty`, `--watch`. Never touches the DB.
- **8.3 — `sm export <query> --format <json|md|mermaid>`** ✅ — replaces the stub. Mini query language (whitespace-separated `key=value`, AND across keys, comma-separated values OR within a key): `kind` (skill / agent / command / hook / note), `has` (`issues` today; `findings` / `summary` reserved for Steps 10 / 11), `path` (POSIX glob with `*` / `**`). New kernel module `src/kernel/scan/query.ts` exports `parseExportQuery` + `applyExportQuery` + `IExportQuery` + `IExportSubset` + `ExportQueryError` (pure, no IO; zero-dep micro-glob → RegExp). Subset semantics: nodes pass under AND-of-filters; links require BOTH endpoints in scope (closed subgraph — boundary edges would confuse focused-view with focused-and-neighbours); issues survive when ANY of their `nodeIds` is in scope (cross-cutting issues like `trigger-collision` stay visible). Formats `json` and `md` real today; `mermaid` exits 5 with a Step-12 pointer (Formatter plug-in lands as a built-in there). Exit 5 on bad format / bad query / missing DB. Step 8 fully closed.

### Step 7 — Robustness

- Trigger normalization pipeline wired into every Extractor that emits `link.trigger`. ✅ already-landed (cabled into `slash`, `at-directive`, `external-url-counter` at Steps 3–4 with `src/kernel/trigger-normalize.ts` + worked-example test fixtures in `src/kernel/trigger-normalize.test.ts`; no dedicated sub-step). The 6-step pipeline contract lives in §Trigger normalization above.
- Sub-step 7.1 ✅ — incremental scan via `chokidar` watcher. `sm watch [roots...]` (and `sm scan --watch` alias) subscribes to the same roots `sm scan` walks, applies the same ignore chain, and triggers an incremental scan after each debounced batch. Debounce window configurable via `scan.watch.debounceMs` (default 300ms). Reuses the existing `scan.*` non-job events; emits one ScanResult per batch under `--json` (ndjson). Closes cleanly on SIGINT/SIGTERM with exit 0; per-batch issues do not flip the watcher exit code (only operational errors during initial setup exit 2). Lays the groundwork for Step 14's WS broadcaster (the same watcher will fan out to UI clients live).
- Sub-step 7.2 ✅ — Extractor conflict resolution. Two pieces. **(a)** New built-in Rule `link-conflict` (`src/built-in-plugins/rules/link-conflict/`): groups `scan_links` rows by `(source, target)` and emits one `warn` Issue per pair where the set of distinct `kind` values has size ≥ 2; `data` carries `{ source, target, variants: [{ kind, sources, confidence }, ...] }`. Cross-Extractor AGREEMENT (single kind across multiple Extractors) is silent by design — confirming the happy path would generate massive noise on real graphs. Severity is `warn`, not `error`: the Rule cannot pick which kind is correct, so per `cli-contract.md` §Exit codes the verb stays exit 0. **(b)** `sm show` pretty link aggregation: human Formatter now groups `linksOut` / `linksIn` by `(endpoint, kind, normalizedTrigger)` and prints one row per group with the union of Extractor ids in a `sources:` field; section header reports raw + unique counts (`Links out (12, 9 unique)`); `(×N)` suffix when N Extractors emit the same logical link. `--json` output stays raw rows (Decision #90 untouched — storage keeps one row per Extractor). UI inspector aggregation explicitly **deferred to Step 14**: the current Flavor A renders `metadata.{related, requires, supersedes, provides, conflictsWith}` chips directly from the frontmatter, not from `scan_links`; when Flavor B lands (Hono BFF + WS + full link panel from scan), the aggregation logic from `src/cli/commands/show.ts` will need to be ported.
- Sub-step 7.3 ✅ — `sm job prune` real implementation. Reads `jobs.retention.{completed,failed}` from layered config; for each non-null policy deletes `state_jobs` rows in that terminal status with `finished_at < Date.now() - policySeconds * 1000` and unlinks the matching MD files in `.skill-map/jobs/`. `--orphan-files` adds a second pass that scans `.skill-map/jobs/` and unlinks MD files whose absolute path is not referenced by any `state_jobs.file_path`; runs after retention so freshly-pruned files don't double-count. `--dry-run` reports what would be pruned without touching DB or FS; `--json` emits `{ dryRun, retention: { completed: { policySeconds, deleted, files }, failed: {...} }, orphanFiles }`. **`state_executions` is NOT touched** — append-only through `v1.0` per `spec/db-schema.md`. Pruning runs ONLY on explicit `sm job prune` invocation; no implicit GC during normal verbs (per `spec/job-lifecycle.md` §Retention and GC). DB-missing → exit 2 with a clear message; file-unlink failures (already missing, permission denied) are swallowed silently — a stale file path doesn't fail the verb.

### Step 6 — Config + onboarding

- `.skill-map/settings.json` + `.skill-map/settings.local.json` + `.skillmapignore`. `sm init` scaffolds the folder and adds the `.local.json` to the project's gitignore.
- Loader walks the hierarchy from §Configuration (defaults → `~/.skill-map/settings(.local).json` → `<scope>/.skill-map/settings(.local).json` → env / flags). UI-side keys are read by the same loader but only delivered over HTTP at Step 15.
- `sm init` scaffolding.
- `sm plugins list / enable / disable / show / doctor`.
- Frontmatter schemas enforced (warn by default, `--strict` promotes to error).

### Step 5 — History + orphan reconciliation

- Execution table `state_executions`.
- `sm history` + filters + `stats`.
- Orphan detection.
- **Automatic rename heuristic**: on scan, when a deleted `node.path` and a newly-seen `node.path` share the same `body_hash`, the scan migrates `state_*` FK rows (executions, jobs, summaries, enrichment) from the old path to the new one at **high** confidence without prompt. `frontmatter_hash`-only match against a **single** candidate → **medium** confidence → emits an `auto-rename-medium` issue (with `data_json.from` + `data_json.to` for machine readback) so the user can inspect / revert. `frontmatter_hash` match against **multiple** candidates → no migration; emits an `auto-rename-ambiguous` issue with `data_json.to` + `data_json.candidates: [...]` so the user can pick via `sm orphans undo-rename --from <old.path>`. Any residual unmatched deletion → `orphan` issue.
- `sm orphans reconcile <orphan.path> --to <new.path>` — forward manual override for semantic-only matches or history repair.
- `sm orphans undo-rename <new.path> [--from <old.path>] [--force]` — reverse a medium- or ambiguous-confidence auto-rename. For `auto-rename-medium`, reads the original path from the issue's `data_json` and migrates `state_*` FKs back (omit `--from`); for `auto-rename-ambiguous`, pass `--from <old.path>` to pick one of the candidates. Resolves the issue; the prior path becomes an `orphan`.

- ✅ Sub-step 5.1 — `scan_meta` table (zone `scan_*`, single-row, CHECK `id = 1`) closes the Step 4.7 follow-up. `persistScanResult` writes the row in the same transaction as the rest of the scan zone; `loadScanResult` reads it and returns real `scope` / `roots` / `scannedAt` / `scannedBy` / `adapters` / `stats.filesWalked` / `stats.filesSkipped` / `stats.durationMs` instead of the synthetic envelope. Synthetic fallback retained for freshly-migrated DBs that have never been scanned. Spec change (additive minor): new table catalog entry in `db-schema.md`. Migration `002_scan_meta.sql`. Test count: 151 → 154.
- ✅ Sub-step 5.2 — Storage helpers in `src/kernel/adapters/sqlite/history.ts`: `insertExecution`, `listExecutions(filter)` (node / action / statuses / sinceMs / untilMs / limit), `aggregateHistoryStats(range, period, topN)` (totals, tokensPerAction, executionsPerPeriod with UTC bucketing, topNodes with tie-break, error rates with all six failure-reason keys always present), and `migrateNodeFks(trx, fromPath, toPath)` covering the three FK shapes (simple column on `state_jobs`, JSON-array on `state_executions.node_ids_json` via `json_each`, composite-PK delete+insert on `state_summaries` / `state_enrichments` / `state_plugin_kvs` with conservative collision resolution preserving the destination row). New domain types `ExecutionRecord` / `HistoryStats` mirror the spec schemas. Test count: 154 → 169.
- ✅ Sub-step 5.3 — `sm history` CLI lands. Real implementation moved out of stubs; flags `-n / --action / --status (csv) / --since / --until / --limit / --json / --quiet` per `cli-contract.md` §History. ISO-8601 inline parser; `--json` array conforms to `execution-record.schema.json`. Shared `src/cli/util/elapsed.ts` (`startElapsed`, `formatElapsed`, `emitDoneStderr`) carries `done in <…>` per §Elapsed time. Test count: 169 → 184.
- ✅ Sub-step 5.4 — `sm history stats` CLI. Period bucketing (UTC `day` / `week` / `month`), top-N nodes, error-rates including all six failure-reason keys (zero-filled). `--json` self-validates against `history-stats.schema.json` before emit (catches drift early). Top-level `elapsedMs` per spec. Test count: 184 → 190.
- ✅ Sub-step 5.5 — Auto-rename heuristic at scan time per `spec/db-schema.md` §Rename detection. New `detectRenamesAndOrphans` orchestrator phase classifies high (body hash match, no issue) / medium (frontmatter hash 1:1, `auto-rename-medium` issue + FK migration) / ambiguous (frontmatter hash N:1, `auto-rename-ambiguous` issue, no migration) / orphan (residual deletion, `orphan` issue, state untouched). 1-to-1 matching enforced; iteration is lex-asc for deterministic output. Body match wins over frontmatter match. New API: `runScanWithRenames` returns `{ result, renameOps[] }`; `runScan` continues to return `ScanResult` only. `persistScanResult(db, result, renameOps?)` applies FK migration via `migrateNodeFks` (5.2) inside the same tx as the scan zone replace-all — atomic per spec. Test count: 184 → 190.
- ✅ Sub-step 5.6 — `sm orphans` verbs land. `sm orphans [--kind orphan|medium|ambiguous] [--json]` lists active issues; `sm orphans reconcile <orphan.path> --to <new.path>` migrates state_* FKs forward and resolves the orphan issue (exit 5 if target node missing or no active orphan); `sm orphans undo-rename <new.path> [--from <old.path>] [--force]` reverses medium/ambiguous auto-renames (reads `data.from` for medium, requires `--from` from `data.candidates` for ambiguous), emits a new `orphan` on the prior path, prompts via readline unless `--force`. `confirm()` helper extracted to `src/cli/util/confirm.ts` so `sm db restore / reset` and `sm orphans undo-rename` share the exact same prompt. Test count: 190 → 201.
- ✅ Sub-step 5.7 — Conformance fixtures for the rename heuristic. Spec change (additive minor): `conformance-case.schema.json` gains `setup.priorScans: Array<{ fixture, flags? }>` so cases can stage a prior snapshot before the main invoke. Two new cases (`rename-high`, `orphan-detection`) and four fixture directories. Runner in `src/conformance/index.ts` extended with `replaceFixture()` helper that wipes every non-`.skill-map/` entry between staging steps so the DB persists across fixture swaps. `coverage.md` row I (Rename heuristic) flips from `🔴 missing` to `🟢 covered`. Conformance suite passing in CI: 1 → 3 cases. Test count: 201 → 203.

- ✅ Sub-step 5.8 — Fire the rename heuristic on every `sm scan`, not just `sm scan --changed`. Decoupled `priorSnapshot` (data) from `enableCache` (behaviour). New `RunScanOptions.enableCache?: boolean` (default `false`) gates cache reuse only; `priorSnapshot` is now always passed by `scan.ts` when the DB has prior nodes. `scan.ts` sets `enableCache: this.changed` so `--changed` keeps its perf win. Behaviour matrix: plain `sm scan` (DB exists) loads prior, no cache, runs heuristic; `sm scan --changed` (DB exists) loads prior, caches, runs heuristic; `--no-built-ins` skips both. CLI e2e test added: write file → scan → delete → scan (no --changed) → assert `orphan` issue emitted. Test count: 203 → 204.
- ✅ Sub-step 5.9 — Orphan persistence across scans. Surfaced during walkthrough: `persistScanResult` did `DELETE FROM scan_issues` on every replace-all, so the `orphan` issue from a deletion-scan disappeared on the very next scan, leaving stranded `state_*` references invisible (and `sm orphans reconcile` impossible because it requires an active orphan issue). New helper `findStrandedStateOrphans(trx, livePaths)` in `kernel/adapters/sqlite/history.ts` sweeps every `state_*` reference (state_jobs.node_id, state_executions.node_ids_json via json_each, state_summaries / state_enrichments / state_plugin_kvs node_id with sentinel `''` skipped) and returns the distinct paths not in the live snapshot. `persistScanResult` calls it after applying renameOps and emits `orphan` issues for paths not already covered by the per-scan heuristic; `result.stats.issuesCount` updated for self-consistency. Self-healing: once state_* no longer references the dead path, the next scan emits no orphan for it. Spec language ("until the user runs `sm orphans reconcile` or accepts the orphan") now backed by behaviour. Test count: 204 → 206.
- ✅ Sub-step 5.10 — Two `sm history` polish fixes from the walkthrough: (a) human-table column widths — previous `formatRow` padded every non-ID column to flat 11 chars, so the 20-char ISO timestamp in STARTED ran into ACTION with zero whitespace; replaced with per-column `COL_WIDTHS` array sized for longest expected content + 2 trailing spaces. (b) `sm history stats --json` `elapsedMs` accuracy — was captured at `stats` construction (BEFORE `loadSchemaValidators()`'s ~100 ms cold load), so JSON reported 10 ms while stderr `done in` reported 111 ms (10× divergence). Re-stamped after validate-before-serialise, gap collapses to ~2 ms. Validator caching itself flagged as out of scope at the time. Test count: 206 → 207.
- ✅ Sub-step 5.11 — `sm history` human renderer shows `failure_reason` inline next to status (`failed (timeout)`, `cancelled (user-cancelled)`); `completed` rows unchanged. STATUS column widened from 12 to 30 chars to fit the longest enum (`cancelled (user-cancelled)` = 26). Test count: 207 → 208.
- ✅ Sub-step 5.12 — `loadSchemaValidators()` cached at module level so subsequent calls in the same process return the same instance for free. Single-shot CLI calls don't benefit (they only call once), but future verbs that validate at multiple boundaries (likely candidates: `sm doctor`, `sm record`, plugin manifest re-checks) get the win without threading a cached bundle through their call stacks. Test-only `_resetSchemaValidatorsCacheForTests()` exported. Test count: 208 → 211.
- ✅ Sub-step 5.13 — `frontmatter_hash` now computed over CANONICAL YAML form (`yaml.dump` with `sortKeys: true`, `lineWidth: -1`, `noRefs: true`, `noCompatMode: true`) instead of raw bytes. Closes the walkthrough finding where `cat <<EOF` and Write-tool output of the SAME logical frontmatter produced different hashes (different trailing-newline / whitespace handling) and demoted what should have been a medium-confidence rename to an `orphan`. New helper `canonicalFrontmatter(parsed, raw)` in `kernel/orchestrator.ts`. Fallback to raw text when the adapter's parse failed silently (so malformed YAML still hashes against itself across rescans). Migration impact: first scan after upgrade sees every file as "frontmatter changed" (cache miss only in `--changed`; otherwise cosmetic — no data loss, no false orphans). Test count: 211 → 213.

> Step 5 closed: 151 → **213 of 213 tests pass** (+62 across Step 5). 0 changesets pending in `.changeset/` — the 25-entry backlog (12 from Step 4 + 13 from Step 5) was drained via Version Packages PR #12 (`@skill-map/spec` → 0.6.0, `@skill-map/cli` → 0.3.2).

### Step 4 — Scan end-to-end — ✅ complete

- ✅ `sm scan` persists `ScanResult` into `<scope>/.skill-map/skill-map.db` (replace-all transactional snapshot across `scan_nodes / scan_links / scan_issues`); auto-migrates on first run; `--no-built-ins` skips persistence (kernel-empty-boot parity).
- ✅ `sm scan -n / --dry-run` skips every DB write (does not even open the adapter unless `--changed` also requires a read).
- ✅ `sm scan --changed` runs incrementally: loads the prior snapshot via `loadScanResult`, reuses nodes whose `bodyHash + frontmatterHash` match, full-processes new / modified files, drops deleted ones, re-runs rules over the merged graph, persists with replace-all. Degrades to a full scan with a stderr warning when no prior snapshot exists. Rejects `--changed --no-built-ins`.
- ✅ `sm list / show / check` read from `scan_*` (replaced their stubs); `--kind`, `--issue`, `--sort-by` (whitelist), `--limit`; per-column default sort direction (numeric → DESC, textual → ASC); `--issue` via SQLite `json_each`.
- ✅ Triple-split bytes + tokens per node (`js-tiktoken` cl100k_base); `--no-tokens` opt-out; encoder reused once per scan.
- ✅ **`external-url-counter` Extractor** landed as the 4th Extractor — one new file under `src/built-in-plugins/extractors/external-url-counter/` + one entry in `built-ins.ts`. Validates Step 2's drop-in litmus. Emits pseudo-links the orchestrator partitions into `node.externalRefsCount` (never persisted to `scan_links`, never reach Rules).
- ✅ `links_out_count`, `links_in_count`, `external_refs_count` denormalised on `scan_nodes`.
- ✅ Self-scan test (mandatory) — validates the live repo against `scan-result.schema.json` top-level + all per-element schemas; asserts no `error`-severity issues; smoke-checks tokens and external refs.
- ✅ 500-MD performance benchmark — measures ~1037 ms vs 2000 ms budget; covered as a `node:test` case alongside the suite.
- ✅ Sub-step 4.7 — runtime ScanResult reconciled with the spec: `scannedAt` integer ms (was ISO string), `scope: 'project' | 'global'`, `adapters[]` enumerated, `scannedBy { name, version, specVersion }`, `stats.filesWalked / filesSkipped`. The spec was authoritative all along; runtime only caught up. `loadScanResult` synthetic envelope updated to satisfy `roots: minItems 1` (returns `['.']` with an inline note that the orchestrator does not consume `roots` from a prior snapshot).
- ✅ Bug fix: `defaultMigrationsDir()` now probes the flat `dist/cli.js` bundle layout before falling back to the source-shaped layout — the prior heuristic silently missed `dist/migrations/` when running the bundled CLI on a fresh DB.
- ✅ Sub-step 4.8 — bundle fix from end-to-end manual validation: (a) `sm scan` exit code now matches `sm check` and the spec (1 only when issues at `error` severity exist; was 1 on any issue, including warn / info — applied to both human and `--json` paths). (b) `sm show` human output now prints `External refs: <N>` after the Weight section; the `--json` output already exposed `externalRefsCount`, the human format had a parity gap. (c) `sm scan --changed` no longer drops `supersedes`-inversion links from cached nodes; the orchestrator's cached-reuse filter now uses `originatingNodeOf(link, priorNodePaths)` which discriminates between forward `supersedes` (where `source` is the originating node) and inverted `supersedes` (where `target` is the originating node, emitted by frontmatter `metadata.supersededBy`) — sufficient because `supersedes` is the only kind with this inversion today; if a future Extractor adds another inversion case, escalate to a persisted `Link.detectedFromPath` field with a schema bump. Regression invariant: full-scan and `--changed`-scan over the same input now produce set-equal `links`.
- ✅ Sub-step 4.9 — (a) `trigger-collision` Rule detects nodes that *advertise* the same trigger via `frontmatter.name` (two `command` files both named `deploy` emit one collision issue). (b) `persistScanResult` runs `PRAGMA wal_checkpoint(TRUNCATE)` after the replace-all transaction commits so external read-only tools (sqlitebrowser, DBeaver, ad-hoc SQL clients) see fresh state without waiting on SQLite's auto-checkpoint threshold (~1000 pages, rarely crossed on small repos).
- ✅ Sub-step 4.10 — scenario coverage from the validation walkthrough: 10 new regression tests across `scan-mutation.test.ts` (new file), `scan-incremental.test.ts`, and `scan-readers.test.ts`. Cover hash discrimination (mutating only the body must keep `frontmatter_hash` byte-equal, and vice versa), external-refs lifecycle (0 → 2 → dedup → invalid URL silently dropped), deletion-driven broken-ref re-evaluation in both full and incremental paths, replace-all ID rotation contract (synthetic `scan_links.id` / `scan_issues.id` may differ between scans; the natural keys `(source, target, kind, normalized_trigger)` are what callers must use as identity), `--no-tokens` flag plumbing through the CLI handler, `--changed --no-built-ins` combination rejection at exit 2, and trigger-collision interaction with `--changed` (collision survives an edit to one advertiser; collision disappears when one advertiser is deleted).
- ✅ Sub-step 4.11 — empty-scan guard against accidental DB wipes. Three layers of defense: (a) `runScan` now validates every entry in `options.roots` exists as a directory; throws on the first failure with a clear message naming the bad path. (b) `ScanCommand` catches that error and surfaces it on stderr with exit code 2 (operational error), without touching the DB. (c) Even if a future bug or weird edge case still produces a zero-result `ScanResult`, the CLI counts existing `scan_*` rows before persisting and refuses to wipe a populated DB without an explicit `--allow-empty` flag. This closes the typo-trap the user hit during validation: `sm scan -- --dry-run` (where `--` made `--dry-run` a positional root that didn't exist) silently wiped the populated sandbox DB. The new flag preserves the legitimate "wipe by scanning an empty fixture" workflow but only when explicit. Six new regression tests cover both the kernel-level and CLI-level paths.

### Step 3 — UI design refinement — ✅ complete

Iterate the Flavor A prototype's visual design against mock data before committing kernel API surface. Cheap to change now; expensive after Step 4 locks the scan output shape.

- ✅ Dark mode parity: `--sm-*` CSS custom properties for kind accents (5 kinds × border/badge-bg/badge-fg), edge colors (3 types), link badge colors, severity colors. `.app-dark` overrides with dark-appropriate values. All ~40 hardcoded hex colors in graph-view, event-log, and inspector-view replaced.
- ✅ Node card redesign: kind-specific subtitles — agent→model, hook→event, command→shortcut, skill→I/O count. Applied to both graph nodes (new `.f-gnode__subtitle` row, `NODE_HEIGHT` 96→110) and list rows (secondary `.list__cell-detail` line).
- ✅ Connection styling: differentiated `stroke-width` (supersedes 2.5, requires 2, related 1.5). SVG `<marker>` arrowhead definitions added (best-effort — depends on Foblex SVG scope).
- ✅ Inspector layout: reordered cards — Summary (full-width hero with left accent) → Kind-specific → Relations → Metadata → Tools → External → Body. Grid switched from `auto-fit, minmax(320px, 1fr)` to explicit `1fr 1fr` with full-width spans.
- ✅ Responsive baseline: `@media` breakpoints at 1280px and 1024px across topbar (compact gaps, hide tag, wrap nav), filter-bar (smaller min-widths), event-log (collapse grid to 2 columns), inspector (single-column grid), graph (reduce min-height to 400px).
- ✅ Empty / error / loading states: shared `.empty-state` CSS utility classes in `styles.css`. Structured icon+title+description pattern applied to graph (loading, error, no-match), inspector (no-selection, not-found), event-log (no events).
- ✅ Bundle budget: investigated — Aura full-preset (~173kB PrimeNG chunk) is the main contributor; per-component theme imports not supported by PrimeNG v21. Warning threshold raised from 500kB to 600kB for prototype phase. Unused `DividerModule` removed from inspector. Full compliance deferred to Step 14.

### Step 2 — First extension instances — ✅ complete

- Runtime contracts: five interfaces in `src/kernel/extensions/` — `IAdapter` (walk async iterator + classify), `IDetector` (detect with scope hint + emitsLinkKinds allowlist), `IRule` (evaluate over full graph), `IRenderer` (render → string keyed by format), `IAudit` (run → TAuditReport). A plugin's default export IS the runtime instance (manifest fields + methods on the same object). ✅ landed.
- Shared utility `src/kernel/trigger-normalize.ts` implements the six-step pipeline (NFD → strip diacritics → lowercase → separator unification → collapse whitespace → trim) from §Architecture Decision #21. ✅ landed.
- Provider: **`claude`** — walks `.claude/{agents,commands,hooks,skills}/*.md` + `notes/**/*.md` with a fallback to `note`, parses frontmatter via js-yaml (tolerating malformed YAML), default ignore set (`.git`, `node_modules`, `dist`, `.skill-map`), async iterator so large scopes don't buffer. ✅ landed.
- Detectors: **`frontmatter`** (structured refs from `metadata.supersedes[]` / `supersededBy` / `requires[]` / `related[]`), **`slash`** (`/command` tokens in body with trigger normalization), **`at-directive`** (`@agent` handles in body). Each dedupes on normalized trigger and respects its declared scope. `external-url-counter` remains deferred to Step 4 as the drop-in litmus proof. ✅ landed.
- Rules: **`trigger-collision`** (error — 2+ distinct targets sharing a normalized trigger), **`broken-ref`** (warn — targets that resolve neither by path nor by normalized name), **`superseded`** (info — one per node declaring `metadata.supersededBy`). ✅ landed.
- Formatter: **`ascii`** — plain-text dump grouped by kind then links then issues. ✅ landed.
- Rule: **`validate-all`** — post-scan consistency check via AJV against `node.schema.json` / `link.schema.json` / `issue.schema.json`. Plugin-manifest validation already enforced at load time by the PluginLoader (Step 1b), so this Rule only revalidates user content. ✅ landed.
- Actions: 0 shipped (contract available). Deferred per the spec.
- Built-ins registry (`src/extensions/built-ins.ts`) exposes the full set as callable instances (`builtIns()`) and as Registry-ready manifest rows (`listBuiltIns()`). The orchestrator wires the two by accepting a new `RunScanOptions.extensions` field alongside the kernel's registry.
- Orchestrator (`src/kernel/orchestrator.ts`) now iterates the pipeline for real: for each Provider it walks roots and classifies nodes, feeds them through scope-appropriate Extractors, collects links, denormalises `linksOutCount` / `linksInCount`, then runs every Rule over the graph. Sha256 body/frontmatter hashes + triple-split bytes are computed on the node record. Links whose kind isn't in the Extractor's declared `emitsLinkKinds` allowlist are silently dropped.
- `sm scan` updated — defaults to the built-in set, exits 1 when the scan surfaces issues (per `cli-contract.md` §Exit codes), exposes `--no-built-ins` for the kernel-empty-boot parity case.
- Acceptance (drop-in proof): the orchestrator iterates `registry.all('extractor')` — adding a 4th Extractor is one new file under `src/built-in-plugins/extractors/` + one entry in `built-ins.ts`. Zero kernel edits. Step 4's `external-url-counter` lands as the live proof. ✅ architecturally honoured.
- End-to-end test (`src/test/scan-e2e.test.ts`) against a temp fixture with 3 nodes covering agent + command kinds — asserts node count / kinds / hashes / bytes, the four expected link families (frontmatter.related, slash, at-directive, supersededBy inversion), and the two expected Rule issues (broken-ref for the unresolved `@backend-lead`, superseded for `deploy.md`). ✅ landed. Suite total: 88 of 88 tests passing (was 36 before Step 2; +52 new across normalization, claude, Extractors, Rules, Formatter, validate-all, built-ins, and the e2e).

### Step 1 — Kernel skeleton (split into three sub-steps)

The original "Step 1" bundled several independent deliverables (storage, migrations, plugin loader, orchestrator, CLI dispatcher, introspection, self-boot). Splitting keeps each sub-step testable on its own; the boundary between them is a green CI plus the specific acceptance criterion named below. All three must land before Step 2 starts.

#### Step 1a — Storage + migrations — ✅ complete

- SQLite (`node:sqlite`) wired behind `StoragePort` via `SqliteStorageAdapter` (Kysely + `CamelCasePlugin`). Kysely's official SQLite dialect depends on `better-sqlite3` (native — forbidden by Decision #7); the kernel ships a bespoke `NodeSqliteDialect` under `src/kernel/adapters/sqlite/dialect.ts` that reuses Kysely's pure-JS `SqliteAdapter` / `SqliteIntrospector` / `SqliteQueryCompiler` and plugs a minimal Driver on top of `node:sqlite`'s `DatabaseSync`. ✅ landed.
- Kernel migrations in `src/migrations/` (`NNN_snake_case.sql`, up-only, transaction-wrapped). `001_initial.sql` provisions all 11 kernel tables from `db-schema.md` with full CHECK constraints, named indexes, and the unique partial index on `state_jobs` that enforces the job-lifecycle duplicate-detection contract. ✅ landed.
- `config_schema_versions` ledger populated; `PRAGMA user_version` kept in sync. Both writes share the same transaction as the migration itself, so partial success cannot drift the ledger. ✅ landed.
- Auto-apply on startup with auto-backup to `.skill-map/backups/skill-map-pre-migrate-v<N>.db`. WAL checkpoint runs before the file copy so the backup is complete without needing to capture `-wal` / `-shm` sidecars. ✅ landed. `autoMigrate: false` / `autoBackup: false` constructor options handle the Step 6 `autoMigrate` config toggle and the `sm db migrate --no-backup` flag respectively.
- `sm db backup / restore / reset / reset --state / reset --hard / shell / dump / migrate [--dry-run|--status|--to|--no-backup]` operational. Destructive verbs (`restore`, `reset --state`, `reset --hard`) prompt via `readline` unless `--yes` / `--force`. `shell` and `dump` spawn the system `sqlite3` binary with a pointed error on ENOENT. ✅ landed.
- `tsup.config.ts` gained an `onSuccess` hook that copies `src/migrations/` to `dist/migrations/` so the published artifacts find them via `defaultMigrationsDir()`; `src/package.json#files` now includes `migrations/`. ✅ landed.

Acceptance: spin a fresh scope, run `sm db migrate --dry-run`, apply, corrupt a row, restore from backup — round-trip green. ✅ codified in `src/test/storage.test.ts` (the `round-trip: migrate → write → backup → corrupt → restore` case). 24 of 24 tests pass.

**Deferred to Step 1b**: `sm db migrate --kernel-only` and `--plugin <id>` — their surface exists in the spec (CLI contract) but every migration today is a kernel migration, so they would be no-ops. They light up when the plugin loader lands and plugin-authored migrations enter the mix.

#### Step 1b — Registry + plugin loader — ✅ complete

- `Registry` enforcing the 6 kinds + duplicate-id rejection within a kind already landed in Step 0b and remained unchanged — the validation the plugin loader needs sits upstream (in the loader itself), where it has the plugin + file context. ✅ landed.
- `PluginLoader` (`src/kernel/adapters/plugin-loader.ts`) implements drop-in discovery in `<scope>/.skill-map/plugins/*` and `~/.skill-map/plugins/*`, parses `plugin.json`, checks `semver.satisfies(installed @skill-map/spec, manifest.specCompat)` with prerelease-aware matching, dynamic-imports every listed extension, and validates each default export against its `extensions/<kind>.schema.json`. All validation goes through AJV Draft 2020-12 compiled from the schemas published by `@skill-map/spec`. ✅ landed.
- `sm plugins list / show / doctor` operational (`src/cli/commands/plugins.ts`). Enable/disable deferred to Step 6 with `config_plugins`. ✅ landed.
- Three failure modes surface precise diagnostics and the kernel keeps booting: `invalid-manifest` (JSON parse failure or AJV failure against `plugins-registry.schema.json#/$defs/PluginManifest`, including a malformed `specCompat` range), `incompatible-spec` (semver mismatch), `load-error` (missing extension file, dynamic-import failure, missing/unknown `kind`, or extension default export failing its kind schema). ✅ landed.
- The six extension-kind schemas use `unevaluatedProperties: false` (rather than `additionalProperties: false`) on top of an `allOf` reference to `base.schema.json` — closed-content enforcement that survives the Draft 2020-12 composition footgun where `additionalProperties: false` + `allOf` would make no real extension manifest validatable. ✅ landed.

Acceptance: three bogus-plugin scenarios codified in `src/test/plugin-loader.test.ts` (`invalid-manifest` via missing required fields AND malformed JSON, `incompatible-spec` via a `>=999.0.0` compat range, `load-error` via missing extension file AND default export failing its kind schema), plus a green-path case and a mixed scenario proving the kernel keeps going when one plugin in the search path is bad. ✅ 32 of 32 tests pass.

**Deferred to Step 2**: `sm db migrate --kernel-only` and `--plugin <id>` flags. Their CLI surface exists in the spec, but every migration today is a kernel migration; the flags only become meaningful when plugin-authored migrations enter the mix, which depends on Step 2's triple-protection SQL parser + prefix rewriter. Also deferred from the earlier roadmap: typed-DTO emission from `@skill-map/spec` — after building the loader against hand-curated local mirrors, closing the DTO gap requires a third consumer to justify a canonical shape, and Step 2's first real adapter is where that arrives.

#### Step 1c — Orchestrator + CLI dispatcher + introspection — ✅ complete

- Scan orchestrator (`src/kernel/orchestrator.ts`) iterates the registry pipeline (Providers → Extractors → Rules) end-to-end and emits `scan.started` / `scan.completed` through a `ProgressEmitterPort`. With zero registered extensions the iteration produces a zero-filled valid `ScanResult` — the same outcome the Step 0b stub produced, now from the real code path. `InMemoryProgressEmitter` lands alongside as the default in-process emitter; the WebSocket-backed emitter arrives at Step 14. ✅ landed.
- Concrete extension runtime interfaces (`provider.classify()`, `extractor.extract()`, `rule.evaluate()`) are still not defined — they arrive with the first real extensions at Step 2. The iteration sites carry `TODO(step-2)` markers so the Step 2 drop-in test (add a 4th Extractor with zero kernel edits) stays honoured.
- Full Clipanion verb registration (`src/cli/commands/stubs.ts`) covers every verb in `cli-contract.md` that doesn't yet have a real implementation. 35 stub classes, each with the contract's declared flags typed correctly and a `category` / `description` / `details` usage block so `sm help` sees the full surface. `execute()` writes a one-liner pointing at the Step that will implement it and returns exit 2. ✅ landed.
- `sm help [<verb>] [--format human|md|json]` operational (`src/cli/commands/help.ts`). `human` delegates to Clipanion's own `cli.usage()` so terminal output matches the built-in exactly; `json` emits the structured surface dump per `cli-contract.md` §Help; `md` emits canonical markdown grouped by category. Single-verb mode (`sm help scan --format json`) emits just the one block. Unknown verb → exit 5; unknown format → exit 2. ✅ landed.
- `context/cli-reference.md` regenerated by the CLI workspace's `reference` script (delegates to `src/scripts/build-reference.js`) from `sm help --format md`. Workspace scripts: `npm run reference --workspace=@skill-map/cli` writes, `npm run reference:check --workspace=@skill-map/cli` fails on drift. Current reference covers every verb — 290 lines, 6.5KB. ✅ landed.
- Self-boot invariant (`kernel-empty-boot` conformance case) passes end-to-end through the real `bin/sm.js` → real `runScan()` path, no longer via the Step 0b stub. ✅ landed.

Acceptance: `sm help` covers every verb in the spec; `context/cli-reference.md` is byte-equal to `sm help --format md` output and the CLI workspace's `reference:check` blocks drift; `kernel-empty-boot` passes via the real orchestrator. 36 of 36 tests passed at Step 1c close (32 prior + 4 new covering scan event emission, empty-registry orchestrator iteration, and InMemoryProgressEmitter subscribe/unsubscribe). Test count continued to grow through Step 2; see the Step 2 completeness marker for the current total.

### Step 0c — UI prototype (Flavor A)

#### Design spec (from §UI section)

Build order inversion: UI prototype **before** kernel implementation. Mocked JSON fixtures derived from a real on-disk collection of skills / agents / commands / hooks / notes. Iterates design cheaply before committing to kernel API.

Scope:
- Graph view (Foblex Flow) — card-style nodes with title, kind badge, version, triggers, link counts.
- List view with frontmatter-driven columns.
- Inspector panel: weight, summary (mocked), links, issues, findings, 🔄 det + 🧠 prob buttons.
- Filters by kind / stability / issue.
- Simulated event flow: fake run-queue emitting canonical events.

Tech picks locked at Step 0c start:
- Frontend framework: **Angular latest** (standalone components). Always track the latest stable Angular release; upgrades happen explicitly by editing the pinned version in `ui/package.json`, not automatically via caret ranges. (Decision #72.)
- Node-based UI library: **Foblex Flow** (Angular-native). Cards as Angular components with arbitrary HTML.
- Component library: **PrimeNG** (tables, forms, dialogs, menus, overlays).
- Styling: **SCSS scoped per component**. No utility CSS framework (no Tailwind, no PrimeFlex) — avoided overlap with PrimeNG's own theming.
- Workspace: `ui/` as an npm workspace peer of `spec/` and `src/`. The kernel never imports Angular; the UI never imports kernel internals (only typed DTOs from `spec/`).

Decisions on the table for §Step 0c:

- **Decision #72**: Angular pin tracks the latest stable release. Upgrades happen explicitly by editing `ui/package.json`; no caret ranges.
- **Dependency pinning policy**: `package.json` at root, `ui/`, and `src/` pin every dependency to an exact version (no `^` / `~`). Reproducible installs and zero-surprise upgrades take priority over automatic patch drift. `spec/` has no dependencies. The policy is revisited the day `src/` flips to public — a published lib may want caret ranges so consumers can dedupe transitive deps. Canonical statement in `AGENTS.md` §Rules for agents working in this repo.
- **DTO gap**: closed via codegen (json-schema-to-typescript from `spec/schemas/`) at Step 4 or Step 5. Hand-curated mirrors in `ui/src/models/` and `src/kernel/types/` until then.
- **Plugin migrations + SQL parser**: ship at Step 9 (Plugin author UX).
- **Plugin API stability (Decision #89)**: extension runtime interfaces (`IProvider`, `IExtractor`, `IRule`, `IAction`, `IFormatter`, `IHook`) declare semver-stable at v1.0.0. Pre-v1.0 changes to these interfaces are minor bumps with a changelog note.
- **Link conflict merge (Decision #90)**: when two Extractors emit a link for the same (from, to) pair, both rows coexist in `scan_links`. No merge, no dedup. Each Extractor's link carries its own confidence and source. Consumers that need uniqueness aggregate at read time.

#### Closure writeup (from §Execution plan)

- **Stack locked**: Angular 21 standalone + Foblex Flow (node-based UI) + PrimeNG + `@primeuix/themes` (the legacy `@primeng/themes` package is deprecated upstream and intentionally avoided) + SCSS scoped (no utility CSS). ✅ landed.
- `ui/` npm workspace created as peer of `spec/` and `src/`. Root `package.json` workspaces array now `["spec", "src", "ui"]`; hoisted single-lockfile install verified. ✅ landed.
- Mock collection at `ui/mock-collection/` — fictional `acme-toolkit` scope with 4 agents, 4 commands, 4 skills, 3 hooks, and 3 notes, all with frontmatter conforming to `spec/schemas/frontmatter/*`. Served as build assets via `angular.json` so the prototype can `fetch('/mock-collection/…')` at runtime, simulating an on-disk scope without wiring a backend. The collection also exercises `supersedes` / `supersededBy`, `requires`, `related`, `@agent` / `#skill` / `/command` tokens in bodies, and external URLs for the future `external-url-counter` Extractor. ✅ landed.
- No backend. No BFF. Reading the mock collection at runtime stays the rule for the whole step — the specific path (`ui/mock-collection/`) is a prototype implementation detail and is NOT a fixture reused by any kernel test.
- Data pipeline: a `build-mock-index.js` prebuild script emits `mock-collection/index.json` deterministically; `CollectionLoaderService` fetches the index, parallel-fetches each `.md`, parses frontmatter with `js-yaml`, classifies kind by directory. A root `FilterStoreService` owns cross-view filter state (text search + kind + stability multi-selects) and exposes an `apply()` projection consumed by every view. `EventBusService` + `ScanSimulatorService` emit a scripted `scan.*` / `issue.*` sequence over the loaded collection so the event-flow surface has something real to display. ✅ landed.
- List view — PrimeNG Table with kind / name / path / version / stability columns, sortable, row-click opens inspector. ✅ landed.
- Inspector — full detail surface: kind + stability tags, metadata grid, kind-specific card (agent.model · command shortcut + args · hook event/condition/blocking/idempotent · skill inputs + outputs), relations as clickable chips (dead-struck-through when the target is not in the loaded set), tools allowlist / allowedTools, external links, raw-markdown body preview. ✅ landed.
- Graph view — Foblex Flow canvas with Dagre TB auto-layout, cards coloured by kind, edges for `supersedes` / `requires` / `related` (dedup'd across both-sides declarations), filter-aware (filtered-out nodes remove themselves and any dangling edges), click-to-inspect, Fit button, legend. ✅ landed.
- Filter bar — shared component mounted in both list and graph views, text search + kind multi-select + stability multi-select + contextual Reset. ✅ landed.
- Simulated event flow — collapsible bottom event-log panel showing `scan.started` / `scan.progress` / `scan.completed` + synthetic `issue.added` for deprecated nodes, auto-scroll, Clear, live "scanning" indicator. Triggered by a Simulate-scan button in the shell topbar. ✅ landed.
- Dark mode toggle — light ↔ dark persisted to localStorage, applies `.app-dark` to the document element (matching the `darkModeSelector` registered in `providePrimeNG`). Icon-only button in the topbar. ✅ landed.
- Roadmap review pass. ✅ landed as part of this section.

**Review-pass decisions**:

- **Kind classifier is throwaway**. The path-based classifier in `ui/src/services/collection-loader.ts` is prototype-only: the real classification lives in the claude adapter at Step 2, and the ui-side classifier is deleted when Step 14 consumes the kernel's real scanner output. The duplication is intentional for Step 0c — isolating the UI from the kernel is the whole point of Flavor A.
- **Simulator + event log are throwaway**. `EventBusService` and `ScanSimulatorService` (+ the `EventLog` component) exist only to give the Step 0c prototype something to render. Step 14 replaces both surfaces with the real WebSocket broadcaster consuming `spec/job-events.md` payloads; the simulator file is deleted at that transition. No Decision log row — it is prototype scope, not a locked-in architectural choice.
- **Desktop-only**. Flavor A assumes ≥1024px viewport. No responsive or mobile work. Step 14 may revisit once the full UI's surfaces and interactions are settled.
- **Bundle size is not a Flavor A objective**. Development bundles clock ~1.86MB initial, well above the `angular.json` production budgets (500 KB warn / 1 MB error); those budgets remain armed because they are the right targets for Step 14. Step 0c is `ng serve` / local-dev only, not distributed.
- **Wildcard route fallback**: `**` → `/list`. Bad deep links self-heal to the default view rather than surfacing a 404.
- **Fallback kind**: the loader classifies unknown paths as `markdown` (the format-named generic fallback; specific roles like agent / command / skill prevail over format naming when classification matches). It is the catch-all by spec convention ("everything else"); alternatives would require a user choice at Flavor A which is premature.
- **URL-synced filter state — closed at 14.3.** `FilterUrlSyncService` (Step 14.3) now bridges `FilterStoreService` and the router query string for `search` / `kinds` / `stabilities` / `hasIssues`, so deep links round-trip and filter state survives a hard reload. Originally an open item flagged here at Step 0c.

### Step 0b — Implementation bootstrap — ✅ complete

- Repo scaffolding: `package.json`, Node ESM, `node:test` wired.
- Package layout: npm workspaces (`spec/`, `src/`) with subpath `exports` on `@skill-map/cli`. `ui/` joins as a third workspace at Step 0c. An `alias/*` glob workspace later held name-reservation packages (`skill-map`, `skill-mapper`) for one publish round, then was dropped once the names were locked and `npm deprecate` redirected users to `@skill-map/cli`.
- Hexagonal skeleton: port interfaces, adapter stubs, kernel shell.
- Clipanion CLI binary prints version.
- Contract test infrastructure runs conformance suite against impl.
- CI green with 0 real features.
- Remaining tech stack picks (YAML parser, MD parsing, templating, pretty CLI, globbing, diff) are deferred to the step that first needs them — lock-in-abstract rejected.

### Step 0a — Spec bootstrap — ✅ complete

- `spec/` scaffolded and public from commit 1.
- `spec/README.md`, `spec/CHANGELOG.md`, `spec/versioning.md`.
- 29 JSON Schemas (draft 2020-12): 11 top-level (`node`, `link`, `issue`, `scan-result`, `execution-record`, `project-config`, `plugins-registry`, `job`, `report-base`, `conformance-case`, `history-stats`), 7 extension schemas under `schemas/extensions/` (`base` + one per kind, validated at plugin load), 6 frontmatter under `schemas/frontmatter/` (`base` + 5 kinds, each extending `base` via `allOf`), 5 summaries under `schemas/summaries/` (each extending `report-base` via `allOf`). Full tree in §Spec as a standard → Repo layout.
- `spec/architecture.md`, `cli-contract.md`, `job-events.md`, `prompt-preamble.md`, `db-schema.md`, `plugin-kv-api.md`, `job-lifecycle.md`.
- `spec/interfaces/security-scanner.md` — convention over the Action kind (NOT a 7th extension kind).
- Conformance suite: `basic-scan` + `kernel-empty-boot` cases, `minimal-claude` fixture, verbatim `preamble-v1.txt` (the third case `preamble-bitwise-match` is deferred to Step 10).
- `spec/index.json` — machine-readable manifest with per-file sha256 integrity block (regenerated by the spec workspace's `npm run spec` script; CI blocks drift via the root `validate` orchestrator, which runs the spec workspace's `spec:check`).
- npm package `@skill-map/spec` published via changesets. Current version lives in `spec/package.json` and `spec/CHANGELOG.md` — do not duplicate it in this narrative.

## Document changelog

The dated edit history of `ROADMAP.md` is preserved here verbatim, oldest first within each entry:

- **2026-05-09** — UI contribution system: view-contribution catalog reorg (contracts retyped to `<scope>-<form>` — `per-node-*` → `node-*`, `node-summary` → `node-markdown`, `node-marker` → `node-alert`, `scope-summary` → `scope-stat`); slot ids retyped to `surface.location.name`; `core/annotations` reclassified as misadopter and its `node-key-values` contribution dropped — frontmatter scalars now live on `node.frontmatter` first-class. Added "Follow-up: slot debug overlay" describing the throwaway `?debug-slots=1` debug visualization and the proper `debug.slotsVisible` settings key that replaces it once the runtime settings loader lands; reserved key documented under §Configuration → "UI-side keys".
- **2026-05-08** — Extractor reduced to deterministic-only: manifest no longer carries `mode`, `ctx.runner` removed from Extractor context, `node_enrichments.{stale, body_hash_at_enrichment, is_probabilistic}` columns reserved-but-inert. LLM-driven enrichment of a node is now strictly an Action concern, queued through the job subsystem; the Extractor pipeline stays on the deterministic scan path with Provider and Formatter. Pre-1.0 minor bump per `versioning.md`.
