@skill-map/cli 0.16.6 → 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.
Files changed (41) hide show
  1. package/dist/cli/tutorial/sm-tutorial.md +8 -0
  2. package/dist/cli.js +8324 -5644
  3. package/dist/cli.js.map +1 -1
  4. package/dist/conformance/index.js +36 -14
  5. package/dist/conformance/index.js.map +1 -1
  6. package/dist/index.d.ts +1 -1
  7. package/dist/index.js +540 -61
  8. package/dist/index.js.map +1 -1
  9. package/dist/kernel/index.d.ts +443 -87
  10. package/dist/kernel/index.js +540 -61
  11. package/dist/kernel/index.js.map +1 -1
  12. package/dist/migrations/002_sidecar_columns.sql +53 -0
  13. package/dist/migrations/003_drop_node_author.sql +20 -0
  14. package/dist/migrations/004_sidecar_root_json.sql +23 -0
  15. package/dist/migrations/005_node_favorites.sql +20 -0
  16. package/dist/ui/chunk-3R7E3HPC.js +7 -0
  17. package/dist/ui/chunk-JKJGGXCS.js +1025 -0
  18. package/dist/ui/chunk-SX2A3WBX.js +247 -0
  19. package/dist/ui/chunk-TWZHUCAT.js +237 -0
  20. package/dist/ui/chunk-UJOZYR5I.js +1 -0
  21. package/dist/ui/chunk-WTAL2RK4.js +1 -0
  22. package/dist/ui/chunk-Z3UJHHTC.js +3091 -0
  23. package/dist/ui/index.html +2 -2
  24. package/dist/ui/main-AAYGMON4.js +1 -0
  25. package/dist/ui/skill-map-mark-dark.svg +8 -0
  26. package/dist/ui/skill-map-mark-light.svg +8 -0
  27. package/dist/ui/{styles-TCK5JUQE.css → styles-CBPFNGXA.css} +1 -1
  28. package/migrations/002_sidecar_columns.sql +53 -0
  29. package/migrations/003_drop_node_author.sql +20 -0
  30. package/migrations/004_sidecar_root_json.sql +23 -0
  31. package/migrations/005_node_favorites.sql +20 -0
  32. package/package.json +6 -6
  33. package/dist/ui/chunk-7PFTODKS.js +0 -1031
  34. package/dist/ui/chunk-A4RBO3TD.js +0 -38
  35. package/dist/ui/chunk-KPEISNOV.js +0 -819
  36. package/dist/ui/chunk-NKC42FI7.js +0 -210
  37. package/dist/ui/chunk-S5C4U3I3.js +0 -2403
  38. package/dist/ui/chunk-TG6IWVEC.js +0 -54
  39. package/dist/ui/chunk-TGJQE3TH.js +0 -54
  40. package/dist/ui/chunk-UGEECDPV.js +0 -1
  41. package/dist/ui/main-XSGTD7FQ.js +0 -1
@@ -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,19 +55,20 @@
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
- * The five node kinds the **built-in Claude Provider** declares — `skill`,
61
- * `agent`, `command`, `hook`, `note`. **NOT** the kernel-wide kind type.
70
+ * The four node kinds the **built-in Claude Provider** declares — `skill`,
71
+ * `agent`, `command`, `note`. **NOT** the kernel-wide kind type.
62
72
  *
63
73
  * `Node.kind` is `string`. An external Provider (Cursor, Obsidian, …)
64
74
  * MAY classify into its own kinds (e.g. `'cursorRule'`, `'daily'`); the
@@ -67,19 +77,29 @@
67
77
  * `node.schema.json#/properties/kind`, the contract is open-by-design
68
78
  * (matches `IProvider.kinds` "open by design" docstring).
