@skill-map/cli 0.23.1 → 0.24.1

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.
@@ -1088,2244 +1088,2252 @@ declare function makePluginStore(opts: {
1088
1088
  }): IPluginStore | undefined;
1089
1089
 
1090
1090
  /**
1091
- * `scan_contributions` adapter, replace-all writer used by
1092
- * `persistScanResult`, plus read helpers consumed by the BFF
1093
- * (`/api/contributions/...`) and rules (`core/contribution-orphan`).
1094
- *
1095
- * One row per `(plugin_id, extension_id, node_path, contribution_id)`
1096
- * tuple. See `spec/architecture.md` § View contribution system →
1097
- * Persistence and `migrations/001_initial.sql` § View contribution
1098
- * layer for the normative shape.
1099
- *
1100
- * Replace-all semantics mirror the rest of the `scan_*` zone: every
1101
- * scan is a fresh snapshot, so prior rows are deleted before insert.
1102
- * Wrapped in the same transaction `persistScanResult` opens.
1103
- *
1104
- * The rename heuristic does NOT need to migrate `node_path` here,
1105
- * because of replace-all, every contribution is re-emitted on the new
1106
- * path automatically. Keeping the rename path lighter than `state_*`
1107
- * (which IS rename-migrated because state survives across scans).
1108
- */
1109
-
1110
- /**
1111
- * In-memory contribution record buffered during scan and flushed to
1112
- * `scan_contributions` by `persistScanResult`. One entry per accepted
1113
- * `ctx.emitContribution(id, payload)` call. Payload validation against
1114
- * the slot's payload schema happens at emit time (orchestrator);
1115
- * by the time records reach this adapter they are wire-shape clean.
1091
+ * Row-level filter for `port.scans.findNodes(...)` (driven by
1092
+ * `sm list`'s flags). All fields are optional, an empty filter
1093
+ * returns every node sorted by `path` asc.
1116
1094
  */
