@skill-map/cli 0.54.0 → 0.55.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/dist/cli/tutorial/sm-tutorial/SKILL.md +22 -24
  2. package/dist/cli/tutorial/sm-tutorial/references/_core.md +8 -7
  3. package/dist/cli/tutorial/sm-tutorial/references/_manifest.yml +21 -42
  4. package/dist/cli/tutorial/sm-tutorial/references/fixtures.md +15 -7
  5. package/dist/cli/tutorial/sm-tutorial/references/part-authoring.md +2 -2
  6. package/dist/cli/tutorial/sm-tutorial/references/part-cli.md +1 -1
  7. package/dist/cli/tutorial/sm-tutorial/references/part-connect-harness.md +9 -10
  8. package/dist/cli/tutorial/sm-tutorial/references/part-daily-loop.md +563 -0
  9. package/dist/cli/tutorial/sm-tutorial/references/part-mcp.md +5 -1
  10. package/dist/cli/tutorial/sm-tutorial/references/part-plugins.md +7 -7
  11. package/dist/cli/tutorial/sm-tutorial/references/part-project-kickoff.md +24 -12
  12. package/dist/cli/tutorial/sm-tutorial/references/part-settings.md +2 -2
  13. package/dist/cli.js +594 -485
  14. package/dist/index.js +127 -13
  15. package/dist/kernel/index.d.ts +187 -94
  16. package/dist/kernel/index.js +127 -13
  17. package/dist/migrations/001_initial.sql +5 -0
  18. package/dist/ui/chunk-CN6IOM67.js +2 -0
  19. package/dist/ui/chunk-HPKRDGLH.js +123 -0
  20. package/dist/ui/{chunk-CXTU4HQV.js → chunk-LREXXX7T.js} +1 -1
  21. package/dist/ui/{chunk-BUNPMGDX.js → chunk-RGB5FBZL.js} +28 -28
  22. package/dist/ui/{chunk-4CXAL43H.js → chunk-XAM6VKXM.js} +1 -1
  23. package/dist/ui/index.html +2 -2
  24. package/dist/ui/{main-HP3MOLI2.js → main-7YXBWYHE.js} +3 -3
  25. package/dist/ui/{styles-4SNVM34O.css → styles-HRJG67XW.css} +1 -1
  26. package/migrations/001_initial.sql +5 -0
  27. package/package.json +2 -2
  28. package/dist/cli/tutorial/sm-tutorial/references/part-live-site.md +0 -155
  29. package/dist/cli/tutorial/sm-tutorial/references/part-maintain.md +0 -284
  30. package/dist/cli/tutorial/sm-tutorial/references/part-run-harness.md +0 -181
  31. package/dist/ui/chunk-DSNBKMYU.js +0 -2
  32. package/dist/ui/chunk-MVRQGDZJ.js +0 -123
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // kernel/i18n/registry.texts.ts
2
2
 