69
79
  *
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`.
89
+ *
70
90
  * This alias survives because:
71
- * - claude-specific code legitimately wants to switch on the five
91
+ * - claude-specific code legitimately wants to switch on the four
72
92
  * hard-coded values (filter widgets, kind-aware UI cards, the
73
93
  * `validate-all` built-in rule that maps each kind to its
74
94
  * frontmatter schema);
75
95
  * - sorting helpers want a stable `KIND_ORDER` for the canonical
76
96
  * catalog;
77
- * - tests expect to enumerate the five kinds when seeding fixtures.
97
+ * - tests expect to enumerate the four kinds when seeding fixtures.
78
98
  *
79
99
  * For "any kind a Provider could declare", use plain `string`. Only use
80
100
  * `NodeKind` when the code is intentionally claude-catalog-specific.
81
101
  */
82
- type NodeKind = 'skill' | 'agent' | 'command' | 'hook' | 'note';
102
+ type NodeKind = 'skill' | 'agent' | 'command' | 'markdown';
83
103
  type LinkKind = 'invokes' | 'references' | 'mentions' | 'supersedes';
84
104
  type Confidence = 'high' | 'medium' | 'low';
85
105
  type Severity = 'error' | 'warn' | 'info';
@@ -134,10 +154,66 @@ interface Node {
134
154
  title?: string | null;
135
155
  description?: string | null;
136
156
  stability?: Stability | null;
137
- version?: string | null;
138
- 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;
139
165
  frontmatter?: Record<string, unknown>;
140
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;
141
217
  }
142
218
  interface Link {
143
219
  /** The originating node — the path of the file the extractor was reading
@@ -180,8 +256,8 @@ interface ScanStats {
180
256
  /**
181
257
  * Files walked but not classified by any Provider. Today every walked
182
258
  * file is classified by its Provider (the `claude` Provider falls back to
183
- * `'note'`), so this is always 0; the field will matter once multiple
184
- * 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.
185
261
  */
186
262
  filesSkipped: number;
187
263
  nodesCount: number;
@@ -363,6 +439,98 @@ declare class Registry {
363
439
  totalCount(): number;
364
440
  }
365
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
+
366
534
  /**
367
535
  * `.skillmapignore` parser + filter facade. Wraps `ignore` (kaelzhang)
368
536
  * with the project-local layering: bundled defaults → `config.ignore`
@@ -406,10 +574,10 @@ interface ProgressEvent {
406
574
  jobId?: string;
407
575
  data?: unknown;
408
576
  }
409
- type ProgressListener = (event: ProgressEvent) => void;
577
+ type TProgressListener = (event: ProgressEvent) => void;
410
578
  interface ProgressEmitterPort {
411
579
  emit(event: ProgressEvent): void;
412
- subscribe(listener: ProgressListener): () => void;
580
+ subscribe(listener: TProgressListener): () => void;
413
581
  }
414
582
 
415
583
  /**
@@ -716,41 +884,6 @@ declare function makePluginStore(opts: {
716
884
  persistDedicated?: IDedicatedStorePersist;
717
885
  }): IPluginStore | undefined;
718
886
 
719
- /**
720
- * Base manifest shape shared by every extension kind. Mirrors
721
- * `spec/schemas/extensions/base.schema.json` at the TypeScript level.
722
- *
723
- * Spec § A.6 — every extension is identified in the registry by the
724
- * qualified id `<pluginId>/<id>`. The `pluginId` field is required at the
725
- * runtime / TS level: built-ins declare it directly in
726
- * `src/extensions/built-ins.ts`; user plugins have it injected by the
727
- * `PluginLoader` from `plugin.json#/id` before the extension reaches the
728
- * registry. A plugin author who hand-codes a `pluginId` that disagrees
729
- * with the manifest's `id` is rejected as `invalid-manifest`.
730
- *
731
- * The JSON Schema deliberately does NOT model `pluginId` — the qualifier
732
- * is a runtime concern composed by the loader, not a manifest field
733
- * authors are expected to set. Stripping it before AJV validation in
734
- * the loader keeps the spec contract clean ("authors declare only the
735
- * short id").
736
- */
737
-
738
- interface IExtensionBase {
739
- id: string;
740
- /**
741
- * Owning plugin namespace. Composed with `id` to produce the
742
- * qualified registry key `<pluginId>/<id>`. Built-ins declare this
743
- * directly; user plugins have it injected by the `PluginLoader`
744
- * from `plugin.json#/id`.
745
- */
746
- pluginId: string;
747
- version: string;
748
- description?: string;
749
- stability?: Stability;
750
- preconditions?: string[];
751
- entry?: string;
752
- }
753
-
754
887
  /**
755
888
  * Provider runtime contract. Walks filesystem roots and emits raw node
756
889
  * records; classification maps path conventions to a node kind.
@@ -876,7 +1009,7 @@ interface IProviderKindUi {
876
1009
  * `emoji`; when both are absent, the UI falls back to the first
877
1010
  * letter of `label` colored with `color`.
878
1011
  */
879
- icon?: IProviderKindIcon;
1012
+ icon?: TProviderKindIcon;
880
1013
  }