1117
- interface IContributionRecord {
1118
- pluginId: string;
1119
- extensionId: string;
1120
- nodePath: string;
1121
- contributionId: string;
1095
+ interface INodeFilter {
1096
+ /** Restrict to a single node kind. Open string (matches `Node.kind`). */
1097
+ kind?: string;
1122
1098
  /**
1123
- * Closed enum value mirroring `view-slots.schema.json#/$defs/SlotName`.
1124
- * Persisted as TEXT (no SQL CHECK by design, see migration comment).
1099
+ * When `true`, keep only nodes whose path is referenced by at least
1100
+ * one `scan_issues.nodeIds` array.
1125
1101
  */
1126
- slot: string;
1127
- /** Already-validated payload. Serialised via `JSON.stringify` at write. */
1128
- payload: unknown;
1129
- emittedAt: number;
1102
+ hasIssues?: boolean;
1103
+ /**
1104
+ * Sort column. The adapter validates against its own whitelist and
1105
+ * rejects anything else with an Error (the CLI's own usage-error
1106
+ * exit is the right place to surface a bad `--sort-by`; the port
1107
+ * defends in depth).
1108
+ */
1109
+ sortBy?: string;
1110
+ /** `'asc'` or `'desc'`. Defaults to the adapter's per-column convention. */
1111
+ sortDirection?: 'asc' | 'desc';
1112
+ /** Cap the result. Positive integer; absent → no limit. */
1113
+ limit?: number;
1130
1114
  }
1131
1115
  /**
1132
- * Single contribution row as returned to callers. The payload is
1133
- * `unknown` because the slot space is open at the type layer (catalog
1134
- * evolution is a kernel + spec concern); narrow at the call site by
1135
- * reading `slot`.
1116
+ * Bundled fetch for `port.scans.findNode(path)`, one node and
1117
+ * everything `sm show <path>` displays alongside it. Every field is
1118
+ * computed from `scan_*` zone reads only; per-domain data (history,
1119
+ * jobs, plugin enrichments) ships through other namespaces.
1136
1120
  */
1137
- interface IPersistedContribution {
1138
- pluginId: string;
1139
- extensionId: string;
1140
- nodePath: string;
1141
- contributionId: string;
1142
- slot: string;
1143
- payload: unknown;
1144
- emittedAt: number;
1121
+ interface INodeBundle {
1122
+ node: Node;
1123
+ linksOut: Link[];
1124
+ linksIn: Link[];
1125
+ issues: Issue[];
1145
1126
  }
1146
-
1147
- /**
1148
- * `loadScanResult`, driving inverse of `persistScanResult`. Reads the
1149
- * `scan_*` tables and reconstructs a `ScanResult` shape so the
1150
- * orchestrator can run an incremental scan (`sm scan --changed`) on
1151
- * top of a prior snapshot.
1152
- *
1153
- * The reconstruction is faithful for everything that was actually
1154
- * persisted: nodes (with triple-split bytes / tokens, denormalised
1155
- * counts, JSON frontmatter), internal links (with regrouped
1156
- * `trigger` / `location`, parsed `sources[]`), and issues
1157
- * (with parsed `nodeIds` / `linkIndices` / `fix` / `data`).
1158
- *
1159
- * **Documented omission**: external pseudo-links (those whose target is
1160
- * an `http://` / `https://` URL emitted by the external-url-counter
1161
- * extractor) are NEVER persisted to `scan_links`, only their per-node
1162
- * count survives in `scan_nodes.external_refs_count`. Therefore the
1163
- * `result.links` returned by `loadScanResult` contains only internal
1164
- * graph links, and `node.externalRefsCount` is the authoritative count
1165
- * carried over from the prior scan. The orchestrator's incremental path
1166
- * preserves that count for "unchanged" nodes and re-derives it for
1167
- * new / modified nodes from a fresh extractor pass.
1168
- *
1169
- * Meta envelope: the `scan_meta` table persists `scope` / `roots` /
1170
- * `scannedAt` / `scannedBy` / `providers` / `stats.filesWalked` /
1171
- * `stats.filesSkipped` / `stats.durationMs`. When the row exists,
1172
- * those fields come back authoritatively. When it does not (DB
1173
- * freshly migrated but never scanned, or a legacy DB never
1174
- * re-persisted), the loader degrades to a synthetic envelope:
1175
- *
1176
- * - `scannedAt` ← max(`scan_nodes.scanned_at`); falls back to `Date.now()`
1177
- * for empty snapshots so the field stays a positive integer.
1178
- * - `scope` ← `'project'`.
1179
- * - `roots` ← `['.']` to satisfy spec's `minItems: 1`. NOT
1180
- * load-bearing: the orchestrator's incremental path only reads
1181
- * `nodes` / `links` / `issues` from the prior; it never reuses the
1182
- * prior `roots`.
1183
- * - `providers` ← `[]`.
1184
- * - `stats` ← zeros for `filesWalked` / `filesSkipped` /
1185
- * `durationMs`; the three count fields derive from row counts.
1186
- *
1187
- * Both branches keep `nodesCount` / `linksCount` / `issuesCount` derived
1188
- * from `COUNT(*)` of the loaded rows, never persisted, always recomputed.
1189
- */
1190
-
1191
1127
  /**
1192
- * Spec § A.9, load the fine-grained Extractor cache as a per-node map
1193
- * from qualified extractor id (`<pluginId>/<id>`) to the run-time
1194
- * hashes the extractor recorded on its last run. Empty map is the
1195
- * default when the table is empty (fresh DB, never-scanned scope, or
1196
- * every extractor has been uninstalled since the last scan).
1197
- *
1198
- * Returned shape: `Map<nodePath, Map<extractorId, IPriorExtractorRun>>`.
1199
- * The inner value carries the body hash AND the sidecar-annotations
1200
- * hash so the orchestrator can apply the widened cache key (both must
1201
- * match for a cache hit).
1128
+ * Output of `port.scans.countRows()`. Used by `sm scan` to decide
1129
+ * whether the persist would wipe a populated DB (the "refusing to
1130
+ * wipe" guard) and by `sm db status` for the human summary.
1202
1131
  */
1203
- interface IPriorExtractorRun {
1204
- bodyHash: string;
1205
- sidecarAnnotationsHash: string;
1132
+ interface INodeCounts {
1133
+ nodes: number;
1134
+ links: number;
1135
+ issues: number;
1206
1136
  }
1207
-
1208
1137
  /**
1209
- * `.skillmapignore` parser + filter facade. Wraps `ignore` (kaelzhang)
1210
- * with the project-local layering: bundled defaults → `config.ignore`
1211
- * (from `.skill-map/settings.json`) `.skillmapignore` file content.
1212
- *
1213
- * Why a wrapper instead of exposing `ignore` directly:
1214
- *
1215
- * 1. Single-source defaults, `src/config/defaults/skillmapignore` is
1216
- * the canonical default list, loaded once at module init (or at
1217
- * explicit build time, depending on bundling). The runtime never
1218
- * re-reads it per scan.
1219
- * 2. Stable interface, Providers and the orchestrator depend on a
1220
- * minimal `IIgnoreFilter` shape, so the underlying library can be
1221
- * swapped without touching every consumer.
1222
- * 3. Path normalization, every consumer passes the path RELATIVE to
1223
- * the scan root (POSIX separators); the wrapper guarantees that
1224
- * contract before delegating to `ignore`.
1138
+ * Lightweight option bag for `port.scans.persist`. Mirrors the trailing
1139
+ * arguments of the legacy `persistScanResult(db, result, renameOps,
1140
+ * extractorRuns, enrichments)` free function so the adapter
1141
+ * implementation is a one-line delegation today; the named-bag shape
1142
+ * tomorrow lets new optional inputs land without breaking callers.
1225
1143
  */
1226
- interface IIgnoreFilter {
1144
+ interface IPersistOptions {
1145
+ renameOps?: RenameOp[];
1146
+ extractorRuns?: IExtractorRunRecord[];
1147
+ enrichments?: IEnrichmentRecord[];
1148
+ contributions?: IContributionRecord[];
1227
1149
  /**
1228
- * Returns `true` when `relativePath` should be skipped. The caller
1229
- * MUST pass paths relative to the scan root, with POSIX separators
1230
- * (forward slashes), no leading `/`. Directories MAY be passed with
1231
- * or without trailing `/`; the wrapper does not require it.
1150
+ * Phase 3 / View contribution system, active runtime catalog of
1151
+ * registered view contributions, keyed by qualified id
1152
+ * `<pluginId>/<extensionId>/<contributionId>`. Passed to the
1153
+ * `scan_contributions` upsert so the catalog sweep can drop rows
1154
+ * belonging to plugins / extensions that are no longer in the
1155
+ * catalog (uninstalled plugins, disabled bundles, removed
1156
+ * contributions). Empty / absent set = no catalog sweep (legacy
1157
+ * behaviour, leaves disabled-plugin rows stale per design F24
1158
+ * pre-fix).
1232
1159
  */
1233
- ignores(relativePath: string): boolean;
1160
+ registeredContributionKeys?: ReadonlySet<string>;
1161
+ /**
1162
+ * Phase 3 / View contribution system, set of `(plugin, extension,
1163
+ * node)` tuples where the extension actually RAN against that node
1164
+ * in this scan. Format: `<pluginId>/<extensionId>/<nodePath>` (no
1165
+ * contribution-id segment, the sweep operates at the (plugin,
1166
+ * extension, node) level and inspects the buffer to decide which
1167
+ * contribution-ids survive).
1168
+ *
1169
+ * Membership rules:
1170
+ * - Extractor + cache miss: tuple INCLUDED (extract() ran).
1171
+ * - Extractor + cache hit: tuple OMITTED (extract() skipped, prior
1172
+ * rows must be preserved).
1173
+ * - Rule, every node in `ctx.nodes`: tuple INCLUDED (rules always
1174
+ * run and see the full graph).
1175
+ *
1176
+ * Drives the per-tuple sweep documented in `spec/architecture.md`
1177
+ * §View contribution system → Persistence (sweep #3): rows whose
1178
+ * `(plugin_id, extension_id, node_path)` is in this set but whose
1179
+ * `(plugin_id, extension_id, node_path, contribution_id)` is NOT in
1180
+ * the buffer get DELETEd before the upsert. Catches the "extractor
1181
+ * used to emit, now does not" case (e.g. body change removes the
1182
+ * trigger). Empty / absent set = no per-tuple sweep (legacy
1183
+ * callers preserve the pre-fix behaviour where stale rows linger).
1184
+ */
1185
+ freshlyRunTuples?: ReadonlySet<string>;
1234
1186
  }
1235
-
1236
1187
  /**
1237
- * Diagnostic surfaced by a parser when the raw input was structurally
1238
- * malformed (e.g. YAML parse error). The parser MUST still return a
1239
- * usable `{ frontmatter, frontmatterRaw, body }` triple (defaults are
1240
- * fine) so the scan keeps making progress; this carries the message
1241
- * the orchestrator translates into a kernel `Issue` with severity
1242
- * `warn` (and `error` under `--strict`).
1243
- *
1244
- * Pure data: parsers never log or throw; they describe the failure
1245
- * here and let the orchestrator decide how to surface it.
1188
+ * Issue row as the storage layer sees it, paired with its DB-assigned
1189
+ * id so `port.issues.deleteById(id)` can target it inside a
1190
+ * transaction. The runtime `Issue` shape (per `issue.schema.json`) does
1191
+ * not carry `id` because the spec models issues as ephemeral findings
1192
+ * scoped to a scan; the DB does need the synthetic id to update / delete
1193
+ * a single row.
1246
1194
  */
1247
- interface IParseIssue {
1248
- /**
1249
- * Stable tag describing the failure class. The only emitter today
1250
- * is `frontmatter-yaml` reporting a YAML parse error
1251
- * (`'frontmatter-parse-error'`); the set may grow as new parsers
1252
- * land.
1253
- */
1254
- code: string;
1255
- /**
1256
- * Human-readable message, sanitised. Never includes the raw input
1257
- * (a hostile YAML could embed multi-line garbage); only the
1258
- * parser-error string is interpolated.
1259
- */
1260
- message: string;
1195
+ interface IIssueRow {
1196
+ id: number;
1197
+ issue: Issue;
1261
1198
  }
1262
-
1263
1199
  /**
1264
- * Provider runtime contract. Walks filesystem roots and emits raw node
1265
- * records; classification maps path conventions to a node kind.
1200
+ * Filter + pagination shape for `port.issues.list(...)`, driven by the
1201
+ * BFF's `/api/issues` route. Every field is optional, an empty filter
1202
+ * returns every issue ordered by `id` ASC (insertion order, stable
1203
+ * across pages so `offset` / `limit` paging is deterministic).
1266
1204
  *
1267
- * Distinct from the **hexagonal-architecture** 'adapter' (`RunnerPort.adapter`,
1268
- * `StoragePort.adapter`, etc.). A `Provider` is an extension kind authored
1269
- * by plugins to declare a platform's universe (the catalog of kinds it
1270
- * emits, the per-kind frontmatter schema, the filesystem directory it
1271
- * owns); a hexagonal adapter is an internal implementation of a port.
1272
- * Both can coexist without confusion because they live in different
1273
- * namespaces.
1205
+ * The three semantic filters mirror `/api/issues`'s query params:
1274
1206
  *
1275
- * `walk()` is an async iterator so large scopes don't buffer in memory.
1276
- * Each yielded `IRawNode` carries the full parsed frontmatter + body plus
1277
- * the path relative to the scan root; the kernel computes hashes, bytes,
1278
- * and tokens on top.
1207
+ * - `severities`, narrowed list of `Severity` values. Empty / absent
1208
+ * matches every severity.
1209
+ * - `analyzerIds`, accepts qualified (`<plugin>/<id>`) AND short
1210
+ * (`<id>`) forms; the suffix-match semantics live in
1211
+ * `matchesAnalyzerFilter`. Each entry generates two SQL clauses
1212
+ * (`= ?` and `LIKE '%/' || ?`) ORed together so the filter remains
1213
+ * a single SQL pass with parameterised values, no string
1214
+ * interpolation. Empty / absent matches every analyzer id.
1215
+ * - `nodePath`, keeps issues whose `nodeIds` JSON array contains the
1216
+ * given path (correlated EXISTS over `json_each`). Absent / null
1217
+ * skips the filter.
1279
1218
  *
1280
- * **Spec 0.8.0**. Per-kind frontmatter schemas relocated from the spec
1281
- * to the Provider that owns them. The flat
1282
- * `defaultRefreshAction` map collapsed into the new `kinds` map: every
1283
- * kind the Provider emits gets one entry that declares both its schema
1284
- * and its refresh action. Spec keeps only `frontmatter/base.schema.json`
1285
- * (universal); per-kind schemas live with the Provider.
1219
+ * Pagination is mandatory; the route layer fills the defaults via
1220
+ * `parsePagination`. `total` in `IIssueListResult` reports the total
1221
+ * MATCHING the filters (not just the page slice) so the SPA can
1222
+ * surface a correct page-count without a second round-trip.
1286
1223
  */
1287
-
1288
- interface IRawNode {
1289
- /** Path relative to the scan root that produced this node. */
1290
- path: string;
1291
- /** Raw markdown body (everything after the frontmatter fence). */
1292
- body: string;
1293
- /** Raw frontmatter text (between `---` fences). Empty string when absent. */
1294
- frontmatterRaw: string;
1295
- /** Parsed frontmatter, or `{}` when absent / unparseable. */
1296
- frontmatter: Record<string, unknown>;
1224
+ interface IIssueListFilter {
1297
1225
  /**
1298
- * Parser diagnostics (audit L1). Populated by the walker when the
1299
- * parser surfaced `IParseIssue` entries (e.g. malformed YAML).
1300
- * Carried through `processRawNode` and converted into warn-level
1301
- * kernel `Issue` rows inside `buildFreshNodeAndValidateFrontmatter`.
1302
- * Empty / undefined on the happy path.
1226
+ * Severity tokens to match. Typed as open `string` (not the
1227
+ * `Severity` union) so an unknown value from a URL query string
1228
+ * surfaces as a zero-match SQL query, not a kernel validation
1229
+ * error. The adapter parameterises each entry into the `IN(...)`
1230
+ * clause; unrecognised severities simply match no rows.
1303
1231
  */
1304
- parseIssues?: readonly IParseIssue[];
1232
+ severities?: readonly string[];
1233
+ analyzerIds?: readonly string[];
1234
+ nodePath?: string | null;
1235
+ offset: number;
1236
+ limit: number;
1305
1237
  }
1306
1238
  /**
1307
- * One entry in a Provider's `kinds` map. Declares both the per-kind
1308
- * frontmatter schema (path relative to the Provider's package dir, plus
1309
- * the loaded JSON object the kernel passes to AJV) and the qualified
1310
- * default refresh action id the UI dispatches for nodes of this kind.
1311
- *
1312
- * The split between `schema` (manifest-level path) and `schemaJson`
1313
- * (runtime-loaded JSON) keeps the manifest shape spec-conformant while
1314
- * letting the runtime instance carry the parsed schema without a second
1315
- * filesystem read at scan time. Built-in Providers populate `schemaJson`
1316
- * via `import schema from './schemas/skill.schema.json' with { type: 'json' }`;
1317
- * user-plugin Providers loaded by `PluginLoader` will have it filled in
1318
- * by the loader after manifest validation.
1239
+ * Output of `port.issues.list(...)`. `items` is the page slice (length
1240
+ * `filter.limit`); `total` is the count of rows matching the filters
1241
+ * before pagination was applied.
1319
1242
  */
1320
- interface IProviderKind {
1321
- /**
1322
- * Path to the kind's frontmatter JSON Schema, relative to the
1323
- * Provider's package directory. Mirrors the spec field of the same
1324
- * name in `extensions/provider.schema.json#/properties/kinds/.../schema`.
1325
- */
1326
- schema: string;
1327
- /**
1328
- * Loaded JSON Schema document for the kind. The kernel registers this
1329
- * with AJV at scan boot and validates each node's frontmatter against
1330
- * it. The schema MUST extend the spec's
1331
- * `frontmatter/base.schema.json` via `allOf` + `$ref` to base's
1332
- * `$id`; the loader registers base into the same AJV instance so
1333
- * cross-package `$ref`-by-`$id` resolves transparently.
1334
- *
1335
- * `unknown` rather than a stronger type because AJV consumes any JSON
1336
- * Schema object; tightening to a concrete shape would require mirroring
1337
- * the JSON Schema vocabulary in TypeScript.
1338
- */
1339
- schemaJson: unknown;
1340
- /**
1341
- * Qualified action id (`<plugin-id>/<action-id>`) the probabilistic-
1342
- * refresh UI dispatches for nodes of this kind. The kernel resolves
1343
- * the id against its qualified action registry; a dangling reference
1344
- * disables the Provider with status `invalid-manifest`.
1345
- */
1346
- defaultRefreshAction: string;
1347
- /**
1348
- * Presentation metadata the UI consumes to render nodes of this kind
1349
- * (palette swatches, list tags, graph nodes, filter chips). Required
1350
- * so the UI never has to invent visuals for a Provider-declared kind.
1351
- * Mirrors `extensions/provider.schema.json#/properties/kinds/.../ui`.
1352
- */
1353
- ui: IProviderKindUi;
1243
+ interface IIssueListResult {
1244
+ items: Issue[];
1245
+ total: number;
1354
1246
  }
1355
- /**
1356
- * Presentation contract for one Provider kind. The Provider declares
1357
- * intent (label + base color, optional dark variant + emoji + icon);
1358
- * the UI derives `bg`/`fg` tints per theme via a deterministic helper
1359
- * and reads the registry from the `kindRegistry` field embedded in REST
1360
- * envelopes. Single source of truth for what a kind looks like, the
1361
- * UI never hardcodes presentation for a built-in kind.
1362
- */
1363
- interface IProviderKindUi {
1364
- /**
1365
- * Plural human-readable label for groups of this kind (e.g. `'Skills'`,
1366
- * `'Agents'`, `'Cursor Rules'`). Used in filter dropdowns, palette
1367
- * tooltips, and any list grouping.
1368
- */
1369
- label: string;
1370
- /**
1371
- * Base hex color (`#RRGGBB`) for the light theme. The UI derives `bg`
1372
- * and `fg` tints from this value at runtime via a deterministic
1373
- * helper. Declaring one base value (instead of three) keeps the
1374
- * manifest small and centralises accessibility-driven contrast in the
1375
- * UI.
1376
- */
1377
- color: string;
1378
- /**
1379
- * Optional dark-theme variant of `color`. When absent, the UI falls
1380
- * back to `color`. Declared explicitly because a luminosity flip
1381
- * rarely matches the brand intent for kinds that should stand out in
1382
- * dark mode.
1383
- */
1384
- colorDark?: string;
1385
- /**
1386
- * Optional decorative emoji used as a fallback when `icon` is absent
1387
- * or fails to render. Length-bound so the UI can lay it out
1388
- * predictably alongside text.
1389
- */
1390
- emoji?: string;
1391
- /**
1392
- * Optional discriminated icon descriptor. The UI prefers `icon` over
1393
- * `emoji`; when both are absent, the UI falls back to the first
1394
- * letter of `label` colored with `color`.
1395
- */
1396
- icon?: TProviderKindIcon;
1247
+ /** Output of `port.jobs.pruneTerminal` / `listTerminalCandidates`. */
1248
+ interface IPruneResult {
1249
+ /** How many `state_jobs` rows were deleted (or would be, in dry-run). */
1250
+ deletedCount: number;
1251
+ /** Job-file paths from the affected rows; the CLI unlinks these from disk. `null` `filePath` rows contribute nothing here. */
1252
+ filePaths: string[];
1253
+ }
1254
+ /** Filter shape for `port.history.list`. All fields optional. */
1255
+ interface IListExecutionsFilter {
1256
+ /** Restrict to executions whose `nodeIds` array contains this path. */
1257
+ nodePath?: string;
1258
+ /** Exact match on `extension_id`. */
1259
+ actionId?: string;
1260
+ /** Subset of {`completed`,`failed`,`cancelled`}. */
1261
+ statuses?: ExecutionStatus[];
1262
+ /** Lower bound (inclusive) on `started_at`. Unix ms. */
1263
+ sinceMs?: number;
1264
+ /** Upper bound (exclusive) on `started_at`. Unix ms. */
1265
+ untilMs?: number;
1266
+ /** Cap result count. No default. */
1267
+ limit?: number;
1397
1268
  }
1269
+ /** Window shape for `port.history.aggregateStats`. */
1270
+ interface IHistoryStatsRange {
1271
+ /** Inclusive lower bound. `null` = all-time. */
1272
+ sinceMs: number | null;
1273
+ /** Exclusive upper bound. */
1274
+ untilMs: number;
1275
+ }
1276
+ /** Period bucket granularity for `port.history.aggregateStats`. */
1277
+ type THistoryStatsPeriod = 'day' | 'week' | 'month';
1398
1278
  /**
1399
- * Discriminated icon contract. `pi` references a PrimeIcons identifier
1400
- * (e.g. `'pi-cog'`); `svg` carries raw SVG path data the UI wraps in a
1401
- * `<svg viewBox="0 0 24 24"><path d="…"/></svg>` element tinted with
1402
- * `currentColor`. The discriminator (`kind`) keeps the UI dispatch
1403
- * exhaustive without string-sniffing the payload.
1279
+ * Output of `port.transaction(tx => tx.history.migrateNodeFks(from, to))`.
1280
+ * Lists how many rows in each `state_*` table were repointed plus any
1281
+ * composite-PK collisions that forced a drop instead of an update.
1404
1282
  */
1405
- type TProviderKindIcon = {
1406
- kind: 'pi';
1407
- id: string;
1408
- } | {
1409
- kind: 'svg';
1410
- path: string;
1411
- };
1412
- interface IProvider extends IExtensionBase {
1413
- kind: 'provider';
1414
- /**
1415
- * Catalog of node kinds this Provider emits. Keyed by kind name. Every
1416
- * kind the Provider can `classify()` MUST have an entry; an entry is
1417
- * the union of the kind's frontmatter schema and its default refresh
1418
- * action.
1419
- *
1420
- * The string keys are typed loosely (`string`) rather than `NodeKind`
1421
- * because the value space is open by design: a future Cursor Provider
1422
- * could declare `rule`, an Obsidian Provider could declare `daily`.
1423
- * The kernel's hard-coded `NodeKind` union represents the kinds the
1424
- * built-in Claude Provider emits; it is NOT the kernel-wide kind type
1425
- * (see `kernel/types.ts:NodeKind` docstring). `Node.kind`, the AJV
1426
- * `node.schema.json` validator, and the SQLite `scan_nodes.kind`
1427
- * column all accept any non-empty string an enabled Provider returns.
1428
- */
1429
- kinds: Record<string, IProviderKind>;
1283
+ interface IMigrateNodeFksReport {
1284
+ jobs: number;
1285
+ executions: number;
1286
+ summaries: number;
1287
+ enrichments: number;
1288
+ pluginKvs: number;
1289
+ nodeFavorites: number;
1430
1290
  /**
1431
- * Optional auxiliary JSON Schemas this Provider's per-kind schemas
1432
- * `$ref` by `$id`. Registered with AJV via `addSchema` BEFORE the
1433
- * per-kind schemas compile, so cross-file `$ref` resolution succeeds.
1434
- *
1435
- * Use case: when several kinds share a common base (e.g. Anthropic's
1436
- * merged skill / command frontmatter, both extend a shared
1437
- * `skill-base.schema.json`), the Provider declares the base here so
1438
- * `skill.schema.json` and `command.schema.json` can `$ref` it without
1439
- * duplicating fields.
1440
- *
1441
- * Runtime-only, does NOT appear in the spec's `provider.schema.json`
1442
- * manifest. Manifest-validated schemas remain the per-kind ones in
1443
- * `kinds[<kind>].schema`; auxiliary schemas are an implementation
1444
- * concern of how the runtime composes those.
1291
+ * Collisions encountered when migrating any of the keyed-by-node
1292
+ * `state_*` tables because a row already existed at the destination
1293
+ * PK. The pre-existing rows are preserved, the migrating rows are
1294
+ * dropped (deleted from `fromPath` without a corresponding INSERT).
1295
+ * One entry per dropped row, with the affected PK fields included
1296
+ * for diagnostic output. `state_node_favorites` has no composite key
1297
+ * so its `keys` is the empty object.
1445
1298
  */
1446
- schemas?: unknown[];
1447
- /**
1448
- * Declarative file-discovery config consumed by the kernel walker.
1449
- * When present, the kernel walks every root, includes files whose
1450
- * extension matches `extensions`, parses each with the parser id
1451
- * registered in the kernel-internal registry, and yields `IRawNode`
1452
- * records the same shape `walk()` would.
1453
- *
1454
- * When neither `read` nor `walk` is declared, `resolveProviderWalk`
1455
- * applies the default `{ extensions: ['.md'], parser: 'frontmatter-yaml' }`
1456
- * so the most common Provider shape needs zero configuration.
1457
- *
1458
- * Precedence: when both `walk()` (runtime field) and `read` are
1459
- * declared, `walk()` wins, `read` is ignored. The escape-hatch
1460
- * relationship is intentional: most Providers should use `read`;
1461
- * Providers with non-standard discovery requirements (custom file
1462
- * naming, multi-pass walks, dynamic ignore logic) implement `walk()`
1463
- * directly and accept the duplication of audit-cleared defences.
1464
- *
1465
- * Built-in parsers: `'frontmatter-yaml'` (markdown with `--- … ---`
1466
- * YAML frontmatter; pollution-strip + JSON_SCHEMA-pinned), `'plain'`
1467
- * (entire body, empty frontmatter). The set is closed; user plugins
1468
- * cannot register their own.
1469
- */
1470
- read?: IProviderReadConfig;
1471
- /**
1472
- * Walk the given roots and yield every node the Provider recognises.
1473
- * Non-matching files are silently skipped. Unreadable files produce
1474
- * a diagnostic via the emitter but do not abort the walk.
1475
- *
1476
- * `options.ignoreFilter`, when supplied, the Provider MUST
1477
- * skip every directory and file whose path-relative-to-root the
1478
- * filter reports as ignored. Providers MAY also keep their own
1479
- * hard-coded skip list (e.g. `.git`) as a defensive measure, but the
1480
- * filter is the canonical source of user intent.
1481
- *
1482
- * Optional. When omitted, the Provider MUST declare `read` (or rely
1483
- * on the default config). The orchestrator never calls `walk()`
1484
- * directly, it goes through `resolveProviderWalk(provider)` which
1485
- * picks `walk` over `read`.
1486
- */
1487
- walk?(roots: string[], options?: {
1488
- ignoreFilter?: IIgnoreFilter;
1489
- }): AsyncIterable<IRawNode>;
1490
- /**
1491
- * Given a path and its parsed frontmatter, decide the node kind, or
1492
- * `null` to disclaim the file. The classifier is called after walk()
1493
- * yields; with multiple Providers active, every Provider walks every
1494
- * file matching its `read.extensions`, so each Provider MUST disclaim
1495
- * paths it does not recognise. Returning the same path's kind from
1496
- * two Providers fires the spec's `provider-ambiguous` issue and the
1497
- * orchestrator drops the duplicate.
1498
- *
1499
- * Convention: a Provider's classify returns one of its own `kinds`
1500
- * map keys for paths in its territory (`.claude/`, `.gemini/`,
1501
- * `.agents/skills/`, etc.) and `null` elsewhere. External Providers
1502
- * (Cursor, Obsidian, …) follow the same rule: claim what's yours,
1503
- * disclaim everything else. The orchestrator does not validate the
1504
- * kind against `NodeKind`.
1505
- */
1506
- classify(path: string, frontmatter: Record<string, unknown>): string | null;
1299
+ collisions: Array<{
1300
+ table: 'state_summaries' | 'state_enrichments' | 'state_plugin_kvs' | 'state_node_favorites';
1301
+ fromPath: string;
1302
+ toPath: string;
1303
+ keys: Record<string, string>;
1304
+ }>;
1305
+ }
1306
+ /** A single `config_plugins` override row as the kernel sees it. */
1307
+ interface IPluginConfigRow {
1308
+ pluginId: string;
1309
+ enabled: boolean;
1310
+ configJson: string | null;
1311
+ updatedAt: number;
1312
+ }
1313
+ /** Discovered kernel migration file (one of `NNN_snake_case.sql`). */
1314
+ interface IMigrationFile {
1315
+ version: number;
1316
+ description: string;
1317
+ filePath: string;
1318
+ }
1319
+ /** A row from the `config_schema_versions` ledger for the kernel scope. */
1320
+ interface IMigrationRecord {
1321
+ scope: string;
1322
+ ownerId: string;
1323
+ version: number;
1324
+ description: string;
1325
+ appliedAt: number;
1326
+ }
1327
+ /** `port.migrations.plan` output: applied vs pending. */
1328
+ interface IMigrationPlan {
1329
+ applied: IMigrationRecord[];
1330
+ pending: IMigrationFile[];
1331
+ }
1332
+ /** Apply-time options for `port.migrations.apply`. */
1333
+ interface IApplyOptions {
1334
+ backup?: boolean;
1335
+ dryRun?: boolean;
1336
+ to?: number;
1337
+ }
1338
+ /** Result of `port.migrations.apply`. */
1339
+ interface IApplyResult {
1340
+ applied: IMigrationFile[];
1341
+ backupPath: string | null;
1342
+ }
1343
+ /** Discovered plugin migration file. Same `NNN_snake_case.sql` convention. */
1344
+ interface IPluginMigrationFile {
1345
+ version: number;
1346
+ description: string;
1347
+ filePath: string;
1348
+ }
1349
+ /** A row from the `config_schema_versions` ledger for a single plugin. */
1350
+ interface IPluginMigrationRecord {
1351
+ version: number;
1352
+ description: string;
1353
+ appliedAt: number;
1354
+ }
1355
+ /** `port.pluginMigrations.plan` output for a single plugin. */
1356
+ interface IPluginMigrationPlan {
1357
+ pluginId: string;
1358
+ applied: IPluginMigrationRecord[];
1359
+ pending: IPluginMigrationFile[];
1360
+ }
1361
+ /** Apply-time options for `port.pluginMigrations.apply`. */
1362
+ interface IPluginApplyOptions {
1363
+ /** No actual writes; surfaces what would run. Default false. */
1364
+ dryRun?: boolean;
1365
+ }
1366
+ /** Result of `port.pluginMigrations.apply`. */
1367
+ interface IPluginApplyResult {
1368
+ pluginId: string;
1369
+ applied: IPluginMigrationFile[];
1370
+ /** Catalog intrusions caught by Layer 3 (post-apply sweep). Empty when clean. */
1371
+ intrusions: string[];
1507
1372
  }
1508
1373
  /**
1509
- * Declarative read config a Provider declares via `IProvider.read`.
1510
- * Mirrors `extensions/provider.schema.json#/properties/read` at the
1511
- * TypeScript level. Built-in parser ids: `'frontmatter-yaml'`, `'plain'`.
1374
+ * Single contribution row as returned to callers of the
1375
+ * `contributions` namespace on `StoragePort`. The payload is
1376
+ * `unknown` because the slot space is open at the type layer (catalog
1377
+ * evolution is a kernel + spec concern); narrow at the call site by
1378
+ * reading `slot`.
1379
+ *
1380
+ * Lives next to the port (not under `adapters/sqlite/`) so non-SQLite
1381
+ * implementations of `StoragePort` (in-memory test harness, future
1382
+ * Postgres adapter) can satisfy the port contract without importing
1383
+ * from the SQLite adapter. The SQLite adapter re-exports this type
1384
+ * for backwards compatibility with callers that still import from
1385
+ * the adapter path.
1512
1386
  */
1513
- interface IProviderReadConfig {
1514
- /**
1515
- * File extensions the walker yields. Strings include the leading dot
1516
- * (e.g. `'.md'`, `'.mdc'`, `'.toml'`). Match is suffix-based; the
1517
- * comparison is case-sensitive.
1518
- */
1519
- extensions: string[];
1520
- /**
1521
- * Parser id from the kernel-internal registry. Built-ins:
1522
- * `'frontmatter-yaml'`, `'plain'`. Unknown ids surface as
1523
- * `UnknownParserError` from the walker; the orchestrator translates
1524
- * the error into a Provider issue with status `invalid-manifest`.
1525
- */
1526
- parser: string;
1387
+ interface IPersistedContribution {
1388
+ pluginId: string;
1389
+ extensionId: string;
1390
+ nodePath: string;
1391
+ contributionId: string;
1392
+ slot: string;
1393
+ payload: unknown;
1394
+ emittedAt: number;
1527
1395
  }
1528
1396
 
1529
1397
  /**
1530
- * Extractor runtime contract. Consumes a single node (frontmatter + body)
1531
- * and emits its output through three context-supplied callbacks rather than
1532
- * a return value. Extractors run in isolation: they MUST NOT read other
1533
- * nodes, the graph, or the DB. Cross-node reasoning lives in rules.
1534
- *
1535
- * Extractors are deterministic-only. They run synchronously inside the
1536
- * scan loop; LLM-driven enrichment of a node is an Action concern, not
1537
- * an Extractor concern. The Extractor context therefore exposes no
1538
- * `RunnerPort`, see spec `architecture.md` §Execution modes.
1539
- *
1540
- * Output channels (all on the context):
1398
+ * `scan_contributions` adapter, replace-all writer used by
1399
+ * `persistScanResult`, plus read helpers consumed by the BFF
1400
+ * (`/api/contributions/...`) and rules (`core/contribution-orphan`).
1541
1401
  *
1542
- * - `ctx.emitLink(link)`, persist a link in the kernel's `links` table.
1543
- * Validated against `emitsLinkKinds` before insertion; an off-contract
1544
- * kind drops the link and surfaces an `extension.error` event.
1545
- * - `ctx.enrichNode(partial)`, merge canonical, kernel-curated properties
1546
- * onto the node. Strictly separate from the author-supplied frontmatter
1547
- * (the latter remains immutable and survives verbatim). Persistence
1548
- * is spec'd in § A.8.
1549
- * - `ctx.store`, plugin-scoped persistence. Present only when the
1550
- * plugin declares `storage.mode` in `plugin.json`; shape depends on the
1551
- * mode (`KvStore` for mode A, scoped `Database` for mode B). See
1552
- * `plugin-kv-api.md` for the contract.
1402
+ * One row per `(plugin_id, extension_id, node_path, contribution_id)`
1403
+ * tuple. See `spec/architecture.md` § View contribution system →
1404
+ * Persistence and `migrations/001_initial.sql` § View contribution
1405
+ * layer for the normative shape.
1553
1406
  *
1554
- * The manifest's `scope` field tells the orchestrator which parts to feed:
1555
- * `frontmatter` extractors receive an empty string for body and vice versa.
1407
+ * Replace-all semantics mirror the rest of the `scan_*` zone: every
1408
+ * scan is a fresh snapshot, so prior rows are deleted before insert.
1409
+ * Wrapped in the same transaction `persistScanResult` opens.
1556
1410
  *
1557
- * Renamed from `Detector` in spec 0.8.x. The previous `detect(ctx) → Link[]`
1558
- * signature is gone; everything now flows through `extract(ctx) void`
1559
- * and the callbacks above.
1411
+ * The rename heuristic does NOT need to migrate `node_path` here,
1412
+ * because of replace-all, every contribution is re-emitted on the new
1413
+ * path automatically. Keeping the rename path lighter than `state_*`
1414
+ * (which IS rename-migrated because state survives across scans).
1560
1415
  */
1561
1416
 
1562
1417
  /**
1563
- * Output callbacks supplied by the kernel on the extractor context.
1564
- * Split out so plugin authors can name the callback shape if they
1565
- * want to mock it in unit tests without depending on the wider
1566
- * `IExtractorContext`.
1418
+ * In-memory contribution record buffered during scan and flushed to
1419
+ * `scan_contributions` by `persistScanResult`. One entry per accepted
1420
+ * `ctx.emitContribution(id, payload)` call. Payload validation against
1421
+ * the slot's payload schema happens at emit time (orchestrator);
1422
+ * by the time records reach this adapter they are wire-shape clean.
1567
1423
  */
1568
- interface IExtractorCallbacks {
1569
- /**
1570
- * Emit a single Link. The orchestrator validates the link against the
1571
- * extractor's declared `emitsLinkKinds` before inserting it; off-contract
1572
- * links are silently dropped with an `extension.error` event.
1573
- */
1574
- emitLink(link: Link): void;
1575
- /**
1576
- * Merge canonical, kernel-curated properties onto the current node's
1577
- * enrichment layer. The author-supplied frontmatter stays untouched
1578
- * (Decision #109 in `ROADMAP.md`). Persistence and stale-tracking
1579
- * semantics live in spec § A.8; the orchestrator already buffers the
1580
- * partials and `persistScanResult` upserts them.
1581
- */
1582
- enrichNode(partial: Partial<Node>): void;
1583
- /**
1584
- * Emit a per-node view contribution. The first argument is the
1585
- * extension-local Record key declared under
1586
- * `extension.viewContributions[<contributionId>]`; the second is a
1587
- * payload that conforms to the slot's payload schema in
1588
- * `spec/schemas/view-slots.schema.json#/$defs/payloads/<slot>`,
1589
- * where `<slot>` is the slot the manifest declared for this
1590
- * contribution. The orchestrator validates the payload against the
1591
- * slot's schema before persisting to `scan_contributions`; off-shape
1592
- * payloads are silently dropped with an `extension.error` event
1593
- * (mirror of `emitLink` rejecting off-`emitsLinkKinds` links).
1594
- * Calling `emitContribution` with a `contributionId` that is not
1595
- * declared in the manifest is also dropped with an `extension.error`.
1596
- * See `architecture.md` §View contribution system → Emit path.
1597
- */
1598
- emitContribution(contributionId: string, payload: unknown): void;
1599
- }
1600
- interface IExtractorContext extends IExtractorCallbacks {
1601
- node: Node;
1602
- body: string;
1603
- frontmatter: Record<string, unknown>;
1424
+ interface IContributionRecord {
1425
+ pluginId: string;
1426
+ extensionId: string;
1427
+ nodePath: string;
1428
+ contributionId: string;
1604
1429
  /**
1605
- * Plugin-scoped persistence. Optional because not every plugin declares
1606
- * a `storage.mode` in `plugin.json`. Shape: `KvStoreWrapper` for mode A
1607
- * (`set(key, value)`), `DedicatedStoreWrapper` for mode B
1608
- * (`write(table, row)`). See `spec/plugin-kv-api.md`.
1609
- *
1610
- * Typed as `unknown` so this contract module stays free of any
1611
- * adapter-side imports, the concrete `IPluginStore` lives in
1612
- * `kernel/adapters/plugin-store.js`. Plugin authors narrow at the
1613
- * call site based on the storage mode declared in their manifest.
1614
- * The orchestrator looks up the wrapper per-extractor in
1615
- * `RunScanOptions.pluginStores` (keyed by `pluginId`) and attaches
1616
- * it here.
1430
+ * Closed enum value mirroring `view-slots.schema.json#/$defs/SlotName`.
1431
+ * Persisted as TEXT (no SQL CHECK by design, see migration comment).
1617
1432
  */
1618
- store?: unknown;
1619
- }
1620
- interface IExtractor extends IExtensionBase {
1621
- kind: 'extractor';
1622
- emitsLinkKinds: LinkKind[];
1623
- defaultConfidence: Confidence;
1624
- scope: 'frontmatter' | 'body' | 'both';
1625
- /**
1626
- * Optional opt-in filter on `node.kind`. When declared, the orchestrator
1627
- * skips invocation of `extract()` for any node whose `kind` is NOT in
1628
- * this list, fail-fast, before context construction, so the extractor
1629
- * wastes zero CPU on inapplicable nodes.
1630
- *
1631
- * Absent (`undefined`) is the default: the extractor applies to every
1632
- * kind. There are no wildcards, the absence of the field already
1633
- * encodes "every kind". An empty array (`[]`) is rejected at load
1634
- * time by AJV (`minItems: 1` in the schema).
1635
- *
1636
- * Unknown kinds (no installed Provider declares them) do NOT block
1637
- * the load: the extractor keeps `loaded` status and `sm plugins doctor`
1638
- * surfaces a warning. The Provider that declares the kind may arrive
1639
- * later (e.g. a user installs the corresponding plugin).
1640
- *
1641
- * Spec: `spec/schemas/extensions/extractor.schema.json#/properties/applicableKinds`.
1642
- */
1643
- applicableKinds?: string[];
1644
- /**
1645
- * Extractor entry point. Returns nothing; output flows through
1646
- * `ctx.emitLink`, `ctx.enrichNode`, and `ctx.store`.
1647
- */
1648
- extract(ctx: IExtractorContext): void | Promise<void>;
1433
+ slot: string;
1434
+ /** Already-validated payload. Serialised via `JSON.stringify` at write. */
1435
+ payload: unknown;
1436
+ emittedAt: number;
1649
1437
  }
1650
1438
 
1651
1439
  /**
1652
- * Analyzer runtime contract. Runs against the whole graph after every
1653
- * Provider and extractor has completed; emits issues and MAY project
1654
- * findings into the UI via view contributions. Deterministic analyzers
1655
- * are pure (same graph in → same issues out) and run synchronously
1656
- * inside `sm scan` / `sm check`. Probabilistic analyzers invoke an LLM
1657
- * through the kernel's `RunnerPort` and dispatch only as queued jobs,
1658
- * they never participate in scan-time pipelines. Mode is declared in
1659
- * the manifest (default `deterministic`).
1440
+ * `loadScanResult`, driving inverse of `persistScanResult`. Reads the
1441
+ * `scan_*` tables and reconstructs a `ScanResult` shape so the
1442
+ * orchestrator can run an incremental scan (`sm scan --changed`) on
1443
+ * top of a prior snapshot.
1444
+ *
1445
+ * The reconstruction is faithful for everything that was actually
1446
+ * persisted: nodes (with triple-split bytes / tokens, denormalised
1447
+ * counts, JSON frontmatter), internal links (with regrouped
1448
+ * `trigger` / `location`, parsed `sources[]`), and issues
1449
+ * (with parsed `nodeIds` / `linkIndices` / `fix` / `data`).
1450
+ *
1451
+ * **Documented omission**: external pseudo-links (those whose target is
1452
+ * an `http://` / `https://` URL emitted by the external-url-counter
1453
+ * extractor) are NEVER persisted to `scan_links`, only their per-node
1454
+ * count survives in `scan_nodes.external_refs_count`. Therefore the
1455
+ * `result.links` returned by `loadScanResult` contains only internal
1456
+ * graph links, and `node.externalRefsCount` is the authoritative count
1457
+ * carried over from the prior scan. The orchestrator's incremental path
1458
+ * preserves that count for "unchanged" nodes and re-derives it for
1459
+ * new / modified nodes from a fresh extractor pass.
1460
+ *
1461
+ * Meta envelope: the `scan_meta` table persists `scope` / `roots` /
1462
+ * `scannedAt` / `scannedBy` / `providers` / `stats.filesWalked` /
1463
+ * `stats.filesSkipped` / `stats.durationMs`. When the row exists,
1464
+ * those fields come back authoritatively. When it does not (DB
1465
+ * freshly migrated but never scanned, or a legacy DB never
1466
+ * re-persisted), the loader degrades to a synthetic envelope:
1467
+ *
1468
+ * - `scannedAt` ← max(`scan_nodes.scanned_at`); falls back to `Date.now()`
1469
+ * for empty snapshots so the field stays a positive integer.
1470
+ * - `scope` ← `'project'`.
1471
+ * - `roots` ← `['.']` to satisfy spec's `minItems: 1`. NOT
1472
+ * load-bearing: the orchestrator's incremental path only reads
1473
+ * `nodes` / `links` / `issues` from the prior; it never reuses the
1474
+ * prior `roots`.
1475
+ * - `providers` ← `[]`.
1476
+ * - `stats` ← zeros for `filesWalked` / `filesSkipped` /
1477
+ * `durationMs`; the three count fields derive from row counts.
1478
+ *
1479
+ * Both branches keep `nodesCount` / `linksCount` / `issuesCount` derived
1480
+ * from `COUNT(*)` of the loaded rows, never persisted, always recomputed.
1660
1481
  */
1661
1482
 
1662
1483
  /**
1663
- * Step 9.6.2, orphan sidecar entry surfaced to analyzers. A `.sm` file
1664
- * whose sibling `.md` does not exist on disk; the `annotation-orphan`
1665
- * built-in analyzer emits one warning per entry. Other analyzers that
1666
- * care about orphan sidecars MAY consume the list too.
1484
+ * Spec § A.9, load the fine-grained Extractor cache as a per-node map
1485
+ * from qualified extractor id (`<pluginId>/<id>`) to the run-time
1486
+ * hashes the extractor recorded on its last run. Empty map is the
1487
+ * default when the table is empty (fresh DB, never-scanned scope, or
1488
+ * every extractor has been uninstalled since the last scan).
1489
+ *
1490
+ * Returned shape: `Map<nodePath, Map<extractorId, IPriorExtractorRun>>`.
1491
+ * The inner value carries the body hash AND the sidecar-annotations
1492
+ * hash so the orchestrator can apply the widened cache key (both must
1493
+ * match for a cache hit).
1667
1494
  */
1668
- interface IAnalyzerOrphanSidecar {
1669
- /** Relative path (POSIX-separated) of the orphan `.sm`. */
1670
- relativePath: string;
1671
- /** Absolute path of the missing `.md` the sidecar was anchored to. */
1672
- expectedMdPath: string;
1495
+ interface IPriorExtractorRun {
1496
+ bodyHash: string;
1497
+ sidecarAnnotationsHash: string;
1673
1498
  }
1674
- interface IAnalyzerContext {
1675
- nodes: Node[];
1676
- links: Link[];
1499
+
1500
+ /**
1501
+ * `.skillmapignore` parser + filter facade. Wraps `ignore` (kaelzhang)
1502
+ * with the project-local layering: bundled defaults → `config.ignore`
1503
+ * (from `.skill-map/settings.json`) → `.skillmapignore` file content.
1504
+ *
1505
+ * Why a wrapper instead of exposing `ignore` directly:
1506
+ *
1507
+ * 1. Single-source defaults, `src/config/defaults/skillmapignore` is
1508
+ * the canonical default list, loaded once at module init (or at
1509
+ * explicit build time, depending on bundling). The runtime never
1510
+ * re-reads it per scan.
1511
+ * 2. Stable interface, Providers and the orchestrator depend on a
1512
+ * minimal `IIgnoreFilter` shape, so the underlying library can be
1513
+ * swapped without touching every consumer.
1514
+ * 3. Path normalization, every consumer passes the path RELATIVE to
1515
+ * the scan root (POSIX separators); the wrapper guarantees that
1516
+ * contract before delegating to `ignore`.
1517
+ */
1518
+ interface IIgnoreFilter {
1677
1519
  /**
1678
- * Step 9.6.2, orphaned sidecars discovered during the scan walk.
1679
- * Empty when sidecar discovery did not run (legacy callers) or
1680
- * when no orphans exist.
1520
+ * Returns `true` when `relativePath` should be skipped. The caller
1521
+ * MUST pass paths relative to the scan root, with POSIX separators
1522
+ * (forward slashes), no leading `/`. Directories MAY be passed with
1523
+ * or without trailing `/`; the wrapper does not require it.
1681
1524
  */
1682
- orphanSidecars?: IAnalyzerOrphanSidecar[];
1525
+ ignores(relativePath: string): boolean;
1526
+ }
1527
+
1528
+ /**
1529
+ * Diagnostic surfaced by a parser when the raw input was structurally
1530
+ * malformed (e.g. YAML parse error). The parser MUST still return a
1531
+ * usable `{ frontmatter, frontmatterRaw, body }` triple (defaults are
1532
+ * fine) so the scan keeps making progress; this carries the message
1533
+ * the orchestrator translates into a kernel `Issue` with severity
1534
+ * `warn` (and `error` under `--strict`).
1535
+ *
1536
+ * Pure data: parsers never log or throw; they describe the failure
1537
+ * here and let the orchestrator decide how to surface it.
1538
+ */
1539
+ interface IParseIssue {
1683
1540
  /**
1684
- * Step 9.6.6, raw parsed sidecar root keyed by `node.path`. Populated
1685
- * by the orchestrator alongside the public `Node.sidecar` overlay so
1686
- * analyzers that inspect plugin namespaces (e.g. the built-in
1687
- * `core/unknown-field` Analyzer) can walk the full tree without
1688
- * re-reading the file from disk. Absent (or `undefined` per node)
1689
- * when no sidecar accompanies the node, or when the sidecar failed
1690
- * to parse. Treat as read-only.
1541
+ * Stable tag describing the failure class. The only emitter today
1542
+ * is `frontmatter-yaml` reporting a YAML parse error
1543
+ * (`'frontmatter-parse-error'`); the set may grow as new parsers
1544
+ * land.
1691
1545
  */
1692
- sidecarRoots?: ReadonlyMap<string, Record<string, unknown>>;
1546
+ code: string;
1693
1547
  /**
1694
- * Step 9.6.6, runtime catalog of plugin-contributed annotation keys,
1695
- * as exposed by `kernel.getRegisteredAnnotationKeys()`. Threaded
1696
- * through so analyzers can reason about the registered-vs-unknown
1697
- * split without reaching back into the kernel. Empty array when no
1698
- * plugin declares contributions; absent for legacy callers (older
1699
- * runScan sites that never wired the catalog through).
1548
+ * Human-readable message, sanitised. Never includes the raw input
1549
+ * (a hostile YAML could embed multi-line garbage); only the
1550
+ * parser-error string is interpolated.
1700
1551
  */
1701
- annotationContributions?: readonly IRegisteredAnnotationKey[];
1552
+ message: string;
1553
+ }
1554
+
1555
+ /**
1556
+ * Provider runtime contract. Walks filesystem roots and emits raw node
1557
+ * records; classification maps path conventions to a node kind.
1558
+ *
1559
+ * Distinct from the **hexagonal-architecture** 'adapter' (`RunnerPort.adapter`,
1560
+ * `StoragePort.adapter`, etc.). A `Provider` is an extension kind authored
1561
+ * by plugins to declare a platform's universe (the catalog of kinds it
1562
+ * emits, the per-kind frontmatter schema, the filesystem directory it
1563
+ * owns); a hexagonal adapter is an internal implementation of a port.
1564
+ * Both can coexist without confusion because they live in different
1565
+ * namespaces.
1566
+ *
1567
+ * `walk()` is an async iterator so large scopes don't buffer in memory.
1568
+ * Each yielded `IRawNode` carries the full parsed frontmatter + body plus
1569
+ * the path relative to the scan root; the kernel computes hashes, bytes,
1570
+ * and tokens on top.
1571
+ *
1572
+ * **Spec 0.8.0**. Per-kind frontmatter schemas relocated from the spec
1573
+ * to the Provider that owns them. The flat
1574
+ * `defaultRefreshAction` map collapsed into the new `kinds` map: every
1575
+ * kind the Provider emits gets one entry that declares both its schema
1576
+ * and its refresh action. Spec keeps only `frontmatter/base.schema.json`
1577
+ * (universal); per-kind schemas live with the Provider.
1578
+ */
1579
+
1580
+ interface IRawNode {
1581
+ /** Path relative to the scan root that produced this node. */
1582
+ path: string;
1583
+ /** Raw markdown body (everything after the frontmatter fence). */
1584
+ body: string;
1585
+ /** Raw frontmatter text (between `---` fences). Empty string when absent. */
1586
+ frontmatterRaw: string;
1587
+ /** Parsed frontmatter, or `{}` when absent / unparseable. */
1588
+ frontmatter: Record<string, unknown>;
1702
1589
  /**
1703
- * Step 11.x, runtime catalog of plugin-contributed view contributions,
1704
- * as exposed by `kernel.getRegisteredViewContributions()`. Threaded
1705
- * through so analyzers can reason about emissions without reaching
1706
- * back into the kernel (built-in `core/contribution-orphan` joins it
1707
- * with the live node set to flag dangling emissions). Slot catalog
1708
- * drift detection is NOT a scan concern, it lives at load time and
1709
- * surfaces via `sm plugins doctor`. Empty array when no extension
1710
- * declares view contributions; absent for legacy callers (older
1711
- * runScan sites that never wired the catalog through).
1590
+ * Parser diagnostics (audit L1). Populated by the walker when the
1591
+ * parser surfaced `IParseIssue` entries (e.g. malformed YAML).
1592
+ * Carried through `processRawNode` and converted into warn-level
1593
+ * kernel `Issue` rows inside `buildFreshNodeAndValidateFrontmatter`.
1594
+ * Empty / undefined on the happy path.
1712
1595
  */
1713
- viewContributions?: readonly IRegisteredViewContribution[];
1596
+ parseIssues?: readonly IParseIssue[];
1597
+ }
1598
+ /**
1599
+ * One entry in a Provider's `kinds` map. Declares both the per-kind
1600
+ * frontmatter schema (path relative to the Provider's package dir, plus
1601
+ * the loaded JSON object the kernel passes to AJV) and the qualified
1602
+ * default refresh action id the UI dispatches for nodes of this kind.
1603
+ *
1604
+ * The split between `schema` (manifest-level path) and `schemaJson`
1605
+ * (runtime-loaded JSON) keeps the manifest shape spec-conformant while
1606
+ * letting the runtime instance carry the parsed schema without a second
1607
+ * filesystem read at scan time. Built-in Providers populate `schemaJson`
1608
+ * via `import schema from './schemas/skill.schema.json' with { type: 'json' }`;
1609
+ * user-plugin Providers loaded by `PluginLoader` will have it filled in
1610
+ * by the loader after manifest validation.
1611
+ */
1612
+ interface IProviderKind {
1714
1613
  /**
1715
- * Absolute paths of `*.md` files under the project's
1716
- * `.skill-map/jobs/` that no `state_jobs.filePath` references, the
1717
- * built-in `core/job-orphan-file` analyzer projects each as a `warn`
1718
- * issue. Pre-computed by the driving adapter (CLI / BFF) inside its
1719
- * already-open storage transaction (mirrors the `orphanSidecars`
1720
- * pattern: detection lives outside the analyzer, the analyzer only
1721
- * projects). Absent (or empty) when the caller does not maintain a
1722
- * jobs directory, when the storage path is unavailable, or when no
1723
- * orphan files exist. Treat as read-only.
1614
+ * Path to the kind's frontmatter JSON Schema, relative to the
1615
+ * Provider's package directory. Mirrors the spec field of the same
1616
+ * name in `extensions/provider.schema.json#/properties/kinds/.../schema`.
1724
1617
  */
1725
- orphanJobFiles?: readonly string[];
1618
+ schema: string;
1726
1619
  /**
1727
- * Set of absolute file paths the operator has opted into for
1728
- * link-validation purposes via `scan.referencePaths`. The driving
1729
- * adapter walks each configured path before the scan and collects
1730
- * every existing file's absolute path here. Files in this set are
1731
- * NOT indexed as graph nodes, the only consumer is
1732
- * `core/broken-ref`, which suppresses its `warn` issue when a
1733
- * path-style link target falls into the set. Absent / empty when
1734
- * the operator left `scan.referencePaths` empty or when the
1735
- * adapter does not maintain the side index. Treat as read-only.
1736
- */
1737
- referenceablePaths?: ReadonlySet<string>;
1738
- /**
1739
- * Absolute path of the scan's project root (cwd of the invocation).
1740
- * Threaded into the analyzer pass so an analyzer that needs to
1741
- * resolve a relative `link.target` to an absolute filesystem path
1742
- * (today only `core/broken-ref`, when consulting
1743
- * `referenceablePaths`) does not have to derive it from
1744
- * `nodes[0].path` heuristics. Absent for legacy callers (older
1745
- * `runScan` sites that never wired the field through). Always an
1746
- * absolute path when present.
1747
- */
1748
- cwd?: string;
1749
- /**
1750
- * Emit a per-node view contribution declared in this analyzer's
1751
- * manifest `viewContributions` map. Sync, void return; the
1752
- * orchestrator validates the payload against the slot's schema at
1753
- * call time and silently drops invalid emissions with a logged
1754
- * `extension.error` event (parallel to
1755
- * `IExtractorCallbacks.emitContribution`).
1756
- *
1757
- * Unlike Extractor's emit (which binds `nodePath` from `ctx.node.path`
1758
- * implicitly because Extractors run per-node), Analyzer's `evaluate()`
1759
- * sees the full graph at once. The analyzer walks `ctx.nodes` itself
1760
- * and MUST supply the target node path explicitly per emission.
1620
+ * Loaded JSON Schema document for the kind. The kernel registers this
1621
+ * with AJV at scan boot and validates each node's frontmatter against
1622
+ * it. The schema MUST extend the spec's
1623
+ * `frontmatter/base.schema.json` via `allOf` + `$ref` to base's
1624
+ * `$id`; the loader registers base into the same AJV instance so
1625
+ * cross-package `$ref`-by-`$id` resolves transparently.
1761
1626
  *
1762
- * Calling `emitContribution` with a `contributionId` that is not
1763
- * declared in the manifest is dropped with an `extension.error`. The
1764
- * kernel routes emitted contributions to the same persistence
1765
- * pipeline as Extractor emissions (`scan_contributions`).
1627
+ * `unknown` rather than a stronger type because AJV consumes any JSON
1628
+ * Schema object; tightening to a concrete shape would require mirroring
1629
+ * the JSON Schema vocabulary in TypeScript.
1766
1630
  */
1767
- emitContribution(nodePath: string, contributionId: string, payload: unknown): void;
1768
- }
1769
- interface IAnalyzer extends IExtensionBase {
1770
- kind: 'analyzer';
1631
+ schemaJson: unknown;
1771
1632
  /**
1772
- * Execution mode. Optional in the manifest with a default of
1773
- * `deterministic` per `spec/schemas/extensions/analyzer.schema.json`.
1633
+ * Qualified action id (`<plugin-id>/<action-id>`) the probabilistic-
1634
+ * refresh UI dispatches for nodes of this kind. The kernel resolves
1635
+ * the id against its qualified action registry; a dangling reference
1636
+ * disables the Provider with status `invalid-manifest`.
1774
1637
  */
1775
- mode?: TExecutionMode;
1638
+ defaultRefreshAction: string;
1776
1639
  /**
1777
- * Qualified `<pluginId>/<id>` Action ids the analyzer recommends to
1778
- * resolve its findings. Distinct from `Action.precondition` (which
1779
- * declares which nodes an Action applies to from the Action side);
1780
- * this field declares which Actions are relevant when this
1781
- * Analyzer fires from the Analyzer side. Actions are per-node by
1782
- * design (project-level cleanup verbs like orphan file prune or
1783
- * contribution relink are CLI verbs, not Actions) and are NOT
1784
- * surfaced through this field. The UI consumes it in the node
1785
- * inspector under "Recommended for issues". Optional; omit when no
1786
- * Action resolves the finding (e.g. `core/superseded` surfaces
1787
- * deliberate user declarations, not problems).
1640
+ * Presentation metadata the UI consumes to render nodes of this kind
1641
+ * (palette swatches, list tags, graph nodes, filter chips). Required
1642
+ * so the UI never has to invent visuals for a Provider-declared kind.
1643
+ * Mirrors `extensions/provider.schema.json#/properties/kinds/.../ui`.
1788
1644
  */
1789
- recommendedActions?: readonly string[];
1790
- evaluate(ctx: IAnalyzerContext): Issue[] | Promise<Issue[]>;
1791
- }
1792
-
1793
- /**
1794
- * Action runtime contract. The fourth plugin kind (spec § A.4 +
1795
- * `spec/schemas/extensions/action.schema.json`).
1796
- *
1797
- * Actions operate on one or more nodes in one of two execution modes:
1798
- *
1799
- * - `deterministic`, code runs in-process; the action computes the
1800
- * report synchronously and returns it. No job file, no runner.
1801
- * - `probabilistic`, the kernel renders a prompt + preamble into a
1802
- * job file; a runner executes it via `RunnerPort` against an LLM;
1803
- * `sm record` closes the job and validates the report against
1804
- * `reportSchemaRef`.
1805
- *
1806
- * **Deferred runtime invocation.** The dispatcher (`Action.run(ctx)` for
1807
- * deterministic; the `RunnerPort` + `sm record` round-trip for
1808
- * probabilistic) lands with the job subsystem (Decision #114 in
1809
- * `ROADMAP.md`). Today the loader still validates `kind: 'action'`
1810
- * manifests against `extension-action.schema.json` and the registry
1811
- * holds them, `sm actions show` and the precondition gating UI consume
1812
- * the manifest data. The runtime entry point is intentionally absent
1813
- * from `IAction` so plugin authors don't ship a method the kernel will
1814
- * not call until the job subsystem is in place; when it ships, the
1815
- * method shape will land here without breaking the manifest contract.
1816
- *
1817
- * Mirrors `extensions/action.schema.json`:
1818
- *
1819
- * - `mode` (required), discriminator between the two modes.
1820
- * - `reportSchemaRef` (required), JSON Schema reference the report
1821
- * MUST validate against. MUST extend `report-base.schema.json`.
1822
- * - `promptTemplateRef`, REQUIRED when `mode: 'probabilistic'`,
1823
- * FORBIDDEN when `mode: 'deterministic'`. The schema's conditional
1824
- * `allOf` enforces both directions; the runtime contract simply
1825
- * surfaces the field as optional and lets the loader catch shape
1826
- * violations at AJV time.
1827
- * - `expectedDurationSeconds`, REQUIRED for probabilistic (drives
1828
- * TTL); advisory for deterministic.
1829
- * - `precondition`, declarative filter consumed by `--all` fan-out,
1830
- * UI button gating, `sm actions show`.
1831
- * - `expectedTools`, hint to Skill / CLI runners about expected
1832
- * tools (no normative enforcement in v0).
1833
- * - `fanOutPolicy`, `'per-node'` (default) vs `'batch'`.
1834
- */
1835
-
1836
- /**
1837
- * Single sidecar write payload an Action can return. Discriminated union so
1838
- * future write kinds (storage rows, plugin KV, etc.) can land additively
1839
- * without breaking consumers that only handle `kind: 'sidecar'`.
1840
- *
1841
- * - `path`, absolute path to the `.sm` file the kernel must materialise
1842
- * the change into. Resolved by the Action from the node's absolute
1843
- * path via `sidecarPathFor()`.
1844
- * - `changes`, partial sidecar root used as a deep-merge patch (NOT a
1845
- * full replacement). Arrays REPLACE; objects RECURSE. Reason:
1846
- * sidecars are shared-write between skill-map core and plugins;
1847
- * a full replace would clobber `<plugin-id>:` namespaced blocks.
1848
- */
1849
- type TActionWrite = {
1850
- kind: 'sidecar';
1851
- path: string;
1852
- changes: Record<string, unknown>;
1853
- };
1854
- /**
1855
- * Result envelope returned by deterministic Actions. The `report` field
1856
- * carries the typed report payload (each Action declares its shape via
1857
- * `reportSchemaRef`); `writes` is opt-in, Actions that do not mutate
1858
- * persistent state simply omit it.
1859
- */
1860
- interface IActionResult<TReport = unknown> {
1861
- report: TReport;
1862
- writes?: TActionWrite[];
1863
- }
1864
- /**
1865
- * Runtime context passed to a deterministic Action's `invoke()` method.
1866
- * Minimal surface, Actions stay pure (no IO inside `invoke`); the kernel
1867
- * materialises any returned `writes` after the call.
1868
- *
1869
- * - `node`, the target `Node` the Action operates on. Open-by-design;
1870
- * batch / fan-out flows pick the matching nodes upstream.
1871
- * - `nodeAbsolutePath`, absolute path to the node's `.md` file on
1872
- * disk. The Action uses this to compute the sidecar path it returns
1873
- * in a `TActionWrite`. Surfaced separately from `node.path` (which is
1874
- * the relative scope-root form) so Actions never compose absolute
1875
- * paths from `node.path` themselves.
1876
- * - `invoker`, identity of the caller; written into the sidecar's
1877
- * `audit.lastBumpedBy` when the Action chooses to. CLI invocations
1878
- * pass `'cli'`; plugin-driven invocations pass `'plugin:<plugin-id>'`.
1879
- * - `now`, clock function; tests inject a deterministic source.
1880
- * Defaults to `() => new Date()` at the composition root.
1881
- */
1882
- interface IActionContext {
1883
- node: Node;
1884
- nodeAbsolutePath: string;
1885
- invoker: string;
1886
- now: () => Date;
1645
+ ui: IProviderKindUi;
1887
1646
  }
1888
1647
  /**
1889
- * Declarative filter applied by `--all` fan-out, UI button gating, and
1890
- * `sm actions show`. All fields optional, an empty precondition matches
1891
- * every node.
1648
+ * Presentation contract for one Provider kind. The Provider declares
1649
+ * intent (label + base color, optional dark variant + emoji + icon);
1650
+ * the UI derives `bg`/`fg` tints per theme via a deterministic helper
1651
+ * and reads the registry from the `kindRegistry` field embedded in REST
1652
+ * envelopes. Single source of truth for what a kind looks like, the
1653
+ * UI never hardcodes presentation for a built-in kind.
1892
1654
  */
1893
- interface IActionPrecondition {
1655
+ interface IProviderKindUi {
1894
1656
  /**
1895
- * Node kinds this action accepts. Open-by-design (matches
1896
- * `node.schema.json#/properties/kind`): an action declared with
1897
- * `kind: ['cursorRule']` is valid as long as some Provider classifies
1898
- * into `cursorRule`. Omitted → any kind.
1657
+ * Plural human-readable label for groups of this kind (e.g. `'Skills'`,
1658
+ * `'Agents'`, `'Cursor Rules'`). Used in filter dropdowns, palette
1659
+ * tooltips, and any list grouping.
1899
1660
  */
1900
- kind?: string[];
1901
- /** Provider ids whose nodes this action accepts. Omitted → any Provider. */
1902
- provider?: string[];
1903
- /** Node stability filter. */
1904
- stability?: Array<'experimental' | 'stable' | 'deprecated'>;
1661
+ label: string;
1905
1662
  /**
1906
- * Free-form precondition strings the kernel forwards to the action for
1907
- * runtime evaluation (example: `frontmatter.metadata.source != null`).
1663
+ * Base hex color (`#RRGGBB`) for the light theme. The UI derives `bg`
1664
+ * and `fg` tints from this value at runtime via a deterministic
1665
+ * helper. Declaring one base value (instead of three) keeps the
1666
+ * manifest small and centralises accessibility-driven contrast in the
1667
+ * UI.
1908
1668
  */
1909
- custom?: string[];
1910
- }
1911
- interface IAction extends IExtensionBase {
1912
- kind: 'action';
1669
+ color: string;
1913
1670
  /**
1914
- * Execution mode discriminator. Required per
1915
- * `extensions/action.schema.json`.
1671
+ * Optional dark-theme variant of `color`. When absent, the UI falls
1672
+ * back to `color`. Declared explicitly because a luminosity flip
1673
+ * rarely matches the brand intent for kinds that should stand out in
1674
+ * dark mode.
1916
1675
  */
1917
- mode: TExecutionMode;
1676
+ colorDark?: string;
1918
1677
  /**
1919
- * Reference to the JSON Schema the report MUST validate against. MUST
1920
- * extend `report-base.schema.json` (directly or transitively).
1921
- * Validation failure → job transitions to `failed` with reason
1922
- * `report-invalid`.
1678
+ * Optional decorative emoji used as a fallback when `icon` is absent
1679
+ * or fails to render. Length-bound so the UI can lay it out
1680
+ * predictably alongside text.
1923
1681
  */
1924
- reportSchemaRef: string;
1682
+ emoji?: string;
1925
1683
  /**
1926
- * Best-effort estimate of wall-clock duration in seconds. Drives TTL
1927
- * (`ttl = max(expectedDurationSeconds × graceMultiplier,
1928
- * minimumTtlSeconds)`). Required for `probabilistic`; advisory for
1929
- * `deterministic`.
1684
+ * Optional discriminated icon descriptor. The UI prefers `icon` over
1685
+ * `emoji`; when both are absent, the UI falls back to the first
1686
+ * letter of `label` colored with `color`.
1930
1687
  */
1931
- expectedDurationSeconds?: number;
1688
+ icon?: TProviderKindIcon;
1689
+ }
1690
+ /**
1691
+ * Discriminated icon contract. `pi` references a PrimeIcons identifier
1692
+ * (e.g. `'pi-cog'`); `svg` carries raw SVG path data the UI wraps in a
1693
+ * `<svg viewBox="0 0 24 24"><path d="…"/></svg>` element tinted with
1694
+ * `currentColor`. The discriminator (`kind`) keeps the UI dispatch
1695
+ * exhaustive without string-sniffing the payload.
1696
+ */
1697
+ type TProviderKindIcon = {
1698
+ kind: 'pi';
1699
+ id: string;
1700
+ } | {
1701
+ kind: 'svg';
1702
+ path: string;
1703
+ };
1704
+ interface IProvider extends IExtensionBase {
1705
+ kind: 'provider';
1932
1706
  /**
1933
- * Path (relative to the extension file) to the prompt template the
1934
- * kernel renders at `sm job submit`. REQUIRED when `mode:
1935
- * 'probabilistic'`; FORBIDDEN when `mode: 'deterministic'`. The
1936
- * conditional shape is enforced by AJV at load time; the runtime
1937
- * contract carries the field as optional so both modes share one
1938
- * interface.
1939
- */
1940
- promptTemplateRef?: string;
1941
- /**
1942
- * Optional declarative filter; absent applies to every node.
1707
+ * Catalog of node kinds this Provider emits. Keyed by kind name. Every
1708
+ * kind the Provider can `classify()` MUST have an entry; an entry is
1709
+ * the union of the kind's frontmatter schema and its default refresh
1710
+ * action.
1711
+ *
1712
+ * The string keys are typed loosely (`string`) rather than `NodeKind`
1713
+ * because the value space is open by design: a future Cursor Provider
1714
+ * could declare `rule`, an Obsidian Provider could declare `daily`.
1715
+ * The kernel's hard-coded `NodeKind` union represents the kinds the
1716
+ * built-in Claude Provider emits; it is NOT the kernel-wide kind type
1717
+ * (see `kernel/types.ts:NodeKind` docstring). `Node.kind`, the AJV
1718
+ * `node.schema.json` validator, and the SQLite `scan_nodes.kind`
1719
+ * column all accept any non-empty string an enabled Provider returns.
1943
1720
  */
1944
- precondition?: IActionPrecondition;
1721
+ kinds: Record<string, IProviderKind>;
1945
1722
  /**
1946
- * Hint to Skill / CLI runners about what tools the rendered prompt
1947
- * expects access to (`Bash`, `Read`, `WebSearch`, …). No normative
1948
- * enforcement in v0.
1723
+ * Optional auxiliary JSON Schemas this Provider's per-kind schemas
1724
+ * `$ref` by `$id`. Registered with AJV via `addSchema` BEFORE the
1725
+ * per-kind schemas compile, so cross-file `$ref` resolution succeeds.
1726
+ *
1727
+ * Use case: when several kinds share a common base (e.g. Anthropic's
1728
+ * merged skill / command frontmatter, both extend a shared
1729
+ * `skill-base.schema.json`), the Provider declares the base here so
1730
+ * `skill.schema.json` and `command.schema.json` can `$ref` it without
1731
+ * duplicating fields.
1732
+ *
1733
+ * Runtime-only, does NOT appear in the spec's `provider.schema.json`
1734
+ * manifest. Manifest-validated schemas remain the per-kind ones in
1735
+ * `kinds[<kind>].schema`; auxiliary schemas are an implementation
1736
+ * concern of how the runtime composes those.
1949
1737
  */
1950
- expectedTools?: string[];
1738
+ schemas?: unknown[];
1951
1739
  /**
1952
- * `'per-node'` (default): `sm job submit --all` produces one job per
1953
- * matching node. `'batch'`: one job whose prompt template receives the
1954
- * full list. Batch actions tend to hit context limits; use sparingly.
1740
+ * Declarative file-discovery config consumed by the kernel walker.
1741
+ * When present, the kernel walks every root, includes files whose
1742
+ * extension matches `extensions`, parses each with the parser id
1743
+ * registered in the kernel-internal registry, and yields `IRawNode`
1744
+ * records the same shape `walk()` would.
1745
+ *
1746
+ * When neither `read` nor `walk` is declared, `resolveProviderWalk`
1747
+ * applies the default `{ extensions: ['.md'], parser: 'frontmatter-yaml' }`
1748
+ * so the most common Provider shape needs zero configuration.
1749
+ *
1750
+ * Precedence: when both `walk()` (runtime field) and `read` are
1751
+ * declared, `walk()` wins, `read` is ignored. The escape-hatch
1752
+ * relationship is intentional: most Providers should use `read`;
1753
+ * Providers with non-standard discovery requirements (custom file
1754
+ * naming, multi-pass walks, dynamic ignore logic) implement `walk()`
1755
+ * directly and accept the duplication of audit-cleared defences.
1756
+ *
1757
+ * Built-in parsers: `'frontmatter-yaml'` (markdown with `--- … ---`
1758
+ * YAML frontmatter; pollution-strip + JSON_SCHEMA-pinned), `'plain'`
1759
+ * (entire body, empty frontmatter). The set is closed; user plugins
1760
+ * cannot register their own.
1955
1761
  */
1956
- fanOutPolicy?: 'per-node' | 'batch';
1762
+ read?: IProviderReadConfig;
1957
1763
  /**
1958
- * Deterministic invocation entry point. OPTIONAL on the runtime
1959
- * contract for backward compatibility with the manifest-only era
1960
- * (Decision #114), actions that ship for the future probabilistic
1961
- * runner / record path leave it absent and the kernel never calls it.
1962
- * Step 9.6.3 (Decision #125) introduces the first concrete consumer:
1963
- * the built-in `bump` Action implements `invoke()` and returns a
1964
- * `writes: [{ kind: 'sidecar', ... }]` payload that the kernel
1965
- * materialises through `ISidecarStore`.
1764
+ * Walk the given roots and yield every node the Provider recognises.
1765
+ * Non-matching files are silently skipped. Unreadable files produce
1766
+ * a diagnostic via the emitter but do not abort the walk.
1966
1767
  *
1967
- * Implementations MUST stay pure, no IO inside `invoke()`. The Action
1968
- * computes the patch and returns it; the kernel reads the on-disk
1969
- * sidecar, deep-merges, validates, and writes back inside its critical
1970
- * section.
1768
+ * `options.ignoreFilter`, when supplied, the Provider MUST
1769
+ * skip every directory and file whose path-relative-to-root the
1770
+ * filter reports as ignored. Providers MAY also keep their own
1771
+ * hard-coded skip list (e.g. `.git`) as a defensive measure, but the
1772
+ * filter is the canonical source of user intent.
1971
1773
  *
1972
- * `TInput` is action-specific; the built-in `bump` Action declares
1973
- * `{ force?: boolean; reason?: string }`. The signature stays generic
1974
- * so each Action narrows it locally without forcing a common base.
1774
+ * Optional. When omitted, the Provider MUST declare `read` (or rely
1775
+ * on the default config). The orchestrator never calls `walk()`
1776
+ * directly, it goes through `resolveProviderWalk(provider)` which
1777
+ * picks `walk` over `read`.
1975
1778
  */
1976
- invoke?: <TInput, TReport>(input: TInput, ctx: IActionContext) => IActionResult<TReport>;
1779
+ walk?(roots: string[], options?: {
1780
+ ignoreFilter?: IIgnoreFilter;
1781
+ }): AsyncIterable<IRawNode>;
1782
+ /**
1783
+ * Given a path and its parsed frontmatter, decide the node kind, or
1784
+ * `null` to disclaim the file. The classifier is called after walk()
1785
+ * yields; with multiple Providers active, every Provider walks every
1786
+ * file matching its `read.extensions`, so each Provider MUST disclaim
1787
+ * paths it does not recognise. Returning the same path's kind from
1788
+ * two Providers fires the spec's `provider-ambiguous` issue and the
1789
+ * orchestrator drops the duplicate.
1790
+ *
1791
+ * Convention: a Provider's classify returns one of its own `kinds`
1792
+ * map keys for paths in its territory (`.claude/`, `.gemini/`,
1793
+ * `.agents/skills/`, etc.) and `null` elsewhere. External Providers
1794
+ * (Cursor, Obsidian, …) follow the same rule: claim what's yours,
1795
+ * disclaim everything else. The orchestrator does not validate the
1796
+ * kind against `NodeKind`.
1797
+ */
1798
+ classify(path: string, frontmatter: Record<string, unknown>): string | null;
1977
1799
  }
1978
-
1979
1800
  /**
1980
- * Formatter runtime contract. Turns the (nodes, links, issues) graph into
1981
- * a textual representation for `sm graph --format <name>`.
1982
- *
1983
- * Two adjacent names live on the same instance:
1984
- *
1985
- * - `formatId: string`, the manifest field consumed by the
1986
- * `--format <name>` CLI flag. The kernel's lookup is
1987
- * `formatters.find((f) => f.formatId === flag)`.
1988
- * - `format(ctx) → string`, the runtime method. Receives the full
1989
- * graph and returns the serialized output. Output MUST be
1990
- * byte-deterministic for the same input (the snapshot-test suite
1991
- * relies on this).
1992
- *
1993
- * The split (`formatId` vs `format`) is deliberate: it keeps the method
1994
- * named after the kind (`Formatter.format()` reads naturally) while the
1995
- * field carries the identifier the user types on the command line.
1801
+ * Declarative read config a Provider declares via `IProvider.read`.
1802
+ * Mirrors `extensions/provider.schema.json#/properties/read` at the
1803
+ * TypeScript level. Built-in parser ids: `'frontmatter-yaml'`, `'plain'`.
1996
1804
  */
1997
-
1998
- interface IFormatterContext {
1999
- nodes: Node[];
2000
- links: Link[];
2001
- issues: Issue[];
1805
+ interface IProviderReadConfig {
2002
1806
  /**
2003
- * Full persisted scan, when the caller has it on hand. Optional so
2004
- * existing formatters that only consume (nodes, links, issues) keep
2005
- * working unchanged; formatters whose output mirrors a `ScanResult`
2006
- * envelope (today: the built-in `json` formatter under
2007
- * `built-in-plugins/formatters/json/`) read this to project the
2008
- * canonical document verbatim. `undefined` when the caller has only
2009
- * the three primary arrays (back-compat with older drivers).
1807
+ * File extensions the walker yields. Strings include the leading dot
1808
+ * (e.g. `'.md'`, `'.mdc'`, `'.toml'`). Match is suffix-based; the
1809
+ * comparison is case-sensitive.
2010
1810
  */
2011
- scanResult?: ScanResult;
2012
- }
2013
- interface IFormatter extends IExtensionBase {
2014
- kind: 'formatter';
2015
- /** Format identifier consumed by `sm graph --format <name>`. */
2016
- formatId: string;
2017
- /** Serialize the graph into a string. Deterministic-only. */
2018
- format(ctx: IFormatterContext): string;
1811
+ extensions: string[];
1812
+ /**
1813
+ * Parser id from the kernel-internal registry. Built-ins:
1814
+ * `'frontmatter-yaml'`, `'plain'`. Unknown ids surface as
1815
+ * `UnknownParserError` from the walker; the orchestrator translates
1816
+ * the error into a Provider issue with status `invalid-manifest`.
1817
+ */
1818
+ parser: string;
2019
1819
  }
2020
1820
 
2021
1821
  /**
2022
- * Hook runtime contract. The sixth plugin kind (spec § A.11).
2023
- *
2024
- * Hooks subscribe declaratively to a curated set of kernel lifecycle
2025
- * events and react to them. Reaction-only by design: a hook cannot
2026
- * mutate the pipeline, block emission, or alter outputs. Use cases
2027
- * are notification (Slack on `job.completed`), integration glue (CI
2028
- * webhook on `job.failed`), and bookkeeping (per-extractor metrics).
1822
+ * Extractor runtime contract. Consumes a single node (frontmatter + body)
1823
+ * and emits its output through three context-supplied callbacks rather than
1824
+ * a return value. Extractors run in isolation: they MUST NOT read other
1825
+ * nodes, the graph, or the DB. Cross-node reasoning lives in rules.
2029
1826
  *
2030
- * The hookable trigger set is INTENTIONALLY SMALL, ten events. Eight
2031
- * are pipeline-driven (emitted from inside `runScan`); two
2032
- * (`boot`, `shutdown`) are CLI-process-driven (emitted by the driving
2033
- * binary before / after the verb runs, fire-and-forget so
2034
- * `process.exit` is never blocked). The full `ProgressEmitterPort`
2035
- * catalog (per-node `scan.progress`, `model.delta`, `run.*`, internal
2036
- * job lifecycle) is deliberately not hookable: too verbose for a
2037
- * reactive surface, internal to the runner, or covered elsewhere.
2038
- * Declaring a trigger outside the curated set yields
2039
- * `invalid-manifest` at load time.
1827
+ * Extractors are deterministic-only. They run synchronously inside the
1828
+ * scan loop; LLM-driven enrichment of a node is an Action concern, not
1829
+ * an Extractor concern. The Extractor context therefore exposes no
1830
+ * `RunnerPort`, see spec `architecture.md` §Execution modes.
2040
1831
  *
2041
- * Dual-mode (declared in manifest):
1832
+ * Output channels (all on the context):
2042
1833
  *
2043
- * - `deterministic` (default): `on(ctx)` runs in-process during the
2044
- * dispatch of the matching event, synchronously between the
2045
- * event's emission and the next pipeline step. Errors are caught
2046
- * by the dispatcher, logged via `extension.error`, and never
2047
- * block the main flow.
2048
- * - `probabilistic`: the hook is enqueued as a job. Until the job
2049
- * subsystem ships, probabilistic hooks load but skip dispatch
2050
- * with a stderr advisory (Decision #114 in `ROADMAP.md`).
1834
+ * - `ctx.emitLink(link)`, persist a link in the kernel's `links` table.
1835
+ * Validated against `emitsLinkKinds` before insertion; an off-contract
1836
+ * kind drops the link and surfaces an `extension.error` event.
1837
+ * - `ctx.enrichNode(partial)`, merge canonical, kernel-curated properties
1838
+ * onto the node. Strictly separate from the author-supplied frontmatter
1839
+ * (the latter remains immutable and survives verbatim). Persistence
1840
+ * is spec'd in § A.8.
1841
+ * - `ctx.store`, plugin-scoped persistence. Present only when the
1842
+ * plugin declares `storage.mode` in `plugin.json`; shape depends on the
1843
+ * mode (`KvStore` for mode A, scoped `Database` for mode B). See
1844
+ * `plugin-kv-api.md` for the contract.
2051
1845
  *
2052
- * Curated trigger set (per spec § A.11):
1846
+ * The manifest's `scope` field tells the orchestrator which parts to feed:
1847
+ * `frontmatter` extractors receive an empty string for body and vice versa.
2053
1848
  *
2054
- * 0. `boot` , once per CLI process, before verb routing.
2055
- * 1. `scan.started` , pre-scan setup (one per scan).
2056
- * 2. `scan.completed` , post-scan reaction (one per scan).
2057
- * 3. `extractor.completed` , aggregated per-Extractor outputs.
2058
- * 4. `analyzer.completed` , aggregated per-Rule outputs.
2059
- * 5. `action.completed` , Action executed on a node.
2060
- * 6. `job.spawning` , pre-spawn of runner subprocess.
2061
- * 7. `job.completed` , most common trigger.
2062
- * 8. `job.failed` , alerts, retry triggers.
2063
- * 9. `shutdown` , once per CLI process, after the verb's
2064
- * exit code resolves and before
2065
- * `process.exit`.
1849
+ * Renamed from `Detector` in spec 0.8.x. The previous `detect(ctx) → Link[]`
1850
+ * signature is gone; everything now flows through `extract(ctx) void`
1851
+ * and the callbacks above.
2066
1852
  */
2067
1853
 
2068
1854
  /**
2069
- * The ten hookable lifecycle events. Mirrors the `triggers[]` enum in
2070
- * `spec/schemas/extensions/hook.schema.json`. Eight are pipeline-driven
2071
- * (emitted from inside `runScan`); two (`boot`, `shutdown`) are
2072
- * CLI-process-driven (emitted by the driving binary before / after the
2073
- * verb runs). Anything outside this set is rejected at load time as
2074
- * `invalid-manifest`.
2075
- */
2076
- type THookTrigger = 'boot' | 'scan.started' | 'scan.completed' | 'extractor.completed' | 'analyzer.completed' | 'action.completed' | 'job.spawning' | 'job.completed' | 'job.failed' | 'shutdown';
2077
- /**
2078
- * Frozen list mirror of `THookTrigger` for runtime introspection. The
2079
- * loader validates `manifest.triggers[]` against this set; the
2080
- * dispatcher iterates it in order when fanning an event out to
2081
- * subscribed hooks. `boot` first / `shutdown` last so a debug log of
2082
- * the array reads in lifecycle order.
1855
+ * Output callbacks supplied by the kernel on the extractor context.
1856
+ * Split out so plugin authors can name the callback shape if they
1857
+ * want to mock it in unit tests without depending on the wider
1858
+ * `IExtractorContext`.
2083
1859
  */
2084
- declare const HOOK_TRIGGERS: readonly THookTrigger[];
2085
- /**
2086
- * Context the dispatcher hands to `Hook.on()`. The shape is intentionally
2087
- * narrow: a hook reacts to an event, it does not steer the pipeline.
2088
- *
2089
- * The `event` carries the raw `ProgressEvent` envelope (type, timestamp,
2090
- * runId/jobId when applicable, data). Optional `node` / `extractorId`
2091
- * / `analyzerId` / `actionId` are extracted from the event payload by the
2092
- * dispatcher when present so authors don't have to walk `event.data`.
2093
- *
2094
- * Probabilistic hooks additionally receive `runner` for LLM dispatch.
2095
- * Deterministic hooks SHOULD ignore the field.
2096
- */
2097
- interface IHookContext {
2098
- /** The raw event the dispatcher matched. */
2099
- event: {
2100
- type: THookTrigger;
2101
- timestamp: string;
2102
- runId?: string;
2103
- jobId?: string;
2104
- data?: unknown;
2105
- };
1860
+ interface IExtractorCallbacks {
2106
1861
  /**
2107
- * Convenience extraction of the node payload when the event is
2108
- * node-scoped (`action.completed`). Undefined for run-scoped or
2109
- * scan-scoped events.
1862
+ * Emit a single Link. The orchestrator validates the link against the
1863
+ * extractor's declared `emitsLinkKinds` before inserting it; off-contract
1864
+ * links are silently dropped with an `extension.error` event.
2110
1865
  */
2111
- node?: Node;
1866
+ emitLink(link: Link): void;
2112
1867
  /**
2113
- * Set on `extractor.completed` events. Qualified extension id of the
2114
- * Extractor whose work the event aggregates.
1868
+ * Merge canonical, kernel-curated properties onto the current node's
1869
+ * enrichment layer. The author-supplied frontmatter stays untouched
1870
+ * (Decision #109 in `ROADMAP.md`). Persistence and stale-tracking
1871
+ * semantics live in spec § A.8; the orchestrator already buffers the
1872
+ * partials and `persistScanResult` upserts them.
2115
1873
  */
2116
- extractorId?: string;
1874
+ enrichNode(partial: Partial<Node>): void;
2117
1875
  /**
2118
- * Set on `analyzer.completed` events. Qualified extension id of the Rule.
1876
+ * Emit a per-node view contribution. The first argument is the
1877
+ * extension-local Record key declared under
1878
+ * `extension.viewContributions[<contributionId>]`; the second is a
1879
+ * payload that conforms to the slot's payload schema in
1880
+ * `spec/schemas/view-slots.schema.json#/$defs/payloads/<slot>`,
1881
+ * where `<slot>` is the slot the manifest declared for this
1882
+ * contribution. The orchestrator validates the payload against the
1883
+ * slot's schema before persisting to `scan_contributions`; off-shape
1884
+ * payloads are silently dropped with an `extension.error` event
1885
+ * (mirror of `emitLink` rejecting off-`emitsLinkKinds` links).
1886
+ * Calling `emitContribution` with a `contributionId` that is not
1887
+ * declared in the manifest is also dropped with an `extension.error`.
1888
+ * See `architecture.md` §View contribution system → Emit path.
2119
1889
  */
2120
- analyzerId?: string;
1890
+ emitContribution(contributionId: string, payload: unknown): void;
1891
+ }
1892
+ interface IExtractorContext extends IExtractorCallbacks {
1893
+ node: Node;
1894
+ body: string;
1895
+ frontmatter: Record<string, unknown>;
2121
1896
  /**
2122
- * Set on `action.completed` events. Qualified extension id of the
2123
- * Action that just ran.
1897
+ * Plugin-scoped persistence. Optional because not every plugin declares
1898
+ * a `storage.mode` in `plugin.json`. Shape: `KvStoreWrapper` for mode A
1899
+ * (`set(key, value)`), `DedicatedStoreWrapper` for mode B
1900
+ * (`write(table, row)`). See `spec/plugin-kv-api.md`.
1901
+ *
1902
+ * Typed as `unknown` so this contract module stays free of any
1903
+ * adapter-side imports, the concrete `IPluginStore` lives in
1904
+ * `kernel/adapters/plugin-store.js`. Plugin authors narrow at the
1905
+ * call site based on the storage mode declared in their manifest.
1906
+ * The orchestrator looks up the wrapper per-extractor in
1907
+ * `RunScanOptions.pluginStores` (keyed by `pluginId`) and attaches
1908
+ * it here.
2124
1909
  */
2125
- actionId?: string;
1910
+ store?: unknown;
1911
+ }
1912
+ interface IExtractor extends IExtensionBase {
1913
+ kind: 'extractor';
1914
+ emitsLinkKinds: LinkKind[];
1915
+ defaultConfidence: Confidence;
1916
+ scope: 'frontmatter' | 'body' | 'both';
2126
1917
  /**
2127
- * Set on `job.*` events once the job subsystem lands. Carries the
2128
- * report payload for `job.completed`, the failure record for
2129
- * `job.failed`, and the spawn metadata for `job.spawning`.
1918
+ * Optional opt-in filter on `node.kind`. When declared, the orchestrator
1919
+ * skips invocation of `extract()` for any node whose `kind` is NOT in
1920
+ * this list, fail-fast, before context construction, so the extractor
1921
+ * wastes zero CPU on inapplicable nodes.
1922
+ *
1923
+ * Absent (`undefined`) is the default: the extractor applies to every
1924
+ * kind. There are no wildcards, the absence of the field already
1925
+ * encodes "every kind". An empty array (`[]`) is rejected at load
1926
+ * time by AJV (`minItems: 1` in the schema).
1927
+ *
1928
+ * Unknown kinds (no installed Provider declares them) do NOT block
1929
+ * the load: the extractor keeps `loaded` status and `sm plugins doctor`
1930
+ * surfaces a warning. The Provider that declares the kind may arrive
1931
+ * later (e.g. a user installs the corresponding plugin).
1932
+ *
1933
+ * Spec: `spec/schemas/extensions/extractor.schema.json#/properties/applicableKinds`.
2130
1934
  */
2131
- jobResult?: unknown;
1935
+ applicableKinds?: string[];
2132
1936
  /**
2133
- * `RunnerPort` injection for `probabilistic` hooks. `undefined` for
2134
- * `deterministic` mode (the default). Probabilistic hooks land with
2135
- * the job subsystem; the field is reserved here so the runtime
2136
- * contract is forward-compatible without a major bump.
1937
+ * Extractor entry point. Returns nothing; output flows through
1938
+ * `ctx.emitLink`, `ctx.enrichNode`, and `ctx.store`.
2137
1939
  */
2138
- runner?: unknown;
1940
+ extract(ctx: IExtractorContext): void | Promise<void>;
2139
1941
  }
1942
+
2140
1943
  /**
2141
- * Optional declarative filter applied by the dispatcher BEFORE
2142
- * invoking `on(ctx)`. Keys are payload field paths (top-level only in
2143
- * v0.x); values are the literal expected match. The dispatcher walks
2144
- * `event.data` for the field and short-circuits the invocation if the
2145
- * value disagrees.
2146
- *
2147
- * Cross-field validation against declared `triggers` is best-effort
2148
- * at load time: when none of the declared triggers carries a given
2149
- * filter field, the loader surfaces `invalid-manifest`. The current
2150
- * impl performs the basic enum check but defers full payload-shape
2151
- * cross-validation to a follow-up, the dispatcher is permissive at
2152
- * runtime (an unknown field never matches → the hook simply never
2153
- * fires for that event, which is a correct interpretation of "filter
2154
- * by a field that doesn't exist").
1944
+ * Analyzer runtime contract. Runs against the whole graph after every
1945
+ * Provider and extractor has completed; emits issues and MAY project
1946
+ * findings into the UI via view contributions. Deterministic analyzers
1947
+ * are pure (same graph in same issues out) and run synchronously
1948
+ * inside `sm scan` / `sm check`. Probabilistic analyzers invoke an LLM
1949
+ * through the kernel's `RunnerPort` and dispatch only as queued jobs,
1950
+ * they never participate in scan-time pipelines. Mode is declared in
1951
+ * the manifest (default `deterministic`).
2155
1952
  */
2156
- type THookFilter = Record<string, string | number | boolean>;
2157
- interface IHook extends IExtensionBase {
2158
- kind: 'hook';
1953
+
1954
+ /**
1955
+ * Step 9.6.2, orphan sidecar entry surfaced to analyzers. A `.sm` file
1956
+ * whose sibling `.md` does not exist on disk; the `annotation-orphan`
1957
+ * built-in analyzer emits one warning per entry. Other analyzers that
1958
+ * care about orphan sidecars MAY consume the list too.
1959
+ */
1960
+ interface IAnalyzerOrphanSidecar {
1961
+ /** Relative path (POSIX-separated) of the orphan `.sm`. */
1962
+ relativePath: string;
1963
+ /** Absolute path of the missing `.md` the sidecar was anchored to. */
1964
+ expectedMdPath: string;
1965
+ }
1966
+ interface IAnalyzerContext {
1967
+ nodes: Node[];
1968
+ links: Link[];
2159
1969
  /**
2160
- * Execution mode. Optional in the manifest with a default of
2161
- * `deterministic` per `spec/schemas/extensions/hook.schema.json`.
2162
- * Probabilistic hooks load but skip dispatch with a stderr advisory
2163
- * until the job subsystem ships (Decision #114).
1970
+ * Step 9.6.2, orphaned sidecars discovered during the scan walk.
1971
+ * Empty when sidecar discovery did not run (legacy callers) or
1972
+ * when no orphans exist.
2164
1973
  */
2165
- mode?: TExecutionMode;
1974
+ orphanSidecars?: IAnalyzerOrphanSidecar[];
2166
1975
  /**
2167
- * Subset of the curated lifecycle trigger set this hook subscribes
2168
- * to. MUST be non-empty; every entry MUST be a member of
2169
- * `HOOK_TRIGGERS`. The loader validates both invariants and surfaces
2170
- * `invalid-manifest` on violation.
1976
+ * Step 9.6.6, raw parsed sidecar root keyed by `node.path`. Populated
1977
+ * by the orchestrator alongside the public `Node.sidecar` overlay so
1978
+ * analyzers that inspect plugin namespaces (e.g. the built-in
1979
+ * `core/unknown-field` Analyzer) can walk the full tree without
1980
+ * re-reading the file from disk. Absent (or `undefined` per node)
1981
+ * when no sidecar accompanies the node, or when the sidecar failed
1982
+ * to parse. Treat as read-only.
2171
1983
  */
2172
- triggers: THookTrigger[];
1984
+ sidecarRoots?: ReadonlyMap<string, Record<string, unknown>>;
2173
1985
  /**
2174
- * Optional declarative filter. Absent invoke on every dispatched
2175
- * event of every declared trigger.
1986
+ * Step 9.6.6, runtime catalog of plugin-contributed annotation keys,
1987
+ * as exposed by `kernel.getRegisteredAnnotationKeys()`. Threaded
1988
+ * through so analyzers can reason about the registered-vs-unknown
1989
+ * split without reaching back into the kernel. Empty array when no
1990
+ * plugin declares contributions; absent for legacy callers (older
1991
+ * runScan sites that never wired the catalog through).
2176
1992
  */
2177
- filter?: THookFilter;
1993
+ annotationContributions?: readonly IRegisteredAnnotationKey[];
2178
1994
  /**
2179
- * Hook entry point. Returns nothing; reactions are side effects.
2180
- * Errors are caught by the dispatcher (logged as `extension.error`,
2181
- * surfaced via `hook.failed` meta-event) and NEVER block the main
2182
- * pipeline, a buggy hook degrades gracefully.
1995
+ * Step 11.x, runtime catalog of plugin-contributed view contributions,
1996
+ * as exposed by `kernel.getRegisteredViewContributions()`. Threaded
1997
+ * through so analyzers can reason about emissions without reaching
1998
+ * back into the kernel (built-in `core/contribution-orphan` joins it
1999
+ * with the live node set to flag dangling emissions). Slot catalog
2000
+ * drift detection is NOT a scan concern, it lives at load time and
2001
+ * surfaces via `sm plugins doctor`. Empty array when no extension
2002
+ * declares view contributions; absent for legacy callers (older
2003
+ * runScan sites that never wired the catalog through).
2183
2004
  */
2184
- on(ctx: IHookContext): void | Promise<void>;
2185
- }
2186
-
2187
- /**
2188
- * `ProgressEmitterPort`, emits progress events during long operations.
2189
- *
2190
- * Shape-only today. The full event catalog (`run.started`,
2191
- * `job.claimed`, `model.delta`, etc.) is normative in
2192
- * `spec/job-events.md`; this port carries an open `data` payload so
2193
- * adapters can emit any documented event without type churn.
2194
- */
2195
- interface ProgressEvent {
2196
- type: string;
2197
- timestamp: string;
2198
- runId?: string;
2199
- jobId?: string;
2200
- data?: unknown;
2201
- }
2202
- type TProgressListener = (event: ProgressEvent) => void;
2203
- interface ProgressEmitterPort {
2204
- emit(event: ProgressEvent): void;
2205
- subscribe(listener: TProgressListener): () => void;
2206
- }
2207
-
2208
- /**
2209
- * Per-node extractor invocation: build a fresh `IExtractorContext` for
2210
- * each extractor, validate every emitted link / contribution against
2211
- * the declared catalog, fold enrichment partials into per-`(node,
2212
- * extractor)` records, and surface emit-time drops as
2213
- * `extension.error` events.
2214
- *
2215
- * Also hosts the post-walk recompute helpers that re-derive
2216
- * `linksOutCount` / `linksInCount` / `externalRefsCount` on every node
2217
- * from the final merged link buffer, plus the `IExtractorRunRecord`
2218
- * and `IEnrichmentRecord` types those records eventually persist as.
2219
- */
2220
-
2221
- /**
2222
- * Spec § A.9, runs to persist into `scan_extractor_runs`. One entry
2223
- * per `(nodePath, qualifiedExtractorId)` pair the orchestrator decided
2224
- * "this extractor is current for this body". Includes both freshly-run
2225
- * pairs (extractor invoked this scan) and reused pairs (cached node, the
2226
- * extractor's prior run still applies to the same body hash). Excludes
2227
- * obsolete pairs, extractors that ran in the prior but are no longer
2228
- * registered, so a replace-all persist drops them automatically.
2229
- */
2230
- interface IExtractorRunRecord {
2231
- nodePath: string;
2232
- extractorId: string;
2233
- bodyHashAtRun: string;
2234
- ranAt: number;
2005
+ viewContributions?: readonly IRegisteredViewContribution[];
2235
2006
  /**
2236
- * sha256 of the canonical-form sidecar annotations the Extractor saw
2237
- * at run time. Always populated (an absent sidecar canonicalises to
2238
- * `{}` so the hash is stable). Used unconditionally by the cache
2239
- * decision alongside `bodyHashAtRun`: a sidecar-only edit invalidates
2240
- * the cached run for every applicable Extractor on that node.
2007
+ * Absolute paths of `*.md` files under the project's
2008
+ * `.skill-map/jobs/` that no `state_jobs.filePath` references, the
2009
+ * built-in `core/job-orphan-file` analyzer projects each as a `warn`
2010
+ * issue. Pre-computed by the driving adapter (CLI / BFF) inside its
2011
+ * already-open storage transaction (mirrors the `orphanSidecars`
2012
+ * pattern: detection lives outside the analyzer, the analyzer only
2013
+ * projects). Absent (or empty) when the caller does not maintain a
2014
+ * jobs directory, when the storage path is unavailable, or when no
2015
+ * orphan files exist. Treat as read-only.
2241
2016
  */
2242
- sidecarAnnotationsHashAtRun: string;
2243
- }
2017
+ orphanJobFiles?: readonly string[];
2018
+ /**
2019
+ * Set of absolute file paths the operator has opted into for
2020
+ * link-validation purposes via `scan.referencePaths`. The driving
2021
+ * adapter walks each configured path before the scan and collects
2022
+ * every existing file's absolute path here. Files in this set are
2023
+ * NOT indexed as graph nodes, the only consumer is
2024
+ * `core/broken-ref`, which suppresses its `warn` issue when a
2025
+ * path-style link target falls into the set. Absent / empty when
2026
+ * the operator left `scan.referencePaths` empty or when the
2027
+ * adapter does not maintain the side index. Treat as read-only.
2028
+ */
2029
+ referenceablePaths?: ReadonlySet<string>;
2030
+ /**
2031
+ * Absolute path of the scan's project root (cwd of the invocation).
2032
+ * Threaded into the analyzer pass so an analyzer that needs to
2033
+ * resolve a relative `link.target` to an absolute filesystem path
2034
+ * (today only `core/broken-ref`, when consulting
2035
+ * `referenceablePaths`) does not have to derive it from
2036
+ * `nodes[0].path` heuristics. Absent for legacy callers (older
2037
+ * `runScan` sites that never wired the field through). Always an
2038
+ * absolute path when present.
2039
+ */
2040
+ cwd?: string;
2041
+ /**
2042
+ * Emit a per-node view contribution declared in this analyzer's
2043
+ * manifest `viewContributions` map. Sync, void return; the
2044
+ * orchestrator validates the payload against the slot's schema at
2045
+ * call time and silently drops invalid emissions with a logged
2046
+ * `extension.error` event (parallel to
2047
+ * `IExtractorCallbacks.emitContribution`).
2048
+ *
2049
+ * Unlike Extractor's emit (which binds `nodePath` from `ctx.node.path`
2050
+ * implicitly because Extractors run per-node), Analyzer's `evaluate()`
2051
+ * sees the full graph at once. The analyzer walks `ctx.nodes` itself
2052
+ * and MUST supply the target node path explicitly per emission.
2053
+ *
2054
+ * Calling `emitContribution` with a `contributionId` that is not
2055
+ * declared in the manifest is dropped with an `extension.error`. The
2056
+ * kernel routes emitted contributions to the same persistence
2057
+ * pipeline as Extractor emissions (`scan_contributions`).
2058
+ */
2059
+ emitContribution(nodePath: string, contributionId: string, payload: unknown): void;
2060
+ }
2061
+ interface IAnalyzer extends IExtensionBase {
2062
+ kind: 'analyzer';
2063
+ /**
2064
+ * Execution mode. Optional in the manifest with a default of
2065
+ * `deterministic` per `spec/schemas/extensions/analyzer.schema.json`.
2066
+ */
2067
+ mode?: TExecutionMode;
2068
+ /**
2069
+ * Qualified `<pluginId>/<id>` Action ids the analyzer recommends to
2070
+ * resolve its findings. Distinct from `Action.precondition` (which
2071
+ * declares which nodes an Action applies to from the Action side);
2072
+ * this field declares which Actions are relevant when this
2073
+ * Analyzer fires from the Analyzer side. Actions are per-node by
2074
+ * design (project-level cleanup verbs like orphan file prune or
2075
+ * contribution relink are CLI verbs, not Actions) and are NOT
2076
+ * surfaced through this field. The UI consumes it in the node
2077
+ * inspector under "Recommended for issues". Optional; omit when no
2078
+ * Action resolves the finding (e.g. `core/superseded` surfaces
2079
+ * deliberate user declarations, not problems).
2080
+ */
2081
+ recommendedActions?: readonly string[];
2082
+ evaluate(ctx: IAnalyzerContext): Issue[] | Promise<Issue[]>;
2083
+ }
2084
+
2244
2085
  /**
2245
- * Spec § A.8, universal enrichment layer.
2246
- *
2247
- * One entry per `(nodePath, qualifiedExtractorId)` pair an Extractor
2248
- * produced via `ctx.enrichNode(...)` during the walk. Attribution is
2249
- * preserved per-Extractor (rather than merged client-side as B.1 did)
2250
- * so the persistence layer can:
2086
+ * Action runtime contract. The fourth plugin kind (spec § A.4 +
2087
+ * `spec/schemas/extensions/action.schema.json`).
2251
2088
  *
2252
- * - upsert a single row per pair (stable PRIMARY KEY conflict on
2253
- * re-extract);
2254
- * - feed `mergeNodeWithEnrichments` with `enrichedAt`-sorted partials
2255
- * for last-write-wins per field at read time.
2089
+ * Actions operate on one or more nodes in one of two execution modes:
2256
2090
  *
2257
- * `value` is the cumulative merge across every `enrichNode` call that
2258
- * Extractor made for this node within this scan, multiple
2259
- * `ctx.enrichNode({...})` calls inside one `extract(ctx)` invocation
2260
- * fold into a single row, but two different Extractors hitting the
2261
- * same node yield two distinct rows.
2091
+ * - `deterministic`, code runs in-process; the action computes the
2092
+ * report synchronously and returns it. No job file, no runner.
2093
+ * - `probabilistic`, the kernel renders a prompt + preamble into a
2094
+ * job file; a runner executes it via `RunnerPort` against an LLM;
2095
+ * `sm record` closes the job and validates the report against
2096
+ * `reportSchemaRef`.
2262
2097
  *
2263
- * `isProbabilistic` is reserved: Extractors are deterministic-only, so
2264
- * every record produced by the orchestrator sets it to `false`. The
2265
- * field is kept on the record (and the row in `node_enrichments`) so a
2266
- * future Action-issued enrichment can populate it without reshaping
2267
- * the persistence contract, see spec `architecture.md`
2268
- * §Extractor · enrichment layer.
2269
- */
2270
- interface IEnrichmentRecord {
2271
- nodePath: string;
2272
- extractorId: string;
2273
- bodyHashAtEnrichment: string;
2274
- value: Partial<Node>;
2275
- enrichedAt: number;
2276
- isProbabilistic: boolean;
2277
- }
2278
- /**
2279
- * Run a set of extractors against a single node, collecting their link
2280
- * emissions and node-enrichment partials. Each extractor is invoked
2281
- * exactly once with a fresh `IExtractorContext`. Caller decides what
2282
- * to do with the returned arrays (push into per-scan buffers, write to
2283
- * a focused refresh result, etc.).
2098
+ * **Deferred runtime invocation.** The dispatcher (`Action.run(ctx)` for
2099
+ * deterministic; the `RunnerPort` + `sm record` round-trip for
2100
+ * probabilistic) lands with the job subsystem (Decision #114 in
2101
+ * `ROADMAP.md`). Today the loader still validates `kind: 'action'`
2102
+ * manifests against `extension-action.schema.json` and the registry
2103
+ * holds them, `sm actions show` and the precondition gating UI consume
2104
+ * the manifest data. The runtime entry point is intentionally absent
2105
+ * from `IAction` so plugin authors don't ship a method the kernel will
2106
+ * not call until the job subsystem is in place; when it ships, the
2107
+ * method shape will land here without breaking the manifest contract.
2284
2108
  *
2285
- * Exported so `cli/commands/refresh.ts` can reuse the same wiring it
2286
- * needs for re-running a single extractor against a single node, the
2287
- * pre-extraction code in `refresh.ts` was hand-duplicating this loop
2288
- * (audit item V4).
2109
+ * Mirrors `extensions/action.schema.json`:
2289
2110
  *
2290
- * Within this call, multiple `enrichNode(partial)` calls from the same
2291
- * extractor against the same node fold into one record (last-write-wins
2292
- * per field), same contract as the in-scan path.
2111
+ * - `mode` (required), discriminator between the two modes.
2112
+ * - `reportSchemaRef` (required), JSON Schema reference the report
2113
+ * MUST validate against. MUST extend `report-base.schema.json`.
2114
+ * - `promptTemplateRef`, REQUIRED when `mode: 'probabilistic'`,
2115
+ * FORBIDDEN when `mode: 'deterministic'`. The schema's conditional
2116
+ * `allOf` enforces both directions; the runtime contract simply
2117
+ * surfaces the field as optional and lets the loader catch shape
2118
+ * violations at AJV time.
2119
+ * - `expectedDurationSeconds`, REQUIRED for probabilistic (drives
2120
+ * TTL); advisory for deterministic.
2121
+ * - `precondition`, declarative filter consumed by `--all` fan-out,
2122
+ * UI button gating, `sm actions show`.
2123
+ * - `expectedTools`, hint to Skill / CLI runners about expected
2124
+ * tools (no normative enforcement in v0).
2125
+ * - `fanOutPolicy`, `'per-node'` (default) vs `'batch'`.
2293
2126
  */
2294
- declare function runExtractorsForNode(opts: {
2295
- extractors: IExtractor[];
2296
- node: Node;
2297
- body: string;
2298
- frontmatter: Record<string, unknown>;
2299
- bodyHash: string;
2300
- emitter: ProgressEmitterPort;
2301
- /**
2302
- * Spec § A.12, per-plugin `ctx.store` wrappers keyed by `pluginId`.
2303
- * The map's lookup is per-extractor inside the loop, so callers that
2304
- * don't track plugin storage can omit it; the resulting `ctx.store`
2305
- * stays `undefined` (the existing contract).
2306
- */
2307
- pluginStores?: ReadonlyMap<string, IPluginStore>;
2308
- }): Promise<{
2309
- internalLinks: Link[];
2310
- externalLinks: Link[];
2311
- enrichments: IEnrichmentRecord[];
2312
- contributions: IContributionRecord[];
2313
- }>;
2314
2127
 
2315
2128
  /**
2316
- * Rename + orphan classification per `spec/db-schema.md` §Rename
2317
- * detection. Pure: takes the prior `ScanResult` and the current node
2318
- * set, mutates the supplied `issues` array in place, and returns the
2319
- * `RenameOp[]` the persistence layer must apply inside the same tx as
2320
- * the scan zone replace-all.
2129
+ * Single sidecar write payload an Action can return. Discriminated union so
2130
+ * future write kinds (storage rows, plugin KV, etc.) can land additively
2131
+ * without breaking consumers that only handle `kind: 'sidecar'`.
2132
+ *
2133
+ * - `path`, absolute path to the `.sm` file the kernel must materialise
2134
+ * the change into. Resolved by the Action from the node's absolute
2135
+ * path via `sidecarPathFor()`.
2136
+ * - `changes`, partial sidecar root used as a deep-merge patch (NOT a
2137
+ * full replacement). Arrays REPLACE; objects RECURSE. Reason:
2138
+ * sidecars are shared-write between skill-map core and plugins;
2139
+ * a full replace would clobber `<plugin-id>:` namespaced blocks.
2321
2140
  */
2322
-
2141
+ type TActionWrite = {
2142
+ kind: 'sidecar';
2143
+ path: string;
2144
+ changes: Record<string, unknown>;
2145
+ };
2323
2146
  /**
2324
- * Confidence-tagged plan to repoint `state_*` references from one node
2325
- * path to another. Emitted by the rename heuristic during `runScan` and
2326
- * consumed by `persistScanResult` so the FK migration runs inside the
2327
- * same transaction as the scan zone replace-all.
2147
+ * Result envelope returned by deterministic Actions. The `report` field
2148
+ * carries the typed report payload (each Action declares its shape via
2149
+ * `reportSchemaRef`); `writes` is opt-in, Actions that do not mutate
2150
+ * persistent state simply omit it.
2328
2151
  */
2329
- interface RenameOp {
2330
- from: string;
2331
- to: string;
2332
- confidence: 'high' | 'medium';
2152
+ interface IActionResult<TReport = unknown> {
2153
+ report: TReport;
2154
+ writes?: TActionWrite[];
2333
2155
  }
2334
2156
  /**
2335
- * Pure rename / orphan classification per `spec/db-schema.md` §Rename
2336
- * detection. Mutates `issues` in place, caller passes the in-progress
2337
- * issue list; returns the `RenameOp[]` for the persistence layer to
2338
- * apply inside its tx.
2339
- *
2340
- * Pipeline (1-to-1: a `newPath` claimed by one stage cannot be reused
2341
- * by another):
2342
- *
2343
- * 1. **High-confidence**: pair each `deletedPath` with a `newPath`
2344
- * that has the same `bodyHash`. No issue, no prompt.
2345
- * 2. **Medium-confidence (1:1)**: of the remaining deletions, pair
2346
- * each with the *unique* unclaimed `newPath` that shares its
2347
- * `frontmatterHash`. Emits `auto-rename-medium` (severity warn)
2348
- * with `data: { from, to, confidence: 'medium' }`.
2349
- * 3. **Ambiguous (N:1)**: when a single `newPath` has more than one
2350
- * remaining frontmatter-matching candidate, emit ONE
2351
- * `auto-rename-ambiguous` issue per `newPath`, listing all
2352
- * candidates in `data.candidates`. NO migration.
2353
- * 4. **Orphan**: every `deletedPath` left after steps 1-3 yields one
2354
- * `orphan` issue (severity info) with `data: { path: <deletedPath> }`.
2157
+ * Runtime context passed to a deterministic Action's `invoke()` method.
2158
+ * Minimal surface, Actions stay pure (no IO inside `invoke`); the kernel
2159
+ * materialises any returned `writes` after the call.
2355
2160
  *
2356
- * Determinism: `deletedPaths` and `newPaths` are iterated in lex-asc
2357
- * order so the same input always produces the same matches,
2358
- * required for reproducible tests and conformance fixtures (the spec
2359
- * does not prescribe an order, but stability is the obvious contract).
2161
+ * - `node`, the target `Node` the Action operates on. Open-by-design;
2162
+ * batch / fan-out flows pick the matching nodes upstream.
2163
+ * - `nodeAbsolutePath`, absolute path to the node's `.md` file on
2164
+ * disk. The Action uses this to compute the sidecar path it returns
2165
+ * in a `TActionWrite`. Surfaced separately from `node.path` (which is
2166
+ * the relative scope-root form) so Actions never compose absolute
2167
+ * paths from `node.path` themselves.
2168
+ * - `invoker`, identity of the caller; written into the sidecar's
2169
+ * `audit.lastBumpedBy` when the Action chooses to. CLI invocations
2170
+ * pass `'cli'`; plugin-driven invocations pass `'plugin:<plugin-id>'`.
2171
+ * - `now`, clock function; tests inject a deterministic source.
2172
+ * Defaults to `() => new Date()` at the composition root.
2360
2173
  */
2361
- declare function detectRenamesAndOrphans(prior: ScanResult, current: Node[], issues: Issue[]): RenameOp[];
2362
-
2174
+ interface IActionContext {
2175
+ node: Node;
2176
+ nodeAbsolutePath: string;
2177
+ invoker: string;
2178
+ now: () => Date;
2179
+ }
2363
2180
  /**
2364
- * Scan orchestrator, runs the Provider extractor analyzer pipeline across
2365
- * every registered extension and emits `ProgressEmitterPort` events in
2366
- * canonical order. The callable extension set is injected via
2367
- * `RunScanOptions.extensions`, the Registry holds manifest metadata, the
2368
- * callable set holds the runtime instances the orchestrator actually
2369
- * invokes. Separating the two lets `sm plugins` and `sm help` introspect
2370
- * the graph without loading code.
2371
- *
2372
- * With zero registered extensions (or a callable set that carries none)
2373
- * the pipeline still produces a valid zero-filled `ScanResult`, the
2374
- * kernel-empty-boot invariant.
2375
- *
2376
- * Roots are validated up front: each entry of `RunScanOptions.roots`
2377
- * must exist on disk as a directory. The first failure throws a clear
2378
- * `Error` naming the offending path. This guards every caller (CLI,
2379
- * server, skill-agent) against silently producing a zero-filled
2380
- * `ScanResult` when a Provider walks a non-existent path, the bug
2381
- * that wiped a populated DB via `sm scan -- --dry-run` (clipanion's
2382
- * `--` made `--dry-run` a positional root that did not exist).
2383
- *
2384
- * Incremental scans: when `priorSnapshot` is supplied, the
2385
- * orchestrator walks the filesystem, hashes each file, and reuses the
2386
- * prior node + its prior-extracted internal links whenever both
2387
- * `bodyHash` and `frontmatterHash` match. New / modified files run
2388
- * through the full extractor pipeline (including the external-url-counter
2389
- * which produces ephemeral pseudo-links). Rules ALWAYS run over the
2390
- * fully merged graph, issue state can change even for an unchanged node
2391
- * (e.g. a previously broken `references` link now resolves because a new
2392
- * node was added). For unchanged nodes the prior `externalRefsCount` is
2393
- * preserved as-is (the external pseudo-links were never persisted, so
2394
- * they cannot be reconstructed; the count survived in the node row).
2395
- *
2396
- * Extractor output model (B.1, post-rename from Detector): extractors
2397
- * return `void` and emit through three callbacks injected on the context:
2398
- * - `ctx.emitLink(link)` → orchestrator validates against
2399
- * `emitsLinkKinds` then partitions into internal / external buckets.
2400
- * - `ctx.enrichNode(partial)` → orchestrator records ONE enrichment
2401
- * entry per `(node, extractor)` so attribution survives into the DB.
2402
- * Persisted into `node_enrichments` (A.8). The author-supplied
2403
- * frontmatter on `node.frontmatter` stays immutable from any Extractor
2404
- * , the enrichment layer is the only writable surface, and rules /
2405
- * formatters consume it via `mergeNodeWithEnrichments`.
2406
- * - `ctx.store` → plugin's own KV / dedicated tables (spec § A.12).
2407
- * Wired by the driving adapter via `RunScanOptions.pluginStores`,
2408
- * which the orchestrator looks up per-extractor by `pluginId` and
2409
- * attaches to the context. The orchestrator never inspects what
2410
- * plugins write through it; the wrapper handles AJV validation
2411
- * when the manifest declared an output schema.
2181
+ * Declarative filter applied by `--all` fan-out, UI button gating, and
2182
+ * `sm actions show`. All fields optional, an empty precondition matches
2183
+ * every node.
2412
2184
  */
2413
-
2414
- interface IScanExtensions {
2415
- providers: IProvider[];
2416
- extractors: IExtractor[];
2417
- analyzers: IAnalyzer[];
2418
- /**
2419
- * Optional hooks (spec § A.11). When supplied, the orchestrator's
2420
- * lifecycle dispatcher invokes deterministic hooks subscribed to one
2421
- * of the eight hookable triggers in canonical order with the matching
2422
- * event payload. Absent → no hooks fire (the scan still emits its
2423
- * lifecycle events to `ProgressEmitterPort` for observability).
2424
- * Probabilistic hooks are loaded but skipped here with a stderr
2425
- * advisory until the job subsystem ships once the job subsystem ships.
2426
- */
2427
- hooks?: IHook[];
2428
- }
2429
- interface RunScanOptions {
2185
+ interface IActionPrecondition {
2430
2186
  /**
2431
- * Filesystem roots to walk. Spec requires `minItems: 1`; passing an
2432
- * empty array makes `runScan` throw before any work happens.
2187
+ * Node kinds this action accepts. Open-by-design (matches
2188
+ * `node.schema.json#/properties/kind`): an action declared with
2189
+ * `kind: ['cursorRule']` is valid as long as some Provider classifies
2190
+ * into `cursorRule`. Omitted → any kind.
2433
2191
  */
2434
- roots: string[];
2435
- emitter?: ProgressEmitterPort;
2436
- /** Runtime extension instances. Absent → empty pipeline. */
2437
- extensions?: IScanExtensions;
2192
+ kind?: string[];
2193
+ /** Provider ids whose nodes this action accepts. Omitted → any Provider. */
2194
+ provider?: string[];
2195
+ /** Node stability filter. */
2196
+ stability?: Array<'experimental' | 'stable' | 'deprecated'>;
2438
2197
  /**
2439
- * Step 9.6.6, runtime catalog of plugin-contributed annotation keys
2440
- * (the same shape `kernel.getRegisteredAnnotationKeys()` returns).
2441
- * Threaded into the rule pass so `core/unknown-field` can
2442
- * legitimise registered plugin namespaces / root keys without
2443
- * re-walking the manifests. Absent → empty catalog (every plugin
2444
- * key is treated as unknown). Built-in catalog from
2445
- * `annotations.schema.json` is NOT included, that is hard-coded
2446
- * inside the rule.
2198
+ * Free-form precondition strings the kernel forwards to the action for
2199
+ * runtime evaluation (example: `frontmatter.metadata.source != null`).
2447
2200
  */
2448
- annotationContributions?: readonly IRegisteredAnnotationKey[];
2201
+ custom?: string[];
2202
+ }
2203
+ interface IAction extends IExtensionBase {
2204
+ kind: 'action';
2449
2205
  /**
2450
- * Runtime catalog of plugin-contributed view contributions (the same
2451
- * shape `kernel.getRegisteredViewContributions()` returns). Threaded
2452
- * into the rule pass so:
2453
- * - `core/contribution-orphan` can introspect the catalog
2454
- * (read-only) and join it with the live node set to flag
2455
- * dangling emissions. Slot catalog drift is NOT a scan concern,
2456
- * it lives at load time and surfaces via `sm plugins doctor`
2457
- * (the kernel rejects unknown slots as `invalid-manifest` first,
2458
- * doctor catches the catalog-version-skew tail).
2459
- * - The orchestrator's per-rule emit closure can look up each
2460
- * declared `(contributionId → slot)` pairing for AJV
2461
- * payload validation.
2462
- * Absent → empty catalog. Rules that emit contributions silently
2463
- * drop emissions when the catalog has no entry for the rule's
2464
- * declared contributionId.
2206
+ * Execution mode discriminator. Required per
2207
+ * `extensions/action.schema.json`.
2465
2208
  */
2466
- viewContributions?: readonly IRegisteredViewContribution[];
2209
+ mode: TExecutionMode;
2467
2210
  /**
2468
- * Scan scope. Defaults to `'project'`. The CLI flag wiring lands in
2469
- * the config layer wiring; `runScan` already accepts the override
2470
- * so plugins / tests can opt into `'global'` today.
2211
+ * Reference to the JSON Schema the report MUST validate against. MUST
2212
+ * extend `report-base.schema.json` (directly or transitively).
2213
+ * Validation failure job transitions to `failed` with reason
2214
+ * `report-invalid`.
2471
2215
  */
2472
- scope?: 'project' | 'global';
2216
+ reportSchemaRef: string;
2473
2217
  /**
2474
- * Compute per-node token counts (frontmatter / body / total) using the
2475
- * cl100k_base BPE (the modern OpenAI tokenizer used by GPT-4 / GPT-3.5).
2476
- * Defaults to true. Set false to skip tokenization; `node.tokens` is
2477
- * left undefined (spec-valid: the field is optional).
2218
+ * Best-effort estimate of wall-clock duration in seconds. Drives TTL
2219
+ * (`ttl = max(expectedDurationSeconds × graceMultiplier,
2220
+ * minimumTtlSeconds)`). Required for `probabilistic`; advisory for
2221
+ * `deterministic`.
2478
2222
  */
2479
- tokenize?: boolean;
2223
+ expectedDurationSeconds?: number;
2480
2224
  /**
2481
- * Prior snapshot for two purposes (decoupled by design):
2482
- *
2483
- * 1. **Rename heuristic** (`spec/db-schema.md` §Rename detection):
2484
- * always evaluated when `priorSnapshot` is supplied. The
2485
- * heuristic compares prior vs current node paths and emits
2486
- * high / medium / ambiguous / orphan classifications. This
2487
- * runs on EVERY `sm scan` (with or without `--changed`) so
2488
- * reorganising files always preserves history, never silently.
2489
- *
2490
- * 2. **Cache reuse** (`sm scan --changed`): only kicks in when
2491
- * `enableCache: true` is also passed. With the flag set, nodes
2492
- * whose `path` exists in the prior with both `bodyHash` and
2493
- * `frontmatterHash` matching the freshly-computed hashes are
2494
- * reused as-is (their internal links and `externalRefsCount`
2495
- * survive); only new / modified nodes run through extractors.
2496
- * Rules always re-run over the merged graph.
2497
- *
2498
- * Pass `null` (or omit) for a fresh scan with no rename detection.
2225
+ * Path (relative to the extension file) to the prompt template the
2226
+ * kernel renders at `sm job submit`. REQUIRED when `mode:
2227
+ * 'probabilistic'`; FORBIDDEN when `mode: 'deterministic'`. The
2228
+ * conditional shape is enforced by AJV at load time; the runtime
2229
+ * contract carries the field as optional so both modes share one
2230
+ * interface.
2499
2231
  */
2500
- priorSnapshot?: ScanResult | null;
2232
+ promptTemplateRef?: string;
2501
2233
  /**
2502
- * Reuse unchanged nodes from `priorSnapshot` instead of re-running
2503
- * extractors over them. Defaults to `false` so a plain `sm scan`
2504
- * always re-walks deterministically. `sm scan --changed` flips this
2505
- * to `true` for the perf win on unchanged files.
2506
- *
2507
- * Has no effect without `priorSnapshot`; setting it to `true` with
2508
- * a null prior is a no-op (every file is "new").
2234
+ * Optional declarative filter; absent applies to every node.
2509
2235
  */
2510
- enableCache?: boolean;
2236
+ precondition?: IActionPrecondition;
2511
2237
  /**
2512
- * Filter that decides which paths the Providers skip. Composed by the
2513
- * caller (typically the CLI) from bundled defaults + `config.ignore`
2514
- * + `.skillmapignore`. Providers that omit this option fall back to
2515
- * their own defensive defaults (just enough to keep `.git` /
2516
- * `node_modules` out).
2238
+ * Hint to Skill / CLI runners about what tools the rendered prompt
2239
+ * expects access to (`Bash`, `Read`, `WebSearch`, …). No normative
2240
+ * enforcement in v0.
2517
2241
  */
2518
- ignoreFilter?: IIgnoreFilter;
2242
+ expectedTools?: string[];
2519
2243
  /**
2520
- * Promote frontmatter-validation findings from `warn` to `error`.
2521
- * Defaults to false. The CLI surfaces this via `--strict` on `sm scan`
2522
- * and the `scan.strict` config key. When false, the orchestrator
2523
- * still emits a `frontmatter-invalid` issue per malformed file but
2524
- * leaves the severity at `warn` so a clean scan exits 0; when true,
2525
- * the same finding becomes `error` and the scan exits 1.
2244
+ * `'per-node'` (default): `sm job submit --all` produces one job per
2245
+ * matching node. `'batch'`: one job whose prompt template receives the
2246
+ * full list. Batch actions tend to hit context limits; use sparingly.
2526
2247
  */
2527
- strict?: boolean;
2248
+ fanOutPolicy?: 'per-node' | 'batch';
2528
2249
  /**
2529
- * Spec § A.9, fine-grained Extractor cache breadcrumbs from the
2530
- * prior scan. Shape: `Map<nodePath, Map<qualifiedExtractorId, IPriorExtractorRun>>`.
2531
- * Loaded from the `scan_extractor_runs` table by the CLI before
2532
- * invoking `runScan`; absent / empty for a fresh DB or an out-of-band
2533
- * caller that does not maintain a cache. Decoupled from `priorSnapshot`
2534
- * because the runs live in a sibling table and are useful only when
2535
- * `enableCache` is also set.
2250
+ * Deterministic invocation entry point. OPTIONAL on the runtime
2251
+ * contract for backward compatibility with the manifest-only era
2252
+ * (Decision #114), actions that ship for the future probabilistic
2253
+ * runner / record path leave it absent and the kernel never calls it.
2254
+ * Step 9.6.3 (Decision #125) introduces the first concrete consumer:
2255
+ * the built-in `bump` Action implements `invoke()` and returns a
2256
+ * `writes: [{ kind: 'sidecar', ... }]` payload that the kernel
2257
+ * materialises through `ISidecarStore`.
2536
2258
  *
2537
- * Cache decision per `(node, extractor)`:
2538
- * - body+frontmatter hashes match the prior node AND every currently-
2539
- * registered extractor that applies to this kind has a matching
2540
- * row → full skip, all prior outbound links reused.
2541
- * - some applicable extractor lacks a matching row (newly registered,
2542
- * or its prior run targeted a different body hash or sidecar
2543
- * annotations hash) → run only the missing extractors, drop prior
2544
- * links whose `sources` map to any missing extractor or to an
2545
- * extractor that is no longer registered.
2546
- */
2547
- priorExtractorRuns?: Map<string, Map<string, IPriorExtractorRun>>;
2548
- /**
2549
- * Spec § A.12, per-plugin storage wrappers exposed to extractors via
2550
- * `ctx.store`. Keyed by `pluginId`; absent / missing entry leaves
2551
- * `ctx.store` undefined for that extractor (the existing contract).
2259
+ * Implementations MUST stay pure, no IO inside `invoke()`. The Action
2260
+ * computes the patch and returns it; the kernel reads the on-disk
2261
+ * sidecar, deep-merges, validates, and writes back inside its critical
2262
+ * section.
2552
2263
  *
2553
- * The kernel does not construct these, the driving adapter (CLI,
2554
- * future server) builds them with `makePluginStore` from
2555
- * `kernel/adapters/plugin-store.js` and threads them through. This
2556
- * keeps the orchestrator persistence-agnostic (the wrapper supplies
2557
- * its own persist callback) and lets tests inject a captured-call
2558
- * mock without spinning up a DB.
2559
- */
2560
- pluginStores?: ReadonlyMap<string, IPluginStore>;
2561
- /**
2562
- * Pre-computed absolute paths of orphan job MD files (files under
2563
- * `.skill-map/jobs/` whose absolute path appears nowhere in
2564
- * `state_jobs.filePath`). Threaded into the rule pass so the
2565
- * built-in `core/job-orphan-file` rule can project each as a `warn`
2566
- * issue without the kernel reaching for the storage port or doing
2567
- * its own FS walk. The driving adapter (CLI, BFF) computes this
2568
- * inside its already-open storage transaction via
2569
- * `findOrphanJobFiles(jobsDir, await port.jobs.listReferencedFilePaths())`
2570
- * mirrors the `orphanSidecars` model where detection lives
2571
- * outside the rule and the rule only projects. Absent / empty when
2572
- * the caller has no jobs context (out-of-band tests, fresh DB,
2573
- * `--no-built-ins`).
2264
+ * `TInput` is action-specific; the built-in `bump` Action declares
2265
+ * `{ force?: boolean; reason?: string }`. The signature stays generic
2266
+ * so each Action narrows it locally without forcing a common base.
2574
2267
  */
2575
- orphanJobFiles?: readonly string[];
2576
- /**
2577
- * Side set of absolute file paths the operator opted into for
2578
- * link-validation purposes via `scan.referencePaths`. Threaded
2579
- * through to `IAnalyzerContext.referenceablePaths` so the built-in
2580
- * `core/broken-ref` rule can suppress its `warn` for path-style
2581
- * links whose target lands in the set. Files are NOT walked by
2582
- * the kernel, the driving adapter populates the set before
2583
- * calling `runScan`. Absent / empty when the operator left
2584
- * `scan.referencePaths` unconfigured.
2585
- */
2586
- referenceablePaths?: ReadonlySet<string>;
2587
- /**
2588
- * Absolute path of the scan's cwd / project root. Threaded onto
2589
- * `IAnalyzerContext.cwd` so rules that need to resolve a relative
2590
- * `link.target` to an absolute filesystem path can do so without
2591
- * heuristics. Absent for callers that don't track a cwd
2592
- * concept (out-of-band tests, embedders).
2593
- */
2594
- cwd?: string;
2268
+ invoke?: <TInput, TReport>(input: TInput, ctx: IActionContext) => IActionResult<TReport>;
2595
2269
  }
2270
+
2596
2271
  /**
2597
- * Same as `runScan` but also returns the rename heuristic's `RenameOp[]`
2598
- * the high- and medium-confidence renames the persistence layer must
2599
- * apply to `state_*` rows inside the same tx as the scan zone replace-
2600
- * all (per `spec/db-schema.md` §Rename detection). Most callers want
2601
- * `runScan` (which returns just `ScanResult`); the CLI's `sm scan`
2602
- * uses this variant so it can hand the ops off to `persistScanResult`.
2272
+ * Formatter runtime contract. Turns the (nodes, links, issues) graph into
2273
+ * a textual representation for `sm graph --format <name>`.
2603
2274
  *
2604
- * Also returns `extractorRuns`, the Spec § A.9 fine-grained cache
2605
- * breadcrumbs the CLI persists into `scan_extractor_runs` so the next
2606
- * incremental scan can decide per-(node, extractor) whether re-running
2607
- * is required.
2275
+ * Two adjacent names live on the same instance:
2276
+ *
2277
+ * - `formatId: string`, the manifest field consumed by the
2278
+ * `--format <name>` CLI flag. The kernel's lookup is
2279
+ * `formatters.find((f) => f.formatId === flag)`.
2280
+ * - `format(ctx) → string`, the runtime method. Receives the full
2281
+ * graph and returns the serialized output. Output MUST be
2282
+ * byte-deterministic for the same input (the snapshot-test suite
2283
+ * relies on this).
2284
+ *
2285
+ * The split (`formatId` vs `format`) is deliberate: it keeps the method
2286
+ * named after the kind (`Formatter.format()` reads naturally) while the
2287
+ * field carries the identifier the user types on the command line.
2608
2288
  */
2609
- declare function runScanWithRenames(_kernel: Kernel, options: RunScanOptions): Promise<{
2610
- result: ScanResult;
2611
- renameOps: RenameOp[];
2612
- extractorRuns: IExtractorRunRecord[];
2613
- enrichments: IEnrichmentRecord[];
2614
- contributions: IContributionRecord[];
2615
- freshlyRunTuples: ReadonlySet<string>;
2616
- }>;
2617
- declare function runScan(_kernel: Kernel, options: RunScanOptions): Promise<ScanResult>;
2618
2289
 
2619
- /**
2620
- * Node-construction helpers: hash a body, canonicalise frontmatter /
2621
- * sidecar annotations, resolve the sidecar overlay for a given relative
2622
- * path, and produce a fresh `Node` (validating its frontmatter on the
2623
- * way out). Also hosts `mergeNodeWithEnrichments` + `IPersistedEnrichment`
2624
- * the read-time merge of author frontmatter with the A.8 enrichment
2625
- * layer.
2626
- */
2290
+ interface IFormatterContext {
2291
+ nodes: Node[];
2292
+ links: Link[];
2293
+ issues: Issue[];
2294
+ /**
2295
+ * Full persisted scan, when the caller has it on hand. Optional so
2296
+ * existing formatters that only consume (nodes, links, issues) keep
2297
+ * working unchanged; formatters whose output mirrors a `ScanResult`
2298
+ * envelope (today: the built-in `json` formatter under
2299
+ * `built-in-plugins/formatters/json/`) read this to project the
2300
+ * canonical document verbatim. `undefined` when the caller has only
2301
+ * the three primary arrays (back-compat with older drivers).
2302
+ */
2303
+ scanResult?: ScanResult;
2304
+ }
2305
+ interface IFormatter extends IExtensionBase {
2306
+ kind: 'formatter';
2307
+ /** Format identifier consumed by `sm graph --format <name>`. */
2308
+ formatId: string;
2309
+ /** Serialize the graph into a string. Deterministic-only. */
2310
+ format(ctx: IFormatterContext): string;
2311
+ }
2627
2312
 
2628
2313
  /**
2629
- * Spec § A.8, produce the merged read-time view of a Node.
2314
+ * Hook runtime contract. The sixth plugin kind (spec § A.11).
2630
2315
  *
2631
- * Rules / `sm check` / `sm export` consume `node.frontmatter` directly
2632
- * (deterministic CI-safe baseline, author intent, byte-stable). UI / future
2633
- * rules that opt into enrichment context call this helper to merge the
2634
- * author frontmatter with the live enrichment layer.
2316
+ * Hooks subscribe declaratively to a curated set of kernel lifecycle
2317
+ * events and react to them. Reaction-only by design: a hook cannot
2318
+ * mutate the pipeline, block emission, or alter outputs. Use cases
2319
+ * are notification (Slack on `job.completed`), integration glue (CI
2320
+ * webhook on `job.failed`), and bookkeeping (per-extractor metrics).
2635
2321
  *
2636
- * Algorithm:
2322
+ * The hookable trigger set is INTENTIONALLY SMALL, ten events. Eight
2323
+ * are pipeline-driven (emitted from inside `runScan`); two
2324
+ * (`boot`, `shutdown`) are CLI-process-driven (emitted by the driving
2325
+ * binary before / after the verb runs, fire-and-forget so
2326
+ * `process.exit` is never blocked). The full `ProgressEmitterPort`
2327
+ * catalog (per-node `scan.progress`, `model.delta`, `run.*`, internal
2328
+ * job lifecycle) is deliberately not hookable: too verbose for a
2329
+ * reactive surface, internal to the runner, or covered elsewhere.
2330
+ * Declaring a trigger outside the curated set yields
2331
+ * `invalid-manifest` at load time.
2637
2332
  *
2638
- * 1. Filter `enrichments` down to rows targeting this node AND not
2639
- * flagged `stale`. With Extractors deterministic-only no row is
2640
- * stale-flagged in this revision; the filter is preserved for the
2641
- * future Action-issued enrichment revision (queued LLM jobs whose
2642
- * output must survive body changes), where stale visibility
2643
- * belongs to the UI layer next to the value.
2644
- * 2. Sort the survivors by `enrichedAt` ASC so iteration order is
2645
- * "oldest first". This makes the spread merge below
2646
- * last-write-wins per field, the freshest Extractor's value
2647
- * pisar the older one for any conflicting key.
2648
- * 3. Spread-merge each row's `value` over `node.frontmatter`. The
2649
- * author's keys are the base; enrichment keys overlay them.
2333
+ * Dual-mode (declared in manifest):
2650
2334
  *
2651
- * The returned object is a fresh shallow copy, mutating it does not
2652
- * touch the caller's node. The original `node.frontmatter` reference
2653
- * remains accessible via `node.frontmatter` for callers that want the
2654
- * pristine author baseline.
2335
+ * - `deterministic` (default): `on(ctx)` runs in-process during the
2336
+ * dispatch of the matching event, synchronously between the
2337
+ * event's emission and the next pipeline step. Errors are caught
2338
+ * by the dispatcher, logged via `extension.error`, and never
2339
+ * block the main flow.
2340
+ * - `probabilistic`: the hook is enqueued as a job. Until the job
2341
+ * subsystem ships, probabilistic hooks load but skip dispatch
2342
+ * with a stderr advisory (Decision #114 in `ROADMAP.md`).
2655
2343
  *
2656
- * @param node Node to merge against; `node.frontmatter` is the base.
2657
- * @param enrichments Per-(node, extractor) enrichment records, typically
2658
- * loaded via `loadNodeEnrichments(db, node.path)` or
2659
- * pre-filtered to this node by the caller.
2660
- * @param opts.includeStale When true, include rows flagged stale. Defaults
2661
- * to false (the safe, CI-deterministic default).
2662
- * UIs that want to display "stale (last value: …)"
2663
- * pass `true` and consult `enrichment.stale`
2664
- * on the source rows.
2344
+ * Curated trigger set (per spec § A.11):
2345
+ *
2346
+ * 0. `boot` , once per CLI process, before verb routing.
2347
+ * 1. `scan.started` , pre-scan setup (one per scan).
2348
+ * 2. `scan.completed` , post-scan reaction (one per scan).
2349
+ * 3. `extractor.completed` , aggregated per-Extractor outputs.
2350
+ * 4. `analyzer.completed` , aggregated per-Rule outputs.
2351
+ * 5. `action.completed` , Action executed on a node.
2352
+ * 6. `job.spawning` , pre-spawn of runner subprocess.
2353
+ * 7. `job.completed` , most common trigger.
2354
+ * 8. `job.failed` , alerts, retry triggers.
2355
+ * 9. `shutdown` , once per CLI process, after the verb's
2356
+ * exit code resolves and before
2357
+ * `process.exit`.
2665
2358
  */
2666
- declare function mergeNodeWithEnrichments(node: Node, enrichments: IPersistedEnrichment[], opts?: {
2667
- includeStale?: boolean;
2668
- }): Record<string, unknown>;
2359
+
2669
2360
  /**
2670
- * A persisted enrichment row, post-load. Mirrors the DB row shape
2671
- * but with `value` already deserialised from JSON and `stale` /
2672
- * `isProbabilistic` already decoded from `0 | 1`. Surfaced via
2673
- * `loadNodeEnrichments` (driven adapter) and consumed by
2674
- * `mergeNodeWithEnrichments` and the `sm refresh` command.
2361
+ * The ten hookable lifecycle events. Mirrors the `triggers[]` enum in
2362
+ * `spec/schemas/extensions/hook.schema.json`. Eight are pipeline-driven
2363
+ * (emitted from inside `runScan`); two (`boot`, `shutdown`) are
2364
+ * CLI-process-driven (emitted by the driving binary before / after the
2365
+ * verb runs). Anything outside this set is rejected at load time as
2366
+ * `invalid-manifest`.
2675
2367
  */
2676
- interface IPersistedEnrichment {
2677
- nodePath: string;
2678
- extractorId: string;
2679
- bodyHashAtEnrichment: string;
2680
- value: Partial<Node>;
2681
- stale: boolean;
2682
- enrichedAt: number;
2683
- isProbabilistic: boolean;
2684
- }
2685
-
2368
+ type THookTrigger = 'boot' | 'scan.started' | 'scan.completed' | 'extractor.completed' | 'analyzer.completed' | 'action.completed' | 'job.spawning' | 'job.completed' | 'job.failed' | 'shutdown';
2686
2369
  /**
2687
- * In-memory `ProgressEmitterPort` adapter. No network, no DB, just a
2688
- * synchronous fan-out to registered listeners. Used by the default scan
2689
- * orchestrator; the WebSocket-backed emitter that streams to
2690
- * the Web UI lands.
2370
+ * Frozen list mirror of `THookTrigger` for runtime introspection. The
2371
+ * loader validates `manifest.triggers[]` against this set; the
2372
+ * dispatcher iterates it in order when fanning an event out to
2373
+ * subscribed hooks. `boot` first / `shutdown` last so a debug log of
2374
+ * the array reads in lifecycle order.
2691
2375
  */
2692
-
2693
- declare class InMemoryProgressEmitter implements ProgressEmitterPort {
2694
- #private;
2695
- emit(event: ProgressEvent): void;
2696
- subscribe(listener: TProgressListener): () => void;
2697
- }
2698
-
2376
+ declare const HOOK_TRIGGERS: readonly THookTrigger[];
2699
2377
  /**
2700
- * File watcher for `sm watch` / `sm scan --watch`.
2701
- *
2702
- * Wraps `chokidar` behind a small `IFsWatcher` interface so:
2703
- *
2704
- * 1. The CLI command is impl-agnostic, swapping chokidar for a
2705
- * different watcher later (Java? Rust port? a future `WatchPort`?)
2706
- * doesn't ripple into the command.
2707
- * 2. Debouncing, batching, and ignore-filter integration live in one
2708
- * place. The CLI just gets `onBatch(paths)` callbacks and decides
2709
- * whether to re-scan.
2378
+ * Context the dispatcher hands to `Hook.on()`. The shape is intentionally
2379
+ * narrow: a hook reacts to an event, it does not steer the pipeline.
2710
2380
  *
2711
- * The watcher does NOT call into the orchestrator itself. That decision
2712
- * is deliberate: the CLI owns the scan-and-persist pipeline (`runScan`,
2713
- * `persistScanResult`, optional rebuild of the ignore filter when
2714
- * `.skillmapignore` itself changes). Pulling that into the watcher
2715
- * would couple the kernel module to `SqliteStorageAdapter`, which the
2716
- * Server wouldn't want. Keep this module side-effect free
2717
- * apart from filesystem subscription.
2381
+ * The `event` carries the raw `ProgressEvent` envelope (type, timestamp,
2382
+ * runId/jobId when applicable, data). Optional `node` / `extractorId`
2383
+ * / `analyzerId` / `actionId` are extracted from the event payload by the
2384
+ * dispatcher when present so authors don't have to walk `event.data`.
2718
2385
  *
2719
- * Ignore filter integration: the supplied `IIgnoreFilter` is consulted
2720
- * via chokidar's `ignored` predicate, which receives an absolute path.
2721
- * We re-derive the path RELATIVE to the closest matching root before
2722
- * passing it through `IIgnoreFilter.ignores`. This mirrors what the
2723
- * scan walker does (`extensions/providers/claude/index.ts`) so both code
2724
- * paths agree on what "ignored" means.
2386
+ * Probabilistic hooks additionally receive `runner` for LLM dispatch.
2387
+ * Deterministic hooks SHOULD ignore the field.
2725
2388
  */
2726
-
2727
- type TWatchEventKind = 'add' | 'change' | 'unlink';
2728
- interface IWatchEvent {
2729
- kind: TWatchEventKind;
2730
- /** Absolute path. */
2731
- absolutePath: string;
2732
- }
2733
- interface IWatchBatch {
2734
- /** Events that arrived inside the debounce window, in arrival order. */
2735
- events: IWatchEvent[];
2736
- /** Convenience: deduplicated absolute paths across the batch. */
2737
- paths: string[];
2738
- }
2739
- interface IFsWatcher {
2740
- /** Resolves once chokidar has finished its initial directory scan and is ready to emit. */
2741
- ready: Promise<void>;
2742
- /** Tear down the watcher. Resolves after chokidar releases handles. */
2743
- close: () => Promise<void>;
2744
- }
2745
- interface ICreateFsWatcherOptions {
2746
- /** Roots to watch. Resolved relative to `cwd` if relative paths are passed. */
2747
- roots: string[];
2748
- /** Working directory used to resolve relative roots and the ignore-filter root. */
2749
- cwd: string;
2750
- /** Debounce window in milliseconds. `0` triggers `onBatch` synchronously per event. */
2751
- debounceMs: number;
2389
+ interface IHookContext {
2390
+ /** The raw event the dispatcher matched. */
2391
+ event: {
2392
+ type: THookTrigger;
2393
+ timestamp: string;
2394
+ runId?: string;
2395
+ jobId?: string;
2396
+ data?: unknown;
2397
+ };
2752
2398
  /**
2753
- * Optional ignore filter, same instance the scan walker uses.
2754
- *
2755
- * Two shapes are accepted:
2756
- *
2757
- * - **`IIgnoreFilter`** (the static one), captured by reference at
2758
- * construction. Use this when the filter never changes for the
2759
- * lifetime of the watcher (the typical CLI `sm watch` flow).
2760
- *
2761
- * - **`() => IIgnoreFilter | undefined`** (a getter), re-evaluated
2762
- * on EVERY chokidar `ignored` predicate call. Use this when the
2763
- * filter can change at runtime, e.g. the BFF rebuilds it after
2764
- * a `.skillmapignore` or `.skill-map/settings.json` edit and
2765
- * wants chokidar to immediately respect the new patterns without
2766
- * tearing down and rebuilding the watcher. A getter that returns
2767
- * `undefined` disables ignore filtering for that call.
2399
+ * Convenience extraction of the node payload when the event is
2400
+ * node-scoped (`action.completed`). Undefined for run-scoped or
2401
+ * scan-scoped events.
2768
2402
  */
2769
- ignoreFilter?: IIgnoreFilter | (() => IIgnoreFilter | undefined) | undefined;
2403
+ node?: Node;
2770
2404
  /**
2771
- * Maximum directory traversal depth. `undefined` (default) walks the
2772
- * tree recursively without bound; `0` limits the watch to the
2773
- * literal `roots` entries (no descent), which is the right setting
2774
- * when watching a directory only to catch changes to specific
2775
- * top-level files (see `subscribeMeta` in `core/watcher/runtime.ts`).
2776
- * Forwarded verbatim to chokidar's `depth` option.
2405
+ * Set on `extractor.completed` events. Qualified extension id of the
2406
+ * Extractor whose work the event aggregates.
2777
2407
  */
2778
- depth?: number;
2779
- /** Called once per debounced batch. Awaited; concurrent batches are serialised. */
2780
- onBatch: (batch: IWatchBatch) => void | Promise<void>;
2408
+ extractorId?: string;
2781
2409
  /**
2782
- * Called when the underlying watcher surfaces an error. The watcher
2783
- * stays open, callers decide whether to log, keep going, or close.
2410
+ * Set on `analyzer.completed` events. Qualified extension id of the Rule.
2784
2411
  */
2785
- onError?: (err: Error) => void;
2412
+ analyzerId?: string;
2413
+ /**
2414
+ * Set on `action.completed` events. Qualified extension id of the
2415
+ * Action that just ran.
2416
+ */
2417
+ actionId?: string;
2418
+ /**
2419
+ * Set on `job.*` events once the job subsystem lands. Carries the
2420
+ * report payload for `job.completed`, the failure record for
2421
+ * `job.failed`, and the spawn metadata for `job.spawning`.
2422
+ */
2423
+ jobResult?: unknown;
2424
+ /**
2425
+ * `RunnerPort` injection for `probabilistic` hooks. `undefined` for
2426
+ * `deterministic` mode (the default). Probabilistic hooks land with
2427
+ * the job subsystem; the field is reserved here so the runtime
2428
+ * contract is forward-compatible without a major bump.
2429
+ */
2430
+ runner?: unknown;
2786
2431
  }
2787
2432
  /**
2788
- * Construct a chokidar-backed watcher. Subscribes immediately; the
2789
- * returned `ready` promise resolves once chokidar's initial directory
2790
- * walk completes, at which point only NEW events fire `onBatch`.
2433
+ * Optional declarative filter applied by the dispatcher BEFORE
2434
+ * invoking `on(ctx)`. Keys are payload field paths (top-level only in
2435
+ * v0.x); values are the literal expected match. The dispatcher walks
2436
+ * `event.data` for the field and short-circuits the invocation if the
2437
+ * value disagrees.
2791
2438
  *
2792
- * The initial directory walk is deliberately silent, we set
2793
- * `ignoreInitial: true`. The CLI runs a one-shot scan before flipping
2794
- * the watcher on, so re-emitting an `add` for every existing file
2795
- * would be redundant churn.
2439
+ * Cross-field validation against declared `triggers` is best-effort
2440
+ * at load time: when none of the declared triggers carries a given
2441
+ * filter field, the loader surfaces `invalid-manifest`. The current
2442
+ * impl performs the basic enum check but defers full payload-shape
2443
+ * cross-validation to a follow-up, the dispatcher is permissive at
2444
+ * runtime (an unknown field never matches → the hook simply never
2445
+ * fires for that event, which is a correct interpretation of "filter
2446
+ * by a field that doesn't exist").
2796
2447
  */
2797
- declare function createChokidarWatcher(opts: ICreateFsWatcherOptions): IFsWatcher;
2448
+ type THookFilter = Record<string, string | number | boolean>;
2449
+ interface IHook extends IExtensionBase {
2450
+ kind: 'hook';
2451
+ /**
2452
+ * Execution mode. Optional in the manifest with a default of
2453
+ * `deterministic` per `spec/schemas/extensions/hook.schema.json`.
2454
+ * Probabilistic hooks load but skip dispatch with a stderr advisory
2455
+ * until the job subsystem ships (Decision #114).
2456
+ */
2457
+ mode?: TExecutionMode;
2458
+ /**
2459
+ * Subset of the curated lifecycle trigger set this hook subscribes
2460
+ * to. MUST be non-empty; every entry MUST be a member of
2461
+ * `HOOK_TRIGGERS`. The loader validates both invariants and surfaces
2462
+ * `invalid-manifest` on violation.
2463
+ */
2464
+ triggers: THookTrigger[];
2465
+ /**
2466
+ * Optional declarative filter. Absent → invoke on every dispatched
2467
+ * event of every declared trigger.
2468
+ */
2469
+ filter?: THookFilter;
2470
+ /**
2471
+ * Hook entry point. Returns nothing; reactions are side effects.
2472
+ * Errors are caught by the dispatcher (logged as `extension.error`,
2473
+ * surfaced via `hook.failed` meta-event) and NEVER block the main
2474
+ * pipeline, a buggy hook degrades gracefully.
2475
+ */
2476
+ on(ctx: IHookContext): void | Promise<void>;
2477
+ }
2798
2478
 
2799
2479
  /**
2800
- * Scan delta, pure comparison of two `ScanResult` snapshots. Drives
2801
- * `sm scan --compare-with <path>` and is the single place the kernel
2802
- * knows how to identify "the same" entity across two scans.
2803
- *
2804
- * **Identity contract** (mirrors decisions made at earlier sub-steps):
2805
- *
2806
- * - **Node**: `node.path`. The path is the only field stable across
2807
- * edits, every other Node field is content-derived (hashes, counts,
2808
- * denormalised frontmatter). Two nodes with the same path are the
2809
- * "same" node; differences are reported as a `changed` entry with
2810
- * a reason narrowing what diverged.
2811
- *
2812
- * - **Link**: `(source, target, kind, normalizedTrigger ?? '')`. This
2813
- * mirrors the link-conflict rule and `sm show` aggregation,
2814
- * two links with identical endpoints, kind, and (optional) trigger
2815
- * are the same link, even if emitted by different extractors. The
2816
- * `sources[]` union and confidence are NOT part of identity; they
2817
- * are presentation facets that can churn without making the link
2818
- * "different" for delta purposes.
2819
- *
2820
- * - **Issue**: `(analyzerId, sorted nodeIds, message)`. Mirrors
2821
- * `spec/job-events.md` §issue.*, same key → same issue, even when
2822
- * `data` / `severity` / `linkIndices` shift. A meaningful change in
2823
- * `message` (or a different set of node ids) is a different issue.
2824
- * This is the same key future job events will use; keep it aligned
2825
- * so consumers can reuse logic.
2826
- *
2827
- * No "changed" bucket for links / issues, identity already captures
2828
- * everything that matters there. Nodes get a "changed" bucket because
2829
- * the path stays stable while the body / frontmatter rewrite, and that
2830
- * change is meaningful (formatters, summarisers, downstream consumers
2831
- * all care about it).
2480
+ * `ProgressEmitterPort`, emits progress events during long operations.
2832
2481
  *
2833
- * Pure: no IO, no DB, no FS. Safe to run in-memory inside `sm scan`
2834
- * without polluting the persisted snapshot.
2482
+ * Shape-only today. The full event catalog (`run.started`,
2483
+ * `job.claimed`, `model.delta`, etc.) is normative in
2484
+ * `spec/job-events.md`; this port carries an open `data` payload so
2485
+ * adapters can emit any documented event without type churn.
2835
2486
  */
2836
-
2837
- type TNodeChangeReason = 'body' | 'frontmatter' | 'both';
2838
- interface INodeChange {
2839
- before: Node;
2840
- after: Node;
2841
- /**
2842
- * Which hash diverged. `'body'` means body rewritten, frontmatter
2843
- * untouched; `'frontmatter'` means metadata rewritten, body
2844
- * untouched; `'both'` means both rewritten in the same edit.
2845
- */
2846
- reason: TNodeChangeReason;
2487
+ interface ProgressEvent {
2488
+ type: string;
2489
+ timestamp: string;
2490
+ runId?: string;
2491
+ jobId?: string;
2492
+ data?: unknown;
2847
2493
  }
2848
- interface IScanDelta {
2849
- /** Path the current scan was compared against (echoed for the report header). */
2850
- comparedWith: string;
2851
- nodes: {
2852
- added: Node[];
2853
- removed: Node[];
2854
- changed: INodeChange[];
2855
- };
2856
- links: {
2857
- added: Link[];
2858
- removed: Link[];
2859
- };
2860
- issues: {
2861
- added: Issue[];
2862
- removed: Issue[];
2863
- };
2494
+ type TProgressListener = (event: ProgressEvent) => void;
2495
+ interface ProgressEmitterPort {
2496
+ emit(event: ProgressEvent): void;
2497
+ subscribe(listener: TProgressListener): () => void;
2864
2498
  }
2865
- declare function computeScanDelta(prior: ScanResult, current: ScanResult, comparedWith: string): IScanDelta;
2499
+
2866
2500
  /**
2867
- * `true` iff every bucket is empty. Callers use this to decide the
2868
- * exit code (`0` clean, `1` non-empty delta).
2501
+ * Per-node extractor invocation: build a fresh `IExtractorContext` for
2502
+ * each extractor, validate every emitted link / contribution against
2503
+ * the declared catalog, fold enrichment partials into per-`(node,
2504
+ * extractor)` records, and surface emit-time drops as
2505
+ * `extension.error` events.
2506
+ *
2507
+ * Also hosts the post-walk recompute helpers that re-derive
2508
+ * `linksOutCount` / `linksInCount` / `externalRefsCount` on every node
2509
+ * from the final merged link buffer, plus the `IExtractorRunRecord`
2510
+ * and `IEnrichmentRecord` types those records eventually persist as.
2869
2511
  */
2870
- declare function isEmptyDelta(delta: IScanDelta): boolean;
2871
2512
 
2872
2513
  /**
2873
- * Export query, minimal filter language for `sm export <query>` (Step 8.3).
2874
- *
2875
- * Spec contract: `spec/cli-contract.md` line 190 says "Query syntax is
2876
- * implementation-defined pre-1.0". This module defines the v0.5.0 syntax.
2877
- *
2878
- * **Grammar** (BNF-ish, intentionally tiny):
2879
- *
2880
- * query := token (WS+ token)*
2881
- * token := key "=" value-list
2882
- * key := "kind" | "has" | "path"
2883
- * value-list := value ("," value)*
2884
- * value := non-comma, non-whitespace string
2514
+ * Spec § A.9, runs to persist into `scan_extractor_runs`. One entry
2515
+ * per `(nodePath, qualifiedExtractorId)` pair the orchestrator decided
2516
+ * "this extractor is current for this body". Includes both freshly-run
2517
+ * pairs (extractor invoked this scan) and reused pairs (cached node, the
2518
+ * extractor's prior run still applies to the same body hash). Excludes
2519
+ * obsolete pairs, extractors that ran in the prior but are no longer
2520
+ * registered, so a replace-all persist drops them automatically.
2521
+ */
2522
+ interface IExtractorRunRecord {
2523
+ nodePath: string;
2524
+ extractorId: string;
2525
+ bodyHashAtRun: string;
2526
+ ranAt: number;
2527
+ /**
2528
+ * sha256 of the canonical-form sidecar annotations the Extractor saw
2529
+ * at run time. Always populated (an absent sidecar canonicalises to
2530
+ * `{}` so the hash is stable). Used unconditionally by the cache
2531
+ * decision alongside `bodyHashAtRun`: a sidecar-only edit invalidates
2532
+ * the cached run for every applicable Extractor on that node.
2533
+ */
2534
+ sidecarAnnotationsHashAtRun: string;
2535
+ }
2536
+ /**
2537
+ * Spec § A.8, universal enrichment layer.
2885
2538
  *
2886
- * Tokens AND together; values within one token OR. An empty / whitespace-only
2887
- * query is valid and matches every node ("export everything").
2539
+ * One entry per `(nodePath, qualifiedExtractorId)` pair an Extractor
2540
+ * produced via `ctx.enrichNode(...)` during the walk. Attribution is
2541
+ * preserved per-Extractor (rather than merged client-side as B.1 did)
2542
+ * so the persistence layer can:
2888
2543
  *
2889
- * **Filters**:
2544
+ * - upsert a single row per pair (stable PRIMARY KEY conflict on
2545
+ * re-extract);
2546
+ * - feed `mergeNodeWithEnrichments` with `enrichedAt`-sorted partials
2547
+ * for last-write-wins per field at read time.
2890
2548
  *
2891
- * - `kind=skill` / `kind=skill,agent`, node kind whitelist.
2892
- * - `has=issues`, node must appear in some issue's `nodeIds`. (Future
2893
- * expansion: `has=findings` / `has=summary` once Step 10 / 11 land.
2894
- * Unknown values are a parse error today; we'll ratchet up the
2895
- * accepted set additively.)
2896
- * - `path=foo/*` / `path=.claude/agents/**`, POSIX glob over `node.path`.
2897
- * Supports `*` (any chars except `/`) and `**` (any chars including `/`).
2549
+ * `value` is the cumulative merge across every `enrichNode` call that
2550
+ * Extractor made for this node within this scan, multiple
2551
+ * `ctx.enrichNode({...})` calls inside one `extract(ctx)` invocation
2552
+ * fold into a single row, but two different Extractors hitting the
2553
+ * same node yield two distinct rows.
2898
2554
  *
2899
- * **Subset semantics** (`applyExportQuery`):
2555
+ * `isProbabilistic` is reserved: Extractors are deterministic-only, so
2556
+ * every record produced by the orchestrator sets it to `false`. The
2557
+ * field is kept on the record (and the row in `node_enrichments`) so a
2558
+ * future Action-issued enrichment can populate it without reshaping
2559
+ * the persistence contract, see spec `architecture.md`
2560
+ * §Extractor · enrichment layer.
2561
+ */
2562
+ interface IEnrichmentRecord {
2563
+ nodePath: string;
2564
+ extractorId: string;
2565
+ bodyHashAtEnrichment: string;
2566
+ value: Partial<Node>;
2567
+ enrichedAt: number;
2568
+ isProbabilistic: boolean;
2569
+ }
2570
+ /**
2571
+ * Run a set of extractors against a single node, collecting their link
2572
+ * emissions and node-enrichment partials. Each extractor is invoked
2573
+ * exactly once with a fresh `IExtractorContext`. Caller decides what
2574
+ * to do with the returned arrays (push into per-scan buffers, write to
2575
+ * a focused refresh result, etc.).
2900
2576
  *
2901
- * - Nodes pass when every specified filter matches (AND across keys,
2902
- * OR within values).
2903
- * - Links survive only when BOTH endpoints (`source` + `target`) belong
2904
- * to the filtered node set. A subset that includes "edges out to
2905
- * unfiltered nodes" would be confusing, the user asked for a focused
2906
- * subgraph, not its boundary. External-URL pseudo-links are already
2907
- * stripped by the orchestrator and never reach this layer.
2908
- * - Issues survive when ANY of the issue's `nodeIds` is in the filtered
2909
- * set. Issues span multiple nodes (e.g. `trigger-collision` over two
2910
- * advertisers); dropping an issue when one of its nodes is outside
2911
- * would hide cross-cutting problems the user is investigating.
2577
+ * Exported so `cli/commands/refresh.ts` can reuse the same wiring it
2578
+ * needs for re-running a single extractor against a single node, the
2579
+ * pre-extraction code in `refresh.ts` was hand-duplicating this loop
2580
+ * (audit item V4).
2912
2581
  *
2913
- * Pure: no IO, no DB, no FS.
2582
+ * Within this call, multiple `enrichNode(partial)` calls from the same
2583
+ * extractor against the same node fold into one record (last-write-wins
2584
+ * per field), same contract as the in-scan path.
2914
2585
  */
2915
-
2916
- interface IExportQuery {
2917
- /** Original query string echoed back so consumers can render the header. */
2918
- raw: string;
2586
+ declare function runExtractorsForNode(opts: {
2587
+ extractors: IExtractor[];
2588
+ node: Node;
2589
+ body: string;
2590
+ frontmatter: Record<string, unknown>;
2591
+ bodyHash: string;
2592
+ emitter: ProgressEmitterPort;
2919
2593
  /**
2920
- * Whitelist of node kinds (`node.kind` is open string, built-in
2921
- * Claude catalog `skill` / `agent` / `command` / `hook` / `note`,
2922
- * plus whatever external Providers declare). The query parser does
2923
- * not validate values against a closed enum; an unknown kind simply
2924
- * yields zero matches at filter time.
2594
+ * Spec § A.12, per-plugin `ctx.store` wrappers keyed by `pluginId`.
2595
+ * The map's lookup is per-extractor inside the loop, so callers that
2596
+ * don't track plugin storage can omit it; the resulting `ctx.store`
2597
+ * stays `undefined` (the existing contract).
2925
2598
  */
2926
- kinds?: string[];
2927
- hasIssues?: boolean;
2928
- pathGlobs?: string[];
2929
- }
2930
- interface IExportSubset {
2931
- query: IExportQuery;
2932
- nodes: Node[];
2933
- links: Link[];
2934
- issues: Issue[];
2935
- }
2936
- declare class ExportQueryError extends Error {
2937
- constructor(message: string);
2938
- }
2939
- declare function parseExportQuery(raw: string): IExportQuery;
2940
- declare function applyExportQuery(scan: {
2941
- nodes: Node[];
2942
- links: Link[];
2943
- issues: Issue[];
2944
- }, query: IExportQuery): IExportSubset;
2599
+ pluginStores?: ReadonlyMap<string, IPluginStore>;
2600
+ }): Promise<{
2601
+ internalLinks: Link[];
2602
+ externalLinks: Link[];
2603
+ enrichments: IEnrichmentRecord[];
2604
+ contributions: IContributionRecord[];
2605
+ }>;
2945
2606
 
2946
2607
  /**
2947
- * `scan_node_tags` adapter, tags · dual-source persistence layer.
2948
- *
2949
- * One row per `(node_path, tag, source)` triple. Projected at persist
2950
- * time from BOTH `frontmatter.tags` (with `source='author'`) and
2951
- * `sidecar.annotations.tags` (with `source='user'`). The same tag
2952
- * string MAY appear under both sources for the same node, the PK
2953
- * accepts the pair; search returns the node once via DISTINCT, the
2954
- * UI renders both chips with their attribution.
2955
- *
2956
- * Belongs to the `scan_*` family, replaced wholesale per scan.
2957
- * Cached nodes' tag rows are projected from the cached
2958
- * `node.frontmatter.tags` / `node.sidecar.annotations.tags` (both
2959
- * already in memory at persist time), so the rebuild is cheap
2960
- * regardless of cache hit / miss. See `spec/db-schema.md`
2961
- * § scan_node_tags for the normative shape and replace-all semantics.
2608
+ * Rename + orphan classification per `spec/db-schema.md` §Rename
2609
+ * detection. Pure: takes the prior `ScanResult` and the current node
2610
+ * set, mutates the supplied `issues` array in place, and returns the
2611
+ * `RenameOp[]` the persistence layer must apply inside the same tx as
2612
+ * the scan zone replace-all.
2962
2613
  */
2963
2614
 
2964
2615
  /**
2965
- * In-memory tag record buffered during scan and flushed to
2966
- * `scan_node_tags` by `persistScanResult`. One entry per
2967
- * `(node_path, tag, source)` projected from a node's frontmatter tags
2968
- * (`source: 'author'`) or sidecar annotations tags
2969
- * (`source: 'user'`).
2616
+ * Confidence-tagged plan to repoint `state_*` references from one node
2617
+ * path to another. Emitted by the rename heuristic during `runScan` and
2618
+ * consumed by `persistScanResult` so the FK migration runs inside the
2619
+ * same transaction as the scan zone replace-all.
2970
2620
  */
2971
- interface ITagRecord {
2972
- nodePath: string;
2973
- tag: string;
2974
- source: 'author' | 'user';
2621
+ interface RenameOp {
2622
+ from: string;
2623
+ to: string;
2624
+ confidence: 'high' | 'medium';
2975
2625
  }
2976
-
2977
2626
  /**
2978
- * Pure helpers for the "update available" notification feature.
2627
+ * Pure rename / orphan classification per `spec/db-schema.md` §Rename
2628
+ * detection. Mutates `issues` in place, caller passes the in-progress
2629
+ * issue list; returns the `RenameOp[]` for the persistence layer to
2630
+ * apply inside its tx.
2979
2631
  *
2980
- * Three responsibilities:
2981
- * - `fetchLatestVersion` , query `https://registry.npmjs.org/<pkg>/latest`
2982
- * with `AbortController` + timeout. Throws on
2983
- * non-200 / parse failure / abort.
2984
- * - `compareVersions` , semver compare (-1 / 0 / 1). Pre-1.0 aware:
2985
- * treats prereleases via the standard rules
2986
- * (release > prerelease at the same triple).
2987
- * - `isOutdated` , sugar over `compareVersions` for the common
2988
- * "is `latest` strictly greater than `current`"
2989
- * check the banner runs against.
2632
+ * Pipeline (1-to-1: a `newPath` claimed by one stage cannot be reused
2633
+ * by another):
2990
2634
  *
2991
- * Pure kernel module, NO `process.env` reads, NO Node globals beyond the
2992
- * built-in `fetch` / `AbortController` (Node 22+). Every env / settings
2993
- * lookup happens in `src/cli/util/update-check-banner.ts`, the CLI-side
2994
- * adapter that owns side effects.
2635
+ * 1. **High-confidence**: pair each `deletedPath` with a `newPath`
2636
+ * that has the same `bodyHash`. No issue, no prompt.
2637
+ * 2. **Medium-confidence (1:1)**: of the remaining deletions, pair
2638
+ * each with the *unique* unclaimed `newPath` that shares its
2639
+ * `frontmatterHash`. Emits `auto-rename-medium` (severity warn)
2640
+ * with `data: { from, to, confidence: 'medium' }`.
2641
+ * 3. **Ambiguous (N:1)**: when a single `newPath` has more than one
2642
+ * remaining frontmatter-matching candidate, emit ONE
2643
+ * `auto-rename-ambiguous` issue per `newPath`, listing all
2644
+ * candidates in `data.candidates`. NO migration.
2645
+ * 4. **Orphan**: every `deletedPath` left after steps 1-3 yields one
2646
+ * `orphan` issue (severity info) with `data: { path: <deletedPath> }`.
2995
2647
  *
2996
- * The shared cache type (`IUpdateCheckCache`) is used by the storage
2997
- * helpers under `kernel/storage/update-check.ts` and by the BFF's
2998
- * `GET /api/update-status` projection. A second type
2999
- * (`IUpdateStatus`) shapes the BFF response, it merges `current`
3000
- * (from `VERSION`) into the cache so the UI can render without a
3001
- * second lookup. Both stay flat, no nested objects, so JSON
3002
- * serialization is trivial.
2648
+ * Determinism: `deletedPaths` and `newPaths` are iterated in lex-asc
2649
+ * order so the same input always produces the same matches,
2650
+ * required for reproducible tests and conformance fixtures (the spec
2651
+ * does not prescribe an order, but stability is the obvious contract).
3003
2652
  */
3004
- interface IUpdateCheckCache {
3005
- latestVersion: string;
3006
- /** Epoch ms, when the registry was last successfully probed. */
3007
- checkedAt: number;
3008
- /** Epoch ms, when the banner was last printed; null = never shown yet. */
3009
- shownAt: number | null;
3010
- }
2653
+ declare function detectRenamesAndOrphans(prior: ScanResult, current: Node[], issues: Issue[]): RenameOp[];
3011
2654
 
3012
2655
  /**
3013
- * `PluginLoaderPort`, discovers plugin directories and loads their
3014
- * extensions. The shape mirrors what the concrete loader actually
3015
- * exposes (see `kernel/adapters/plugin-loader.ts`); the port exists so
3016
- * the CLI consumes the abstract contract via `createPluginLoader(...)`
3017
- * instead of `new PluginLoader(...)` and so the concrete adapter is
3018
- * structurally pinned to the port (`implements PluginLoaderPort` makes
3019
- * any drift a compile error).
2656
+ * Scan orchestrator, runs the Provider extractor → analyzer pipeline across
2657
+ * every registered extension and emits `ProgressEmitterPort` events in
2658
+ * canonical order. The callable extension set is injected via
2659
+ * `RunScanOptions.extensions`, the Registry holds manifest metadata, the
2660
+ * callable set holds the runtime instances the orchestrator actually
2661
+ * invokes. Separating the two lets `sm plugins` and `sm help` introspect
2662
+ * the graph without loading code.
3020
2663
  *
3021
- * Domain types (`IPluginManifest`, `ILoadedExtension`, `IDiscoveredPlugin`,
3022
- * `TPluginStorage`, `TPluginLoadStatus`, `TGranularity`) live in
3023
- * `kernel/types/plugin.ts` because they are spec-mirroring DTOs, not
3024
- * port-shape types. The port re-exports them for callers that import
3025
- * from the ports barrel.
2664
+ * With zero registered extensions (or a callable set that carries none)
2665
+ * the pipeline still produces a valid zero-filled `ScanResult`, the
2666
+ * kernel-empty-boot invariant.
2667
+ *
2668
+ * Roots are validated up front: each entry of `RunScanOptions.roots`
2669
+ * must exist on disk as a directory. The first failure throws a clear
2670
+ * `Error` naming the offending path. This guards every caller (CLI,
2671
+ * server, skill-agent) against silently producing a zero-filled
2672
+ * `ScanResult` when a Provider walks a non-existent path, the bug
2673
+ * that wiped a populated DB via `sm scan -- --dry-run` (clipanion's
2674
+ * `--` made `--dry-run` a positional root that did not exist).
2675
+ *
2676
+ * Incremental scans: when `priorSnapshot` is supplied, the
2677
+ * orchestrator walks the filesystem, hashes each file, and reuses the
2678
+ * prior node + its prior-extracted internal links whenever both
2679
+ * `bodyHash` and `frontmatterHash` match. New / modified files run
2680
+ * through the full extractor pipeline (including the external-url-counter
2681
+ * which produces ephemeral pseudo-links). Rules ALWAYS run over the
2682
+ * fully merged graph, issue state can change even for an unchanged node
2683
+ * (e.g. a previously broken `references` link now resolves because a new
2684
+ * node was added). For unchanged nodes the prior `externalRefsCount` is
2685
+ * preserved as-is (the external pseudo-links were never persisted, so
2686
+ * they cannot be reconstructed; the count survived in the node row).
2687
+ *
2688
+ * Extractor output model (B.1, post-rename from Detector): extractors
2689
+ * return `void` and emit through three callbacks injected on the context:
2690
+ * - `ctx.emitLink(link)` → orchestrator validates against
2691
+ * `emitsLinkKinds` then partitions into internal / external buckets.
2692
+ * - `ctx.enrichNode(partial)` → orchestrator records ONE enrichment
2693
+ * entry per `(node, extractor)` so attribution survives into the DB.
2694
+ * Persisted into `node_enrichments` (A.8). The author-supplied
2695
+ * frontmatter on `node.frontmatter` stays immutable from any Extractor
2696
+ * , the enrichment layer is the only writable surface, and rules /
2697
+ * formatters consume it via `mergeNodeWithEnrichments`.
2698
+ * - `ctx.store` → plugin's own KV / dedicated tables (spec § A.12).
2699
+ * Wired by the driving adapter via `RunScanOptions.pluginStores`,
2700
+ * which the orchestrator looks up per-extractor by `pluginId` and
2701
+ * attaches to the context. The orchestrator never inspects what
2702
+ * plugins write through it; the wrapper handles AJV validation
2703
+ * when the manifest declared an output schema.
3026
2704
  */
3027
2705
 
3028
- interface PluginLoaderPort {
2706
+ interface IScanExtensions {
2707
+ providers: IProvider[];
2708
+ extractors: IExtractor[];
2709
+ analyzers: IAnalyzer[];
3029
2710
  /**
3030
- * Synchronously enumerate every directory containing a `plugin.json`
3031
- * across the configured search paths. Non-existent paths are skipped.
2711
+ * Optional hooks (spec § A.11). When supplied, the orchestrator's
2712
+ * lifecycle dispatcher invokes deterministic hooks subscribed to one
2713
+ * of the eight hookable triggers in canonical order with the matching
2714
+ * event payload. Absent → no hooks fire (the scan still emits its
2715
+ * lifecycle events to `ProgressEmitterPort` for observability).
2716
+ * Probabilistic hooks are loaded but skipped here with a stderr
2717
+ * advisory until the job subsystem ships once the job subsystem ships.
3032
2718
  */
3033
- discoverPaths(): string[];
2719
+ hooks?: IHook[];
2720
+ }
2721
+ interface RunScanOptions {
3034
2722
  /**
3035
- * Discover every plugin, attempt to load each, then apply the
3036
- * cross-root id-collision pass. Never throws, failures are reported
3037
- * via `IDiscoveredPlugin.status`.
2723
+ * Filesystem roots to walk. Spec requires `minItems: 1`; passing an
2724
+ * empty array makes `runScan` throw before any work happens.
3038
2725
  */
3039
- discoverAndLoadAll(): Promise<IDiscoveredPlugin[]>;
2726
+ roots: string[];
2727
+ emitter?: ProgressEmitterPort;
2728
+ /** Runtime extension instances. Absent → empty pipeline. */
2729
+ extensions?: IScanExtensions;
3040
2730
  /**
3041
- * Load a single plugin from its directory. Never throws, failure is
3042
- * reported via the returned `status`.
2731
+ * Step 9.6.6, runtime catalog of plugin-contributed annotation keys
2732
+ * (the same shape `kernel.getRegisteredAnnotationKeys()` returns).
2733
+ * Threaded into the rule pass so `core/unknown-field` can
2734
+ * legitimise registered plugin namespaces / root keys without
2735
+ * re-walking the manifests. Absent → empty catalog (every plugin
2736
+ * key is treated as unknown). Built-in catalog from
2737
+ * `annotations.schema.json` is NOT included, that is hard-coded
2738
+ * inside the rule.
3043
2739
  */
3044
- loadOne(pluginPath: string): Promise<IDiscoveredPlugin>;
3045
- }
3046
-
3047
- /**
3048
- * Row-level filter for `port.scans.findNodes(...)` (driven by
3049
- * `sm list`'s flags). All fields are optional, an empty filter
3050
- * returns every node sorted by `path` asc.
3051
- */
3052
- interface INodeFilter {
3053
- /** Restrict to a single node kind. Open string (matches `Node.kind`). */
3054
- kind?: string;
2740
+ annotationContributions?: readonly IRegisteredAnnotationKey[];
3055
2741
  /**
3056
- * When `true`, keep only nodes whose path is referenced by at least
3057
- * one `scan_issues.nodeIds` array.
2742
+ * Runtime catalog of plugin-contributed view contributions (the same
2743
+ * shape `kernel.getRegisteredViewContributions()` returns). Threaded
2744
+ * into the rule pass so:
2745
+ * - `core/contribution-orphan` can introspect the catalog
2746
+ * (read-only) and join it with the live node set to flag
2747
+ * dangling emissions. Slot catalog drift is NOT a scan concern,
2748
+ * it lives at load time and surfaces via `sm plugins doctor`
2749
+ * (the kernel rejects unknown slots as `invalid-manifest` first,
2750
+ * doctor catches the catalog-version-skew tail).
2751
+ * - The orchestrator's per-rule emit closure can look up each
2752
+ * declared `(contributionId → slot)` pairing for AJV
2753
+ * payload validation.
2754
+ * Absent → empty catalog. Rules that emit contributions silently
2755
+ * drop emissions when the catalog has no entry for the rule's
2756
+ * declared contributionId.
3058
2757
  */
3059
- hasIssues?: boolean;
2758
+ viewContributions?: readonly IRegisteredViewContribution[];
3060
2759
  /**
3061
- * Sort column. The adapter validates against its own whitelist and
3062
- * rejects anything else with an Error (the CLI's own usage-error
3063
- * exit is the right place to surface a bad `--sort-by`; the port
3064
- * defends in depth).
2760
+ * Scan scope. Defaults to `'project'`. The CLI flag wiring lands in
2761
+ * the config layer wiring; `runScan` already accepts the override
2762
+ * so plugins / tests can opt into `'global'` today.
3065
2763
  */
3066
- sortBy?: string;
3067
- /** `'asc'` or `'desc'`. Defaults to the adapter's per-column convention. */
3068
- sortDirection?: 'asc' | 'desc';
3069
- /** Cap the result. Positive integer; absent → no limit. */
3070
- limit?: number;
3071
- }
3072
- /**
3073
- * Bundled fetch for `port.scans.findNode(path)`, one node and
3074
- * everything `sm show <path>` displays alongside it. Every field is
3075
- * computed from `scan_*` zone reads only; per-domain data (history,
3076
- * jobs, plugin enrichments) ships through other namespaces.
3077
- */
3078
- interface INodeBundle {
3079
- node: Node;
3080
- linksOut: Link[];
3081
- linksIn: Link[];
3082
- issues: Issue[];
3083
- }
3084
- /**
3085
- * Output of `port.scans.countRows()`. Used by `sm scan` to decide
3086
- * whether the persist would wipe a populated DB (the "refusing to
3087
- * wipe" guard) and by `sm db status` for the human summary.
3088
- */
3089
- interface INodeCounts {
3090
- nodes: number;
3091
- links: number;
3092
- issues: number;
3093
- }
3094
- /**
3095
- * Lightweight option bag for `port.scans.persist`. Mirrors the trailing
3096
- * arguments of the legacy `persistScanResult(db, result, renameOps,
3097
- * extractorRuns, enrichments)` free function so the adapter
3098
- * implementation is a one-line delegation today; the named-bag shape
3099
- * tomorrow lets new optional inputs land without breaking callers.
3100
- */
3101
- interface IPersistOptions {
3102
- renameOps?: RenameOp[];
3103
- extractorRuns?: IExtractorRunRecord[];
3104
- enrichments?: IEnrichmentRecord[];
3105
- contributions?: IContributionRecord[];
2764
+ scope?: 'project' | 'global';
3106
2765
  /**
3107
- * Phase 3 / View contribution system, active runtime catalog of
3108
- * registered view contributions, keyed by qualified id
3109
- * `<pluginId>/<extensionId>/<contributionId>`. Passed to the
3110
- * `scan_contributions` upsert so the catalog sweep can drop rows
3111
- * belonging to plugins / extensions that are no longer in the
3112
- * catalog (uninstalled plugins, disabled bundles, removed
3113
- * contributions). Empty / absent set = no catalog sweep (legacy
3114
- * behaviour, leaves disabled-plugin rows stale per design F24
3115
- * pre-fix).
2766
+ * Compute per-node token counts (frontmatter / body / total) using the
2767
+ * cl100k_base BPE (the modern OpenAI tokenizer used by GPT-4 / GPT-3.5).
2768
+ * Defaults to true. Set false to skip tokenization; `node.tokens` is
2769
+ * left undefined (spec-valid: the field is optional).
3116
2770
  */
3117
- registeredContributionKeys?: ReadonlySet<string>;
2771
+ tokenize?: boolean;
3118
2772
  /**
3119
- * Phase 3 / View contribution system, set of `(plugin, extension,
3120
- * node)` tuples where the extension actually RAN against that node
3121
- * in this scan. Format: `<pluginId>/<extensionId>/<nodePath>` (no
3122
- * contribution-id segment, the sweep operates at the (plugin,
3123
- * extension, node) level and inspects the buffer to decide which
3124
- * contribution-ids survive).
2773
+ * Prior snapshot for two purposes (decoupled by design):
3125
2774
  *
3126
- * Membership rules:
3127
- * - Extractor + cache miss: tuple INCLUDED (extract() ran).
3128
- * - Extractor + cache hit: tuple OMITTED (extract() skipped, prior
3129
- * rows must be preserved).
3130
- * - Rule, every node in `ctx.nodes`: tuple INCLUDED (rules always
3131
- * run and see the full graph).
2775
+ * 1. **Rename heuristic** (`spec/db-schema.md` §Rename detection):
2776
+ * always evaluated when `priorSnapshot` is supplied. The
2777
+ * heuristic compares prior vs current node paths and emits
2778
+ * high / medium / ambiguous / orphan classifications. This
2779
+ * runs on EVERY `sm scan` (with or without `--changed`) so
2780
+ * reorganising files always preserves history, never silently.
3132
2781
  *
3133
- * Drives the per-tuple sweep documented in `spec/architecture.md`
3134
- * §View contribution system Persistence (sweep #3): rows whose
3135
- * `(plugin_id, extension_id, node_path)` is in this set but whose
3136
- * `(plugin_id, extension_id, node_path, contribution_id)` is NOT in
3137
- * the buffer get DELETEd before the upsert. Catches the "extractor
3138
- * used to emit, now does not" case (e.g. body change removes the
3139
- * trigger). Empty / absent set = no per-tuple sweep (legacy
3140
- * callers preserve the pre-fix behaviour where stale rows linger).
2782
+ * 2. **Cache reuse** (`sm scan --changed`): only kicks in when
2783
+ * `enableCache: true` is also passed. With the flag set, nodes
2784
+ * whose `path` exists in the prior with both `bodyHash` and
2785
+ * `frontmatterHash` matching the freshly-computed hashes are
2786
+ * reused as-is (their internal links and `externalRefsCount`
2787
+ * survive); only new / modified nodes run through extractors.
2788
+ * Rules always re-run over the merged graph.
2789
+ *
2790
+ * Pass `null` (or omit) for a fresh scan with no rename detection.
3141
2791
  */
3142
- freshlyRunTuples?: ReadonlySet<string>;
3143
- }
3144
- /**
3145
- * Issue row as the storage layer sees it, paired with its DB-assigned
3146
- * id so `port.issues.deleteById(id)` can target it inside a
3147
- * transaction. The runtime `Issue` shape (per `issue.schema.json`) does
3148
- * not carry `id` because the spec models issues as ephemeral findings
3149
- * scoped to a scan; the DB does need the synthetic id to update / delete
3150
- * a single row.
3151
- */
3152
- interface IIssueRow {
3153
- id: number;
3154
- issue: Issue;
3155
- }
3156
- /**
3157
- * Filter + pagination shape for `port.issues.list(...)`, driven by the
3158
- * BFF's `/api/issues` route. Every field is optional, an empty filter
3159
- * returns every issue ordered by `id` ASC (insertion order, stable
3160
- * across pages so `offset` / `limit` paging is deterministic).
3161
- *
3162
- * The three semantic filters mirror `/api/issues`'s query params:
3163
- *
3164
- * - `severities`, narrowed list of `Severity` values. Empty / absent
3165
- * matches every severity.
3166
- * - `analyzerIds`, accepts qualified (`<plugin>/<id>`) AND short
3167
- * (`<id>`) forms; the suffix-match semantics live in
3168
- * `matchesAnalyzerFilter`. Each entry generates two SQL clauses
3169
- * (`= ?` and `LIKE '%/' || ?`) ORed together so the filter remains
3170
- * a single SQL pass with parameterised values, no string
3171
- * interpolation. Empty / absent matches every analyzer id.
3172
- * - `nodePath`, keeps issues whose `nodeIds` JSON array contains the
3173
- * given path (correlated EXISTS over `json_each`). Absent / null
3174
- * skips the filter.
3175
- *
3176
- * Pagination is mandatory; the route layer fills the defaults via
3177
- * `parsePagination`. `total` in `IIssueListResult` reports the total
3178
- * MATCHING the filters (not just the page slice) so the SPA can
3179
- * surface a correct page-count without a second round-trip.
3180
- */
3181
- interface IIssueListFilter {
2792
+ priorSnapshot?: ScanResult | null;
3182
2793
  /**
3183
- * Severity tokens to match. Typed as open `string` (not the
3184
- * `Severity` union) so an unknown value from a URL query string
3185
- * surfaces as a zero-match SQL query, not a kernel validation
3186
- * error. The adapter parameterises each entry into the `IN(...)`
3187
- * clause; unrecognised severities simply match no rows.
2794
+ * Reuse unchanged nodes from `priorSnapshot` instead of re-running
2795
+ * extractors over them. Defaults to `false` so a plain `sm scan`
2796
+ * always re-walks deterministically. `sm scan --changed` flips this
2797
+ * to `true` for the perf win on unchanged files.
2798
+ *
2799
+ * Has no effect without `priorSnapshot`; setting it to `true` with
2800
+ * a null prior is a no-op (every file is "new").
3188
2801
  */
3189
- severities?: readonly string[];
3190
- analyzerIds?: readonly string[];
3191
- nodePath?: string | null;
3192
- offset: number;
3193
- limit: number;
3194
- }
3195
- /**
3196
- * Output of `port.issues.list(...)`. `items` is the page slice (length
3197
- * ≤ `filter.limit`); `total` is the count of rows matching the filters
3198
- * before pagination was applied.
3199
- */
3200
- interface IIssueListResult {
3201
- items: Issue[];
3202
- total: number;
3203
- }
3204
- /** Output of `port.jobs.pruneTerminal` / `listTerminalCandidates`. */
3205
- interface IPruneResult {
3206
- /** How many `state_jobs` rows were deleted (or would be, in dry-run). */
3207
- deletedCount: number;
3208
- /** Job-file paths from the affected rows; the CLI unlinks these from disk. `null` `filePath` rows contribute nothing here. */
3209
- filePaths: string[];
3210
- }
3211
- /** Filter shape for `port.history.list`. All fields optional. */
3212
- interface IListExecutionsFilter {
3213
- /** Restrict to executions whose `nodeIds` array contains this path. */
3214
- nodePath?: string;
3215
- /** Exact match on `extension_id`. */
3216
- actionId?: string;
3217
- /** Subset of {`completed`,`failed`,`cancelled`}. */
3218
- statuses?: ExecutionStatus[];
3219
- /** Lower bound (inclusive) on `started_at`. Unix ms. */
3220
- sinceMs?: number;
3221
- /** Upper bound (exclusive) on `started_at`. Unix ms. */
3222
- untilMs?: number;
3223
- /** Cap result count. No default. */
3224
- limit?: number;
3225
- }
3226
- /** Window shape for `port.history.aggregateStats`. */
3227
- interface IHistoryStatsRange {
3228
- /** Inclusive lower bound. `null` = all-time. */
3229
- sinceMs: number | null;
3230
- /** Exclusive upper bound. */
3231
- untilMs: number;
3232
- }
3233
- /** Period bucket granularity for `port.history.aggregateStats`. */
3234
- type THistoryStatsPeriod = 'day' | 'week' | 'month';
3235
- /**
3236
- * Output of `port.transaction(tx => tx.history.migrateNodeFks(from, to))`.
3237
- * Lists how many rows in each `state_*` table were repointed plus any
3238
- * composite-PK collisions that forced a drop instead of an update.
3239
- */
3240
- interface IMigrateNodeFksReport {
3241
- jobs: number;
3242
- executions: number;
3243
- summaries: number;
3244
- enrichments: number;
3245
- pluginKvs: number;
3246
- nodeFavorites: number;
2802
+ enableCache?: boolean;
3247
2803
  /**
3248
- * Collisions encountered when migrating any of the keyed-by-node
3249
- * `state_*` tables because a row already existed at the destination
3250
- * PK. The pre-existing rows are preserved, the migrating rows are
3251
- * dropped (deleted from `fromPath` without a corresponding INSERT).
3252
- * One entry per dropped row, with the affected PK fields included
3253
- * for diagnostic output. `state_node_favorites` has no composite key
3254
- * so its `keys` is the empty object.
2804
+ * Filter that decides which paths the Providers skip. Composed by the
2805
+ * caller (typically the CLI) from bundled defaults + `config.ignore`
2806
+ * + `.skillmapignore`. Providers that omit this option fall back to
2807
+ * their own defensive defaults (just enough to keep `.git` /
2808
+ * `node_modules` out).
3255
2809
  */
3256
- collisions: Array<{
3257
- table: 'state_summaries' | 'state_enrichments' | 'state_plugin_kvs' | 'state_node_favorites';
3258
- fromPath: string;
3259
- toPath: string;
3260
- keys: Record<string, string>;
3261
- }>;
2810
+ ignoreFilter?: IIgnoreFilter;
2811
+ /**
2812
+ * Promote frontmatter-validation findings from `warn` to `error`.
2813
+ * Defaults to false. The CLI surfaces this via `--strict` on `sm scan`
2814
+ * and the `scan.strict` config key. When false, the orchestrator
2815
+ * still emits a `frontmatter-invalid` issue per malformed file but
2816
+ * leaves the severity at `warn` so a clean scan exits 0; when true,
2817
+ * the same finding becomes `error` and the scan exits 1.
2818
+ */
2819
+ strict?: boolean;
2820
+ /**
2821
+ * Spec § A.9, fine-grained Extractor cache breadcrumbs from the
2822
+ * prior scan. Shape: `Map<nodePath, Map<qualifiedExtractorId, IPriorExtractorRun>>`.
2823
+ * Loaded from the `scan_extractor_runs` table by the CLI before
2824
+ * invoking `runScan`; absent / empty for a fresh DB or an out-of-band
2825
+ * caller that does not maintain a cache. Decoupled from `priorSnapshot`
2826
+ * because the runs live in a sibling table and are useful only when
2827
+ * `enableCache` is also set.
2828
+ *
2829
+ * Cache decision per `(node, extractor)`:
2830
+ * - body+frontmatter hashes match the prior node AND every currently-
2831
+ * registered extractor that applies to this kind has a matching
2832
+ * row → full skip, all prior outbound links reused.
2833
+ * - some applicable extractor lacks a matching row (newly registered,
2834
+ * or its prior run targeted a different body hash or sidecar
2835
+ * annotations hash) → run only the missing extractors, drop prior
2836
+ * links whose `sources` map to any missing extractor or to an
2837
+ * extractor that is no longer registered.
2838
+ */
2839
+ priorExtractorRuns?: Map<string, Map<string, IPriorExtractorRun>>;
2840
+ /**
2841
+ * Spec § A.12, per-plugin storage wrappers exposed to extractors via
2842
+ * `ctx.store`. Keyed by `pluginId`; absent / missing entry leaves
2843
+ * `ctx.store` undefined for that extractor (the existing contract).
2844
+ *
2845
+ * The kernel does not construct these, the driving adapter (CLI,
2846
+ * future server) builds them with `makePluginStore` from
2847
+ * `kernel/adapters/plugin-store.js` and threads them through. This
2848
+ * keeps the orchestrator persistence-agnostic (the wrapper supplies
2849
+ * its own persist callback) and lets tests inject a captured-call
2850
+ * mock without spinning up a DB.
2851
+ */
2852
+ pluginStores?: ReadonlyMap<string, IPluginStore>;
2853
+ /**
2854
+ * Pre-computed absolute paths of orphan job MD files (files under
2855
+ * `.skill-map/jobs/` whose absolute path appears nowhere in
2856
+ * `state_jobs.filePath`). Threaded into the rule pass so the
2857
+ * built-in `core/job-orphan-file` rule can project each as a `warn`
2858
+ * issue without the kernel reaching for the storage port or doing
2859
+ * its own FS walk. The driving adapter (CLI, BFF) computes this
2860
+ * inside its already-open storage transaction via
2861
+ * `findOrphanJobFiles(jobsDir, await port.jobs.listReferencedFilePaths())`
2862
+ * mirrors the `orphanSidecars` model where detection lives
2863
+ * outside the rule and the rule only projects. Absent / empty when
2864
+ * the caller has no jobs context (out-of-band tests, fresh DB,
2865
+ * `--no-built-ins`).
2866
+ */
2867
+ orphanJobFiles?: readonly string[];
2868
+ /**
2869
+ * Side set of absolute file paths the operator opted into for
2870
+ * link-validation purposes via `scan.referencePaths`. Threaded
2871
+ * through to `IAnalyzerContext.referenceablePaths` so the built-in
2872
+ * `core/broken-ref` rule can suppress its `warn` for path-style
2873
+ * links whose target lands in the set. Files are NOT walked by
2874
+ * the kernel, the driving adapter populates the set before
2875
+ * calling `runScan`. Absent / empty when the operator left
2876
+ * `scan.referencePaths` unconfigured.
2877
+ */
2878
+ referenceablePaths?: ReadonlySet<string>;
2879
+ /**
2880
+ * Absolute path of the scan's cwd / project root. Threaded onto
2881
+ * `IAnalyzerContext.cwd` so rules that need to resolve a relative
2882
+ * `link.target` to an absolute filesystem path can do so without
2883
+ * heuristics. Absent for callers that don't track a cwd
2884
+ * concept (out-of-band tests, embedders).
2885
+ */
2886
+ cwd?: string;
3262
2887
  }
3263
- /** A single `config_plugins` override row as the kernel sees it. */
3264
- interface IPluginConfigRow {
3265
- pluginId: string;
3266
- enabled: boolean;
3267
- configJson: string | null;
3268
- updatedAt: number;
2888
+ /**
2889
+ * Same as `runScan` but also returns the rename heuristic's `RenameOp[]`
2890
+ * the high- and medium-confidence renames the persistence layer must
2891
+ * apply to `state_*` rows inside the same tx as the scan zone replace-
2892
+ * all (per `spec/db-schema.md` §Rename detection). Most callers want
2893
+ * `runScan` (which returns just `ScanResult`); the CLI's `sm scan`
2894
+ * uses this variant so it can hand the ops off to `persistScanResult`.
2895
+ *
2896
+ * Also returns `extractorRuns`, the Spec § A.9 fine-grained cache
2897
+ * breadcrumbs the CLI persists into `scan_extractor_runs` so the next
2898
+ * incremental scan can decide per-(node, extractor) whether re-running
2899
+ * is required.
2900
+ */
2901
+ declare function runScanWithRenames(_kernel: Kernel, options: RunScanOptions): Promise<{
2902
+ result: ScanResult;
2903
+ renameOps: RenameOp[];
2904
+ extractorRuns: IExtractorRunRecord[];
2905
+ enrichments: IEnrichmentRecord[];
2906
+ contributions: IContributionRecord[];
2907
+ freshlyRunTuples: ReadonlySet<string>;
2908
+ }>;
2909
+ declare function runScan(_kernel: Kernel, options: RunScanOptions): Promise<ScanResult>;
2910
+
2911
+ /**
2912
+ * Node-construction helpers: hash a body, canonicalise frontmatter /
2913
+ * sidecar annotations, resolve the sidecar overlay for a given relative
2914
+ * path, and produce a fresh `Node` (validating its frontmatter on the
2915
+ * way out). Also hosts `mergeNodeWithEnrichments` + `IPersistedEnrichment`
2916
+ * the read-time merge of author frontmatter with the A.8 enrichment
2917
+ * layer.
2918
+ */
2919
+
2920
+ /**
2921
+ * Spec § A.8, produce the merged read-time view of a Node.
2922
+ *
2923
+ * Rules / `sm check` / `sm export` consume `node.frontmatter` directly
2924
+ * (deterministic CI-safe baseline, author intent, byte-stable). UI / future
2925
+ * rules that opt into enrichment context call this helper to merge the
2926
+ * author frontmatter with the live enrichment layer.
2927
+ *
2928
+ * Algorithm:
2929
+ *
2930
+ * 1. Filter `enrichments` down to rows targeting this node AND not
2931
+ * flagged `stale`. With Extractors deterministic-only no row is
2932
+ * stale-flagged in this revision; the filter is preserved for the
2933
+ * future Action-issued enrichment revision (queued LLM jobs whose
2934
+ * output must survive body changes), where stale visibility
2935
+ * belongs to the UI layer next to the value.
2936
+ * 2. Sort the survivors by `enrichedAt` ASC so iteration order is
2937
+ * "oldest first". This makes the spread merge below
2938
+ * last-write-wins per field, the freshest Extractor's value
2939
+ * pisar the older one for any conflicting key.
2940
+ * 3. Spread-merge each row's `value` over `node.frontmatter`. The
2941
+ * author's keys are the base; enrichment keys overlay them.
2942
+ *
2943
+ * The returned object is a fresh shallow copy, mutating it does not
2944
+ * touch the caller's node. The original `node.frontmatter` reference
2945
+ * remains accessible via `node.frontmatter` for callers that want the
2946
+ * pristine author baseline.
2947
+ *
2948
+ * @param node Node to merge against; `node.frontmatter` is the base.
2949
+ * @param enrichments Per-(node, extractor) enrichment records, typically
2950
+ * loaded via `loadNodeEnrichments(db, node.path)` or
2951
+ * pre-filtered to this node by the caller.
2952
+ * @param opts.includeStale When true, include rows flagged stale. Defaults
2953
+ * to false (the safe, CI-deterministic default).
2954
+ * UIs that want to display "stale (last value: …)"
2955
+ * pass `true` and consult `enrichment.stale`
2956
+ * on the source rows.
2957
+ */
2958
+ declare function mergeNodeWithEnrichments(node: Node, enrichments: IPersistedEnrichment[], opts?: {
2959
+ includeStale?: boolean;
2960
+ }): Record<string, unknown>;
2961
+ /**
2962
+ * A persisted enrichment row, post-load. Mirrors the DB row shape
2963
+ * but with `value` already deserialised from JSON and `stale` /
2964
+ * `isProbabilistic` already decoded from `0 | 1`. Surfaced via
2965
+ * `loadNodeEnrichments` (driven adapter) and consumed by
2966
+ * `mergeNodeWithEnrichments` and the `sm refresh` command.
2967
+ */
2968
+ interface IPersistedEnrichment {
2969
+ nodePath: string;
2970
+ extractorId: string;
2971
+ bodyHashAtEnrichment: string;
2972
+ value: Partial<Node>;
2973
+ stale: boolean;
2974
+ enrichedAt: number;
2975
+ isProbabilistic: boolean;
3269
2976
  }
3270
- /** Discovered kernel migration file (one of `NNN_snake_case.sql`). */
3271
- interface IMigrationFile {
3272
- version: number;
3273
- description: string;
3274
- filePath: string;
2977
+
2978
+ /**
2979
+ * In-memory `ProgressEmitterPort` adapter. No network, no DB, just a
2980
+ * synchronous fan-out to registered listeners. Used by the default scan
2981
+ * orchestrator; the WebSocket-backed emitter that streams to
2982
+ * the Web UI lands.
2983
+ */
2984
+
2985
+ declare class InMemoryProgressEmitter implements ProgressEmitterPort {
2986
+ #private;
2987
+ emit(event: ProgressEvent): void;
2988
+ subscribe(listener: TProgressListener): () => void;
3275
2989
  }
3276
- /** A row from the `config_schema_versions` ledger for the kernel scope. */
3277
- interface IMigrationRecord {
3278
- scope: string;
3279
- ownerId: string;
3280
- version: number;
3281
- description: string;
3282
- appliedAt: number;
2990
+
2991
+ /**
2992
+ * File watcher for `sm watch` / `sm scan --watch`.
2993
+ *
2994
+ * Wraps `chokidar` behind a small `IFsWatcher` interface so:
2995
+ *
2996
+ * 1. The CLI command is impl-agnostic, swapping chokidar for a
2997
+ * different watcher later (Java? Rust port? a future `WatchPort`?)
2998
+ * doesn't ripple into the command.
2999
+ * 2. Debouncing, batching, and ignore-filter integration live in one
3000
+ * place. The CLI just gets `onBatch(paths)` callbacks and decides
3001
+ * whether to re-scan.
3002
+ *
3003
+ * The watcher does NOT call into the orchestrator itself. That decision
3004
+ * is deliberate: the CLI owns the scan-and-persist pipeline (`runScan`,
3005
+ * `persistScanResult`, optional rebuild of the ignore filter when
3006
+ * `.skillmapignore` itself changes). Pulling that into the watcher
3007
+ * would couple the kernel module to `SqliteStorageAdapter`, which the
3008
+ * Server wouldn't want. Keep this module side-effect free
3009
+ * apart from filesystem subscription.
3010
+ *
3011
+ * Ignore filter integration: the supplied `IIgnoreFilter` is consulted
3012
+ * via chokidar's `ignored` predicate, which receives an absolute path.
3013
+ * We re-derive the path RELATIVE to the closest matching root before
3014
+ * passing it through `IIgnoreFilter.ignores`. This mirrors what the
3015
+ * scan walker does (`extensions/providers/claude/index.ts`) so both code
3016
+ * paths agree on what "ignored" means.
3017
+ */
3018
+
3019
+ type TWatchEventKind = 'add' | 'change' | 'unlink';
3020
+ interface IWatchEvent {
3021
+ kind: TWatchEventKind;
3022
+ /** Absolute path. */
3023
+ absolutePath: string;
3283
3024
  }
3284
- /** `port.migrations.plan` output: applied vs pending. */
3285
- interface IMigrationPlan {
3286
- applied: IMigrationRecord[];
3287
- pending: IMigrationFile[];
3025
+ interface IWatchBatch {
3026
+ /** Events that arrived inside the debounce window, in arrival order. */
3027
+ events: IWatchEvent[];
3028
+ /** Convenience: deduplicated absolute paths across the batch. */
3029
+ paths: string[];
3030
+ }
3031
+ interface IFsWatcher {
3032
+ /** Resolves once chokidar has finished its initial directory scan and is ready to emit. */
3033
+ ready: Promise<void>;
3034
+ /** Tear down the watcher. Resolves after chokidar releases handles. */
3035
+ close: () => Promise<void>;
3036
+ }
3037
+ interface ICreateFsWatcherOptions {
3038
+ /** Roots to watch. Resolved relative to `cwd` if relative paths are passed. */
3039
+ roots: string[];
3040
+ /** Working directory used to resolve relative roots and the ignore-filter root. */
3041
+ cwd: string;
3042
+ /** Debounce window in milliseconds. `0` triggers `onBatch` synchronously per event. */
3043
+ debounceMs: number;
3044
+ /**
3045
+ * Optional ignore filter, same instance the scan walker uses.
3046
+ *
3047
+ * Two shapes are accepted:
3048
+ *
3049
+ * - **`IIgnoreFilter`** (the static one), captured by reference at
3050
+ * construction. Use this when the filter never changes for the
3051
+ * lifetime of the watcher (the typical CLI `sm watch` flow).
3052
+ *
3053
+ * - **`() => IIgnoreFilter | undefined`** (a getter), re-evaluated
3054
+ * on EVERY chokidar `ignored` predicate call. Use this when the
3055
+ * filter can change at runtime, e.g. the BFF rebuilds it after
3056
+ * a `.skillmapignore` or `.skill-map/settings.json` edit and
3057
+ * wants chokidar to immediately respect the new patterns without
3058
+ * tearing down and rebuilding the watcher. A getter that returns
3059
+ * `undefined` disables ignore filtering for that call.
3060
+ */
3061
+ ignoreFilter?: IIgnoreFilter | (() => IIgnoreFilter | undefined) | undefined;
3062
+ /**
3063
+ * Maximum directory traversal depth. `undefined` (default) walks the
3064
+ * tree recursively without bound; `0` limits the watch to the
3065
+ * literal `roots` entries (no descent), which is the right setting
3066
+ * when watching a directory only to catch changes to specific
3067
+ * top-level files (see `subscribeMeta` in `core/watcher/runtime.ts`).
3068
+ * Forwarded verbatim to chokidar's `depth` option.
3069
+ */
3070
+ depth?: number;
3071
+ /** Called once per debounced batch. Awaited; concurrent batches are serialised. */
3072
+ onBatch: (batch: IWatchBatch) => void | Promise<void>;
3073
+ /**
3074
+ * Called when the underlying watcher surfaces an error. The watcher
3075
+ * stays open, callers decide whether to log, keep going, or close.
3076
+ */
3077
+ onError?: (err: Error) => void;
3078
+ }
3079
+ /**
3080
+ * Construct a chokidar-backed watcher. Subscribes immediately; the
3081
+ * returned `ready` promise resolves once chokidar's initial directory
3082
+ * walk completes, at which point only NEW events fire `onBatch`.
3083
+ *
3084
+ * The initial directory walk is deliberately silent, we set
3085
+ * `ignoreInitial: true`. The CLI runs a one-shot scan before flipping
3086
+ * the watcher on, so re-emitting an `add` for every existing file
3087
+ * would be redundant churn.
3088
+ */
3089
+ declare function createChokidarWatcher(opts: ICreateFsWatcherOptions): IFsWatcher;
3090
+
3091
+ /**
3092
+ * Scan delta, pure comparison of two `ScanResult` snapshots. Drives
3093
+ * `sm scan --compare-with <path>` and is the single place the kernel
3094
+ * knows how to identify "the same" entity across two scans.
3095
+ *
3096
+ * **Identity contract** (mirrors decisions made at earlier sub-steps):
3097
+ *
3098
+ * - **Node**: `node.path`. The path is the only field stable across
3099
+ * edits, every other Node field is content-derived (hashes, counts,
3100
+ * denormalised frontmatter). Two nodes with the same path are the
3101
+ * "same" node; differences are reported as a `changed` entry with
3102
+ * a reason narrowing what diverged.
3103
+ *
3104
+ * - **Link**: `(source, target, kind, normalizedTrigger ?? '')`. This
3105
+ * mirrors the link-conflict rule and `sm show` aggregation,
3106
+ * two links with identical endpoints, kind, and (optional) trigger
3107
+ * are the same link, even if emitted by different extractors. The
3108
+ * `sources[]` union and confidence are NOT part of identity; they
3109
+ * are presentation facets that can churn without making the link
3110
+ * "different" for delta purposes.
3111
+ *
3112
+ * - **Issue**: `(analyzerId, sorted nodeIds, message)`. Mirrors
3113
+ * `spec/job-events.md` §issue.*, same key → same issue, even when
3114
+ * `data` / `severity` / `linkIndices` shift. A meaningful change in
3115
+ * `message` (or a different set of node ids) is a different issue.
3116
+ * This is the same key future job events will use; keep it aligned
3117
+ * so consumers can reuse logic.
3118
+ *
3119
+ * No "changed" bucket for links / issues, identity already captures
3120
+ * everything that matters there. Nodes get a "changed" bucket because
3121
+ * the path stays stable while the body / frontmatter rewrite, and that
3122
+ * change is meaningful (formatters, summarisers, downstream consumers
3123
+ * all care about it).
3124
+ *
3125
+ * Pure: no IO, no DB, no FS. Safe to run in-memory inside `sm scan`
3126
+ * without polluting the persisted snapshot.
3127
+ */
3128
+
3129
+ type TNodeChangeReason = 'body' | 'frontmatter' | 'both';
3130
+ interface INodeChange {
3131
+ before: Node;
3132
+ after: Node;
3133
+ /**
3134
+ * Which hash diverged. `'body'` means body rewritten, frontmatter
3135
+ * untouched; `'frontmatter'` means metadata rewritten, body
3136
+ * untouched; `'both'` means both rewritten in the same edit.
3137
+ */
3138
+ reason: TNodeChangeReason;
3288
3139
  }
3289
- /** Apply-time options for `port.migrations.apply`. */
3290
- interface IApplyOptions {
3291
- backup?: boolean;
3292
- dryRun?: boolean;
3293
- to?: number;
3140
+ interface IScanDelta {
3141
+ /** Path the current scan was compared against (echoed for the report header). */
3142
+ comparedWith: string;
3143
+ nodes: {
3144
+ added: Node[];
3145
+ removed: Node[];
3146
+ changed: INodeChange[];
3147
+ };
3148
+ links: {
3149
+ added: Link[];
3150
+ removed: Link[];
3151
+ };
3152
+ issues: {
3153
+ added: Issue[];
3154
+ removed: Issue[];
3155
+ };
3294
3156
  }
3295
- /** Result of `port.migrations.apply`. */
3296
- interface IApplyResult {
3297
- applied: IMigrationFile[];
3298
- backupPath: string | null;
3157
+ declare function computeScanDelta(prior: ScanResult, current: ScanResult, comparedWith: string): IScanDelta;
3158
+ /**
3159
+ * `true` iff every bucket is empty. Callers use this to decide the
3160
+ * exit code (`0` clean, `1` non-empty delta).
3161
+ */
3162
+ declare function isEmptyDelta(delta: IScanDelta): boolean;
3163
+
3164
+ /**
3165
+ * Export query, minimal filter language for `sm export <query>` (Step 8.3).
3166
+ *
3167
+ * Spec contract: `spec/cli-contract.md` line 190 says "Query syntax is
3168
+ * implementation-defined pre-1.0". This module defines the v0.5.0 syntax.
3169
+ *
3170
+ * **Grammar** (BNF-ish, intentionally tiny):
3171
+ *
3172
+ * query := token (WS+ token)*
3173
+ * token := key "=" value-list
3174
+ * key := "kind" | "has" | "path"
3175
+ * value-list := value ("," value)*
3176
+ * value := non-comma, non-whitespace string
3177
+ *
3178
+ * Tokens AND together; values within one token OR. An empty / whitespace-only
3179
+ * query is valid and matches every node ("export everything").
3180
+ *
3181
+ * **Filters**:
3182
+ *
3183
+ * - `kind=skill` / `kind=skill,agent`, node kind whitelist.
3184
+ * - `has=issues`, node must appear in some issue's `nodeIds`. (Future
3185
+ * expansion: `has=findings` / `has=summary` once Step 10 / 11 land.
3186
+ * Unknown values are a parse error today; we'll ratchet up the
3187
+ * accepted set additively.)
3188
+ * - `path=foo/*` / `path=.claude/agents/**`, POSIX glob over `node.path`.
3189
+ * Supports `*` (any chars except `/`) and `**` (any chars including `/`).
3190
+ *
3191
+ * **Subset semantics** (`applyExportQuery`):
3192
+ *
3193
+ * - Nodes pass when every specified filter matches (AND across keys,
3194
+ * OR within values).
3195
+ * - Links survive only when BOTH endpoints (`source` + `target`) belong
3196
+ * to the filtered node set. A subset that includes "edges out to
3197
+ * unfiltered nodes" would be confusing, the user asked for a focused
3198
+ * subgraph, not its boundary. External-URL pseudo-links are already
3199
+ * stripped by the orchestrator and never reach this layer.
3200
+ * - Issues survive when ANY of the issue's `nodeIds` is in the filtered
3201
+ * set. Issues span multiple nodes (e.g. `trigger-collision` over two
3202
+ * advertisers); dropping an issue when one of its nodes is outside
3203
+ * would hide cross-cutting problems the user is investigating.
3204
+ *
3205
+ * Pure: no IO, no DB, no FS.
3206
+ */
3207
+
3208
+ interface IExportQuery {
3209
+ /** Original query string echoed back so consumers can render the header. */
3210
+ raw: string;
3211
+ /**
3212
+ * Whitelist of node kinds (`node.kind` is open string, built-in
3213
+ * Claude catalog `skill` / `agent` / `command` / `hook` / `note`,
3214
+ * plus whatever external Providers declare). The query parser does
3215
+ * not validate values against a closed enum; an unknown kind simply
3216
+ * yields zero matches at filter time.
3217
+ */
3218
+ kinds?: string[];
3219
+ hasIssues?: boolean;
3220
+ pathGlobs?: string[];
3299
3221
  }
3300
- /** Discovered plugin migration file. Same `NNN_snake_case.sql` convention. */
3301
- interface IPluginMigrationFile {
3302
- version: number;
3303
- description: string;
3304
- filePath: string;
3222
+ interface IExportSubset {
3223
+ query: IExportQuery;
3224
+ nodes: Node[];
3225
+ links: Link[];
3226
+ issues: Issue[];
3305
3227
  }
3306
- /** A row from the `config_schema_versions` ledger for a single plugin. */
3307
- interface IPluginMigrationRecord {
3308
- version: number;
3309
- description: string;
3310
- appliedAt: number;
3228
+ declare class ExportQueryError extends Error {
3229
+ constructor(message: string);
3311
3230
  }
3312
- /** `port.pluginMigrations.plan` output for a single plugin. */
3313
- interface IPluginMigrationPlan {
3314
- pluginId: string;
3315
- applied: IPluginMigrationRecord[];
3316
- pending: IPluginMigrationFile[];
3231
+ declare function parseExportQuery(raw: string): IExportQuery;
3232
+ declare function applyExportQuery(scan: {
3233
+ nodes: Node[];
3234
+ links: Link[];
3235
+ issues: Issue[];
3236
+ }, query: IExportQuery): IExportSubset;
3237
+
3238
+ /**
3239
+ * `scan_node_tags` adapter, tags · dual-source persistence layer.
3240
+ *
3241
+ * One row per `(node_path, tag, source)` triple. Projected at persist
3242
+ * time from BOTH `frontmatter.tags` (with `source='author'`) and
3243
+ * `sidecar.annotations.tags` (with `source='user'`). The same tag
3244
+ * string MAY appear under both sources for the same node, the PK
3245
+ * accepts the pair; search returns the node once via DISTINCT, the
3246
+ * UI renders both chips with their attribution.
3247
+ *
3248
+ * Belongs to the `scan_*` family, replaced wholesale per scan.
3249
+ * Cached nodes' tag rows are projected from the cached
3250
+ * `node.frontmatter.tags` / `node.sidecar.annotations.tags` (both
3251
+ * already in memory at persist time), so the rebuild is cheap
3252
+ * regardless of cache hit / miss. See `spec/db-schema.md`
3253
+ * § scan_node_tags for the normative shape and replace-all semantics.
3254
+ */
3255
+
3256
+ /**
3257
+ * In-memory tag record buffered during scan and flushed to
3258
+ * `scan_node_tags` by `persistScanResult`. One entry per
3259
+ * `(node_path, tag, source)` projected from a node's frontmatter tags
3260
+ * (`source: 'author'`) or sidecar annotations tags
3261
+ * (`source: 'user'`).
3262
+ */
3263
+ interface ITagRecord {
3264
+ nodePath: string;
3265
+ tag: string;
3266
+ source: 'author' | 'user';
3317
3267
  }
3318
- /** Apply-time options for `port.pluginMigrations.apply`. */
3319
- interface IPluginApplyOptions {
3320
- /** No actual writes; surfaces what would run. Default false. */
3321
- dryRun?: boolean;
3268
+
3269
+ /**
3270
+ * Pure helpers for the "update available" notification feature.
3271
+ *
3272
+ * Three responsibilities:
3273
+ * - `fetchLatestVersion` , query `https://registry.npmjs.org/<pkg>/latest`
3274
+ * with `AbortController` + timeout. Throws on
3275
+ * non-200 / parse failure / abort.
3276
+ * - `compareVersions` , semver compare (-1 / 0 / 1). Pre-1.0 aware:
3277
+ * treats prereleases via the standard rules
3278
+ * (release > prerelease at the same triple).
3279
+ * - `isOutdated` , sugar over `compareVersions` for the common
3280
+ * "is `latest` strictly greater than `current`"
3281
+ * check the banner runs against.
3282
+ *
3283
+ * Pure kernel module, NO `process.env` reads, NO Node globals beyond the
3284
+ * built-in `fetch` / `AbortController` (Node 22+). Every env / settings
3285
+ * lookup happens in `src/cli/util/update-check-banner.ts`, the CLI-side
3286
+ * adapter that owns side effects.
3287
+ *
3288
+ * The shared cache type (`IUpdateCheckCache`) is used by the storage
3289
+ * helpers under `kernel/storage/update-check.ts` and by the BFF's
3290
+ * `GET /api/update-status` projection. A second type
3291
+ * (`IUpdateStatus`) shapes the BFF response, it merges `current`
3292
+ * (from `VERSION`) into the cache so the UI can render without a
3293
+ * second lookup. Both stay flat, no nested objects, so JSON
3294
+ * serialization is trivial.
3295
+ */
3296
+ interface IUpdateCheckCache {
3297
+ latestVersion: string;
3298
+ /** Epoch ms, when the registry was last successfully probed. */
3299
+ checkedAt: number;
3300
+ /** Epoch ms, when the banner was last printed; null = never shown yet. */
3301
+ shownAt: number | null;
3322
3302
  }
3323
- /** Result of `port.pluginMigrations.apply`. */
3324
- interface IPluginApplyResult {
3325
- pluginId: string;
3326
- applied: IPluginMigrationFile[];
3327
- /** Catalog intrusions caught by Layer 3 (post-apply sweep). Empty when clean. */
3328
- intrusions: string[];
3303
+
3304
+ /**
3305
+ * `PluginLoaderPort`, discovers plugin directories and loads their
3306
+ * extensions. The shape mirrors what the concrete loader actually
3307
+ * exposes (see `kernel/adapters/plugin-loader.ts`); the port exists so
3308
+ * the CLI consumes the abstract contract via `createPluginLoader(...)`
3309
+ * instead of `new PluginLoader(...)` and so the concrete adapter is
3310
+ * structurally pinned to the port (`implements PluginLoaderPort` makes
3311
+ * any drift a compile error).
3312
+ *
3313
+ * Domain types (`IPluginManifest`, `ILoadedExtension`, `IDiscoveredPlugin`,
3314
+ * `TPluginStorage`, `TPluginLoadStatus`, `TGranularity`) live in
3315
+ * `kernel/types/plugin.ts` because they are spec-mirroring DTOs, not
3316
+ * port-shape types. The port re-exports them for callers that import
3317
+ * from the ports barrel.
3318
+ */
3319
+
3320
+ interface PluginLoaderPort {
3321
+ /**
3322
+ * Synchronously enumerate every directory containing a `plugin.json`
3323
+ * across the configured search paths. Non-existent paths are skipped.
3324
+ */
3325
+ discoverPaths(): string[];
3326
+ /**
3327
+ * Discover every plugin, attempt to load each, then apply the
3328
+ * cross-root id-collision pass. Never throws, failures are reported
3329
+ * via `IDiscoveredPlugin.status`.
3330
+ */
3331
+ discoverAndLoadAll(): Promise<IDiscoveredPlugin[]>;
3332
+ /**
3333
+ * Load a single plugin from its directory. Never throws, failure is
3334
+ * reported via the returned `status`.
3335
+ */
3336
+ loadOne(pluginPath: string): Promise<IDiscoveredPlugin>;
3329
3337
  }
3330
3338
 
3331
3339
  /**