@skill-map/cli 0.17.0 → 0.18.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.
@@ -8,7 +8,7 @@
8
8
  *
9
9
  * --- Naming convention (kernel-wide) -------------------------------------
10
10
  *
11
- * Four categories with distinct prefix rules; the rules are deliberate
11
+ * Five categories with distinct prefix rules; the rules are deliberate
12
12
  * even though they look mixed at first read:
13
13
  *
14
14
  * 1. **Domain types** — every shape that mirrors a `spec/schemas/*.json`
@@ -31,13 +31,22 @@
31
31
  * reading as the rest of TypeScript's plugin ecosystems where a
32
32
  * shape is implementable.
33
33
  *
34
- * 4. **Internal shapes** — option bags, result records, config
35
- * slices, anything passed across function boundaries inside the
36
- * kernel / CLI but not part of the spec: `IRunScanOptions` (well,
37
- * `RunScanOptions` — see below), `IPluginRuntimeBundle`,
38
- * `IPruneResult`, `IMigrationFile`, `IDbLocationOptions`. **`I`
39
- * prefix.** The prefix matches category 3 because both are
40
- * "shapes that live in TypeScript only, never in JSON".
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".
41
+ *
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).
41
50
  *
42
51
  * Edge cases worth knowing:
43
52
  * - The following category-4 names lack the `I` prefix because
@@ -46,15 +55,16 @@
46
55
  * option bags / records: `RunScanOptions`, `RenameOp`;
47
56
  * TS-only exports from `kernel/index.ts` / `kernel/ports/*`:
48
57
  * `Kernel`, `ProgressEvent`, `LogRecord`, `NodeStat`.
49
- * New public option bags and TS-only exports MUST still use
50
- * `I*`; removing a name from this list is a breaking change.
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.
51
61
  * - `IDatabase` (SQLite schema) is category 4 but lives in
52
62
  * `adapters/sqlite/schema.ts`, not here. Same rule applies.
53
63
  *
54
64
  * If you find yourself wanting to add a new type and aren't sure which
55
65
  * bucket it falls in: ask "does this shape exist in the spec?". If
56
- * yes, no prefix and align the name with the schema. If no, `I`
57
- * prefix.
66
+ * yes, no prefix and align the name with the schema. If no, `I` prefix
67
+ * for `interface`, `T` prefix for `type` aliases.
58
68
  */
59
69
  /**
60
70
  * The four node kinds the **built-in Claude Provider** declares — `skill`,
@@ -70,8 +80,12 @@
70
80
  * Step 9.5 dropped `hook` from the catalog: `.claude/hooks/*.md` is NOT
71
81
  * an Anthropic-defined node type — hooks live in `settings.json` or as
72
82
  * sub-objects of agent / skill frontmatter (see
73
- * https://code.claude.com/docs/en/hooks.md). Files at the old path now
74
- * classify as `note` via the Provider's fallback.
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`.
75
89
  *
76
90
  * This alias survives because:
77
91
  * - claude-specific code legitimately wants to switch on the four
@@ -85,7 +99,7 @@
85
99
  * For "any kind a Provider could declare", use plain `string`. Only use
86
100
  * `NodeKind` when the code is intentionally claude-catalog-specific.
87
101
  */
88
- type NodeKind = 'skill' | 'agent' | 'command' | 'note';
102
+ type NodeKind = 'skill' | 'agent' | 'command' | 'markdown';
89
103
  type LinkKind = 'invokes' | 'references' | 'mentions' | 'supersedes';
90
104
  type Confidence = 'high' | 'medium' | 'low';
91
105
  type Severity = 'error' | 'warn' | 'info';