3
- !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="f476faaa-267b-52a2-84d7-7b8859286768")}catch(e){}}();
3
+ !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="1f2129d2-2039-52b2-acb6-f7adcf338044")}catch(e){}}();
4
4
  var REGISTRY_TEXTS = {
5
5
  duplicateExtension: "Extension already registered: {{kind}}:{{qualifiedId}}",
6
6
  unknownKind: "Unknown extension kind: {{kind}}",
@@ -102,7 +102,7 @@ import { Tiktoken as Tiktoken2 } from "js-tiktoken/lite";
102
102
  // package.json
103
103
  var package_default = {
104
104
  name: "@skill-map/cli",
105
- version: "0.54.0",
105
+ version: "0.55.0",
106
106
  description: "skill-map reference implementation \u2014 kernel + CLI + adapters.",
107
107
  license: "MIT",
108
108
  type: "module",
@@ -1190,6 +1190,81 @@ function isExternalUrlLink(link) {
1190
1190
  return EXTERNAL_URL_SCHEME_RE.test(link.target);
1191
1191
  }
1192
1192
 
1193
+ // kernel/orchestrator/action-projections.ts
1194
+ function runActionProjections(actions, nodes, links, emitter) {
1195
+ const contributions = [];
1196
+ const contributionErrors = [];
1197
+ const validators = loadSchemaValidators();
1198
+ for (const action of actions) {
1199
+ if (typeof action.project !== "function") continue;
1200
+ const qualifiedId = qualifiedExtensionId(action.pluginId, action.id);
1201
+ const declaredContributions = readDeclaredContributionRefs(action);
1202
+ const emitContribution = (nodePath, ref, payload) => {
1203
+ const declared = typeof ref === "object" && ref !== null ? declaredContributions.get(ref) : void 0;
1204
+ if (!declared) {
1205
+ const message = tx(ORCHESTRATOR_TEXTS.extensionErrorContributionUndeclaredRef, {
1206
+ extractorId: qualifiedId,
1207
+ nodePath
1208
+ });
1209
+ emitExtensionError(emitter, qualifiedId, nodePath, {
1210
+ phase: "emitContribution",
1211
+ reason: "undeclared-contribution-ref",
1212
+ message
1213
+ });
1214
+ contributionErrors.push({
1215
+ pluginId: action.pluginId,
1216
+ extensionId: action.id,
1217
+ nodePath,
1218
+ reason: "undeclared-contribution-ref",
1219
+ message,
1220
+ emittedAt: Date.now()
1221
+ });
1222
+ return;
1223
+ }
1224
+ const result = validators.validateContributionPayload(declared.slot, payload);
1225
+ if (!result.ok) {
1226
+ const message = tx(ORCHESTRATOR_TEXTS.extensionErrorContributionPayloadInvalid, {
1227
+ extractorId: qualifiedId,
1228
+ contributionId: declared.id,
1229
+ nodePath,
1230
+ slot: declared.slot,
1231
+ errors: result.errors
1232
+ });
1233
+ emitExtensionError(emitter, qualifiedId, nodePath, {
1234
+ phase: "emitContribution",
1235
+ contributionId: declared.id,
1236
+ slot: declared.slot,
1237
+ reason: result.errors,
1238
+ message
1239
+ });
1240
+ contributionErrors.push({
1241
+ pluginId: action.pluginId,
1242
+ extensionId: action.id,
1243
+ nodePath,
1244
+ reason: result.errors,
1245
+ message,
1246
+ contributionId: declared.id,
1247
+ slot: declared.slot,
1248
+ emittedAt: Date.now()
1249
+ });
1250
+ return;
1251
+ }
1252
+ contributions.push({
1253
+ pluginId: action.pluginId,
1254
+ extensionId: action.id,
1255
+ nodePath,
1256
+ contributionId: declared.id,
1257
+ slot: declared.slot,
1258
+ payload,
1259
+ emittedAt: Date.now()
1260
+ });
1261
+ };
1262
+ const ctx = { nodes, links, emitContribution };
1263
+ action.project(ctx);
1264
+ }
1265
+ return { contributions, contributionErrors };
1266
+ }
1267
+
1193
1268
  // kernel/orchestrator/analyzers.ts
1194
1269
  async function runAnalyzers(analyzers, nodes, internalLinks, orphanSidecars, sidecarRoots, annotationContributions, viewContributions, orphanJobFiles, referenceablePaths, cwd, registeredActionIds, emitter, hookDispatcher, reservedNodePaths, signals, seedIssues = []) {
1195
1270
  const issues = [...seedIssues];
@@ -1560,17 +1635,26 @@ function readDirname(node) {
1560
1635
 
1561
1636
  // kernel/orchestrator/lift-resolved-link-confidence.ts
1562
1637
  var RESERVED_TARGET_CONFIDENCE = 0.1;
1638
+ var BROKEN_TARGET_CONFIDENCE = 0.5;
1563
1639
  function liftResolvedLinkConfidence(links, nodes, ctx) {
1564
1640
  if (!links.some((l) => l.confidence < 1)) return;
1565
1641
  const indexes = buildIndexes(nodes, ctx);
1566
1642
  for (const link of links) {
1567
- if (link.confidence >= 1) continue;
1568
- const resolution = resolve5(link, indexes, ctx);
1569
- if (resolution === "none") continue;
1570
- link.confidence = ctx.reservedNodePaths.has(resolution) ? RESERVED_TARGET_CONFIDENCE : 1;
1571
- link.resolvedTarget = resolution;
1643
+ if (link.confidence < 1) applyResolution(link, indexes, ctx);
1572
1644
  }
1573
1645
  }
1646
+ function applyResolution(link, indexes, ctx) {
1647
+ const resolution = resolve5(link, indexes, ctx);
1648
+ if (resolution === "none") {
1649
+ if (isGenuinelyBroken(link, indexes)) {
1650
+ link.confidence = Math.min(link.confidence, BROKEN_TARGET_CONFIDENCE);
1651
+ }
1652
+ return;
1653
+ }
1654
+ link.resolvedTarget = resolution;
1655
+ if (indexes.nodeByPath.get(resolution)?.virtual) return;
1656
+ link.confidence = ctx.reservedNodePaths.has(resolution) ? RESERVED_TARGET_CONFIDENCE : 1;
1657
+ }
1574
1658
  function buildIndexes(nodes, ctx) {
1575
1659
  const byPath2 = /* @__PURE__ */ new Set();
1576
1660
  const byName = /* @__PURE__ */ new Map();
@@ -1586,6 +1670,12 @@ function resolve5(link, indexes, ctx) {
1586
1670
  if (indexes.byPath.has(link.target)) return link.target;
1587
1671
  return resolveByName(link, indexes, ctx);
1588
1672
  }
1673
+ function isGenuinelyBroken(link, indexes) {
1674
+ if (indexes.byPath.has(link.target)) return false;
1675
+ const stripped = stripTriggerSigil(link.trigger?.normalizedTrigger);
1676
+ if (stripped !== null && indexes.byName.has(stripped)) return false;
1677
+ return true;
1678
+ }
1589
1679
  function resolveByName(link, indexes, ctx) {
1590
1680
  const stripped = stripTriggerSigil(link.trigger?.normalizedTrigger);
1591
1681
  if (stripped === null) return "none";
@@ -2146,11 +2236,11 @@ async function* walkContent(roots, options) {
2146
2236
  const extensions = options.extensions;
2147
2237
  const sizeLimit = buildSizeLimit(options);
2148
2238
  for (const root of roots) {
2149
- for await (const file of walkRoot(root, root, filter, extensions, sizeLimit)) {
2150
- const relPath = relative2(root, file).split(sep).join("/");
2239
+ for await (const entry of walkRoot(root, root, filter, extensions, sizeLimit)) {
2240
+ const relPath = relative2(root, entry.full).split(sep).join("/");
2151
2241
  let raw;
2152
2242
  try {
2153
- raw = await readFile(file, "utf8");
2243
+ raw = await readFile(entry.full, "utf8");
2154
2244
  } catch {
2155
2245
  continue;
2156
2246
  }
@@ -2160,6 +2250,9 @@ async function* walkContent(roots, options) {
2160
2250
  body: parsed.body,
2161
2251
  frontmatterRaw: parsed.frontmatterRaw,
2162
2252
  frontmatter: parsed.frontmatter,
2253
+ // File mtime from the TOCTOU `lstat` (zero extra syscalls).
2254
+ // Threaded onto the persisted `Node` as `modifiedAtMs`.
2255
+ modifiedAtMs: entry.modifiedAtMs,
2163
2256
  // Audit L1: forward parser diagnostics (e.g. malformed YAML)
2164
2257
  // through the IRawNode surface so the orchestrator can
2165
2258
  // convert them into warn-level kernel `Issue` rows. Omitted
@@ -2200,7 +2293,7 @@ async function* walkRoot(root, current, filter, extensions, sizeLimit) {
2200
2293
  sizeLimit.onOversizedFile?.({ path: rel, bytes: s.size });
2201
2294
  continue;
2202
2295
  }
2203
- yield full;
2296
+ yield { full, modifiedAtMs: Math.round(s.mtimeMs) };
2204
2297
  } catch {
2205
2298
  }
2206
2299
  }
@@ -2491,6 +2584,7 @@ function buildNode(args) {
2491
2584
  externalRefsCount: 0,
2492
2585
  frontmatter: args.frontmatter
2493
2586
  };
2587
+ if (args.modifiedAtMs !== void 0) node.modifiedAtMs = args.modifiedAtMs;
2494
2588
  if (args.encoder) {
2495
2589
  node.tokens = countTokens(args.encoder, args.frontmatterRaw, args.body);
2496
2590
  }
@@ -2606,7 +2700,10 @@ function buildFreshNodeAndValidateFrontmatter(opts) {
2606
2700
  frontmatter: opts.raw.frontmatter,
2607
2701
  bodyHash: opts.bodyHash,
2608
2702
  frontmatterHash: opts.frontmatterHash,
2609
- encoder: opts.encoder
2703
+ encoder: opts.encoder,
2704
+ // Thread the walker's mtime through; `buildNode` only attaches it
2705
+ // when present, so virtual / walk()-without-stat sources stay absent.
2706
+ modifiedAtMs: opts.raw.modifiedAtMs
2610
2707
  });
2611
2708
  const frontmatterIssues = [];
2612
2709
  if (opts.raw.parseIssues && opts.raw.parseIssues.length > 0) {
@@ -3056,6 +3153,13 @@ async function runScanInternal(_kernel, options) {
3056
3153
  walked.frontmatterIssues
3057
3154
  );
3058
3155
  mergeAnalyzerEmissions(walked, analyzerResult, exts.analyzers);
3156
+ const projectionResult = runActionProjections(
3157
+ exts.actions ?? [],
3158
+ walked.nodes,
3159
+ walked.internalLinks,
3160
+ emitter
3161
+ );
3162
+ mergeActionProjections(walked, projectionResult, exts.actions);
3059
3163
  const issues = analyzerResult.issues;
3060
3164
  const silenced = options.ignoreFilter ? (path) => options.ignoreFilter.ignores(path) : void 0;
3061
3165
  const renameOps = prior ? detectRenamesAndOrphans(prior, walked.nodes, issues, silenced) : [];
@@ -3164,6 +3268,16 @@ function mergeAnalyzerEmissions(walked, analyzerResult, analyzers) {
3164
3268
  }
3165
3269
  }
3166
3270
  }
3271
+ function mergeActionProjections(walked, projectionResult, actions) {
3272
+ for (const c of projectionResult.contributions) walked.contributions.push(c);
3273
+ for (const e of projectionResult.contributionErrors) walked.contributionErrors.push(e);
3274
+ for (const action of actions ?? []) {
3275
+ if (action.ui === void 0 || typeof action.project !== "function") continue;
3276
+ for (const node of walked.nodes) {
3277
+ walked.freshlyRunTuples.add(`${action.pluginId}\0${action.id}\0${node.path}`);
3278
+ }
3279
+ }
3280
+ }
3167
3281
  function buildScanStats(walked, issues, start) {
3168
3282
  return {
3169
3283
  // `filesSkipped` is "files walked but not classified by any
@@ -3632,4 +3746,4 @@ export {
3632
3746
  runScanWithRenames
3633
3747
  };
3634
3748
  //# sourceMappingURL=index.js.map
3635
- //# debugId=f476faaa-267b-52a2-84d7-7b8859286768
3749
+ //# debugId=1f2129d2-2039-52b2-acb6-f7adcf338044
@@ -1,87 +1,3 @@
1
- /**
2
- * Extension registry, six kinds, first-class, loaded through a single API.
3
- *
4
- * The `IExtension` shape is aligned with `spec/schemas/extensions/base.schema.json`.
5
- * Kind-specific manifests (provider / extractor / analyzer / action / formatter /
6
- * hook) extend this base structurally; the registry stores the base view
7
- * and each kind's code carries its own fuller type where needed.
8
- *
9
- * **Spec § A.6, qualified ids.** Every extension is keyed in the registry
10
- * by `<pluginId>/<id>` (e.g. `core/annotations`, `core/slash-command`,
11
- * `my-plugin/my-extractor`). `IExtension.id` carries the **short** id as authored;
12
- * `IExtension.pluginId` carries the namespace; the registry composes the
13
- * qualifier internally and exposes lookup APIs that operate on either form
14
- * (qualified for direct lookup, kind-scoped listing for enumeration).
15
- *
16
- * Boot invariant: `new Registry()` is empty. `registry.totalCount() === 0`
17
- * when the kernel boots with zero extensions. This is the data side of the
18
- * `kernel-empty-boot` conformance contract.
19
- */
20
- type ExtensionKind = 'provider' | 'extractor' | 'analyzer' | 'action' | 'formatter' | 'hook';
21
- declare const EXTENSION_KINDS: readonly ExtensionKind[];
22
- interface IExtension {
23
- /** Short (unqualified) extension id, injected by the loader from the leaf folder name. */
24
- id: string;
25
- /** Owning plugin namespace, injected by the loader from the plugin folder name. */
26
- pluginId: string;
27
- kind: ExtensionKind;
28
- version: string;
29
- /** Required short description; surfaced in `sm <kind>s list` and the UI. */
30
- description: string;
31
- entry?: string;
32
- }
33
- /**
34
- * Compose the qualified registry key for an extension. Single source of
35
- * truth so callers don't reinvent the format and a future change (e.g. a
36
- * different separator) lands in one place.
37
- */
38
- declare function qualifiedExtensionId(pluginId: string, id: string): string;
39
- declare class DuplicateExtensionError extends Error {
40
- constructor(kind: ExtensionKind, qualifiedId: string);
41
- }
42
- declare class Registry {
43
- #private;
44
- constructor();
45
- register(ext: IExtension): void;
46
- /**
47
- * Lookup by qualified id (`<pluginId>/<id>`). Returns `undefined` when
48
- * no extension of that kind is registered under the qualifier.
49
- */
50
- get(kind: ExtensionKind, qualifiedId: string): IExtension | undefined;
51
- /**
52
- * Convenience wrapper that composes the qualified id for the caller.
53
- * Equivalent to `get(kind, qualifiedExtensionId(pluginId, id))`.
54
- */
55
- find(kind: ExtensionKind, pluginId: string, id: string): IExtension | undefined;
56
- all(kind: ExtensionKind): IExtension[];
57
- count(kind: ExtensionKind): number;
58
- totalCount(): number;
59
- }
60
-
61
- /**
62
- * Step 9.6.6, runtime annotation-contribution catalog types.
63
- *
64
- * Lives in its own module (rather than `kernel/index.ts`) so consumers
65
- * deep inside the kernel, `IAnalyzerContext`, the BFF route factories,
66
- * future Action contexts, can depend on the catalog shape without
67
- * dragging the whole kernel barrel and risking a cycle.
68
- */
69
- /**
70
- * Single row of the runtime annotation-contribution catalog surfaced by
71
- * `kernel.getRegisteredAnnotationKeys()`. One row per (plugin × key)
72
- * tuple. Built-in catalog keys from `annotations.schema.json` are NOT
73
- * included, this catalog is plugin-only; the UI knows the built-in
74
- * catalog via the schema bundle.
75
- */
76
- interface IRegisteredAnnotationKey {
77
- pluginId: string;
78
- key: string;
79
- location: 'namespaced' | 'root';
80
- ownership: 'exclusive' | 'shared';
81
- /** Inline JSON Schema as declared in the manifest (not the AJV compiled validator). */
82
- schema: Record<string, unknown>;
83
- }
84
-
85
1
  /**
86
2
  * Closed enum of view slot names. Mirror of
87
3
  * `spec/schemas/view-slots.schema.json#/$defs/SlotName`.
@@ -554,13 +470,24 @@ type TSettingValue = string | string[] | boolean | number | ISetting_KeyValueLis
554
470
  */
555
471
 
556
472
  /**
557
- * Lifecycle label an extension manifest MAY declare. Presentation-only
558
- * metadata: the non-default values render as a badge next to the
559
- * extension in `sm plugins list` / `sm plugins show` and the Settings
560
- * plugins panel, and the kernel never gates behaviour on it (a
561
- * `deprecated` extension still runs). Default: missing == `stable`,
562
- * and `stable` (declared or defaulted) renders no badge. Mirrors
473
+ * Lifecycle label an extension manifest MAY declare. Renders as a badge
474
+ * next to the extension in `sm plugins list <id>` / `sm plugins show` and
475
+ * the Settings plugins panel for the non-default values.
476
+ *
477
+ * Two values ALSO change behaviour: `experimental` (not ready yet) and
478
+ * `deprecated` (on its way out) flip the extension's installed default
479
+ * to DISABLED, so the extension does not load (does not run, does not
480
+ * register) unless the operator opts in (`sm plugins enable
481
+ * <plugin>/<ext>`, the Settings toggle, or a `settings.json` /
482
+ * `config_plugins` override). The opt-in is a plain enable override,
483
+ * once set it wins over the installed default exactly like any other
484
+ * extension (so a deprecated extension can still be kept running during
485
+ * a migration). The remaining values are presentation-only and default
486
+ * to ENABLED: `beta` runs by default with a badge, `stable` (declared
487
+ * or defaulted) runs with no badge. Missing == `stable` == enabled, no
488
+ * badge. Mirrors
563
489
  * `spec/schemas/extensions/base.schema.json#/properties/stability`.
490
+ *
564
491
  * Deliberately a superset of the node-level annotations enum (which has
565
492
  * no `beta`): this describes the maturity of the extension itself, not
566
493
  * of a scanned node.
@@ -620,8 +547,9 @@ interface IExtensionBase {
620
547
  description: string;
621
548
  /**
622
549
  * Optional lifecycle label (`experimental` / `beta` / `stable` /
623
- * `deprecated`). Missing == `stable`, no badge rendered. See
624
- * `TExtensionStability` for the full semantics.
550
+ * `deprecated`). Missing == `stable`, no badge rendered. `experimental`
551
+ * and `deprecated` additionally flip the installed default to disabled.
552
+ * See `TExtensionStability` for the full semantics.
625
553
  */
626
554
  stability?: TExtensionStability;
627
555
  /**
@@ -657,14 +585,107 @@ interface IExtensionBase {
657
585
  * `viewContributions` with the structure-as-truth refactor. Each
658
586
  * entry maps a local contribution id (kebab-case, unique within the
659
587
  * extension) to an `IViewContribution` that picks a view slot by
660
- * name from the closed catalog. Only `extractor` and `analyzer` kinds
661
- * may declare this field.
588
+ * name from the closed catalog. Declared by `extractor` and
589
+ * `analyzer` kinds (emitted during scan / graph evaluation) and by
590
+ * `action` kinds (emitted from the Action's scan-time `project()`
591
+ * self-projection, see `IActionProjectionContext`).
662
592
  */
663
593
  ui?: Record<string, IViewContribution>;
664
594
  /** Runtime-only, absolute path of the extension entry file. */
665
595
  entry?: string;
666
596
  }
667
597
 
598
+ /**
599
+ * Extension registry, six kinds, first-class, loaded through a single API.
600
+ *
601
+ * The `IExtension` shape is aligned with `spec/schemas/extensions/base.schema.json`.
602
+ * Kind-specific manifests (provider / extractor / analyzer / action / formatter /
603
+ * hook) extend this base structurally; the registry stores the base view
604
+ * and each kind's code carries its own fuller type where needed.
605
+ *
606
+ * **Spec § A.6, qualified ids.** Every extension is keyed in the registry
607
+ * by `<pluginId>/<id>` (e.g. `core/annotations`, `core/slash-command`,
608
+ * `my-plugin/my-extractor`). `IExtension.id` carries the **short** id as authored;
609
+ * `IExtension.pluginId` carries the namespace; the registry composes the
610
+ * qualifier internally and exposes lookup APIs that operate on either form
611
+ * (qualified for direct lookup, kind-scoped listing for enumeration).
612
+ *
613
+ * Boot invariant: `new Registry()` is empty. `registry.totalCount() === 0`
614
+ * when the kernel boots with zero extensions. This is the data side of the
615
+ * `kernel-empty-boot` conformance contract.
616
+ */
617
+
618
+ type ExtensionKind = 'provider' | 'extractor' | 'analyzer' | 'action' | 'formatter' | 'hook';
619
+ declare const EXTENSION_KINDS: readonly ExtensionKind[];
620
+ interface IExtension {
621
+ /** Short (unqualified) extension id, injected by the loader from the leaf folder name. */
622
+ id: string;
623
+ /** Owning plugin namespace, injected by the loader from the plugin folder name. */
624
+ pluginId: string;
625
+ kind: ExtensionKind;
626
+ version: string;
627
+ /** Required short description; surfaced in `sm <kind>s list` and the UI. */
628
+ description: string;
629
+ /**
630
+ * Optional lifecycle label (`IExtensionBase.stability`). Carried on the
631
+ * registry view so the enabled-resolver can read it: `experimental`
632
+ * flips an extension's installed default to disabled. Absent == stable.
633
+ */
634
+ stability?: TExtensionStability;
635
+ entry?: string;
636
+ }
637
+ /**
638
+ * Compose the qualified registry key for an extension. Single source of
639
+ * truth so callers don't reinvent the format and a future change (e.g. a
640
+ * different separator) lands in one place.
641
+ */
642
+ declare function qualifiedExtensionId(pluginId: string, id: string): string;
643
+ declare class DuplicateExtensionError extends Error {
644
+ constructor(kind: ExtensionKind, qualifiedId: string);
645
+ }
646
+ declare class Registry {
647
+ #private;
648
+ constructor();
649
+ register(ext: IExtension): void;
650
+ /**
651
+ * Lookup by qualified id (`<pluginId>/<id>`). Returns `undefined` when
652
+ * no extension of that kind is registered under the qualifier.
653
+ */
654
+ get(kind: ExtensionKind, qualifiedId: string): IExtension | undefined;
655
+ /**
656
+ * Convenience wrapper that composes the qualified id for the caller.
657
+ * Equivalent to `get(kind, qualifiedExtensionId(pluginId, id))`.
658
+ */
659
+ find(kind: ExtensionKind, pluginId: string, id: string): IExtension | undefined;
660
+ all(kind: ExtensionKind): IExtension[];
661
+ count(kind: ExtensionKind): number;
662
+ totalCount(): number;
663
+ }
664
+
665
+ /**
666
+ * Step 9.6.6, runtime annotation-contribution catalog types.
667
+ *
668
+ * Lives in its own module (rather than `kernel/index.ts`) so consumers
669
+ * deep inside the kernel, `IAnalyzerContext`, the BFF route factories,
670
+ * future Action contexts, can depend on the catalog shape without
671
+ * dragging the whole kernel barrel and risking a cycle.
672
+ */
673
+ /**
674
+ * Single row of the runtime annotation-contribution catalog surfaced by
675
+ * `kernel.getRegisteredAnnotationKeys()`. One row per (plugin × key)
676
+ * tuple. Built-in catalog keys from `annotations.schema.json` are NOT
677
+ * included, this catalog is plugin-only; the UI knows the built-in
678
+ * catalog via the schema bundle.
679
+ */
680
+ interface IRegisteredAnnotationKey {
681
+ pluginId: string;
682
+ key: string;
683
+ location: 'namespaced' | 'root';
684
+ ownership: 'exclusive' | 'shared';
685
+ /** Inline JSON Schema as declared in the manifest (not the AJV compiled validator). */
686
+ schema: Record<string, unknown>;
687
+ }
688
+
668
689
  /**
669
690
  * Domain types, byte-aligned with `spec/schemas/{node,link,issue,scan-result}.schema.json`.
670
691
  *
@@ -897,6 +918,16 @@ interface Node {
897
918
  externalRefs?: IExternalRef[];
898
919
  frontmatter?: Record<string, unknown>;
899
920
  tokens?: TripleSplit;
921
+ /**
922
+ * File modification time (`mtime`) in Unix milliseconds, captured at
923
+ * scan time from the on-disk `lstat`. Absent for virtual / derived
924
+ * nodes (`virtual === true`, no backing file) and for nodes built by a
925
+ * Provider `walk()` that does not stat its sources. Persisted to
926
+ * `scan_nodes.modified_at_ms` and surfaced on `/api/nodes` /
927
+ * `/api/scan` so the UI can show and sort a "last modified" column.
928
+ * NOT content: never participates in `bodyHash` / `frontmatterHash`.
929
+ */
930
+ modifiedAtMs?: number;
900
931
  /**
901
932
  * Step 9.6.2, sidecar denormalisation surface. Populated by the
902
933
  * orchestrator at scan time; absent when the orchestrator did not
@@ -2190,6 +2221,15 @@ interface IRawNode {
2190
2221
  frontmatterRaw: string;
2191
2222
  /** Parsed frontmatter, or `{}` when absent / unparseable. */
2192
2223
  frontmatter: Record<string, unknown>;
2224
+ /**
2225
+ * File modification time (`mtime`) in Unix milliseconds, captured by
2226
+ * the kernel walker from the same `lstat` that guards the read (zero
2227
+ * extra syscalls). Threaded onto the persisted `Node` as
2228
+ * `modifiedAtMs`. Optional: a Provider that ships its own `walk()` and
2229
+ * does not stat its sources MAY omit it; virtual / derived nodes carry
2230
+ * no file and never set it.
2231
+ */
2232
+ modifiedAtMs?: number;
2193
2233
  /**
2194
2234
  * Parser diagnostics (audit L1). Populated by the walker when the
2195
2235
  * parser surfaced `IParseIssue` entries (e.g. malformed YAML).
@@ -3126,6 +3166,32 @@ interface IActionContext {
3126
3166
  */
3127
3167
  settings: Record<string, unknown>;
3128
3168
  }
3169
+ /**
3170
+ * Read-only graph context handed to an Action's scan-time `project()`
3171
+ * method. Mirrors the Analyzer emit path (`IAnalyzerContext`): the
3172
+ * Action sees the full merged graph (`nodes` + `links`) and emits its
3173
+ * own per-node view contributions via `emitContribution`, supplying the
3174
+ * target node path explicitly because, like the Analyzer, it walks the
3175
+ * whole graph rather than running per-node.
3176
+ *
3177
+ * The contribution is declared in the Action's manifest `ui` map and
3178
+ * passed BY REFERENCE (same object-identity model as Extractor /
3179
+ * Analyzer emit). The orchestrator validates the payload against the
3180
+ * slot's schema at call time, dropping invalid emissions with an
3181
+ * `extension.error` event.
3182
+ *
3183
+ * `project()` is strictly DETERMINISTIC and side-effect-free: no writes,
3184
+ * no runner, no IO. It runs during the scan's contribution phase on
3185
+ * EVERY scan, exactly like an Analyzer's emit path, so its cost is the
3186
+ * same per-scan cost as today's projector analyzers. Even an Action
3187
+ * whose `invoke` is `mode: 'probabilistic'` MUST keep `project()`
3188
+ * deterministic, only `invoke` may be probabilistic.
3189
+ */
3190
+ interface IActionProjectionContext {
3191
+ readonly nodes: readonly Node[];
3192
+ readonly links: readonly Link[];
3193
+ emitContribution(nodePath: string, ref: IViewContribution, payload: unknown): void;
3194
+ }
3129
3195
  /**
3130
3196
  * Declarative filter applied by `--all` fan-out, UI button gating, and
3131
3197
  * `sm actions show`. Same shape used by Extractor and Analyzer so the
@@ -3182,6 +3248,23 @@ interface IAction extends IExtensionBase {
3182
3248
  * kernel materialises any returned `writes` after the call.
3183
3249
  */
3184
3250
  invoke?: <TInput, TReport>(input: TInput, ctx: IActionContext) => IActionResult<TReport>;
3251
+ /**
3252
+ * Optional scan-time self-projection. When present, the orchestrator
3253
+ * calls it during the contribution phase (right after the analyzer
3254
+ * pass) with read-only graph access, and the Action emits its OWN
3255
+ * `inspector.action.button` (or any declared `ui` contribution) per
3256
+ * node. This replaces the former "projector analyzer" pattern: the
3257
+ * button now lives with the Action that dispatches it, not in a
3258
+ * sibling Analyzer.
3259
+ *
3260
+ * MUST be deterministic and side-effect-free (no writes, no runner,
3261
+ * no IO), exactly like an Analyzer's emit path. The button declares
3262
+ * its own qualified id as `actionId` in the payload. Actions that ship
3263
+ * for the future probabilistic runner / record path leave it absent;
3264
+ * an Action MAY declare both `project` and `invoke` (advertiser +
3265
+ * executor), or only one.
3266
+ */
3267
+ project?(ctx: IActionProjectionContext): void;
3185
3268
  }
3186
3269
 
3187
3270
  /**
@@ -3648,6 +3731,16 @@ interface IScanExtensions {
3648
3731
  * advisory until the job subsystem ships once the job subsystem ships.
3649
3732
  */
3650
3733
  hooks?: IHook[];
3734
+ /**
3735
+ * Optional enabled actions. When supplied, the orchestrator runs the
3736
+ * action-projection pass right after the analyzer pass: every action
3737
+ * carrying a scan-time `project()` self-projection emits its own view
3738
+ * contributions (e.g. `inspector.action.button`) onto the merged
3739
+ * graph. Actions without `project` (only `invoke`) ride along inert.
3740
+ * Absent → no projection pass runs (the gate is the composed enabled
3741
+ * set, so a disabled / experimental action never reaches here).
3742
+ */
3743
+ actions?: IAction[];
3651
3744
  }
3652
3745
  interface RunScanOptions {
3653
3746
  /**