@skill-map/spec 0.50.0 → 0.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,61 @@
1
1
  # Spec changelog
2
2
 
3
+ ## 0.52.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Remove the `supersede` feature end to end. The `supersedes` link kind is dropped from the global link-kind enum, the `annotations.supersedes` and `supersededBy` sidecar fields are removed from the spec, and the three built-ins that powered it (the `core/annotations` extractor, the `core/node-supersede` action, the `core/node-superseded` analyzer) are deleted. Scans no longer produce supersede links, and the inspector drops the Supersede button and the superseded-by banner.
8
+
9
+ ## User-facing
10
+
11
+ The Supersede inspector button, the "superseded by" banner, and supersede links on the map are gone. The `supersedes` and `supersededBy` keys in `.sm` sidecars are no longer recognized, remove them from any sidecar that still declares them.
12
+
13
+ ## 0.51.0
14
+
15
+ ### Minor Changes
16
+
17
+ - Normalize every built-in analyzer finding into one canonical message shape via the shared `formatFinding` helper: an optional backtick-quoted subject line, then `L<line>: <what>; <why>` (the `L<line>:` prefix only when the finding maps to body line(s)). Remediation advice moves out of `message` into `Issue.fix.summary`. `issue.schema.json` documents the grammar as normative; all 14 message-emitting analyzers were migrated, so `sm check` and the UI Inspector read consistently.
18
+
19
+ ## User-facing
20
+
21
+ **Finding messages now read the same way everywhere.** Each one shows the offending subject on its own line, then `L<line>: what; why`, with the fix hint shown separately instead of appended. Output in `sm check` and the Inspector is more consistent and easier to scan.
22
+
23
+ - Redesign the link-confidence scoring model: the kernel seeds a 1.0 baseline on every link (the per-extractor emit floor is dropped) and the score-phase detectors subtract a fixed penalty on top, so `core/name-reserved` lands a reserved link at 0.1 and `core/reference-broken` a broken one at 0.5, while disabling a detector leaves its link at 1.0. The built-in `core/score-resolution` analyzer is deleted (its 1.0 is now the baseline), so a clean resolved link records no `scan_link_scores` row.
24
+
25
+ ## User-facing
26
+
27
+ **Link confidence now starts at 1.0 and each rule subtracts a fixed amount.** A clean link reads 1.0, a reserved one 0.1, a broken one 0.5. Turning a rule off leaves its links at full confidence. The internal score-resolution scorer was retired.
28
+
29
+ - Rename the built-in analyzer `core/link-conflict` to `core/link-kind-conflict`. The rule flags two detectors emitting different `kind` values for the same `(source, target)` pair, so the id now names what it actually checks (a kind disagreement). Folder, id, texts, spec, and tests were renamed together, no compatibility alias. The rule also gains a `fix.summary` remediation hint (drop one conflicting source, or ignore the overlap deliberately).
30
+
31
+ ## User-facing
32
+
33
+ **The `link-conflict` rule is now `link-kind-conflict`.** If you enabled or disabled it via `sm plugins`, re-apply the toggle under the new id; the old id is no longer recognized. The warning it raises is unchanged.
34
+
35
+ - Rename `core/signal-collision` to `core/extractor-collision` (the rule surfaces two extractors colliding over the same span of text; "Signal" was internal IR jargon) and drop the dead `extractorDisabled` / `belowFloor` rejection stubs from the resolver schema, the `ISignalResolution` type, and the analyzer. The finding now carries the canonical `L<line>:` prefix and a `fix.summary` hint (rephrase one token, or accept the winner).
36
+
37
+ ## User-facing
38
+
39
+ **`signal-collision` is now `extractor-collision`** and reads clearer: it points at the body line, names the two extractors that overlapped, and suggests how to resolve it (rephrase one token, accept the winner, or flip the tiebreak).
40
+
41
+ - Rename `core/trigger-collision` to `core/name-collision` and key it on the resolution identifier instead of the slashed trigger. It fires (`error`) when two or more name-resolvable nodes (kinds whose `identifiers` include `frontmatter.name`) declare the same normalised `name`. The subject is the bare name (the old `/` sigil was wrong for agents), and case / separator invocation variants no longer false-positive.
42
+
43
+ ## User-facing
44
+
45
+ **`trigger-collision` is now `name-collision`** and fires only when two files declare the same resolvable name (a command and an agent both named `deploy`, say), across any name-resolvable kind. Plain notes, addressed by path, never collide.
46
+
47
+ - Make the link-confidence scoring mechanism spec-official. `analyzer.schema.json` gains a `phase` enum so external analyzers can declare `phase: 'score'` and adjust link confidence via `ctx.adjustConfidence(link, op)` (op kinds `set` / `delta` / `ceil` / `floor`), folded deterministically and clamped to [0,1] before the read-only phases. The spec now documents the phase, the fold, and the `scan_link_scores` attribution table, with a `score-phase-confidence` conformance case locking it.
48
+
49
+ ## User-facing
50
+
51
+ **Plugin authors can ship a `score`-phase analyzer that adds or subtracts link confidence.** Declare `phase: 'score'` and call `ctx.adjustConfidence(link, op)` to compose on top of the kernel's own scoring; every adjustment is recorded in `scan_link_scores` for auditing.
52
+
53
+ - The `/ws` server now pings every client every 30s so idle connections survive intermediary proxies and half-open peers get terminated, and the SPA's WebSocket client resets its reconnect backoff only after a connection stays open long enough to be stable. Together these stop a flapping connection from looping at 1s and re-seeding `GET /api/scan` in a tight poll storm; an unrecoverable drop now escalates to the non-fatal 'connection lost' state.
54
+
55
+ ## User-facing
56
+
57
+ **The live view stops hammering the server on a dropped connection.** Idle tabs stay connected instead of silently dropping, and a connection that cannot recover now shows a clear 'connection lost' notice instead of retrying scans forever in the background.
58
+
3
59
  ## 0.50.0
4
60
 
5
61
  ### Minor Changes