@@ -140,10 +154,66 @@ interface Node {
140
154
  title?: string | null;
141
155
  description?: string | null;
142
156
  stability?: Stability | null;
143
- version?: string | null;
144
- author?: string | null;
157
+ /**
158
+ * Monotonic version counter sourced from the sidecar's
159
+ * `annotations.version` (Step 9.6.2). `null` when no sidecar accompanies
160
+ * the node, or when the sidecar omits `version`. Pre-9.6.2 the field
161
+ * was a semver string sourced from `frontmatter.metadata.version`;
162
+ * see migration `002_sidecar_columns.sql` and Decision #125.
163
+ */
164
+ version?: number | null;
145
165
  frontmatter?: Record<string, unknown>;
146
166
  tokens?: TripleSplit;
167
+ /**
168
+ * Step 9.6.2 — sidecar denormalisation surface. Populated by the
169
+ * orchestrator at scan time; absent when the orchestrator did not
170
+ * inspect sidecars (legacy code paths) or when no sidecar accompanies
171
+ * the node. Read by `annotation-stale` rule and the persistence layer.
172
+ */
173
+ sidecar?: ISidecarOverlay | null;
174
+ /**
175
+ * Per-user "favorite" flag, decorated by the BFF on `/api/nodes` and
176
+ * `/api/nodes/:pathB64` responses via in-memory `Set` lookup against
177
+ * `state_node_favorites`. Absent on emissions that don't carry per-user
178
+ * state (e.g. `sm export --json`); consumers that don't recognise the
179
+ * field MUST treat the absence as "unknown" rather than "false" — a
180
+ * truthy `isFavorite` only ever lands when the BFF set it.
181
+ */
182
+ isFavorite?: boolean;
183
+ }
184
+ /**
185
+ * Drift status of a co-located `.sm` sidecar relative to the live
186
+ * node hashes. Mirrors `TSidecarStatus` on the SQLite schema.
187
+ */
188
+ type SidecarStatus = 'fresh' | 'stale-body' | 'stale-frontmatter' | 'stale-both';
189
+ /**
190
+ * Sidecar overlay attached to a `Node` after the orchestrator parses
191
+ * `<basename>.sm`. `present === false` is the empty overlay (no
192
+ * sidecar accompanies the node); the other fields are absent or null
193
+ * in that case. When `present === true` and parse + validation
194
+ * succeeded, `status` carries the drift state and `annotations` carries
195
+ * the parsed (typed) `annotations:` block.
196
+ */
197
+ interface ISidecarOverlay {
198
+ present: boolean;
199
+ status?: SidecarStatus | null;
200
+ /**
201
+ * Parsed `annotations:` block. Untyped object — schema lives in
202
+ * `spec/schemas/annotations.schema.json`. Null when no sidecar or
203
+ * the block is empty/absent.
204
+ */
205
+ annotations?: Record<string, unknown> | null;
206
+ /**
207
+ * R15 closure (2026-05-07) — full parsed YAML root of the sidecar
208
+ * (the entire `.sm` payload, mirroring `sidecar.schema.json`). Surfaced
209
+ * so the UI inspector can render `for:`, `audit:`, `settings:`, and
210
+ * `<plugin-id>:` namespace blocks without re-reading the file. NULL
211
+ * when no sidecar is present, or when the sidecar exists but failed
212
+ * to parse / validate. The `annotations` field above stays — it
213
+ * duplicates `root.annotations` intentionally so existing consumers
214
+ * keep working unchanged.
215
+ */
216
+ root?: Record<string, unknown> | null;
147
217
  }
