@skill-map/cli 0.27.0 → 0.29.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.
Files changed (36) hide show
  1. package/dist/cli/tutorial/sm-master.md +181 -97
  2. package/dist/cli/tutorial/sm-tutorial.md +94 -20
  3. package/dist/cli.js +1422 -1194
  4. package/dist/cli.js.map +1 -1
  5. package/dist/index.js +116 -99
  6. package/dist/index.js.map +1 -1
  7. package/dist/kernel/index.d.ts +903 -1004
  8. package/dist/kernel/index.js +116 -99
  9. package/dist/kernel/index.js.map +1 -1
  10. package/dist/ui/chunk-3SI3TVER.js +7 -0
  11. package/dist/ui/{chunk-4GTCV7V4.js → chunk-47OZB7LR.js} +1 -1
  12. package/dist/ui/{chunk-JMP2LDMI.js → chunk-5JBW2LUN.js} +1 -1
  13. package/dist/ui/chunk-BL7KARTN.js +317 -0
  14. package/dist/ui/chunk-DMSZOXER.js +1 -0
  15. package/dist/ui/{chunk-Y7MXGXU3.js → chunk-DZBSELHN.js} +1 -1
  16. package/dist/ui/chunk-EFKSD7PT.js +123 -0
  17. package/dist/ui/{chunk-Z2667C3S.js → chunk-FEPH4VNB.js} +1 -1
  18. package/dist/ui/{chunk-PY2R7LHN.js → chunk-FQOZBFJ5.js} +1 -1
  19. package/dist/ui/{chunk-WOLLYGGL.js → chunk-KJQEO6P3.js} +1 -1
  20. package/dist/ui/{chunk-VO6NF24F.js → chunk-LS2NXZQZ.js} +1 -1
  21. package/dist/ui/{chunk-J3YWUNFO.js → chunk-LTQTJU54.js} +1 -1
  22. package/dist/ui/{chunk-6BG7PBUN.js → chunk-NGIFGXW7.js} +1 -1
  23. package/dist/ui/{chunk-5W6J6H76.js → chunk-SBCO7ZSP.js} +1 -1
  24. package/dist/ui/chunk-VB56BUGO.js +1 -0
  25. package/dist/ui/{chunk-UXCAEDR6.js → chunk-VDQLDTTR.js} +1 -1
  26. package/dist/ui/{chunk-AD7RBRD3.js → chunk-WJLIYGWJ.js} +5 -5
  27. package/dist/ui/index.html +2 -2
  28. package/dist/ui/{main-LM44IIOO.js → main-LGW7AYEA.js} +2 -2
  29. package/dist/ui/skill-map-mark-matrix.svg +8 -0
  30. package/dist/ui/{styles-EGXMA46P.css → styles-CDN434T2.css} +1 -1
  31. package/package.json +10 -7
  32. package/dist/ui/chunk-H2J55DNK.js +0 -7
  33. package/dist/ui/chunk-LTSP2F6C.js +0 -123
  34. package/dist/ui/chunk-Q7L6LLAK.js +0 -1
  35. package/dist/ui/chunk-UAG2DUVV.js +0 -1
  36. package/dist/ui/chunk-VH5GRUT7.js +0 -255
@@ -1,760 +1,767 @@
1
1
  /**
2
- * Domain types, byte-aligned with `spec/schemas/{node,link,issue,scan-result}.schema.json`.
3
- *
4
- * The kernel is the reference consumer of the spec; these types are therefore
5
- * derived from the schemas, not invented. When a schema changes, this file
6
- * follows. Until automatic AJV-driven derivation lands, the mapping is
7
- * hand-maintained and the release gate is the conformance suite.
8
- *
9
- * --- Naming convention (kernel-wide) -------------------------------------
10
- *
11
- * Five categories with distinct prefix rules; the rules are deliberate
12
- * even though they look mixed at first read:
13
- *
14
- * 1. **Domain types**, every shape that mirrors a `spec/schemas/*.json`
15
- * file: `Node`, `Link`, `Issue`, `ScanResult`, `ScanStats`,
16
- * `ExecutionRecord`, `HistoryStats`, …. **No prefix.** Names track
17
- * the spec verbatim because the spec is the source of truth.
18
- * Renaming any of these is a spec change.
19
- *
20
- * 2. **Hexagonal ports**, the abstract boundaries the kernel calls
21
- * out to (`StoragePort`, `RunnerPort`, `ProgressEmitterPort`,
22
- * `FilesystemPort`, `PluginLoaderPort`). **`Port` suffix.** The
23
- * suffix calls out the architectural role and avoids name clashes
24
- * with the concrete adapter classes (`SqliteStorageAdapter`
25
- * implements `StoragePort`).
26
- *
27
- * 3. **Runtime extension contracts**, what a plugin author
28
- * implements: `IProvider`, `IExtractor`, `IAnalyzer`, `IFormatter`,
29
- * `IExtensionBase`. **`I` prefix.** The prefix flags "this is a
30
- * contract you supply, not a value the kernel hands you", same
31
- * reading as the rest of TypeScript's plugin ecosystems where a
32
- * shape is implementable.
2
+ * Extension registry, six kinds, first-class, loaded through a single API.
33
3
  *
34
- * 4. **Internal interfaces**, option bags, result records, config
35
- * slices, anything declared as `interface` and passed across
36
- * function boundaries inside the kernel / CLI but not part of the
37
- * spec: `IPluginRuntimeBundle`, `IPruneResult`, `IMigrationFile`,
38
- * `IDbLocationOptions`. **`I` prefix.** The prefix matches
39
- * category 3 because both are "shapes that live in TypeScript
40
- * only, never in JSON".
4
+ * The `Extension` shape is aligned with `spec/schemas/extensions/base.schema.json`.
5
+ * Kind-specific manifests (provider / extractor / analyzer / action / formatter /
6
+ * hook) extend this base structurally; the registry stores the base view
7
+ * and each kind's code carries its own fuller type where needed.
41
8
  *
42
- * 5. **Internal type aliases**, anything declared as `type` (string-
43
- * literal unions, function types, mapped/derived types) that lives
44
- * only in TS: `TLogLevel`, `TLogMethodLevel`, `TProgressListener`,
45
- * `TLogFormatter`, `TActionWrite`, `TExecutionMode`, `TGranularity`,
46
- * `THookFilter`, `THookTrigger`, `TNodeChangeReason`,
47
- * `TPluginLoadStatus`, `TPluginStorage`, `TWatchEventKind`. **`T`
48
- * prefix.** Use this bucket when `interface` is the wrong shape
49
- * (a union, a callback signature, an `Exclude<…>` derivation).
9
+ * **Spec § A.6, qualified ids.** Every extension is keyed in the registry
10
+ * by `<pluginId>/<id>` (e.g. `core/annotations`, `core/slash`,
11
+ * `hello-world/greet`). `Extension.id` carries the **short** id as authored;
12
+ * `Extension.pluginId` carries the namespace; the registry composes the
13
+ * qualifier internally and exposes lookup APIs that operate on either form
14
+ * (qualified for direct lookup, kind-scoped listing for enumeration).
50
15
  *
51
- * Edge cases worth knowing:
52
- * - The following category-4 names lack the `I` prefix because
53
- * they are part of the public kernel surface and renaming is a
54
- * breaking change for downstream consumers. The list is closed:
55
- * option bags / records: `RunScanOptions`, `RenameOp`;
56
- * TS-only exports from `kernel/index.ts` / `kernel/ports/*`:
57
- * `Kernel`, `ProgressEvent`, `LogRecord`, `NodeStat`.
58
- * New public option bags MUST still use `I*`; new public type
59
- * aliases MUST still use `T*`. Removing a name from this list is a
60
- * breaking change.
61
- * - `IDatabase` (SQLite schema) is category 4 but lives in
62
- * `adapters/sqlite/schema.ts`, not here. Same rule applies.
16
+ * Boot invariant: `new Registry()` is empty. `registry.totalCount() === 0`
17
+ * when the kernel boots with zero extensions. This is the data side of the
18
+ * `kernel-empty-boot` conformance contract.
19
+ */
20
+ type ExtensionKind = 'provider' | 'extractor' | 'analyzer' | 'action' | 'formatter' | 'hook';
21
+ declare const EXTENSION_KINDS: readonly ExtensionKind[];
22
+ interface Extension {
23
+ /** Short (unqualified) extension id, injected by the loader from the leaf folder name. */
24
+ id: string;
25
+ /** Owning plugin namespace, injected by the loader from the plugin folder name. */
26
+ pluginId: string;
27
+ kind: ExtensionKind;
28
+ version: string;
29
+ /** Required short description; surfaced in `sm <kind>s list` and the UI. */
30
+ description: string;
31
+ entry?: string;
32
+ }
33
+ /**
34
+ * Compose the qualified registry key for an extension. Single source of
35
+ * truth so callers don't reinvent the format and a future change (e.g. a
36
+ * different separator) lands in one place.
37
+ */
38
+ declare function qualifiedExtensionId(pluginId: string, id: string): string;
39
+ declare class DuplicateExtensionError extends Error {
40
+ constructor(kind: ExtensionKind, qualifiedId: string);
41
+ }
42
+ declare class Registry {
43
+ #private;
44
+ constructor();
45
+ register(ext: Extension): void;
46
+ /**
47
+ * Lookup by qualified id (`<pluginId>/<id>`). Returns `undefined` when
48
+ * no extension of that kind is registered under the qualifier.
49
+ */
50
+ get(kind: ExtensionKind, qualifiedId: string): Extension | undefined;
51
+ /**
52
+ * Convenience wrapper that composes the qualified id for the caller.
53
+ * Equivalent to `get(kind, qualifiedExtensionId(pluginId, id))`.
54
+ */
55
+ find(kind: ExtensionKind, pluginId: string, id: string): Extension | undefined;
56
+ all(kind: ExtensionKind): Extension[];
57
+ count(kind: ExtensionKind): number;
58
+ totalCount(): number;
59
+ }
60
+
61
+ /**
62
+ * Step 9.6.6, runtime annotation-contribution catalog types.
63
63
  *
64
- * If you find yourself wanting to add a new type and aren't sure which
65
- * bucket it falls in: ask "does this shape exist in the spec?". If
66
- * yes, no prefix and align the name with the schema. If no, `I` prefix
67
- * for `interface`, `T` prefix for `type` aliases.
64
+ * Lives in its own module (rather than `kernel/index.ts`) so consumers
65
+ * deep inside the kernel, `IAnalyzerContext`, the BFF route factories,
66
+ * future Action contexts, can depend on the catalog shape without
67
+ * dragging the whole kernel barrel and risking a cycle.
68
68
  */
69
69
  /**
70
- * The four node kinds the **built-in Claude Provider** declares, `skill`,
71
- * `agent`, `command`, `note`. **NOT** the kernel-wide kind type.
70
+ * Single row of the runtime annotation-contribution catalog surfaced by
71
+ * `kernel.getRegisteredAnnotationKeys()`. One row per (plugin × key)
72
+ * tuple. Built-in catalog keys from `annotations.schema.json` are NOT
73
+ * included, this catalog is plugin-only; the UI knows the built-in
74
+ * catalog via the schema bundle.
75
+ */
76
+ interface IRegisteredAnnotationKey {
77
+ pluginId: string;
78
+ key: string;
79
+ location: 'namespaced' | 'root';
80
+ ownership: 'exclusive' | 'shared';
81
+ /** Inline JSON Schema as declared in the manifest (not the AJV compiled validator). */
82
+ schema: Record<string, unknown>;
83
+ }
84
+
85
+ /**
86
+ * Step 11.x, runtime view-contribution catalog types.
72
87
  *
73
- * `Node.kind` is `string`. An external Provider (Cursor, Obsidian, )
74
- * MAY classify into its own kinds (e.g. `'cursorRule'`, `'daily'`); the
75
- * orchestrator, persistence layer, and AJV `node.schema.json` accept any
76
- * non-empty string. Per `spec/db-schema.md` § scan_nodes and
77
- * `node.schema.json#/properties/kind`, the contract is open-by-design
78
- * (matches `IProvider.kinds` "open by design" docstring).
88
+ * Lives in its own module (rather than `kernel/index.ts`) so consumers
89
+ * deep inside the kernel, `IAnalyzerContext`, the BFF route factories,
90
+ * future Action contexts, can depend on the catalog shape without
91
+ * dragging the whole kernel barrel and risking a cycle.
79
92
  *
80
- * Step 9.5 dropped `hook` from the catalog: `.claude/hooks/*.md` is NOT
81
- * an Anthropic-defined node type, hooks live in `settings.json` or as
82
- * sub-objects of agent / skill frontmatter (see
83
- * https://code.claude.com/docs/en/hooks.md). Files at the old path
84
- * classify as `markdown` via the Provider's fallback. The fallback is
85
- * named after the *format* because the file is generic markdown with
86
- * no specific role; format-named kinds apply only as the generic
87
- * fallback, a file that matches a specific role (agent / command /
88
- * skill) classifies under that role, not under `markdown`.
93
+ * Mirrors `annotation-catalog.ts` for the annotation contribution side
94
+ * (Step 9.6.6). The two systems share the "plugin contributes data,
95
+ * kernel exposes catalog, UI renders" pattern but never overlap in
96
+ * storage or routing, see `architecture.md` §View contribution system
97
+ * for the comparison table.
89
98
  *
90
- * This alias survives because:
91
- * - claude-specific code legitimately wants to switch on the four
92
- * hard-coded values (filter widgets, kind-aware UI cards, the
93
- * `validate-all` built-in rule that maps each kind to its
94
- * frontmatter schema);
95
- * - sorting helpers want a stable `KIND_ORDER` for the canonical
96
- * catalog;
97
- * - tests expect to enumerate the four kinds when seeding fixtures.
99
+ * **Closed catalog by design.** Both `TSlotName` and `TInputTypeName`
100
+ * mirror the closed enums in `spec/schemas/view-slots.schema.json`
101
+ * and `spec/schemas/input-types.schema.json`. Adding a member is a
102
+ * coordinated kernel + spec + UI + scaffolder change. The closed-enum
103
+ * shape lets TypeScript surface unknown slots at author time
104
+ * (in plugin authors' editors when their plugin imports `@skill-map/cli`)
105
+ * AND lets the runtime exhaustively dispatch slot → renderer in the
106
+ * UI without `default:` fallbacks.
107
+ */
108
+ /**
109
+ * Closed enum of view slot names. Mirror of
110
+ * `spec/schemas/view-slots.schema.json#/$defs/SlotName`.
98
111
  *
99
- * For "any kind a Provider could declare", use plain `string`. Only use
100
- * `NodeKind` when the code is intentionally claude-catalog-specific.
112
+ * Plugins pick one of these by name in their extension manifest's
113
+ * `viewContributions[<contributionId>].slot` field. The kernel
114
+ * validates each pick at load time (`invalid-manifest` on miss); the
115
+ * slot fixes both the renderer and the payload shape.
101
116
  */