881
1014
  /**
882
1015
  * Discriminated icon contract. `pi` references a PrimeIcons identifier
@@ -885,7 +1018,7 @@ interface IProviderKindUi {
885
1018
  * `currentColor`. The discriminator (`kind`) keeps the UI dispatch
886
1019
  * exhaustive without string-sniffing the payload.
887
1020
  */
888
- type IProviderKindIcon = {
1021
+ type TProviderKindIcon = {
889
1022
  kind: 'pi';
890
1023
  id: string;
891
1024
  } | {
@@ -919,6 +1052,47 @@ interface IProvider extends IExtensionBase {
919
1052
  * column all accept any non-empty string an enabled Provider returns.
920
1053
  */
921
1054
  kinds: Record<string, IProviderKind>;
1055
+ /**
1056
+ * Optional auxiliary JSON Schemas this Provider's per-kind schemas
1057
+ * `$ref` by `$id`. Registered with AJV via `addSchema` BEFORE the
1058
+ * per-kind schemas compile, so cross-file `$ref` resolution succeeds.
1059
+ *
1060
+ * Use case: when several kinds share a common base (e.g. Anthropic's
1061
+ * merged skill / command frontmatter — both extend a shared
1062
+ * `skill-base.schema.json`), the Provider declares the base here so
1063
+ * `skill.schema.json` and `command.schema.json` can `$ref` it without
1064
+ * duplicating fields.
1065
+ *
1066
+ * Runtime-only — does NOT appear in the spec's `provider.schema.json`
1067
+ * manifest. Manifest-validated schemas remain the per-kind ones in
1068
+ * `kinds[<kind>].schema`; auxiliary schemas are an implementation
1069
+ * concern of how the runtime composes those.
1070
+ */
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;
922
1096
  /**
923
1097
  * Walk the given roots and yield every node the Provider recognises.
924
1098
  * Non-matching files are silently skipped. Unreadable files produce
@@ -929,22 +1103,52 @@ interface IProvider extends IExtensionBase {
929
1103
  * filter reports as ignored. Providers MAY also keep their own
930
1104
  * hard-coded skip list (e.g. `.git`) as a defensive measure, but the
931
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`.
932
1111
  */
933
- walk(roots: string[], options?: {
1112
+ walk?(roots: string[], options?: {
934
1113
  ignoreFilter?: IIgnoreFilter;
935
1114
  }): AsyncIterable<IRawNode>;
936
1115
  /**
937
- * Given a path and its parsed frontmatter, decide the node kind. The
938
- * classifier is called after walk() yields — Providers MAY embed the
939
- * logic inside walk itself, but exposing it lets the kernel rebuild
940
- * 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.
941
1123
  *
942
- * Returns an open `string`. The returned value MUST be a key of the
943
- * Provider's own `kinds` catalog; the orchestrator does not validate
944
- * the kind against `NodeKind`. External Providers (Cursor, Obsidian,
945
- * …) 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`.
946
1150
  */
947
- classify(path: string, frontmatter: Record<string, unknown>): string;
1151
+ parser: string;
948
1152
  }
949
1153
 
950
1154
  /**
@@ -1076,9 +1280,46 @@ interface IExtractor extends IExtensionBase {
1076
1280
  * `deterministic`).
1077
1281
  */
1078
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
+ }
1079
1295
  interface IRuleContext {
1080
1296
  nodes: Node[];
1081
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[];
1082
1323
  }
1083
1324
  interface IRule extends IExtensionBase {
1084
1325
  kind: 'rule';
@@ -1133,6 +1374,58 @@ interface IRule extends IExtensionBase {
1133
1374
  * - `fanOutPolicy` — `'per-node'` (default) vs `'batch'`.
1134
1375
  */
1135
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
+ }
1136
1429
  /**
1137
1430
  * Declarative filter applied by `--all` fan-out, UI button gating, and
1138
1431
  * `sm actions show`. All fields optional — an empty precondition matches
@@ -1202,6 +1495,26 @@ interface IAction extends IExtensionBase {
1202
1495
  * full list. Batch actions tend to hit context limits; use sparingly.
1203
1496
  */
1204
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>;
1205
1518
  }
1206
1519
 
1207
1520
  /**
@@ -1476,6 +1789,17 @@ interface RunScanOptions {
1476
1789
  emitter?: ProgressEmitterPort;
1477
1790
  /** Runtime extension instances. Absent → empty pipeline. */
1478
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[];
1479
1803
  /**
1480
1804
  * Scan scope. Defaults to `'project'`. The CLI flag wiring lands in
1481
1805
  * the config layer wiring; `runScan` already accepts the override
@@ -1769,7 +2093,7 @@ interface IPersistedEnrichment {
1769
2093
  declare class InMemoryProgressEmitter implements ProgressEmitterPort {
1770
2094
  #private;
1771
2095
  emit(event: ProgressEvent): void;
1772
- subscribe(listener: ProgressListener): () => void;
2096
+ subscribe(listener: TProgressListener): () => void;
1773
2097
  }
1774
2098
 
1775
2099
  /**
@@ -2158,16 +2482,18 @@ interface IMigrateNodeFksReport {
2158
2482
  summaries: number;
2159
2483
  enrichments: number;
2160
2484
  pluginKvs: number;
2485
+ nodeFavorites: number;
2161
2486
  /**
2162
- * Composite-PK collisions encountered when migrating
2163
- * `state_summaries` / `state_enrichments` / `state_plugin_kvs` because
2164
- * a row already existed at the destination PK. The pre-existing rows
2165
- * are preserved the migrating rows are dropped (deleted from
2166
- * `fromPath` without a corresponding INSERT). One entry per dropped
2167
- * 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.
2168
2494
  */
2169
2495
  collisions: Array<{
2170
- table: 'state_summaries' | 'state_enrichments' | 'state_plugin_kvs';
2496
+ table: 'state_summaries' | 'state_enrichments' | 'state_plugin_kvs' | 'state_node_favorites';
2171
2497
  fromPath: string;
2172
2498
  toPath: string;
2173
2499
  keys: Record<string, string>;
@@ -2395,6 +2721,23 @@ interface StoragePort {
2395
2721
  */
2396
2722
  listReferencedFilePaths(): Promise<Set<string>>;
2397
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
+ };
2398
2741
  history: {
2399
2742
  /** List `state_executions` rows (paginated by filter). */
2400
2743
  list(filter: IListExecutionsFilter): Promise<ExecutionRecord[]>;
@@ -2521,19 +2864,19 @@ interface RunnerPort {
2521
2864
  * `LogRecord.level`. Setting an adapter to `silent` disables every
2522
2865
  * method.
2523
2866
  */
2524
- type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'silent';
2525
- type LogMethodLevel = Exclude<LogLevel, 'silent'>;
2526
- declare const LOG_LEVELS: readonly LogLevel[];
2527
- declare function logLevelRank(level: LogLevel): number;
2528
- 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;
2529
2872
  /**
2530
- * Parse a string into a `LogLevel`. Returns `null` for invalid input
2873
+ * Parse a string into a `TLogLevel`. Returns `null` for invalid input
2531
2874
  * (incl. `undefined` / `null` / empty). Case-insensitive; trims
2532
2875
  * whitespace.
2533
2876
  */
2534
- declare function parseLogLevel(value: string | undefined | null): LogLevel | null;
2877
+ declare function parseLogLevel(value: string | undefined | null): TLogLevel | null;
2535
2878
  interface LogRecord {
2536
- level: LogMethodLevel;
2879
+ level: TLogMethodLevel;
2537
2880
  /** ISO 8601 timestamp produced at the moment the log call was made. */
2538
2881
  timestamp: string;
2539
2882
  message: string;
@@ -2605,7 +2948,20 @@ declare function getActiveLogger(): LoggerPort;
2605
2948
 
2606
2949
  interface Kernel {
2607
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;
2608
2964
  }
2609
2965
  declare function createKernel(): Kernel;
2610
2966
 
2611
- 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 };