148
218
  interface Link {
149
219
  /** The originating node — the path of the file the extractor was reading
@@ -186,8 +256,8 @@ interface ScanStats {
186
256
  /**
187
257
  * Files walked but not classified by any Provider. Today every walked
188
258
  * file is classified by its Provider (the `claude` Provider falls back to
189
- * `'note'`), so this is always 0; the field will matter once multiple
190
- * Providers can claim the same file.
259
+ * `'markdown'`), so this is always 0; the field will matter once
260
+ * multiple Providers can claim the same file.
191
261
  */
192
262
  filesSkipped: number;
193
263
  nodesCount: number;
@@ -369,6 +439,98 @@ declare class Registry {
369
439
  totalCount(): number;
370
440
  }
371
441
 
442
+ /**
443
+ * Step 9.6.6 — runtime annotation-contribution catalog types.
444
+ *
445
+ * Lives in its own module (rather than `kernel/index.ts`) so consumers
446
+ * deep inside the kernel — `IRuleContext`, the BFF route factories,
447
+ * future Action contexts — can depend on the catalog shape without
448
+ * dragging the whole kernel barrel and risking a cycle.
449
+ */
450
+ /**
451
+ * Single row of the runtime annotation-contribution catalog surfaced by
452
+ * `kernel.getRegisteredAnnotationKeys()`. One row per (plugin × key)
453
+ * tuple. Built-in catalog keys from `annotations.schema.json` are NOT
454
+ * included — this catalog is plugin-only; the UI knows the built-in
455
+ * catalog via the schema bundle.
456
+ */
457
+ interface IRegisteredAnnotationKey {
458
+ pluginId: string;
459
+ key: string;
460
+ location: 'namespaced' | 'root';
461
+ ownership: 'exclusive' | 'shared';
462
+ /** Inline JSON Schema as declared in the manifest (not the AJV compiled validator). */
463
+ schema: Record<string, unknown>;
464
+ }
465
+
466
+ /**
467
+ * Base manifest shape shared by every extension kind. Mirrors
468
+ * `spec/schemas/extensions/base.schema.json` at the TypeScript level.
469
+ *
470
+ * Spec § A.6 — every extension is identified in the registry by the
471
+ * qualified id `<pluginId>/<id>`. The `pluginId` field is required at the
472
+ * runtime / TS level: built-ins declare it directly in
473
+ * `src/extensions/built-ins.ts`; user plugins have it injected by the
474
+ * `PluginLoader` from `plugin.json#/id` before the extension reaches the
475
+ * registry. A plugin author who hand-codes a `pluginId` that disagrees
476
+ * with the manifest's `id` is rejected as `invalid-manifest`.
477
+ *
478
+ * The JSON Schema deliberately does NOT model `pluginId` — the qualifier
479
+ * is a runtime concern composed by the loader, not a manifest field
480
+ * authors are expected to set. Stripping it before AJV validation in
481
+ * the loader keeps the spec contract clean ("authors declare only the
482
+ * short id").
483
+ */
484
+
485
+ /**
486
+ * Step 9.6.6 — single entry of an extension's `annotationContributions`
487
+ * map. Mirrors `spec/schemas/extensions/base.schema.json#/properties/annotationContributions/additionalProperties`.
488
+ *
489
+ * `schema` is an INLINE JSON Schema (object literal in the manifest),
490
+ * not a `$ref` to a file. The kernel compiles it at load time; an
491
+ * invalid schema rejects the extension as `invalid-manifest`.
492
+ */
493
+ interface IAnnotationContribution {
494
+ /** Inline JSON Schema describing the value written under this key. */
495
+ schema: Record<string, unknown>;
496
+ /**
497
+ * Conflict policy. `shared` (default) — multiple plugins MAY write
498
+ * the key; `exclusive` — only this plugin may. REQUIRED to be
499
+ * `'exclusive'` when `location: 'root'`.
500
+ */
501
+ ownership?: 'exclusive' | 'shared';
502
+ /**
503
+ * Where the key lands. `namespaced` (default) — under the plugin's
504
+ * `<plugin-id>:` block; `root` — top-level, alongside `for` /
505
+ * `annotations` / `settings` / `audit`. Cross-plugin root-key
506
+ * collisions on `exclusive` are a fatal startup error.
507
+ */
508
+ location?: 'namespaced' | 'root';
509
+ }
510
+ interface IExtensionBase {
511
+ id: string;
512
+ /**
513
+ * Owning plugin namespace. Composed with `id` to produce the
514
+ * qualified registry key `<pluginId>/<id>`. Built-ins declare this
515
+ * directly; user plugins have it injected by the `PluginLoader`
516
+ * from `plugin.json#/id`.
517
+ */
518
+ pluginId: string;
519
+ version: string;
520
+ description?: string;
521
+ stability?: Stability;
522
+ preconditions?: string[];
523
+ entry?: string;
524
+ /**
525
+ * Step 9.6.6 — plugin-contributed annotation keys. Each entry maps a
526
+ * key name to an inline JSON Schema + ownership + location triple.
527
+ * The kernel surfaces the aggregate via `kernel.getRegisteredAnnotationKeys()`.
528
+ * See `IAnnotationContribution` for the field semantics and
529
+ * `plugin-author-guide.md` §Annotation contributions for examples.
530
+ */
531
+ annotationContributions?: Record<string, IAnnotationContribution>;
532
+ }
533
+
372
534
  /**
373
535
  * `.skillmapignore` parser + filter facade. Wraps `ignore` (kaelzhang)
374
536
  * with the project-local layering: bundled defaults → `config.ignore`
@@ -412,10 +574,10 @@ interface ProgressEvent {
412
574
  jobId?: string;
413
575
  data?: unknown;
414
576
  }
415
- type ProgressListener = (event: ProgressEvent) => void;
577
+ type TProgressListener = (event: ProgressEvent) => void;
416
578
  interface ProgressEmitterPort {
417
579
  emit(event: ProgressEvent): void;
418
- subscribe(listener: ProgressListener): () => void;
580
+ subscribe(listener: TProgressListener): () => void;
419
581
  }
420
582
 
421
583
  /**
@@ -722,41 +884,6 @@ declare function makePluginStore(opts: {
722
884
  persistDedicated?: IDedicatedStorePersist;
723
885
  }): IPluginStore | undefined;
724
886
 
725
- /**
726
- * Base manifest shape shared by every extension kind. Mirrors
727
- * `spec/schemas/extensions/base.schema.json` at the TypeScript level.
728
- *
729
- * Spec § A.6 — every extension is identified in the registry by the
730
- * qualified id `<pluginId>/<id>`. The `pluginId` field is required at the
731
- * runtime / TS level: built-ins declare it directly in
732
- * `src/extensions/built-ins.ts`; user plugins have it injected by the
733
- * `PluginLoader` from `plugin.json#/id` before the extension reaches the
734
- * registry. A plugin author who hand-codes a `pluginId` that disagrees
735
- * with the manifest's `id` is rejected as `invalid-manifest`.
736
- *
737
- * The JSON Schema deliberately does NOT model `pluginId` — the qualifier
738
- * is a runtime concern composed by the loader, not a manifest field
739
- * authors are expected to set. Stripping it before AJV validation in
740
- * the loader keeps the spec contract clean ("authors declare only the
741
- * short id").
742
- */
743
-
744
- interface IExtensionBase {
745
- id: string;
746
- /**
747
- * Owning plugin namespace. Composed with `id` to produce the
748
- * qualified registry key `<pluginId>/<id>`. Built-ins declare this
749
- * directly; user plugins have it injected by the `PluginLoader`
750
- * from `plugin.json#/id`.
751
- */
752
- pluginId: string;
753
- version: string;
754
- description?: string;
755
- stability?: Stability;
756
- preconditions?: string[];
757
- entry?: string;
758
- }
759
-
760
887
  /**
761
888
  * Provider runtime contract. Walks filesystem roots and emits raw node
762
889
  * records; classification maps path conventions to a node kind.
@@ -882,7 +1009,7 @@ interface IProviderKindUi {
882
1009
  * `emoji`; when both are absent, the UI falls back to the first
883
1010
  * letter of `label` colored with `color`.
884
1011
  */
885
- icon?: IProviderKindIcon;
1012
+ icon?: TProviderKindIcon;
886
1013
  }
