@skill-map/spec 0.48.0 → 0.50.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,65 @@
1
1
  # Spec changelog
2
2
 
3
+ ## 0.50.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Plugin extensions declare operator-configurable `settings` in their manifest, read at scan time via `ctx.settings` and resolved through the config layers under `plugins.<id>.extensions.<extId>.settings`. The `sm plugins config <plugin>/<ext>` verb, `GET`/`PATCH /api/plugins`, and per-plugin sections in Settings all read and write them; `secret` values route to the gitignored project-local file (no encryption). Adds a `number` (decimal) input-type to the catalog.
8
+
9
+ ## User-facing
10
+
11
+ Plugin extensions can expose options: edit them per plugin in Settings (one global Apply) or via `sm plugins config <plugin>/<ext>` (saved in `.skill-map/settings.json`; secrets stay local, never committed). Run `sm scan` to apply. New decimal `number` option type.
12
+
13
+ ## 0.49.0
14
+
15
+ ### Minor Changes
16
+
17
+ - Inspector action buttons are now self-projected by the dispatching Action instead of a sibling projector Analyzer: an Action may declare a `ui` button plus an optional deterministic scan-time `project(ctx)` (read-only graph) that emits its own `inspector.action.button` per node. The pure projector analyzers `core/supersede` and `core/tags` were removed and `core/annotation-stale` trimmed to its badge + issue (the Bump button moved to `core/node-bump`).
18
+
19
+ ## User-facing
20
+
21
+ No change to how the inspector behaves: the Supersede, Edit tags, and Bump buttons look and work exactly as before, they are just now produced by the action they trigger rather than a separate analyzer.
22
+
23
+ - Extensions declaring `stability: 'deprecated'` now also ship DISABLED by default, joining `experimental` in the ships-disabled set: a deprecated extension does not run or register until the operator opts in (`sm plugins enable <plugin>/<ext>`, the Settings toggle, or a `settings.json` / `config_plugins` override), the same opt-in `experimental` uses. `beta` / `stable` keep running. No built-in is deprecated today, so the default scan is unchanged until one is marked.
24
+
25
+ ## User-facing
26
+
27
+ Deprecated plugin extensions now start **disabled**, like experimental ones: they show an off toggle (with the deprecated badge) in Settings and `sm plugins list`, and don't run until you enable them. Enabling one keeps it working while you migrate off it.
28
+
29
+ - Extensions declaring `stability: 'experimental'` now ship DISABLED by default: their installed default flips from enabled to disabled, so the extension does not run or register until the operator opts in (`sm plugins enable <plugin>/<ext>`, the Settings toggle, or a `settings.json` / `config_plugins` override). `beta` / `deprecated` / `stable` keep running. Built-ins flipped to experimental: `core/mcp-tools` and the Supersede declarer (`core/supersede` button + `core/node-supersede` action).
30
+
31
+ ## User-facing
32
+
33
+ Experimental plugin extensions now start **disabled**: an off toggle (with the experimental badge) in Settings and `sm plugins list`, not running until you enable them. The MCP tools extractor and the Supersede button are experimental, so both are off until you turn them on.
34
+
35
+ - The scan now captures each file's modification time (`mtime`) from the walker's existing `lstat`, persisted on `scan_nodes.modified_at_ms` and surfaced on the node wire shape as `modifiedAtMs` (nullable for virtual / derived nodes). The files table gains a sortable "Modified" column at the end, rendered as an ISO short date with a full date+time tooltip; sorting orders by the raw timestamp and sinks fileless nodes to the bottom. The value never participates in `bodyHash` / `frontmatterHash`.
36
+
37
+ ## User-facing
38
+
39
+ The files table has a new **Modified** column showing when each file was last edited (for example `2026-06-13`). Click the header to sort newest or oldest first, and hover a cell to see the exact date and time.
40
+
41
+ - `sm plugins show` is now extension-only: it takes a qualified `<plugin>/<ext>` id and renders one extension's detail. The whole-plugin view (manifest plus extension rows) moves to `sm plugins list <id>`, and the top-level `sm plugins list` index drops the per-extension name sub-lines. A bare `show <plugin>` id and a qualified `list <plugin>/<ext>` id are each rejected with a directed redirect to the other verb.
42
+
43
+ ## User-facing
44
+
45
+ **Plugin commands split by altitude.** `sm plugins list <id>` now shows a whole plugin's extensions (kinds, versions, status); `sm plugins show` is for a single `<plugin>/<ext>` extension. The plain `sm plugins list` stays a clean index, one row per plugin.
46
+
47
+ ### Patch Changes
48
+
49
+ - `core/backtick-path` now matches bare `.md` filenames inside code spans, not only slashed paths: a backticked `` `algo4.md` `` becomes a `points` edge the way the runtime follows it. The `/` separator is now optional, with the first path segment anchored to a word char so globs and placeholders (`{PROJECT}-x.md`, `*-S.md`) stay rejected. Slashless names like `SKILL.md` match too; a self-reference becomes a self-loop, other misses flag via `core/reference-broken`.
50
+
51
+ ## User-facing
52
+
53
+ Backticked filenames now become links even without a folder: writing `` `algo4.md` `` inside code formatting (not just `` `docs/algo4.md` ``) draws an arrow to that file in the graph, matching how an agent actually follows the reference.
54
+
55
+ - Broken graph edges now render fainter than resolved ones. `core/markdown-link` emits the spec's `0.95` (unambiguous syntax) instead of a hardcoded `1.0`, and the post-walk confidence-lift transform adds a `BROKEN_TARGET_CONFIDENCE = 0.5` downgrade for links that resolve to nothing (no path and no name-index match, like `core/reference-broken`). A dangling `[x](missing.md)`, `@missing.md`, or `/no-such-command` now sits at `0.5`, below a resolved `1.0` and above a reserved `0.1`.
56
+
57
+ ## User-facing
58
+
59
+ Broken links in the graph now appear fainter than working ones: a markdown link, `@file`, or `/command` pointing at something that does not exist renders at low opacity, so dangling references stand out at a glance instead of looking like solid edges.
60
+
61
+ - The post-walk confidence-lift transform no longer bumps a link to `1.0` when its resolved target is a `virtual: true` node (today only `core/mcp-tools`' `mcp://<server>` nodes, reconstructed from frontmatter, never verified on disk). The edge still resolves (`resolvedTarget` set, navigable) but keeps its extractor emit confidence, so an MCP edge stays `0.85`: an unverified entity is not full certainty, like the reserved-target downgrade.
62
+
3
63
  ## 0.48.0
4
64
 
5
65
  ### Minor Changes
package/architecture.md CHANGED
@@ -259,7 +259,7 @@ Six kinds, all first-class, all loaded through the same registry. Each kind has
259
259
  | **Provider** | Recognizes a platform. The kind catalog lives on disk under `<plugin>/kinds/<kindName>/{schema.json, kind.json}` (structure-as-truth); the loader projects it onto the runtime descriptor. The Provider's walker hardcodes the paths it scans within the project (e.g. `.claude/`, `.codex/`); it does NOT extend the scan into the user's HOME. `Provider.roots` is enforcement-grade: a Provider with declared roots only sees matching files; a Provider without `roots` acts as the fallback. Deterministic-only. | Filesystem walk results, candidate path. | `{ kind, provider } \| null`. |
260
260
  | **Extractor** | Extracts signals from a node body. Deterministic-only: runs synchronously inside `sm scan`. Output flows through context callbacks (no return value): `ctx.emitLink(link)` for the kernel's `links` table (validated against the global closed enum of link kinds; per-extractor allowlist was retired with the structure-as-truth refactor), `ctx.enrichNode(partial)` for the kernel's enrichment layer (separate from the author's frontmatter), `ctx.emitContribution(id, payload)` for view contributions, `ctx.store` for the plugin's own KV / dedicated tables. | Parsed node (frontmatter + body) + callbacks. | `void` (output via callbacks). |
261
261
  | **Analyzer** | Evaluates the graph. Dual-mode: `deterministic` runs in `sm check`, `probabilistic` runs in jobs. The analyzer↔action relationship is declared from the Action side via `precondition.analyzerIds` (Modelo B). | Full graph (nodes + links). | `Issue[]`. |
262
- | **Action** | Operates on one or more nodes. Dual-mode: `deterministic` (in-process code) or `probabilistic` (rendered prompt the runner executes). Files-by-convention: every Action carries `<action-dir>/report.schema.json`; probabilistic Actions additionally carry `<action-dir>/prompt.md`. The retired `reportSchemaRef` / `promptTemplateRef` / `expectedTools` / `fanOutPolicy` manifest fields were replaced by these conventions and the simplified `precondition` block. | Node(s), optional args. | Deterministic: report JSON. Probabilistic: rendered prompt that a runner executes. |
262
+ | **Action** | Operates on one or more nodes. Two independent surfaces: **`invoke(input, ctx)`** is the on-demand executor (deterministic in-process code, or a probabilistic rendered prompt the runner executes); **`project(ctx)`** is an OPTIONAL, deterministic, side-effect-free scan-time method that runs in the contribution phase with read-only graph access (`ctx.nodes` / `ctx.links`) and emits the Action's OWN view contributions via `ctx.emitContribution(...)` (e.g. its `inspector.action.button`). `project()` is always deterministic even when `invoke` is probabilistic. Files-by-convention: every Action carries `<action-dir>/report.schema.json`; probabilistic Actions additionally carry `<action-dir>/prompt.md`. The retired `reportSchemaRef` / `promptTemplateRef` / `expectedTools` / `fanOutPolicy` manifest fields were replaced by these conventions and the simplified `precondition` block. | `project`: full graph. `invoke`: node(s) + optional args. | `project`: `void` (contributions via callback). `invoke`: deterministic report JSON or probabilistic rendered prompt. |
263
263
  | **Formatter** | Serializes the graph. Deterministic-only. The `formatId` consumed by `sm graph --format <name>` comes from the formatter's folder name. | Graph + optional filter. | String (ASCII / Mermaid / DOT / JSON / user-defined). |
264
264
  | **Hook** | Reacts declaratively to one of ten curated lifecycle events, eight pipeline-driven (`scan.started`, `scan.completed`, `extractor.completed`, `analyzer.completed`, `action.completed`, `job.spawning`, `job.completed`, `job.failed`) plus two CLI-process-driven (`boot` before verb routing, `shutdown` after the verb's exit code resolves). **Deterministic-only** since the structure-as-truth refactor: LLM-dependent reactions are modeled as a deterministic Hook that enqueues a probabilistic Action via `ctx.queue('<plugin>/<action>', payload)`. Hooks REACT to events; they cannot block, mutate, or steer the pipeline. | A curated event payload (run-scoped, scan-scoped, job-scoped, or process-scoped) plus an optional declarative `filter` map. | `void` (reactions are side effects). |
265
265
 
@@ -335,9 +335,13 @@ Each Provider MAY declare an optional `resolution: Record<linkKind, targetKind[]
335
335
 
336
336
  The transform runs after `dedupeLinks` and before the analyzer pipeline. For each link below `confidence: 1.0`:
337
337
 
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 and `core/mcp-tools` synthetic edges.
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.
339
339
 
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`. Otherwise the link stays at its extractor-emitted confidence.
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`.
341
+
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.
343
+
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.
341
345
 
342
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.
343
347
 
@@ -399,7 +403,7 @@ Every body extractor strips fenced code blocks and inline code spans before matc
399
403
  The contract:
400
404
 
401
405
  - **Domain**: the extractor matches exclusively inside fenced code blocks and inline code spans, over the *inverse mask* of the code-strip transform: same-length text where code-region characters survive and everything else is blanked. Same-length masking keeps byte offsets and line numbers valid against the original body.
402
- - **Token grammar** (pinned; implementations MUST match it exactly): `/(?<![\w/:.-])(?:\.{1,2}\/)?[\w.-]+(?:\/[\w.-]+)+\.md\b(?![\w/])/g`. In words: an optional `./` or `../` prefix, at least one `/` separator, a `.md` suffix at a word boundary. The character classes and guards 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}`, `*` are outside the segment class), near-miss suffixes (`.mdx`, `.md_var`), slashless filenames (`SKILL.md`), and absolute paths (a leading `/` fails the lookbehind).
406
+ - **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.
403
407
  - **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.
404
408
  - **Resolution**: identical to `core/markdown-link`, POSIX-normalised against `dirname(node.path)`. Per-node dedup on the resolved target, first occurrence wins.
405
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).
@@ -629,6 +633,10 @@ One locality class constrains which layers a given key MAY live in. It is enforc
629
633
 
630
634
  Adding a new entry is a behaviour change for older installs that wrote the key into a committed file, the value gets stripped at read time. The changeset that adds the entry MUST document the migration.
631
635
 
636
+ ### Extension settings resolution
637
+
638
+ Plugin extensions declare user-configurable `settings` in their manifest (per-extension, see `plugin-author-guide.md` §Settings); the operator's values for them live in the config tree under `plugins.<pluginId>.extensions.<extId>.settings.<settingId>` and therefore flow through the same four-layer merge as any other key. The kernel's settings resolver runs once per scan, while composing the enabled extensions: for each declared setting it takes the manifest `default`, overlays the merged config value, and validates the result against the input-type's value schema (`input-types.schema.json#/$defs/ISettingDeclaration`); a value that fails validation falls back to the default with a warning, so the scan never aborts on a bad setting. The resolved object reaches the extension's runtime methods as `ctx.settings.<settingId>`. The `project-config.schema.json` keeps the `settings` object permissive (`additionalProperties: true`) on purpose: the static schema cannot know which input-type a given `settingId` picked, so per-value validation is the resolver's responsibility, not AJV's. `secret`-typed settings are still config-layer values, but the kernel forces them into the project-local layer (`settings.local.json`, gitignored), never the committed `settings.json`, the dynamic equivalent of `PROJECT_LOCAL_ONLY_KEYS` (the destination follows the declared type, not a fixed key list). There is no encryption in v1: the protection is that the value never travels via the shared repo (see `input-types.schema.json#/$defs/Setting_Secret`).
639
+
632
640
  ---
633
641
 
634
642
  ## Annotation system
@@ -847,7 +855,7 @@ Endpoints under `/api/contributions/*`:
847
855
  - `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)).
848
856
  - `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.
849
857
 
850
- The `inspector.action.button` slot dispatches to a generic Action endpoint, sibling of the single-node `POST /api/sidecar/bump`:
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`:
851
859
 
852
860
  - `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.
853
861
 
package/cli-contract.md CHANGED
@@ -539,10 +539,11 @@ Authentication: the nonce is the sole credential. An implementation MUST reject
539
539
 
540
540
  | Command | Purpose |
541
541
  |---|---|
542
- | `sm plugins list` | Auto-discovered plugins with status. `--json` emits an array of `DiscoveredPlugin`. |
543
- | `sm plugins show <id>` | Full manifest + compat detail. |
542
+ | `sm plugins list [<id>]` | No id: auto-discovered plugins with status, one row per plugin (`--json` emits the aggregate discovered-plugin registry). With a bare plugin id: that plugin's manifest plus its extension detail (kind / version / per-extension status; `--json` emits the single `DiscoveredPlugin`). A qualified `<plugin>/<ext>` id is rejected with a redirect to `sm plugins show`. |
543
+ | `sm plugins show <plugin>/<ext>` | Single-extension detail (Kind / Version / Stability / Description / Preconditions / Entry; `--json` emits the single extension object). Accepts only a qualified `<plugin>/<ext>` id; a bare plugin id is rejected with a redirect to `sm plugins list <id>`. |
544
544
  | `sm plugins enable <id>... \| --all` | Toggle on. Persists in `config_plugins`. Accepts one or more ids; batches are all-or-nothing (any unknown / mismatched id aborts before any write) and repeated ids are deduped. `--all` applies to every discovered plugin. |
545
545
  | `sm plugins disable <id>... \| --all` | Toggle off; does not delete the plugin directory. Eagerly purges each id's rows from `scan_contributions` so its UI chips disappear before the next scan (plugin-managed state in `state_plugin_kvs` / dedicated tables is preserved, see `plugin-kv-api.md`). Accepts one or more ids; batches are all-or-nothing and repeated ids are deduped. `--all` applies to every discovered plugin. |
546
+ | `sm plugins config <plugin>/<ext> [<settingId> [<value>]] [--reset]` | Read or write the operator-supplied values for an extension's declared `settings`. No `settingId`: table of each declared setting with its effective value and the layer that set it (`--json` emits the resolved set). With `<settingId> <value>`: coerce the shell string to the setting's input-type, validate, then write it under `plugins.<pluginId>.extensions.<extId>.settings.<settingId>` (a normal setting lands in `settings.json`; a `secret`-typed one is forced into `settings.local.json`, gitignored, never committed); prints a "re-scan to apply" reminder. `--reset` drops the override back to the manifest default. Requires a qualified `<plugin>/<ext>` id; a bare plugin id is rejected with a redirect to `sm plugins list <id>`. `secret` values are redacted as `<redacted>` in output. |
546
547
  | `sm plugins doctor` | Revalidate all plugins against current spec version; update `status` fields. `--json` emits the report shape declared by [`plugins-doctor.schema.json`](./schemas/plugins-doctor.schema.json): `{ ok: true, kind: 'plugins.doctor', counts, issues[], warnings[], elapsedMs }`. |
547
548
 
548
549
  ---
@@ -606,7 +607,7 @@ The reference implementation ships a Hono BFF rooted at `src/server/`. One Node
606
607
  | `GET /api/issues?severity=&analyzerId=&node=` | implemented | `RestEnvelope` (`kind: 'issues'`), list of issues. Filters: `severity` (CSV from `error\|warn\|info`), `analyzerId` (CSV; qualified or short suffix per `sm check --analyzers`), `node` (filter to issues whose `nodeIds` includes the path). No pagination at v14.2. |
607
608
  | `GET /api/graph?format=ascii\|json\|md` | implemented | formatter-rendered graph. `Content-Type` per format: `text/plain` (ascii), `application/json` (json), `text/markdown` (md / mermaid). Default `format=ascii`. Unknown format → 400 `bad-query`. |
608
609
  | `GET /api/config` | implemented | `RestEnvelope` (`kind: 'config'`), merged effective config (defaults → user → user-local → project → project-local → override). |
609
- | `GET /api/plugins` | implemented | `RestEnvelope` (`kind: 'plugins'`), list of installed plugins (built-in + drop-in) with status. Item shape: `{ id, version, kinds, status, reason, source: 'built-in'\|'project', description?: string, locked?: boolean, startsAsDisabled?: boolean, extensions?: Array<{ id, kind, version, enabled, description?: string, stability?: 'experimental'\|'beta'\|'stable'\|'deprecated', locked?: boolean }> }`. The plugin row itself has no granular toggle axis; the row's `status` aggregates the children (`'enabled'` when at least one extension is enabled, `'disabled'` otherwise). The `description` field on the plugin item carries the manifest-declared description (built-ins: hardcoded on `IBuiltInPlugin`; drop-ins: `plugin.json#/description`); each `extensions[]` entry carries its extension manifest's `description` per `IExtensionBase` (`extensions/base.schema.json#/properties/description`), plus the optional `stability` lifecycle label per `extensions/base.schema.json#/properties/stability` (omitted when the manifest does not declare it; missing means `stable`. The SPA badges only the non-default values, `experimental` / `beta` / `deprecated`, next to the extension row; `stable` renders nothing. Presentation-only). The SPA's Settings list renders the descriptions as muted secondary text and includes them in its substring-search index alongside the ids. The `extensions` array is present whenever the plugin declares any extension AND the plugin loaded successfully. Each entry's `enabled` reflects the per-extension override resolution (DB > settings.json > installed default). The optional `locked: true` flag is stamped when the plugin id (or qualified extension id) appears in the host's lock-list (`src/server/locked-plugins.ts`); locked items render the toggle disabled in the SPA and any `PATCH` against them returns `403 locked`. The flag is omitted when false. The optional `startsAsDisabled: true` flag is stamped on drop-in plugins (never built-ins) whose discovery-time `status` was `'disabled'`, that is, every extension of the plugin was disabled in `config_plugins` / `settings.json` at `sm serve` boot, so the handlers were never bucketed into the runtime. The SPA renders a per-row hint when this flag is set AND the user re-enables at least one of the plugin's extensions in the buffered state, since re-enabling them requires `sm serve` restart (the rest of the toggle pipeline applies live). The flag is omitted when false. |
610
+ | `GET /api/plugins` | implemented | `RestEnvelope` (`kind: 'plugins'`), list of installed plugins (built-in + drop-in) with status. Item shape: `{ id, version, kinds, status, reason, source: 'built-in'\|'project', description?: string, locked?: boolean, startsAsDisabled?: boolean, extensions?: Array<{ id, kind, version, enabled, description?: string, stability?: 'experimental'\|'beta'\|'stable'\|'deprecated', locked?: boolean }> }`. The plugin row itself has no granular toggle axis; the row's `status` aggregates the children (`'enabled'` when at least one extension is enabled, `'disabled'` otherwise). The `description` field on the plugin item carries the manifest-declared description (built-ins: hardcoded on `IBuiltInPlugin`; drop-ins: `plugin.json#/description`); each `extensions[]` entry carries its extension manifest's `description` per `IExtensionBase` (`extensions/base.schema.json#/properties/description`), plus the optional `stability` lifecycle label per `extensions/base.schema.json#/properties/stability` (omitted when the manifest does not declare it; missing means `stable`. The SPA badges only the non-default values, `experimental` / `beta` / `deprecated`, next to the extension row; `stable` renders nothing. Presentation-only EXCEPT `experimental` and `deprecated`, which each flip the extension's installed default to disabled). The SPA's Settings list renders the descriptions as muted secondary text and includes them in its substring-search index alongside the ids. The `extensions` array is present whenever the plugin declares any extension AND the plugin loaded successfully. Each entry's `enabled` reflects the per-extension override resolution (DB > settings.json > installed default, where the installed default is `false` for `experimental` and `deprecated` extensions and `true` otherwise), so an experimental or deprecated extension reads `enabled: false` until the operator turns it on. The optional `locked: true` flag is stamped when the plugin id (or qualified extension id) appears in the host's lock-list (`src/server/locked-plugins.ts`); locked items render the toggle disabled in the SPA and any `PATCH` against them returns `403 locked`. The flag is omitted when false. The optional `startsAsDisabled: true` flag is stamped on drop-in plugins (never built-ins) whose discovery-time `status` was `'disabled'`, that is, every extension of the plugin was disabled in `config_plugins` / `settings.json` at `sm serve` boot, so the handlers were never bucketed into the runtime. The SPA renders a per-row hint when this flag is set AND the user re-enables at least one of the plugin's extensions in the buffered state, since re-enabling them requires `sm serve` restart (the rest of the toggle pipeline applies live). The flag is omitted when false. |
610
611
  | `PATCH /api/plugins/:id` | implemented | **Bundle (aggregate) macro endpoint**: fans the toggle out across every extension inside the plugin. `:id` MUST be a top-level plugin id (no slash); qualified-id form is the sibling route below. Body `{ enabled: boolean }` (JSON). Persists one `config_plugins` row per child extension (`<plugin>/<ext>`) inside a single transaction; locked children are silently dropped, mirroring the CLI's bulk-mode lock semantics. Response is the canonical `RestEnvelope` (`kind: 'plugins'`) reflecting the post-write state. **Lock**, rejected with 403 `locked` when the plugin id itself is in the host lock-list (`src/server/locked-plugins.ts`). **Apply window**, the override applies on the next scan; both the BFF and the watcher build a fresh resolver from `config_plugins` before composing extensions, so the toggle is honoured without restarting `sm serve`. The endpoint purges `scan_contributions` rows for each disabled extension immediately so the UI stops rendering its chips before the next scan. **Exception**, drop-in plugins whose discovery-time `status` was `'disabled'` (carried as `startsAsDisabled: true` on the read shape) are NOT in the runtime extension buckets; re-enabling them via PATCH persists the override but requires `sm serve` restart for the handlers to be loaded. The SPA surfaces this per-row when the user re-enables such a plugin. The endpoint does NOT broadcast a WS event today. |
611
612
  | `PATCH /api/plugins/:pluginId/extensions/:extensionId` | implemented | Canonical per-extension toggle. Body `{ enabled: boolean }`. Both segments are URL-path-segment-encoded (no slash inside `:pluginId` or `:extensionId`). 404 `not-found` when the plugin id is unknown or the extension id does not belong to that plugin. **Lock**, rejected with 403 `locked` when either the plugin id or the qualified `pluginId/extensionId` appears in the host lock-list. Same persistence + apply-window semantics as the bundle (aggregate) macro form (including the `startsAsDisabled` exception). The SPA's buffered Settings modal posts here for every per-row flip. |
612
613
  | `PATCH /api/plugins` | implemented | Bulk toggle. Body `{ "changes": Array<{ "id": string, "enabled": boolean }> }` where each `id` is either a bare plugin id (bundle cascade macro, same semantics as `PATCH /api/plugins/:id`) or a qualified `<plugin>/<extension>` id. Empty `changes` array is accepted as a no-op (returns the current `GET /api/plugins` envelope). The route validates the **entire batch** before writing, any invalid entry (`unknown-plugin` / `locked`) rejects the whole request with the offending id in `error.details.id`, and the DB is not touched. Valid batches are applied in **one SQLite transaction**: bare plugin ids expand to their child qualified ids before persistence; `IConfigPluginsPort.set` is called per resulting key, then one grouped `scan_contributions` purge per disabled extension (enables skip the purge). Response is the same `RestEnvelope` (`kind: 'plugins'`) shape as `GET /api/plugins`, reflecting the post-write state, the SPA replaces its modal state from this envelope. Apply-window and `startsAsDisabled` exception semantics match the per-id routes. The single-id `PATCH /api/plugins/:id` and qualified-id sibling stay available for CLI / external automation; the bulk variant exists so the SPA can stage edits in a buffered modal and ship the final delta atomically. |
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://skill-map.ai/spec/v0/conformance-case.schema.json",
3
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 (1.0 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.",
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.",
5
5
  "fixture": "signal-ir-collision",
6
6
  "invoke": {
7
7
  "verb": "scan",
@@ -14,7 +14,7 @@ This file is hand-maintained. A CI check before spec release compares the schema
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. |
17
- | 7 | `plugins-registry.schema.json` |, | 🔴 missing | Two sub-cases required: (a) `PluginManifest` validation via `sm plugins show --json`; (b) aggregate `PluginsRegistry` via `sm plugins list --json`. |
17
+ | 7 | `plugins-registry.schema.json` |, | 🔴 missing | Two sub-cases required: (a) `PluginManifest` validation via `sm plugins list <id> --json` (the per-plugin detail form); (b) aggregate `PluginsRegistry` via `sm plugins list --json` (no id). The single-extension shape emitted by `sm plugins show <plugin>/<ext> --json` is not part of `plugins-registry.schema.json`. |
18
18
  | 8 | `job.schema.json` |, | 🔴 missing | Blocked by Step 10 (job system). Needs a case that submits a local action (no LLM), inspects `sm job show --json`. |
19
19
  | 9 | `report-base.schema.json` |, | 🔴 missing | Indirect coverage once any summarizer case lands. Direct contract case: validate a handcrafted minimal report ({confidence, safety}) against the base schema. |
20
20
  | 10 | `conformance-case.schema.json` |, | 🔴 missing | Self-referential: every `*.json` under `cases/` MUST validate against this schema. Add a meta-case that enumerates + validates all cases. |
package/db-schema.md CHANGED
@@ -92,6 +92,7 @@ One row per detected node, matching [`schemas/node.schema.json`](./schemas/node.
92
92
  | `links_in_count` | INTEGER | NOT NULL DEFAULT 0 | |
93
93
  | `external_refs_count` | INTEGER | NOT NULL DEFAULT 0 | |
94
94
  | `scanned_at` | INTEGER | NOT NULL | Unix ms. |
95
+ | `modified_at_ms` | INTEGER | NULL | File `mtime` in Unix ms, captured at scan time from `lstat`. NULL for virtual / derived nodes (no backing file). Drives the UI "last modified" sortable column; never participates in hashing. |
95
96
 
96
97
  Indexes: `ix_scan_nodes_kind`, `ix_scan_nodes_provider`, `ix_scan_nodes_body_hash` (rename heuristic).
97
98
 
package/index.json CHANGED
@@ -174,14 +174,14 @@
174
174
  }
175
175
  ]
176
176
  },
177
- "specPackageVersion": "0.48.0",
177
+ "specPackageVersion": "0.50.0",
178
178
  "integrity": {
179
179
  "algorithm": "sha256",
180
180
  "files": {
181
- "CHANGELOG.md": "e7fc58aaced4c555405dd1e41a860b016cfb4fca7da6dbb29dfa6b39fddcba79",
181
+ "CHANGELOG.md": "d67e2fa8c42deb9f771652c66c5bc95db5df53445a956d2dc6d936e5342b2467",
182
182
  "README.md": "a7505a7b0672c39a8b011e3c5e7d41826306476ee63768249bba4bdb3c03d4d1",
183
- "architecture.md": "961a1aedf037dc9a2e6bdef1944d04e35bf826fdf8579ee64b0aff9f6d9f70da",
184
- "cli-contract.md": "f2d5bbe15c19646b69fd1aaff8a380b7044966dad049a180445e5c2130ec051c",
183
+ "architecture.md": "9fb9167ac9604cb8e4fb89e514b176ffb9ddfcf510ce94d5a3074c83a274bbcd",
184
+ "cli-contract.md": "6f719b132f7f219c34bca3ffe2de7e4d4b54dd16c071254c3c081c3b4ea965fe",
185
185
  "conformance/README.md": "4ec22ca3cc8e4282fe0bfd111f22b121e0781e2b525867cd092258b8f58ae1e1",
186
186
  "conformance/cases/backtick-path-extraction.json": "4620e7f8bc161fc57cb44001e9d99879c7e22b4865a0c27a20dc28969cd936d9",
187
187
  "conformance/cases/extractor-emits-signal.json": "0115c7bb62a7a705f72e9d8048b3f0396e5caaeb3d04dea204415e279e58479d",
@@ -190,11 +190,11 @@
190
190
  "conformance/cases/orphan-markdown-fallback.json": "506119323ddde85c1fb4c986c7f6f40a345d44adb06de8d84002591df0e479ee",
191
191
  "conformance/cases/plugin-missing-ui-rejected.json": "2074fd71937feae136c999f76da81f334f2caf8b65bfe8dc9d7fb800699fb85c",
192
192
  "conformance/cases/sidecar-end-to-end.json": "0a0d941ab50bd7619e1021a6c6d6dc92918429c2efcf25236b42b5fac9eab901",
193
- "conformance/cases/signal-collision-detection.json": "c5e39a406ded6928a14c1a22b84f7b3cd49805bec56bd65de83130d9e419c09e",
193
+ "conformance/cases/signal-collision-detection.json": "a71598327efc05c66b971bdcb5fc3af2816fa921b14b3f54284c783ac93354a8",
194
194
  "conformance/cases/view-action-button.json": "51331f725be1c3655351f8fca6fc9d3d301ae68ea1741ff6c79998332ba2dfeb",
195
195
  "conformance/cases/view-contribution-payloads.json": "e8f54ed62e64a2a0f86729866e507abb1f4246683f0e60d538280536f7cd3ecc",
196
196
  "conformance/cases/view-slots-all.json": "05284e0324dd2da72b6b21d397c11b355802229a68053e9dddc323f69b3a1eba",
197
- "conformance/coverage.md": "f93676ede774003c4d15ccf8d3bf2f65b5d032d75ae572df01dff892aeb1a8cf",
197
+ "conformance/coverage.md": "aed73083125e81bc7c03f7884ac0984c6d4e0494711889b0808661ffe11c358b",
198
198
  "conformance/fixtures/backtick-path/docs/target.md": "a09ae2cb4c96358a2e0692215f172b0f8c48028b6b123e4e83424b28302e644c",
199
199
  "conformance/fixtures/backtick-path/source.md": "217f78b12b3ff47a938a5cc9c1ff7d6989d6a1db82bd1ddf3656787f31efb902",
200
200
  "conformance/fixtures/orphan-markdown/.claude/agents/reviewer.md": "7f062731106f2d9811e4fffcf6ab44b8dfff4cfb16536a469514cc0664e832bf",
@@ -224,11 +224,11 @@
224
224
  "conformance/fixtures/view-contribution-payloads/notes/example.md": "312b1919cd7fd0f233648b053acfb2975662ede3c65dd391cc508204b67ad6fb",
225
225
  "conformance/fixtures/view-slots-all/.skill-map/plugins/all-slots/analyzers/everything/index.js": "ea0022fec7f0fd5a26ba12db1310335f434f2f820682206a3a9542d98db0d346",
226
226
  "conformance/fixtures/view-slots-all/.skill-map/plugins/all-slots/plugin.json": "c48e8a0574947ade0b4eb189d6bc27a48e24f92f616aacdc177f2d22d472a599",
227
- "db-schema.md": "9f99e1c2b73570a12021dd2cd640afd4b1f78ac31f898f0485bba7ed86adaac6",
227
+ "db-schema.md": "4f555d80f6d8d9b629cf14406be471a0039d0cb645b8169ae08c0166c7c1b1ff",
228
228
  "interfaces/security-scanner.md": "e8049712b9cf7a07c786bf19f8f775f8ef9638f063f7fba5c7a8b1431b92f38e",
229
229
  "job-events.md": "9d5b35d4c451a7f8eef9915d85316d924ac52f1c026a316cdda5f1099d496854",
230
230
  "job-lifecycle.md": "9c429121f98a07c8795f8979ed1abc5e5334e3f89db51585a8da55c527ef855b",
231
- "plugin-author-guide.md": "ab40dd384186e02d7123f0a202e6ce4cd1a11870112e1b94937a5026ce2d9133",
231
+ "plugin-author-guide.md": "7b50d52f39bb17f9b27fd9fabcd375b9ade08fc61b33e68d78fc01a85887a3ac",
232
232
  "plugin-kv-api.md": "1acc69ed82433a74e35ada61d63a6d7379fb61046ff83de1e0facbe884c64704",
233
233
  "prompt-preamble.md": "9dd4f6d1bc6a425f8782fcee10cbe75909e8d64e28781fda56c2fae909b02f40",
234
234
  "schemas/annotations.schema.json": "8c639b149cad675fdd4e7d6be2b47e920cfdd24087b41361d6e1b8280f646322",
@@ -237,9 +237,9 @@
237
237
  "schemas/conformance-case.schema.json": "958b316d646d0c64a715a7a28cee66d2c2d2498a60dbfc5ae8970687c2a96954",
238
238
  "schemas/conformance-result.schema.json": "14f983a8f4e62cd4ff964688c9b2b026a3bee3a0b762b64091c8c34db5b75777",
239
239
  "schemas/execution-record.schema.json": "db0eb16153493ad9f13ea0ecede44191e4a8536979adffd17ca278ddf8786c77",
240
- "schemas/extensions/action.schema.json": "8b300532c0217c0f65c454edd6df86d1fe4245590fb5e0974944ce9e593f7f28",
240
+ "schemas/extensions/action.schema.json": "d81661e4ba4e24bc339943b317446879699ccf370c10004f702ed61301e4bf94",
241
241
  "schemas/extensions/analyzer.schema.json": "8def4a5ca4934197c34abde97da70704b2751041a443c859eddd4b783e2fe1db",
242
- "schemas/extensions/base.schema.json": "c78bdf1057cd19cf370d1343c801a0deeaf38d745e9ec40ec141de52b658243a",
242
+ "schemas/extensions/base.schema.json": "ec4cef21bc5d493c4d60ae3208c5e15364b02176f5f32bb00bbd62e9578befdc",
243
243
  "schemas/extensions/extractor.schema.json": "ee44bf562b19318c93116c574a811857cdef1f4119326a9a604fa408889dd230",
244
244
  "schemas/extensions/formatter.schema.json": "880dc379ad545a62404403533a01eda5171edba0390561fc46ec6e986e0b9bd3",
245
245
  "schemas/extensions/hook.schema.json": "f56aef59e9986ffdf7d86aa2e048dccccf217000a358b8c64737cbd911c48dad",
@@ -247,14 +247,14 @@
247
247
  "schemas/extensions/provider.schema.json": "bea1d73897dc8fa8499ba7c77ce535337473e5ecb3702ebca9966c08afc920f4",
248
248
  "schemas/frontmatter/base.schema.json": "cff81510ed94824dfd12ab8b30ce9fbac65e42d61ae0edf3fbb6bbb6bb8bcb8c",
249
249
  "schemas/history-stats.schema.json": "436aa0ffe744bdb699000447e86b45724fbd2cc4642781074eb1527522b9058c",
250
- "schemas/input-types.schema.json": "1c81704783627c5e89dd40cb20368d9e9aa94a15f32c2f929964e392cf2a12b6",
250
+ "schemas/input-types.schema.json": "93b27a1cbd1f131d42730eb9a89cf3af6889e9f17b20a48ce36133885503e01b",
251
251
  "schemas/issue.schema.json": "d173aa5c5312b3d2a2cd249f55c10943c8f3cd5799e4645ae3c66316221e12d1",
252
252
  "schemas/job.schema.json": "dbcedf137de03fde38f74686f594e600c627bf808f2aad23511a26617a663a02",
253
- "schemas/link.schema.json": "df1466499e78f68056b302dc2a5a1bf3bdcc0ffa6b7b01ffe89111c78e1b2c3f",
254
- "schemas/node.schema.json": "14ed2e4c44d01e3f662e240219819895cca06dead374a5cadccfd423c520ed69",
253
+ "schemas/link.schema.json": "02d9d8b2a1cdd1c6672e6b5821e08f09e43c298c8d602520a95decaffabfd1d3",
254
+ "schemas/node.schema.json": "1ebba38e0c0ae022fccbc0cdf7c298da1720a68d4cb375f0baf9f0847998a0d8",
255
255
  "schemas/plugins-doctor.schema.json": "03e2dc51c052a09bf0198c80e2c26e6129734ada4a748e483245de3dd8576c42",
256
256
  "schemas/plugins-registry.schema.json": "211d081691fc83526e1593c79ed9741ad8a5dbd4db1a756f72141b0cced2ea15",
257
- "schemas/project-config.schema.json": "0a4a12a3409f900bd19b47c34588c77ac894b944d21a9beebb91ae1e9c0f3d01",
257
+ "schemas/project-config.schema.json": "6f161b547072d8470eedbc773a5e0e04edf3479e91f4de3fbd9b4b7a36a2e69d",
258
258
  "schemas/refresh-report.schema.json": "47184d4f6b15e9b7671dc178b3b3886a64422da198898508ecdb2cb27876db04",
259
259
  "schemas/report-base-deterministic.schema.json": "59785fe6f3ceb34814bbbd03d10fa7336a32835ce598946f2923d469b32aa32a",
260
260
  "schemas/report-base.schema.json": "e4d25f055e24f18ae0f77c24661c1bddc87ff2e43b001b6a827fcb14f9753f44",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skill-map/spec",
3
- "version": "0.48.0",
3
+ "version": "0.50.0",
4
4
  "description": "JSON Schemas, prose contracts, and conformance suite for the skill-map specification.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -143,7 +143,7 @@ Two id shapes resolve at the toggle surface:
143
143
 
144
144
  `--all` is the cascade variant: it expands to every extension in every discovered plugin and applies the same `--yes` / TTY-confirm gate.
145
145
 
146
- Resolution order per id: DB override (`config_plugins`) > `settings.json#/plugins/<id>/enabled` > installed default (`true`). Persisted toggle keys are always qualified `<plugin>/<ext>` ids (the bundle macro path expands at write time).
146
+ Resolution order per id: DB override (`config_plugins`) > `settings.json#/plugins/<id>/enabled` > installed default. The installed default is `true` for ordinary extensions and `false` for extensions declaring `stability: 'experimental'` or `stability: 'deprecated'` (they ship disabled until the operator opts in; see [Extension manifests](#extension-manifests)). Persisted toggle keys are always qualified `<plugin>/<ext>` ids (the bundle macro path expands at write time).
147
147
 
148
148
  There is no `granularity` manifest field; per-extension toggling is the only model.
149
149
 
@@ -236,13 +236,13 @@ The kernel knows six categories. Each has a JSON Schema under [`schemas/extensio
236
236
  | `provider` | `walk` / `classify` | filesystem roots, candidate path | `{ kind, provider } \| null` | deterministic only |
237
237
  | `extractor` | `extract(ctx)` | one node + body + frontmatter + callbacks | `void` (via `ctx.emitLink` / `ctx.enrichNode` / `ctx.emitContribution` / `ctx.store`) | deterministic only |
238
238
  | `analyzer` | `evaluate(ctx)` | full graph | `Issue[]` | dual-mode |
239
- | `action` | `run(ctx)` | one or more nodes | report / rendered prompt | dual-mode |
239
+ | `action` | `invoke(input, ctx)` + optional `project(ctx)` | `invoke`: one node + input; `project`: full graph + `emitContribution` | `invoke`: report / rendered prompt; `project`: `void` (its own view contributions) | `invoke`: dual-mode; `project`: deterministic |
240
240
  | `formatter` | `format(ctx)` | full graph | `string` | deterministic only |
241
241
  | `hook` | `on(ctx)` | a curated lifecycle event payload | `void` (side effects) | **deterministic only** |
242
242
 
243
243
  The runtime instance you `export default` includes both the manifest fields (`version`, `description`, plus kind-specific metadata) AND the runtime method. The kernel strips function-typed properties before AJV-validating the manifest, so the method lives alongside metadata.
244
244
 
245
- Base manifest fields shared by every kind (normative shape in [`schemas/extensions/base.schema.json`](./schemas/extensions/base.schema.json)): `version` (required for external plugins), `description` (required), and the optional `stability`, `order`, `annotation`, `settings`. `stability` (`'experimental' | 'beta' | 'stable' | 'deprecated'`, default `stable`) is a presentation-only lifecycle label: the non-default values render as a badge next to the extension in `sm plugins list` / `sm plugins show` and the Settings plugins panel, and the kernel never gates behaviour on it (a `deprecated` extension still runs). A stable extension simply omits the field; declaring `stability: 'stable'` is valid but renders nothing.
245
+ Base manifest fields shared by every kind (normative shape in [`schemas/extensions/base.schema.json`](./schemas/extensions/base.schema.json)): `version` (required for external plugins), `description` (required), and the optional `stability`, `order`, `annotation`, `settings`. `stability` (`'experimental' | 'beta' | 'stable' | 'deprecated'`, default `stable`) is a lifecycle label: the non-default values render as a badge next to the extension in `sm plugins list <id>` / `sm plugins show` and the Settings plugins panel. It is presentation-only for `beta` and `stable`, but `experimental` and `deprecated` additionally flip the extension's installed default to DISABLED, the extension does not load (does not run, does not register, shows its toggle off) until the operator opts in via `sm plugins enable <plugin>/<ext>`, the Settings toggle, or a `settings.json` / `config_plugins` override. The opt-in wins over the installed default like any other enable, so a `deprecated` extension can still be kept running during a migration. A stable extension simply omits the field; declaring `stability: 'stable'` is valid but renders nothing.
246
246
 
247
247
  ### Extractors
248
248
 
@@ -374,7 +374,12 @@ my-provider/
374
374
 
375
375
  ### Actions
376
376
 
377
- Operate on one or more nodes. Dual-mode (`mode` optional, default `'deterministic'`). Files-by-convention: every Action carries `<action-dir>/report.schema.json`; probabilistic Actions additionally carry `<action-dir>/prompt.md`. Probabilistic estimates go in `probExpectedDurationSeconds` (drives job TTL). Optional `precondition` (including `analyzerIds`, the Modelo B link). These ship later in the v1.x line as bundled built-ins; until Step 10 lands the job subsystem, test them with a live kernel via `sm scan` against a fixture rather than in unit tests. Spec at [`schemas/extensions/action.schema.json`](./schemas/extensions/action.schema.json).
377
+ Operate on one or more nodes. Dual-mode (`mode` optional, default `'deterministic'`). Files-by-convention: every Action carries `<action-dir>/report.schema.json`; probabilistic Actions additionally carry `<action-dir>/prompt.md`. Probabilistic estimates go in `probExpectedDurationSeconds` (drives job TTL). Optional `precondition` (including `analyzerIds`, the Modelo B link). Spec at [`schemas/extensions/action.schema.json`](./schemas/extensions/action.schema.json).
378
+
379
+ An Action has two independent surfaces:
380
+
381
+ - **`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.
378
383
 
379
384
  ---
380
385
 
@@ -690,6 +695,22 @@ The ten input-types: `string-list`, `single-string`, `boolean-flag`, `integer`,
690
695
 
691
696
  The kernel exposes resolved settings via `ctx.settings.<settingId>`. Settings are read once at extension invocation; **changing a setting requires `sm scan` to re-emit** affected contributions (the UI surfaces a "settings changed, rescan needed" indicator).
692
697
 
698
+ ### Setting values and the operator
699
+
700
+ The manifest declares the *shape* (label, type, default); the **operator** supplies the *values*. Non-`secret` values live in the project config under `plugins.<pluginId>.extensions.<extId>.settings.<settingId>` (the extension id is the leaf folder name, not the qualified `<plugin>/<ext>` id, the plugin is already the parent key), so a team can commit them in `settings.json` or keep a per-checkout override in `settings.local.json`. The kernel's settings resolver builds the runtime `ctx.settings` object by taking each declared setting's `default`, overlaying the merged config value, and validating the result against the input-type's value schema; a value that fails validation is dropped back to the default with a warning (the scan never crashes on bad settings). The `project-config.schema.json` keeps the `settings` object deliberately permissive (`additionalProperties: true`), the per-type validation is the resolver's job because the static schema cannot know which type a given `settingId` picked.
701
+
702
+ `secret` settings are the exception on WHERE they land: the kernel forces them into project-local `settings.local.json` (gitignored), never the committed `settings.json`, so a token never travels via the shared repo. There is **no encryption** (the value is plain text on the local machine); the only protection is "does not leave the checkout". An optional `envVar` lets CI inject the value without writing it to disk at all. See `input-types.schema.json#/$defs/Setting_Secret`.
703
+
704
+ The operator reads and writes values through the CLI (UI form is the parallel surface):
705
+
706
+ ```text
707
+ sm plugins config <plugin>/<ext> # table: declared setting · effective value · source layer
708
+ sm plugins config <plugin>/<ext> <settingId> <value> # validate against the input-type, then write
709
+ sm plugins config <plugin>/<ext> <settingId> --reset # remove the override (falls back to the manifest default)
710
+ ```
711
+
712
+ A write lands in `settings.json` by default (or `settings.local.json` when the layering routes it per-checkout); the command prints a "re-scan to apply" footer because settings are read once per scan.
713
+
693
714
  ### Catalog version
694
715
 
695
716
  The slot + input-type catalog evolves on its own cadence. `catalogCompat` (required in the manifest) is the semver range you tested against, independent of `specCompat`. A mismatch surfaces as `incompatible-catalog`; resolution is `sm plugins upgrade <id>`, which runs registered migrations from the kernel's closed registry. When auto-migration is impossible (a slot you used was removed), the upgrade verb fails loud and your manifest needs a manual edit.
@@ -83,6 +83,16 @@
83
83
  }
84
84
  },
85
85
  "description": "Reserved (Steps 3+). When set, a parametrized Action declares the single user input it needs; the UI renders the matching input-type control before dispatch and places the value under `paramKey` in the dispatch body. Deterministic no-prompt actions (e.g. `node-bump`) omit it. Mirrors `view-slots.schema.json#/$defs/payloads/_ActionPrompt`."
86
+ },
87
+ "ui": {
88
+ "type": "object",
89
+ "additionalProperties": {
90
+ "$ref": "../view-slots.schema.json#/$defs/IViewContribution"
91
+ },
92
+ "propertyNames": {
93
+ "pattern": "^[a-z][a-z0-9]*(-[a-z0-9]+)*$"
94
+ },
95
+ "description": "Plugin-contributed view contributions. Same contract as Extractor.ui / Analyzer.ui (slot-driven, payload-validated). An Action emits these during its scan-time `project()` self-projection (deterministic, side-effect-free, runs in the contribution phase), supplying the target node path explicitly per emission via `ctx.emitContribution(<nodePath>, <contributionId>, payload)`, the same signature Analyzer uses because the Action sees the full graph, not a single node. The canonical use is an Action declaring its OWN `inspector.action.button` so the button lives with the Action that dispatches it (no separate projector analyzer). `extractor`, `analyzer`, and `action` kinds may declare this field."
86
96
  }
87
97
  },
88
98
  "allOf": [
@@ -18,7 +18,7 @@
18
18
  "stability": {
19
19
  "type": "string",
20
20
  "enum": ["experimental", "beta", "stable", "deprecated"],
21
- "description": "Optional lifecycle label for the extension. Presentation-only metadata: it drives a badge next to the extension in `sm plugins list` / `sm plugins show` and the Settings plugins panel, and the loader never gates behaviour on it (a `deprecated` extension still runs). Default: missing == `stable`. Only the non-default values (`experimental`, `beta`, `deprecated`) render a badge; `stable`, declared or defaulted, renders nothing, so authors only declare the field while the extension is NOT stable. Deliberately a superset of the node-level enum at `annotations.schema.json#/properties/stability` (which has no `beta`): this field describes the maturity of the extension itself, not of a scanned node."
21
+ "description": "Optional lifecycle label for the extension. Drives a badge next to the extension in `sm plugins list <id>` / `sm plugins show` and the Settings plugins panel for the non-default values (`experimental`, `beta`, `deprecated`); `stable`, declared or defaulted, renders nothing, so authors only declare the field while the extension is NOT stable. `experimental` and `deprecated` ALSO change behaviour: each flips the extension's installed default to DISABLED, so the extension does not load (does not run, does not register, shows its toggle off) until the operator opts in via `sm plugins enable <plugin>/<ext>`, the Settings toggle, or a `settings.json` / `config_plugins` override. That opt-in is a plain enable override and wins over the installed default like any other id (so a deprecated extension can still be kept running during a migration). The remaining values are presentation-only and default to ENABLED: `beta` runs with a badge, `stable` runs with no badge. Default: missing == `stable` == enabled. Deliberately a superset of the node-level enum at `annotations.schema.json#/properties/stability` (which has no `beta`): this field describes the maturity of the extension itself, not of a scanned node."
22
22
  },
23
23
  "order": {
24
24
  "type": "number",
@@ -12,11 +12,12 @@
12
12
  { "const": "single-string", "description": "Single text input." },
13
13
  { "const": "boolean-flag", "description": "On/off toggle." },
14
14
  { "const": "integer", "description": "Integer with optional bounds." },
15
+ { "const": "number", "description": "Decimal number with optional bounds." },
15
16
  { "const": "enum-pick", "description": "Pick one from a closed set." },
16
17
  { "const": "enum-multipick", "description": "Pick zero or more from a closed set." },
17
18
  { "const": "path-glob", "description": "Glob pattern (single or multiple)." },
18
19
  { "const": "regex", "description": "ECMAScript regex pattern body." },
19
- { "const": "secret", "description": "Sensitive string (encrypted at rest)." },
20
+ { "const": "secret", "description": "Sensitive string, forced into project-local storage (gitignored), not encrypted." },
20
21
  { "const": "key-value-list", "description": "Editable mapping of strings to strings." }
21
22
  ],
22
23
  "description": "Closed enum of input-type identifiers. Adding an entry requires the full spec/UI/CLI/tests round-trip. Removing or renaming an entry is a catalog-major-bump and triggers `sm plugins upgrade` migration. Each member's `description` is the catalog summary surfaced by `sm plugins slots list` and is the single source of truth for the generated kernel + CLI mirrors (see `scripts/generate-view-catalog.js`)."
@@ -28,6 +29,7 @@
28
29
  { "$ref": "#/$defs/Setting_SingleString" },
29
30
  { "$ref": "#/$defs/Setting_BooleanFlag" },
30
31
  { "$ref": "#/$defs/Setting_Integer" },
32
+ { "$ref": "#/$defs/Setting_Number" },
31
33
  { "$ref": "#/$defs/Setting_EnumPick" },
32
34
  { "$ref": "#/$defs/Setting_EnumMultipick" },
33
35
  { "$ref": "#/$defs/Setting_PathGlob" },
@@ -120,6 +122,22 @@
120
122
  },
121
123
  "description": "Integer input with optional bounds. Renders as PrimeNG `<p-inputnumber>` with spinner. Value type at runtime: `number` (always integer)."
122
124
  },
125
+ "Setting_Number": {
126
+ "allOf": [{ "$ref": "#/$defs/_Common" }],
127
+ "type": "object",
128
+ "additionalProperties": false,
129
+ "required": ["type", "label"],
130
+ "properties": {
131
+ "type": { "const": "number" },
132
+ "label": true,
133
+ "description": true,
134
+ "default": { "type": "number" },
135
+ "min": { "type": "number" },
136
+ "max": { "type": "number" },
137
+ "step": { "type": "number", "exclusiveMinimum": 0, "default": 1 }
138
+ },
139
+ "description": "Decimal number input with optional bounds (a threshold like 0.3, a ratio, a confidence floor). Renders as PrimeNG `<p-inputnumber>` with `mode=\"decimal\"`. Value type at runtime: `number` (whole OR fractional accepted; pick `integer` instead when the value must be a whole number). Validation rejects `NaN` / `Infinity`."
140
+ },
123
141
  "Setting_EnumPick": {
124
142
  "allOf": [{ "$ref": "#/$defs/_Common" }],
125
143
  "type": "object",
@@ -223,10 +241,10 @@
223
241
  "envVar": {
224
242
  "type": "string",
225
243
  "pattern": "^[A-Z][A-Z0-9_]*$",
226
- "description": "Optional env var name the kernel checks first; if set in the process environment, that value wins over any stored value (lets CI inject without writing to disk). The user-supplied value is stored encrypted at rest."
244
+ "description": "Optional env var name the kernel checks first; if set in the process environment, that value wins over any stored value, letting CI inject the secret without writing it to disk at all."
227
245
  }
228
246
  },
229
- "description": "Sensitive string (token, password, API key). Renders as `<input type=\"password\">` with reveal toggle. Stored encrypted at rest (kernel-managed key in `state_secrets` table). Logged as `<redacted>` in CLI output. Value type at runtime: `string`. Triggers an `audit.secret-read` event on every read."
247
+ "description": "Sensitive string (token, password, API key). Renders as `<input type=\"password\">` with reveal toggle. **Stored in project-local `settings.local.json` (gitignored), never in the committed `settings.json`**: the protection is that the value never travels via the shared repo, NOT encryption, it is kept as plain text on the local machine. The kernel routes any `secret`-typed setting to the project-local layer automatically, the dynamic equivalent of `PROJECT_LOCAL_ONLY_KEYS` (the destination follows the declared type, not a fixed key list), so `sm plugins config` writes a secret to `settings.local.json` even without an explicit local flag. Logged as `<redacted>` in CLI output. Value type at runtime: `string`."
230
248
  },
231
249
  "Setting_KeyValueList": {
232
250
  "allOf": [{ "$ref": "#/$defs/_Common" }],
@@ -24,7 +24,7 @@
24
24
  "type": "number",
25
25
  "minimum": 0,
26
26
  "maximum": 1,
27
- "description": "Extractor's self-assessed confidence `[0..1]`. Drives UI edge opacity (more confident = more opaque). Migrated from the legacy string union `'high' | 'medium' | 'low'` to a numeric range so callers can express finer granularity than three buckets. Reference scoring: `1.0` = structured input (sidecar annotation), `0.95` = unambiguous syntax (`[text](file.md)`), `0.85` = strong signal with one inference (`@file.md`), `0.5` = genuine ambiguity (`@bare-handle`). The named tiers `HIGH = 0.9`, `MEDIUM = 0.6`, `LOW = 0.3` are exposed on the kernel side as the `ConfidenceTier` constants for callers that want to think in buckets. Analyzers MAY filter by confidence threshold."
27
+ "description": "Extractor's self-assessed confidence `[0..1]`. Drives UI edge opacity (more confident = more opaque). Migrated from the legacy string union `'high' | 'medium' | 'low'` to a numeric range so callers can express finer granularity than three buckets. Reference scoring (extractor EMIT values, before resolution): `1.0` = structured input (sidecar annotation), `0.95` = unambiguous syntax (`[text](file.md)`), `0.85` = strong signal with one inference (`@file.md`), `0.5` = genuine ambiguity (`@bare-handle`). On the merged graph the post-walk confidence-lift transform (see `architecture.md` §Provider · resolution rules) overrides the emit value: a resolved target lifts to `1.0`, a resolved-but-`virtual` target (e.g. an `mcp://` node reconstructed from frontmatter, not verified on disk) keeps its emit value (an unverified entity is not full certainty), a resolved-but-reserved target drops to `0.1`, and a genuinely-broken target drops to `0.5`, so a dangling edge renders fainter than a resolved one. The named tiers `HIGH = 0.9`, `MEDIUM = 0.6`, `LOW = 0.3` are exposed on the kernel side as the `ConfidenceTier` constants for callers that want to think in buckets. Analyzers MAY filter by confidence threshold."
28
28
  },
29
29
  "sources": {
30
30
  "type": "array",
@@ -44,6 +44,11 @@
44
44
  "$ref": "#/$defs/tripleSplit",
45
45
  "description": "Size breakdown in tokens (via `js-tiktoken`): frontmatter, body, total. Optional; MAY be absent if tokenization was disabled."
46
46
  },
47
+ "modifiedAtMs": {
48
+ "type": ["integer", "null"],
49
+ "minimum": 0,
50
+ "description": "File modification time (`mtime`) in Unix milliseconds, captured at scan time from the on-disk `lstat` that already guards the read. Optional and nullable: virtual / derived nodes (`virtual: true`) have no backing file and omit it. Surfaced via `/api/nodes` and `/api/scan` so the UI can show and sort a 'last modified' column; sorting uses this raw value, the display format is a UI concern. NOT content: it never participates in `bodyHash` / `frontmatterHash`."
51
+ },
47
52
  "linksOutCount": {
48
53
  "type": "integer",
49
54
  "minimum": 0,
@@ -72,12 +72,27 @@
72
72
  },
73
73
  "plugins": {
74
74
  "type": "object",
75
- "description": "Per-plugin enable/disable overrides. Keys are plugin ids. Absent = use installed default (enabled).",
75
+ "description": "Per-plugin overrides. Keys are plugin ids. Carries the enable/disable toggle and, under `extensions.<extId>.settings`, the operator-supplied values for the settings each extension declares in its manifest. Absent = installed defaults (enabled, declared setting defaults).",
76
76
  "additionalProperties": {
77
77
  "type": "object",
78
78
  "additionalProperties": false,
79
79
  "properties": {
80
- "enabled": { "type": "boolean" }
80
+ "enabled": { "type": "boolean" },
81
+ "extensions": {
82
+ "type": "object",
83
+ "description": "Per-extension overrides, keyed by extension id (the leaf folder name, NOT the qualified `<plugin>/<ext>` id, the plugin is already the parent key). Today only `settings`; a future per-extension `enabled` filter (the deferred Phase 4+ work) would slot in alongside it.",
84
+ "additionalProperties": {
85
+ "type": "object",
86
+ "additionalProperties": false,
87
+ "properties": {
88
+ "settings": {
89
+ "type": "object",
90
+ "description": "Operator-supplied values for the extension's declared settings, keyed by settingId. Values are intentionally NOT validated by this schema: the kernel's settings resolver validates each value against the per-type value schema of the input-type the manifest declares (`input-types.schema.json#/$defs/ISettingDeclaration`), since this schema cannot know which type a given settingId picked. A non-`secret` setting lands here in `settings.json` (team-shared) or `settings.local.json` (per-checkout override) via the normal config layering; `secret` settings are NOT stored here, they ride the dedicated encrypted `state_secrets` path (see `input-types.schema.json#/$defs/Setting_Secret`).",
91
+ "additionalProperties": true
92
+ }
93
+ }
94
+ }
95
+ }
81
96
  }
82
97
  }
83
98
  },