102
- type NodeKind = 'skill' | 'agent' | 'command' | 'markdown';
103
- type LinkKind = 'invokes' | 'references' | 'mentions' | 'supersedes';
104
- type Confidence = 'high' | 'medium' | 'low';
105
- type Severity = 'error' | 'warn' | 'info';
106
- type Stability = 'experimental' | 'stable' | 'deprecated';
117
+ type TSlotName = 'card.title.right' | 'card.subtitle.left' | 'card.footer.left' | 'card.footer.right' | 'graph.node.alert' | 'inspector.header.badge.counter' | 'inspector.header.badge.tag' | 'inspector.body.panel.breakdown' | 'inspector.body.panel.records' | 'inspector.body.panel.tree' | 'inspector.body.panel.key-values' | 'inspector.body.panel.link-list' | 'inspector.body.panel.markdown' | 'topbar.nav.start';
107
118
  /**
108
- * Execution mode of an analytical extension. Mirrors the per-kind capability
109
- * matrix in `spec/architecture.md` §Execution modes:
119
+ * Closed enum of input-type names for plugin settings. Mirror of
120
+ * `spec/schemas/input-types.schema.json#/$defs/InputTypeName`.
110
121
  *
111
- * - `deterministic`, pure code, runs synchronously inside `sm scan` /
112
- * `sm check`. Same input same output, every run.
113
- * - `probabilistic`, calls an LLM through `RunnerPort`, dispatches only
114
- * as a queued job (`sm job submit <kind>:<id>`); never participates in
115
- * scan-time pipelines.
122
+ * Plugins pick one of these by name in their plugin manifest's
123
+ * `settings[<settingId>].type` field. The kernel exposes the resolved
124
+ * value via `ctx.settings.<settingId>` typed per the input-type's
125
+ * value-type promise.
126
+ */
127
+ type TInputTypeName = 'string-list' | 'single-string' | 'boolean-flag' | 'integer' | 'enum-pick' | 'enum-multipick' | 'path-glob' | 'regex' | 'secret' | 'key-value-list';
128
+ /** Closed severity palette aligned with PrimeNG `<p-tag>` / `<p-message>`. */
129
+ type TSeverity = 'info' | 'warn' | 'success' | 'danger';
130
+ /**
131
+ * Manifest-side declaration of a single view contribution. The plugin
132
+ * author writes one of these per Record key in
133
+ * `IExtensionBase.viewContributions[<contributionId>]`.
116
134
  *
117
- * Extractor / Rule / Action declare it directly (default `deterministic` when
118
- * omitted in the manifest). Provider / Formatter are deterministic-only and
119
- * MUST NOT carry the field.
135
+ * Mirror of `view-slots.schema.json#/$defs/IViewContribution`.
120
136
  */