887
1014
  /**
888
1015
  * Discriminated icon contract. `pi` references a PrimeIcons identifier
@@ -891,7 +1018,7 @@ interface IProviderKindUi {
891
1018
  * `currentColor`. The discriminator (`kind`) keeps the UI dispatch
892
1019
  * exhaustive without string-sniffing the payload.
893
1020
  */
894
- type IProviderKindIcon = {
1021
+ type TProviderKindIcon = {
895
1022
  kind: 'pi';
896
1023
  id: string;
897
1024
  } | {
@@ -942,6 +1069,30 @@ interface IProvider extends IExtensionBase {
942
1069
  * concern of how the runtime composes those.
943
1070
  */
944
1071
  schemas?: unknown[];
1072
+ /**
1073
+ * Declarative file-discovery config consumed by the kernel walker.
1074
+ * When present, the kernel walks every root, includes files whose
1075
+ * extension matches `extensions`, parses each with the parser id
1076
+ * registered in the kernel-internal registry, and yields `IRawNode`
1077
+ * records the same shape `walk()` would.
1078
+ *
1079
+ * When neither `read` nor `walk` is declared, `resolveProviderWalk`
1080
+ * applies the default `{ extensions: ['.md'], parser: 'frontmatter-yaml' }`
1081
+ * so the most common Provider shape needs zero configuration.
1082
+ *
1083
+ * Precedence: when both `walk()` (runtime field) and `read` are
1084
+ * declared, `walk()` wins — `read` is ignored. The escape-hatch
1085
+ * relationship is intentional: most Providers should use `read`;
1086
+ * Providers with non-standard discovery requirements (custom file
1087
+ * naming, multi-pass walks, dynamic ignore logic) implement `walk()`
1088
+ * directly and accept the duplication of audit-cleared defences.
1089
+ *
1090
+ * Built-in parsers: `'frontmatter-yaml'` (markdown with `--- … ---`
1091
+ * YAML frontmatter; pollution-strip + JSON_SCHEMA-pinned), `'plain'`
1092
+ * (entire body, empty frontmatter). The set is closed; user plugins
1093
+ * cannot register their own.
1094
+ */
1095
+ read?: IProviderReadConfig;
945
1096
  /**
946
1097
  * Walk the given roots and yield every node the Provider recognises.
947
1098
  * Non-matching files are silently skipped. Unreadable files produce
@@ -952,22 +1103,52 @@ interface IProvider extends IExtensionBase {
952
1103
  * filter reports as ignored. Providers MAY also keep their own
953
1104
  * hard-coded skip list (e.g. `.git`) as a defensive measure, but the
954
1105
  * filter is the canonical source of user intent.
1106
+ *
1107
+ * Optional. When omitted, the Provider MUST declare `read` (or rely
1108
+ * on the default config). The orchestrator never calls `walk()`
1109
+ * directly — it goes through `resolveProviderWalk(provider)` which
1110
+ * picks `walk` over `read`.
955
1111
  */
956
- walk(roots: string[], options?: {
1112
+ walk?(roots: string[], options?: {
957
1113
  ignoreFilter?: IIgnoreFilter;
958
1114
  }): AsyncIterable<IRawNode>;
959
1115
  /**
960
- * Given a path and its parsed frontmatter, decide the node kind. The
961
- * classifier is called after walk() yields — Providers MAY embed the
962
- * logic inside walk itself, but exposing it lets the kernel rebuild
963
- * classification during partial scans without re-walking.
1116
+ * Given a path and its parsed frontmatter, decide the node kind — or
1117
+ * `null` to disclaim the file. The classifier is called after walk()
1118
+ * yields; with multiple Providers active, every Provider walks every
1119
+ * file matching its `read.extensions`, so each Provider MUST disclaim
1120
+ * paths it does not recognise. Returning the same path's kind from
1121
+ * two Providers fires the spec's `provider-ambiguous` issue and the
1122
+ * orchestrator drops the duplicate.
964
1123
  *
965
- * Returns an open `string`. The returned value MUST be a key of the
966
- * Provider's own `kinds` catalog; the orchestrator does not validate
967
- * the kind against `NodeKind`. External Providers (Cursor, Obsidian,
968
- * …) freely return their own kinds (e.g. `'cursorRule'`, `'daily'`).
1124
+ * Convention: a Provider's classify returns one of its own `kinds`
1125
+ * map keys for paths in its territory (`.claude/`, `.gemini/`,
1126
+ * `.agents/skills/`, etc.) and `null` elsewhere. External Providers
1127
+ * (Cursor, Obsidian, …) follow the same rule: claim what's yours,
1128
+ * disclaim everything else. The orchestrator does not validate the
1129
+ * kind against `NodeKind`.
1130
+ */
1131
+ classify(path: string, frontmatter: Record<string, unknown>): string | null;
1132
+ }
1133
+ /**
1134
+ * Declarative read config a Provider declares via `IProvider.read`.
1135
+ * Mirrors `extensions/provider.schema.json#/properties/read` at the
1136
+ * TypeScript level. Built-in parser ids: `'frontmatter-yaml'`, `'plain'`.
1137
+ */
1138
+ interface IProviderReadConfig {
1139
+ /**
1140
+ * File extensions the walker yields. Strings include the leading dot
1141
+ * (e.g. `'.md'`, `'.mdc'`, `'.toml'`). Match is suffix-based; the
1142
+ * comparison is case-sensitive.
1143
+ */
1144
+ extensions: string[];
1145
+ /**
1146
+ * Parser id from the kernel-internal registry. Built-ins:
1147
+ * `'frontmatter-yaml'`, `'plain'`. Unknown ids surface as
1148
+ * `UnknownParserError` from the walker; the orchestrator translates
1149
+ * the error into a Provider issue with status `invalid-manifest`.
969
1150
  */
970
- classify(path: string, frontmatter: Record<string, unknown>): string;
1151
+ parser: string;
971
1152
  }
972
1153
 
973
1154
  /**
@@ -1099,9 +1280,46 @@ interface IExtractor extends IExtensionBase {
1099
1280
  * `deterministic`).
1100
1281
  */
1101
1282
 
1283
+ /**
1284
+ * Step 9.6.2 — orphan sidecar entry surfaced to rules. A `.sm` file
1285
+ * whose sibling `.md` does not exist on disk; the `annotation-orphan`
1286
+ * built-in rule emits one warning per entry. Other rules that care
1287
+ * about orphan sidecars MAY consume the list too.
1288
+ */
1289
+ interface IRuleOrphanSidecar {
1290
+ /** Relative path (POSIX-separated) of the orphan `.sm`. */
1291
+ relativePath: string;
1292
+ /** Absolute path of the missing `.md` the sidecar was anchored to. */
1293
+ expectedMdPath: string;
1294
+ }
1102
1295
  interface IRuleContext {
1103
1296
  nodes: Node[];
1104
1297
  links: Link[];
1298
+ /**
1299
+ * Step 9.6.2 — orphaned sidecars discovered during the scan walk.
1300
+ * Empty when sidecar discovery did not run (legacy callers) or
1301
+ * when no orphans exist.
1302
+ */
1303
+ orphanSidecars?: IRuleOrphanSidecar[];
1304
+ /**
1305
+ * Step 9.6.6 — raw parsed sidecar root keyed by `node.path`. Populated
1306
+ * by the orchestrator alongside the public `Node.sidecar` overlay so
1307
+ * rules that inspect plugin namespaces (e.g. the built-in
1308
+ * `core/unknown-field` Rule) can walk the full tree without re-reading
1309
+ * the file from disk. Absent (or `undefined` per node) when no
1310
+ * sidecar accompanies the node, or when the sidecar failed to parse.
1311
+ * Treat as read-only.
1312
+ */
1313
+ sidecarRoots?: ReadonlyMap<string, Record<string, unknown>>;
1314
+ /**
1315
+ * Step 9.6.6 — runtime catalog of plugin-contributed annotation keys,
1316
+ * as exposed by `kernel.getRegisteredAnnotationKeys()`. Threaded
1317
+ * through so rules can reason about the registered-vs-unknown split
1318
+ * without reaching back into the kernel. Empty array when no plugin
1319
+ * declares contributions; absent for legacy callers (older runScan
1320
+ * sites that never wired the catalog through).
1321
+ */
1322
+ annotationContributions?: readonly IRegisteredAnnotationKey[];
1105
1323
  }
1106
1324
  interface IRule extends IExtensionBase {
1107
1325
  kind: 'rule';
@@ -1156,6 +1374,58 @@ interface IRule extends IExtensionBase {
1156
1374
  * - `fanOutPolicy` — `'per-node'` (default) vs `'batch'`.
1157
1375
  */
1158
1376
 
1377
+ /**
1378
+ * Single sidecar write payload an Action can return. Discriminated union so
1379
+ * future write kinds (storage rows, plugin KV, etc.) can land additively
1380
+ * without breaking consumers that only handle `kind: 'sidecar'`.
1381
+ *
1382
+ * - `path` — absolute path to the `.sm` file the kernel must materialise
1383
+ * the change into. Resolved by the Action from the node's absolute
1384
+ * path via `sidecarPathFor()`.
1385
+ * - `changes` — partial sidecar root used as a deep-merge patch (NOT a
1386
+ * full replacement). Arrays REPLACE; objects RECURSE. Reason:
1387
+ * sidecars are shared-write between skill-map core and plugins;
1388
+ * a full replace would clobber `<plugin-id>:` namespaced blocks.
1389
+ */
1390
+ type TActionWrite = {
1391
+ kind: 'sidecar';
1392
+ path: string;
1393
+ changes: Record<string, unknown>;
1394
+ };
1395
+ /**
1396
+ * Result envelope returned by deterministic Actions. The `report` field
1397
+ * carries the typed report payload (each Action declares its shape via
1398
+ * `reportSchemaRef`); `writes` is opt-in — Actions that do not mutate
1399
+ * persistent state simply omit it.
1400
+ */
1401
+ interface IActionResult<TReport = unknown> {
1402
+ report: TReport;
1403
+ writes?: TActionWrite[];
1404
+ }
1405
+ /**
1406
+ * Runtime context passed to a deterministic Action's `invoke()` method.
1407
+ * Minimal surface — Actions stay pure (no IO inside `invoke`); the kernel
1408
+ * materialises any returned `writes` after the call.
1409
+ *
1410
+ * - `node` — the target `Node` the Action operates on. Open-by-design;
1411
+ * batch / fan-out flows pick the matching nodes upstream.
1412
+ * - `nodeAbsolutePath` — absolute path to the node's `.md` file on
1413
+ * disk. The Action uses this to compute the sidecar path it returns
1414
+ * in a `TActionWrite`. Surfaced separately from `node.path` (which is
1415
+ * the relative scope-root form) so Actions never compose absolute
1416
+ * paths from `node.path` themselves.
1417
+ * - `invoker` — identity of the caller; written into the sidecar's
1418
+ * `audit.lastBumpedBy` when the Action chooses to. CLI invocations
1419
+ * pass `'cli'`; plugin-driven invocations pass `'plugin:<plugin-id>'`.
1420
+ * - `now` — clock function; tests inject a deterministic source.
1421
+ * Defaults to `() => new Date()` at the composition root.
1422
+ */
1423
+ interface IActionContext {
1424
+ node: Node;
1425
+ nodeAbsolutePath: string;
1426
+ invoker: string;
1427
+ now: () => Date;
1428
+ }
1159
1429
  /**
1160
1430
  * Declarative filter applied by `--all` fan-out, UI button gating, and
1161
1431
  * `sm actions show`. All fields optional — an empty precondition matches
@@ -1225,6 +1495,26 @@ interface IAction extends IExtensionBase {
1225
1495
  * full list. Batch actions tend to hit context limits; use sparingly.
1226
1496
  */
1227
1497
  fanOutPolicy?: 'per-node' | 'batch';
1498
+ /**
1499
+ * Deterministic invocation entry point. OPTIONAL on the runtime
1500
+ * contract for backward compatibility with the manifest-only era
1501
+ * (Decision #114) — actions that ship for the future probabilistic
1502
+ * runner / record path leave it absent and the kernel never calls it.
1503
+ * Step 9.6.3 (Decision #125) introduces the first concrete consumer:
1504
+ * the built-in `bump` Action implements `invoke()` and returns a
1505
+ * `writes: [{ kind: 'sidecar', ... }]` payload that the kernel
1506
+ * materialises through `ISidecarStore`.
1507
+ *
1508
+ * Implementations MUST stay pure — no IO inside `invoke()`. The Action
1509
+ * computes the patch and returns it; the kernel reads the on-disk
1510
+ * sidecar, deep-merges, validates, and writes back inside its critical
1511
+ * section.
1512
+ *
1513
+ * `TInput` is action-specific; the built-in `bump` Action declares
1514
+ * `{ force?: boolean; reason?: string }`. The signature stays generic
1515
+ * so each Action narrows it locally without forcing a common base.
1516
+ */
1517
+ invoke?: <TInput, TReport>(input: TInput, ctx: IActionContext) => IActionResult<TReport>;
1228
1518
  }
1229
1519
 
1230
1520
  /**
@@ -1499,6 +1789,17 @@ interface RunScanOptions {
1499
1789
  emitter?: ProgressEmitterPort;
1500
1790
  /** Runtime extension instances. Absent → empty pipeline. */
1501
1791
  extensions?: IScanExtensions;
1792
+ /**
1793
+ * Step 9.6.6 — runtime catalog of plugin-contributed annotation keys
1794
+ * (the same shape `kernel.getRegisteredAnnotationKeys()` returns).
1795
+ * Threaded into the rule pass so `core/unknown-field` can
1796
+ * legitimise registered plugin namespaces / root keys without
1797
+ * re-walking the manifests. Absent → empty catalog (every plugin
1798
+ * key is treated as unknown). Built-in catalog from
1799
+ * `annotations.schema.json` is NOT included — that is hard-coded
1800
+ * inside the rule.
1801
+ */
1802
+ annotationContributions?: readonly IRegisteredAnnotationKey[];
1502
1803
  /**
1503
1804
  * Scan scope. Defaults to `'project'`. The CLI flag wiring lands in
1504
1805
  * the config layer wiring; `runScan` already accepts the override
@@ -1792,7 +2093,7 @@ interface IPersistedEnrichment {
1792
2093
  declare class InMemoryProgressEmitter implements ProgressEmitterPort {
1793
2094
  #private;
1794
2095
  emit(event: ProgressEvent): void;
1795
- subscribe(listener: ProgressListener): () => void;
2096
+ subscribe(listener: TProgressListener): () => void;
1796
2097
  }
1797
2098
 
1798
2099
  /**
@@ -2181,16 +2482,18 @@ interface IMigrateNodeFksReport {
2181
2482
  summaries: number;
2182
2483
  enrichments: number;
2183
2484
  pluginKvs: number;
2485
+ nodeFavorites: number;
2184
2486
  /**
2185
- * Composite-PK collisions encountered when migrating
2186
- * `state_summaries` / `state_enrichments` / `state_plugin_kvs` because
2187
- * a row already existed at the destination PK. The pre-existing rows
2188
- * are preserved the migrating rows are dropped (deleted from
2189
- * `fromPath` without a corresponding INSERT). One entry per dropped
2190
- * row, with the affected PK fields included for diagnostic output.
2487
+ * Collisions encountered when migrating any of the keyed-by-node
2488
+ * `state_*` tables because a row already existed at the destination
2489
+ * PK. The pre-existing rows are preserved the migrating rows are
2490
+ * dropped (deleted from `fromPath` without a corresponding INSERT).
2491
+ * One entry per dropped row, with the affected PK fields included
2492
+ * for diagnostic output. `state_node_favorites` has no composite key
2493
+ * so its `keys` is the empty object.
2191
2494
  */
2192
2495
  collisions: Array<{
2193
- table: 'state_summaries' | 'state_enrichments' | 'state_plugin_kvs';
2496
+ table: 'state_summaries' | 'state_enrichments' | 'state_plugin_kvs' | 'state_node_favorites';
2194
2497
  fromPath: string;
2195
2498
  toPath: string;
2196
2499
  keys: Record<string, string>;
@@ -2418,6 +2721,23 @@ interface StoragePort {
2418
2721
  */
2419
2722
  listReferencedFilePaths(): Promise<Set<string>>;
2420
2723
  };
2724
+ favorites: {
2725
+ /**
2726
+ * Mark `path` as favorited. Idempotent — a second call refreshes
2727
+ * `favoritedAt` but does not error. The path is FK-semantic to
2728
+ * `scan_nodes.path`; the route layer is responsible for confirming
2729
+ * the path exists in the live scan before calling.
2730
+ */
2731
+ set(path: string): Promise<void>;
2732
+ /** Drop the favorite row for `path`. Idempotent — no-op when absent. */
2733
+ unset(path: string): Promise<void>;
2734
+ /**
2735
+ * Load every favorited path as a `Set<string>` ready for `O(1)`
2736
+ * membership checks. Used by the BFF's `/api/nodes` decorator —
2737
+ * one query per request, no SQL JOIN against `scan_nodes`.
2738
+ */
2739
+ listPaths(): Promise<Set<string>>;
2740
+ };
2421
2741
  history: {
2422
2742
  /** List `state_executions` rows (paginated by filter). */
2423
2743
  list(filter: IListExecutionsFilter): Promise<ExecutionRecord[]>;
@@ -2544,19 +2864,19 @@ interface RunnerPort {
2544
2864
  * `LogRecord.level`. Setting an adapter to `silent` disables every
2545
2865
  * method.
2546
2866
  */
2547
- type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'silent';
2548
- type LogMethodLevel = Exclude<LogLevel, 'silent'>;
2549
- declare const LOG_LEVELS: readonly LogLevel[];
2550
- declare function logLevelRank(level: LogLevel): number;
2551
- declare function isLogLevel(value: unknown): value is LogLevel;
2867
+ type TLogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'silent';
2868
+ type TLogMethodLevel = Exclude<TLogLevel, 'silent'>;
2869
+ declare const LOG_LEVELS: readonly TLogLevel[];
2870
+ declare function logLevelRank(level: TLogLevel): number;
2871
+ declare function isLogLevel(value: unknown): value is TLogLevel;
2552
2872
  /**
2553
- * Parse a string into a `LogLevel`. Returns `null` for invalid input
2873
+ * Parse a string into a `TLogLevel`. Returns `null` for invalid input
2554
2874
  * (incl. `undefined` / `null` / empty). Case-insensitive; trims
2555
2875
  * whitespace.
2556
2876
  */
2557
- declare function parseLogLevel(value: string | undefined | null): LogLevel | null;
2877
+ declare function parseLogLevel(value: string | undefined | null): TLogLevel | null;
2558
2878
  interface LogRecord {
2559
- level: LogMethodLevel;
2879
+ level: TLogMethodLevel;
2560
2880
  /** ISO 8601 timestamp produced at the moment the log call was made. */
2561
2881
  timestamp: string;
2562
2882
  message: string;
@@ -2628,7 +2948,20 @@ declare function getActiveLogger(): LoggerPort;
2628
2948
 
2629
2949
  interface Kernel {
2630
2950
  registry: Registry;
2951
+ /**
2952
+ * Step 9.6.6 — read-only catalog of plugin-contributed annotation
2953
+ * keys, keyed by `(pluginId, key)`. Populated at plugin-load time;
2954
+ * pure read with no side effects. Built-in catalog (from
2955
+ * `annotations.schema.json`) is NOT included here.
2956
+ */
2957
+ getRegisteredAnnotationKeys: () => readonly IRegisteredAnnotationKey[];
2958
+ /**
2959
+ * Internal — replace the frozen catalog. Called once by the
2960
+ * plugin runtime composer after every plugin has loaded; consumers
2961
+ * MUST treat the resulting array as immutable.
2962
+ */
2963
+ setRegisteredAnnotationKeys: (entries: readonly IRegisteredAnnotationKey[]) => void;
2631
2964
  }
2632
2965
  declare function createKernel(): Kernel;
2633
2966
 
2634
- export { type Confidence, DuplicateExtensionError, EXTENSION_KINDS, type ExecutionFailureReason, type ExecutionKind, type ExecutionRecord, type ExecutionRunner, type ExecutionStatus, ExportQueryError, type Extension, type ExtensionKind, type FilesystemPort, HOOK_TRIGGERS, type HistoryStats, type HistoryStatsErrorRates, type HistoryStatsExecutionsPerPeriod, type HistoryStatsPerActionRate, type HistoryStatsTokensPerAction, type HistoryStatsTopNode, type HistoryStatsTotals, type IAction, type IActionPrecondition, type ICreateFsWatcherOptions, type IDedicatedStorePersist, type IDedicatedStoreWrapper, type IDiscoveredPlugin, type IEnrichmentRecord, type IExportQuery, type IExportSubset, type IExtensionBase, type IExtractor, type IExtractorCallbacks, type IExtractorContext, type IExtractorRunRecord, type IFormatter, type IFormatterContext, type IFsWatcher, type IHook, type IHookContext, type IIssueRow, type IKvStorePersist, type IKvStoreWrapper, type ILoadedExtension, type INodeBundle, type INodeChange, type INodeCounts, type INodeFilter, type IPersistOptions, type IPersistedEnrichment, type IPluginManifest, type IPluginStorageSchema, type IPluginStore, type IProvider, type IRawNode, type IRule, type IRuleContext, type IRunOptions, type IRunResult, type IScanDelta, type ITransactionalStorage, type IWalkOptions, type IWatchBatch, type IWatchEvent, InMemoryProgressEmitter, type Issue, type IssueFix, KV_SCHEMA_KEY, type Kernel, LOG_LEVELS, type Link, type LinkKind, type LinkLocation, type LinkTrigger, type LogLevel, type LogMethodLevel, type LogRecord, type LoggerPort, type Node, type NodeKind, type NodeStat, type PluginLoaderPort, type ProgressEmitterPort, type ProgressEvent, type ProgressListener, Registry, type RenameOp, type RunScanOptions, type RunnerPort, type ScanResult, type ScanScannedBy, type ScanStats, type Severity, SilentLogger, type Stability, type StoragePort, type TExecutionMode, type TGranularity, type THookFilter, type THookTrigger, type TNodeChangeReason, type TPluginLoadStatus, type TPluginStorage, type TWatchEventKind, type TripleSplit, applyExportQuery, computeScanDelta, configureLogger, createChokidarWatcher, createKernel, detectRenamesAndOrphans, getActiveLogger, isEmptyDelta, isLogLevel, log, logLevelRank, makeDedicatedStoreWrapper, makeKvStoreWrapper, makePluginStore, mergeNodeWithEnrichments, parseExportQuery, parseLogLevel, qualifiedExtensionId, resetLogger, runExtractorsForNode, runScan, runScanWithRenames };
2967
+ export { type Confidence, DuplicateExtensionError, EXTENSION_KINDS, type ExecutionFailureReason, type ExecutionKind, type ExecutionRecord, type ExecutionRunner, type ExecutionStatus, ExportQueryError, type Extension, type ExtensionKind, type FilesystemPort, HOOK_TRIGGERS, type HistoryStats, type HistoryStatsErrorRates, type HistoryStatsExecutionsPerPeriod, type HistoryStatsPerActionRate, type HistoryStatsTokensPerAction, type HistoryStatsTopNode, type HistoryStatsTotals, type IAction, type IActionContext, type IActionPrecondition, type IActionResult, type IAnnotationContribution, type ICreateFsWatcherOptions, type IDedicatedStorePersist, type IDedicatedStoreWrapper, type IDiscoveredPlugin, type IEnrichmentRecord, type IExportQuery, type IExportSubset, type IExtensionBase, type IExtractor, type IExtractorCallbacks, type IExtractorContext, type IExtractorRunRecord, type IFormatter, type IFormatterContext, type IFsWatcher, type IHook, type IHookContext, type IIssueRow, type IKvStorePersist, type IKvStoreWrapper, type ILoadedExtension, type INodeBundle, type INodeChange, type INodeCounts, type INodeFilter, type IPersistOptions, type IPersistedEnrichment, type IPluginManifest, type IPluginStorageSchema, type IPluginStore, type IProvider, type IRawNode, type IRegisteredAnnotationKey, type IRule, type IRuleContext, type IRunOptions, type IRunResult, type IScanDelta, type ITransactionalStorage, type IWalkOptions, type IWatchBatch, type IWatchEvent, InMemoryProgressEmitter, type Issue, type IssueFix, KV_SCHEMA_KEY, type Kernel, LOG_LEVELS, type Link, type LinkKind, type LinkLocation, type LinkTrigger, type LogRecord, type LoggerPort, type Node, type NodeKind, type NodeStat, type PluginLoaderPort, type ProgressEmitterPort, type ProgressEvent, Registry, type RenameOp, type RunScanOptions, type RunnerPort, type ScanResult, type ScanScannedBy, type ScanStats, type Severity, SilentLogger, type Stability, type StoragePort, type TActionWrite, type TExecutionMode, type TGranularity, type THookFilter, type THookTrigger, type TLogLevel, type TLogMethodLevel, type TNodeChangeReason, type TPluginLoadStatus, type TPluginStorage, type TProgressListener, type TWatchEventKind, type TripleSplit, applyExportQuery, computeScanDelta, configureLogger, createChokidarWatcher, createKernel, detectRenamesAndOrphans, getActiveLogger, isEmptyDelta, isLogLevel, log, logLevelRank, makeDedicatedStoreWrapper, makeKvStoreWrapper, makePluginStore, mergeNodeWithEnrichments, parseExportQuery, parseLogLevel, qualifiedExtensionId, resetLogger, runExtractorsForNode, runScan, runScanWithRenames };