@skill-map/spec 0.19.0 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +607 -6
- package/README.md +6 -6
- package/architecture.md +129 -57
- package/cli-contract.md +71 -25
- package/conformance/README.md +2 -2
- package/conformance/cases/kernel-empty-boot.json +2 -2
- package/conformance/cases/sidecar-end-to-end.json +3 -3
- package/conformance/coverage.md +5 -5
- package/conformance/fixtures/sidecar-end-to-end/.claude/agents/orphan.sm +1 -1
- package/conformance/fixtures/sidecar-example/agent-example.md +1 -1
- package/db-schema.md +22 -18
- package/index.json +36 -36
- package/interfaces/security-scanner.md +2 -2
- package/job-events.md +12 -12
- package/job-lifecycle.md +1 -1
- package/package.json +1 -1
- package/plugin-author-guide.md +131 -82
- package/plugin-kv-api.md +6 -6
- package/prompt-preamble.md +1 -1
- package/schemas/annotations.schema.json +4 -4
- package/schemas/api/rest-envelope.schema.json +4 -4
- package/schemas/conformance-case.schema.json +2 -2
- package/schemas/extensions/analyzer.schema.json +43 -0
- package/schemas/extensions/base.schema.json +5 -5
- package/schemas/extensions/extractor.schema.json +1 -1
- package/schemas/extensions/hook.schema.json +6 -4
- package/schemas/issue.schema.json +6 -6
- package/schemas/link.schema.json +2 -2
- package/schemas/plugins-registry.schema.json +1 -1
- package/schemas/project-config.schema.json +19 -1
- package/schemas/sidecar.schema.json +2 -2
- package/schemas/summaries/agent.schema.json +1 -1
- package/schemas/summaries/command.schema.json +1 -1
- package/schemas/summaries/hook.schema.json +1 -1
- package/schemas/{view-contracts.schema.json → view-slots.schema.json} +93 -55
- package/schemas/extensions/rule.schema.json +0 -43
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@ This document is the **source of truth**. The reference implementation under `..
|
|
|
7
7
|
## What this spec defines
|
|
8
8
|
|
|
9
9
|
- The **domain model**: nodes, links, issues, scan results.
|
|
10
|
-
- The **extension contract**: six extension kinds (provider, extractor,
|
|
10
|
+
- The **extension contract**: six extension kinds (provider, extractor, analyzer, action, formatter, hook) with their input/output shapes.
|
|
11
11
|
- The **CLI contract**: verb set, flags, exit codes, JSON introspection.
|
|
12
12
|
- The **persistence contract**: table catalog owned by the kernel, plugin key-value API.
|
|
13
13
|
- The **job contract**: lifecycle states, event stream, prompt preamble, submit/claim/record semantics.
|
|
@@ -36,10 +36,10 @@ These are implementation decisions. The reference impl picks them (see [`../AGEN
|
|
|
36
36
|
|
|
37
37
|
## Naming conventions
|
|
38
38
|
|
|
39
|
-
Two
|
|
39
|
+
Two analyzers govern every identifier in the spec. They are **normative**.
|
|
40
40
|
|
|
41
|
-
- **Filesystem artefacts use kebab-case.** Every file and directory in `spec/` (and in any conforming implementation) — `scan-result.schema.json`, `job-lifecycle.md`, `report-base.schema.json`, `auto-rename-medium` (as an `issue.
|
|
42
|
-
- **JSON content uses camelCase.** Every key inside a JSON Schema, frontmatter block, config file, plugin manifest, action manifest, job record, report, event payload, or API response is camelCase: `whatItDoes`, `injectionDetected`, `expectedTools`, `conflictsWith`, `docsUrl`, `examplesUrl`, `ttlSeconds`, `runId`, `jobId`. This matches the JS/TS ecosystem the reference impl ships in and the Kysely `CamelCasePlugin` that bridges to the `snake_case` SQL layer — but the
|
|
41
|
+
- **Filesystem artefacts use kebab-case.** Every file and directory in `spec/` (and in any conforming implementation) — `scan-result.schema.json`, `job-lifecycle.md`, `report-base.schema.json`, `auto-rename-medium` (as an `issue.analyzerId` value), `direct-override` (as a `safety.injectionType` enum value), and so on — is kebab-case lowercase. Enum values and issue analyzer ids follow the same convention so they can be echoed back into URLs, filenames, and log keys without escaping.
|
|
42
|
+
- **JSON content uses camelCase.** Every key inside a JSON Schema, frontmatter block, config file, plugin manifest, action manifest, job record, report, event payload, or API response is camelCase: `whatItDoes`, `injectionDetected`, `expectedTools`, `conflictsWith`, `docsUrl`, `examplesUrl`, `ttlSeconds`, `runId`, `jobId`. This matches the JS/TS ecosystem the reference impl ships in and the Kysely `CamelCasePlugin` that bridges to the `snake_case` SQL layer — but the analyzer is spec-level, not implementation-level: an alternative implementation in any language still exposes camelCase JSON keys.
|
|
43
43
|
|
|
44
44
|
The SQL persistence layer is the sole exception: tables, columns, and migration filenames use `snake_case` (see `db-schema.md`). That boundary is crossed only inside a storage adapter; nothing that leaves the kernel should ever be `snake_case`.
|
|
45
45
|
|
|
@@ -78,7 +78,7 @@ spec/ ← published as @skill-map/spec
|
|
|
78
78
|
│ │ ├── base.schema.json ┐
|
|
79
79
|
│ │ ├── provider.schema.json │
|
|
80
80
|
│ │ ├── extractor.schema.json │ 6 extension schemas
|
|
81
|
-
│ │ ├──
|
|
81
|
+
│ │ ├── analyzer.schema.json │ (base + 5 kinds)
|
|
82
82
|
│ │ ├── action.schema.json │
|
|
83
83
|
│ │ └── formatter.schema.json ┘
|
|
84
84
|
│ │
|
|
@@ -108,7 +108,7 @@ spec/ ← published as @skill-map/spec
|
|
|
108
108
|
## How to read this spec
|
|
109
109
|
|
|
110
110
|
- **Building a tool or plugin that consumes skill-map output?** Start with [`schemas/scan-result.schema.json`](./schemas/scan-result.schema.json) and [`schemas/node.schema.json`](./schemas/node.schema.json).
|
|
111
|
-
- **Building a custom extractor,
|
|
111
|
+
- **Building a custom extractor, analyzer, or formatter?** Read [`architecture.md`](./architecture.md), then the relevant schema under [`schemas/extensions/`](./schemas/extensions/).
|
|
112
112
|
- **Building an alternative CLI implementation?** Read [`cli-contract.md`](./cli-contract.md) and run [`conformance/`](./conformance/README.md).
|
|
113
113
|
- **Integrating a new platform (adapter)?** Read [`architecture.md`](./architecture.md) §adapters, then the Claude adapter source in `../src/extensions/adapters/claude/` as a worked example.
|
|
114
114
|
- **Shipping a job-running runner?** Read [`job-events.md`](./job-events.md), [`job-lifecycle.md`](./job-lifecycle.md), [`prompt-preamble.md`](./prompt-preamble.md).
|
package/architecture.md
CHANGED
|
@@ -67,12 +67,12 @@ Discovers plugin directories, reads `plugin.json`, checks `specCompat`, dynamica
|
|
|
67
67
|
|
|
68
68
|
Operations: `discover(scopes)`, `load(pluginPath)`, `validateManifest(json)`.
|
|
69
69
|
|
|
70
|
-
The loader enforces two id-uniqueness
|
|
70
|
+
The loader enforces two id-uniqueness analyzers during discovery (see [`plugin-author-guide.md` §Plugin id uniqueness](./plugin-author-guide.md#plugin-id-uniqueness) for the author-facing summary):
|
|
71
71
|
|
|
72
|
-
1. **Directory name == manifest id.** A plugin lives at `<root>/<id>/plugin.json`. A mismatch surfaces as status `invalid-manifest`. This
|
|
73
|
-
2. **Cross-root id collision blocks both sides.** Two plugins reachable from different roots (project + global, or any `--plugin-dir` combination) that declare the same `id` BOTH receive status `id-collision`. No precedence
|
|
72
|
+
1. **Directory name == manifest id.** A plugin lives at `<root>/<id>/plugin.json`. A mismatch surfaces as status `invalid-manifest`. This analyzer eliminates same-root collisions by construction.
|
|
73
|
+
2. **Cross-root id collision blocks both sides.** Two plugins reachable from different roots (project + global, or any `--plugin-dir` combination) that declare the same `id` BOTH receive status `id-collision`. No precedence analyzer applies — coherent with §Boot invariant ("no extension is privileged"). The user resolves by renaming one of them.
|
|
74
74
|
|
|
75
|
-
In addition, the loader **qualifies every extension** with its owning plugin id before registering it. The registry stores extensions under the qualified id `<plugin-id>/<extension-id>` (e.g. `core/slash`, `core/broken-ref`, `hello-world/greet`). Authors continue to declare the short `id` in each extension manifest; the loader composes the qualified form from `manifest.id` at load time. Built-in extensions bundled with the reference impl declare their `pluginId` directly in `built-ins.ts` — `core/` for kernel-internal primitives (every
|
|
75
|
+
In addition, the loader **qualifies every extension** with its owning plugin id before registering it. The registry stores extensions under the qualified id `<plugin-id>/<extension-id>` (e.g. `core/slash`, `core/broken-ref`, `hello-world/greet`). Authors continue to declare the short `id` in each extension manifest; the loader composes the qualified form from `manifest.id` at load time. Built-in extensions bundled with the reference impl declare their `pluginId` directly in `built-ins.ts` — `core/` for kernel-internal primitives (every analyzer, the formatter, the cross-vendor extractors `annotations` / `slash` / `at-directive` / `markdown-link` / `external-url-counter` / `stability`) and vendor-specific bundles such as `claude/` (the Claude provider) for Provider integrations whose territory is platform-bound. If a plugin author injects a `pluginId` field on an extension that disagrees with `plugin.json`'s `id`, the loader emits `invalid-manifest` with a directed reason.
|
|
76
76
|
|
|
77
77
|
Each plugin (and each built-in bundle) declares a **granularity** that controls how its extensions are toggled. `granularity: 'bundle'` (the default) means the plugin id is the only enable/disable key; `granularity: 'extension'` means each extension is independently toggle-able under its qualified id. The loader's pre-import `resolveEnabled(pluginId)` short-circuit is always coarse (bundle level) — when a granularity=`extension` bundle is partially enabled, the import work proceeds and the runtime composer (the CLI's `composeScanExtensions` / `composeFormatters` in `src/cli/util/plugin-runtime.ts`) drops the disabled extensions before they reach the orchestrator. Vendor Provider bundles (`claude`, `gemini`, `agent-skills`) ship as granularity=`bundle` (the platform integration is on or off as a whole); the `core` bundle is granularity=`extension` (every kernel built-in is removable, satisfying §Boot invariant: "no extension is privileged"). See [`plugin-author-guide.md` §Granularity — bundle vs extension](./plugin-author-guide.md#granularity--bundle-vs-extension) for the author-facing summary.
|
|
78
78
|
|
|
@@ -142,7 +142,7 @@ Mode is a property of the extension as a whole, not of an individual call. **An
|
|
|
142
142
|
| Kind | Modes | How mode is set |
|
|
143
143
|
|---|---|---|
|
|
144
144
|
| **Extractor** | deterministic-only | implicit; `mode` field MUST NOT appear |
|
|
145
|
-
| **
|
|
145
|
+
| **Analyzer** | deterministic / probabilistic | declared in manifest (`mode` field, optional; defaults to `deterministic`) |
|
|
146
146
|
| **Action** | deterministic / probabilistic | declared in manifest (`mode` field, **required** — no default) |
|
|
147
147
|
| **Hook** | deterministic / probabilistic | declared in manifest (`mode` field, optional; defaults to `deterministic`) |
|
|
148
148
|
| **Provider** | deterministic-only | implicit; `mode` field MUST NOT appear |
|
|
@@ -163,7 +163,7 @@ This separation is normative: a probabilistic extension cannot register a hook t
|
|
|
163
163
|
|
|
164
164
|
The kernel exposes the LLM through the `RunnerPort` (see §Ports above). Reference impl: `ClaudeCliRunner`. Tests: `MockRunner`. Other adapters (OpenAI, local Ollama, etc.) implement the same port without spec changes.
|
|
165
165
|
|
|
166
|
-
A probabilistic Action,
|
|
166
|
+
A probabilistic Action, Analyzer, or Hook receives the runner in its invocation context alongside `ctx.store` (Extractors are deterministic-only and never see the runner). The extension never imports a specific LLM SDK — the runner contract is what the spec normalizes; wire format and model selection are adapter concerns.
|
|
167
167
|
|
|
168
168
|
---
|
|
169
169
|
|
|
@@ -175,10 +175,22 @@ Six kinds, all first-class, all loaded through the same registry. Each kind has
|
|
|
175
175
|
|---|---|---|---|
|
|
176
176
|
| **Provider** | Recognizes a platform. Declares the catalog of node `kind`s it emits via the `kinds` map; each map entry pairs the kind's frontmatter schema (path relative to the Provider's package directory) with its `defaultRefreshAction` (a qualified action id that drives the probabilistic-refresh surface). Also declares the filesystem `explorationDir` where its content lives. Deterministic-only. | Filesystem walk results, candidate path. | `{ kind, provider } \| null`. |
|
|
177
177
|
| **Extractor** | Extracts signals from a node body. Deterministic-only: runs synchronously inside `sm scan`. Output flows through three context callbacks (no return value): `ctx.emitLink(link)` for the kernel's `links` table, `ctx.enrichNode(partial)` for the kernel's enrichment layer (separate from the author's frontmatter), `ctx.store` for the plugin's own KV / dedicated tables. | Parsed node (frontmatter + body) + callbacks. | `void` (output via callbacks). |
|
|
178
|
-
| **
|
|
178
|
+
| **Analyzer** | Evaluates the graph. Dual-mode: `deterministic` runs in `sm check`, `probabilistic` runs in jobs. | Full graph (nodes + links). | `Issue[]`. |
|
|
179
179
|
| **Action** | Operates on one or more nodes. Dual-mode: `deterministic` (in-process code) or `probabilistic` (rendered prompt the runner executes). | Node(s), optional args. | Deterministic: report JSON. Probabilistic: rendered prompt that a runner executes. |
|
|
180
180
|
| **Formatter** | Serializes the graph. Deterministic-only. | Graph + optional filter. | String (ASCII / Mermaid / DOT / JSON / user-defined). |
|
|
181
|
-
| **Hook** | Reacts declaratively to one of
|
|
181
|
+
| **Hook** | Reacts declaratively to one of ten curated lifecycle events — eight pipeline-driven (`scan.started`, `scan.completed`, `extractor.completed`, `analyzer.completed`, `action.completed`, `job.spawning`, `job.completed`, `job.failed`) plus two CLI-process-driven (`boot` before verb routing, `shutdown` after the verb's exit code resolves). Dual-mode: `deterministic` runs in-process during the dispatch, `probabilistic` is enqueued as a job. Hooks REACT to events; they cannot block, mutate, or steer the pipeline. | A curated event payload (run-scoped, scan-scoped, job-scoped, or process-scoped) plus an optional declarative `filter` map. | `void` (reactions are side effects). |
|
|
182
|
+
|
|
183
|
+
### IO discipline — extensions never write to the filesystem
|
|
184
|
+
|
|
185
|
+
Extensions (Provider / Extractor / Analyzer / Action / Formatter / Hook) are **pure**: they consume kernel-supplied context and emit data through return values or `ctx.*` callbacks. They MUST NOT perform filesystem writes directly — not via `fs.writeFile`, not via shell, not via a third-party library. Implementations MUST NOT expose any port that hands an extension a writable filesystem handle.
|
|
186
|
+
|
|
187
|
+
The materialisation of any kernel-managed artefact (the SQLite DB at `.skill-map/skill-map.db`, the `.sm` sidecars next to source files, the job ledger at `.skill-map/jobs/`, the `scan_extractor_runs` cache, the enrichment overlay rows) is the **kernel's** responsibility, gated through the relevant Port:
|
|
188
|
+
|
|
189
|
+
- Extractors persist via `ctx.emitLink` / `ctx.enrichNode` / `ctx.store` — never by writing files. `ctx.store` is plugin-scoped persistence routed through `StoragePort`; it cannot reach the project filesystem.
|
|
190
|
+
- Actions return either a deterministic report (JSON), a rendered prompt (probabilistic), or — for the small subset of actions that legitimately mutate persisted state — an explicit `TActionWrite` discriminated union the kernel interprets. The built-in `core/bump` is the only action that returns `{ kind: 'sidecar' }` today; the kernel routes that write through `SidecarStore.applyPatch`, which is the single gated chokepoint for all `.sm` writes (see §Annotation system · Write consent).
|
|
191
|
+
- Providers, Analyzers, Formatters, Hooks have no write surface at all.
|
|
192
|
+
|
|
193
|
+
This invariant is what makes the consent gate at the kernel boundary sufficient: no extension can bypass it because no extension has the means to write in the first place. Conformance: a third-party extension that imports `node:fs` write APIs (or equivalent in another language) is non-conforming.
|
|
182
194
|
|
|
183
195
|
### Provider · `kinds` catalog
|
|
184
196
|
|
|
@@ -245,7 +257,7 @@ Read-side merge (`mergeNodeWithEnrichments` in the reference impl):
|
|
|
245
257
|
2. Sort by `enriched_at` ASC.
|
|
246
258
|
3. Spread-merge each `value` over the author frontmatter (last-write-wins per field).
|
|
247
259
|
|
|
248
|
-
|
|
260
|
+
Analyzers / `sm check` / `sm export` consume `node.frontmatter` directly (deterministic CI-safe baseline); enrichment consumption is opt-in by the caller.
|
|
249
261
|
|
|
250
262
|
Refresh verbs (`sm refresh <node>` and `sm refresh --stale`) re-run the Extractor pipeline against a node or the stale set and upsert fresh enrichment rows — see [`cli-contract.md` §Scan](./cli-contract.md#scan). With Extractors deterministic-only, `--stale` is a no-op today (no rows are stale-flagged); it remains in the contract for the future Action-prob enrichment revision noted above.
|
|
251
263
|
|
|
@@ -262,7 +274,8 @@ The contract the cache MUST satisfy (engine-agnostic):
|
|
|
262
274
|
- A node-level cache hit (body+frontmatter unchanged) is upgraded to a full skip ONLY when every currently-registered Extractor that applies to the node's kind has a recorded run against the prior body hash.
|
|
263
275
|
- A new Extractor registered between scans MUST run on the cached node — its absence from the cache is the canonical signal. The rest of the cache (existing Extractors against the same body) is preserved.
|
|
264
276
|
- An Extractor uninstalled between scans MUST have its cache rows removed and its sole-source links dropped. Links whose `sources` mix the uninstalled Extractor's short id with a still-cached Extractor's short id MUST be reshaped: the obsolete short id is stripped from the array and the link survives with the cached attribution intact. The persisted audit trail therefore never references a removed contributor.
|
|
265
|
-
- The cache
|
|
277
|
+
- The cache key includes the canonical hash of `node.sidecar.annotations` alongside the body hash. A sidecar-only edit (`.sm` change without a `.md` change) invalidates the cached run for every Extractor that ran against that node. Universal invalidation is deliberate: an opt-in flag was considered and rejected because forgetting it produces a silent stale-data bug, while the cost of running every Extractor again on a `.sm` edit is negligible (sidecars change rarely, Extractors are pure-CPU). The hash uses a deterministic canonical form so a YAML re-format that does not change the annotation values does not invalidate the cache.
|
|
278
|
+
- The cache is otherwise transparent to plugin authors. An Extractor cannot opt out and cannot inspect the cache; its only obligation is to be deterministic for a given input (this is structural: every Extractor is deterministic-only, by spec).
|
|
266
279
|
|
|
267
280
|
The invariant exists to keep `sm scan --changed` cheap on real corpora: re-parsing a body that has not changed for an Extractor that has not changed is wasted work; the cache turns it into a one-row reuse. The same machinery is what will let a future Action-prob enrichment revision (see §Extractor · enrichment layer) reuse paid LLM output across unchanged bodies.
|
|
268
281
|
|
|
@@ -271,7 +284,7 @@ The invariant exists to keep `sm scan --changed` cheap on real corpora: re-parsi
|
|
|
271
284
|
Extractors that emit invocation-style links (slashes, at-directives, command names) populate the `link.trigger` block defined in [`schemas/link.schema.json`](./schemas/link.schema.json):
|
|
272
285
|
|
|
273
286
|
- `originalTrigger` — the exact source text the extractor saw, byte-for-byte. Used only for display.
|
|
274
|
-
- `normalizedTrigger` — the output of the pipeline below. Used for equality and collision detection — the built-in `trigger-collision`
|
|
287
|
+
- `normalizedTrigger` — the output of the pipeline below. Used for equality and collision detection — the built-in `trigger-collision` analyzer keys on this field.
|
|
275
288
|
|
|
276
289
|
Both fields MUST be present whenever `link.trigger` is non-null. Implementations MUST produce byte-identical `normalizedTrigger` output for byte-identical input across platforms and locales.
|
|
277
290
|
|
|
@@ -303,17 +316,19 @@ Characters outside the separator set that are not letters or digits (e.g. `/`, `
|
|
|
303
316
|
|
|
304
317
|
### Hook · curated trigger set
|
|
305
318
|
|
|
306
|
-
Hooks subscribe declaratively to a curated set of kernel lifecycle events and react to them. Reaction-only by design: a hook cannot mutate the pipeline, block emission, or alter outputs. The hookable trigger set is intentionally small —
|
|
319
|
+
Hooks subscribe declaratively to a curated set of kernel lifecycle events and react to them. Reaction-only by design: a hook cannot mutate the pipeline, block emission, or alter outputs. The hookable trigger set is intentionally small — ten events out of the full [`job-events.md`](./job-events.md) catalog. Eight are pipeline-driven (emitted from inside `runScan`); two (`boot`, `shutdown`) are CLI-process-driven (emitted by the driving binary before / after the verb runs, fire-and-forget so `process.exit` is never blocked). Other events (per-node `scan.progress`, `model.delta`, `run.*`, `job.claimed`, `job.callback.received`) are deliberately NOT hookable: too verbose for a reactive surface, internal to the runner, or covered elsewhere. Declaring a trigger outside the curated set yields `invalid-manifest` at load time.
|
|
307
320
|
|
|
308
321
|
| Trigger | When it fires | Payload (key fields) | Hook scope |
|
|
309
322
|
|---|---|---|---|
|
|
323
|
+
| `boot` | Once per CLI process invocation, BEFORE the verb routes. The dispatcher AWAITS subscribed hooks so anything they print lands above the verb's output (the `core/update-check` banner relies on this); a slow hook therefore delays the first verb paint. The dispatcher catches every hook error so a buggy hook never prevents the verb from running — it can only delay it. Use sparingly. | `argv: string[]` (the routed argv slice the CLI is about to parse). | Boot-time output that must appear above the verb (the `core/update-check` banner), pre-flight checks, telemetry warm-up. |
|
|
310
324
|
| `scan.started` | Once at the start of every `sm scan` invocation. | `roots: string[]`. | Pre-scan setup (cache warm-up, telemetry init). |
|
|
311
325
|
| `scan.completed` | Once at the end of every `sm scan` invocation. | `stats: { filesWalked, nodesCount, linksCount, issuesCount, durationMs }`. | Post-scan reaction (Slack notification, CI gate, summary). |
|
|
312
326
|
| `extractor.completed` | Once per registered Extractor, after the full walk completes. Aggregated, NOT per-node. | `extractorId: string` (qualified). | Per-Extractor metrics, audit. |
|
|
313
|
-
| `
|
|
327
|
+
| `analyzer.completed` | Once per Analyzer, after every issue has been validated. | `analyzerId: string` (qualified). | Per-Analyzer alerting, downstream tooling. |
|
|
314
328
|
| `action.completed` | Once per Action invocation, after the report has been recorded. | `actionId: string` (qualified), `node`, `jobResult`. | Per-Action notification, integration glue. |
|
|
315
329
|
| `job.spawning` | Pre-spawn of a runner subprocess (job subsystem; Step 10). | `jobId`, `actionId`, spawn metadata. | Pre-flight checks, audit logging. |
|
|
316
330
|
| `job.spawning`, `job.completed`, `job.failed` | The three job-lifecycle hookables; same payload shapes as the [`job-events.md`](./job-events.md) entries of the same name. | See [`job-events.md` §Event catalog](./job-events.md#event-catalog). | Most common Hook surface (notifications, retries, billing). |
|
|
331
|
+
| `shutdown` | Once per CLI process invocation, AFTER the verb returns its exit code and BEFORE `process.exit`. The dispatcher awaits subscribed hooks so they finish before the process terminates, but every hook MUST be fast (the user already saw the verb's output and is waiting for the prompt back). The dispatcher catches every hook error so a buggy hook never alters the verb's exit code; it can only delay the exit. | `exitCode: number` (the verb's resolved exit code, `0..5`). | Cleanup, post-run telemetry, the `core/update-check` banner. |
|
|
317
332
|
|
|
318
333
|
A hook MAY narrow further with an optional declarative `filter` map: keys are payload field paths (top-level only in v0.x); values are the literal expected match. The dispatcher walks `event.data` for each declared key and short-circuits the invocation when any value disagrees. Examples:
|
|
319
334
|
|
|
@@ -330,7 +345,7 @@ A hook MAY narrow further with an optional declarative `filter` map: keys are pa
|
|
|
330
345
|
|
|
331
346
|
Hooks introduce no new persisted state and do NOT participate in the deterministic scan cache (A.9). A scan that re-runs against an unchanged corpus dispatches `scan.started` / `scan.completed` exactly as before; subscribed hooks fire on every scan regardless of cache hit / miss. Hooks that need cache-aware behaviour MUST inspect their own state via `ctx.store` (declared in their plugin's manifest).
|
|
332
347
|
|
|
333
|
-
### Contract
|
|
348
|
+
### Contract analyzers
|
|
334
349
|
|
|
335
350
|
1. An extension declares its kind in its module export and its manifest. Kind mismatch → load-error.
|
|
336
351
|
2. An extension MAY declare `preconditions` — predicates that must be satisfied for the extension to be offered (e.g., `action.requires: ["kind=skill"]`).
|
|
@@ -341,11 +356,11 @@ Hooks introduce no new persisted state and do NOT participate in the determinist
|
|
|
341
356
|
### Locality
|
|
342
357
|
|
|
343
358
|
- **Drop-in**: extensions live inside plugins, discovered at boot from `.skill-map/plugins/<id>/` and `~/.skill-map/plugins/<id>/`.
|
|
344
|
-
- **Built-in**: the reference impl bundles a default extension set (one Provider, four extractors, five
|
|
359
|
+
- **Built-in**: the reference impl bundles a default extension set (one Provider, four extractors, five analyzers, one formatter, one hook). The fifth analyzer, `core/validate-all`, replays every scanned node and link through the authoritative spec schemas via AJV — the kernel-side guard against persisting non-conforming graph rows. The first built-in Hook is `core/update-check`, which subscribes to `shutdown` and runs the once-per-day "update available" probe + banner that lived on the CLI entry path before the Hook kind had concrete consumers. These are loaded from `src/extensions/` and are indistinguishable from plugin-supplied extensions from the kernel's point of view.
|
|
345
360
|
|
|
346
361
|
---
|
|
347
362
|
|
|
348
|
-
## Dependency
|
|
363
|
+
## Dependency analyzers
|
|
349
364
|
|
|
350
365
|
The following imports are NORMATIVELY FORBIDDEN:
|
|
351
366
|
|
|
@@ -397,7 +412,7 @@ Alternative implementations MAY use workspaces, separate packages, or a compiled
|
|
|
397
412
|
|
|
398
413
|
---
|
|
399
414
|
|
|
400
|
-
## Driving-adapter peer
|
|
415
|
+
## Driving-adapter peer analyzer
|
|
401
416
|
|
|
402
417
|
The CLI, Server, and Skill driving adapters are **peers**. None depends on another.
|
|
403
418
|
|
|
@@ -407,24 +422,61 @@ The CLI, Server, and Skill driving adapters are **peers**. None depends on anoth
|
|
|
407
422
|
|
|
408
423
|
All three consume the same kernel API. Any use case a driving adapter needs MUST be available as a kernel function — if it isn't, the gap is a kernel bug, not a driving-adapter workaround.
|
|
409
424
|
|
|
410
|
-
This is what makes "CLI-first" a coherent
|
|
425
|
+
This is what makes "CLI-first" a coherent analyzer: every CLI verb is a kernel function call. The UI does not reimplement business logic; it calls the same functions.
|
|
426
|
+
|
|
427
|
+
---
|
|
428
|
+
|
|
429
|
+
## Config layering
|
|
430
|
+
|
|
431
|
+
`.skill-map/settings.json` (and its `.local.json` partner) are loaded through a layered hierarchy. Implementations MUST evaluate the six layers in order (low → high precedence) and deep-merge per key:
|
|
432
|
+
|
|
433
|
+
| # | Layer | Source | Audience |
|
|
434
|
+
|---|---|---|---|
|
|
435
|
+
| 1 | `defaults` | Bundled `defaults.json` (ships in the CLI binary). | Every install. |
|
|
436
|
+
| 2 | `user` | `~/.skill-map/settings.json` | Per-machine, per-user; lives in `$HOME` (never in the repo). |
|
|
437
|
+
| 3 | `user-local` | `~/.skill-map/settings.local.json` | Same audience as `user`; intended for values the user might want to keep out of dotfile sync. |
|
|
438
|
+
| 4 | `project` | `<cwd>/.skill-map/settings.json` | **Committed to the repo** — values are shared with every collaborator and CI. |
|
|
439
|
+
| 5 | `project-local` | `<cwd>/.skill-map/settings.local.json` | **Gitignored** — values are per-checkout, never travel via the repo. |
|
|
440
|
+
| 6 | `override` | Caller-supplied (env vars, CLI flags). | Process-scoped, ephemeral. |
|
|
441
|
+
|
|
442
|
+
The merge is per dot-path: a value declared at a higher layer replaces the value at lower layers; objects recurse, arrays replace. The loader records which layer last wrote each key in a `sources` map so `sm config show --source` can attribute every effective value.
|
|
443
|
+
|
|
444
|
+
Layers 1, 2, 3, 5, 6 carry **per-user / per-machine state**. Only layer 4 (`project`) travels via the shared repo, so values landing in `project` are part of the contract every collaborator inherits.
|
|
445
|
+
|
|
446
|
+
### Per-key locality
|
|
447
|
+
|
|
448
|
+
Two locality classes constrain which layers a given key MAY live in. Both are enforced in code (reference impl: `core/config/helper.ts`), not in the JSON Schema — the schema stays additive so older settings files keep validating even when a key is reclassified.
|
|
449
|
+
|
|
450
|
+
- **`USER_ONLY_KEYS`** — keys describing per-user preferences that have no project meaning. Read forces `scope: 'global'` (project layers ignored); write rejects `target: 'project'` with a directed error. Today: `updateCheck.enabled`.
|
|
451
|
+
|
|
452
|
+
- **`PROJECT_LOCAL_ONLY_KEYS`** — keys describing per-user-per-project preferences. Valid in layers 1, 2, 3, 5, 6. **Stripped (with a warning) from layer 4 (`project`)** because the value is inherently per-user and must not be shared via the committed repo. Writes target `project-local` (`<cwd>/.skill-map/settings.local.json`); `sm config set` rejects `--scope project` for these keys.
|
|
453
|
+
|
|
454
|
+
Members:
|
|
455
|
+
- `allowEditSmFiles` — per-project consent to create / modify `.sm` sidecars.
|
|
456
|
+
- `scan.includeHome` — appends per-Provider HOME paths to the scan roots.
|
|
457
|
+
- `scan.extraRoots` — additional scan paths.
|
|
458
|
+
- `scan.referencePaths` — additional link-validation paths.
|
|
459
|
+
|
|
460
|
+
All four describe disk access the local operator opted into; sharing them via the repo would silently expand every collaborator's scan surface to paths that only make sense on the original author's machine.
|
|
461
|
+
|
|
462
|
+
Adding a new entry to either set is a behaviour change for older installs that wrote the key into a committed file — the value gets stripped (PROJECT_LOCAL_ONLY) or ignored (USER_ONLY) at read time. The changeset that adds the entry MUST document the migration.
|
|
411
463
|
|
|
412
464
|
---
|
|
413
465
|
|
|
414
466
|
## Annotation system
|
|
415
467
|
|
|
416
|
-
Skill-map's own metadata layer (versioning, supersession, provenance, taxonomy, docs) lives in **co-located YAML sidecars** with extension `.sm`, in the same directory as the markdown node they annotate. Vendor files (`.claude/agents/foo.md`, `.cursor/
|
|
468
|
+
Skill-map's own metadata layer (versioning, supersession, provenance, taxonomy, docs) lives in **co-located YAML sidecars** with extension `.sm`, in the same directory as the markdown node they annotate. Vendor files (`.claude/agents/foo.md`, `.cursor/analyzers/bar.mdc`, …) stay untouched; the sidecar (`foo.sm` / `bar.sm`) IS skill-map's "annotations file" for that node — every key under it is, conceptually, an annotation. The YAML root organizes those annotations into structural blocks (identity, the curated annotations catalog, audit timestamps, settings, plugin namespaces); the file as a whole is the annotation surface.
|
|
417
469
|
|
|
418
470
|
Two schemas describe the wire shape:
|
|
419
471
|
|
|
420
472
|
- [`schemas/sidecar.schema.json`](./schemas/sidecar.schema.json) — root shape with reserved blocks `identity` (anchor + drift hashes), `annotations` (the conventional catalog), `settings` (reserved), `audit` (write trail), plus opt-in `<plugin-id>:` namespacing.
|
|
421
|
-
- [`schemas/annotations.schema.json`](./schemas/annotations.schema.json) — curated 13-field catalog: versioning + supersession (`version`, `stability`, `supersedes`, `supersededBy`, `requires`, `conflictsWith`, `related`), provenance (`authors`, `license`, `source`, `sourceVersion`), taxonomy (`tags`), docs (`docsUrl`). The activity timestamp lives in the reserved `audit:` block (`audit.lastBumpedAt`), not in `annotations:`. `additionalProperties: true` so plugins or users add custom keys without coordination; the built-in `unknown-field`
|
|
473
|
+
- [`schemas/annotations.schema.json`](./schemas/annotations.schema.json) — curated 13-field catalog: versioning + supersession (`version`, `stability`, `supersedes`, `supersededBy`, `requires`, `conflictsWith`, `related`), provenance (`authors`, `license`, `source`, `sourceVersion`), taxonomy (`tags`), docs (`docsUrl`). The activity timestamp lives in the reserved `audit:` block (`audit.lastBumpedAt`), not in `annotations:`. `additionalProperties: true` so plugins or users add custom keys without coordination; the built-in `unknown-field` analyzer warns on truly unrecognized keys (typo guard).
|
|
422
474
|
|
|
423
475
|
### Identity and drift
|
|
424
476
|
|
|
425
477
|
`identity` carries `path` (scope-root-relative, matches the canonical Node identifier in [`schemas/node.schema.json`](./schemas/node.schema.json)) plus `bodyHash` and `frontmatterHash`. Both hashes are sha256 over the kernel's canonical form of the markdown body (post-frontmatter bytes) and frontmatter (YAML re-emitted via `js-yaml dump` with `sortKeys: true`, `lineWidth: -1`, `noRefs: true`, `noCompatMode: true`); each sidecar captures the values the kernel saw at the moment it was last written.
|
|
426
478
|
|
|
427
|
-
At scan time the kernel re-computes the live hashes and compares against the stored ones. Mismatch in either is **drift**, surfaced via the built-in `annotation-stale`
|
|
479
|
+
At scan time the kernel re-computes the live hashes and compares against the stored ones. Mismatch in either is **drift**, surfaced via the built-in `annotation-stale` analyzer (severity `warning`, never blocking — soft mode by design). A `.sm` whose `identity.path` no longer points at an existing `.md` is **orphan**, surfaced via the built-in `annotation-orphan` analyzer (also `warning`). Drift state is **derived**, never stored — pure function over existing data, no flag to drift between flag and reality.
|
|
428
480
|
|
|
429
481
|
### Bump model
|
|
430
482
|
|
|
@@ -444,6 +496,21 @@ The Action stays pure (no IO). The kernel materializes the patch through the `Si
|
|
|
444
496
|
- **Opt-in pre-commit hook**: `sm hooks install pre-commit-bump` writes a `.git/hooks/pre-commit` block that calls `sm bump --pending --staged --force` on commit. Idempotent reinstall via sentinel markers.
|
|
445
497
|
- **Watch mode**: never auto-bumps. Computes "stale" state on demand from hash comparison.
|
|
446
498
|
|
|
499
|
+
### Write consent
|
|
500
|
+
|
|
501
|
+
Every `.sm` write — scaffold (`sm sidecar annotate`), hash-only update (`sm sidecar refresh`), bump (`sm bump`, `POST /api/sidecar/bump`), or any future write surface — passes through `SidecarStore.applyPatch` (or, where the verb writes a fresh sidecar, the equivalent kernel-managed entry point). That single chokepoint MUST consult `allowEditSmFiles` (see §Config layering) before touching disk:
|
|
502
|
+
|
|
503
|
+
- `allowEditSmFiles === true` → write proceeds.
|
|
504
|
+
- `allowEditSmFiles === false` AND the caller passes `confirm: true` → the kernel persists `allowEditSmFiles: true` to `<cwd>/.skill-map/settings.local.json` (layer `project-local`) and then performs the write.
|
|
505
|
+
- `allowEditSmFiles === false` AND `confirm` is missing / false → the kernel raises `EConsentRequiredError`. The driving adapter MUST translate it into a surface-appropriate prompt:
|
|
506
|
+
- **CLI on a TTY**: interactive `confirm()` prompt. Accept re-invokes the verb with `confirm: true`; decline aborts without persisting the rejection.
|
|
507
|
+
- **CLI without a TTY** (CI, scripts): exit with the standard "user input required" code and a message hinting `--yes`.
|
|
508
|
+
- **BFF**: 412 `confirm-required` envelope (`{ ok: false, error: { code: 'confirm-required', message, details: { key: 'allowEditSmFiles' } } }`). The UI catches it, opens a confirm dialog, and on accept retries the original request with `{ confirm: true }`.
|
|
509
|
+
|
|
510
|
+
The rejection is **not persisted**. Declining the prompt aborts the current operation but the next attempt re-asks. This is deliberate: a "no" today should not foreclose a "yes" tomorrow without the user having to hand-edit the settings file.
|
|
511
|
+
|
|
512
|
+
The flag lives in `project-local` (gitignored) so each collaborator consents independently; a single contributor's "yes" never enrols their teammates without their knowledge.
|
|
513
|
+
|
|
447
514
|
### Plugin contributions
|
|
448
515
|
|
|
449
516
|
Plugins extend the annotation surface via the `annotationContributions` manifest field — a map of contributed key → `{ schema, ownership, location }`. Inline JSON Schema (no `$ref` to external files). Two location modes:
|
|
@@ -487,7 +554,7 @@ The **format** (YAML, extension `.sm`, not `.md.sm`) is stable as of spec v1.0.0
|
|
|
487
554
|
|
|
488
555
|
The **reserved block names** (`for`, `annotations`, `settings`, `audit`) are stable as of spec v1.0.0. Adding a new reserved block is a minor bump; renaming or removing one is a major bump.
|
|
489
556
|
|
|
490
|
-
The **identity contract** (`identity.path` + `identity.bodyHash` + `identity.frontmatterHash`, with `resolvedAs` optional) is stable as of spec v1.0.0. Changing the hash algorithm or canonicalization
|
|
557
|
+
The **identity contract** (`identity.path` + `identity.bodyHash` + `identity.frontmatterHash`, with `resolvedAs` optional) is stable as of spec v1.0.0. Changing the hash algorithm or canonicalization analyzer is a major bump.
|
|
491
558
|
|
|
492
559
|
The **bump field set** (the four `audit` fields `lastBumpedAt` / `lastBumpedBy` / `createdAt` / `createdBy`) is stable as of spec v1.0.0. Adding new audit fields is a minor bump; removing or renaming is a major bump. The audit block is `additionalProperties: true` so plugins or future Actions MAY ride additional keys opaquely.
|
|
493
560
|
|
|
@@ -505,14 +572,14 @@ Sibling system to the annotation contributions above. Both let plugins extend th
|
|
|
505
572
|
|---|---|---|
|
|
506
573
|
| **Data lives in** | the user-facing sidecar `.sm` file | the kernel-managed `scan_contributions` table |
|
|
507
574
|
| **Author intent** | extend the metadata catalog | surface per-node data in the UI |
|
|
508
|
-
| **Plugin author writes** | inline JSON Schema for the value | `
|
|
575
|
+
| **Plugin author writes** | inline JSON Schema for the value | `slot` name from a closed catalog |
|
|
509
576
|
| **Validation** | AJV at sidecar-write time | AJV at `ctx.emitContribution(...)` time |
|
|
510
577
|
| **Lifecycle** | persists across scans (file-on-disk) | re-emitted on every scan (table cleared per node) |
|
|
511
|
-
| **Surfaces in** | sidecar consumers + `<sm-plugin-contributions>` panel | renderer per
|
|
578
|
+
| **Surfaces in** | sidecar consumers + `<sm-plugin-contributions>` panel | fixed renderer per slot, mounted at exactly the slot the author declared |
|
|
512
579
|
|
|
513
580
|
Two schemas describe the wire shape:
|
|
514
581
|
|
|
515
|
-
- [`schemas/view-
|
|
582
|
+
- [`schemas/view-slots.schema.json`](./schemas/view-slots.schema.json) — closed catalog: 15 slot names + the `IViewContribution` manifest declaration shape + per-slot payload schemas (in `$defs/payloads`) the kernel uses to validate emit-time payloads.
|
|
516
583
|
- [`schemas/input-types.schema.json`](./schemas/input-types.schema.json) — closed catalog: 10 input-type names + the `ISettingDeclaration` manifest declaration shape (discriminated by `type`).
|
|
517
584
|
|
|
518
585
|
### Identity
|
|
@@ -521,18 +588,18 @@ Each view contribution is identified by the qualified id `<pluginId>/<extensionI
|
|
|
521
588
|
|
|
522
589
|
### Manifest
|
|
523
590
|
|
|
524
|
-
Each entry picks a `
|
|
591
|
+
Each entry picks a `slot` name from the closed catalog and supplies presentation tuning. The slot fixes both the renderer and the payload shape — there is no separate "contract" abstraction:
|
|
525
592
|
|
|
526
593
|
```jsonc
|
|
527
594
|
{
|
|
528
595
|
"viewContributions": {
|
|
529
596
|
"breakdown": {
|
|
530
|
-
"
|
|
597
|
+
"slot": "inspector.body.panel.breakdown",
|
|
531
598
|
"label": "Keyword hits",
|
|
532
599
|
"emptyText": "No matches."
|
|
533
600
|
},
|
|
534
601
|
"total": {
|
|
535
|
-
"
|
|
602
|
+
"slot": "card.footer.left",
|
|
536
603
|
"icon": "🔍",
|
|
537
604
|
"label": "kw",
|
|
538
605
|
"emitWhenEmpty": false
|
|
@@ -541,7 +608,7 @@ Each entry picks a `contract` name from the closed catalog and supplies presenta
|
|
|
541
608
|
}
|
|
542
609
|
```
|
|
543
610
|
|
|
544
|
-
The plugin author
|
|
611
|
+
The plugin author picks ONE slot per contribution; that single decision determines where the data renders, what payload shape `ctx.emitContribution(...)` must produce, and which Angular component draws it. Six manifest fields per contribution + the slot catalog page is the entire mental model. See [`plugin-author-guide.md`](./plugin-author-guide.md) §View contributions for worked examples.
|
|
545
612
|
|
|
546
613
|
### Settings
|
|
547
614
|
|
|
@@ -551,9 +618,9 @@ Settings are read once at extractor invocation; changing a setting requires `sm
|
|
|
551
618
|
|
|
552
619
|
### Runtime catalog
|
|
553
620
|
|
|
554
|
-
The kernel exposes a runtime catalog (`Kernel.getRegisteredViewContributions()`) listing every plugin-contributed view contribution with its `pluginId`, `extensionId`, `contributionId`, `
|
|
621
|
+
The kernel exposes a runtime catalog (`Kernel.getRegisteredViewContributions()`) listing every plugin-contributed view contribution with its `pluginId`, `extensionId`, `contributionId`, `slot`, and the manifest-declared `label` / `tooltip` / `icon` / `emptyText` / `emitWhenEmpty`. The catalog is built once at boot from every loaded extension's `viewContributions` map, AJV-validated, and frozen — same lifecycle as `getRegisteredAnnotationKeys()`.
|
|
555
622
|
|
|
556
|
-
|
|
623
|
+
Analyzers see the catalog through `IAnalyzerContext.viewContributions` so cross-cutting checks (`core/unknown-slot`, `core/contribution-orphan`) can reason about emissions.
|
|
557
624
|
|
|
558
625
|
### Emit path
|
|
559
626
|
|
|
@@ -563,16 +630,16 @@ Extensions emit per-node payloads via context callbacks:
|
|
|
563
630
|
// Extractors (per-node walk)
|
|
564
631
|
ctx.emitContribution(contributionId, payload);
|
|
565
632
|
|
|
566
|
-
//
|
|
567
|
-
// because the
|
|
633
|
+
// Analyzers (post-merge graph) — same payload contract, explicit nodePath
|
|
634
|
+
// because the analyzer sees every node at once
|
|
568
635
|
ctx.emitContribution(nodePath, contributionId, payload);
|
|
569
636
|
```
|
|
570
637
|
|
|
571
|
-
Parallel to `ctx.emitLink(link)`. The kernel buffers the emission, validates the payload against the
|
|
638
|
+
Parallel to `ctx.emitLink(link)`. The kernel buffers the emission, validates the payload against the slot's payload schema in `$defs/payloads/<slot>` (AJV-compiled at boot), and persists the row to `scan_contributions` during `persistScanResult`. Off-shape payloads emit an `extension.error` event and drop silently — same posture as `emitLink` rejecting off-`emitsLinkKinds` links. Both Extractor and Analyzer emissions land in the same `scan_contributions` rows; the row's `extension_id` records which kind of extension produced it.
|
|
572
639
|
|
|
573
|
-
The Extractor-emit signature binds `nodePath` implicitly (the extractor runs per-node, with `ctx.node.path` available as the only sensible target). The
|
|
640
|
+
The Extractor-emit signature binds `nodePath` implicitly (the extractor runs per-node, with `ctx.node.path` available as the only sensible target). The Analyzer-emit signature requires the analyzer to declare the target node explicitly because Analyzers see the full graph at once and may emit for any subset of nodes — the canonical use case is a analyzer that derives per-node values from cross-graph aggregations (`core/link-counts` projects `linksOutCount` / `linksInCount` this way).
|
|
574
641
|
|
|
575
|
-
|
|
642
|
+
Analyzers MAY also emit scope-level contributions via `IAnalyzerContext.emitScopeContribution(contributionId, payload)` (only slots whose schema permits scope-level emission, today only `topbar.nav.start`). That signature is reserved in the spec; the runtime callback lands when the first scope-level adopter arrives.
|
|
576
643
|
|
|
577
644
|
### Persistence
|
|
578
645
|
|
|
@@ -584,21 +651,26 @@ A new table `scan_contributions` (see [`db-schema.md`](./db-schema.md) §scan_co
|
|
|
584
651
|
| `extension_id` | TEXT | extension id within the plugin |
|
|
585
652
|
| `node_path` | TEXT | scope-relative path |
|
|
586
653
|
| `contribution_id` | TEXT | manifest Record key |
|
|
587
|
-
| `
|
|
588
|
-
| `payload_json` | TEXT | JSON-serialized payload (already validated against
|
|
654
|
+
| `slot` | TEXT | denormalized slot name (`view-slots.schema.json#/$defs/SlotName`) |
|
|
655
|
+
| `payload_json` | TEXT | JSON-serialized payload (already validated against the slot's payload schema) |
|
|
589
656
|
| `emitted_at` | INTEGER | unix epoch ms |
|
|
590
657
|
|
|
591
658
|
PK `(plugin_id, extension_id, node_path, contribution_id)` so re-emission upserts. Index on `node_path` (inspector lazy-fetch + orphan sweep) and on `plugin_id` (catalog sweep + `purgeByPlugin`).
|
|
592
659
|
|
|
593
|
-
**NOT pure replace-all** (the way `scan_links` / `scan_issues` are). The watcher's cached pass leaves the contributions buffer empty for cached nodes — the orchestrator skips `extract()` when the per-(node, extractor) cache hits, so no `emitContribution` fires. A naive wipe-all would silently drop the prior valid rows on every watcher boot. The persist runs
|
|
660
|
+
**NOT pure replace-all** (the way `scan_links` / `scan_issues` are). The watcher's cached pass leaves the contributions buffer empty for cached nodes — the orchestrator skips `extract()` when the per-(node, extractor) cache hits, so no `emitContribution` fires. A naive wipe-all would silently drop the prior valid rows on every watcher boot. The persist runs four passes inside the same transaction:
|
|
594
661
|
|
|
595
662
|
1. **Orphan sweep** — drops every row whose `node_path` is NOT in the current live node set. Disappeared nodes lose their contributions.
|
|
596
|
-
2. **Catalog sweep** — drops every row whose qualified id `(pluginId, extensionId, contributionId)` is NOT in the registered runtime catalog (uninstalled plugins,
|
|
597
|
-
3. **
|
|
663
|
+
2. **Catalog sweep** — drops every row whose qualified id `(pluginId, extensionId, contributionId)` is NOT in the registered runtime catalog (uninstalled-on-disk plugins, removed contributions). Disabled bundles are normally purged eagerly by `sm plugins disable` (see `StoragePort.contributions.purgeByPlugin`); the catalog sweep here is the fallback for the rare "config flipped between scans without going through the CLI" case.
|
|
664
|
+
3. **Per-tuple sweep** — for every `(pluginId, extensionId, nodePath)` tuple where the extension actually RAN against that node in this scan (extractor cache miss, OR analyzer — analyzers always run), drop any row carrying that triple whose `contribution_id` is NOT present in the buffer for that triple. This catches the "extractor used to emit, now does not" case (e.g. a node body change that removes the trigger). Cached-extractor tuples are NOT in the set, so their rows survive untouched.
|
|
665
|
+
4. **Upsert** — `INSERT ... ON CONFLICT DO UPDATE SET payload_json = excluded.payload_json, slot = excluded.slot` for every row in the buffer. PK conflict refreshes payload + `slot` + `emitted_at`.
|
|
666
|
+
|
|
667
|
+
Cached nodes' rows survive untouched (still in the live set, still in the catalog, the (plugin, extension, node) tuple is not in the freshly-run set, no buffer hit). The next time the body changes, the orchestrator re-runs the extractor, the tuple lands in the freshly-run set, and either the upsert refreshes the row OR the per-tuple sweep drops it (when the extractor no longer emits for that node).
|
|
598
668
|
|
|
599
|
-
|
|
669
|
+
Empty buffer + non-empty live set = the cached-pass case (no-op). Empty buffer + empty live set = legacy fallback to wipe-all (cold start). Three `IPersistOptions` fields control which sweeps activate — absent values fall back to legacy behaviour (sweep skipped) so older callers keep working:
|
|
600
670
|
|
|
601
|
-
|
|
671
|
+
- `livePaths?: ReadonlySet<string>` — gates the orphan sweep (1).
|
|
672
|
+
- `registeredContributionKeys?: ReadonlySet<string>` — gates the catalog sweep (2). Element format: qualified id `<pluginId>/<extensionId>/<contributionId>`.
|
|
673
|
+
- `freshlyRunTuples?: ReadonlySet<string>` — gates the per-tuple sweep (3). Element format: `<pluginId>/<extensionId>/<nodePath>` (no contribution-id segment — the sweep operates at the (plugin, extension, node) level and inspects the buffer to decide which contribution-ids survive).
|
|
602
674
|
|
|
603
675
|
Cold-start posture: the BFF endpoints below return empty arrays when the table is missing (mirror of the `tryWithSqlite` graceful-null pattern used by `routes/nodes.ts`); never a 500.
|
|
604
676
|
|
|
@@ -621,35 +693,35 @@ Plus per-node embedding on node responses:
|
|
|
621
693
|
|
|
622
694
|
### Isolation
|
|
623
695
|
|
|
624
|
-
View contributions extend the existing plugin-isolation model (see [`plugin-kv-api.md`](./plugin-kv-api.md) §Honest note on isolation) with six
|
|
696
|
+
View contributions extend the existing plugin-isolation model (see [`plugin-kv-api.md`](./plugin-kv-api.md) §Honest note on isolation) with six analyzers specific to UI rendering:
|
|
625
697
|
|
|
626
|
-
1. **No raw DOM from plugin** — contributions are typed data only; the UI renders them via a closed catalog of Angular components mapped from
|
|
698
|
+
1. **No raw DOM from plugin** — contributions are typed data only; the UI renders them via a closed catalog of Angular components mapped from slot id.
|
|
627
699
|
2. **CSS scoping by Angular view encapsulation** — plugin does not write CSS; per-plugin tinting is sourced from a kernel-managed palette derived from `pluginId`.
|
|
628
700
|
3. **Data path namespaced and BFF-enforced** — `GET /api/contributions/:pluginId/:extensionId/:contributionId?path=...` rejects cross-plugin reads at the route level (the qualified id triple is the URL shape).
|
|
629
701
|
4. **Click actions are typed kernel verb dispatches** — a button rendered from a contribution invokes a kernel verb by qualified id; no arbitrary URLs / effects.
|
|
630
|
-
5. **AJV at three layers** — manifest at load (rejects unknown `
|
|
631
|
-
6. **Renderer attr-sanitization** — the UI's renderer components MUST NOT bind contribution data to `[innerHTML]`, `[style]`, `[src]`, `[href]`, or any DomSanitizer DANGEROUS_ATTR. Lint-enforced in the UI workspace; documented in [`context/view-
|
|
702
|
+
5. **AJV at three layers** — manifest at load (rejects unknown `slot` names with `invalid-manifest`), payload at emit (rejects off-shape payloads with `extension.error`), envelope at BFF response.
|
|
703
|
+
6. **Renderer attr-sanitization** — the UI's renderer components MUST NOT bind contribution data to `[innerHTML]`, `[style]`, `[src]`, `[href]`, or any DomSanitizer DANGEROUS_ATTR. Lint-enforced in the UI workspace; documented in [`context/view-slots.md`](../context/view-slots.md).
|
|
632
704
|
|
|
633
705
|
Same honest-note posture as [`plugin-kv-api.md`](./plugin-kv-api.md): isolated against accidents, not hostile code, until worker-thread / iframe sandbox post-v1.0.
|
|
634
706
|
|
|
635
|
-
### Soft-warning
|
|
707
|
+
### Soft-warning analyzers
|
|
636
708
|
|
|
637
709
|
Two built-ins ship with the system to cover catalog evolution and rename edge cases:
|
|
638
710
|
|
|
639
|
-
- **`core/unknown-
|
|
711
|
+
- **`core/unknown-slot`** — walks every loaded plugin's `viewContributions[*].slot`; emits an `Issue` of severity `warn` for any slot not in the current kernel catalog. Parallel to `core/unknown-field` for annotations. Note: AJV at manifest load already rejects unknown slots as `invalid-manifest`; this analyzer covers the soft-warning path when a plugin remains loaded across a catalog version bump.
|
|
640
712
|
- **`core/contribution-orphan`** — joins `scan_contributions` against the live `scan_nodes` set; emits an `Issue` of severity `warn` for emissions whose `node_path` no longer exists (post-rename heuristic miss).
|
|
641
713
|
|
|
642
714
|
### Catalog versioning
|
|
643
715
|
|
|
644
|
-
The catalog of
|
|
716
|
+
The catalog of slots and input-types evolves on its own cadence, independent of the spec version. Plugin manifests carry an optional `catalogCompat: string` (semver range) field at the root, parallel to `specCompat`. The kernel checks `semver.satisfies(catalogVersion, plugin.catalogCompat)` at load. Mismatch surfaces as `incompatible-catalog` plugin status (new entry in the load-status enum). Resolution: `sm plugins upgrade <id>` runs registered migrations from a closed kernel-side registry of `{ from, to, transform }` triples; auto-migration impossible → CLI exit ≠ 0 + UI dialog naming the offending slot / input-type.
|
|
645
717
|
|
|
646
|
-
Pre-1.0 versioning
|
|
718
|
+
Pre-1.0 versioning analyzer (per [`AGENTS.md`](../AGENTS.md)): catalog breaking changes ship as minor bumps while in `0.y.z`; the first `1.0.0` is a deliberate stabilization moment, not a side-effect.
|
|
647
719
|
|
|
648
720
|
### Stability
|
|
649
721
|
|
|
650
|
-
The **closed catalog of view
|
|
722
|
+
The **closed catalog of view slots** is stable as of the v1 of this system: adding a new slot is a minor bump; renaming or removing one is a catalog-major bump and triggers `sm plugins upgrade` migration of every dependent plugin.
|
|
651
723
|
|
|
652
|
-
The **`IViewContribution` manifest shape** (six fields: `
|
|
724
|
+
The **`IViewContribution` manifest shape** (six fields: `slot`, `label?`, `tooltip?`, `icon?`, `emptyText?`, `emitWhenEmpty?`) is stable. Adding a new optional field is a minor bump; making a field required or removing one is a catalog-major bump.
|
|
653
725
|
|
|
654
726
|
The **closed catalog of input-types** is stable on the same model: adding minor, renaming/removing major.
|
|
655
727
|
|
|
@@ -657,7 +729,7 @@ The **`ctx.emitContribution(id, payload)` signature** is stable. Adding new cont
|
|
|
657
729
|
|
|
658
730
|
The **persistence shape** (`scan_contributions` columns) is stable; column additions are minor bumps. Renames or removals trigger a kernel migration.
|
|
659
731
|
|
|
660
|
-
The **slot catalog ownership**
|
|
732
|
+
The **slot catalog ownership** is now spec-level (kernel + spec own the catalog jointly); the UI implementation may rearrange visual placement WITHOUT renaming a slot — the slot id is the public handle, the visual surface beneath it can evolve. Different driving adapters (UI, future TUI, `sm show --json`) MUST honour the same slot vocabulary; surface-level rendering policy stays adapter-specific (e.g. a TUI may render `card.title.right` as a prefix glyph instead of a right-side marker).
|
|
661
733
|
|
|
662
734
|
The **isolation honest-note** (accidents, not hostile code) is the same posture as [`plugin-kv-api.md`](./plugin-kv-api.md) and migrates together when worker-thread / iframe sandbox lands post-v1.0.
|
|
663
735
|
|
|
@@ -680,12 +752,12 @@ The **isolation honest-note** (accidents, not hostile code) is the same posture
|
|
|
680
752
|
|
|
681
753
|
The **port list** is stable as of spec v1.0.0. Adding a sixth port is a major bump.
|
|
682
754
|
|
|
683
|
-
The **extension kind list** (6 kinds: Provider, Extractor,
|
|
755
|
+
The **extension kind list** (6 kinds: Provider, Extractor, Analyzer, Action, Formatter, Hook) is stable as of spec v1.0.0. Adding a seventh kind is a major bump. Removing or renaming a kind is a major bump.
|
|
684
756
|
|
|
685
|
-
The **Hook curated trigger set** (
|
|
757
|
+
The **Hook curated trigger set** (ten events: `boot`, `scan.started`, `scan.completed`, `extractor.completed`, `analyzer.completed`, `action.completed`, `job.spawning`, `job.completed`, `job.failed`, `shutdown`) is stable as of spec v1.0.0. Adding an eleventh trigger is a minor bump; removing or renaming any of the ten is a major bump.
|
|
686
758
|
|
|
687
759
|
The **execution modes** (`deterministic` / `probabilistic`) and the per-kind mode capability matrix above are stable as of spec v1.0.0. Adding a third mode is a major bump. Renaming or repurposing the mode enum values is a major bump. Pre-1.0, narrowing a kind from dual-mode to single-mode is permitted as a minor bump (Extractor went from `deterministic / probabilistic` to `deterministic-only` in 0.X.0); post-1.0 the same change would be major.
|
|
688
760
|
|
|
689
|
-
The **dependency
|
|
761
|
+
The **dependency analyzers** above are stable as of spec v1.0.0. Relaxing any is a major bump; tightening (forbidding an allowed import) is a minor bump.
|
|
690
762
|
|
|
691
763
|
The **Extractor · trigger normalization** pipeline (six steps, in order) is stable from the next spec release. Adding a new step at the end is a minor bump; reordering, removing, or changing any existing step (including the character classes in step 4) is a major bump. Implementations that produce different `normalizedTrigger` output for equivalent input are non-conforming.
|