121
- type TExecutionMode = 'deterministic' | 'probabilistic';
122
- interface TripleSplit {
123
- frontmatter: number;
124
- body: number;
125
- total: number;
126
- }
127
- interface LinkTrigger {
128
- originalTrigger: string;
129
- normalizedTrigger: string;
130
- }
131
- interface LinkLocation {
132
- line: number;
133
- column?: number;
134
- offset?: number;
135
- }
136
- interface Node {
137
- path: string;
137
+ interface IViewContribution {
138
+ /**
139
+ * Required. Closed-catalog slot name. Unknown name rejects the
140
+ * extension as `invalid-manifest` at load. The slot fixes both the
141
+ * renderer and the payload shape; there is no separate "contract"
142
+ * abstraction.
143
+ */
144
+ slot: TSlotName;
145
+ /**
146
+ * Optional human-readable label. English-only per `AGENTS.md`
147
+ * (`Externalized texts, not internationalized`).
148
+ */
149
+ label?: string;
150
+ /** Optional hover tooltip. English-only. */
151
+ tooltip?: string;
152
+ /**
153
+ * Optional emoji codepoint OR PrimeIcons class id (without the
154
+ * `pi-` prefix). The UI discriminates: matches Unicode
155
+ * `\p{Extended_Pictographic}` → emoji text, otherwise → PrimeIcon.
156
+ * Required for counter slots and `card.title.right` (enforced by
157
+ * the manifest-side conditional in `view-slots.schema.json`).
158
+ */
159
+ icon?: string;
138
160
  /**
139
- * Provider-declared category. Open string (matches
140
- * `node.schema.json#/properties/kind`): the built-in Claude Provider
141
- * emits one of `NodeKind`'s values, but external Providers MAY emit
142
- * their own. Code that intentionally switches on the claude catalog
143
- * narrows via `if (kind === 'skill' \| ... )`; everything else
144
- * accepts the open string and treats unknown values as opaque labels.
161
+ * Optional empty placeholder text shown when the payload is empty
162
+ * AND `emitWhenEmpty` is true. Falls back to a UI-supplied generic
163
+ * 'No data.' string. English-only.
145
164
  */
146
- kind: string;
147
- provider: string;
148
- bodyHash: string;
149
- frontmatterHash: string;
150
- bytes: TripleSplit;
151
- linksOutCount: number;
152
- linksInCount: number;
153
- externalRefsCount: number;
154
- frontmatter?: Record<string, unknown>;
155
- tokens?: TripleSplit;
165
+ emptyText?: string;
156
166
  /**
157
- * Step 9.6.2, sidecar denormalisation surface. Populated by the
158
- * orchestrator at scan time; absent when the orchestrator did not
159
- * inspect sidecars (legacy code paths) or when no sidecar accompanies
160
- * the node. Read by `annotation-stale` rule and the persistence layer.
167
+ * When false (default), the kernel drops emissions whose payload is
168
+ * structurally empty so the slot stays silent. When true, the
169
+ * renderer surfaces an empty placeholder. Per-slot definition of
170
+ * "empty" lives in the slot's payload schema.
161
171
  */
162
- sidecar?: ISidecarOverlay | null;
172
+ emitWhenEmpty?: boolean;
163
173
  /**
164
- * Per-user "favorite" flag, decorated by the BFF on `/api/nodes` and
165
- * `/api/nodes/:pathB64` responses via in-memory `Set` lookup against
166
- * `state_node_favorites`. Absent on emissions that don't carry per-user
167
- * state (e.g. `sm export --json`); consumers that don't recognise the
168
- * field MUST treat the absence as "unknown" rather than "false", a
169
- * truthy `isFavorite` only ever lands when the BFF set it.
174
+ * Optional ordering hint (default 100). Slots configured with
175
+ * `order: 'priority'` sort contributions ASC by this value, with
176
+ * alphabetical tie-break by qualified id. The plugin uses this to
177
+ * suggest where its contribution belongs relative to others sharing
178
+ * the same slot, the slot has the final say.
170
179
  */
171
- isFavorite?: boolean;
180
+ priority?: number;
172
181
  }
173
182
  /**
174
- * Drift status of a co-located `.sm` sidecar relative to the live
175
- * node hashes. Mirrors `TSidecarStatus` on the SQLite schema.
183
+ * Single row of the runtime view-contribution catalog surfaced by
184
+ * `kernel.getRegisteredViewContributions()`. One row per
185
+ * `(pluginId × extensionId × contributionId)` tuple. Composed at boot
186
+ * by `loadPluginRuntime` from every loaded extension's
187
+ * `viewContributions` map.
188
+ *
189
+ * The qualified id is `<pluginId>/<extensionId>/<contributionId>`,
190
+ * matches the qualified id pattern used elsewhere in the kernel
191
+ * (`<pluginId>/<extensionId>` for extensions; this adds the third
192
+ * segment for per-contribution identity).
176
193
  */
177
- type SidecarStatus = 'fresh' | 'stale-body' | 'stale-frontmatter' | 'stale-both';
194
+ interface IRegisteredViewContribution {
195
+ pluginId: string;
196
+ extensionId: string;
197
+ contributionId: string;
198
+ slot: TSlotName;
199
+ /** Optional manifest-declared label (English-only). */
200
+ label?: string;
201
+ tooltip?: string;
202
+ icon?: string;
203
+ emptyText?: string;
204
+ emitWhenEmpty: boolean;
205
+ /** Manifest-declared ordering hint (default 100). See `IViewContribution.priority`. */
206
+ priority?: number;
207
+ }
178
208
  /**
179
- * Sidecar overlay attached to a `Node` after the orchestrator parses
180
- * `<basename>.sm`. `present === false` is the empty overlay (no
181
- * sidecar accompanies the node); the other fields are absent or null
182
- * in that case. When `present === true` and parse + validation
183
- * succeeded, `status` carries the drift state and `annotations` carries
184
- * the parsed (typed) `annotations:` block.
209
+ * Common fields on every setting declaration. The discriminated union
210
+ * `ISettingDeclaration` extends one of these per `type` value.
185
211
  */
186
- interface ISidecarOverlay {
187
- present: boolean;
188
- status?: SidecarStatus | null;
189
- /**
190
- * Parsed `annotations:` block. Untyped object, schema lives in
191
- * `spec/schemas/annotations.schema.json`. Null when no sidecar or
192
- * the block is empty/absent.
193
- */
194
- annotations?: Record<string, unknown> | null;
195
- /**
196
- * R15 closure (2026-05-07), full parsed YAML root of the sidecar
197
- * (the entire `.sm` payload, mirroring `sidecar.schema.json`). Surfaced
198
- * so the UI inspector can render `for:`, `audit:`, `settings:`, and
199
- * `<plugin-id>:` namespace blocks without re-reading the file. NULL
200
- * when no sidecar is present, or when the sidecar exists but failed
201
- * to parse / validate. The `annotations` field above stays, it
202
- * duplicates `root.annotations` intentionally so existing consumers
203
- * keep working unchanged.
204
- */
205
- root?: Record<string, unknown> | null;
212
+ interface ISettingCommon {
213
+ /** Required. Short human-readable label. English-only. */
214
+ label: string;
215
+ /** Optional helper text shown below the control. English-only. */
216
+ description?: string;
206
217
  }
207
- interface Link {
208
- /** The originating node, the path of the file the extractor was reading
209
- * when it emitted this link. Singular, NOT to be confused with
210
- * `sources` (plural) below. */
211
- source: string;
212
- target: string;
213
- kind: LinkKind;
214
- confidence: Confidence;
215
- /** Identifiers of the extractors / extensions that contributed evidence
216
- * for this link (one link can be confirmed by multiple extractors).
217
- * Plural; NOT the same as `source` (singular) above, which is the
218
- * originating node path. Naming is unfortunate but spec-frozen. */
219
- sources: string[];
220
- trigger?: LinkTrigger | null;
221
- location?: LinkLocation | null;
222
- raw?: string | null;
218
+ interface ISetting_StringList extends ISettingCommon {
219
+ type: 'string-list';
220
+ default?: string[];
221
+ min?: number;
222
+ max?: number;
223
+ itemMaxLength?: number;
223
224
  }
224
- interface IssueFix {
225
- summary?: string;
226
- autofixable?: boolean;
225
+ interface ISetting_SingleString extends ISettingCommon {
226
+ type: 'single-string';
227
+ default?: string;
228
+ minLength?: number;
229
+ maxLength?: number;
230
+ /** Optional ECMAScript regex pattern (no flags). */
231
+ pattern?: string;
227
232
  }
228
- interface Issue {
229
- analyzerId: string;
230
- severity: Severity;
231
- nodeIds: string[];
232
- message: string;
233
- linkIndices?: number[];
234
- detail?: string | null;
235
- fix?: IssueFix | null;
236
- data?: Record<string, unknown>;
233
+ interface ISetting_BooleanFlag extends ISettingCommon {
234
+ type: 'boolean-flag';
235
+ default?: boolean;
237
236
  }
238
- interface ScanStats {
239
- /**
240
- * Files visited by the Provider walkers. With a single Provider this
241
- * matches `nodesCount`; with multiple Providers running on overlapping
242
- * roots it can diverge (each yielded `IRawNode` is one walked file).
243
- */
244
- filesWalked: number;
245
- /**
246
- * Files walked but not classified by any Provider. Today every walked
247
- * file is classified by its Provider (the `claude` Provider falls back to
248
- * `'markdown'`), so this is always 0; the field will matter once
249
- * multiple Providers can claim the same file.
250
- */
251
- filesSkipped: number;
252
- nodesCount: number;
253
- linksCount: number;
254
- issuesCount: number;
255
- durationMs: number;
237
+ interface ISetting_Integer extends ISettingCommon {
238
+ type: 'integer';
239
+ default?: number;
240
+ min?: number;
241
+ max?: number;
242
+ step?: number;
256
243
  }
257
- interface ScanScannedBy {
258
- name: string;
259
- version: string;
260
- specVersion: string;
244
+ interface ISetting_EnumOption {
245
+ value: string;
246
+ label: string;
261
247
  }
262
- type ExecutionKind = 'action';
263
- type ExecutionStatus = 'completed' | 'failed' | 'cancelled';
264
- type ExecutionFailureReason = 'runner-error' | 'report-invalid' | 'timeout' | 'abandoned' | 'job-file-missing' | 'user-cancelled';
265
- type ExecutionRunner = 'cli' | 'skill' | 'in-process';
266
- /**
267
- * One row of execution history (`state_executions`). Matches
268
- * `spec/schemas/execution-record.schema.json`. `nodeIds` is the camelCased
269
- * domain field name; storage flattens it to `node_ids_json`.
270
- */
271
- interface ExecutionRecord {
272
- id: string;
273
- kind: ExecutionKind;
274
- extensionId: string;
275
- extensionVersion: string;
276
- nodeIds?: string[];
277
- contentHash?: string | null;
278
- status: ExecutionStatus;
279
- failureReason?: ExecutionFailureReason | null;
280
- exitCode?: number | null;
281
- runner?: ExecutionRunner | null;
282
- startedAt: number;
283
- finishedAt: number;
284
- durationMs?: number | null;
285
- tokensIn?: number | null;
286
- tokensOut?: number | null;
287
- reportPath?: string | null;
288
- jobId?: string | null;
248
+ interface ISetting_EnumPick extends ISettingCommon {
249
+ type: 'enum-pick';
250
+ options: ISetting_EnumOption[];
251
+ default?: string;
289
252
  }
290
- interface HistoryStatsTotals {
291
- executionsCount: number;
292
- completedCount: number;
293
- failedCount: number;
294
- tokensIn: number;
295
- tokensOut: number;
296
- durationMsTotal: number;
253
+ interface ISetting_EnumMultipick extends ISettingCommon {
254
+ type: 'enum-multipick';
255
+ options: ISetting_EnumOption[];
256
+ default?: string[];
257
+ min?: number;
258
+ max?: number;
297
259
  }
298
- interface HistoryStatsTokensPerAction {
299
- actionId: string;
300
- actionVersion: string;
301
- executionsCount: number;
302
- tokensIn: number;
303
- tokensOut: number;
304
- durationMsMean: number | null;
305
- durationMsMedian: number | null;
260
+ interface ISetting_PathGlob extends ISettingCommon {
261
+ type: 'path-glob';
262
+ default?: string;
263
+ /** When true, accepts string[]; when false (default), single string. */
264
+ multiple?: boolean;
306
265
  }
307
- interface HistoryStatsExecutionsPerPeriod {
308
- periodStart: string;
309
- periodUnit: 'day' | 'week' | 'month';
310
- executionsCount: number;
311
- tokensIn: number;
312
- tokensOut: number;
266
+ interface ISetting_Regex extends ISettingCommon {
267
+ type: 'regex';
268
+ default?: string;
269
+ /** Subset of `gimsuy`. Default `''`. */
270
+ flags?: string;
313
271
  }
314
- interface HistoryStatsTopNode {
315
- nodePath: string;
316
- executionsCount: number;
317
- lastExecutedAt: number;
272
+ interface ISetting_Secret extends ISettingCommon {
273
+ type: 'secret';
274
+ /**
275
+ * Optional uppercase-ASCII identifier. When set in the process
276
+ * environment, that value wins over any stored value (lets CI
277
+ * inject without writing to disk).
278
+ */
279
+ envVar?: string;
318
280
  }
319
- interface HistoryStatsPerActionRate {
320
- actionId: string;
321
- rate: number;
322
- executionsCount: number;
323
- failedCount: number;
281
+ interface ISetting_KeyValueListEntry {
282
+ key: string;
283
+ value: string;
324
284
  }
325
- interface HistoryStatsErrorRates {
326
- global: number;
327
- perAction: HistoryStatsPerActionRate[];
328
- perFailureReason: Record<ExecutionFailureReason, number>;
285
+ interface ISetting_KeyValueList extends ISettingCommon {
286
+ type: 'key-value-list';
287
+ keyLabel?: string;
288
+ valueLabel?: string;
289
+ default?: ISetting_KeyValueListEntry[];
290
+ min?: number;
291
+ max?: number;
329
292
  }
330
293
  /**
331
- * `sm history stats --json` payload, conforming to
332
- * `spec/schemas/history-stats.schema.json`. `elapsedMs` is the command's
333
- * own wall-clock per `cli-contract.md` §Elapsed time.
294
+ * Discriminated union of every setting declaration shape. The plugin
295
+ * author NEVER writes JSON Schema for settings, they pick one of
296
+ * these `type` values and supply per-type parameters.
297
+ *
298
+ * Mirror of `input-types.schema.json#/$defs/ISettingDeclaration`.
334
299
  */
335
- interface HistoryStats {
336
- schemaVersion: 1;
337
- range: {
338
- since: string | null;
339
- until: string;
340
- };
341
- totals: HistoryStatsTotals;
342
- tokensPerAction: HistoryStatsTokensPerAction[];
343
- executionsPerPeriod: HistoryStatsExecutionsPerPeriod[];
344
- topNodes: HistoryStatsTopNode[];
345
- errorRates: HistoryStatsErrorRates;
346
- elapsedMs: number;
347
- }
348
- interface ScanResult {
349
- schemaVersion: 1;
350
- /** Unix milliseconds when the scan started. */
351
- scannedAt: number;
352
- /**
353
- * Filesystem roots that were walked during this scan. Spec requires
354
- * `minItems: 1`, `runScan` throws if `roots: []` is supplied.
355
- */
356
- roots: string[];
357
- /** Provider ids that participated in classification. Empty if no Provider matched. */
358
- providers: string[];
359
- /** Implementation metadata. Populated by `runScan` for self-describing output. */
360
- scannedBy?: ScanScannedBy;
361
- nodes: Node[];
362
- links: Link[];
363
- issues: Issue[];
364
- stats: ScanStats;
365
- }
300
+ type ISettingDeclaration = ISetting_StringList | ISetting_SingleString | ISetting_BooleanFlag | ISetting_Integer | ISetting_EnumPick | ISetting_EnumMultipick | ISetting_PathGlob | ISetting_Regex | ISetting_Secret | ISetting_KeyValueList;
301
+ /**
302
+ * Runtime value type for a setting, derived from its declaration. The
303
+ * kernel exposes settings to extractors as `Record<string, TSettingValue>`
304
+ * via `ctx.settings.<settingId>`; consumers that want narrow typing
305
+ * narrow at the call site by reading `manifest.settings[id].type`.
306
+ */
307
+ type TSettingValue = string | string[] | boolean | number | ISetting_KeyValueListEntry[];
366
308
 
367
309
  /**
368
- * Extension registry, six kinds, first-class, loaded through a single API.
369
- *
370
- * The `Extension` shape is aligned with `spec/schemas/extensions/base.schema.json`.
371
- * Kind-specific manifests (provider / extractor / analyzer / action / formatter /
372
- * hook) extend this base structurally; the registry stores the base view
373
- * and each kind's code carries its own fuller type where needed.
374
- *
375
- * **Spec § A.6, qualified ids.** Every extension is keyed in the registry
376
- * by `<pluginId>/<id>` (e.g. `core/annotations`, `core/slash`,
377
- * `hello-world/greet`). `Extension.id` carries the **short** id as authored;
378
- * `Extension.pluginId` carries the namespace; the registry composes the
379
- * qualifier internally and exposes lookup APIs that operate on either form
380
- * (qualified for direct lookup, kind-scoped listing for enumeration).
310
+ * Base extension shape shared by every kind. Mirrors
311
+ * `spec/schemas/extensions/base.schema.json` at the TypeScript level.
381
312
  *
382
- * Boot invariant: `new Registry()` is empty. `registry.totalCount() === 0`
383
- * when the kernel boots with zero extensions. This is the data side of the
384
- * `kernel-empty-boot` conformance contract.
313
+ * **Structure-as-truth**: the manifest authored on disk no longer carries
314
+ * `id` or `kind`. Both fields are derived from the filesystem path
315
+ * (`<plugin>/<kind-plural>/<id>/index.ts`, parent folder dictates kind, leaf
316
+ * folder dictates id). The loader strips/rejects any `id` / `kind` literals
317
+ * a hand-written manifest carries and injects the derived values into the
318
+ * runtime descriptor before it reaches the registry. The qualified registry
319
+ * key is `<pluginId>/<id>`; `pluginId` similarly comes from the plugin's
320
+ * folder name and is injected at load time.
385
321
  */
386
322
 
387
- type ExtensionKind = 'provider' | 'extractor' | 'analyzer' | 'action' | 'formatter' | 'hook';
388
- declare const EXTENSION_KINDS: readonly ExtensionKind[];
389
- interface Extension {
390
- /** Short (unqualified) extension id as declared in the manifest. */
391
- id: string;
392
- /** Owning plugin namespace. Composed with `id` to form the qualified key. */
393
- pluginId: string;
394
- kind: ExtensionKind;
395
- version: string;
396
- description?: string;
397
- stability?: Stability;
398
- preconditions?: string[];
399
- entry?: string;
400
- }
401
323
  /**
402
- * Compose the qualified registry key for an extension. Single source of
403
- * truth so callers don't reinvent the format and a future change (e.g. a
404
- * different separator) lands in one place.
324
+ * Single declaration of an extension's optional sidecar annotation
325
+ * contribution. The annotation key is the extension's id (the leaf folder
326
+ * name); to contribute additional keys, split into additional extensions.
327
+ * Mirrors `spec/schemas/extensions/base.schema.json#/properties/annotation`.
405
328
  */
406
- declare function qualifiedExtensionId(pluginId: string, id: string): string;
407
- declare class DuplicateExtensionError extends Error {
408
- constructor(kind: ExtensionKind, qualifiedId: string);
409
- }
410
- declare class Registry {
411
- #private;
412
- constructor();
413
- register(ext: Extension): void;
329
+ interface IAnnotationContribution {
330
+ /** Inline JSON Schema describing the value written under this key. */
331
+ schema: Record<string, unknown>;
414
332
  /**
415
- * Lookup by qualified id (`<pluginId>/<id>`). Returns `undefined` when
416
- * no extension of that kind is registered under the qualifier.
333
+ * Conflict policy. `shared` (default): multiple plugins MAY write the
334
+ * key; `exclusive`: only this plugin may. REQUIRED to be `'exclusive'`
335
+ * when `location: 'root'`.
417
336
  */
418
- get(kind: ExtensionKind, qualifiedId: string): Extension | undefined;
337
+ ownership?: 'exclusive' | 'shared';
419
338
  /**
420
- * Convenience wrapper that composes the qualified id for the caller.
421
- * Equivalent to `get(kind, qualifiedExtensionId(pluginId, id))`.
339
+ * Where the key lands. `namespaced` (default): under the plugin's
340
+ * `<plugin-id>:` block; `root`: top-level, alongside `for` /
341
+ * `annotations` / `settings` / `audit`. Cross-plugin root-key
342
+ * collisions on `exclusive` are a fatal startup error.
422
343
  */
423
- find(kind: ExtensionKind, pluginId: string, id: string): Extension | undefined;
424
- all(kind: ExtensionKind): Extension[];
425
- count(kind: ExtensionKind): number;
426
- totalCount(): number;
344
+ location?: 'namespaced' | 'root';
427
345
  }
428
-
429
- /**
430
- * Step 9.6.6, runtime annotation-contribution catalog types.
431
- *
432
- * Lives in its own module (rather than `kernel/index.ts`) so consumers
433
- * deep inside the kernel, `IAnalyzerContext`, the BFF route factories,
434
- * future Action contexts, can depend on the catalog shape without
435
- * dragging the whole kernel barrel and risking a cycle.
436
- */
437
346
  /**
438
- * Single row of the runtime annotation-contribution catalog surfaced by
439
- * `kernel.getRegisteredAnnotationKeys()`. One row per (plugin × key)
440
- * tuple. Built-in catalog keys from `annotations.schema.json` are NOT
441
- * included, this catalog is plugin-only; the UI knows the built-in
442
- * catalog via the schema bundle.
347
+ * Runtime extension descriptor as seen by the registry / orchestrator.
348
+ * Authors writing a manifest on disk do NOT declare `id`, `kind`, or
349
+ * `pluginId`; the loader injects all three from the filesystem layout.
443
350
  */
444
- interface IRegisteredAnnotationKey {
351
+ interface IExtensionBase {
352
+ /**
353
+ * Short id, the leaf folder name of the extension. Injected by the
354
+ * loader. Hand-authored manifests carrying an `id` literal are
355
+ * rejected as `invalid-manifest`.
356
+ */
357
+ id: string;
358
+ /**
359
+ * Owning plugin namespace, the plugin folder name. Composed with `id`
360
+ * to produce the qualified registry key `<pluginId>/<id>`. Injected
361
+ * by the loader.
362
+ */
445
363
  pluginId: string;
446
- key: string;
447
- location: 'namespaced' | 'root';
448
- ownership: 'exclusive' | 'shared';
449
- /** Inline JSON Schema as declared in the manifest (not the AJV compiled validator). */
450
- schema: Record<string, unknown>;
364
+ version: string;
365
+ /** Required short description shown by `sm <kind>s list` / UI. */
366
+ description: string;
367
+ /**
368
+ * Optional opt-in single annotation contribution. Renamed from
369
+ * `annotationContributions` (mapa) with the structure-as-truth
370
+ * refactor; the key is the extension's id, so only the schema +
371
+ * ownership + location triple lives in the manifest.
372
+ */
373
+ annotation?: IAnnotationContribution;
374
+ /**
375
+ * Optional extension-scoped user settings. Moved here from
376
+ * `plugin.json` with the structure-as-truth refactor: settings now
377
+ * live with the extension that consumes them, exposed at runtime as
378
+ * `ctx.settings.<settingId>`. Settings are read once at extension
379
+ * invocation; changing a setting requires `sm scan` to re-emit.
380
+ */
381
+ settings?: Record<string, ISettingDeclaration>;
382
+ /**
383
+ * Resolved values of the settings declared above, populated by the
384
+ * orchestrator from project config + user overrides. Runtime-only,
385
+ * never written to disk by authors.
386
+ */
387
+ resolvedSettings?: Record<string, TSettingValue>;
388
+ /**
389
+ * Optional plugin-contributed view contributions. Renamed from
390
+ * `viewContributions` with the structure-as-truth refactor. Each
391
+ * entry maps a local contribution id (kebab-case, unique within the
392
+ * extension) to an `IViewContribution` that picks a view slot by
393
+ * name from the closed catalog. Only `extractor` and `analyzer` kinds
394
+ * may declare this field.
395
+ */
396
+ ui?: Record<string, IViewContribution>;
397
+ /** Runtime-only, absolute path of the extension entry file. */
398
+ entry?: string;
451
399
  }
452
400
 
453
401
  /**
454
- * Step 11.x, runtime view-contribution catalog types.
402
+ * Domain types, byte-aligned with `spec/schemas/{node,link,issue,scan-result}.schema.json`.
455
403
  *
456
- * Lives in its own module (rather than `kernel/index.ts`) so consumers
457
- * deep inside the kernel, `IAnalyzerContext`, the BFF route factories,
458
- * future Action contexts, can depend on the catalog shape without
459
- * dragging the whole kernel barrel and risking a cycle.
404
+ * The kernel is the reference consumer of the spec; these types are therefore
405
+ * derived from the schemas, not invented. When a schema changes, this file
406
+ * follows. Until automatic AJV-driven derivation lands, the mapping is
407
+ * hand-maintained and the release gate is the conformance suite.
460
408
  *
461
- * Mirrors `annotation-catalog.ts` for the annotation contribution side
462
- * (Step 9.6.6). The two systems share the "plugin contributes data,
463
- * kernel exposes catalog, UI renders" pattern but never overlap in
464
- * storage or routing, see `architecture.md` §View contribution system
465
- * for the comparison table.
409
+ * --- Naming convention (kernel-wide) -------------------------------------
410
+ *
411
+ * Five categories with distinct prefix rules; the rules are deliberate
412
+ * even though they look mixed at first read:
413
+ *
414
+ * 1. **Domain types**, every shape that mirrors a `spec/schemas/*.json`
415
+ * file: `Node`, `Link`, `Issue`, `ScanResult`, `ScanStats`,
416
+ * `ExecutionRecord`, `HistoryStats`, …. **No prefix.** Names track
417
+ * the spec verbatim because the spec is the source of truth.
418
+ * Renaming any of these is a spec change.
419
+ *
420
+ * 2. **Hexagonal ports**, the abstract boundaries the kernel calls
421
+ * out to (`StoragePort`, `RunnerPort`, `ProgressEmitterPort`,
422
+ * `FilesystemPort`, `PluginLoaderPort`). **`Port` suffix.** The
423
+ * suffix calls out the architectural role and avoids name clashes
424
+ * with the concrete adapter classes (`SqliteStorageAdapter`
425
+ * implements `StoragePort`).
426
+ *
427
+ * 3. **Runtime extension contracts**, what a plugin author
428
+ * implements: `IProvider`, `IExtractor`, `IAnalyzer`, `IFormatter`,
429
+ * `IExtensionBase`. **`I` prefix.** The prefix flags "this is a
430
+ * contract you supply, not a value the kernel hands you", same
431
+ * reading as the rest of TypeScript's plugin ecosystems where a
432
+ * shape is implementable.
433
+ *
434
+ * 4. **Internal interfaces**, option bags, result records, config
435
+ * slices, anything declared as `interface` and passed across
436
+ * function boundaries inside the kernel / CLI but not part of the
437
+ * spec: `IPluginRuntimeBundle`, `IPruneResult`, `IMigrationFile`,
438
+ * `IDbLocationOptions`. **`I` prefix.** The prefix matches
439
+ * category 3 because both are "shapes that live in TypeScript
440
+ * only, never in JSON".
441
+ *
442
+ * 5. **Internal type aliases**, anything declared as `type` (string-
443
+ * literal unions, function types, mapped/derived types) that lives
444
+ * only in TS: `TLogLevel`, `TLogMethodLevel`, `TProgressListener`,
445
+ * `TLogFormatter`, `TActionWrite`, `TExecutionMode`, `TGranularity`,
446
+ * `THookFilter`, `THookTrigger`, `TNodeChangeReason`,
447
+ * `TPluginLoadStatus`, `TPluginStorage`, `TWatchEventKind`. **`T`
448
+ * prefix.** Use this bucket when `interface` is the wrong shape
449
+ * (a union, a callback signature, an `Exclude<…>` derivation).
450
+ *
451
+ * Edge cases worth knowing:
452
+ * - The following category-4 names lack the `I` prefix because
453
+ * they are part of the public kernel surface and renaming is a
454
+ * breaking change for downstream consumers. The list is closed:
455
+ * option bags / records: `RunScanOptions`, `RenameOp`;
456
+ * TS-only exports from `kernel/index.ts` / `kernel/ports/*`:
457
+ * `Kernel`, `ProgressEvent`, `LogRecord`, `NodeStat`.
458
+ * New public option bags MUST still use `I*`; new public type
459
+ * aliases MUST still use `T*`. Removing a name from this list is a
460
+ * breaking change.
461
+ * - `IDatabase` (SQLite schema) is category 4 but lives in
462
+ * `adapters/sqlite/schema.ts`, not here. Same rule applies.
463
+ *
464
+ * If you find yourself wanting to add a new type and aren't sure which
465
+ * bucket it falls in: ask "does this shape exist in the spec?". If
466
+ * yes, no prefix and align the name with the schema. If no, `I` prefix
467
+ * for `interface`, `T` prefix for `type` aliases.
468
+ */
469
+ /**
470
+ * The four node kinds the **built-in Claude Provider** declares, `skill`,
471
+ * `agent`, `command`, `note`. **NOT** the kernel-wide kind type.
472
+ *
473
+ * `Node.kind` is `string`. An external Provider (Cursor, Obsidian, …)
474
+ * MAY classify into its own kinds (e.g. `'cursorRule'`, `'daily'`); the
475
+ * orchestrator, persistence layer, and AJV `node.schema.json` accept any
476
+ * non-empty string. Per `spec/db-schema.md` § scan_nodes and
477
+ * `node.schema.json#/properties/kind`, the contract is open-by-design
478
+ * (matches `IProvider.kinds` "open by design" docstring).
479
+ *
480
+ * Step 9.5 dropped `hook` from the catalog: `.claude/hooks/*.md` is NOT
481
+ * an Anthropic-defined node type, hooks live in `settings.json` or as
482
+ * sub-objects of agent / skill frontmatter (see
483
+ * https://code.claude.com/docs/en/hooks.md). Files at the old path
484
+ * classify as `markdown` via the Provider's fallback. The fallback is
485
+ * named after the *format* because the file is generic markdown with
486
+ * no specific role; format-named kinds apply only as the generic
487
+ * fallback, a file that matches a specific role (agent / command /
488
+ * skill) classifies under that role, not under `markdown`.
466
489
  *
467
- * **Closed catalog by design.** Both `TSlotName` and `TInputTypeName`
468
- * mirror the closed enums in `spec/schemas/view-slots.schema.json`
469
- * and `spec/schemas/input-types.schema.json`. Adding a member is a
470
- * coordinated kernel + spec + UI + scaffolder change. The closed-enum
471
- * shape lets TypeScript surface unknown slots at author time
472
- * (in plugin authors' editors when their plugin imports `@skill-map/cli`)
473
- * AND lets the runtime exhaustively dispatch slot → renderer in the
474
- * UI without `default:` fallbacks.
475
- */
476
- /**
477
- * Closed enum of view slot names. Mirror of
478
- * `spec/schemas/view-slots.schema.json#/$defs/SlotName`.
490
+ * This alias survives because:
491
+ * - claude-specific code legitimately wants to switch on the four
492
+ * hard-coded values (filter widgets, kind-aware UI cards, the
493
+ * `validate-all` built-in rule that maps each kind to its
494
+ * frontmatter schema);
495
+ * - sorting helpers want a stable `KIND_ORDER` for the canonical
496
+ * catalog;
497
+ * - tests expect to enumerate the four kinds when seeding fixtures.
479
498
  *
480
- * Plugins pick one of these by name in their extension manifest's
481
- * `viewContributions[<contributionId>].slot` field. The kernel
482
- * validates each pick at load time (`invalid-manifest` on miss); the
483
- * slot fixes both the renderer and the payload shape.
499
+ * For "any kind a Provider could declare", use plain `string`. Only use
500
+ * `NodeKind` when the code is intentionally claude-catalog-specific.
484
501
  */
485
- type TSlotName = 'card.title.right' | 'card.subtitle.left' | 'card.footer.left' | 'card.footer.right' | 'graph.node.alert' | 'inspector.header.badge.counter' | 'inspector.header.badge.tag' | 'inspector.body.panel.breakdown' | 'inspector.body.panel.records' | 'inspector.body.panel.tree' | 'inspector.body.panel.key-values' | 'inspector.body.panel.link-list' | 'inspector.body.panel.markdown' | 'topbar.nav.start';
502
+ type NodeKind = 'skill' | 'agent' | 'command' | 'markdown';
503
+ type LinkKind = 'invokes' | 'references' | 'mentions' | 'supersedes';
504
+ type Confidence = 'high' | 'medium' | 'low';
505
+ type Severity = 'error' | 'warn' | 'info';
506
+ type Stability = 'experimental' | 'stable' | 'deprecated';
486
507
  /**
487
- * Closed enum of input-type names for plugin settings. Mirror of
488
- * `spec/schemas/input-types.schema.json#/$defs/InputTypeName`.
508
+ * Execution mode of an analytical extension. Mirrors the per-kind capability
509
+ * matrix in `spec/architecture.md` §Execution modes:
489
510
  *
490
- * Plugins pick one of these by name in their plugin manifest's
491
- * `settings[<settingId>].type` field. The kernel exposes the resolved
492
- * value via `ctx.settings.<settingId>` typed per the input-type's
493
- * value-type promise.
494
- */
495
- type TInputTypeName = 'string-list' | 'single-string' | 'boolean-flag' | 'integer' | 'enum-pick' | 'enum-multipick' | 'path-glob' | 'regex' | 'secret' | 'key-value-list';
496
- /** Closed severity palette aligned with PrimeNG `<p-tag>` / `<p-message>`. */
497
- type TSeverity = 'info' | 'warn' | 'success' | 'danger';
498
- /**
499
- * Manifest-side declaration of a single view contribution. The plugin
500
- * author writes one of these per Record key in
501
- * `IExtensionBase.viewContributions[<contributionId>]`.
511
+ * - `deterministic`, pure code, runs synchronously inside `sm scan` /
512
+ * `sm check`. Same input same output, every run.
513
+ * - `probabilistic`, calls an LLM through `RunnerPort`, dispatches only
514
+ * as a queued job (`sm job submit <kind>:<id>`); never participates in
515
+ * scan-time pipelines.
502
516
  *
503
- * Mirror of `view-slots.schema.json#/$defs/IViewContribution`.
517
+ * Extractor / Rule / Action declare it directly (default `deterministic` when
518
+ * omitted in the manifest). Provider / Formatter are deterministic-only and
519
+ * MUST NOT carry the field.
504
520
  */
505
- interface IViewContribution {
506
- /**
507
- * Required. Closed-catalog slot name. Unknown name rejects the
508
- * extension as `invalid-manifest` at load. The slot fixes both the
509
- * renderer and the payload shape; there is no separate "contract"
510
- * abstraction.
511
- */
512
- slot: TSlotName;
513
- /**
514
- * Optional human-readable label. English-only per `AGENTS.md`
515
- * (`Externalized texts, not internationalized`).
516
- */
517
- label?: string;
518
- /** Optional hover tooltip. English-only. */
519
- tooltip?: string;
520
- /**
521
- * Optional emoji codepoint OR PrimeIcons class id (without the
522
- * `pi-` prefix). The UI discriminates: matches Unicode
523
- * `\p{Extended_Pictographic}` → emoji text, otherwise → PrimeIcon.
524
- * Required for counter slots and `card.title.right` (enforced by
525
- * the manifest-side conditional in `view-slots.schema.json`).
526
- */
527
- icon?: string;
521
+ type TExecutionMode = 'deterministic' | 'probabilistic';
522
+ interface TripleSplit {
523
+ frontmatter: number;
524
+ body: number;
525
+ total: number;
526
+ }
527
+ interface LinkTrigger {
528
+ originalTrigger: string;
529
+ normalizedTrigger: string;
530
+ }
531
+ interface LinkLocation {
532
+ line: number;
533
+ column?: number;
534
+ offset?: number;
535
+ }
536
+ interface Node {
537
+ path: string;
528
538
  /**
529
- * Optional empty placeholder text shown when the payload is empty
530
- * AND `emitWhenEmpty` is true. Falls back to a UI-supplied generic
531
- * 'No data.' string. English-only.
539
+ * Provider-declared category. Open string (matches
540
+ * `node.schema.json#/properties/kind`): the built-in Claude Provider
541
+ * emits one of `NodeKind`'s values, but external Providers MAY emit
542
+ * their own. Code that intentionally switches on the claude catalog
543
+ * narrows via `if (kind === 'skill' \| ... )`; everything else
544
+ * accepts the open string and treats unknown values as opaque labels.
532
545
  */
533
- emptyText?: string;
546
+ kind: string;
547
+ provider: string;
548
+ bodyHash: string;
549
+ frontmatterHash: string;
550
+ bytes: TripleSplit;
551
+ linksOutCount: number;
552
+ linksInCount: number;
553
+ externalRefsCount: number;
554
+ frontmatter?: Record<string, unknown>;
555
+ tokens?: TripleSplit;
534
556
  /**
535
- * When false (default), the kernel drops emissions whose payload is
536
- * structurally empty so the slot stays silent. When true, the
537
- * renderer surfaces an empty placeholder. Per-slot definition of
538
- * "empty" lives in the slot's payload schema.
557
+ * Step 9.6.2, sidecar denormalisation surface. Populated by the
558
+ * orchestrator at scan time; absent when the orchestrator did not
559
+ * inspect sidecars (legacy code paths) or when no sidecar accompanies
560
+ * the node. Read by `annotation-stale` rule and the persistence layer.
539
561
  */
540
- emitWhenEmpty?: boolean;
562
+ sidecar?: ISidecarOverlay | null;
541
563
  /**
542
- * Optional ordering hint (default 100). Slots configured with
543
- * `order: 'priority'` sort contributions ASC by this value, with
544
- * alphabetical tie-break by qualified id. The plugin uses this to
545
- * suggest where its contribution belongs relative to others sharing
546
- * the same slot, the slot has the final say.
564
+ * Per-user "favorite" flag, decorated by the BFF on `/api/nodes` and
565
+ * `/api/nodes/:pathB64` responses via in-memory `Set` lookup against
566
+ * `state_node_favorites`. Absent on emissions that don't carry per-user
567
+ * state (e.g. `sm export --json`); consumers that don't recognise the
568
+ * field MUST treat the absence as "unknown" rather than "false", a
569
+ * truthy `isFavorite` only ever lands when the BFF set it.
547
570
  */
548
- priority?: number;
571
+ isFavorite?: boolean;
549
572
  }
550
573
  /**
551
- * Single row of the runtime view-contribution catalog surfaced by
552
- * `kernel.getRegisteredViewContributions()`. One row per
553
- * `(pluginId × extensionId × contributionId)` tuple. Composed at boot
554
- * by `loadPluginRuntime` from every loaded extension's
555
- * `viewContributions` map.
556
- *
557
- * The qualified id is `<pluginId>/<extensionId>/<contributionId>`,
558
- * matches the qualified id pattern used elsewhere in the kernel
559
- * (`<pluginId>/<extensionId>` for extensions; this adds the third
560
- * segment for per-contribution identity).
574
+ * Drift status of a co-located `.sm` sidecar relative to the live
575
+ * node hashes. Mirrors `TSidecarStatus` on the SQLite schema.
561
576
  */
562
- interface IRegisteredViewContribution {
563
- pluginId: string;
564
- extensionId: string;
565
- contributionId: string;
566
- slot: TSlotName;
567
- /** Optional manifest-declared label (English-only). */
568
- label?: string;
569
- tooltip?: string;
570
- icon?: string;
571
- emptyText?: string;
572
- emitWhenEmpty: boolean;
573
- /** Manifest-declared ordering hint (default 100). See `IViewContribution.priority`. */
574
- priority?: number;
575
- }
577
+ type SidecarStatus = 'fresh' | 'stale-body' | 'stale-frontmatter' | 'stale-both';
576
578
  /**
577
- * Common fields on every setting declaration. The discriminated union
578
- * `ISettingDeclaration` extends one of these per `type` value.
579
+ * Sidecar overlay attached to a `Node` after the orchestrator parses
580
+ * `<basename>.sm`. `present === false` is the empty overlay (no
581
+ * sidecar accompanies the node); the other fields are absent or null
582
+ * in that case. When `present === true` and parse + validation
583
+ * succeeded, `status` carries the drift state and `annotations` carries
584
+ * the parsed (typed) `annotations:` block.
579
585
  */
580
- interface ISettingCommon {
581
- /** Required. Short human-readable label. English-only. */
582
- label: string;
583
- /** Optional helper text shown below the control. English-only. */
584
- description?: string;
586
+ interface ISidecarOverlay {
587
+ present: boolean;
588
+ status?: SidecarStatus | null;
589
+ /**
590
+ * Parsed `annotations:` block. Untyped object, schema lives in
591
+ * `spec/schemas/annotations.schema.json`. Null when no sidecar or
592
+ * the block is empty/absent.
593
+ */
594
+ annotations?: Record<string, unknown> | null;
595
+ /**
596
+ * R15 closure (2026-05-07), full parsed YAML root of the sidecar
597
+ * (the entire `.sm` payload, mirroring `sidecar.schema.json`). Surfaced
598
+ * so the UI inspector can render `for:`, `audit:`, `settings:`, and
599
+ * `<plugin-id>:` namespace blocks without re-reading the file. NULL
600
+ * when no sidecar is present, or when the sidecar exists but failed
601
+ * to parse / validate. The `annotations` field above stays, it
602
+ * duplicates `root.annotations` intentionally so existing consumers
603
+ * keep working unchanged.
604
+ */
605
+ root?: Record<string, unknown> | null;
585
606
  }
586
- interface ISetting_StringList extends ISettingCommon {
587
- type: 'string-list';
588
- default?: string[];
589
- min?: number;
590
- max?: number;
591
- itemMaxLength?: number;
607
+ interface Link {
608
+ /** The originating node, the path of the file the extractor was reading
609
+ * when it emitted this link. Singular, NOT to be confused with
610
+ * `sources` (plural) below. */
611
+ source: string;
612
+ target: string;
613
+ kind: LinkKind;
614
+ confidence: Confidence;
615
+ /** Identifiers of the extractors / extensions that contributed evidence
616
+ * for this link (one link can be confirmed by multiple extractors).
617
+ * Plural; NOT the same as `source` (singular) above, which is the
618
+ * originating node path. Naming is unfortunate but spec-frozen. */
619
+ sources: string[];
620
+ trigger?: LinkTrigger | null;
621
+ location?: LinkLocation | null;
622
+ raw?: string | null;
592
623
  }
593
- interface ISetting_SingleString extends ISettingCommon {
594
- type: 'single-string';
595
- default?: string;
596
- minLength?: number;
597
- maxLength?: number;
598
- /** Optional ECMAScript regex pattern (no flags). */
599
- pattern?: string;
624
+ interface IssueFix {
625
+ summary?: string;
626
+ autofixable?: boolean;
600
627
  }
601
- interface ISetting_BooleanFlag extends ISettingCommon {
602
- type: 'boolean-flag';
603
- default?: boolean;
628
+ interface Issue {
629
+ analyzerId: string;
630
+ severity: Severity;
631
+ nodeIds: string[];
632
+ message: string;
633
+ linkIndices?: number[];
634
+ detail?: string | null;
635
+ fix?: IssueFix | null;
636
+ data?: Record<string, unknown>;
604
637
  }
605
- interface ISetting_Integer extends ISettingCommon {
606
- type: 'integer';
607
- default?: number;
608
- min?: number;
609
- max?: number;
610
- step?: number;
638
+ interface ScanStats {
639
+ /**
640
+ * Files visited by the Provider walkers. With a single Provider this
641
+ * matches `nodesCount`; with multiple Providers running on overlapping
642
+ * roots it can diverge (each yielded `IRawNode` is one walked file).
643
+ */
644
+ filesWalked: number;
645
+ /**
646
+ * Files walked but not classified by any Provider. Today every walked
647
+ * file is classified by its Provider (the `claude` Provider falls back to
648
+ * `'markdown'`), so this is always 0; the field will matter once
649
+ * multiple Providers can claim the same file.
650
+ */
651
+ filesSkipped: number;
652
+ nodesCount: number;
653
+ linksCount: number;
654
+ issuesCount: number;
655
+ durationMs: number;
611
656
  }
612
- interface ISetting_EnumOption {
613
- value: string;
614
- label: string;
657
+ interface ScanScannedBy {
658
+ name: string;
659
+ version: string;
660
+ specVersion: string;
615
661
  }
616
- interface ISetting_EnumPick extends ISettingCommon {
617
- type: 'enum-pick';
618
- options: ISetting_EnumOption[];
619
- default?: string;
662
+ type ExecutionKind = 'action';
663
+ type ExecutionStatus = 'completed' | 'failed' | 'cancelled';
664
+ type ExecutionFailureReason = 'runner-error' | 'report-invalid' | 'timeout' | 'abandoned' | 'job-file-missing' | 'user-cancelled';
665
+ type ExecutionRunner = 'cli' | 'skill' | 'in-process';
666
+ /**
667
+ * One row of execution history (`state_executions`). Matches
668
+ * `spec/schemas/execution-record.schema.json`. `nodeIds` is the camelCased
669
+ * domain field name; storage flattens it to `node_ids_json`.
670
+ */
671
+ interface ExecutionRecord {
672
+ id: string;
673
+ kind: ExecutionKind;
674
+ extensionId: string;
675
+ extensionVersion: string;
676
+ nodeIds?: string[];
677
+ contentHash?: string | null;
678
+ status: ExecutionStatus;
679
+ failureReason?: ExecutionFailureReason | null;
680
+ exitCode?: number | null;
681
+ runner?: ExecutionRunner | null;
682
+ startedAt: number;
683
+ finishedAt: number;
684
+ durationMs?: number | null;
685
+ tokensIn?: number | null;
686
+ tokensOut?: number | null;
687
+ reportPath?: string | null;
688
+ jobId?: string | null;
620
689
  }
621
- interface ISetting_EnumMultipick extends ISettingCommon {
622
- type: 'enum-multipick';
623
- options: ISetting_EnumOption[];
624
- default?: string[];
625
- min?: number;
626
- max?: number;
690
+ interface HistoryStatsTotals {
691
+ executionsCount: number;
692
+ completedCount: number;
693
+ failedCount: number;
694
+ tokensIn: number;
695
+ tokensOut: number;
696
+ durationMsTotal: number;
627
697
  }
628
- interface ISetting_PathGlob extends ISettingCommon {
629
- type: 'path-glob';
630
- default?: string;
631
- /** When true, accepts string[]; when false (default), single string. */
632
- multiple?: boolean;
698
+ interface HistoryStatsTokensPerAction {
699
+ actionId: string;
700
+ actionVersion: string;
701
+ executionsCount: number;
702
+ tokensIn: number;
703
+ tokensOut: number;
704
+ durationMsMean: number | null;
705
+ durationMsMedian: number | null;
633
706
  }
634
- interface ISetting_Regex extends ISettingCommon {
635
- type: 'regex';
636
- default?: string;
637
- /** Subset of `gimsuy`. Default `''`. */
638
- flags?: string;
707
+ interface HistoryStatsExecutionsPerPeriod {
708
+ periodStart: string;
709
+ periodUnit: 'day' | 'week' | 'month';
710
+ executionsCount: number;
711
+ tokensIn: number;
712
+ tokensOut: number;
639
713
  }
640
- interface ISetting_Secret extends ISettingCommon {
641
- type: 'secret';
642
- /**
643
- * Optional uppercase-ASCII identifier. When set in the process
644
- * environment, that value wins over any stored value (lets CI
645
- * inject without writing to disk).
646
- */
647
- envVar?: string;
714
+ interface HistoryStatsTopNode {
715
+ nodePath: string;
716
+ executionsCount: number;
717
+ lastExecutedAt: number;
648
718
  }
649
- interface ISetting_KeyValueListEntry {
650
- key: string;
651
- value: string;
719
+ interface HistoryStatsPerActionRate {
720
+ actionId: string;
721
+ rate: number;
722
+ executionsCount: number;
723
+ failedCount: number;
652
724
  }
653
- interface ISetting_KeyValueList extends ISettingCommon {
654
- type: 'key-value-list';
655
- keyLabel?: string;
656
- valueLabel?: string;
657
- default?: ISetting_KeyValueListEntry[];
658
- min?: number;
659
- max?: number;
725
+ interface HistoryStatsErrorRates {
726
+ global: number;
727
+ perAction: HistoryStatsPerActionRate[];
728
+ perFailureReason: Record<ExecutionFailureReason, number>;
660
729
  }
661
730
  /**
662
- * Discriminated union of every setting declaration shape. The plugin
663
- * author NEVER writes JSON Schema for settings, they pick one of
664
- * these `type` values and supply per-type parameters.
665
- *
666
- * Mirror of `input-types.schema.json#/$defs/ISettingDeclaration`.
667
- */
668
- type ISettingDeclaration = ISetting_StringList | ISetting_SingleString | ISetting_BooleanFlag | ISetting_Integer | ISetting_EnumPick | ISetting_EnumMultipick | ISetting_PathGlob | ISetting_Regex | ISetting_Secret | ISetting_KeyValueList;
669
- /**
670
- * Runtime value type for a setting, derived from its declaration. The
671
- * kernel exposes settings to extractors as `Record<string, TSettingValue>`
672
- * via `ctx.settings.<settingId>`; consumers that want narrow typing
673
- * narrow at the call site by reading `manifest.settings[id].type`.
674
- */
675
- type TSettingValue = string | string[] | boolean | number | ISetting_KeyValueListEntry[];
676
-
677
- /**
678
- * Base manifest shape shared by every extension kind. Mirrors
679
- * `spec/schemas/extensions/base.schema.json` at the TypeScript level.
680
- *
681
- * Spec § A.6, every extension is identified in the registry by the
682
- * qualified id `<pluginId>/<id>`. The `pluginId` field is required at the
683
- * runtime / TS level: built-ins declare it directly in
684
- * `src/extensions/built-ins.ts`; user plugins have it injected by the
685
- * `PluginLoader` from `plugin.json#/id` before the extension reaches the
686
- * registry. A plugin author who hand-codes a `pluginId` that disagrees
687
- * with the manifest's `id` is rejected as `invalid-manifest`.
688
- *
689
- * The JSON Schema deliberately does NOT model `pluginId`, the qualifier
690
- * is a runtime concern composed by the loader, not a manifest field
691
- * authors are expected to set. Stripping it before AJV validation in
692
- * the loader keeps the spec contract clean ("authors declare only the
693
- * short id").
694
- */
695
-
696
- /**
697
- * Step 9.6.6, single entry of an extension's `annotationContributions`
698
- * map. Mirrors `spec/schemas/extensions/base.schema.json#/properties/annotationContributions/additionalProperties`.
699
- *
700
- * `schema` is an INLINE JSON Schema (object literal in the manifest),
701
- * not a `$ref` to a file. The kernel compiles it at load time; an
702
- * invalid schema rejects the extension as `invalid-manifest`.
731
+ * `sm history stats --json` payload, conforming to
732
+ * `spec/schemas/history-stats.schema.json`. `elapsedMs` is the command's
733
+ * own wall-clock per `cli-contract.md` §Elapsed time.
703
734
  */
704
- interface IAnnotationContribution {
705
- /** Inline JSON Schema describing the value written under this key. */
706
- schema: Record<string, unknown>;
707
- /**
708
- * Conflict policy. `shared` (default), multiple plugins MAY write
709
- * the key; `exclusive`, only this plugin may. REQUIRED to be
710
- * `'exclusive'` when `location: 'root'`.
711
- */
712
- ownership?: 'exclusive' | 'shared';
713
- /**
714
- * Where the key lands. `namespaced` (default), under the plugin's
715
- * `<plugin-id>:` block; `root`, top-level, alongside `for` /
716
- * `annotations` / `settings` / `audit`. Cross-plugin root-key
717
- * collisions on `exclusive` are a fatal startup error.
718
- */
719
- location?: 'namespaced' | 'root';
735
+ interface HistoryStats {
736
+ schemaVersion: 1;
737
+ range: {
738
+ since: string | null;
739
+ until: string;
740
+ };
741
+ totals: HistoryStatsTotals;
742
+ tokensPerAction: HistoryStatsTokensPerAction[];
743
+ executionsPerPeriod: HistoryStatsExecutionsPerPeriod[];
744
+ topNodes: HistoryStatsTopNode[];
745
+ errorRates: HistoryStatsErrorRates;
746
+ elapsedMs: number;
720
747
  }
721
- interface IExtensionBase {
722
- id: string;
748
+ interface ScanResult {
749
+ schemaVersion: 1;
750
+ /** Unix milliseconds when the scan started. */
751
+ scannedAt: number;
723
752
  /**
724
- * Owning plugin namespace. Composed with `id` to produce the
725
- * qualified registry key `<pluginId>/<id>`. Built-ins declare this
726
- * directly; user plugins have it injected by the `PluginLoader`
727
- * from `plugin.json#/id`.
753
+ * Filesystem roots that were walked during this scan. Spec requires
754
+ * `minItems: 1`, `runScan` throws if `roots: []` is supplied.
728
755
  */
729
- pluginId: string;
730
- version: string;
731
- description?: string;
732
- stability?: Stability;
733
- preconditions?: string[];
734
- entry?: string;
735
- /**
736
- * Step 9.6.6, plugin-contributed annotation keys. Each entry maps a
737
- * key name to an inline JSON Schema + ownership + location triple.
738
- * The kernel surfaces the aggregate via `kernel.getRegisteredAnnotationKeys()`.
739
- * See `IAnnotationContribution` for the field semantics and
740
- * `plugin-author-guide.md` §Annotation contributions for examples.
741
- */
742
- annotationContributions?: Record<string, IAnnotationContribution>;
743
- /**
744
- * Plugin-contributed view contributions. Each entry maps a local
745
- * contribution id (kebab-case, unique within the extension) to a
746
- * `IViewContribution` declaration that picks a view slot by name
747
- * from the closed kernel catalog (`view-catalog.ts#TSlotName`).
748
- * The slot fixes both the renderer and the payload shape, there
749
- * is no separate "contract" abstraction. The kernel validates each
750
- * `slot` pick at load time (`invalid-manifest` on miss); the plugin
751
- * emits per-node payloads via `ctx.emitContribution(<contributionId>,
752
- * payload)` during scan; the runtime validates payloads against
753
- * the slot's payload schema. The aggregate runtime catalog is
754
- * exposed via `kernel.getRegisteredViewContributions()`. See
755
- * `architecture.md` §View contribution system.
756
- */
757
- viewContributions?: Record<string, IViewContribution>;
756
+ roots: string[];
757
+ /** Provider ids that participated in classification. Empty if no Provider matched. */
758
+ providers: string[];
759
+ /** Implementation metadata. Populated by `runScan` for self-describing output. */
760
+ scannedBy?: ScanScannedBy;
761
+ nodes: Node[];
762
+ links: Link[];
763
+ issues: Issue[];
764
+ stats: ScanStats;
758
765
  }
759
766
 
760
767
  /**
@@ -815,42 +822,35 @@ type TPluginStorage = {
815
822
  * capabilities a user might reasonably want piecemeal.
816
823
  */
817
824
  type TGranularity = 'bundle' | 'extension';
818
- /** Raw `plugin.json` shape after successful AJV validation. */
825
+ /**
826
+ * Raw `plugin.json` shape after successful AJV validation.
827
+ *
828
+ * **Structure-as-truth**: the plugin id comes from the directory name
829
+ * (`<root>/<id>/plugin.json`); it is NOT a manifest field. The loader
830
+ * rejects manifests carrying an `id` literal. Settings moved out of
831
+ * `plugin.json` into each extension's own manifest with the same refactor.
832
+ */
819
833
  interface IPluginManifest {
820
- id: string;
821
834
  version: string;
822
835
  specCompat: string;
823
836
  /**
824
- * Optional semver range against the kernel's view-slots +
825
- * input-types catalog version. Independent from `specCompat` because
826
- * the catalog evolves on its own cadence (see `architecture.md`
827
- * §View contribution system Catalog versioning). Mismatch surfaces
828
- * as `incompatible-catalog`. Absent = the plugin opts out of catalog
829
- * checking; `sm plugins doctor` warns if such a plugin actually
830
- * declares `viewContributions` or `settings`.
837
+ * Required semver range against the kernel's view-slots + input-types
838
+ * catalog version. Mismatch surfaces as `incompatible-catalog`. Promoted
839
+ * from optional to required with the structure-as-truth refactor,
840
+ * declaring compat is part of the plugin contract regardless of which
841
+ * catalog surfaces it actually consumes.
831
842
  */
832
- catalogCompat?: string;
833
- extensions: string[];
834
- description?: string;
843
+ catalogCompat: string;
844
+ /** Required short description shown in `sm plugins list` and the UI. */
845
+ description: string;
835
846
  storage?: TPluginStorage;
836
847
  /**
837
- * Toggle granularity for this plugin. Default `'bundle'`. See
838
- * `TGranularity` for the trade-off; in practice 95% of plugins want
839
- * the default.
848
+ * Toggle granularity for this plugin. Optional with default
849
+ * `'extension'` since the structure-as-truth refactor (more
850
+ * permissive default: each extension is independently toggleable
851
+ * unless the author opts into bundle-level coupling).
840
852
  */
841
853
  granularity?: TGranularity;
842
- /**
843
- * Plugin user-configurable settings. Each entry picks an `input-type`
844
- * from the closed catalog at
845
- * `spec/schemas/input-types.schema.json#/$defs/InputTypeName`.
846
- * The plugin author NEVER writes JSON Schema, they pick `type` by
847
- * name and supply per-type parameters. The kernel exposes resolved
848
- * settings to extractors via `ctx.settings.<settingId>`; settings
849
- * are read once at extractor invocation; changing a setting requires
850
- * `sm scan` to re-emit. See `architecture.md` §View contribution
851
- * system → Settings.
852
- */
853
- settings?: Record<string, ISettingDeclaration>;
854
854
  author?: string;
855
855
  license?: string;
856
856
  homepage?: string;
@@ -1566,12 +1566,14 @@ interface IParseIssue {
1566
1566
  * the path relative to the scan root; the kernel computes hashes, bytes,
1567
1567
  * and tokens on top.
1568
1568
  *
1569
- * **Spec 0.8.0**. Per-kind frontmatter schemas relocated from the spec
1570
- * to the Provider that owns them. The flat
1571
- * `defaultRefreshAction` map collapsed into the new `kinds` map: every
1572
- * kind the Provider emits gets one entry that declares both its schema
1573
- * and its refresh action. Spec keeps only `frontmatter/base.schema.json`
1574
- * (universal); per-kind schemas live with the Provider.
1569
+ * **Structure-as-truth**: each plugin carries at most one Provider, declared
1570
+ * as `<plugin>/provider.ts`. The kinds catalog lives as folders under
1571
+ * `<plugin>/kinds/<kindName>/`; each kind folder contains `schema.json`
1572
+ * (the frontmatter JSON Schema) and `kind.json` (UI metadata). The loader
1573
+ * discovers each entry by walking the directory and populates the runtime
1574
+ * `kinds` map below. The manifest itself NO LONGER carries a `kinds` map
1575
+ * or a `defaultRefreshAction` field (the UI's Refresh button consumer was
1576
+ * retired alongside it; the replacement TBD).
1575
1577
  */
1576
1578
 
1577
1579
  interface IRawNode {
@@ -1593,51 +1595,36 @@ interface IRawNode {
1593
1595
  parseIssues?: readonly IParseIssue[];
1594
1596
  }
1595
1597
  /**
1596
- * One entry in a Provider's `kinds` map. Declares both the per-kind
1597
- * frontmatter schema (path relative to the Provider's package dir, plus
1598
- * the loaded JSON object the kernel passes to AJV) and the qualified
1599
- * default refresh action id the UI dispatches for nodes of this kind.
1600
- *
1601
- * The split between `schema` (manifest-level path) and `schemaJson`
1602
- * (runtime-loaded JSON) keeps the manifest shape spec-conformant while
1603
- * letting the runtime instance carry the parsed schema without a second
1604
- * filesystem read at scan time. Built-in Providers populate `schemaJson`
1605
- * via `import schema from './schemas/skill.schema.json' with { type: 'json' }`;
1606
- * user-plugin Providers loaded by `PluginLoader` will have it filled in
1607
- * by the loader after manifest validation.
1598
+ * Runtime descriptor of one Provider kind, populated by the loader from
1599
+ * the structure under `<plugin>/kinds/<kindName>/`. The loader reads
1600
+ * `schema.json` from the kind folder, parses it once, attaches the path
1601
+ * (for diagnostics) and the parsed object (for AJV registration), and
1602
+ * reads `kind.json` for the UI metadata. The runtime descriptor lives in
1603
+ * memory; no field in this shape comes from the Provider manifest itself
1604
+ * since the structure-as-truth refactor.
1608
1605
  */
1609
1606
  interface IProviderKind {
1610
1607
  /**
1611
- * Path to the kind's frontmatter JSON Schema, relative to the
1612
- * Provider's package directory. Mirrors the spec field of the same
1613
- * name in `extensions/provider.schema.json#/properties/kinds/.../schema`.
1608
+ * Path to the kind's frontmatter JSON Schema, relative to the Provider's
1609
+ * package directory. Always `kinds/<kindName>/schema.json` under the new
1610
+ * layout. Kept on the descriptor for diagnostics (file references in
1611
+ * error messages, doctor reports).
1614
1612
  */
1615
1613
  schema: string;
1616
1614
  /**
1617
1615
  * Loaded JSON Schema document for the kind. The kernel registers this
1618
1616
  * with AJV at scan boot and validates each node's frontmatter against
1619
1617
  * it. The schema MUST extend the spec's
1620
- * `frontmatter/base.schema.json` via `allOf` + `$ref` to base's
1621
- * `$id`; the loader registers base into the same AJV instance so
1622
- * cross-package `$ref`-by-`$id` resolves transparently.
1623
- *
1624
- * `unknown` rather than a stronger type because AJV consumes any JSON
1625
- * Schema object; tightening to a concrete shape would require mirroring
1626
- * the JSON Schema vocabulary in TypeScript.
1618
+ * `frontmatter/base.schema.json` via `allOf` + `$ref` to base's `$id`;
1619
+ * the loader registers base into the same AJV instance so cross-package
1620
+ * `$ref`-by-`$id` resolves transparently.
1627
1621
  */
1628
1622
  schemaJson: unknown;
1629
- /**
1630
- * Qualified action id (`<plugin-id>/<action-id>`) the probabilistic-
1631
- * refresh UI dispatches for nodes of this kind. The kernel resolves
1632
- * the id against its qualified action registry; a dangling reference
1633
- * disables the Provider with status `invalid-manifest`.
1634
- */
1635
- defaultRefreshAction: string;
1636
1623
  /**
1637
1624
  * Presentation metadata the UI consumes to render nodes of this kind
1638
- * (palette swatches, list tags, graph nodes, filter chips). Required
1639
- * so the UI never has to invent visuals for a Provider-declared kind.
1640
- * Mirrors `extensions/provider.schema.json#/properties/kinds/.../ui`.
1625
+ * (palette swatches, list tags, graph nodes, filter chips). Read from
1626
+ * `kinds/<kindName>/kind.json#/ui`. Required so the UI never has to
1627
+ * invent visuals for a Provider-declared kind.
1641
1628
  */
1642
1629
  ui: IProviderKindUi;
1643
1630
  }
@@ -1699,23 +1686,30 @@ type TProviderKindIcon = {
1699
1686
  path: string;
1700
1687
  };
1701
1688
  interface IProvider extends IExtensionBase {
1689
+ /** Discriminant injected by the loader from the folder structure. */
1702
1690
  kind: 'provider';
1703
1691
  /**
1704
- * Catalog of node kinds this Provider emits. Keyed by kind name. Every
1705
- * kind the Provider can `classify()` MUST have an entry; an entry is
1706
- * the union of the kind's frontmatter schema and its default refresh
1707
- * action.
1692
+ * Catalog of node kinds this Provider emits. Populated by the loader
1693
+ * from the `<plugin>/kinds/<kindName>/` directory layout: each subfolder
1694
+ * becomes one entry, with `schema.json` parsed into `schemaJson` and
1695
+ * `kind.json#/ui` projected into `ui`. Authors do NOT write this map by
1696
+ * hand any more, it is a runtime descriptor only.
1708
1697
  *
1709
1698
  * The string keys are typed loosely (`string`) rather than `NodeKind`
1710
1699
  * because the value space is open by design: a future Cursor Provider
1711
1700
  * could declare `rule`, an Obsidian Provider could declare `daily`.
1712
- * The kernel's hard-coded `NodeKind` union represents the kinds the
1713
- * built-in Claude Provider emits; it is NOT the kernel-wide kind type
1714
- * (see `kernel/types.ts:NodeKind` docstring). `Node.kind`, the AJV
1715
- * `node.schema.json` validator, and the SQLite `scan_nodes.kind`
1716
- * column all accept any non-empty string an enabled Provider returns.
1717
1701
  */
1718
1702
  kinds: Record<string, IProviderKind>;
1703
+ /**
1704
+ * Optional path globs the Provider claims. Enforcement-grade since
1705
+ * structure-as-truth: a Provider declaring `roots` only receives files
1706
+ * matching at least one glob; a Provider without `roots` acts as a
1707
+ * fallback for files unmatched by every other Provider's roots. Two
1708
+ * Providers whose `roots` both match the same file produce a
1709
+ * `provider-ambiguous` issue and the file stays unclassified. Mirrors
1710
+ * `extensions/provider.schema.json#/properties/roots`.
1711
+ */
1712
+ roots?: string[];
1719
1713
  /**
1720
1714
  * Optional auxiliary JSON Schemas this Provider's per-kind schemas
1721
1715
  * `$ref` by `$id`. Registered with AJV via `addSchema` BEFORE the
@@ -1817,72 +1811,48 @@ interface IProviderReadConfig {
1817
1811
 
1818
1812
  /**
1819
1813
  * Extractor runtime contract. Consumes a single node (frontmatter + body)
1820
- * and emits its output through three context-supplied callbacks rather than
1821
- * a return value. Extractors run in isolation: they MUST NOT read other
1822
- * nodes, the graph, or the DB. Cross-node reasoning lives in rules.
1814
+ * and emits its output through context-supplied callbacks rather than a
1815
+ * return value. Extractors run in isolation: they MUST NOT read other
1816
+ * nodes, the graph, or the DB. Cross-node reasoning lives in Analyzers.
1823
1817
  *
1824
1818
  * Extractors are deterministic-only. They run synchronously inside the
1825
1819
  * scan loop; LLM-driven enrichment of a node is an Action concern, not
1826
1820
  * an Extractor concern. The Extractor context therefore exposes no
1827
1821
  * `RunnerPort`, see spec `architecture.md` §Execution modes.
1828
1822
  *
1829
- * Output channels (all on the context):
1830
- *
1831
- * - `ctx.emitLink(link)`, persist a link in the kernel's `links` table.
1832
- * Validated against `emitsLinkKinds` before insertion; an off-contract
1833
- * kind drops the link and surfaces an `extension.error` event.
1834
- * - `ctx.enrichNode(partial)`, merge canonical, kernel-curated properties
1835
- * onto the node. Strictly separate from the author-supplied frontmatter
1836
- * (the latter remains immutable and survives verbatim). Persistence
1837
- * is spec'd in § A.8.
1838
- * - `ctx.store`, plugin-scoped persistence. Present only when the
1839
- * plugin declares `storage.mode` in `plugin.json`; shape depends on the
1840
- * mode (`KvStore` for mode A, scoped `Database` for mode B). See
1841
- * `plugin-kv-api.md` for the contract.
1842
- *
1843
- * The manifest's `scope` field tells the orchestrator which parts to feed:
1844
- * `frontmatter` extractors receive an empty string for body and vice versa.
1845
- *
1846
- * Renamed from `Detector` in spec 0.8.x. The previous `detect(ctx) → Link[]`
1847
- * signature is gone; everything now flows through `extract(ctx) → void`
1848
- * and the callbacks above.
1823
+ * **Structure-as-truth**: the extension's `id` and `kind` come from the
1824
+ * filesystem (`<plugin>/extractors/<id>/index.ts`); the manifest does NOT
1825
+ * declare them. The `emitsLinkKinds` allowlist was retired with the same
1826
+ * refactor: the global closed enum of link kinds is the contract, and an
1827
+ * extractor emitting an off-enum kind keeps surfacing `extension.error`.
1828
+ * Confidence is per-emit (no manifest-level default).
1849
1829
  */
1850
1830
 
1851
1831
  /**
1852
1832
  * Output callbacks supplied by the kernel on the extractor context.
1853
- * Split out so plugin authors can name the callback shape if they
1854
- * want to mock it in unit tests without depending on the wider
1855
- * `IExtractorContext`.
1856
1833
  */
1857
1834
  interface IExtractorCallbacks {
1858
1835
  /**
1859
- * Emit a single Link. The orchestrator validates the link against the
1860
- * extractor's declared `emitsLinkKinds` before inserting it; off-contract
1861
- * links are silently dropped with an `extension.error` event.
1836
+ * Emit a single Link. Validated against the global closed enum of
1837
+ * link kinds (`invokes`, `references`, `mentions`, `supersedes`)
1838
+ * before insertion; off-enum kinds drop silently with an
1839
+ * `extension.error` event.
1862
1840
  */
1863
1841
  emitLink(link: Link): void;
1864
1842
  /**
1865
1843
  * Merge canonical, kernel-curated properties onto the current node's
1866
1844
  * enrichment layer. The author-supplied frontmatter stays untouched
1867
- * (Decision #109 in `ROADMAP.md`). Persistence and stale-tracking
1868
- * semantics live in spec § A.8; the orchestrator already buffers the
1869
- * partials and `persistScanResult` upserts them.
1845
+ * (Decision #109 in `ROADMAP.md`).
1870
1846
  */
1871
1847
  enrichNode(partial: Partial<Node>): void;
1872
1848
  /**
1873
- * Emit a per-node view contribution. The first argument is the
1874
- * extension-local Record key declared under
1875
- * `extension.viewContributions[<contributionId>]`; the second is a
1876
- * payload that conforms to the slot's payload schema in
1877
- * `spec/schemas/view-slots.schema.json#/$defs/payloads/<slot>`,
1878
- * where `<slot>` is the slot the manifest declared for this
1879
- * contribution. The orchestrator validates the payload against the
1880
- * slot's schema before persisting to `scan_contributions`; off-shape
1881
- * payloads are silently dropped with an `extension.error` event
1882
- * (mirror of `emitLink` rejecting off-`emitsLinkKinds` links).
1883
- * Calling `emitContribution` with a `contributionId` that is not
1884
- * declared in the manifest is also dropped with an `extension.error`.
1885
- * See `architecture.md` §View contribution system → Emit path.
1849
+ * Emit a per-node view contribution. `contributionId` MUST be a key
1850
+ * declared under the manifest's `ui` map; the payload MUST conform to
1851
+ * the slot's payload schema in
1852
+ * `spec/schemas/view-slots.schema.json#/$defs/payloads/<slot>`. Off-shape
1853
+ * payloads (or unknown contribution ids) drop silently with an
1854
+ * `extension.error`. Renamed from `viewContributions` with the
1855
+ * structure-as-truth refactor.
1886
1856
  */
1887
1857
  emitContribution(contributionId: string, payload: unknown): void;
1888
1858
  }
@@ -1890,49 +1860,48 @@ interface IExtractorContext extends IExtractorCallbacks {
1890
1860
  node: Node;
1891
1861
  body: string;
1892
1862
  frontmatter: Record<string, unknown>;
1863
+ /**
1864
+ * Resolved values of the extension's declared `settings`, populated
1865
+ * from project config + user overrides. Empty object when no settings
1866
+ * are declared.
1867
+ */
1868
+ settings: Record<string, unknown>;
1893
1869
  /**
1894
1870
  * Plugin-scoped persistence. Optional because not every plugin declares
1895
- * a `storage.mode` in `plugin.json`. Shape: `KvStoreWrapper` for mode A
1896
- * (`set(key, value)`), `DedicatedStoreWrapper` for mode B
1897
- * (`write(table, row)`). See `spec/plugin-kv-api.md`.
1898
- *
1899
- * Typed as `unknown` so this contract module stays free of any
1900
- * adapter-side imports, the concrete `IPluginStore` lives in
1901
- * `kernel/adapters/plugin-store.js`. Plugin authors narrow at the
1902
- * call site based on the storage mode declared in their manifest.
1903
- * The orchestrator looks up the wrapper per-extractor in
1904
- * `RunScanOptions.pluginStores` (keyed by `pluginId`) and attaches
1905
- * it here.
1871
+ * a `storage.mode` in `plugin.json`. See `spec/plugin-kv-api.md`.
1906
1872
  */
1907
1873
  store?: unknown;
1908
1874
  }
1875
+ /**
1876
+ * Optional declarative filter shared with Analyzer and Action. The kernel
1877
+ * applies a single matcher: every declared sub-filter must hold for the
1878
+ * extension to be invoked on the candidate node.
1879
+ */
1880
+ interface IExtensionPrecondition {
1881
+ /**
1882
+ * Qualified node kinds the extension accepts, written as
1883
+ * `<provider-plugin>/<kindName>` (e.g. `claude/agent`). Unknown
1884
+ * qualified kinds load OK but surface a `precondition-kind-unknown`
1885
+ * warning in `sm plugins doctor`.
1886
+ */
1887
+ kind?: string[];
1888
+ /** Provider ids whose nodes the extension accepts. */
1889
+ provider?: string[];
1890
+ }
1909
1891
  interface IExtractor extends IExtensionBase {
1892
+ /** Discriminant injected by the loader from the folder structure. */
1910
1893
  kind: 'extractor';
1911
- emitsLinkKinds: LinkKind[];
1912
- defaultConfidence: Confidence;
1913
- scope: 'frontmatter' | 'body' | 'both';
1914
- /**
1915
- * Optional opt-in filter on `node.kind`. When declared, the orchestrator
1916
- * skips invocation of `extract()` for any node whose `kind` is NOT in
1917
- * this list, fail-fast, before context construction, so the extractor
1918
- * wastes zero CPU on inapplicable nodes.
1919
- *
1920
- * Absent (`undefined`) is the default: the extractor applies to every
1921
- * kind. There are no wildcards, the absence of the field already
1922
- * encodes "every kind". An empty array (`[]`) is rejected at load
1923
- * time by AJV (`minItems: 1` in the schema).
1924
- *
1925
- * Unknown kinds (no installed Provider declares them) do NOT block
1926
- * the load: the extractor keeps `loaded` status and `sm plugins doctor`
1927
- * surfaces a warning. The Provider that declares the kind may arrive
1928
- * later (e.g. a user installs the corresponding plugin).
1929
- *
1930
- * Spec: `spec/schemas/extensions/extractor.schema.json#/properties/applicableKinds`.
1894
+ /** Which slice of the node the orchestrator feeds. Defaults to `both`. */
1895
+ scope?: 'frontmatter' | 'body' | 'both';
1896
+ /**
1897
+ * Optional precondition that gates `extract()` invocation. Replaces
1898
+ * the old `applicableKinds` field; same shape used by Analyzer and
1899
+ * Action so the kernel ships a single matcher.
1931
1900
  */
1932
- applicableKinds?: string[];
1901
+ precondition?: IExtensionPrecondition;
1933
1902
  /**
1934
1903
  * Extractor entry point. Returns nothing; output flows through
1935
- * `ctx.emitLink`, `ctx.enrichNode`, and `ctx.store`.
1904
+ * `ctx.emitLink`, `ctx.enrichNode`, `ctx.emitContribution`, `ctx.store`.
1936
1905
  */
1937
1906
  extract(ctx: IExtractorContext): void | Promise<void>;
1938
1907
  }
@@ -2056,26 +2025,26 @@ interface IAnalyzerContext {
2056
2025
  emitContribution(nodePath: string, contributionId: string, payload: unknown): void;
2057
2026
  }
2058
2027
  interface IAnalyzer extends IExtensionBase {
2028
+ /** Discriminant injected by the loader from the folder structure. */
2059
2029
  kind: 'analyzer';
2060
2030
  /**
2061
2031
  * Execution mode. Optional in the manifest with a default of
2062
- * `deterministic` per `spec/schemas/extensions/analyzer.schema.json`.
2032
+ * `deterministic`. `probabilistic` analyzers run only as queued jobs.
2063
2033
  */
2064
2034
  mode?: TExecutionMode;
2065
2035
  /**
2066
- * Qualified `<pluginId>/<id>` Action ids the analyzer recommends to
2067
- * resolve its findings. Distinct from `Action.precondition` (which
2068
- * declares which nodes an Action applies to from the Action side);
2069
- * this field declares which Actions are relevant when this
2070
- * Analyzer fires from the Analyzer side. Actions are per-node by
2071
- * design (project-level cleanup verbs like orphan file prune or
2072
- * contribution relink are CLI verbs, not Actions) and are NOT
2073
- * surfaced through this field. The UI consumes it in the node
2074
- * inspector under "Recommended for issues". Optional; omit when no
2075
- * Action resolves the finding (e.g. `core/superseded` surfaces
2076
- * deliberate user declarations, not problems).
2077
- */
2078
- recommendedActions?: readonly string[];
2036
+ * Optional declarative precondition. Same shape used by Extractor and
2037
+ * Action. The analyzer is invoked only when the graph contains at
2038
+ * least one node matching every declared sub-filter.
2039
+ *
2040
+ * The reverse relationship (which Actions resolve this analyzer's
2041
+ * findings) is now declared on the Action side via
2042
+ * `precondition.analyzerIds` (Modelo B). The old
2043
+ * `recommendedActions` field was retired with the structure-as-truth
2044
+ * refactor; the UI matches against Action manifests when surfacing
2045
+ * "Resolve this issue" affordances.
2046
+ */
2047
+ precondition?: IExtensionPrecondition;
2079
2048
  evaluate(ctx: IAnalyzerContext): Issue[] | Promise<Issue[]>;
2080
2049
  }
2081
2050
 
@@ -2085,182 +2054,107 @@ interface IAnalyzer extends IExtensionBase {
2085
2054
  *
2086
2055
  * Actions operate on one or more nodes in one of two execution modes:
2087
2056
  *
2088
- * - `deterministic`, code runs in-process; the action computes the
2089
- * report synchronously and returns it. No job file, no runner.
2090
- * - `probabilistic`, the kernel renders a prompt + preamble into a
2091
- * job file; a runner executes it via `RunnerPort` against an LLM;
2092
- * `sm record` closes the job and validates the report against
2093
- * `reportSchemaRef`.
2094
- *
2095
- * **Deferred runtime invocation.** The dispatcher (`Action.run(ctx)` for
2096
- * deterministic; the `RunnerPort` + `sm record` round-trip for
2097
- * probabilistic) lands with the job subsystem (Decision #114 in
2098
- * `ROADMAP.md`). Today the loader still validates `kind: 'action'`
2099
- * manifests against `extension-action.schema.json` and the registry
2100
- * holds them, `sm actions show` and the precondition gating UI consume
2101
- * the manifest data. The runtime entry point is intentionally absent
2102
- * from `IAction` so plugin authors don't ship a method the kernel will
2103
- * not call until the job subsystem is in place; when it ships, the
2104
- * method shape will land here without breaking the manifest contract.
2105
- *
2106
- * Mirrors `extensions/action.schema.json`:
2107
- *
2108
- * - `mode` (required), discriminator between the two modes.
2109
- * - `reportSchemaRef` (required), JSON Schema reference the report
2110
- * MUST validate against. MUST extend `report-base.schema.json`.
2111
- * - `promptTemplateRef`, REQUIRED when `mode: 'probabilistic'`,
2112
- * FORBIDDEN when `mode: 'deterministic'`. The schema's conditional
2113
- * `allOf` enforces both directions; the runtime contract simply
2114
- * surfaces the field as optional and lets the loader catch shape
2115
- * violations at AJV time.
2116
- * - `expectedDurationSeconds`, REQUIRED for probabilistic (drives
2117
- * TTL); advisory for deterministic.
2118
- * - `precondition`, declarative filter consumed by `--all` fan-out,
2119
- * UI button gating, `sm actions show`.
2120
- * - `expectedTools`, hint to Skill / CLI runners about expected
2121
- * tools (no normative enforcement in v0).
2122
- * - `fanOutPolicy`, `'per-node'` (default) vs `'batch'`.
2057
+ * - `deterministic` (default), code runs in-process; the action computes
2058
+ * the report synchronously and returns it. No job file, no runner.
2059
+ * - `probabilistic`, the kernel renders `<action-dir>/prompt.md` + preamble
2060
+ * into a job file; a runner executes it via `RunnerPort` against an
2061
+ * LLM; `sm record` closes the job and validates the report against
2062
+ * `<action-dir>/report.schema.json`.
2063
+ *
2064
+ * **Structure-as-truth file conventions**: every Action carries
2065
+ * `<action-dir>/report.schema.json` (the JSON Schema for the report, MUST
2066
+ * extend `report-base.schema.json`). Probabilistic Actions additionally
2067
+ * carry `<action-dir>/prompt.md` (the prompt template). The loader resolves
2068
+ * both by convention; missing or mis-placed files surface as `load-error`.
2069
+ * The `reportSchemaRef` / `promptTemplateRef` manifest fields were retired
2070
+ * with the same refactor.
2071
+ *
2072
+ * **`prob*` prefix convention**: manifest fields that only apply when
2073
+ * `mode=probabilistic` start with `prob`. Today only
2074
+ * `probExpectedDurationSeconds` follows this convention.
2075
+ *
2076
+ * **Deferred runtime invocation**: the dispatcher (`Action.invoke(input, ctx)`
2077
+ * for deterministic; the `RunnerPort` + `sm record` round-trip for
2078
+ * probabilistic) lands fully with the job subsystem (Decision #114 in
2079
+ * `ROADMAP.md`). The kernel today still validates manifests and surfaces
2080
+ * the precondition gating to the UI; the runtime entry point stays
2081
+ * optional until the job subsystem ships.
2123
2082
  */
2124
2083
 
2125
- /**
2126
- * Single sidecar write payload an Action can return. Discriminated union so
2127
- * future write kinds (storage rows, plugin KV, etc.) can land additively
2128
- * without breaking consumers that only handle `kind: 'sidecar'`.
2129
- *
2130
- * - `path`, absolute path to the `.sm` file the kernel must materialise
2131
- * the change into. Resolved by the Action from the node's absolute
2132
- * path via `sidecarPathFor()`.
2133
- * - `changes`, partial sidecar root used as a deep-merge patch (NOT a
2134
- * full replacement). Arrays REPLACE; objects RECURSE. Reason:
2135
- * sidecars are shared-write between skill-map core and plugins;
2136
- * a full replace would clobber `<plugin-id>:` namespaced blocks.
2137
- */
2138
2084
  type TActionWrite = {
2139
2085
  kind: 'sidecar';
2140
2086
  path: string;
2141
2087
  changes: Record<string, unknown>;
2142
2088
  };
2143
- /**
2144
- * Result envelope returned by deterministic Actions. The `report` field
2145
- * carries the typed report payload (each Action declares its shape via
2146
- * `reportSchemaRef`); `writes` is opt-in, Actions that do not mutate
2147
- * persistent state simply omit it.
2148
- */
2149
2089
  interface IActionResult<TReport = unknown> {
2150
2090
  report: TReport;
2151
2091
  writes?: TActionWrite[];
2152
2092
  }
2153
- /**
2154
- * Runtime context passed to a deterministic Action's `invoke()` method.
2155
- * Minimal surface, Actions stay pure (no IO inside `invoke`); the kernel
2156
- * materialises any returned `writes` after the call.
2157
- *
2158
- * - `node`, the target `Node` the Action operates on. Open-by-design;
2159
- * batch / fan-out flows pick the matching nodes upstream.
2160
- * - `nodeAbsolutePath`, absolute path to the node's `.md` file on
2161
- * disk. The Action uses this to compute the sidecar path it returns
2162
- * in a `TActionWrite`. Surfaced separately from `node.path` (which is
2163
- * the relative scope-root form) so Actions never compose absolute
2164
- * paths from `node.path` themselves.
2165
- * - `invoker`, identity of the caller; written into the sidecar's
2166
- * `audit.lastBumpedBy` when the Action chooses to. CLI invocations
2167
- * pass `'cli'`; plugin-driven invocations pass `'plugin:<plugin-id>'`.
2168
- * - `now`, clock function; tests inject a deterministic source.
2169
- * Defaults to `() => new Date()` at the composition root.
2170
- */
2171
2093
  interface IActionContext {
2172
2094
  node: Node;
2173
2095
  nodeAbsolutePath: string;
2174
2096
  invoker: string;
2175
2097
  now: () => Date;
2098
+ /**
2099
+ * Resolved values of the Action's declared `settings`. Empty when no
2100
+ * settings are declared on the manifest.
2101
+ */
2102
+ settings: Record<string, unknown>;
2176
2103
  }
2177
2104
  /**
2178
2105
  * Declarative filter applied by `--all` fan-out, UI button gating, and
2179
- * `sm actions show`. All fields optional, an empty precondition matches
2180
- * every node.
2106
+ * `sm actions show`. Same shape used by Extractor and Analyzer so the
2107
+ * kernel ships a single matcher; the `analyzerIds` field is unique to
2108
+ * Action and powers Modelo B (Action declares which Analyzer findings
2109
+ * it resolves; replaces the deprecated `Analyzer.recommendedActions`).
2181
2110
  */
2182
2111
  interface IActionPrecondition {
2183
2112
  /**
2184
- * Node kinds this action accepts. Open-by-design (matches
2185
- * `node.schema.json#/properties/kind`): an action declared with
2186
- * `kind: ['cursorRule']` is valid as long as some Provider classifies
2187
- * into `cursorRule`. Omitted → any kind.
2113
+ * Qualified node kinds this action accepts, written as
2114
+ * `<provider-plugin>/<kindName>` (e.g. `claude/agent`). Unknown
2115
+ * qualified kinds load OK but surface a `precondition-kind-unknown`
2116
+ * warning in `sm plugins doctor`.
2188
2117
  */
2189
2118
  kind?: string[];
2190
- /** Provider ids whose nodes this action accepts. Omitted → any Provider. */
2119
+ /** Provider ids whose nodes this action accepts. */
2191
2120
  provider?: string[];
2192
- /** Node stability filter. */
2193
- stability?: Array<'experimental' | 'stable' | 'deprecated'>;
2194
2121
  /**
2195
- * Free-form precondition strings the kernel forwards to the action for
2196
- * runtime evaluation (example: `frontmatter.metadata.source != null`).
2122
+ * Qualified analyzer ids whose findings this action resolves
2123
+ * (`<plugin>/<analyzer>` or `<plugin>/<analyzer>:<sub-id>` when the
2124
+ * analyzer emits sub-typed issues). The UI matches against this list
2125
+ * to surface "Resolve this issue" affordances. Dangling references
2126
+ * warn via `recommended-action-missing` in `sm plugins doctor` but
2127
+ * do NOT block load.
2197
2128
  */
2198
- custom?: string[];
2129
+ analyzerIds?: string[];
2199
2130
  }
2200
2131
  interface IAction extends IExtensionBase {
2132
+ /** Discriminant injected by the loader from the folder structure. */
2201
2133
  kind: 'action';
2202
2134
  /**
2203
- * Execution mode discriminator. Required per
2204
- * `extensions/action.schema.json`.
2205
- */
2206
- mode: TExecutionMode;
2207
- /**
2208
- * Reference to the JSON Schema the report MUST validate against. MUST
2209
- * extend `report-base.schema.json` (directly or transitively).
2210
- * Validation failure → job transitions to `failed` with reason
2211
- * `report-invalid`.
2135
+ * Execution mode. Optional with default `deterministic` since the
2136
+ * structure-as-truth refactor.
2212
2137
  */
2213
- reportSchemaRef: string;
2214
- /**
2215
- * Best-effort estimate of wall-clock duration in seconds. Drives TTL
2216
- * (`ttl = max(expectedDurationSeconds × graceMultiplier,
2217
- * minimumTtlSeconds)`). Required for `probabilistic`; advisory for
2218
- * `deterministic`.
2219
- */
2220
- expectedDurationSeconds?: number;
2138
+ mode?: TExecutionMode;
2221
2139
  /**
2222
- * Path (relative to the extension file) to the prompt template the
2223
- * kernel renders at `sm job submit`. REQUIRED when `mode:
2224
- * 'probabilistic'`; FORBIDDEN when `mode: 'deterministic'`. The
2225
- * conditional shape is enforced by AJV at load time; the runtime
2226
- * contract carries the field as optional so both modes share one
2227
- * interface.
2140
+ * Best-effort estimate of wall-clock duration in seconds when
2141
+ * `mode=probabilistic`. Drives TTL
2142
+ * (`ttl = max(probExpectedDurationSeconds × graceMultiplier,
2143
+ * minimumTtlSeconds)`). Required by the schema's conditional for
2144
+ * probabilistic Actions; ignored otherwise. Renamed from
2145
+ * `expectedDurationSeconds` with the `prob*` prefix convention.
2228
2146
  */
2229
- promptTemplateRef?: string;
2147
+ probExpectedDurationSeconds?: number;
2230
2148
  /**
2231
2149
  * Optional declarative filter; absent → applies to every node.
2232
2150
  */
2233
2151
  precondition?: IActionPrecondition;
2234
2152
  /**
2235
- * Hint to Skill / CLI runners about what tools the rendered prompt
2236
- * expects access to (`Bash`, `Read`, `WebSearch`, …). No normative
2237
- * enforcement in v0.
2238
- */
2239
- expectedTools?: string[];
2240
- /**
2241
- * `'per-node'` (default): `sm job submit --all` produces one job per
2242
- * matching node. `'batch'`: one job whose prompt template receives the
2243
- * full list. Batch actions tend to hit context limits; use sparingly.
2244
- */
2245
- fanOutPolicy?: 'per-node' | 'batch';
2246
- /**
2247
- * Deterministic invocation entry point. OPTIONAL on the runtime
2248
- * contract for backward compatibility with the manifest-only era
2249
- * (Decision #114), actions that ship for the future probabilistic
2250
- * runner / record path leave it absent and the kernel never calls it.
2251
- * Step 9.6.3 (Decision #125) introduces the first concrete consumer:
2252
- * the built-in `bump` Action implements `invoke()` and returns a
2253
- * `writes: [{ kind: 'sidecar', ... }]` payload that the kernel
2254
- * materialises through `ISidecarStore`.
2255
- *
2256
- * Implementations MUST stay pure, no IO inside `invoke()`. The Action
2257
- * computes the patch and returns it; the kernel reads the on-disk
2258
- * sidecar, deep-merges, validates, and writes back inside its critical
2259
- * section.
2260
- *
2261
- * `TInput` is action-specific; the built-in `bump` Action declares
2262
- * `{ force?: boolean; reason?: string }`. The signature stays generic
2263
- * so each Action narrows it locally without forcing a common base.
2153
+ * Deterministic invocation entry point. Optional on the runtime
2154
+ * contract until the job subsystem ships; Actions that ship for the
2155
+ * future probabilistic runner / record path leave it absent.
2156
+ * Implementations MUST stay pure (no IO inside `invoke()`); the
2157
+ * kernel materialises any returned `writes` after the call.
2264
2158
  */
2265
2159
  invoke?: <TInput, TReport>(input: TInput, ctx: IActionContext) => IActionResult<TReport>;
2266
2160
  }
@@ -2269,19 +2163,14 @@ interface IAction extends IExtensionBase {
2269
2163
  * Formatter runtime contract. Turns the (nodes, links, issues) graph into
2270
2164
  * a textual representation for `sm graph --format <name>`.
2271
2165
  *
2272
- * Two adjacent names live on the same instance:
2273
- *
2274
- * - `formatId: string`, the manifest field consumed by the
2275
- * `--format <name>` CLI flag. The kernel's lookup is
2276
- * `formatters.find((f) => f.formatId === flag)`.
2277
- * - `format(ctx) → string`, the runtime method. Receives the full
2278
- * graph and returns the serialized output. Output MUST be
2279
- * byte-deterministic for the same input (the snapshot-test suite
2280
- * relies on this).
2166
+ * **Structure-as-truth**: the format id comes from the formatter's folder
2167
+ * name (`<plugin>/formatters/<formatId>/index.ts`); it is injected by the
2168
+ * loader into `id` and surfaced here as `formatId` for the existing CLI
2169
+ * lookup (`formatters.find((f) => f.formatId === flag)`). Manifests carrying
2170
+ * a `formatId` literal are rejected as `invalid-manifest`.
2281
2171
  *
2282
- * The split (`formatId` vs `format`) is deliberate: it keeps the method
2283
- * named after the kind (`Formatter.format()` reads naturally) while the
2284
- * field carries the identifier the user types on the command line.
2172
+ * All formatters accept the `--filter` expression; opting out is no longer
2173
+ * supported (the old `supportsFilter` field was retired).
2285
2174
  */
2286
2175
 
2287
2176
  interface IFormatterContext {
@@ -2290,19 +2179,28 @@ interface IFormatterContext {
2290
2179
  issues: Issue[];
2291
2180
  /**
2292
2181
  * Full persisted scan, when the caller has it on hand. Optional so
2293
- * existing formatters that only consume (nodes, links, issues) keep
2294
- * working unchanged; formatters whose output mirrors a `ScanResult`
2295
- * envelope (today: the built-in `json` formatter under
2296
- * `built-in-plugins/formatters/json/`) read this to project the
2297
- * canonical document verbatim. `undefined` when the caller has only
2298
- * the three primary arrays (back-compat with older drivers).
2182
+ * formatters that only consume (nodes, links, issues) keep working
2183
+ * unchanged; formatters whose output mirrors a `ScanResult` envelope
2184
+ * (today: the built-in `json` formatter) read this to project the
2185
+ * canonical document verbatim.
2299
2186
  */
2300
2187
  scanResult?: ScanResult;
2301
2188
  }
2302
2189
  interface IFormatter extends IExtensionBase {
2190
+ /** Discriminant injected by the loader from the folder structure. */
2303
2191
  kind: 'formatter';
2304
- /** Format identifier consumed by `sm graph --format <name>`. */
2192
+ /**
2193
+ * Format identifier consumed by `sm graph --format <name>`. Injected
2194
+ * by the loader from the formatter folder name. Surfaced as a top-level
2195
+ * field (rather than reusing `id`) so the existing CLI lookup keeps its
2196
+ * domain-specific name.
2197
+ */
2305
2198
  formatId: string;
2199
+ /**
2200
+ * MIME-like hint surfaced when streaming over HTTP. Advisory; default
2201
+ * `'text/plain'`.
2202
+ */
2203
+ contentType?: string;
2306
2204
  /** Serialize the graph into a string. Deterministic-only. */
2307
2205
  format(ctx: IFormatterContext): string;
2308
2206
  }
@@ -2327,16 +2225,14 @@ interface IFormatter extends IExtensionBase {
2327
2225
  * Declaring a trigger outside the curated set yields
2328
2226
  * `invalid-manifest` at load time.
2329
2227
  *
2330
- * Dual-mode (declared in manifest):
2331
- *
2332
- * - `deterministic` (default): `on(ctx)` runs in-process during the
2333
- * dispatch of the matching event, synchronously between the
2334
- * event's emission and the next pipeline step. Errors are caught
2335
- * by the dispatcher, logged via `extension.error`, and never
2336
- * block the main flow.
2337
- * - `probabilistic`: the hook is enqueued as a job. Until the job
2338
- * subsystem ships, probabilistic hooks load but skip dispatch
2339
- * with a stderr advisory (Decision #114 in `ROADMAP.md`).
2228
+ * **Deterministic-only since the structure-as-truth refactor**: the
2229
+ * `mode` field was removed from the manifest. `on(ctx)` runs in-process
2230
+ * during the dispatch of the matching event, synchronously between the
2231
+ * event's emission and the next pipeline step. Errors are caught by
2232
+ * the dispatcher, logged via `extension.error`, and never block the
2233
+ * main flow. To react to a lifecycle event with an LLM call, write a
2234
+ * deterministic Hook that enqueues a probabilistic Action via
2235
+ * `ctx.queue('<plugin>/<action>', payload)`.
2340
2236
  *
2341
2237
  * Curated trigger set (per spec § A.11):
2342
2238
  *
@@ -2419,12 +2315,12 @@ interface IHookContext {
2419
2315
  */
2420
2316
  jobResult?: unknown;
2421
2317
  /**
2422
- * `RunnerPort` injection for `probabilistic` hooks. `undefined` for
2423
- * `deterministic` mode (the default). Probabilistic hooks land with
2424
- * the job subsystem; the field is reserved here so the runtime
2425
- * contract is forward-compatible without a major bump.
2318
+ * Enqueue a probabilistic Action as a deferred job. The Hook stays
2319
+ * deterministic; LLM dispatch happens via the job subsystem the
2320
+ * Action drives. Available when the job subsystem is wired in;
2321
+ * placeholder `undefined` for legacy callers.
2426
2322
  */
2427
- runner?: unknown;
2323
+ queue?: (actionId: string, payload: unknown) => void;
2428
2324
  }
2429
2325
  /**
2430
2326
  * Optional declarative filter applied by the dispatcher BEFORE
@@ -2444,14 +2340,8 @@ interface IHookContext {
2444
2340
  */
2445
2341
  type THookFilter = Record<string, string | number | boolean>;
2446
2342
  interface IHook extends IExtensionBase {
2343
+ /** Discriminant injected by the loader from the folder structure. */
2447
2344
  kind: 'hook';
2448
- /**
2449
- * Execution mode. Optional in the manifest with a default of
2450
- * `deterministic` per `spec/schemas/extensions/hook.schema.json`.
2451
- * Probabilistic hooks load but skip dispatch with a stderr advisory
2452
- * until the job subsystem ships (Decision #114).
2453
- */
2454
- mode?: TExecutionMode;
2455
2345
  /**
2456
2346
  * Subset of the curated lifecycle trigger set this hook subscribes
2457
2347
  * to. MUST be non-empty; every entry MUST be a member of
@@ -2646,8 +2536,17 @@ interface RenameOp {
2646
2536
  * order so the same input always produces the same matches,
2647
2537
  * required for reproducible tests and conformance fixtures (the spec
2648
2538
  * does not prescribe an order, but stability is the obvious contract).
2539
+ *
2540
+ * `silenced` (optional): predicate that returns true when a path
2541
+ * disappeared from the current scan because the project's
2542
+ * `.skillmapignore` (or any other ignore source) started excluding
2543
+ * it, not because the file was actually deleted from disk. The
2544
+ * orphan flagger uses it to skip the info-severity issue for those
2545
+ * paths: silencing a node intentionally is not the same as losing
2546
+ * one without a rename match. Callers that don't pass it preserve
2547
+ * the previous behaviour (treat every disappearance as an orphan).
2649
2548
  */
2650
- declare function detectRenamesAndOrphans(prior: ScanResult, current: Node[], issues: Issue[]): RenameOp[];
2549
+ declare function detectRenamesAndOrphans(prior: ScanResult, current: Node[], issues: Issue[], silenced?: (path: string) => boolean): RenameOp[];
2651
2550
 
2652
2551
  /**
2653
2552
  * Scan orchestrator, runs the Provider → extractor → analyzer pipeline across