package/README.md CHANGED
@@ -39,7 +39,7 @@ These are implementation decisions. The reference impl picks them (see [`../AGEN
39
39
  Two analyzers govern every identifier in the spec. They are **normative**.
40
40
 
41
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`, `supersededBy`, `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.
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`, `sourceVersion`, `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
 
package/architecture.md CHANGED
@@ -271,9 +271,28 @@ The materialisation of any kernel-managed artefact (the SQLite DB at `.skill-map
271
271
 
272
272
  - 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.
273
273
  - 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/node-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).
274
- - Providers, Analyzers, Formatters, Hooks have no write surface at all.
274
+ - Providers, Formatters, Hooks have no write surface at all.
275
+ - Analyzers have no FILESYSTEM write surface. They emit `Issue[]` and (via `ctx.emitContribution`) view contributions, both kernel-persisted. The single exception is the `score` phase (see §Analyzer phases): a `score`-phase analyzer MAY adjust `link.confidence` via `ctx.adjustConfidence(link, op)`. That writes a DB-persisted GRAPH value the kernel folds and clamps; it is NOT a filesystem write and does not touch `.sm` sidecars, the project tree, or any path under `.skill-map/` directly. The no-filesystem-write invariant therefore holds unchanged for every kind.
275
276
 
276
- 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.
277
+ 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 to the filesystem in the first place. Conformance: a third-party extension that imports `node:fs` write APIs (or equivalent in another language) is non-conforming.
278
+
279
+ ### Analyzer phases
280
+
281
+ An Analyzer declares an optional `phase` in its manifest (`analyzer.schema.json#/properties/phase`, default `detect`). The orchestrator schedules analyzers by phase, so a filesystem-sorted built-ins registry keeps its alphabetical output while the kernel applies the phase order at run time. The three phases run in this strict order:
282
+
283
+ 1. **`score`** runs FIRST, before any read-only analyzer. It is the ONE phase permitted to WRITE: it adjusts link confidence through the `ctx.adjustConfidence(link, op)` callback (present ONLY in this phase). `op` is a `TConfidenceOp` discriminated union with four kinds:
284
+ - `{ kind: 'set', value }`, a hard override.
285
+ - `{ kind: 'delta', value }`, additive (may be negative).
286
+ - `{ kind: 'ceil', value }`, an upper cap (lowers only).
287
+ - `{ kind: 'floor', value }`, a lower bound (raises only).
288
+
289
+ The orchestrator buffers every op (attributed to the calling `pluginId` / `extensionId`, like `emitContribution`) and folds all ops for a link into the final `link.confidence` BEFORE the `detect` phase, so the read-only `detect` analyzers and the persisted `scan_links.confidence` see the final value. The kernel seeds a **1.0 baseline on every link** (the per-extractor emit value is discarded; see §Provider · resolution rules); the fold then layers the score-phase ops on top. The fold is **deterministic and order-independent across the four buckets**: starting from that baseline, `set` overrides (last in canonical order wins), `delta` sums, `floor` raises, then `ceil` caps, with a single clamp to `[0,1]` at the end (so opposing deltas round-trip without mid-fold clipping). Ops are sorted canonically by `(pluginId, extensionId)` so the `set` winner and the float sum are reproducible across runs. The kernel dogfoods this phase through TWO built-in score-phase detectors, each co-locating its penalty `delta` with the finding it reports: `core/name-reserved` (reserved → `delta -0.9` → 0.1, alongside its warns) and `core/reference-broken` (broken → `delta -0.5` → 0.5, alongside its errors); disabling a detector removes both its report and its score effect, so the link falls back to the 1.0 baseline (see §Provider · resolution rules). A clean-resolved or untouched link keeps the 1.0 baseline (no built-in op). A third-party scorer composes on top of that baseline via the same callback (it may RAISE confidence with a positive `delta` / `floor`, or lower it). Every applied op is persisted to `scan_link_scores` (see [`db-schema.md`](./db-schema.md#scan_link_scores)) as a per-op attribution audit trail. Adjusting confidence is a DB-persisted GRAPH write, NOT a filesystem write: the no-filesystem-write invariant of §IO discipline holds unchanged.
290
+
291
+ 2. **`detect`** (default) is the main read-only pass: it walks `ctx.nodes` / `ctx.links` and emits `Issue[]`. Most analyzers live here.
292
+
293
+ 3. **`aggregate`** runs LAST, after every `detect` analyzer. The orchestrator threads the full issue accumulator on `ctx.accumulatedIssues` so an aggregator (e.g. `core/issue-counter`) can compute cross-analyzer summaries (per-node severity totals) without re-reading the DB. Read-only.
294
+
295
+ Probabilistic analyzers (`mode: 'probabilistic'`) never participate in any scan-time phase; phases describe the deterministic `sm scan` / `sm check` pipeline only.
277
296
 
278
297
  ### Provider · `kinds` catalog
279
298
 
@@ -331,19 +350,24 @@ Implementations MUST treat an absent `identifiers` field exactly like `[]`: the
331
350
 
332
351
  ### Provider · resolution rules
333
352
 
334
- Each Provider MAY declare an optional `resolution: Record<linkKind, targetKind[]>` map listing, for each `link.kind` an Extractor in this Provider's plugin emits, the set of target `node.kind` values that count as a valid resolution for the post-walk confidence-lift transform. Absent = no link.kind bumps under this Provider via the name path (path-match always fires).
353
+ Each Provider MAY declare an optional `resolution: Record<linkKind, targetKind[]>` map listing, for each `link.kind` an Extractor in this Provider's plugin emits, the set of target `node.kind` values that count as a valid resolution. Absent = no link.kind resolves under this Provider via the name path (path-match always fires).
354
+
355
+ Resolution and confidence are TWO distinct steps with two distinct owners:
356
+
357
+ - The **post-walk lift transform** (`liftResolvedLinkConfidence`) runs after `dedupeLinks` and before the analyzer pipeline. It seeds the **confidence baseline** (`link.confidence = 1.0` for EVERY link, the per-extractor emit floor is discarded) and RECORDS `link.resolvedTarget` (the node path the link resolves to). It also computes the per-link resolution facts (resolved / reserved-target / genuinely-broken) that the analyzer pass reads via `IAnalyzerContext.reservedNodePaths` and `IAnalyzerContext.brokenLinks`. The lift assigns NO penalty values; it only sets the baseline + the resolved path.
358
+ - The penalty VALUES are applied by two built-in score-phase detectors (`phase: 'score'`, see §Analyzer phases) through the public `ctx.adjustConfidence(link, op)` API, each reading the lift's facts and co-locating its op with the finding it owns: `delta -0.9` (reserved → 0.1) by **`core/name-reserved`**, `delta -0.5` (broken → 0.5) by **`core/reference-broken`**. A clean-resolved or virtual-target link gets no built-in op and keeps the 1.0 baseline. Third-party `score`-phase analyzers compose `set` / `delta` / `ceil` / `floor` ops on top (a positive `delta` / `floor` may RAISE confidence), folded deterministically and clamped to `[0,1]`.
335
359
 
336
- The transform runs after `dedupeLinks` and before the analyzer pipeline. For each link below `confidence: 1.0`:
360
+ The rules below describe both halves together. The kernel seeds `confidence: 1.0` on every link first; each rule then records resolution facts and the matching detector applies its penalty:
337
361
 
338
- 1. **Path match (universal)**: if `link.target` equals some node's `path`, confidence is bumped to `1.0`. Applies to every link.kind, ignores the `resolution` map. Drives resolved markdown / at-directive references. `core/mcp-tools` synthetic edges path-match here too, but they hit the virtual-target exception below and keep their emit confidence rather than bumping.
362
+ 1. **Path match (universal)**: if `link.target` equals some node's `path`, the link is resolved (`resolvedTarget` set) and keeps the 1.0 baseline. Applies to every link.kind, ignores the `resolution` map. Drives resolved markdown / at-directive references. `core/mcp-tools` synthetic edges path-match here too, recording `resolvedTarget`.
339
363
 
340
- 2. **Name match (links carrying a `trigger.normalizedTrigger`)**: strip the leading `@` / `/` sigil, look up the resulting handle in the cross-kind name index built from every node's declared `identifiers` (see §Provider · kind identifiers). The lookup keys on the ACTIVE PROVIDER LENS: `resolution = providers[activeProvider].resolution`. If `resolution[link.kind]` exists AND any candidate node's kind appears in it, confidence is bumped to `1.0`.
364
+ 2. **Name match (links carrying a `trigger.normalizedTrigger`)**: strip the leading `@` / `/` sigil, look up the resulting handle in the cross-kind name index built from every node's declared `identifiers` (see §Provider · kind identifiers). The lookup keys on the ACTIVE PROVIDER LENS: `resolution = providers[activeProvider].resolution`. If `resolution[link.kind]` exists AND any candidate node's kind appears in it, the link resolves and keeps the 1.0 baseline.
341
365
 
342
- **Virtual-target exception (applies to both bump rules above):** when the resolved target node carries `virtual: true` (a derived, in-memory entity reconstructed from frontmatter and never verified on disk, e.g. an `mcp://<server>` node emitted by `core/mcp-tools`), the link does NOT bump to `1.0`, it keeps its extractor-emitted confidence. `link.resolvedTarget` is still set (the edge points at a real graph node and stays navigable), but resolution to a fabricated, unverified node is not full certainty. Same principle as the reserved-target downgrade (§Provider · reservedNames): resolution alone is not certainty when the target is something the runtime may not actually act on. A virtual target is never "genuinely broken" either (it resolves), so rule 3 does not fire on it.
366
+ **Virtual target (applies to both rules above):** when the resolved target node carries `virtual: true` (a derived, in-memory entity reconstructed from frontmatter and never verified on disk, e.g. an `mcp://<server>` node emitted by `core/mcp-tools`), the link still resolves (`resolvedTarget` is set, the edge stays navigable) and keeps the 1.0 baseline like any clean resolution; no built-in penalty applies. A virtual target is never "genuinely broken" (it resolves), so rule 3 does not fire on it.
343
367
 
344
- 3. **Broken downgrade (universal)**: when neither rule above bumped the link AND the link is genuinely broken, its confidence is lowered to `BROKEN_TARGET_CONFIDENCE = 0.5` (a cap: the value is only lowered, never raised, so a link emitted below `0.5` keeps its lower value). "Genuinely broken" means `link.target` matches no node `path` AND the stripped `trigger.normalizedTrigger` matches no entry in the cross-kind name index, i.e. the same kind-agnostic notion of "the name exists nowhere" that `core/reference-broken` uses. The effect is uniform across link kinds: a dangling markdown reference (`[x](missing.md)`), a `@missing.md`, and a `/no-such-command` all render at `0.5`, visibly fainter than a resolved edge at `1.0`. A link that fails the strict kind/lens bump of rule 2 but DOES match a name in the index (the `not-broken` + `not-bumped` case documented below) is NOT broken: it keeps its extractor-emitted confidence, because it resolves to a real node, just not as a valid target for this `link.kind`. The downgrade sits ABOVE the reserved-target value (`0.1`, §Provider · reservedNames): a target that resolves to a real-but-runtime-ignored file is flagged more faintly than one that resolves to nothing, deliberately, the reserved shadow is the subtler trap.
368
+ 3. **Broken penalty (universal)**: when neither rule above resolved the link AND the link is genuinely broken, `core/reference-broken` subtracts `BROKEN_PENALTY = 0.5` via a `delta` op, folding the kernel's 1.0 baseline down to `0.5`, in the same score-phase pass as its broken-ref errors. "Genuinely broken" means `link.target` matches no node `path` AND the stripped `trigger.normalizedTrigger` matches no entry in the cross-kind name index, i.e. the same kind-agnostic notion of "the name exists nowhere" that `core/reference-broken` uses (the lift surfaces this set on `ctx.brokenLinks`). A link resolved via `scan.referencePaths` (the escape-hatch) is neither flagged NOR penalised: the penalty follows the issue. The effect is uniform across link kinds: a dangling markdown reference (`[x](missing.md)`), a `@missing.md`, and a `/no-such-command` all render at `0.5`, visibly fainter than a resolved edge at `1.0`. A link that fails the strict kind/lens resolution of rule 2 but DOES match a name in the index (the `not-broken` + `not-resolved` case documented below) is NOT broken: it keeps the 1.0 baseline, because it resolves to a real node, just not as a valid target for this `link.kind`. The broken floor sits ABOVE the reserved-target value (`0.1`, §Provider · reservedNames): a target that resolves to a real-but-runtime-ignored file is flagged more faintly than one that resolves to nothing, deliberately, the reserved shadow is the subtler trap.
345
369
 
346
- The matrix is **per-link-kind, per-Provider**, strict: a `claude` Provider that declares `resolution: { mentions: ['agent'], invokes: ['command', 'skill'] }` does NOT bump a `/foo` slash matching an agent named `foo` (slash → agent is a kind mismatch surfaced by `link-conflict` / `kind-mismatch` analyzers, not silently treated as a resolution). The strictness is the load-bearing difference from the kind-agnostic `core/reference-broken` analyzer: `broken-ref`'s scope is "the name exists somewhere" (a name-only resolution is enough to clear the broken flag), the post-walk bump is "the name exists AS A VALID resolution for this link.kind". The `not-broken` + `not-bumped` combination is a documented edge case: the trigger resolves to a real node but the link's kind cannot legitimately point there.
370
+ The matrix is **per-link-kind, per-Provider**, strict: a `claude` Provider that declares `resolution: { mentions: ['agent'], invokes: ['command', 'skill'] }` does NOT resolve a `/foo` slash matching an agent named `foo` (slash → agent is a kind mismatch surfaced by `link-kind-conflict` / `kind-mismatch` analyzers, not silently treated as a resolution). The strictness is the load-bearing difference from the kind-agnostic `core/reference-broken` analyzer: `broken-ref`'s scope is "the name exists somewhere" (a name-only resolution is enough to clear the broken flag), the post-walk resolution is "the name exists AS A VALID resolution for this link.kind". The `not-broken` + `not-resolved` combination is a documented edge case: the trigger resolves to a real node but the link's kind cannot legitimately point there, so no built-in detector touches it and it keeps the 1.0 baseline.
347
371
 
348
372
  The lookup uses the ACTIVE PROVIDER LENS deliberately, mirroring the extractor gate (§Universal extractors and per-provider extractors). The lens represents the runtime grammar the operator is authoring under, and that grammar applies across the project's surface, not only to files the matching `classify()` claimed. A `@handle` in `notes/todo.md` (classified by `core/markdown`) under the `claude` lens still parses as a claude mention (the extractor gate authorises it) and resolves against claude's `resolution.mentions` (the resolver gate now mirrors the same authority). The same body under the `openai` lens follows openai's resolution map, or short-circuits if openai declares no entry for that `link.kind`. When `activeProvider === null` (unlensed project: no setting, no filesystem signal), the name path short-circuits uniformly; the path-match rule still applies.
349
373
 
@@ -358,23 +382,23 @@ The kernel intersects each Provider's `reservedNames[kind]` catalog with the sca
358
382
  1. **Self scope.** `reservedNames[node.kind]` of the node's OWN Provider (`node.provider`). The self-contained case: Claude classifies `.claude/commands/help.md` as `claude`/`command` and reserves `help` under `command`, so the file is flagged.
359
383
  2. **Lens scope.** When a Provider is the active lens (`activeProvider === provider.id`) it ALSO lends its catalog to nodes that a *universal* (ungated) Provider classified, matched by `node.kind`. This is required by runtimes that adopt the open `.agents/skills/` standard instead of a vendor directory: their user invocables are owned by the neutral `agent-skills` Provider (`kind: skill`), not by the vendor Provider, so self scope alone would never reach them. Google's Antigravity is exactly this shape, it is metadata-only (classifies nothing) and reserves its `agy` built-in slash commands under `skill`; when `activeProvider === 'antigravity'`, a user `.agents/skills/goal/SKILL.md` is flagged because `/goal` is a built-in. Lens scope is skipped when `node.provider === activeProvider` (it would duplicate self scope) and when no lens is resolved (`activeProvider === null`).
360
384
 
361
- A node's identifiers are always derived from its OWN kind contract; only the reserved-set lookup widens under the lens. A node landing in either scope's set joins a per-scan `Set<nodePath>` consumed by two surfaces:
385
+ A node's identifiers are always derived from its OWN kind contract; only the reserved-set lookup widens under the lens. A node landing in either scope's set joins a per-scan `Set<nodePath>` consumed by the score-phase `core/name-reserved` analyzer, which co-locates two effects in one pass (detection still lives in the orchestrator, so the same set drives both):
362
386
 
363
- 1. **The `core/name-reserved` analyzer projects one `warn` issue per reserved-shadow node** (`severity: 'warn'`, message points at the offending file and suggests renaming). The analyzer is a pure projector, detection lives in the orchestrator so the same set drives the next surface.
387
+ 1. **It projects one `warn` issue per reserved-shadow node** (`severity: 'warn'`, message points at the offending file and suggests renaming).
364
388
 
365
- 2. **The post-walk confidence-lift transform downgrades any link that resolves to a reserved target** (by path OR by name match) to `RESERVED_TARGET_CONFIDENCE = 0.1` instead of bumping it to `1.0`. The visual weight in the graph drops well below the `0.5` / `0.8` extractor emit floors so the operator sees at a glance that the edge resolves to a file the runtime ignores. When the trigger has multiple candidates (name index collision) and the strict-kind filter accepts more than one, the resolver picks the first allowed candidate, if it is non-reserved, the link bumps to `1.0` normally; only when EVERY accepted candidate is reserved does the downgrade apply.
389
+ 2. **It downgrades any link that resolves to a reserved target** (by path OR by name match) by subtracting `RESERVED_PENALTY = 0.9` (a `delta` op) from the kernel's 1.0 baseline, folding it to `RESERVED_TARGET = 0.1`, emitting the `delta -0.9` in the same score-phase pass as its reserved warns. The reserved-target set is computed by the post-walk lift and surfaced to the detector via `ctx.reservedNodePaths`. The visual weight in the graph drops well below the broken floor (`0.5`) so the operator sees at a glance that the edge resolves to a file the runtime ignores. When the trigger has multiple candidates (name index collision) and the strict-kind filter accepts more than one, the resolver picks the first allowed candidate, if it is non-reserved, the link keeps the 1.0 baseline (no penalty); only when EVERY accepted candidate is reserved does the penalty apply. With `core/name-reserved` disabled, a reserved-resolving link gets no `delta -0.9` and no warn, so the link falls back to the kernel's 1.0 baseline (symmetric disable).
366
390
 
367
391
  The lookup normalises both sides through the §Extractor · trigger normalization pipeline, so a literal `Init-Project` in the manifest still matches a user `name: init project` or filename `Init-Project.md`. The catalog is intentionally per-kind, not global: a name reserved for commands (`/help`) MAY legitimately appear as a skill (a "help" skill triggered through a non-command channel). Lens scope respects the same per-kind boundary: Antigravity declares its reserved names under `skill` (not `command`) precisely because the invocable they shadow is a skill file, so only `skill`-kind nodes are tested against it.
368
392
 
369
- **Update policy.** Built-in catalogs drift as vendor runtimes evolve. Each catalog change ships as a kernel patch with a changeset entry; the catalog is considered API surface that users rely on the analyzer to reflect. User-installed Providers MAY declare their own `reservedNames` with the same shape; the analyzer and the downgrade run uniformly across built-in and user-installed Providers.
393
+ **Update policy.** Built-in catalogs drift as vendor runtimes evolve. Each catalog change ships as a kernel patch with a changeset entry; the catalog is considered API surface that users rely on the analyzer to reflect. User-installed Providers MAY declare their own `reservedNames` with the same shape; the analyzer and the penalty run uniformly across built-in and user-installed Providers.
370
394
 
371
- Default `undefined` ≡ empty map ≡ no reserved names. Path matches against non-reserved targets are unaffected (they continue to bump to `1.0` unconditionally), the downgrade only fires when the resolved target is in the reserved set.
395
+ Default `undefined` ≡ empty map ≡ no reserved names. Links to non-reserved targets are unaffected (they keep the kernel's 1.0 baseline), the penalty only fires when the resolved target is in the reserved set and `core/name-reserved` is enabled.
372
396
 
373
397
  ### Extractor · output callbacks
374
398
 
375
399
  The `Extractor` runtime contract is `extract(ctx) → void`. The extractor emits its work through three callbacks the kernel binds onto `ctx`:
376
400
 
377
- - `ctx.emitLink(link)`, append a `Link` to the kernel's `links` table. The kernel validates `link.kind` against the **global closed enum** of link kinds (`invokes`, `references`, `mentions`, `supersedes`, `points`) before persistence; off-enum links are dropped and surface as `extension.error` events (the per-extractor `emitsLinkKinds` allowlist was retired with the structure-as-truth refactor; confidence is declared per emit, default `'medium'`). URL-shaped targets (`http(s)://…`) are partitioned out into `node.externalRefsCount` and never persisted.
401
+ - `ctx.emitLink(link)`, append a `Link` to the kernel's `links` table. The kernel validates `link.kind` against the **global closed enum** of link kinds (`invokes`, `references`, `mentions`, `points`) before persistence; off-enum links are dropped and surface as `extension.error` events (the per-extractor `emitsLinkKinds` allowlist was retired with the structure-as-truth refactor; confidence is declared per emit, default `'medium'`). URL-shaped targets (`http(s)://…`) are partitioned out into `node.externalRefsCount` and never persisted.
378
402
  - `ctx.enrichNode(partial)`, merge canonical, kernel-curated properties onto the current node's enrichment layer (persisted into [`node_enrichments`](./db-schema.md#node_enrichments)). **Strictly separate from the author-supplied frontmatter** (the latter remains immutable across scans). The enrichment layer is the right home for kernel-derived facts (computed titles, summaries, signals an Extractor inferred from the body) without polluting what the user wrote on disk. See §Enrichment layer below for the full lifecycle (per-extractor attribution, refresh verbs).
379
403
  - `ctx.store`, plugin-scoped persistence. Optional, present only when the plugin declares `storage.mode` in `plugin.json`. Shape depends on the mode (`KvStore` for mode A, scoped `Database` for mode B). See [`plugin-kv-api.md`](./plugin-kv-api.md). The plugin author MAY opt into shape validation for their own writes by declaring `storage.schema` (Mode A) or `storage.schemas` (Mode B) in the manifest, JSON Schemas the kernel AJV-compiles at load time and runs against every `ctx.store.set(key, value)` / `ctx.store.write(table, row)` call. Absent = permissive (status quo). `emitLink` and `enrichNode` keep their universal validation against `link.schema.json` / `node.schema.json` regardless of this opt-in. See [`plugin-author-guide.md` §`outputSchema`](./plugin-author-guide.md#outputschema--opt-in-correctness-for-custom-storage-writes).
380
404
 
@@ -382,7 +406,7 @@ Extractors are deterministic-only; `ctx.runner` is NOT exposed on the Extractor
382
406
 
383
407
  ### Extractor · Signal IR (opt-in)
384
408
 
385
- In addition to the `emitLink` path, Extractors MAY emit **Signals** via `ctx.emitSignal(signal)`. A Signal is a candidate detection: one or many alternative interpretations of the same body or frontmatter location, each carrying its own kind, target, confidence, and rationale. See [`signal.schema.json`](./schemas/signal.schema.json) for the full contract. The Signal IR is opt-in; an extractor whose detection is unambiguous (sidecar `supersedes`, `[text](file.md)` markdown links, plain `https://…` URLs) is encouraged to keep emitting Links directly with `ctx.emitLink`. Signals exist for the cases the resolver actually helps: detections where a single body token can plausibly mean several things and the active provider's rules need to decide.
409
+ In addition to the `emitLink` path, Extractors MAY emit **Signals** via `ctx.emitSignal(signal)`. A Signal is a candidate detection: one or many alternative interpretations of the same body or frontmatter location, each carrying its own kind, target, confidence, and rationale. See [`signal.schema.json`](./schemas/signal.schema.json) for the full contract. The Signal IR is opt-in; an extractor whose detection is unambiguous (`[text](file.md)` markdown links, plain `https://…` URLs) is encouraged to keep emitting Links directly with `ctx.emitLink`. Signals exist for the cases the resolver actually helps: detections where a single body token can plausibly mean several things and the active provider's rules need to decide.
386
410
 
387
411
  The kernel's **resolver phase** runs after extraction completes and before analysis starts. For each Signal, the resolver:
388
412
 
@@ -392,7 +416,7 @@ The kernel's **resolver phase** runs after extraction completes and before analy
392
416
  4. Materialises every Signal whose final `outcome === 'materialised'` as a Link, identical in shape to a Link emitted directly via `emitLink`. The materialised Link's `sources[]` carries the winning candidate's `extractorId` so attribution survives the resolver.
393
417
  5. (Phase 4+, not yet wired) Rejects a whole Signal when every candidate's `confidence` falls below the configured floor: `resolution.outcome = 'rejected'` with `belowFloor = { threshold }`. Today the resolver materialises every Signal that survives overlap regardless of confidence.
394
418
 
395
- Both materialised and rejected Signals remain on `IAnalyzerContext.signals` post-resolver. The built-in `core/signal-collision` analyzer reads this buffer and emits one `warn` issue per rejected Signal so the operator sees WHICH extractor lost, against WHO, and WHY. Rejected Signals never enter the graph as Links, but their existence is visible end-to-end through the issue surface.
419
+ Both materialised and rejected Signals remain on `IAnalyzerContext.signals` post-resolver. The built-in `core/extractor-collision` analyzer reads this buffer and emits one `warn` issue per rejected Signal so the operator sees WHICH extractor lost, against WHO, and WHY. Rejected Signals never enter the graph as Links, but their existence is visible end-to-end through the issue surface.
396
420
 
397
421
  The Signal's `range` field (byte offsets in the source) powers two cross-extractor analyses no Link can support today: collision detection (two extractors emitting Signals with overlapping ranges, contract above) and fragmentation detection (an authored intent split across several adjacent Signals, deferred to Phase 5+). Both surface as analyzer issues, not silent merges.
398
422
 
@@ -406,7 +430,7 @@ The contract:
406
430
  - **Token grammar** (pinned; implementations MUST match it exactly): `/(?<![\w/:.-])(?:\.{1,2}\/)?[\w][\w.-]*(?:\/[\w.-]+)*\.md\b(?![\w/])/g`. In words: an optional `./` or `../` prefix, a first segment that MUST start with a word character, zero or more `/` separators, a `.md` suffix at a word boundary. A bare filename (`algo4.md`) matches, the same way the consuming runtime follows it: a skill body's `lee el archivo: ` + "`algo4.md`" is an instruction the LLM resolves against the skill directory (verified empirically, every tested model reads the bare-referenced sibling), so the graph models the edge. The character classes and guards still reject, by construction: URL interiors (`https://example.com/docs/x.md` cannot match at any start position because of the lookbehind), template placeholders and globs (`{PROJECT}-x.md`, `*-S.md`, the leading `{` / `*` is outside the segment class AND the word-character anchor refuses the `-x.md` tail that would otherwise leak once the `/` separator became optional), near-miss suffixes (`.mdx`, `.md_var`), and absolute paths (a leading `/` fails the lookbehind). Slashless convention filenames (`SKILL.md`, `README.md`) now match too: a self-referential `SKILL.md` resolves to the node's own sibling and surfaces as a self-loop (excluded from card chips by `core/link-self-loop`), and any other unresolved bare filename is flagged by `core/reference-broken`, so the relaxed recall does not corrupt the graph.
407
431
  - **Targets**: `.md` only. Markdown files are the one class with a guaranteed node on the scan side (the `core/markdown` fallback), so every resolvable token has a target to land on.
408
432
  - **Resolution**: identical to `core/markdown-link`, POSIX-normalised against `dirname(node.path)`. Per-node dedup on the resolved target, first occurrence wins.
409
- - **Emission**: one single-candidate Signal per distinct resolved target, `kind: 'points'` (the code-region path pointer kind, distinct from `references` so the two surfaces stay visually and semantically separable), confidence `0.85` (same value and rationale as a path-style at-directive: a strong file signal with one degree of inference, the author wrote a path but not an explicit link syntax). The candidate's `normalizedTrigger` is the resolved target so resolution and the confidence lift behave exactly like `markdown-link`. A prose `[x](references/a.md)` (`references`) and a backticked `` `references/a.md` `` (`points`) targeting the same file COEXIST as two Link rows: the post-resolver dedup keys on `kind`, so the rows never merge, and `core/link-conflict` excludes `points` from disagreement detection (two complementary authoring surfaces, not two detectors disputing one meaning).
433
+ - **Emission**: one single-candidate Signal per distinct resolved target, `kind: 'points'` (the code-region path pointer kind, distinct from `references` so the two surfaces stay visually and semantically separable), confidence `0.85` (same value and rationale as a path-style at-directive: a strong file signal with one degree of inference, the author wrote a path but not an explicit link syntax). The candidate's `normalizedTrigger` is the resolved target so resolution and the confidence lift behave exactly like `markdown-link`. A prose `[x](references/a.md)` (`references`) and a backticked `` `references/a.md` `` (`points`) targeting the same file COEXIST as two Link rows: the post-resolver dedup keys on `kind`, so the rows never merge, and `core/link-kind-conflict` excludes `points` from disagreement detection (two complementary authoring surfaces, not two detectors disputing one meaning).
410
434
  - **Unresolved targets are NOT suppressed.** The extractor emits unconditionally, mirroring `markdown-link`; `core/reference-broken` flags targets that resolve to no node. This is deliberate: a backticked path pointing at a deleted or misspelled bundled doc is a real authoring bug (the standard's progressive disclosure breaks at runtime). Out-of-scope paths that the consuming runtime resolves against a different root (a target workspace, a generated tree) are silenced through the existing `scan.referencePaths` escape hatch, not by weakening the extractor.
411
435
 
412
436
  A path written in prose without any wrapping (neither backticks nor markdown-link syntax) stays invisible in this revision; the code-region domain is the verified, bounded surface.
@@ -454,7 +478,7 @@ The invariant exists to keep `sm scan --changed` cheap on real corpora: re-parsi
454
478
  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):
455
479
 
456
480
  - `originalTrigger`, the exact source text the extractor saw, byte-for-byte. Used only for display.
457
- - `normalizedTrigger`, the output of the pipeline below. Used for equality and collision detection, the built-in `trigger-collision` analyzer keys on this field.
481
+ - `normalizedTrigger`, the output of the pipeline below. Used for equality and resolution: the post-walk resolver keys on this field to match a trigger-style link against node identifiers. The same normalization (applied to `frontmatter.name`) backs the built-in `name-collision` analyzer's verdict.
458
482
 
459
483
  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.
460
484
 
@@ -646,7 +670,7 @@ Skill-map's own metadata layer (versioning, supersession, provenance, taxonomy,
646
670
  Two schemas describe the wire shape:
647
671
 
648
672
  - [`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.
649
- - [`schemas/annotations.schema.json`](./schemas/annotations.schema.json), curated 10-field catalog: versioning + supersession (`version`, `stability`, `supersedes`, `supersededBy`), 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).
673
+ - [`schemas/annotations.schema.json`](./schemas/annotations.schema.json), curated 8-field catalog: versioning (`version`, `stability`), 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).
650
674
 
651
675
  ### Identity and drift
652
676
 
@@ -658,7 +682,7 @@ At scan time the kernel re-computes the live hashes and compares against the sto
658
682
 
659
683
  The deterministic built-in `core/node-bump` Action produces a sidecar patch:
660
684
 
661
- - Increments `annotations.version` by 1 (or sets to `1` if missing, single integer monotonic, orthogonal to `stability`; major bumps are not a concept, the convention for breaking changes is "create a new node, supersede the old").
685
+ - Increments `annotations.version` by 1 (or sets to `1` if missing, single integer monotonic, orthogonal to `stability`; major bumps are not a concept, the convention for breaking changes is "create a new node and retire the old").
662
686
  - Refreshes `identity.bodyHash` and `identity.frontmatterHash` to the live values.
663
687
  - Stamps `audit.lastBumpedAt` (ISO 8601 datetime) and `audit.lastBumpedBy` (the Git author name from `git config user.name` when the project is a Git repo; otherwise the channel literal `'cli'`, `'ui'`, or `'plugin:<id>'`).
664
688
  - On first-time creation also stamps `audit.createdAt` and `audit.createdBy` (set once, stable thereafter).
@@ -855,7 +879,7 @@ Endpoints under `/api/contributions/*`:
855
879
  - `GET /api/contributions/registered`, runtime catalog. Mirror of `/api/annotations/registered`. Envelope variant `kind: 'contributions.registered'` (see [`schemas/api/rest-envelope.schema.json`](./schemas/api/rest-envelope.schema.json)).
856
880
  - `GET /api/contributions/:pluginId/:extensionId/:contributionId?path=...`, lazy per-node fetch for inspector slots. **Three URL segments** mirror the qualified id `<pluginId>/<extensionId>/<contributionId>`. Filters by qualified id + node path; the BFF enforces `pluginId` ↔ namespace at the route level, no cross-plugin reads via this endpoint.
857
881
 
858
- The `inspector.action.button` contribution is **self-projected by the dispatching Action's own `project(ctx)`** (scan-time, deterministic), not by a separate projector Analyzer. The Action computes the per-node `enabled` / `disabledReason` and the prompt `options` / `defaultValue` from the live graph it receives, emits the button, and is itself the dispatch target. (This reverses the earlier "an Analyzer projects the button" shape; the projector Analyzers `core/supersede` and `core/tags` were removed and `core/annotation-stale` keeps only its badge + issue.) The slot dispatches to a generic Action endpoint, sibling of the single-node `POST /api/sidecar/bump`:
882
+ The `inspector.action.button` contribution is **self-projected by the dispatching Action's own `project(ctx)`** (scan-time, deterministic), not by a separate projector Analyzer. The Action computes the per-node `enabled` / `disabledReason` and the prompt `options` / `defaultValue` from the live graph it receives, emits the button, and is itself the dispatch target. (This reverses the earlier "an Analyzer projects the button" shape; the projector Analyzer `core/tags` was removed and `core/annotation-stale` keeps only its badge + issue.) The slot dispatches to a generic Action endpoint, sibling of the single-node `POST /api/sidecar/bump`:
859
883
 
860
884
  - `POST /api/actions/:id`, dispatch a kernel Action by qualified id (`:id` is the `<plugin>/<action>` from the button payload's `actionId`). Body carries the target `nodePath`, the optional reserved `input` object (Steps 2+), and the consent fields `confirm` / `always` (see §Annotation system → Write consent) for `.sm`-writing Actions. The kernel resolves the Action in its registry (unknown id → 404), runs it against the node, and answers the action-result envelope `kind: 'action.applied'` (`{ value: { actionId, nodePath, report }, elapsedMs }`, see [`schemas/api/rest-envelope.schema.json`](./schemas/api/rest-envelope.schema.json)). `POST /api/sidecar/bump` remains the dedicated single-purpose route for `core/node-bump` (`kind: 'sidecar.bumped'`); the generic dispatch route shares the same action-result envelope variant.
861
885
 
package/cli-contract.md CHANGED
@@ -376,10 +376,9 @@ identity:
376
376
 
377
377
  annotations:
378
378
  version: 3
379
- # Deprecated because v0.6 architecture supersedes this skill.
379
+ # Deprecated because the v0.6 architecture replaced this skill.
380
380
  # See decision #142 in ROADMAP for context.
381
381
  stability: deprecated
382
- supersededBy: agents/reviewer-v2.md
383
382
  tags:
384
383
  - review
385
384
  - typescript # only TS, not JS
@@ -390,7 +389,6 @@ annotations:
390
389
  ```yaml
391
390
  annotations:
392
391
  stability: deprecated
393
- supersededBy: agents/reviewer-v2.md
394
392
  tags:
395
393
  - review
396
394
  - typescript
@@ -681,12 +679,13 @@ The `/ws` endpoint is the live-events channel for the SPA. Clients connect once
681
679
  - **Deferred to a follow-up**: `issue.added` / `issue.resolved` (per `job-events.md` §Issue events line 446) and `scan.failed`. The 14.4.a surface fans out only the events the kernel emitter already produces; the diff-based issue events and a dedicated batch-failure event require additional plumbing inside the BFF watcher loop.
682
680
  - **Connection lifecycle**:
683
681
  1. Client opens `ws://<host>:<port>/ws`. The server completes the WS handshake and registers the underlying socket with the broadcaster.
684
- 2. Server pushes events. The client sends nothing at v14.4.a, `onMessage` is intentionally not registered. A future heartbeat / subscribe / filter request lands in a follow-up.
682
+ 2. Server pushes events. The client sends no application frames, `onMessage` is intentionally not registered for app data (transport-level pong frames are answered by the browser automatically, see **Keep-alive** below). A future client-initiated subscribe / filter request lands in a follow-up.
685
683
  3. Server has NO state push on connect (no replay of last events). The client SHOULD poll `/api/scan` once on connect to seed initial state, then rely on `/ws` for deltas.
686
684
  4. On normal disconnect: client closes with code 1000 ('normal closure') or 1001 ('going away'). The broadcaster unregisters silently.
687
685
  5. On server shutdown (SIGINT / SIGTERM): the broadcaster sends close code 1001 + reason `'server shutdown'` to every client, then closes the http listener.
688
686
  6. **Backpressure**: if a client's outbound buffer (`bufferedAmount`) exceeds an implementation-defined threshold (the reference impl uses 4 MiB), the broadcaster closes that client with code 1009 ('message too big') and unregisters it. Clients SHOULD reconnect after backpressure eviction with a fresh `/api/scan` poll.
689
- 7. **Reconnect responsibility**: the server does NOT reconnect on the client's behalf and does NOT replay missed events on reconnect. The client SHOULD treat `/ws` as a best-effort delta channel and re-seed via `/api/scan` whenever the connection drops.
687
+ 7. **Reconnect responsibility**: the server does NOT reconnect on the client's behalf and does NOT replay missed events on reconnect. The client SHOULD treat `/ws` as a best-effort delta channel and re-seed via `/api/scan` whenever the connection drops. To avoid a re-seed storm against a flapping endpoint, the client SHOULD reset its reconnect backoff only after a connection has stayed open long enough to be considered stable; a socket that opens and then drops before that window counts as a failed attempt, so the backoff keeps escalating and eventually surfaces a non-fatal 'connection lost' state instead of reconnecting (and re-seeding) in a tight loop.
688
+ - **Keep-alive (heartbeat)**: the server sends an RFC 6455 ping control frame to every connected client on a fixed interval (implementation-defined; the reference impl uses 30s). The browser answers each ping with an automatic pong, transparent to the page's JS `WebSocket` API, so the connection never sits idle and an intermediary that drops idle sockets (the Angular dev-server proxy under `pnpm dev`, a hosted nginx / load balancer) leaves it alone. The same exchange is dead-peer detection: a client that has not ponged since the previous interval is terminated server-side (it vanished without a close frame, e.g. host sleep or a NAT timeout). This complements the `bufferedAmount` backpressure eviction, which only fires when there is an event to send, so a silent half-open socket on an idle workspace is still reaped. No application frame or envelope is involved; this is purely transport keep-alive and does not appear in the event catalog above.
690
689
  - **Loopback-only assumption (Decision #119)**: no per-connection authentication on `/ws` through v0.6.0. The transport security boundary is the `--host` flag (defaults to `127.0.0.1`); the server rejects `--dev-cors` combined with a non-loopback `--host` precisely because that combination would expose `/ws` over the network without auth. Multi-host serve and an auth model re-open post-v0.6.0.
691
690
 
692
691
  **Graceful shutdown**: SIGINT / SIGTERM trigger a graceful close; the verb returns exit 0 on clean shutdown. Bind failure (port in use, EACCES) returns exit 2. The shutdown sequence drains the in-flight watcher batch (if any), closes every WS client with code 1001, then closes the http listener.
@@ -122,6 +122,7 @@ Assertion types beyond this list MAY be proposed via spec-vX.Y.Z minor bumps. Im
122
122
  | `no-global-scope` | The `-g/--global` flag does not exist. Implementations MUST reject it on every verb (exit `2`, "unknown option"). Guards `cli-contract.md` §Scope is always project-local. |
123
123
  | `orphan-markdown-fallback` | Multi-Provider corpus where one node lands via the universal `core/markdown` fallback and another via vendor-specific claude classification. Locks the orchestrator's path-dedup contract. |
124
124
  | `plugin-missing-ui-rejected` | Drop-in Provider whose `kinds[*]` entry omits the required `ui` block fails AJV validation with `invalid-manifest` while the rest of the pipeline keeps running. |
125
+ | `score-phase-confidence` | Drop-in analyzer declaring `phase: 'score'` composes a confidence adjustment (`delta -0.4`, then a no-op `floor 0.5`) on top of the kernel's 1.0 baseline (a clean resolved link keeps that baseline, no built-in op); the folded, clamped `scan_links.confidence` lands at exactly `0.6`. |
125
126
  | `sidecar-end-to-end` | Co-located `.sm` sidecar shape, stale / orphan detection, populated `Node.sidecar` overlay, both `annotation-stale` and `annotation-orphan` issues emitted. |
126
127
  | `view-action-button` | An analyzer declaring the unified `inspector.header.badge` + the new `inspector.action.button` slots loads clean, while a sibling declaring the retired `inspector.header.badge.counter` slot fails as `invalid-manifest`; `sm scan` survives. |
127
128
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://skill-map.ai/spec/v0/conformance-case.schema.json",
3
- "id": "signal-collision-detection",
4
- "description": "Signal IR resolver phase, range-overlap collision. A body that contains `[@./api.md](./api.md)` triggers a cross-extractor range overlap: `core/markdown-link` matches the whole bracketed-and-parenthesised span; `claude/at-directive` matches the `@./api.md` token INSIDE the bracket text. The two byte ranges overlap (the at-directive range is a strict subset of the markdown-link range). The kernel resolver picks ONE winner per the four-step tiebreak (`kind-priority` -> `higher-confidence` -> `longer-range` -> `earlier-declaration`); markdown-link wins on confidence (0.95 vs 0.85). The resolver materialises the winner as a Link, marks the loser's `resolution.outcome === 'rejected'` with `rejectedBy` naming the winner, and the built-in `core/signal-collision` analyzer surfaces the rejection as ONE `warn` issue attached to the source node. Locks the contract that range-overlap collisions surface to the operator instead of being silently merged.",
3
+ "id": "extractor-collision-detection",
4
+ "description": "Signal IR resolver phase, range-overlap collision. A body that contains `[@./api.md](./api.md)` triggers a cross-extractor range overlap: `core/markdown-link` matches the whole bracketed-and-parenthesised span; `claude/at-directive` matches the `@./api.md` token INSIDE the bracket text. The two byte ranges overlap (the at-directive range is a strict subset of the markdown-link range). The kernel resolver picks ONE winner per the four-step tiebreak (`kind-priority` -> `higher-confidence` -> `longer-range` -> `earlier-declaration`); markdown-link wins on confidence (0.95 vs 0.85). The resolver materialises the winner as a Link, marks the loser's `resolution.outcome === 'rejected'` with `rejectedBy` naming the winner, and the built-in `core/extractor-collision` analyzer surfaces the rejection as ONE `warn` issue attached to the source node. Locks the contract that range-overlap collisions surface to the operator instead of being silently merged.",
5
5
  "fixture": "signal-ir-collision",
6
6
  "invoke": {
7
7
  "verb": "scan",
@@ -14,7 +14,7 @@
14
14
  { "type": "json-path", "path": "$.links[0].target", "equals": ".claude/agents/api.md" },
15
15
  { "type": "json-path", "path": "$.links[0].sources[0]", "equals": "markdown-link" },
16
16
  { "type": "json-path", "path": "$.stats.issuesCount", "equals": 1 },
17
- { "type": "json-path", "path": "$.issues[0].analyzerId", "equals": "signal-collision" },
17
+ { "type": "json-path", "path": "$.issues[0].analyzerId", "equals": "extractor-collision" },
18
18
  { "type": "json-path", "path": "$.issues[0].severity", "equals": "warn" }
19
19
  ]
20
20
  }
@@ -0,0 +1,21 @@
1
+ {
2
+ "$schema": "https://skill-map.ai/spec/v0/conformance-case.schema.json",
3
+ "id": "score-phase-confidence",
4
+ "description": "Score-phase confidence composition, end-to-end. A drop-in plugin ships an analyzer declaring `phase: 'score'` that calls `ctx.adjustConfidence(link, op)`. A `[text](./target.md)` link resolves to `target.md`; the kernel seeds the 1.0 confidence baseline on every link, and a clean resolved link gets no built-in score-phase op. The drop-in then folds a `delta -0.4` (→ 0.6) and a no-op `floor 0.5` on top. The fold is deterministic and clamped to [0,1], so the persisted `scan_links.confidence` MUST be exactly `0.6`. Locks that a third-party `score`-phase analyzer loads from a manifest, composes confidence ops on top of the kernel 1.0 baseline, and the folded value reaches the scan output.",
5
+ "fixture": "score-phase-confidence",
6
+ "invoke": {
7
+ "verb": "scan",
8
+ "flags": ["--json"]
9
+ },
10
+ "assertions": [
11
+ { "type": "exit-code", "value": 0 },
12
+ { "type": "json-path", "path": "$.schemaVersion", "equals": 1 },
13
+ { "type": "json-path", "path": "$.stats.nodesCount", "equals": 2 },
14
+ { "type": "json-path", "path": "$.stats.linksCount", "equals": 1 },
15
+ { "type": "json-path", "path": "$.links[0].source", "equals": "source.md" },
16
+ { "type": "json-path", "path": "$.links[0].target", "equals": "target.md" },
17
+ { "type": "json-path", "path": "$.links[0].kind", "equals": "references" },
18
+ { "type": "json-path", "path": "$.links[0].resolvedTarget", "equals": "target.md" },
19
+ { "type": "json-path", "path": "$.links[0].confidence", "equals": 0.6 }
20
+ ]
21
+ }
@@ -10,7 +10,7 @@ This file is hand-maintained. A CI check before spec release compares the schema
10
10
  |---|---|---|---|---|
11
11
  | 1 | `node.schema.json` | `kernel-empty-boot` (indirect) | 🟡 partial | Empty-boot validates the zero-filled ScanResult shape end-to-end. Direct cases that exercise populated `Node` rows are Provider-specific and live in the Provider's own conformance suite (see `provider:claude` for `basic-scan`). |
12
12
  | 2 | `link.schema.json` |, | 🔴 missing | Needs fixture with at least one `invokes` + `references` + `mentions` link, both `high`/`medium`/`low` confidence. |
13
- | 3 | `issue.schema.json` |, | 🔴 missing | Needs fixture triggering `trigger-collision` + `broken-ref` + `superseded`. |
13
+ | 3 | `issue.schema.json` |, | 🔴 missing | Needs fixture triggering `name-collision` + `broken-ref`. |
14
14
  | 4 | `scan-result.schema.json` | `kernel-empty-boot`, `orphan-markdown-fallback` | 🟢 covered | Zero-filled case via empty-boot. `orphan-markdown-fallback` (spec 0.18.0) asserts a populated `ScanResult` over a multi-Provider corpus where one node lands via the universal `core/markdown` fallback and another via vendor-specific claude classification, locks the orchestrator's path-dedup contract. Populated rename / orphan cases live under `provider:claude` (`basic-scan` / `rename-high` / `orphan-detection`). |
15
15
  | 5 | `execution-record.schema.json` |, | 🔴 missing | Blocked by Step 5 (history). Needs a case that runs a `deterministic` action and inspects `state_executions` via `sm history --json`. |
16
16
  | 6 | `project-config.schema.json` |, | 🔴 missing | Case: init a scope, write a partial `.skill-map/settings.json` (optionally with a `.skill-map/settings.local.json` overlay), assert effective config after the layered merge. |
@@ -27,7 +27,7 @@ This file is hand-maintained. A CI check before spec release compares the schema
27
27
  | 17 | `extensions/base.schema.json` |, | 🔴 missing | Meta-case: every manifest under `src/extensions/` validates against the appropriate kind schema (which extends base via `allOf`). |
28
28
  | 18 | `extensions/provider.schema.json` | `plugin-missing-ui-rejected` | 🟡 partial | A Provider plugin whose `kinds/<kindName>/kind.json` omits the required `ui` block fails AJV validation with `invalid-manifest` while the rest of the pipeline keeps running (built-in Claude Provider, exit 0). Since the structure-as-truth refactor, Providers no longer carry a `kinds` map in the manifest; per-kind metadata lives under `kinds/<kindName>/kind.json` and frontmatter schemas under `kinds/<kindName>/schema.json`. The complementary positive case (canonical Claude Provider validates) lives in `provider:claude` conformance. Direct case for missing `kinds/` directory rejection still pending. |
29
29
  | 19 | `extensions/extractor.schema.json` |, | 🔴 missing | Case: `frontmatter` + `slash` + `at-directive` extractor manifests validate; an extractor declaring a `precondition.kind` against an unknown qualified kind emits `precondition-kind-unknown` in `sm plugins doctor`. |
30
- | 20 | `extensions/analyzer.schema.json` |, | 🔴 missing | Case: `trigger-collision`, `broken-ref`, `superseded` manifests validate. |
30
+ | 20 | `extensions/analyzer.schema.json` | `score-phase-confidence` | 🟡 partial | `score-phase-confidence` exercises the `phase` enum end-to-end: a drop-in analyzer declaring `phase: 'score'` loads from its manifest and composes a confidence op (`delta -0.4`, then a no-op `floor 0.5`) on top of the kernel's 1.0 baseline (a clean resolved link keeps that baseline, no built-in op), landing the folded `scan_links.confidence` at `0.6`. Direct manifest-validation cases for the `detect` / `aggregate` defaults and the `precondition` / `ui` blocks (`name-collision`, `broken-ref`) still pending. |
31
31
  | 21 | `extensions/action.schema.json` |, | 🔴 missing | Case: a `deterministic` action manifest validates; a `probabilistic` action without `<action-dir>/prompt.md` surfaces as `load-error` (structure-as-truth: prompt template lives on disk by convention, no `promptTemplateRef` field). |
32
32
  | 22 | `extensions/formatter.schema.json` |, | 🔴 missing | Case: `ascii` formatter manifest validates. |
33
33
  | 23 | `history-stats.schema.json` |, | 🔴 missing | Blocked by Step 5 (history). Case: seed `state_executions` with a deterministic fixture, run `sm history stats --json --since <T0> --until <T1> --period month --top 5`, assert the document validates and that `totals.executionsCount == sum(perAction.executionsCount)` and `errorRates.global == totals.failedCount / totals.executionsCount`. Percentiles (`p95`/`p99`) intentionally omitted in v1, add later as a minor bump without breaking consumers. |
@@ -44,7 +44,7 @@ This file is hand-maintained. A CI check before spec release compares the schema
44
44
  | 33 | `plugins-doctor.schema.json` |, | 🔴 missing | Machine-readable output of `sm plugins doctor --json`. Aggregates per-status counts plus structured issue / warning lists. Direct conformance case pending: prime a scope with one healthy + one invalid-manifest drop-in plugin, run `sm plugins doctor --json`, assert the envelope validates and the invalid plugin appears under `issues[]`. Implementation tests at `src/test/plugins-cli.test.ts` cover the runtime behaviour. |
45
45
  | 34 | `conformance-result.schema.json` |, | 🔴 missing | Machine-readable output of `sm conformance run --json`. Self-referential by design (a conformance case would invoke the verb against itself); a direct case is deferred until the runner gains a meta-loopback mode. Implementation tests at `src/test/conformance-cli.test.ts` cover the envelope shape today. |
46
46
  | 35 | `user-settings.schema.json` | (indirect via `no-global-scope`) | 🟡 partial | Per-user / per-machine settings file at `~/.skill-map/settings.json` (the narrow `$HOME` exception, see `cli-contract.md` §User-settings file). Direct case is not added because alt-impls MAY choose to not ship an update-check feature, requiring them to produce this file would over-prescribe. The implementation-side AJV round-trip is covered by `src/test/user-settings-store.test.ts` (15 cases: defaults, malformed JSON, schemaVersion mismatch, wrong-type fields, unknown top-level keys, deep-merge writes, off-shape rejection). The behavioral counterpart (no global / user scope) lives at `no-global-scope` in the non-schema table below. |
47
- | 37 | `signal.schema.json` | `extractor-emits-signal`, `signal-collision-detection` | ✅ covered | Intermediate Representation (IR) emitted by extractors via `ctx.emitSignal()`; the kernel resolver phase consumes Signals and materialises Links. Opt-in: the existing `ctx.emitLink()` path coexists. Phase 2.A wired the resolver end-to-end (filter -> rank -> overlap -> materialise + annotate); Phase 2.B + 2.C migrated all six link-emitter extractors (`claude/at-directive`, `claude/slash-command`, `core/markdown-link`, `core/annotations`, `core/mcp-tools`, `core/external-url-counter`); Phase 2.D added the `core/signal-collision` analyzer + the two cases. The cases cover (a) `extractor-emits-signal`, a markdown body with one `[text](path)` link materialises one Link via the Signal IR path; (b) `signal-collision-detection`, a body with both `[label](./api.md)` AND `@./api.md` at overlapping byte ranges triggers a cross-extractor collision, the resolver materialises ONE Link (markdown-link wins on confidence) and the loser's `resolution.rejectedBy` reaches the `core/signal-collision` analyzer which emits a `warn` issue naming WHO won, WHO lost, and WHY. |
47
+ | 37 | `signal.schema.json` | `extractor-emits-signal`, `extractor-collision-detection` | ✅ covered | Intermediate Representation (IR) emitted by extractors via `ctx.emitSignal()`; the kernel resolver phase consumes Signals and materialises Links. Opt-in: the existing `ctx.emitLink()` path coexists. Phase 2.A wired the resolver end-to-end (filter -> rank -> overlap -> materialise + annotate); Phase 2.B + 2.C migrated all six link-emitter extractors (`claude/at-directive`, `claude/slash-command`, `core/markdown-link`, `core/annotations`, `core/mcp-tools`, `core/external-url-counter`); Phase 2.D added the `core/extractor-collision` analyzer + the two cases. The cases cover (a) `extractor-emits-signal`, a markdown body with one `[text](path)` link materialises one Link via the Signal IR path; (b) `extractor-collision-detection`, a body with both `[label](./api.md)` AND `@./api.md` at overlapping byte ranges triggers a cross-extractor collision, the resolver materialises ONE Link (markdown-link wins on confidence) and the loser's `resolution.rejectedBy` reaches the `core/extractor-collision` analyzer which emits a `warn` issue naming WHO won, WHO lost, and WHY. |
48
48
 
49
49
  > **Note on Provider-owned schemas.** Per-kind frontmatter schemas (`skill`, `agent`, `command`, `note` for the built-in Claude Provider; other Providers MAY declare different kinds) live with the Provider that emits them, for the built-in Claude Provider, under `src/extensions/providers/claude/schemas/`. Those schemas are NOT counted in the spec's coverage matrix above; they belong to the Provider's own conformance suite at `src/extensions/providers/claude/conformance/coverage.md`. The same split applies to the cases that exercise Provider-specific kinds (`basic-scan`, `rename-high`, `orphan-detection`), they live in the Provider's `cases/` directory.
50
50
 
@@ -0,0 +1,31 @@
1
+ // Conformance fixture: a THIRD-PARTY `score`-phase analyzer. It declares
2
+ // `phase: 'score'` (newly admitted by `analyzer.schema.json#/properties/phase`)
3
+ // and calls `ctx.adjustConfidence(link, op)` to compose confidence ops on
4
+ // top of the kernel's own 1.0 baseline.
5
+ //
6
+ // The companion case `score-phase-confidence.json` scans a `source.md`
7
+ // whose `[text](./target.md)` link resolves to `target.md`. The kernel
8
+ // seeds the 1.0 baseline on every link and a clean resolved link gets no
9
+ // built-in score-phase op; this drop-in then folds a `delta -0.4` (→ 0.6)
10
+ // and a `floor 0.5` (no-op, 0.6 > 0.5) on top. The fold is deterministic
11
+ // and clamped to [0,1], so the persisted `scan_links.confidence` is
12
+ // exactly `0.6`.
13
+ //
14
+ // A scorer emits no issues; its only output is the confidence ops, so
15
+ // `evaluate` returns `[]`. The callback is present ONLY in the score
16
+ // phase, the `?.` guard keeps the extension inert if it ever runs
17
+ // elsewhere.
18
+ export default {
19
+ version: '0.1.0',
20
+ description: 'score-phase scorer: delta -0.4 then floor 0.5 on every link',
21
+ mode: 'deterministic',
22
+ phase: 'score',
23
+
24
+ evaluate(ctx) {
25
+ for (const link of ctx.links) {
26
+ ctx.adjustConfidence?.(link, { kind: 'delta', value: -0.4 });
27
+ ctx.adjustConfidence?.(link, { kind: 'floor', value: 0.5 });
28
+ }
29
+ return [];
30
+ },
31
+ };
@@ -0,0 +1,6 @@
1
+ {
2
+ "version": "0.1.0",
3
+ "specCompat": "*",
4
+ "catalogCompat": "*",
5
+ "description": "Conformance fixture: a drop-in plugin shipping a score-phase analyzer that composes a confidence adjustment (delta -0.4, then a no-op floor 0.5) on top of the kernel's own 1.0 confidence baseline."
6
+ }
@@ -0,0 +1,6 @@
1
+ ---
2
+ name: source
3
+ description: Fixture for the score-phase-confidence conformance case. The single markdown link below resolves to target.md and keeps the kernel's 1.0 confidence baseline, then the drop-in link-scorer plugin folds a delta -0.4 (then a no-op floor 0.5) on top, landing the persisted confidence at 0.6.
4
+ ---
5
+
6
+ Read [the target file](./target.md) for more context.
@@ -0,0 +1,6 @@
1
+ ---
2
+ name: target
3
+ description: Target of the score-phase-confidence conformance fixture. Body content is irrelevant; the assertion only checks the inbound link's folded confidence.
4
+ ---
5
+
6
+ Body.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: architect
3
- description: Fixture for the Signal IR `signal-collision-detection` conformance case. Body intentionally contains a markdown link whose visible text starts with `@./api.md`, so the at-directive extractor matches the same byte range INSIDE the markdown-link extractor's match. Cross-extractor range overlap; the resolver picks ONE winner (markdown-link, higher confidence) and the loser surfaces as a signal-collision warn.
3
+ description: Fixture for the Signal IR `extractor-collision-detection` conformance case. Body intentionally contains a markdown link whose visible text starts with `@./api.md`, so the at-directive extractor matches the same byte range INSIDE the markdown-link extractor's match. Cross-extractor range overlap; the resolver picks ONE winner (markdown-link, higher confidence) and the loser surfaces as a extractor-collision warn.
4
4
  ---
5
5
 
6
6
  Consult [@./api.md](./api.md) before deploying.
package/db-schema.md CHANGED
@@ -105,8 +105,8 @@ One row per detected link, matching [`schemas/link.schema.json`](./schemas/link.
105
105
  | `id` | INTEGER | PRIMARY KEY AUTOINCREMENT | |
106
106
  | `source_path` | TEXT | NOT NULL | FK semantically; MAY be unenforced for performance. |
107
107
  | `target_path` | TEXT | NOT NULL | MAY point to a missing node (broken ref). |
108
- | `kind` | TEXT | NOT NULL, CHECK in (`invokes`, `references`, `mentions`, `supersedes`, `points`) | |
109
- | `confidence` | TEXT | NOT NULL, CHECK in (`high`, `medium`, `low`) | |
108
+ | `kind` | TEXT | NOT NULL, CHECK in (`invokes`, `references`, `mentions`, `points`) | |
109
+ | `confidence` | REAL | NOT NULL, CHECK `>= 0.0 AND <= 1.0` | Numeric `[0,1]` (`link.schema.json#/properties/confidence`). The kernel's 1.0 baseline, then the folded result of every `score`-phase `ctx.adjustConfidence` op (the built-in score-phase detectors `core/name-reserved`, `core/reference-broken`, plus any third-party scorer); the per-op attribution lives in `scan_link_scores`. Migrated from the legacy `high`/`medium`/`low` TEXT enum. |
110
110
  | `sources_json` | TEXT | NOT NULL | JSON array of extractor ids. |
111
111
  | `original_trigger` | TEXT | NULL | |
112
112
  | `normalized_trigger` | TEXT | NULL | |
@@ -247,6 +247,27 @@ NOT analogous to `state_plugin_kvs` (which is plugin-managed). Belongs to the `s
247
247
 
248
248
  **Eager purge on disable.** `sm plugins disable <id>` calls `StoragePort.contributions.purgeByPlugin(pluginId, extensionId)` immediately after persisting `config_plugins[<id>].enabled = false`. Every persisted toggle key is the qualified `<plugin>/<ext>` shape (the CLI's bundle macro form and the BFF's cascade endpoint expand bare plugin ids before persistence), so the purge always receives both segments. The eager purge avoids the "I disabled the extension but its chips are still rendered in the UI until I re-scan" gap. Re-enabling (`sm plugins enable <id>`) does NOT restore the rows, the next scan re-emits them, same as a cold start. Contributions are scan-derived, so this is cheap; for plugin-managed state (`state_plugin_kvs`, dedicated tables) the opposite policy holds, see `plugin-kv-api.md` § "disable does not drop data".
249
249
 
250
+ ### `scan_link_scores`
251
+
252
+ Per-op confidence-attribution audit trail. One row per attributed `ctx.adjustConfidence(link, op)` call buffered by a `score`-phase analyzer during the scan (the kernel's own built-in score-phase detectors `core/name-reserved`, `core/reference-broken` dogfood the API, applying penalty deltas on top of the kernel's 1.0 baseline; third-party scorers add rows of their own). Lets an operator answer "why is this link at `0.3`?" by listing the plugin / extension / op that moved it, with the FOLDED final value denormalised onto every row.
253
+
254
+ | Column | Type | Constraint | Notes |
255
+ |---|---|---|---|
256
+ | `plugin_id` | TEXT | NOT NULL | Owning plugin namespace of the scorer (per spec § A.6). `core` for the built-in detectors (`name-reserved` / `reference-broken`). |
257
+ | `extension_id` | TEXT | NOT NULL | Scorer extension id within the plugin. |
258
+ | `source_path` | TEXT | NOT NULL | The link's `source` (originating node path). Part of the structural identity key, the same tuple `scan_links` dedups on. |
259
+ | `target` | TEXT | NOT NULL | The link's `target` (MAY be a missing node: broken refs get scored too). |
260
+ | `kind` | TEXT | NOT NULL | The link's `kind` (`invokes` / `references` / `mentions` / `points`). |
261
+ | `normalized_trigger` | TEXT | NULL | The link's `trigger.normalizedTrigger`; NULL for path-style links that carry no trigger. Completes the structural identity key. |
262
+ | `op_kind` | TEXT | NOT NULL | Confidence-algebra bucket: `set` / `delta` / `ceil` / `floor`. Kept open at the SQL layer (no CHECK) so the op catalog can evolve as a kernel + spec change without a DDL migration. |
263
+ | `op_value` | REAL | NOT NULL | The op's operand. |
264
+ | `result_confidence` | REAL | NOT NULL | Denormalised FOLDED final `link.confidence` after every op for this link was applied. Equal across all rows for one link; mirrors `scan_links.confidence` for the same structural edge so the audit read needs no join. |
265
+ | `emitted_at` | INTEGER | NOT NULL | Unix milliseconds. |
266
+
267
+ No primary key (multiple ops MAY land on one link). Index: `ix_scan_link_scores_source_path` (per-node "why this link?" lookup).
268
+
269
+ **Persistence, plain replace-all per scan** (delete every row, then insert), the same posture as `scan_issues` / `scan_contribution_errors`, NOT the orphan/catalog/per-tuple sweep `scan_contributions` uses. A score adjustment is a transient scan finding re-derived in full on every analyzer pass, so there is no cached-node row to preserve. An empty buffer (a scan whose scorers touched nothing) wipes the table, clearing any stale rows from a prior scan.
270
+
250
271
  ### `scan_node_tags`
251
272
 
252
273
  Tags. One row per `(node_path, tag)` pair, projected at persist time from `sidecar.annotations.tags`. Tags are a skill-map concept (no vendor carries `tags` in frontmatter), so the sidecar is the single source. Drives `sm list --tag <name>` and the UI's tag-faceted search; the `(tag)` index keeps "find all nodes with tag X" `O(log n)`.
package/index.json CHANGED
@@ -174,27 +174,28 @@
174
174
  }
175
175
  ]
176
176
  },
177
- "specPackageVersion": "0.50.0",
177
+ "specPackageVersion": "0.52.0",
178
178
  "integrity": {
179
179
  "algorithm": "sha256",
180
180
  "files": {
181
- "CHANGELOG.md": "d67e2fa8c42deb9f771652c66c5bc95db5df53445a956d2dc6d936e5342b2467",
182
- "README.md": "a7505a7b0672c39a8b011e3c5e7d41826306476ee63768249bba4bdb3c03d4d1",
183
- "architecture.md": "9fb9167ac9604cb8e4fb89e514b176ffb9ddfcf510ce94d5a3074c83a274bbcd",
184
- "cli-contract.md": "6f719b132f7f219c34bca3ffe2de7e4d4b54dd16c071254c3c081c3b4ea965fe",
185
- "conformance/README.md": "4ec22ca3cc8e4282fe0bfd111f22b121e0781e2b525867cd092258b8f58ae1e1",
181
+ "CHANGELOG.md": "5aa02a003b2185b4ec1dd2b83241a7c28f00891703a8283541e60764007842d6",
182
+ "README.md": "abb663d0c96c3158ffc501b9d5ef6f58e0db09bdea824cda7f9f70f465793925",
183
+ "architecture.md": "044f7216015fc0629b0e4665e35953f14ab657ca75aaf1cae9ba47d3cd68226c",
184
+ "cli-contract.md": "be13ffcb8c96065f1686443af82324c8175962b53e3777ef42158c1cdc3bb381",
185
+ "conformance/README.md": "33fef8cea919c1d7440c06c7eaa100e50014f52636490f550720e086cf8b651f",
186
186
  "conformance/cases/backtick-path-extraction.json": "4620e7f8bc161fc57cb44001e9d99879c7e22b4865a0c27a20dc28969cd936d9",
187
+ "conformance/cases/extractor-collision-detection.json": "179a02c61892f0d26492de0c4e2c327fa6b4986d1265a8f119e871df6afe4658",
187
188
  "conformance/cases/extractor-emits-signal.json": "0115c7bb62a7a705f72e9d8048b3f0396e5caaeb3d04dea204415e279e58479d",
188
189
  "conformance/cases/kernel-empty-boot.json": "9b51b85ff62479cd0eee37cad260245208d94f6d79644f7ee40945a934960913",
189
190
  "conformance/cases/no-global-scope.json": "1c83343422144be2ad9e3d27d2062e61af87c7c1c1f3b051b6b9f687d845ac7b",
190
191
  "conformance/cases/orphan-markdown-fallback.json": "506119323ddde85c1fb4c986c7f6f40a345d44adb06de8d84002591df0e479ee",
191
192
  "conformance/cases/plugin-missing-ui-rejected.json": "2074fd71937feae136c999f76da81f334f2caf8b65bfe8dc9d7fb800699fb85c",
193
+ "conformance/cases/score-phase-confidence.json": "aa3a06149d78ff056dd1a47852baaedc200e47b0d5b1e778d3459ae62f65f390",
192
194
  "conformance/cases/sidecar-end-to-end.json": "0a0d941ab50bd7619e1021a6c6d6dc92918429c2efcf25236b42b5fac9eab901",
193
- "conformance/cases/signal-collision-detection.json": "a71598327efc05c66b971bdcb5fc3af2816fa921b14b3f54284c783ac93354a8",
194
195
  "conformance/cases/view-action-button.json": "51331f725be1c3655351f8fca6fc9d3d301ae68ea1741ff6c79998332ba2dfeb",
195
196
  "conformance/cases/view-contribution-payloads.json": "e8f54ed62e64a2a0f86729866e507abb1f4246683f0e60d538280536f7cd3ecc",
196
197
  "conformance/cases/view-slots-all.json": "05284e0324dd2da72b6b21d397c11b355802229a68053e9dddc323f69b3a1eba",
197
- "conformance/coverage.md": "aed73083125e81bc7c03f7884ac0984c6d4e0494711889b0808661ffe11c358b",
198
+ "conformance/coverage.md": "63fed07a979fcc1ce0b3142a5f060d8ad049bef3430996f159a389e0989f3233",
198
199
  "conformance/fixtures/backtick-path/docs/target.md": "a09ae2cb4c96358a2e0692215f172b0f8c48028b6b123e4e83424b28302e644c",
199
200
  "conformance/fixtures/backtick-path/source.md": "217f78b12b3ff47a938a5cc9c1ff7d6989d6a1db82bd1ddf3656787f31efb902",
200
201
  "conformance/fixtures/orphan-markdown/.claude/agents/reviewer.md": "7f062731106f2d9811e4fffcf6ab44b8dfff4cfb16536a469514cc0664e832bf",
@@ -205,13 +206,17 @@
205
206
  "conformance/fixtures/plugin-missing-ui/.skill-map/plugins/bad-provider/providers/bad-provider/index.js": "df0d9f200e401b6b13115d6e97e5a00a78074229bd86c22ec5321accf5673c01",
206
207
  "conformance/fixtures/plugin-missing-ui/notes/example.md": "55767f0aa1b6774546a99f28c58e7b732aa9cfa5dfce8d0326470f7f622f577e",
207
208
  "conformance/fixtures/preamble-v1.txt": "1e0aeef224b64477bdc13a949c3ad402e68249caf499ecdba1302371677c068b",
209
+ "conformance/fixtures/score-phase-confidence/.skill-map/plugins/link-scorer/analyzers/demote/index.js": "713ea8ea9585e966fe07ccab281be426cbee08223d60af427ca808581787de60",
210
+ "conformance/fixtures/score-phase-confidence/.skill-map/plugins/link-scorer/plugin.json": "fef9b4cf51c2eb1cf849e024f39b2e03f0c89bec8e487a3af4ca8f9b83879a8a",
211
+ "conformance/fixtures/score-phase-confidence/source.md": "503aa9d0e624098864b208ec561b51e031333bdc1e7c683c9a2aa038c974038b",
212
+ "conformance/fixtures/score-phase-confidence/target.md": "1b6924ca00d8ebc17411e9f6cd9ee60a25b60bed9194167300a21a16bf323010",
208
213
  "conformance/fixtures/sidecar-end-to-end/.claude/agents/orphan.sm": "3102ff10a0f08f60c014f82409d45ad4faf2cefa04d652a87676d3557ad64944",
209
214
  "conformance/fixtures/sidecar-end-to-end/.claude/agents/stale.md": "cb3a95777cba530d47e6040c5601b6dcd34b5fc653dd69f183369eb6bdd956b5",
210
215
  "conformance/fixtures/sidecar-end-to-end/.claude/agents/stale.sm": "cb04f7f3103b4218b09fd4da92f7ea429588b04c1dac6a9547ce362263b11224",
211
216
  "conformance/fixtures/sidecar-example/agent-example.md": "741131403e8c9580d0b7a8c2446cb4502d01f80053b7a2092663de92431aaa82",
212
217
  "conformance/fixtures/sidecar-example/agent-example.sm": "fba04f4d81c6c3308316dca9bb5cdd72471052df577ed939c1c1915deeb5dd0f",
213
218
  "conformance/fixtures/signal-ir-collision/.claude/agents/api.md": "7bdd260d82c2bf1ffc3324820e1b806684674981f9234f7c9f4f6aa61dd1cec5",
214
- "conformance/fixtures/signal-ir-collision/.claude/agents/architect.md": "acc46b5b2dff73d98a354e4d53b5041164595deae466a4e2ce41d7c5a72f28fb",
219
+ "conformance/fixtures/signal-ir-collision/.claude/agents/architect.md": "14df915d4f11a7f41fd2e84600e77f21a596490b60da1b81ebd1ad555452f862",
215
220
  "conformance/fixtures/signal-ir-single-signal/source.md": "1eda417b4c6eed372b66870e385c8d8cd631372b77cab7e996bb711e22218f89",
216
221
  "conformance/fixtures/signal-ir-single-signal/target.md": "527137f2b4f46c0034b0edc8932cf8613d2bf22ffaaf78f01085c82a3baaebe3",
217
222
  "conformance/fixtures/view-action-button/.skill-map/plugins/badge-actions/analyzers/good-badges/index.js": "943fc3f11c328d2ccc4f8474106f4ae92077d353d02bd0207153efd1d0a1cf42",
@@ -224,33 +229,33 @@
224
229
  "conformance/fixtures/view-contribution-payloads/notes/example.md": "312b1919cd7fd0f233648b053acfb2975662ede3c65dd391cc508204b67ad6fb",
225
230
  "conformance/fixtures/view-slots-all/.skill-map/plugins/all-slots/analyzers/everything/index.js": "ea0022fec7f0fd5a26ba12db1310335f434f2f820682206a3a9542d98db0d346",
226
231
  "conformance/fixtures/view-slots-all/.skill-map/plugins/all-slots/plugin.json": "c48e8a0574947ade0b4eb189d6bc27a48e24f92f616aacdc177f2d22d472a599",
227
- "db-schema.md": "4f555d80f6d8d9b629cf14406be471a0039d0cb645b8169ae08c0166c7c1b1ff",
232
+ "db-schema.md": "d6af5d26626b692b05e96726d3bdaa77ec1b703bb30e83f752ffcca55e163fa4",
228
233
  "interfaces/security-scanner.md": "e8049712b9cf7a07c786bf19f8f775f8ef9638f063f7fba5c7a8b1431b92f38e",
229
- "job-events.md": "9d5b35d4c451a7f8eef9915d85316d924ac52f1c026a316cdda5f1099d496854",
234
+ "job-events.md": "d5dc11cda9e9302d0d9f17d467c14c80ad4011456f574dc87516470aff0323cb",
230
235
  "job-lifecycle.md": "9c429121f98a07c8795f8979ed1abc5e5334e3f89db51585a8da55c527ef855b",
231
- "plugin-author-guide.md": "7b50d52f39bb17f9b27fd9fabcd375b9ade08fc61b33e68d78fc01a85887a3ac",
236
+ "plugin-author-guide.md": "a6bca100e8963e74e0299eeaa15819d0734e9aee57af89f7f56707eb3c99dbb9",
232
237
  "plugin-kv-api.md": "1acc69ed82433a74e35ada61d63a6d7379fb61046ff83de1e0facbe884c64704",
233
238
  "prompt-preamble.md": "9dd4f6d1bc6a425f8782fcee10cbe75909e8d64e28781fda56c2fae909b02f40",
234
- "schemas/annotations.schema.json": "8c639b149cad675fdd4e7d6be2b47e920cfdd24087b41361d6e1b8280f646322",
239
+ "schemas/annotations.schema.json": "09fcebc86e3b793bf9f03a35b38e5ca2a08d79ac3504f6f03895ac2ae1c2aded",
235
240
  "schemas/api/rest-envelope.schema.json": "8eeb1c2d79fb69eaef23737a2231d48d67e59b8b19aad816239ab4680e2c4752",
236
241
  "schemas/bump-report.schema.json": "c763e1f89f2665c479d6a4985c1d324c65e5278331ebab82220287a07e4c4429",
237
242
  "schemas/conformance-case.schema.json": "958b316d646d0c64a715a7a28cee66d2c2d2498a60dbfc5ae8970687c2a96954",
238
243
  "schemas/conformance-result.schema.json": "14f983a8f4e62cd4ff964688c9b2b026a3bee3a0b762b64091c8c34db5b75777",
239
244
  "schemas/execution-record.schema.json": "db0eb16153493ad9f13ea0ecede44191e4a8536979adffd17ca278ddf8786c77",
240
245
  "schemas/extensions/action.schema.json": "d81661e4ba4e24bc339943b317446879699ccf370c10004f702ed61301e4bf94",
241
- "schemas/extensions/analyzer.schema.json": "8def4a5ca4934197c34abde97da70704b2751041a443c859eddd4b783e2fe1db",
246
+ "schemas/extensions/analyzer.schema.json": "5ab80cba46f4ca6ca78bd9484cc1b46d949d77142b5a8864dc09af5e406908e0",
242
247
  "schemas/extensions/base.schema.json": "ec4cef21bc5d493c4d60ae3208c5e15364b02176f5f32bb00bbd62e9578befdc",
243
248
  "schemas/extensions/extractor.schema.json": "ee44bf562b19318c93116c574a811857cdef1f4119326a9a604fa408889dd230",
244
249
  "schemas/extensions/formatter.schema.json": "880dc379ad545a62404403533a01eda5171edba0390561fc46ec6e986e0b9bd3",
245
250
  "schemas/extensions/hook.schema.json": "f56aef59e9986ffdf7d86aa2e048dccccf217000a358b8c64737cbd911c48dad",
246
251
  "schemas/extensions/provider-kind.schema.json": "499b2418bbe6d8a84a1608e26c56b52c2652a30ce314bc2989094418797dc1e6",
247
- "schemas/extensions/provider.schema.json": "bea1d73897dc8fa8499ba7c77ce535337473e5ecb3702ebca9966c08afc920f4",
252
+ "schemas/extensions/provider.schema.json": "30f1f001192b3ca9fc1e3aa383b23419f8d6c179d0239f54cb7f41910126a6bb",
248
253
  "schemas/frontmatter/base.schema.json": "cff81510ed94824dfd12ab8b30ce9fbac65e42d61ae0edf3fbb6bbb6bb8bcb8c",
249
254
  "schemas/history-stats.schema.json": "436aa0ffe744bdb699000447e86b45724fbd2cc4642781074eb1527522b9058c",
250
255
  "schemas/input-types.schema.json": "93b27a1cbd1f131d42730eb9a89cf3af6889e9f17b20a48ce36133885503e01b",
251
- "schemas/issue.schema.json": "d173aa5c5312b3d2a2cd249f55c10943c8f3cd5799e4645ae3c66316221e12d1",
256
+ "schemas/issue.schema.json": "840198acc36ed17f30957733dfaf4463d07d72911b13fb7f58b037a7dcf2d5b1",
252
257
  "schemas/job.schema.json": "dbcedf137de03fde38f74686f594e600c627bf808f2aad23511a26617a663a02",
253
- "schemas/link.schema.json": "02d9d8b2a1cdd1c6672e6b5821e08f09e43c298c8d602520a95decaffabfd1d3",
258
+ "schemas/link.schema.json": "e2f615bff0e569be936acd6ec249795906dfb56084f3a1a9a2a37fd51c0b00e2",
254
259
  "schemas/node.schema.json": "1ebba38e0c0ae022fccbc0cdf7c298da1720a68d4cb375f0baf9f0847998a0d8",
255
260
  "schemas/plugins-doctor.schema.json": "03e2dc51c052a09bf0198c80e2c26e6129734ada4a748e483245de3dd8576c42",
256
261
  "schemas/plugins-registry.schema.json": "211d081691fc83526e1593c79ed9741ad8a5dbd4db1a756f72141b0cced2ea15",
@@ -260,7 +265,7 @@
260
265
  "schemas/report-base.schema.json": "e4d25f055e24f18ae0f77c24661c1bddc87ff2e43b001b6a827fcb14f9753f44",
261
266
  "schemas/scan-result.schema.json": "9fb81f496d6f8bdcb82131d0b2eb532da1addb801e7d27bd192a0c286a28c2c0",
262
267
  "schemas/sidecar.schema.json": "f9d914e61b2d04495b84dc90e55240aca959e6f16137e5bfa4c0e10ada33ecbe",
263
- "schemas/signal.schema.json": "39dd0e6989a1141bf7769bbb26b3d750b6ebcd8e3215ebe50efd0ad30ccb46fc",
268
+ "schemas/signal.schema.json": "c677f04964dcc15e00368c5cc4b0569fb4cf21889d34fa3c29dc21a5cb6b919c",
264
269
  "schemas/summaries/agent.schema.json": "5b26b95fb082b73d302c8aa6489ab09488a155ccfbb8943dfc47079509d35122",
265
270
  "schemas/summaries/command.schema.json": "7f522c682d0fdf5a40172c7fc8fcd23e60a0ab0253354146525bd3a3d417f1f8",
266
271
  "schemas/summaries/hook.schema.json": "6a1ceecda7a7173dfcd8b5f705d84be1792c4bb5a2269ff666088128c02c888a",
package/job-events.md CHANGED
@@ -412,7 +412,7 @@ Emitted once per registered Analyzer, after every issue has been validated.
412
412
  "runId": "...",
413
413
  "jobId": null,
414
414
  "data": {
415
- "analyzerId": "core/node-superseded"
415
+ "analyzerId": "core/node-stability"
416
416
  }
417
417
  }
418
418
  ```
@@ -456,7 +456,7 @@ Emitted by the scan after `scan.completed` when the new scan's issue set differs
456
456
  "runId": "...",
457
457
  "jobId": null,
458
458
  "data": {
459
- "analyzerId": "trigger-collision",
459
+ "analyzerId": "name-collision",
460
460
  "severity": "warn",
461
461
  "nodeIds": ["skills/a.md", "skills/b.md"],
462
462
  "message": "..."
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skill-map/spec",
3
- "version": "0.50.0",
3
+ "version": "0.52.0",
4
4
  "description": "JSON Schemas, prose contracts, and conformance suite for the skill-map specification.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -126,13 +126,13 @@ Built-ins split between two namespaces:
126
126
 
127
127
  ### Extension id shape
128
128
 
129
- The convention applied to every built-in extension id is **`<domain>-<detail>`** (general to specific): the leftmost segment names the entity the extension reasons about (`node`, `link`, `annotation`, `reference`, `name`, ...), the rest narrows the behaviour. Examples: `annotation-orphan`, `link-counter`, `node-stability`, `name-reserved`, `reference-broken`. Even Actions live under their entity domain (`node-bump`, `node-supersede`) rather than verb-style ids, so the catalog reads as a structured list.
129
+ The convention applied to every built-in extension id is **`<domain>-<detail>`** (general to specific): the leftmost segment names the entity the extension reasons about (`node`, `link`, `annotation`, `reference`, `name`, ...), the rest narrows the behaviour. Examples: `annotation-orphan`, `link-counter`, `node-stability`, `name-reserved`, `reference-broken`. Even Actions live under their entity domain (`node-bump`, `node-set-tags`) rather than verb-style ids, so the catalog reads as a structured list.
130
130
 
131
131
  Authors are not required to follow this, but it makes `sm plugins list` self-grouping. In the extension file, declare only the short id-bearing **folder name**, not a prefixed id; the loader composes `<plugin-id>/<short-id>` from `plugin.json` (the directory name) and the extension folder. Any other cross-extension reference (`precondition.analyzerIds`, ...) uses the qualified id of the target.
132
132
 
133
133
  ### Toggle model
134
134
 
135
- Every extension is independently toggle-able by its qualified id `<plugin>/<ext-id>` (e.g. `claude/at-directive`, `core/node-superseded`). The **plugin row is a presentational grouping**, not the granular toggle target: the user sees a row per plugin in `sm plugins list` and the Settings UI, with each extension listed underneath with its own enabled / disabled state.
135
+ Every extension is independently toggle-able by its qualified id `<plugin>/<ext-id>` (e.g. `claude/at-directive`, `core/reference-broken`). The **plugin row is a presentational grouping**, not the granular toggle target: the user sees a row per plugin in `sm plugins list` and the Settings UI, with each extension listed underneath with its own enabled / disabled state.
136
136
 
137
137
  Two id shapes resolve at the toggle surface:
138
138
 
@@ -250,14 +250,14 @@ Pure single-node analysis. **Never** read another node, the graph, or the databa
250
250
 
251
251
  `extract(ctx) → void`. Output flows through callbacks the kernel binds onto `ctx`:
252
252
 
253
- - **`ctx.emitLink(link)`**, append a `Link`. The kernel validates `link.kind` against the **global closed enum** (`invokes`, `references`, `mentions`, `supersedes`, `points`); off-enum kinds drop as `extension.error`. Confidence is declared per emit (default `'medium'`). URL-shaped targets are partitioned into `node.externalRefsCount` and never persisted. (There is no per-extractor `emitsLinkKinds` allowlist anymore.)
253
+ - **`ctx.emitLink(link)`**, append a `Link`. The kernel validates `link.kind` against the **global closed enum** (`invokes`, `references`, `mentions`, `points`); off-enum kinds drop as `extension.error`. Confidence is declared per emit (default `'medium'`). URL-shaped targets are partitioned into `node.externalRefsCount` and never persisted. (There is no per-extractor `emitsLinkKinds` allowlist anymore.)
254
254
  - **`ctx.enrichNode(partial)`**, merge kernel-curated properties onto the node's enrichment layer (persisted into `node_enrichments`). **Strictly separate from the author frontmatter**, which is immutable from any Extractor. Use it for inferred facts (computed titles, summaries) the author did not write.
255
255
  - **`ctx.emitContribution(id, payload)`**, view contributions (see [View contributions](#view-contributions)).
256
256
  - **`ctx.store`**, plugin-scoped persistence, present only when `plugin.json` declares `storage.mode`. See [`plugin-kv-api.md`](./plugin-kv-api.md).
257
257
 
258
258
  You can read `ctx.node.sidecar.*` freely: the per-`(node, extractor)` cache hashes the sidecar `annotations` block alongside the body, so a `.sm`-only edit invalidates the cached run automatically.
259
259
 
260
- > **Pick a syntax that doesn't collide with built-ins.** `core/at-directive` claims `@`, `core/slash-command` claims `/`, both with LLM-aligned semantics (and both strip fenced code blocks + inline backticks before matching). `core/backtick-path` is the deliberate inverse: it matches relative `.md` paths ONLY inside those stripped code regions, so by construction it cannot overlap the prose-side extractors. A new extractor matching one of those prefixes will fire on the same input and, if it emits a different `target` shape, raises a `trigger-collision`. The example below uses a wikilink-style `[[ref:<name>]]` pattern to side-step this. See [`architecture.md` §Extractor · trigger normalization](./architecture.md#extractor--trigger-normalization) for the normalization pipeline.
260
+ > **Pick a syntax that doesn't collide with built-ins.** `core/at-directive` claims `@`, `core/slash-command` claims `/`, both with LLM-aligned semantics (and both strip fenced code blocks + inline backticks before matching). `core/backtick-path` is the deliberate inverse: it matches relative `.md` paths ONLY inside those stripped code regions, so by construction it cannot overlap the prose-side extractors. A new extractor matching one of those prefixes will fire on the same input and emit a competing link; when both resolve to the same node that surfaces as `reference-redundant` (`name-collision` is reserved for two nodes that declare the same resolvable `name`, not for overlapping invocation forms). The example below uses a wikilink-style `[[ref:<name>]]` pattern to side-step the overlap entirely. See [`architecture.md` §Extractor · trigger normalization](./architecture.md#extractor--trigger-normalization) for the normalization pipeline.
261
261
 
262
262
  ```javascript
263
263
  export default {
@@ -308,6 +308,41 @@ export default {
308
308
 
309
309
  > Until the job subsystem ships (Step 10), probabilistic analyzers are skipped silently by `sm scan`; `sm check --include-prob` loads them, lists them on stderr, and the `--async` companion is a reserved no-op.
310
310
 
311
+ ### Score-phase analyzers
312
+
313
+ An analyzer that declares `phase: 'score'` runs in the kernel's write-capable phase, BEFORE every read-only (`detect` / `aggregate`) analyzer. It is the only place a plugin may adjust link confidence. Declare the phase in the manifest and call `ctx.adjustConfidence(link, op)` from `evaluate` (the callback is present ONLY in the `score` phase; guard for `undefined` so the same code is inert if it ever runs outside it):
314
+
315
+ ```javascript
316
+ // analyzers/demote-mentions/index.js → phase: 'score'
317
+ export default {
318
+ version: '1.0.0',
319
+ description: 'Demotes low-signal mention edges by a fixed delta.',
320
+ phase: 'score',
321
+ evaluate(ctx) {
322
+ for (const link of ctx.links) {
323
+ if (link.kind === 'mentions') {
324
+ ctx.adjustConfidence?.(link, { kind: 'delta', value: -0.3 });
325
+ ctx.adjustConfidence?.(link, { kind: 'floor', value: 0.2 });
326
+ }
327
+ }
328
+ return []; // a scorer emits no issues; its output is the confidence ops
329
+ },
330
+ };
331
+ ```
332
+
333
+ The `op` is one of four kinds:
334
+
335
+ | `op.kind` | Effect | Direction |
336
+ |---|---|---|
337
+ | `set` | Hard override to `value`. | replaces |
338
+ | `delta` | Add `value` (may be negative). | additive |
339
+ | `floor` | Raise to at least `value`. | raises only |
340
+ | `ceil` | Lower to at most `value`. | lowers only |
341
+
342
+ `link` MUST be one of `ctx.links` (matched by object identity). The kernel seeds a **1.0 baseline** on every link, then **folds** every op contributed to that link (across all scorers) into the final `link.confidence`, deterministically and order-independently: from the 1.0 baseline it applies `set` (last in canonical order wins), then sums `delta`, then `floor` (raise), then `ceil` (cap), and clamps to `[0,1]` exactly once at the end (so a `-0.4` then `+0.4` round-trips to the base instead of clipping mid-fold). Across scorers the ops are sorted by `(pluginId, extensionId)`, so two scans always produce the same value and the same adjustment ordering. Each applied op is attributed to your plugin / extension and persisted to the `scan_link_scores` audit table (the "why is this link at X?" trail).
343
+
344
+ The kernel **dogfoods this exact API** through two built-in score-phase detectors, each co-locating its penalty `delta` with the finding it reports: `core/name-reserved` (reserved → `delta -0.9` → 0.1, alongside its warns) and `core/reference-broken` (broken → `delta -0.5` → 0.5, alongside its errors). A clean-resolved link keeps the 1.0 baseline (no built-in op). This is the pattern to copy: **detect, report, AND score in one `phase: 'score'` evaluate**, so disabling a rule drops both effects together (no report and no confidence move, the link falls back to the 1.0 baseline). Your scorer composes ON TOP of that baseline: it runs in the same phase, against the same links, and its ops fold together with the built-ins'. To subtract from a link, use a negative `delta`; to RAISE one, a positive `delta` or a `floor`; to never let your value exceed a ceiling, use `ceil`. See [`architecture.md` §Analyzer phases](./architecture.md#analyzer-phases) for the normative fold semantics.
345
+
311
346
  ### Formatters
312
347
 
313
348
  Graph-to-string serializers, invoked by `sm graph --format <name>`. The format **name** comes from the formatter's folder name; the manifest declares `contentType` (MIME hint). Output **MUST** be byte-deterministic for the same input graph (the snapshot suite relies on it). Spec at [`schemas/extensions/formatter.schema.json`](./schemas/extensions/formatter.schema.json).
@@ -379,7 +414,7 @@ Operate on one or more nodes. Dual-mode (`mode` optional, default `'deterministi
379
414
  An Action has two independent surfaces:
380
415
 
381
416
  - **`invoke(input, ctx)`**, the on-demand executor the user triggers (deterministic in-process code, or a probabilistic rendered prompt the runner executes). Unit-test deterministic ones by calling `invoke(input, ctx)` with a fake context; probabilistic ones still need a live kernel until Step 10 lands the job subsystem.
382
- - **`project(ctx)`** (optional), a deterministic, side-effect-free, scan-time method with read-only graph access (`ctx.nodes`, `ctx.links`) plus `ctx.emitContribution(nodePath, ref, payload)`. Use it to self-project the Action's own UI affordance, typically an `inspector.action.button` declared in the manifest `ui` map (see [View contributions](#view-contributions)), computing the per-node `enabled` / prompt `options` from the live graph. `project()` is always deterministic, even when `invoke` is probabilistic, and runs every scan (same cost as an analyzer's emit). This is how built-in buttons like Supersede / Edit tags / Bump are produced: the dispatching Action owns its button, there is no separate "projector" analyzer. Unit-test it by calling `project(ctx)` with a fake `{ nodes, links, emitContribution }` and asserting the captured payload.
417
+ - **`project(ctx)`** (optional), a deterministic, side-effect-free, scan-time method with read-only graph access (`ctx.nodes`, `ctx.links`) plus `ctx.emitContribution(nodePath, ref, payload)`. Use it to self-project the Action's own UI affordance, typically an `inspector.action.button` declared in the manifest `ui` map (see [View contributions](#view-contributions)), computing the per-node `enabled` / prompt `options` from the live graph. `project()` is always deterministic, even when `invoke` is probabilistic, and runs every scan (same cost as an analyzer's emit). This is how built-in buttons like Edit tags / Bump are produced: the dispatching Action owns its button, there is no separate "projector" analyzer. Unit-test it by calling `project(ctx)` with a fake `{ nodes, links, emitContribution }` and asserting the captured payload.
383
418
 
384
419
  ---
385
420
 
@@ -2,30 +2,20 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "https://skill-map.ai/spec/v0/annotations.schema.json",
4
4
  "title": "Annotations",
5
- "description": "Catalog of conventional annotation fields skill-map ships out of the box, written into the `annotations:` block of a sidecar (`<basename>.sm`). Every field is OPTIONAL, a sidecar with an empty `annotations: {}` is valid. Schema is `additionalProperties: true` so users / plugins can add custom keys without coordination; the built-in `unknown-field` analyzer emits a warning on unrecognized keys (typo guard). The curated catalog is the load-bearing 10 fields below, versioning + supersession (`version`, `stability`, `supersedes`, `supersededBy`), provenance (`authors`, `license`, `source`, `sourceVersion`), taxonomy (`tags`), docs (`docsUrl`). The activity timestamp lives in the reserved `audit:` block (`audit.lastBumpedAt`), not in `annotations:`. Plugins that want first-class custom keys with their own validation declare `annotationContributions` in their manifest (see Step 9.6.6).",
5
+ "description": "Catalog of conventional annotation fields skill-map ships out of the box, written into the `annotations:` block of a sidecar (`<basename>.sm`). Every field is OPTIONAL, a sidecar with an empty `annotations: {}` is valid. Schema is `additionalProperties: true` so users / plugins can add custom keys without coordination; the built-in `unknown-field` analyzer emits a warning on unrecognized keys (typo guard). The curated catalog is the load-bearing fields below, versioning (`version`, `stability`), provenance (`authors`, `license`, `source`, `sourceVersion`), taxonomy (`tags`), docs (`docsUrl`). The activity timestamp lives in the reserved `audit:` block (`audit.lastBumpedAt`), not in `annotations:`. Plugins that want first-class custom keys with their own validation declare `annotationContributions` in their manifest (see Step 9.6.6).",
6
6
  "type": "object",
7
7
  "additionalProperties": true,
8
8
  "properties": {
9
9
  "version": {
10
10
  "type": "integer",
11
11
  "minimum": 1,
12
- "description": "Monotonic counter. Bumped via the built-in `bump` Action when the underlying node changes meaningfully. Orthogonal to `stability`, `stability` carries the lifecycle stage; `version` is just a counter. There is no major: a change so big it would justify a major bump uses the convention `create a new node, supersede the old one` instead. Default: missing == unversioned."
12
+ "description": "Monotonic counter. Bumped via the built-in `bump` Action when the underlying node changes meaningfully. Orthogonal to `stability`, `stability` carries the lifecycle stage; `version` is just a counter. There is no major: a change so big it would justify a major bump uses the convention `create a new node and retire the old one` instead. Default: missing == unversioned."
13
13
  },
14
14
  "stability": {
15
15
  "type": "string",
16
16
  "enum": ["experimental", "stable", "deprecated"],
17
17
  "description": "Lifecycle stage. Denormalized into `scan_nodes.stability` for fast queries (see `node.schema.json` #/properties/stability). Default: missing == unspecified."
18
18
  },
19
- "supersedes": {
20
- "type": "array",
21
- "items": { "type": "string", "minLength": 1 },
22
- "description": "Paths (relative to scope root) of nodes this node replaces. Consumed by the built-in `superseded` analyzer and surfaces in `sm list --superseded`."
23
- },
24
- "supersededBy": {
25
- "type": "string",
26
- "minLength": 1,
27
- "description": "Path (relative to scope root) of the node that replaces this one. When set, the current node is end-of-life and consumers should migrate."
28
- },
29
19
  "authors": {
30
20
  "type": "array",
31
21
  "items": { "type": "string", "minLength": 1 },
@@ -16,6 +16,12 @@
16
16
  "default": "deterministic",
17
17
  "description": "`deterministic` (default): pure code, byte-for-byte reproducible, runs during `sm check` and `sm scan`. `probabilistic`: invokes an LLM via `ctx.runner` and runs only as a queued job; never participates in scan-time pipelines. The kernel rejects probabilistic analyzers that try to register scan-time hooks at load time."
18
18
  },
19
+ "phase": {
20
+ "type": "string",
21
+ "enum": ["score", "detect", "aggregate"],
22
+ "default": "detect",
23
+ "description": "Execution phase the orchestrator schedules this analyzer in. `score` runs FIRST and is the ONLY phase permitted to WRITE: it adjusts link confidence via `ctx.adjustConfidence(link, op)`, the orchestrator folds every score-phase op into `link.confidence` (clamped to [0,1], deterministic) before the read-only phases run. `detect` (default) is the main read-only pass that walks the merged graph and emits `Issue[]`. `aggregate` runs LAST and reads `ctx.accumulatedIssues` so an analyzer can compute cross-analyzer summaries (per-node severity totals, etc.) without re-reading the DB. The kernel seeds a 1.0 confidence baseline on every link and dogfoods the `score` phase through two built-in detectors (`core/name-reserved`: reserved -> delta -0.9 -> 0.1; `core/reference-broken`: broken -> delta -0.5 -> 0.5); a clean resolved link keeps the 1.0 baseline. Writing confidence is NOT a filesystem write: the consent-gate / no-write invariant for Analyzers still holds (see `architecture.md` §Analyzer phases)."
24
+ },
19
25
  "precondition": {
20
26
  "type": "object",
21
27
  "additionalProperties": false,
@@ -127,7 +127,7 @@
127
127
  "description": "When present, the resolver ranks candidates whose `kind` appears earlier in this array ABOVE candidates whose `kind` appears later. Candidates whose `kind` is absent from the array drop to the end (after every listed kind). Example: a Provider that wants `invokes` edges to win against `mentions` and `references` of the same range declares `['invokes', 'references', 'mentions']`. Ties inside the same `kindPriority` bucket fall through to the confidence -> range length -> declaration order tiebreaks.",
128
128
  "items": {
129
129
  "type": "string",
130
- "enum": ["invokes", "references", "mentions", "supersedes", "points"]
130
+ "enum": ["invokes", "references", "mentions", "points"]
131
131
  }
132
132
  }
133
133
  }
@@ -10,7 +10,7 @@
10
10
  "analyzerId": {
11
11
  "type": "string",
12
12
  "pattern": "^[a-z][a-z0-9]*(-[a-z0-9]+)*$",
13
- "description": "Kebab-case identifier of the analyzer that emitted this issue (e.g. `trigger-collision`, `broken-ref`, `superseded`)."
13
+ "description": "Kebab-case identifier of the analyzer that emitted this issue (e.g. `name-collision`, `broken-ref`, `node-stability`)."
14
14
  },
15
15
  "severity": {
16
16
  "type": "string",
@@ -19,7 +19,7 @@
19
19
  },
20
20
  "nodeIds": {
21
21
  "type": "array",
22
- "description": "`node.path` values involved in this issue. Most analyzers emit 1 or 2; trigger-collision may emit N. Field name uses `id` generically to remain stable across future identifier changes.",
22
+ "description": "`node.path` values involved in this issue. Most analyzers emit 1 or 2; name-collision may emit N. Field name uses `id` generically to remain stable across future identifier changes.",
23
23
  "minItems": 1,
24
24
  "items": { "type": "string" }
25
25
  },
@@ -30,7 +30,7 @@
30
30
  },
31
31
  "message": {
32
32
  "type": "string",
33
- "description": "Short one-line description, human-readable."
33
+ "description": "Human-readable finding in the canonical shape: an optional backtick-quoted subject line (the offending token / path / name), then the diagnosis `L<line>: <what>; <why>`. The `L<line>: ` location prefix (`L2: ` / `L2, 5: `) appears only when the finding maps to body line(s); it is omitted for sidecar / frontmatter / abstract checks (and the subject line is omitted when there is no single offending token). The body reads `<what>; <why>` (what was detected, then why it matters), in English. Remediation hints do NOT belong here, they go in `fix.summary`. Built-in analyzers assemble this via the shared `formatFinding` helper. Multi-line via `\\n`."
34
34
  },
35
35
  "detail": {
36
36
  "type": ["string", "null"],
@@ -41,7 +41,7 @@
41
41
  "description": "Machine-readable fix hint. Stability: experimental, shape may change before v1.",
42
42
  "additionalProperties": false,
43
43
  "properties": {
44
- "summary": { "type": "string" },
44
+ "summary": { "type": "string", "description": "Human-readable remediation hint (e.g. `Rename the file or its frontmatter.name`). This is the home for the actionable advice that used to be appended to `message`." },
45
45
  "autofixable": { "type": "boolean" }
46
46
  }
47
47
  },
@@ -17,8 +17,8 @@
17
17
  },
18
18
  "kind": {
19
19
  "type": "string",
20
- "enum": ["invokes", "references", "mentions", "supersedes", "points"],
21
- "description": "Nature of the relation. `invokes` = execution-level call (e.g. slash command). `references` = explicit link (e.g. wikilink, @-directive). `mentions` = informal textual mention. `supersedes` = replaces another node (from `metadata.supersedes`). `points` = relative file path written inside a code region (backtick span / fenced block); coexists with `references` on the same `(source, target)` pair as a separate Link row (no merge, and `core/link-conflict` does not treat the pair as a conflict)."
20
+ "enum": ["invokes", "references", "mentions", "points"],
21
+ "description": "Nature of the relation. `invokes` = execution-level call (e.g. slash command). `references` = explicit link (e.g. wikilink, @-directive). `mentions` = informal textual mention. `points` = relative file path written inside a code region (backtick span / fenced block); coexists with `references` on the same `(source, target)` pair as a separate Link row (no merge, and `core/link-kind-conflict` does not treat the pair as a conflict)."
22
22
  },
23
23
  "confidence": {
24
24
  "type": "number",
@@ -56,7 +56,7 @@
56
56
  },
57
57
  "kind": {
58
58
  "type": "string",
59
- "enum": ["invokes", "references", "mentions", "supersedes", "points"],
59
+ "enum": ["invokes", "references", "mentions", "points"],
60
60
  "description": "Proposed link kind, matching `link.schema.json#/properties/kind/enum`. Closed enum in v1; provider-specific kinds wait until a concrete need emerges."
61
61
  },
62
62
  "target": {
@@ -88,7 +88,7 @@
88
88
  },
89
89
  "resolution": {
90
90
  "type": "object",
91
- "description": "Resolver outcome annotation, populated by the kernel resolver phase after `resolveSignals` runs. Absent before the resolver fires (raw extractor output). When `outcome` is `materialised`, `winnerIndex` points into `candidates[]` and a corresponding `Link` was emitted. When `outcome` is `rejected`, one of `rejectedBy` / `extractorDisabled` / `belowFloor` carries the reason. Both materialised and rejected Signals remain accessible to analyzers via `IAnalyzerContext.signals` so the `core/signal-collision` analyzer can surface losers as `warn` issues. Phase 4+ adds the `extractorDisabled` and `belowFloor` paths; today only the `rejectedBy` / range-overlap path populates rejection state.",
91
+ "description": "Resolver outcome annotation, populated by the kernel resolver phase after `resolveSignals` runs. Absent before the resolver fires (raw extractor output). When `outcome` is `materialised`, `winnerIndex` points into `candidates[]` and a corresponding `Link` was emitted. When `outcome` is `rejected`, `rejectedBy` carries the reason (a cross-extractor range-overlap collision). Both materialised and rejected Signals remain accessible to analyzers via `IAnalyzerContext.signals` so the `core/extractor-collision` analyzer can surface losers as `warn` issues.",
92
92
  "required": ["outcome"],
93
93
  "additionalProperties": false,
94
94
  "properties": {
@@ -132,28 +132,6 @@
132
132
  "description": "Which tiebreak rule decided the winner. The four rules apply in this order: 1) `kind-priority` (provider `resolverRules.kindPriority`), 2) `higher-confidence` (numeric confidence DESC), 3) `longer-range` (`end - start` DESC), 4) `earlier-declaration` (extractor registration order)."
133
133
  }
134
134
  }
135
- },
136
- "extractorDisabled": {
137
- "type": "object",
138
- "description": "Reserved for Phase 4+: populated when every candidate of this Signal came from an extractor the operator has disabled. The config surface that toggles individual extensions is not defined yet (the earlier `plugins.<id>.extensions.<extId>.enabled` placeholder was removed); it will be specified when the filter lands. Today the resolver never sets this; the field is documented so the analyzer / UI surface can be built once the filter lands.",
139
- "required": ["extractorId"],
140
- "additionalProperties": false,
141
- "properties": {
142
- "extractorId": { "type": "string" }
143
- }
144
- },
145
- "belowFloor": {
146
- "type": "object",
147
- "description": "Reserved for Phase 4+: populated when every candidate's `confidence` fell below the configured floor. Today the resolver materialises every Signal that survives overlap, regardless of confidence; the field is documented so the analyzer / UI surface can be built once the floor lands.",
148
- "required": ["threshold"],
149
- "additionalProperties": false,
150
- "properties": {
151
- "threshold": {
152
- "type": "number",
153
- "minimum": 0,
154
- "maximum": 1
155
- }
156
- }
157
135
  }
158
136
  }
159
137
  }