@skill-map/cli 0.20.0 → 0.21.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 (37) hide show
  1. package/dist/cli/tutorial/sm-tutorial.md +93 -14
  2. package/dist/cli.js +1332 -339
  3. package/dist/cli.js.map +1 -1
  4. package/dist/index.js +300 -238
  5. package/dist/index.js.map +1 -1
  6. package/dist/kernel/index.d.ts +91 -11
  7. package/dist/kernel/index.js +300 -238
  8. package/dist/kernel/index.js.map +1 -1
  9. package/dist/migrations/001_initial.sql +13 -0
  10. package/dist/ui/chunk-25AWRVIC.js +965 -0
  11. package/dist/ui/chunk-6FTVUS57.js +123 -0
  12. package/dist/ui/{chunk-LQTUSDHD.js → chunk-GXRWH2VL.js} +1 -1
  13. package/dist/ui/chunk-MF2M6GYF.js +1 -0
  14. package/dist/ui/{chunk-2W62S3FU.js → chunk-MPMBTIUR.js} +2 -2
  15. package/dist/ui/chunk-N366HMME.js +1 -0
  16. package/dist/ui/{chunk-QICH7GU2.js → chunk-OPPQMCMQ.js} +1 -1
  17. package/dist/ui/chunk-V3SZQETX.js +61 -0
  18. package/dist/ui/{chunk-HJSRMZTK.js → chunk-VVOEPDQD.js} +1 -1
  19. package/dist/ui/{chunk-DLT5AP43.js → chunk-W2EFGI3J.js} +1 -1
  20. package/dist/ui/chunk-W62WVNU4.js +251 -0
  21. package/dist/ui/index.html +2 -10
  22. package/dist/ui/main-NIYE2VFS.js +2 -0
  23. package/dist/ui/media/fa-brands-400-AHOAZHCU.woff2 +0 -0
  24. package/dist/ui/media/fa-regular-400-VRZYIBIZ.woff2 +0 -0
  25. package/dist/ui/media/fa-solid-900-MDEYK55F.woff2 +0 -0
  26. package/dist/ui/media/fa-v4compatibility-ETEVP6IB.woff2 +0 -0
  27. package/dist/ui/styles-M2FETVAG.css +1 -0
  28. package/migrations/001_initial.sql +13 -0
  29. package/package.json +2 -2
  30. package/dist/ui/chunk-C7QWBAYP.js +0 -247
  31. package/dist/ui/chunk-HOBQ4G4O.js +0 -125
  32. package/dist/ui/chunk-IBUV6OG2.js +0 -1
  33. package/dist/ui/chunk-UJRROL5X.js +0 -1
  34. package/dist/ui/chunk-VLNLJAUB.js +0 -61
  35. package/dist/ui/chunk-W3JLG7BI.js +0 -965
  36. package/dist/ui/main-QHE47BCM.js +0 -1
  37. package/dist/ui/styles-VJ5Q6D2X.css +0 -1
package/dist/cli.js CHANGED
@@ -614,20 +614,23 @@ var geminiProvider = {
614
614
  // registry entries (they ship later under the Gemini bundle), but
615
615
  // the qualified form is the contract.
616
616
  //
617
- // UI presentation: Google brand palette Gemini purple for agents,
618
- // Google blue for skills, Claude-equivalent neutral for the markdown
619
- // fallback (so the fallback reads consistent across vendors). Light
620
- // / dark variants follow the same hue with a luminosity flip.
617
+ // UI presentation: kind visuals are normalised across Providers every
618
+ // Provider that contributes `agent` declares the same color + icon as
619
+ // Claude, every Provider that contributes `skill` declares the same
620
+ // color + icon as Claude, etc. The declaration STAYS per-Provider (the
621
+ // shape allows divergence the day a Provider wants its own identity for
622
+ // a kind), but today the values mirror Claude so the visual vocabulary
623
+ // is uniform regardless of where a node was sourced from.
621
624
  kinds: {
622
625
  agent: {
623
626
  schema: "./schemas/agent.schema.json",
624
627
  schemaJson: agent_schema_default2,
625
628
  defaultRefreshAction: "gemini/summarize-agent",
626
629
  ui: {
627
- label: "Gemini Agents",
628
- color: "#9b72cb",
629
- colorDark: "#b794d4",
630
- icon: { kind: "pi", id: "pi-sparkles" }
630
+ label: "Agents",
631
+ color: "#3b82f6",
632
+ colorDark: "#60a5fa",
633
+ icon: { kind: "pi", id: "pi-user" }
631
634
  }
632
635
  },
633
636
  skill: {
@@ -635,9 +638,9 @@ var geminiProvider = {
635
638
  schemaJson: skill_schema_default2,
636
639
  defaultRefreshAction: "gemini/summarize-skill",
637
640
  ui: {
638
- label: "Gemini Skills",
639
- color: "#4285f4",
640
- colorDark: "#669df6",
641
+ label: "Skills",
642
+ color: "#10b981",
643
+ colorDark: "#34d399",
641
644
  icon: { kind: "pi", id: "pi-bolt" }
642
645
  }
643
646
  }
@@ -685,12 +688,9 @@ var agentSkillsProvider = {
685
688
  schemaJson: skill_schema_default3,
686
689
  defaultRefreshAction: "agent-skills/summarize-skill",
687
690
  ui: {
688
- label: "Agent Skills",
689
- // Neutral slate — distinct from Claude green and Gemini blue
690
- // so a node painted with this Provider's color reads as
691
- // "vendor-agnostic open-standard" at a glance.
692
- color: "#64748b",
693
- colorDark: "#94a3b8",
691
+ label: "Skills",
692
+ color: "#10b981",
693
+ colorDark: "#34d399",
694
694
  icon: { kind: "pi", id: "pi-bolt" }
695
695
  }
696
696
  }
@@ -766,7 +766,7 @@ var annotationsExtractor = {
766
766
  pluginId: "core",
767
767
  kind: "extractor",
768
768
  version: "1.0.0",
769
- description: "Reads structured references from the sidecar `.sm` `annotations:` block (supersedes, supersededBy, requires, related, conflictsWith).",
769
+ description: "Turns the `supersedes`, `requires`, `related`, `conflictsWith`, and `supersededBy` entries you write in a node's `.sm` sidecar into the arrows (edges) shown between nodes in the graph.",
770
770
  stability: "stable",
771
771
  emitsLinkKinds: ["supersedes", "references"],
772
772
  defaultConfidence: "high",
@@ -843,25 +843,11 @@ var slashExtractor = {
843
843
  pluginId: "core",
844
844
  kind: "extractor",
845
845
  version: "1.0.0",
846
- description: "Detects /command invocation tokens in the node body.",
846
+ description: "Detects `/command` invocations in a node's body and turns each one into an arrow (edge) between nodes in the graph.",
847
847
  stability: "stable",
848
848
  emitsLinkKinds: ["invokes"],
849
849
  defaultConfidence: "medium",
850
850
  scope: "body",
851
- /**
852
- * View contribution — surface the distinct-invocation count as a
853
- * counter chip in `card.footer.left.counter`, alongside the
854
- * at-directive and markdown-link counters. `emitWhenEmpty: false`
855
- * keeps unrelated nodes free of a `/ 0` decoration.
856
- */
857
- viewContributions: {
858
- count: {
859
- slot: "card.footer.left.counter",
860
- icon: "/",
861
- label: "commands",
862
- emitWhenEmpty: false
863
- }
864
- },
865
851
  extract(ctx) {
866
852
  const seen = /* @__PURE__ */ new Set();
867
853
  for (const match of ctx.body.matchAll(SLASH_RE)) {
@@ -881,9 +867,6 @@ var slashExtractor = {
881
867
  }
882
868
  });
883
869
  }
884
- if (seen.size > 0) {
885
- ctx.emitContribution("count", { value: seen.size });
886
- }
887
870
  }
888
871
  };
889
872
 
@@ -895,25 +878,11 @@ var atDirectiveExtractor = {
895
878
  pluginId: "core",
896
879
  kind: "extractor",
897
880
  version: "1.0.0",
898
- description: "Detects @agent-name mentions in the node body.",
881
+ description: "Detects `@agent-name` mentions in a node's body and turns each one into an arrow (edge) between nodes in the graph.",
899
882
  stability: "stable",
900
883
  emitsLinkKinds: ["mentions"],
901
884
  defaultConfidence: "medium",
902
885
  scope: "body",
903
- /**
904
- * View contribution — surface the distinct-mention count as a
905
- * counter chip in `card.footer.left.counter`. `emitWhenEmpty: false`
906
- * keeps unrelated nodes (no @-handles in the body) free of a `@ 0`
907
- * decoration.
908
- */
909
- viewContributions: {
910
- count: {
911
- slot: "card.footer.left.counter",
912
- icon: "@",
913
- label: "mentions",
914
- emitWhenEmpty: false
915
- }
916
- },
917
886
  extract(ctx) {
918
887
  const seen = /* @__PURE__ */ new Set();
919
888
  for (const match of ctx.body.matchAll(AT_RE)) {
@@ -933,9 +902,6 @@ var atDirectiveExtractor = {
933
902
  }
934
903
  });
935
904
  }
936
- if (seen.size > 0) {
937
- ctx.emitContribution("count", { value: seen.size });
938
- }
939
905
  }
940
906
  };
941
907
 
@@ -948,7 +914,7 @@ var externalUrlCounterExtractor = {
948
914
  pluginId: "core",
949
915
  kind: "extractor",
950
916
  version: "1.0.0",
951
- description: "Counts distinct external http(s) URLs in the node body. Emits pseudo-links the orchestrator strips after counting.",
917
+ description: "Counts the distinct external URLs (`http://` / `https://`) in a node's body and shows the total as a chip on the card.",
952
918
  stability: "stable",
953
919
  emitsLinkKinds: ["references"],
954
920
  defaultConfidence: "low",
@@ -957,14 +923,21 @@ var externalUrlCounterExtractor = {
957
923
  * Phase 6 / View contribution system — surface the distinct-URL
958
924
  * count as a card-footer-right chip. The chip is silent when zero
959
925
  * URLs were emitted (`emitWhenEmpty: false`), so unrelated nodes
960
- * do not gain a `🔗 0` decoration. The counter rides on exactly
926
+ * do not gain a `link 0` decoration. The counter rides on exactly
961
927
  * the same data the orchestrator was already going to count — there
962
928
  * is no second pass.
929
+ *
930
+ * Icon is the PrimeIcons `pi-link` glyph (declared as the bare
931
+ * `'link'` per `IconString` rules in `view-slots.schema.json`).
932
+ * Mirrors the look of the legacy hardcoded `pi pi-link` chip in
933
+ * `node-card.html` it is poised to replace — same icon font, same
934
+ * sizing inherited from the footer `.sm-gnode__stat` styles cloned
935
+ * by the `NodeCounter` renderer.
963
936
  */
964
937
  viewContributions: {
965
938
  count: {
966
939
  slot: "card.footer.right",
967
- icon: "\u{1F517}",
940
+ icon: "pi-link",
968
941
  label: "urls",
969
942
  emitWhenEmpty: false
970
943
  }
@@ -1040,25 +1013,11 @@ var markdownLinkExtractor = {
1040
1013
  pluginId: "core",
1041
1014
  kind: "extractor",
1042
1015
  version: "1.0.0",
1043
- description: "Detects [text](path) markdown links and emits one references link per resolved file path.",
1016
+ description: "Detects markdown links (`[text](path)`) in a node's body and turns each one into an arrow (edge) between nodes in the graph.",
1044
1017
  stability: "stable",
1045
1018
  emitsLinkKinds: ["references"],
1046
1019
  defaultConfidence: "high",
1047
1020
  scope: "body",
1048
- /**
1049
- * View contribution — surface the distinct-link count as a counter
1050
- * chip in `card.footer.left.counter`, alongside the at-directive
1051
- * (`@`) and slash (`/`) counters. `emitWhenEmpty: false` keeps
1052
- * unrelated nodes (no markdown links) free of a `📎 0` decoration.
1053
- */
1054
- viewContributions: {
1055
- count: {
1056
- slot: "card.footer.left.counter",
1057
- icon: "\u{1F4CE}",
1058
- label: "links",
1059
- emitWhenEmpty: false
1060
- }
1061
- },
1062
1021
  extract(ctx) {
1063
1022
  const seen = /* @__PURE__ */ new Set();
1064
1023
  const lineStarts = computeLineStarts2(ctx.body);
@@ -1084,9 +1043,6 @@ var markdownLinkExtractor = {
1084
1043
  };
1085
1044
  ctx.emitLink(link2);
1086
1045
  }
1087
- if (seen.size > 0) {
1088
- ctx.emitContribution("count", { value: seen.size });
1089
- }
1090
1046
  }
1091
1047
  };
1092
1048
  function resolveTarget(sourceDir, raw) {
@@ -1117,6 +1073,107 @@ function lineFor2(lineStarts, offset) {
1117
1073
  return lo + 1;
1118
1074
  }
1119
1075
 
1076
+ // built-in-plugins/extractors/stability/index.ts
1077
+ var ID6 = "stability";
1078
+ var EXPERIMENTAL_TOOLTIP = "Experimental \u2014 API may change";
1079
+ var DEPRECATED_TOOLTIP = "Deprecated \u2014 avoid in new code";
1080
+ var stabilityExtractor = {
1081
+ id: ID6,
1082
+ pluginId: "core",
1083
+ kind: "extractor",
1084
+ version: "1.0.0",
1085
+ description: "Shows an icon chip on the card footer when the node is marked `stability: experimental` or `stability: deprecated` (read from the sidecar `annotations:` block, with legacy `metadata:` frontmatter as fallback).",
1086
+ stability: "stable",
1087
+ emitsLinkKinds: [],
1088
+ defaultConfidence: "high",
1089
+ scope: "frontmatter",
1090
+ viewContributions: {
1091
+ experimental: {
1092
+ slot: "card.footer.right",
1093
+ icon: "fa-solid fa-flask",
1094
+ label: "experimental",
1095
+ emitWhenEmpty: false
1096
+ },
1097
+ deprecated: {
1098
+ slot: "card.footer.right",
1099
+ icon: "pi-ban",
1100
+ label: "deprecated",
1101
+ emitWhenEmpty: false
1102
+ }
1103
+ },
1104
+ extract(ctx) {
1105
+ const stability = readStability(ctx);
1106
+ if (stability === "experimental") {
1107
+ ctx.emitContribution("experimental", {
1108
+ value: 0,
1109
+ tooltip: EXPERIMENTAL_TOOLTIP
1110
+ });
1111
+ } else if (stability === "deprecated") {
1112
+ ctx.emitContribution("deprecated", {
1113
+ value: 0,
1114
+ tooltip: DEPRECATED_TOOLTIP,
1115
+ severity: "warn"
1116
+ });
1117
+ }
1118
+ }
1119
+ };
1120
+ function readStability(ctx) {
1121
+ const ann = ctx.node.sidecar?.annotations;
1122
+ const fromAnn = ann?.["stability"];
1123
+ if (isStability(fromAnn)) return fromAnn;
1124
+ const meta = ctx.frontmatter["metadata"];
1125
+ if (meta && typeof meta === "object" && !Array.isArray(meta)) {
1126
+ const legacy = meta["stability"];
1127
+ if (isStability(legacy)) return legacy;
1128
+ }
1129
+ return null;
1130
+ }
1131
+ function isStability(value) {
1132
+ return value === "experimental" || value === "deprecated" || value === "stable";
1133
+ }
1134
+
1135
+ // built-in-plugins/extractors/tools-count/index.ts
1136
+ var ID7 = "tools-count";
1137
+ var TOOLTIP_MAX = 255;
1138
+ var toolsCountExtractor = {
1139
+ id: ID7,
1140
+ pluginId: "core",
1141
+ kind: "extractor",
1142
+ version: "1.0.0",
1143
+ description: "Counts the tools an agent declares in its frontmatter and shows the total as a wrench chip on the agent card.",
1144
+ stability: "stable",
1145
+ emitsLinkKinds: [],
1146
+ defaultConfidence: "high",
1147
+ scope: "frontmatter",
1148
+ applicableKinds: ["agent"],
1149
+ viewContributions: {
1150
+ count: {
1151
+ slot: "card.footer.left",
1152
+ icon: "pi-wrench",
1153
+ label: "tools",
1154
+ emitWhenEmpty: false
1155
+ }
1156
+ },
1157
+ extract(ctx) {
1158
+ const raw = ctx.frontmatter["tools"];
1159
+ if (!Array.isArray(raw)) return;
1160
+ const names = [];
1161
+ for (const t of raw) {
1162
+ if (typeof t === "string" && t.length > 0) names.push(t);
1163
+ }
1164
+ if (names.length === 0) return;
1165
+ ctx.emitContribution("count", {
1166
+ value: names.length,
1167
+ tooltip: buildTooltip(names)
1168
+ });
1169
+ }
1170
+ };
1171
+ function buildTooltip(names) {
1172
+ const joined = names.join(" \xB7 ");
1173
+ if (joined.length <= TOOLTIP_MAX) return joined;
1174
+ return `${joined.slice(0, TOOLTIP_MAX - 1)}\u2026`;
1175
+ }
1176
+
1120
1177
  // built-in-plugins/i18n/trigger-collision.texts.ts
1121
1178
  var TRIGGER_COLLISION_TEXTS = {
1122
1179
  /**
@@ -1143,19 +1200,19 @@ var TRIGGER_COLLISION_TEXTS = {
1143
1200
  };
1144
1201
 
1145
1202
  // built-in-plugins/analyzers/trigger-collision/index.ts
1146
- var ID6 = "trigger-collision";
1203
+ var ID8 = "trigger-collision";
1147
1204
  var ADVERTISING_KINDS = /* @__PURE__ */ new Set([
1148
1205
  "command",
1149
1206
  "skill",
1150
1207
  "agent"
1151
1208
  ]);
1152
1209
  var triggerCollisionAnalyzer = {
1153
- id: ID6,
1210
+ id: ID8,
1154
1211
  pluginId: "core",
1155
1212
  kind: "analyzer",
1156
1213
  mode: "deterministic",
1157
1214
  version: "1.0.0",
1158
- description: "Flags trigger names (/command, @agent) claimed by multiple distinct nodes \u2014 by advertisement (frontmatter.name) or by invocation.",
1215
+ description: "Flags when two or more nodes claim the same `/command` or `@agent` name \u2014 either by their `name` field or by how they are invoked elsewhere.",
1159
1216
  stability: "stable",
1160
1217
  // Two claim-collection passes (advertisement + invocation) feeding
1161
1218
  // the bucket map. Per-bucket analysis lives in `analyzeTriggerBucket`.
@@ -1250,7 +1307,7 @@ function analyzeTriggerBucket(normalized, claims) {
1250
1307
  part: parts[0]
1251
1308
  });
1252
1309
  return {
1253
- analyzerId: ID6,
1310
+ analyzerId: ID8,
1254
1311
  severity: "error",
1255
1312
  nodeIds,
1256
1313
  message,
@@ -1268,35 +1325,79 @@ import { resolve } from "path";
1268
1325
  // built-in-plugins/i18n/broken-ref.texts.ts
1269
1326
  var BROKEN_REF_TEXTS = {
1270
1327
  /** `Broken <kind> reference from <source> → <target>` */
1271
- message: "Broken {{kind}} reference from {{source}} \u2192 {{target}}"
1328
+ message: "Broken {{kind}} reference from {{source}} \u2192 {{target}}",
1329
+ // Tooltips for the per-node view-contribution badges. Singular vs
1330
+ // plural keeps the count grammar correct without a sub-template.
1331
+ alertTooltipSingle: "This node has a broken reference. Open the inspector for details.",
1332
+ alertTooltipMany: "This node has {{count}} broken references. Open the inspector for details."
1272
1333
  };
1273
1334
 
1274
1335
  // built-in-plugins/analyzers/broken-ref/index.ts
1275
- var ID7 = "broken-ref";
1336
+ var ID9 = "broken-ref";
1276
1337
  var brokenRefAnalyzer = {
1277
- id: ID7,
1338
+ id: ID9,
1278
1339
  pluginId: "core",
1279
1340
  kind: "analyzer",
1280
1341
  version: "1.0.0",
1281
- description: "Flags links whose target cannot be resolved to a scanned node.",
1342
+ description: "Flags arrows pointing at a node that is not part of the current scan (broken link).",
1282
1343
  stability: "stable",
1283
1344
  mode: "deterministic",
1345
+ viewContributions: {
1346
+ // Corner badge on the graph card; count omitted when there is a
1347
+ // single broken ref (avoids a noisy "icon + 1" chip).
1348
+ alert: {
1349
+ slot: "graph.node.alert",
1350
+ icon: "fa-solid fa-circle-xmark",
1351
+ emitWhenEmpty: false
1352
+ },
1353
+ // Footer chip on the card. `_counter` shape — `value` always shows,
1354
+ // so the operator sees "how many" at a glance. Renders OUTLINED
1355
+ // (`fa-regular`) so the corner alert (filled, attention-grabbing)
1356
+ // and the footer chip (quieter, paired with a number) read as two
1357
+ // beats of the same signal rather than two identical glyphs.
1358
+ chip: {
1359
+ slot: "card.footer.right",
1360
+ icon: "fa-regular fa-circle-xmark",
1361
+ emitWhenEmpty: false
1362
+ }
1363
+ },
1364
+ // The resolver, the reference-paths escape hatch, the per-source
1365
+ // aggregation, and the dual-slot emit (with single/plural tooltip and
1366
+ // optional count) all live in one flow because they share the per-link
1367
+ // loop. Splitting them would re-walk `ctx.links` three times.
1368
+ // eslint-disable-next-line complexity
1284
1369
  evaluate(ctx) {
1285
1370
  const byPath3 = new Set(ctx.nodes.map((n) => n.path));
1286
1371
  const byNormalizedName = indexByNormalizedName(ctx.nodes);
1287
1372
  const refIndex = ctx.referenceablePaths && ctx.referenceablePaths.size > 0 && ctx.cwd ? { paths: ctx.referenceablePaths, cwd: ctx.cwd } : null;
1288
1373
  const issues = [];
1374
+ const perNode = /* @__PURE__ */ new Map();
1289
1375
  for (const link2 of ctx.links) {
1290
1376
  if (isResolved(link2, byPath3, byNormalizedName)) continue;
1291
1377
  if (refIndex && resolvesViaReferencePaths(link2, refIndex)) continue;
1292
1378
  issues.push(buildIssue(link2));
1379
+ perNode.set(link2.source, (perNode.get(link2.source) ?? 0) + 1);
1380
+ }
1381
+ for (const [nodePath, count] of perNode) {
1382
+ const tooltip = count === 1 ? BROKEN_REF_TEXTS.alertTooltipSingle : tx(BROKEN_REF_TEXTS.alertTooltipMany, { count });
1383
+ const capped = Math.min(count, 99);
1384
+ ctx.emitContribution(nodePath, "alert", {
1385
+ icon: "fa-solid fa-circle-xmark",
1386
+ severity: "danger",
1387
+ tooltip
1388
+ });
1389
+ ctx.emitContribution(nodePath, "chip", {
1390
+ value: capped,
1391
+ severity: "danger",
1392
+ tooltip
1393
+ });
1293
1394
  }
1294
1395
  return issues;
1295
1396
  }
1296
1397
  };
1297
1398
  function buildIssue(link2) {
1298
1399
  return {
1299
- analyzerId: ID7,
1400
+ analyzerId: ID9,
1300
1401
  severity: "warn",
1301
1402
  nodeIds: [link2.source],
1302
1403
  message: tx(BROKEN_REF_TEXTS.message, {
@@ -1350,13 +1451,13 @@ var SUPERSEDED_TEXTS = {
1350
1451
  };
1351
1452
 
1352
1453
  // built-in-plugins/analyzers/superseded/index.ts
1353
- var ID8 = "superseded";
1454
+ var ID10 = "superseded";
1354
1455
  var supersededAnalyzer = {
1355
- id: ID8,
1456
+ id: ID10,
1356
1457
  pluginId: "core",
1357
1458
  kind: "analyzer",
1358
1459
  version: "1.0.0",
1359
- description: "Surfaces nodes whose sidecar annotations declare a supersededBy replacement.",
1460
+ description: "Marks nodes that have been replaced by a newer one (the sidecar declares `supersededBy`).",
1360
1461
  stability: "stable",
1361
1462
  mode: "deterministic",
1362
1463
  evaluate(ctx) {
@@ -1365,7 +1466,7 @@ var supersededAnalyzer = {
1365
1466
  const supersededBy = pickSupersededBy(node);
1366
1467
  if (supersededBy === null) continue;
1367
1468
  issues.push({
1368
- analyzerId: ID8,
1469
+ analyzerId: ID10,
1369
1470
  severity: "info",
1370
1471
  nodeIds: [node.path],
1371
1472
  message: tx(SUPERSEDED_TEXTS.message, {
@@ -1395,13 +1496,13 @@ var LINK_CONFLICT_TEXTS = {
1395
1496
  };
1396
1497
 
1397
1498
  // built-in-plugins/analyzers/link-conflict/index.ts
1398
- var ID9 = "link-conflict";
1499
+ var ID11 = "link-conflict";
1399
1500
  var linkConflictAnalyzer = {
1400
- id: ID9,
1501
+ id: ID11,
1401
1502
  pluginId: "core",
1402
1503
  kind: "analyzer",
1403
1504
  version: "1.0.0",
1404
- description: "Flags (source, target) pairs where detectors disagree on the link kind.",
1505
+ description: 'Flags when two extractors disagree on the meaning of the same arrow (e.g. one says "references", the other says "invokes").',
1405
1506
  stability: "stable",
1406
1507
  mode: "deterministic",
1407
1508
  // Bucket links by (source, target), then per-bucket detect distinct
@@ -1446,7 +1547,7 @@ var linkConflictAnalyzer = {
1446
1547
  const [source, target] = key.split("\0");
1447
1548
  const kindList = variants.map((v) => v.kind).join(" / ");
1448
1549
  issues.push({
1449
- analyzerId: ID9,
1550
+ analyzerId: ID11,
1450
1551
  severity: "warn",
1451
1552
  nodeIds: [source, target],
1452
1553
  message: tx(LINK_CONFLICT_TEXTS.message, {
@@ -1478,19 +1579,44 @@ var ANNOTATION_STALE_TEXTS = {
1478
1579
  /** frontmatter changed since last bump */
1479
1580
  frontmatterDrift: "{{path}}: sidecar `.sm` is stale (frontmatter changed since last bump).",
1480
1581
  /** both body and frontmatter changed */
1481
- bothDrift: "{{path}}: sidecar `.sm` is stale (body and frontmatter changed since last bump)."
1582
+ bothDrift: "{{path}}: sidecar `.sm` is stale (body and frontmatter changed since last bump).",
1583
+ // Tooltips for the `card.footer.right` clock chip emitted alongside
1584
+ // the issue. Lists only the drifted face(s) — in-sync faces are
1585
+ // omitted so the operator immediately sees what's modified without
1586
+ // scanning prose. No `{{path}}` placeholder — the chip already sits
1587
+ // on the affected node. The hint `sm bump <path>` keeps `<path>` as
1588
+ // a literal placeholder the operator substitutes.
1589
+ bodyTooltip: "Sidecar drift since last bump:\n \u2022 body\nRun `sm bump <path>` to refresh.",
1590
+ frontmatterTooltip: "Sidecar drift since last bump:\n \u2022 frontmatter\nRun `sm bump <path>` to refresh.",
1591
+ bothTooltip: "Sidecar drift since last bump:\n \u2022 body\n \u2022 frontmatter\nRun `sm bump <path>` to refresh."
1482
1592
  };
1483
1593
 
1484
1594
  // built-in-plugins/analyzers/annotation-stale/index.ts
1485
- var ID10 = "annotation-stale";
1595
+ var ID12 = "annotation-stale";
1486
1596
  var annotationStaleAnalyzer = {
1487
- id: ID10,
1597
+ id: ID12,
1488
1598
  pluginId: "core",
1489
1599
  kind: "analyzer",
1490
1600
  version: "1.0.0",
1491
- description: "Surfaces nodes whose co-located .sm sidecar is stale relative to current hashes.",
1601
+ description: "Marks nodes whose `.sm` sidecar is out of date \u2014 the `.md` content changed since the last sidecar bump. Surfaces an Issue (panel) plus a `pi-clock` chip in the card footer.",
1492
1602
  stability: "stable",
1493
1603
  mode: "deterministic",
1604
+ viewContributions: {
1605
+ // A `pi-clock` chip in the footer-right cluster so the operator
1606
+ // spots drift in the list / inspector view (and on the graph card
1607
+ // body). Emitted with `value: 0` and `emitWhenEmpty: true` so the
1608
+ // renderer treats it as icon-only — drift severity is binary at
1609
+ // this surface (the tooltip carries the per-face detail body /
1610
+ // frontmatter / both). The corner badge on `graph.node.alert` was
1611
+ // dropped on purpose: a tooltip on the footer chip is enough, and
1612
+ // the corner badge stacked on top of broken-ref / unknown-field
1613
+ // alerts produced visual noise.
1614
+ staleIcon: {
1615
+ slot: "card.footer.right",
1616
+ icon: "pi-clock",
1617
+ emitWhenEmpty: true
1618
+ }
1619
+ },
1494
1620
  evaluate(ctx) {
1495
1621
  const issues = [];
1496
1622
  for (const node of ctx.nodes) {
@@ -1499,16 +1625,31 @@ var annotationStaleAnalyzer = {
1499
1625
  if (status === "fresh") continue;
1500
1626
  const message = status === "stale-body" ? tx(ANNOTATION_STALE_TEXTS.bodyDrift, { path: node.path }) : status === "stale-frontmatter" ? tx(ANNOTATION_STALE_TEXTS.frontmatterDrift, { path: node.path }) : tx(ANNOTATION_STALE_TEXTS.bothDrift, { path: node.path });
1501
1627
  issues.push({
1502
- analyzerId: ID10,
1628
+ analyzerId: ID12,
1503
1629
  severity: "warn",
1504
1630
  nodeIds: [node.path],
1505
1631
  message,
1506
1632
  data: { status }
1507
1633
  });
1634
+ ctx.emitContribution(node.path, "staleIcon", {
1635
+ value: 0,
1636
+ severity: "warn",
1637
+ tooltip: tooltipFor(status)
1638
+ });
1508
1639
  }
1509
1640
  return issues;
1510
1641
  }
1511
1642
  };
1643
+ function tooltipFor(status) {
1644
+ switch (status) {
1645
+ case "stale-body":
1646
+ return ANNOTATION_STALE_TEXTS.bodyTooltip;
1647
+ case "stale-frontmatter":
1648
+ return ANNOTATION_STALE_TEXTS.frontmatterTooltip;
1649
+ case "stale-both":
1650
+ return ANNOTATION_STALE_TEXTS.bothTooltip;
1651
+ }
1652
+ }
1512
1653
 
1513
1654
  // built-in-plugins/i18n/annotation-orphan.texts.ts
1514
1655
  var ANNOTATION_ORPHAN_TEXTS = {
@@ -1517,13 +1658,13 @@ var ANNOTATION_ORPHAN_TEXTS = {
1517
1658
  };
1518
1659
 
1519
1660
  // built-in-plugins/analyzers/annotation-orphan/index.ts
1520
- var ID11 = "annotation-orphan";
1661
+ var ID13 = "annotation-orphan";
1521
1662
  var annotationOrphanAnalyzer = {
1522
- id: ID11,
1663
+ id: ID13,
1523
1664
  pluginId: "core",
1524
1665
  kind: "analyzer",
1525
1666
  version: "1.0.0",
1526
- description: "Flags .sm sidecars whose accompanying .md node is missing on disk.",
1667
+ description: "Flags `.sm` sidecars whose matching `.md` file no longer exists on disk.",
1527
1668
  stability: "stable",
1528
1669
  mode: "deterministic",
1529
1670
  evaluate(ctx) {
@@ -1533,7 +1674,7 @@ var annotationOrphanAnalyzer = {
1533
1674
  for (const orphan of orphans) {
1534
1675
  const expectedMdRelative = orphan.relativePath.endsWith(".sm") ? `${orphan.relativePath.slice(0, -".sm".length)}.md` : `${orphan.relativePath}.md`;
1535
1676
  issues.push({
1536
- analyzerId: ID11,
1677
+ analyzerId: ID13,
1537
1678
  severity: "warn",
1538
1679
  nodeIds: [expectedMdRelative],
1539
1680
  message: tx(ANNOTATION_ORPHAN_TEXTS.message, {
@@ -1560,13 +1701,13 @@ var JOB_ORPHAN_FILE_TEXTS = {
1560
1701
  };
1561
1702
 
1562
1703
  // built-in-plugins/analyzers/job-orphan-file/index.ts
1563
- var ID12 = "job-orphan-file";
1704
+ var ID14 = "job-orphan-file";
1564
1705
  var jobOrphanFileAnalyzer = {
1565
- id: ID12,
1706
+ id: ID14,
1566
1707
  pluginId: "core",
1567
1708
  kind: "analyzer",
1568
1709
  version: "1.0.0",
1569
- description: "Flags MD files under .skill-map/jobs/ that no state_jobs row references. Cleanup via `sm job prune --orphan-files`.",
1710
+ description: "Flags leftover job result files in `.skill-map/jobs/` that no live job references. Cleanup via `sm job prune --orphan-files`.",
1570
1711
  stability: "stable",
1571
1712
  mode: "deterministic",
1572
1713
  evaluate(ctx) {
@@ -1575,7 +1716,7 @@ var jobOrphanFileAnalyzer = {
1575
1716
  const issues = [];
1576
1717
  for (const filePath of orphans) {
1577
1718
  issues.push({
1578
- analyzerId: ID12,
1719
+ analyzerId: ID14,
1579
1720
  severity: "warn",
1580
1721
  nodeIds: [filePath],
1581
1722
  message: tx(JOB_ORPHAN_FILE_TEXTS.message, { filePath }),
@@ -1606,20 +1747,48 @@ var UNKNOWN_FIELD_TEXTS = {
1606
1747
  /** Top-level key is neither reserved, nor a registered plugin namespace, nor a registered root key. */
1607
1748
  unknownRootKey: "{{path}}: sidecar declares unknown top-level key '{{key}}' \u2014 not a reserved block, not a registered plugin namespace, not a registered root contribution.",
1608
1749
  /** Value under a registered plugin namespace fails the contributed schema. */
1609
- pluginNamespaceInvalid: "{{path}}: sidecar block '{{pluginId}}.{{key}}' fails the schema contributed by plugin '{{pluginId}}' \u2014 {{errors}}."
1750
+ pluginNamespaceInvalid: "{{path}}: sidecar block '{{pluginId}}.{{key}}' fails the schema contributed by plugin '{{pluginId}}' \u2014 {{errors}}.",
1751
+ // Tooltips for the per-node view-contribution badges. Singular vs
1752
+ // plural keeps the count grammar correct without a sub-template.
1753
+ alertTooltipSingle: "This node has 1 unknown field in its sidecar. Open the inspector for details.",
1754
+ alertTooltipMany: "This node has {{count}} unknown fields in its sidecar. Open the inspector for details."
1610
1755
  };
1611
1756
 
1612
1757
  // built-in-plugins/analyzers/unknown-field/index.ts
1613
- var ID13 = "unknown-field";
1758
+ var ID15 = "unknown-field";
1614
1759
  var RESERVED_ROOT_BLOCKS = /* @__PURE__ */ new Set(["identity", "annotations", "settings", "audit"]);
1615
1760
  var unknownFieldAnalyzer = {
1616
- id: ID13,
1761
+ id: ID15,
1617
1762
  pluginId: "core",
1618
1763
  kind: "analyzer",
1619
1764
  version: "1.0.0",
1620
- description: "Tier-1 typo guard. Warns on unknown keys inside annotations: and at the sidecar root, and on plugin-namespaced values that fail their contributed schema.",
1765
+ description: "Catches typos and unrecognized keys inside `.sm` sidecars, including plugin-contributed annotation fields that fail their own schema.",
1621
1766
  stability: "stable",
1622
1767
  mode: "deterministic",
1768
+ viewContributions: {
1769
+ // Corner badge on the graph card; count omitted when there is a
1770
+ // single unknown field (avoids a noisy "icon + 1" chip).
1771
+ alert: {
1772
+ slot: "graph.node.alert",
1773
+ // Filled warning triangle on the corner — matches the broken-ref
1774
+ // alert's "attention-grabbing solid" pattern; the footer chip
1775
+ // below stays outlined for the quieter counter pairing.
1776
+ icon: "fa-solid fa-triangle-exclamation",
1777
+ emitWhenEmpty: false
1778
+ },
1779
+ // Footer chip on the card — `_counter` shape but rendered icon-only
1780
+ // (the analyzer emits `value: 0` so NodeCounter hides the number
1781
+ // and only the glyph shows). PrimeIcons `pi-question-circle` so the
1782
+ // visual weight matches `annotation-stale`'s `pi-clock` chip
1783
+ // sitting next to it on the same footer row. `emitWhenEmpty: true`
1784
+ // is required: with `value: 0` the slot treats the payload as
1785
+ // empty, so the manifest has to opt in to keep the emission.
1786
+ chip: {
1787
+ slot: "card.footer.right",
1788
+ icon: "pi-question-circle",
1789
+ emitWhenEmpty: true
1790
+ }
1791
+ },
1623
1792
  // eslint-disable-next-line complexity
1624
1793
  evaluate(ctx) {
1625
1794
  const sidecarRoots = ctx.sidecarRoots;
@@ -1630,6 +1799,10 @@ var unknownFieldAnalyzer = {
1630
1799
  const rootKeys = indexRootContributions(contributions);
1631
1800
  const knownPluginIds = collectPluginIds(contributions);
1632
1801
  const issues = [];
1802
+ const perNode = /* @__PURE__ */ new Map();
1803
+ const bump2 = (nodePath) => {
1804
+ perNode.set(nodePath, (perNode.get(nodePath) ?? 0) + 1);
1805
+ };
1633
1806
  for (const node of ctx.nodes) {
1634
1807
  const root = sidecarRoots.get(node.path);
1635
1808
  if (!root) continue;
@@ -1638,7 +1811,7 @@ var unknownFieldAnalyzer = {
1638
1811
  for (const key of Object.keys(annotations)) {
1639
1812
  if (!knownAnnotationKeys.has(key)) {
1640
1813
  issues.push({
1641
- analyzerId: ID13,
1814
+ analyzerId: ID15,
1642
1815
  severity: "warn",
1643
1816
  nodeIds: [node.path],
1644
1817
  message: tx(UNKNOWN_FIELD_TEXTS.unknownAnnotationKey, {
@@ -1647,6 +1820,7 @@ var unknownFieldAnalyzer = {
1647
1820
  }),
1648
1821
  data: { surface: "annotations", key }
1649
1822
  });
1823
+ bump2(node.path);
1650
1824
  }
1651
1825
  }
1652
1826
  }
@@ -1664,7 +1838,7 @@ var unknownFieldAnalyzer = {
1664
1838
  if (validator(value)) continue;
1665
1839
  const errors = (validator.errors ?? []).map((e) => `${e.instancePath || "(root)"} ${e.message ?? e.keyword}`).join("; ");
1666
1840
  issues.push({
1667
- analyzerId: ID13,
1841
+ analyzerId: ID15,
1668
1842
  severity: "warn",
1669
1843
  nodeIds: [node.path],
1670
1844
  message: tx(UNKNOWN_FIELD_TEXTS.pluginNamespaceInvalid, {
@@ -1675,11 +1849,12 @@ var unknownFieldAnalyzer = {
1675
1849
  }),
1676
1850
  data: { surface: "plugin-namespace", pluginId: key, key: contribKey }
1677
1851
  });
1852
+ bump2(node.path);
1678
1853
  }
1679
1854
  continue;
1680
1855
  }
1681
1856
  issues.push({
1682
- analyzerId: ID13,
1857
+ analyzerId: ID15,
1683
1858
  severity: "warn",
1684
1859
  nodeIds: [node.path],
1685
1860
  message: tx(UNKNOWN_FIELD_TEXTS.unknownRootKey, {
@@ -1688,8 +1863,22 @@ var unknownFieldAnalyzer = {
1688
1863
  }),
1689
1864
  data: { surface: "root", key }
1690
1865
  });
1866
+ bump2(node.path);
1691
1867
  }
1692
1868
  }
1869
+ for (const [nodePath, count] of perNode) {
1870
+ const tooltip = count === 1 ? UNKNOWN_FIELD_TEXTS.alertTooltipSingle : tx(UNKNOWN_FIELD_TEXTS.alertTooltipMany, { count });
1871
+ ctx.emitContribution(nodePath, "alert", {
1872
+ icon: "fa-solid fa-triangle-exclamation",
1873
+ severity: "warn",
1874
+ tooltip
1875
+ });
1876
+ ctx.emitContribution(nodePath, "chip", {
1877
+ value: 0,
1878
+ severity: "warn",
1879
+ tooltip
1880
+ });
1881
+ }
1693
1882
  return issues;
1694
1883
  }
1695
1884
  };
@@ -1737,11 +1926,11 @@ function collectPluginIds(contributions) {
1737
1926
  }
1738
1927
 
1739
1928
  // built-in-plugins/analyzers/unknown-slot/index.ts
1740
- var ID14 = "unknown-slot";
1929
+ var ID16 = "unknown-slot";
1741
1930
  var KNOWN_SLOTS = /* @__PURE__ */ new Set([
1742
1931
  "card.title.right",
1743
1932
  "card.subtitle.left",
1744
- "card.footer.left.counter",
1933
+ "card.footer.left",
1745
1934
  "card.footer.right",
1746
1935
  "graph.node.alert",
1747
1936
  "inspector.header.badge.counter",
@@ -1752,14 +1941,14 @@ var KNOWN_SLOTS = /* @__PURE__ */ new Set([
1752
1941
  "inspector.body.panel.key-values",
1753
1942
  "inspector.body.panel.link-list",
1754
1943
  "inspector.body.panel.markdown",
1755
- "topbar.actions.indicator"
1944
+ "topbar.nav.start"
1756
1945
  ]);
1757
1946
  var unknownSlotAnalyzer = {
1758
- id: ID14,
1947
+ id: ID16,
1759
1948
  pluginId: "core",
1760
1949
  kind: "analyzer",
1761
1950
  version: "1.0.0",
1762
- description: "Warns on plugin view contributions that reference a slot not in the current closed catalog.",
1951
+ description: "Warns when a plugin tries to render in a UI position that does not exist (typo or removed in a newer skill-map version).",
1763
1952
  stability: "stable",
1764
1953
  mode: "deterministic",
1765
1954
  evaluate(ctx) {
@@ -1770,7 +1959,7 @@ var unknownSlotAnalyzer = {
1770
1959
  if (KNOWN_SLOTS.has(c.slot)) continue;
1771
1960
  const qualified = `${c.pluginId}/${c.extensionId}/${c.contributionId}`;
1772
1961
  issues.push({
1773
- analyzerId: ID14,
1962
+ analyzerId: ID16,
1774
1963
  severity: "warn",
1775
1964
  nodeIds: [],
1776
1965
  message: `Plugin ${qualified} declares unknown slot '${c.slot}'. Run \`sm plugins upgrade ${c.pluginId}\` or update the plugin to a slot in the current catalog (\`sm plugins slots list\`).`,
@@ -1787,13 +1976,13 @@ var unknownSlotAnalyzer = {
1787
1976
  };
1788
1977
 
1789
1978
  // built-in-plugins/analyzers/contribution-orphan/index.ts
1790
- var ID15 = "contribution-orphan";
1979
+ var ID17 = "contribution-orphan";
1791
1980
  var contributionOrphanAnalyzer = {
1792
- id: ID15,
1981
+ id: ID17,
1793
1982
  pluginId: "core",
1794
1983
  kind: "analyzer",
1795
1984
  version: "1.0.0",
1796
- description: "Warns when scan_contributions rows reference nodes that no longer exist (post-rename heuristic miss).",
1985
+ description: "Warns when a plugin's per-node chips reference a node that was renamed or deleted in the latest scan.",
1797
1986
  stability: "experimental",
1798
1987
  mode: "deterministic",
1799
1988
  evaluate(_ctx) {
@@ -1829,14 +2018,14 @@ var ASCII_FORMATTER_TEXTS = {
1829
2018
  };
1830
2019
 
1831
2020
  // built-in-plugins/formatters/ascii/index.ts
1832
- var ID16 = "ascii";
2021
+ var ID18 = "ascii";
1833
2022
  var KIND_ORDER = ["agent", "command", "skill", "markdown"];
1834
2023
  var asciiFormatter = {
1835
- id: ID16,
2024
+ id: ID18,
1836
2025
  pluginId: "core",
1837
2026
  kind: "formatter",
1838
2027
  version: "1.0.0",
1839
- description: "Plain-text graph dump, grouped by node kind then links then issues.",
2028
+ description: "Plain-text dump of the scan grouped by kind, then arrows, then issues. Used by `sm scan --format=ascii`.",
1840
2029
  stability: "stable",
1841
2030
  formatId: "ascii",
1842
2031
  // ASCII tree formatter — header + per-kind sections + per-issue
@@ -2005,7 +2194,7 @@ function buildSchemaValidators() {
2005
2194
  const KNOWN_SLOTS2 = /* @__PURE__ */ new Set([
2006
2195
  "card.title.right",
2007
2196
  "card.subtitle.left",
2008
- "card.footer.left.counter",
2197
+ "card.footer.left",
2009
2198
  "card.footer.right",
2010
2199
  "graph.node.alert",
2011
2200
  "inspector.header.badge.counter",
@@ -2016,7 +2205,7 @@ function buildSchemaValidators() {
2016
2205
  "inspector.body.panel.key-values",
2017
2206
  "inspector.body.panel.link-list",
2018
2207
  "inspector.body.panel.markdown",
2019
- "topbar.actions.indicator"
2208
+ "topbar.nav.start"
2020
2209
  ]);
2021
2210
  function getContributionValidator(slot) {
2022
2211
  if (!KNOWN_SLOTS2.has(slot)) return null;
@@ -2139,9 +2328,9 @@ var VALIDATE_ALL_TEXTS = {
2139
2328
  };
2140
2329
 
2141
2330
  // built-in-plugins/analyzers/validate-all/index.ts
2142
- var ID17 = "validate-all";
2331
+ var ID19 = "validate-all";
2143
2332
  var validateAllAnalyzer = {
2144
- id: ID17,
2333
+ id: ID19,
2145
2334
  pluginId: "core",
2146
2335
  kind: "analyzer",
2147
2336
  version: "1.0.0",
@@ -2164,7 +2353,7 @@ function collectNodeFindings(v, node, out) {
2164
2353
  const result = v.validate("node", toNodeForSchema(node));
2165
2354
  if (result.ok) return;
2166
2355
  out.push({
2167
- analyzerId: ID17,
2356
+ analyzerId: ID19,
2168
2357
  severity: "error",
2169
2358
  nodeIds: [node.path],
2170
2359
  message: tx(VALIDATE_ALL_TEXTS.nodeFailure, {
@@ -2178,7 +2367,7 @@ function collectLinkFindings(v, link2, out) {
2178
2367
  const result = v.validate("link", toLinkForSchema(link2));
2179
2368
  if (result.ok) return;
2180
2369
  out.push({
2181
- analyzerId: ID17,
2370
+ analyzerId: ID19,
2182
2371
  severity: "error",
2183
2372
  nodeIds: [link2.source],
2184
2373
  message: tx(VALIDATE_ALL_TEXTS.linkFailure, {
@@ -2219,19 +2408,67 @@ function toLinkForSchema(link2) {
2219
2408
  }
2220
2409
 
2221
2410
  // built-in-plugins/analyzers/link-counts/index.ts
2222
- var ID18 = "link-counts";
2411
+ var ID20 = "link-counts";
2223
2412
  var linkCountsAnalyzer = {
2224
- id: ID18,
2413
+ id: ID20,
2225
2414
  pluginId: "core",
2226
2415
  kind: "analyzer",
2227
2416
  version: "1.0.0",
2228
- description: "No-op placeholder \u2014 view contributions paused (see file header).",
2417
+ description: "Counts incoming and outgoing links per node and surfaces them as paired footer chips.",
2229
2418
  stability: "stable",
2230
2419
  mode: "deterministic",
2231
- evaluate(_ctx) {
2420
+ viewContributions: {
2421
+ linksIn: {
2422
+ slot: "card.footer.left",
2423
+ icon: "pi-arrow-up",
2424
+ label: "incoming links",
2425
+ emitWhenEmpty: false
2426
+ },
2427
+ linksOut: {
2428
+ slot: "card.footer.left",
2429
+ icon: "pi-arrow-down",
2430
+ label: "outgoing links",
2431
+ emitWhenEmpty: false
2432
+ }
2433
+ },
2434
+ evaluate(ctx) {
2435
+ const perTarget = /* @__PURE__ */ new Map();
2436
+ const perSource = /* @__PURE__ */ new Map();
2437
+ for (const link2 of ctx.links) {
2438
+ bump(perTarget, link2.target, link2.kind);
2439
+ bump(perSource, link2.source, link2.kind);
2440
+ }
2441
+ for (const node of ctx.nodes) {
2442
+ emitChip(ctx, node.path, "linksIn", perTarget.get(node.path));
2443
+ emitChip(ctx, node.path, "linksOut", perSource.get(node.path));
2444
+ }
2232
2445
  return [];
2233
2446
  }
2234
2447
  };
2448
+ function bump(map, key, kind) {
2449
+ let byKind = map.get(key);
2450
+ if (!byKind) {
2451
+ byKind = /* @__PURE__ */ new Map();
2452
+ map.set(key, byKind);
2453
+ }
2454
+ byKind.set(kind, (byKind.get(kind) ?? 0) + 1);
2455
+ }
2456
+ function emitChip(ctx, nodePath, contributionId, byKind) {
2457
+ if (!byKind) return;
2458
+ let total = 0;
2459
+ for (const n of byKind.values()) total += n;
2460
+ if (total === 0) return;
2461
+ const capped = Math.min(total, 99);
2462
+ const direction = contributionId === "linksIn" ? "in" : "out";
2463
+ ctx.emitContribution(nodePath, contributionId, {
2464
+ value: capped,
2465
+ tooltip: formatBreakdown(byKind, direction)
2466
+ });
2467
+ }
2468
+ function formatBreakdown(byKind, direction) {
2469
+ const lines = [...byKind.entries()].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([kind, n]) => `${kind}: ${n}`);
2470
+ return [direction, ...lines].join("\n");
2471
+ }
2235
2472
 
2236
2473
  // kernel/sidecar/parse.ts
2237
2474
  import { existsSync, readFileSync as readFileSync3 } from "fs";
@@ -2335,14 +2572,14 @@ function resolveSpecRoot2() {
2335
2572
  }
2336
2573
 
2337
2574
  // built-in-plugins/actions/bump/index.ts
2338
- var ID19 = "bump";
2575
+ var ID21 = "bump";
2339
2576
  var PLUGIN_ID = "core";
2340
2577
  var bumpAction = {
2341
- id: ID19,
2578
+ id: ID21,
2342
2579
  pluginId: PLUGIN_ID,
2343
2580
  kind: "action",
2344
2581
  version: "1.0.0",
2345
- description: "Increments the sidecar `annotations.version`, refreshes the identity hashes, and stamps the audit block. Refuses on a fresh (non-stale) node unless `force: true` is passed.",
2582
+ description: "Marks a node as updated \u2014 bumps its version, refreshes the sidecar hashes, and records the timestamp. Refuses on a fresh node unless `force: true` is passed.",
2346
2583
  stability: "stable",
2347
2584
  mode: "deterministic",
2348
2585
  reportSchemaRef: "https://skill-map.dev/spec/v0/bump-report.schema.json",
@@ -3762,7 +3999,7 @@ function rowToIssue(row) {
3762
3999
  return issue;
3763
4000
  }
3764
4001
  async function loadExtractorRuns(db) {
3765
- const rows = await db.selectFrom("scan_extractor_runs").select(["nodePath", "extractorId", "bodyHashAtRun"]).execute();
4002
+ const rows = await db.selectFrom("scan_extractor_runs").select(["nodePath", "extractorId", "bodyHashAtRun", "sidecarAnnotationsHashAtRun"]).execute();
3766
4003
  const result = /* @__PURE__ */ new Map();
3767
4004
  for (const row of rows) {
3768
4005
  let perNode = result.get(row.nodePath);
@@ -3770,7 +4007,10 @@ async function loadExtractorRuns(db) {
3770
4007
  perNode = /* @__PURE__ */ new Map();
3771
4008
  result.set(row.nodePath, perNode);
3772
4009
  }
3773
- perNode.set(row.extractorId, row.bodyHashAtRun);
4010
+ perNode.set(row.extractorId, {
4011
+ bodyHash: row.bodyHashAtRun,
4012
+ sidecarAnnotationsHash: row.sidecarAnnotationsHashAtRun
4013
+ });
3774
4014
  }
3775
4015
  return result;
3776
4016
  }
@@ -3837,14 +4077,14 @@ async function replaceAllScanContributions(trx, contributions, livePaths = /* @_
3837
4077
  if (freshlyRunTuples.size > 0) {
3838
4078
  const bufferKeys = /* @__PURE__ */ new Set();
3839
4079
  for (const c of contributions) {
3840
- bufferKeys.add(`${c.pluginId}/${c.extensionId}/${c.nodePath}/${c.contributionId}`);
4080
+ bufferKeys.add(`${c.pluginId}\0${c.extensionId}\0${c.nodePath}\0${c.contributionId}`);
3841
4081
  }
3842
4082
  const tuplesByPluginExt = /* @__PURE__ */ new Map();
3843
4083
  for (const tuple of freshlyRunTuples) {
3844
- const lastSlash = tuple.lastIndexOf("/");
3845
- if (lastSlash < 0) continue;
3846
- const pe = tuple.slice(0, lastSlash);
3847
- const node = tuple.slice(lastSlash + 1);
4084
+ const parts = tuple.split("\0");
4085
+ if (parts.length !== 3) continue;
4086
+ const [pluginId, extensionId, node] = parts;
4087
+ const pe = `${pluginId}\0${extensionId}`;
3848
4088
  let nodes = tuplesByPluginExt.get(pe);
3849
4089
  if (!nodes) {
3850
4090
  nodes = /* @__PURE__ */ new Set();
@@ -3853,15 +4093,15 @@ async function replaceAllScanContributions(trx, contributions, livePaths = /* @_
3853
4093
  nodes.add(node);
3854
4094
  }
3855
4095
  for (const [pe, nodes] of tuplesByPluginExt) {
3856
- const slash = pe.indexOf("/");
3857
- if (slash < 0) continue;
3858
- const pluginId = pe.slice(0, slash);
3859
- const extensionId = pe.slice(slash + 1);
4096
+ const sep6 = pe.indexOf("\0");
4097
+ if (sep6 < 0) continue;
4098
+ const pluginId = pe.slice(0, sep6);
4099
+ const extensionId = pe.slice(sep6 + 1);
3860
4100
  const nodeArr = [...nodes];
3861
4101
  const candidates = await trx.selectFrom("scan_contributions").select(["nodePath", "contributionId"]).where("pluginId", "=", pluginId).where("extensionId", "=", extensionId).where("nodePath", "in", nodeArr).execute();
3862
4102
  const stale = [];
3863
4103
  for (const row of candidates) {
3864
- const key = `${pluginId}/${extensionId}/${row.nodePath}/${row.contributionId}`;
4104
+ const key = `${pluginId}\0${extensionId}\0${row.nodePath}\0${row.contributionId}`;
3865
4105
  if (!bufferKeys.has(key)) stale.push(row);
3866
4106
  }
3867
4107
  for (const s of stale) {
@@ -3908,6 +4148,14 @@ async function loadContributionLookup(db, pluginId, contributionId, nodePath, ex
3908
4148
  const rows = await query.orderBy("extensionId", "asc").execute();
3909
4149
  return rows.map(rowToContribution);
3910
4150
  }
4151
+ async function purgeContributionsByPlugin(db, pluginId, extensionId) {
4152
+ let query = db.deleteFrom("scan_contributions").where("pluginId", "=", pluginId);
4153
+ if (extensionId !== void 0) {
4154
+ query = query.where("extensionId", "=", extensionId);
4155
+ }
4156
+ const result = await query.executeTakeFirst();
4157
+ return Number(result.numDeletedRows ?? 0n);
4158
+ }
3911
4159
  function rowToContribution(row) {
3912
4160
  let payload;
3913
4161
  try {
@@ -4174,7 +4422,8 @@ function extractorRunToRow(record) {
4174
4422
  nodePath: record.nodePath,
4175
4423
  extractorId: record.extractorId,
4176
4424
  bodyHashAtRun: record.bodyHashAtRun,
4177
- ranAt: record.ranAt
4425
+ ranAt: record.ranAt,
4426
+ sidecarAnnotationsHashAtRun: record.sidecarAnnotationsHashAtRun
4178
4427
  };
4179
4428
  }
4180
4429
  function enrichmentToRow(record) {
@@ -4349,7 +4598,8 @@ var SqliteStorageAdapter = class {
4349
4598
  this.contributions = {
4350
4599
  listForNode: (nodePath) => loadContributionsForNode(this.db, nodePath),
4351
4600
  listForPaths: (paths) => loadContributionsForPaths(this.db, paths),
4352
- lookup: (pluginId, contributionId, nodePath, extensionId) => loadContributionLookup(this.db, pluginId, contributionId, nodePath, extensionId)
4601
+ lookup: (pluginId, contributionId, nodePath, extensionId) => loadContributionLookup(this.db, pluginId, contributionId, nodePath, extensionId),
4602
+ purgeByPlugin: (pluginId, extensionId) => purgeContributionsByPlugin(this.db, pluginId, extensionId)
4353
4603
  };
4354
4604
  this.tags = {
4355
4605
  listForNode: (nodePath) => loadTagsForNode(this.db, nodePath),
@@ -4636,7 +4886,8 @@ var CONFIG_LOADER_TEXTS = {
4636
4886
  invalidJson: "[config:{{layer}}] invalid JSON in {{path}}: {{message}}",
4637
4887
  expectedObject: "[config:{{layer}}] expected a JSON object, got {{type}}; ignored",
4638
4888
  unknownKey: "[config:{{layer}}] unknown key {{key}} ignored",
4639
- invalidValue: "[config:{{layer}}] invalid value at {{path}}: {{message}}"
4889
+ invalidValue: "[config:{{layer}}] invalid value at {{path}}: {{message}}",
4890
+ projectLocalOnlyStripped: "[config:{{layer}}] key {{key}} is project-local only; stripped from the committed project layer. Move it to .skill-map/settings.local.json (gitignored, per-checkout)."
4640
4891
  };
4641
4892
 
4642
4893
  // kernel/util/skill-map-paths.ts
@@ -4701,6 +4952,7 @@ function kernelLocalSettingsPath(scopeRoot) {
4701
4952
  var defaults_default = {
4702
4953
  schemaVersion: 1,
4703
4954
  autoMigrate: true,
4955
+ allowEditSmFiles: false,
4704
4956
  tokenizer: "cl100k_base",
4705
4957
  providers: [],
4706
4958
  roots: [],
@@ -4738,6 +4990,12 @@ var defaults_default = {
4738
4990
  };
4739
4991
 
4740
4992
  // kernel/config/loader.ts
4993
+ var PROJECT_LOCAL_ONLY_KEYS = /* @__PURE__ */ new Set([
4994
+ "allowEditSmFiles",
4995
+ "scan.includeHome",
4996
+ "scan.extraRoots",
4997
+ "scan.referencePaths"
4998
+ ]);
4741
4999
  var DEFAULTS = defaults_default;
4742
5000
  function loadConfig(opts) {
4743
5001
  const cwd = opts.cwd;
@@ -4763,6 +5021,9 @@ function loadConfig(opts) {
4763
5021
  const partial = readJsonSafe(path, layer, warnings, strict);
4764
5022
  if (partial === null) continue;
4765
5023
  const cleaned = validateAndStrip(validators, partial, layer, warnings, strict);
5024
+ if (layer === "project") {
5025
+ stripProjectLocalOnlyKeys(cleaned, warnings, strict);
5026
+ }
4766
5027
  effective = deepMerge(effective, cleaned);
4767
5028
  recordSources("", cleaned, sources, layer);
4768
5029
  }
@@ -4853,6 +5114,30 @@ function deleteAtPath(root, parentPath, key) {
4853
5114
  }
4854
5115
  if (isPlainObject2(cur)) delete cur[key];
4855
5116
  }
5117
+ function stripProjectLocalOnlyKeys(cloned, warnings, strict) {
5118
+ for (const dotKey of PROJECT_LOCAL_ONLY_KEYS) {
5119
+ const segments = dotKey.split(".").filter(Boolean);
5120
+ if (segments.length === 0) continue;
5121
+ const leaf = segments.pop();
5122
+ if (!keyPresentAtPath(cloned, segments, leaf)) continue;
5123
+ const parentPath = "/" + segments.join("/");
5124
+ deleteAtPath(cloned, parentPath, leaf);
5125
+ const msg = tx(CONFIG_LOADER_TEXTS.projectLocalOnlyStripped, {
5126
+ layer: "project",
5127
+ key: dotKey
5128
+ });
5129
+ if (strict) throw new Error(msg);
5130
+ warnings.push(msg);
5131
+ }
5132
+ }
5133
+ function keyPresentAtPath(root, parentSegments, leaf) {
5134
+ let cur = root;
5135
+ for (const seg of parentSegments) {
5136
+ if (!isPlainObject2(cur)) return false;
5137
+ cur = cur[seg];
5138
+ }
5139
+ return isPlainObject2(cur) && Object.prototype.hasOwnProperty.call(cur, leaf);
5140
+ }
4856
5141
  function isPlainObject2(v) {
4857
5142
  return v !== null && typeof v === "object" && !Array.isArray(v);
4858
5143
  }
@@ -5050,6 +5335,16 @@ var UserOnlyKeyError = class extends Error {
5050
5335
  }
5051
5336
  key;
5052
5337
  };
5338
+ var ProjectLocalOnlyKeyError = class extends Error {
5339
+ constructor(key) {
5340
+ super(
5341
+ `Config key '${key}' is project-local only. Pass { target: 'project-local' } to write it to .skill-map/settings.local.json (gitignored), or use -g for the user / user-local scope.`
5342
+ );
5343
+ this.key = key;
5344
+ this.name = "ProjectLocalOnlyKeyError";
5345
+ }
5346
+ key;
5347
+ };
5053
5348
  function readConfigValue(key, opts) {
5054
5349
  const scope = USER_ONLY_KEYS.has(key) ? "global" : opts.scope;
5055
5350
  const loaded = loadConfigForScope(scope, opts);
@@ -5061,6 +5356,9 @@ function writeConfigValue(key, value, opts) {
5061
5356
  if (USER_ONLY_KEYS.has(key) && opts.target === "project") {
5062
5357
  throw new UserOnlyKeyError(key);
5063
5358
  }
5359
+ if (PROJECT_LOCAL_ONLY_KEYS.has(key) && opts.target === "project") {
5360
+ throw new ProjectLocalOnlyKeyError(key);
5361
+ }
5064
5362
  const path = targetSettingsPath(opts.target, opts.cwd, opts.homedir);
5065
5363
  const merged = readJsonObjectOrEmpty(path);
5066
5364
  setAtPath(merged, key, value);
@@ -5071,6 +5369,9 @@ function removeConfigValue(key, opts) {
5071
5369
  if (USER_ONLY_KEYS.has(key) && opts.target === "project") {
5072
5370
  throw new UserOnlyKeyError(key);
5073
5371
  }
5372
+ if (PROJECT_LOCAL_ONLY_KEYS.has(key) && opts.target === "project") {
5373
+ throw new ProjectLocalOnlyKeyError(key);
5374
+ }
5074
5375
  const path = targetSettingsPath(opts.target, opts.cwd, opts.homedir);
5075
5376
  const merged = readJsonObjectOrEmpty(path);
5076
5377
  const removed = deleteAtPath2(merged, key);
@@ -5088,8 +5389,16 @@ function loadConfigForScope(scope, opts) {
5088
5389
  });
5089
5390
  }
5090
5391
  function targetSettingsPath(target, cwd, home) {
5091
- const root = target === "user" ? home : cwd;
5092
- return defaultSettingsPath(root);
5392
+ switch (target) {
5393
+ case "user":
5394
+ return defaultSettingsPath(home);
5395
+ case "user-local":
5396
+ return defaultLocalSettingsPath(home);
5397
+ case "project":
5398
+ return defaultSettingsPath(cwd);
5399
+ case "project-local":
5400
+ return defaultLocalSettingsPath(cwd);
5401
+ }
5093
5402
  }
5094
5403
  function validateOrThrow(content) {
5095
5404
  const validators = loadSchemaValidators();
@@ -5243,7 +5552,7 @@ var UPDATE_CHECK_TEXTS = {
5243
5552
  // package.json
5244
5553
  var package_default = {
5245
5554
  name: "@skill-map/cli",
5246
- version: "0.20.0",
5555
+ version: "0.21.0",
5247
5556
  description: "skill-map reference implementation \u2014 kernel + CLI + adapters.",
5248
5557
  license: "MIT",
5249
5558
  type: "module",
@@ -5309,7 +5618,7 @@ var package_default = {
5309
5618
  },
5310
5619
  dependencies: {
5311
5620
  "@hono/node-server": "2.0.1",
5312
- "@skill-map/spec": "0.20.0",
5621
+ "@skill-map/spec": "0.21.0",
5313
5622
  ajv: "8.18.0",
5314
5623
  "ajv-formats": "3.0.1",
5315
5624
  chokidar: "5.0.0",
@@ -5433,10 +5742,14 @@ function isUpdateCheckEnabled(opts) {
5433
5742
  async function runWithAdapter(adapter, opts) {
5434
5743
  const cache = await adapter.preferences.loadUpdateCheckCache();
5435
5744
  const now = Date.now();
5745
+ let lastShownAt = cache?.shownAt ?? null;
5746
+ let didShowThisRun = false;
5436
5747
  if (cache && isOutdated(VERSION, cache.latestVersion)) {
5437
- const dueToShow = cache.shownAt === null || now - cache.shownAt > ONE_DAY_MS;
5748
+ const dueToShow = lastShownAt === null || now - lastShownAt > ONE_DAY_MS;
5438
5749
  if (dueToShow) {
5439
5750
  writeBanner(opts, cache.latestVersion);
5751
+ didShowThisRun = true;
5752
+ lastShownAt = now;
5440
5753
  try {
5441
5754
  await adapter.preferences.saveUpdateCheckCache({
5442
5755
  latestVersion: cache.latestVersion,
@@ -5455,11 +5768,18 @@ async function runWithAdapter(adapter, opts) {
5455
5768
  } catch {
5456
5769
  return;
5457
5770
  }
5771
+ if (!didShowThisRun && isOutdated(VERSION, latest)) {
5772
+ const dueToShow = lastShownAt === null || now - lastShownAt > ONE_DAY_MS;
5773
+ if (dueToShow) {
5774
+ writeBanner(opts, latest);
5775
+ lastShownAt = now;
5776
+ }
5777
+ }
5458
5778
  try {
5459
5779
  await adapter.preferences.saveUpdateCheckCache({
5460
5780
  latestVersion: latest,
5461
5781
  checkedAt: now,
5462
- shownAt: cache?.shownAt ?? null
5782
+ shownAt: lastShownAt
5463
5783
  });
5464
5784
  } catch {
5465
5785
  }
@@ -5484,7 +5804,7 @@ var updateCheckHook = {
5484
5804
  pluginId: "core",
5485
5805
  kind: "hook",
5486
5806
  version: "1.0.0",
5487
- description: 'Once-per-day "update available" banner. Subscribes to `boot`; runs the registry probe (cache-aware) before the verb routes.',
5807
+ description: "Checks once a day for a newer version of skill-map on npm and shows the `update available` banner when one exists.",
5488
5808
  stability: "stable",
5489
5809
  mode: "deterministic",
5490
5810
  triggers: ["boot"],
@@ -5549,6 +5869,8 @@ var builtInBundles = [
5549
5869
  externalUrlCounterExtractor,
5550
5870
  markdownLinkExtractor,
5551
5871
  slashExtractor,
5872
+ stabilityExtractor,
5873
+ toolsCountExtractor,
5552
5874
  triggerCollisionAnalyzer,
5553
5875
  brokenRefAnalyzer,
5554
5876
  supersededAnalyzer,
@@ -5772,15 +6094,20 @@ var UTIL_TEXTS = {
5772
6094
  // Every verb's body is expected to end on a content line (with or
5773
6095
  // without its own trailing \n); the blank line here is universal.
5774
6096
  doneIn: "\ndone in {{elapsed}}\n",
5775
- // confirm.ts (default-no prompt suffix)
6097
+ // confirm.ts (default-no prompt suffix — destructive verbs)
5776
6098
  confirmPromptSuffix: " [y/N] ",
6099
+ // confirm.ts (default-yes prompt suffix — consent-style verbs where the
6100
+ // user already triggered the action and is just acknowledging it,
6101
+ // e.g. the .sm write consent gate).
6102
+ confirmPromptSuffixDefaultYes: " [Y/n] ",
5777
6103
  /**
5778
- * Regex source matching affirmative answers in `confirm()`. Compiled
5779
- * with the `i` flag in the helper. Pre-i18n today the pattern is
5780
- * English-only; when a non-English locale lands the catalog grows
5781
- * alternations (e.g. `^(y(es)?|s(í|i)?)$`).
6104
+ * Regex source matching affirmative / negative answers in `confirm()`.
6105
+ * Compiled with the `i` flag in the helper. Pre-i18n today the
6106
+ * patterns are English-only; when a non-English locale lands each
6107
+ * catalog entry grows alternations (e.g. `^(y(es)?|s(í|i)?)$`).
5782
6108
  */
5783
- confirmYesPatternSource: "^y(es)?$"
6109
+ confirmYesPatternSource: "^y(es)?$",
6110
+ confirmNoPatternSource: "^no?$"
5784
6111
  };
5785
6112
 
5786
6113
  // cli/util/exit-codes.ts
@@ -5992,6 +6319,41 @@ import { existsSync as existsSync10 } from "fs";
5992
6319
  import { dirname as dirname8, resolve as resolve12 } from "path";
5993
6320
  import { Command as Command2, Option as Option2 } from "clipanion";
5994
6321
 
6322
+ // core/config/sidecar-consent.ts
6323
+ var EConsentRequiredError = class extends Error {
6324
+ key;
6325
+ hintTarget;
6326
+ constructor(init) {
6327
+ super(
6328
+ `Skill-map needs your consent to create .sm sidecars in this project. Set '${init.key}' to true in .skill-map/settings.local.json (gitignored), or pass --yes / { confirm: true } to grant on the fly.`
6329
+ );
6330
+ this.name = "EConsentRequiredError";
6331
+ this.key = init.key;
6332
+ this.hintTarget = init.hintTarget;
6333
+ }
6334
+ };
6335
+ function ensureSidecarWritesAllowed(opts) {
6336
+ const allowed = readConfigValue("allowEditSmFiles", {
6337
+ scope: "project",
6338
+ cwd: opts.cwd,
6339
+ homedir: opts.homedir,
6340
+ default: false
6341
+ });
6342
+ if (allowed === true) return;
6343
+ if (opts.confirm === true) {
6344
+ writeConfigValue("allowEditSmFiles", true, {
6345
+ target: "project-local",
6346
+ cwd: opts.cwd,
6347
+ homedir: opts.homedir
6348
+ });
6349
+ return;
6350
+ }
6351
+ throw new EConsentRequiredError({
6352
+ key: "allowEditSmFiles",
6353
+ hintTarget: "project-local"
6354
+ });
6355
+ }
6356
+
5995
6357
  // kernel/sidecar/store.ts
5996
6358
  import { existsSync as existsSync9, readFileSync as readFileSync8, renameSync as renameSync2, writeFileSync as writeFileSync2, unlinkSync as unlinkSync2 } from "fs";
5997
6359
  import { dirname as dirname7, resolve as resolve10 } from "path";
@@ -6008,7 +6370,12 @@ var FilesystemSidecarStore = class {
6008
6370
  * files in the repo and entries are tiny).
6009
6371
  */
6010
6372
  #locks = /* @__PURE__ */ new Map();
6011
- async applyPatch(sidecarAbsPath, changes) {
6373
+ async applyPatch(sidecarAbsPath, changes, consent) {
6374
+ ensureSidecarWritesAllowed({
6375
+ confirm: consent.confirm,
6376
+ cwd: consent.cwd,
6377
+ homedir: consent.homedir
6378
+ });
6012
6379
  const prev = this.#locks.get(sidecarAbsPath) ?? Promise.resolve();
6013
6380
  let release;
6014
6381
  const settled = new Promise((res) => {
@@ -6146,9 +6513,41 @@ var BUMP_TEXTS = {
6146
6513
  // --- failures -------------------------------------------------------------
6147
6514
  bumpFailed: "{{glyph}} sm bump: {{message}}\n",
6148
6515
  storeFailedDetail: "sidecar write failed for {{path}}: {{message}}",
6149
- resolveAbsPathFailed: "cannot resolve absolute path for {{nodePath}}: {{message}}"
6516
+ resolveAbsPathFailed: "cannot resolve absolute path for {{nodePath}}: {{message}}",
6517
+ // --- .sm consent gate ---------------------------------------------------
6518
+ /**
6519
+ * Pre-prompt context shown before the interactive `confirm()` so the
6520
+ * operator sees what they are about to opt into. `.skill-map/settings.local.json`
6521
+ * is gitignored — the choice is saved per-checkout, never travels via the repo.
6522
+ */
6523
+ consentPrompt: "skill-map needs your consent to create .sm sidecar files next to your\nsource files in this project. The choice is saved to\n.skill-map/settings.local.json (gitignored, per-checkout) so this prompt\nnever appears again. Decline to abort without persisting the rejection.\n\nAllow .sm sidecar writes in this project?",
6524
+ consentAborted: "{{glyph}} sm bump: aborted by user. No .sm sidecar files were written.\n",
6525
+ consentRequiredNonTty: "{{glyph}} sm bump: consent required to write .sm sidecar files in this project.\n {{hint}}\n",
6526
+ consentRequiredNonTtyHint: "Pass --yes to grant (writes to .skill-map/settings.local.json \u2014 gitignored)."
6150
6527
  };
6151
6528
 
6529
+ // cli/util/confirm.ts
6530
+ import { createInterface } from "readline";
6531
+ var YES_PATTERN = new RegExp(UTIL_TEXTS.confirmYesPatternSource, "i");
6532
+ var NO_PATTERN = new RegExp(UTIL_TEXTS.confirmNoPatternSource, "i");
6533
+ async function confirm(question, streams, opts) {
6534
+ const defaultAnswer = opts?.defaultAnswer ?? "no";
6535
+ const suffix = defaultAnswer === "yes" ? UTIL_TEXTS.confirmPromptSuffixDefaultYes : UTIL_TEXTS.confirmPromptSuffix;
6536
+ const rl = createInterface({ input: streams.stdin, output: streams.stderr });
6537
+ try {
6538
+ const answer = await new Promise(
6539
+ (resolveP) => rl.question(`${question}${suffix}`, resolveP)
6540
+ );
6541
+ const trimmed = answer.trim();
6542
+ if (trimmed === "") return defaultAnswer === "yes";
6543
+ if (YES_PATTERN.test(trimmed)) return true;
6544
+ if (NO_PATTERN.test(trimmed)) return false;
6545
+ return defaultAnswer === "yes";
6546
+ } finally {
6547
+ rl.close();
6548
+ }
6549
+ }
6550
+
6152
6551
  // cli/util/path-guard.ts
6153
6552
  import { isAbsolute as isAbsolute2, resolve as resolve11, sep } from "path";
6154
6553
  function assertContained(cwd, rel) {
@@ -6337,6 +6736,9 @@ var BumpCommand = class extends SmCommand {
6337
6736
  force = Option2.Boolean("--force", false, {
6338
6737
  description: "Single-node: bump even when the node is fresh. Batch: turn fresh-node refusals into silent no-ops."
6339
6738
  });
6739
+ yes = Option2.Boolean("--yes", false, {
6740
+ description: "Confirm writing .sm sidecar files in this project (sets allowEditSmFiles=true on first run)."
6741
+ });
6340
6742
  // The remaining cyclomatic count is from CLI ergonomics — argument
6341
6743
  // validation guards (3) + dispatch (1) + JSON-vs-pretty branch.
6342
6744
  // eslint-disable-next-line complexity
@@ -6372,10 +6774,53 @@ var BumpCommand = class extends SmCommand {
6372
6774
  );
6373
6775
  return ExitCode.NotFound;
6374
6776
  }
6375
- if (this.pending) {
6376
- return this.#runPending(persisted.nodes, ctx.cwd, ansi);
6777
+ return this.#runWithConsent(
6778
+ ansi,
6779
+ () => this.pending ? this.#runPending(persisted.nodes, ctx.cwd, ansi) : this.#runSingle(persisted.nodes, ctx.cwd, ansi)
6780
+ );
6781
+ }
6782
+ /**
6783
+ * Wrap `dispatch` with the `.sm` consent gate: on the first
6784
+ * `EConsentRequiredError` thrown by `FilesystemSidecarStore.applyPatch`
6785
+ * (via `ensureSidecarWritesAllowed`), prompt the operator if stdin is
6786
+ * a TTY and `--yes` was not passed. On accept, flip `this.yes` to
6787
+ * true and re-run `dispatch` (the second pass passes `confirm: true`
6788
+ * to the store and the gate persists the flag to project-local).
6789
+ * On decline or non-TTY without `--yes`, print a directed message
6790
+ * and return `ExitCode.Error`.
6791
+ */
6792
+ async #runWithConsent(ansi, dispatch) {
6793
+ try {
6794
+ return await dispatch();
6795
+ } catch (err) {
6796
+ if (!(err instanceof EConsentRequiredError)) throw err;
6797
+ const stdin = this.context.stdin;
6798
+ const stderr = this.context.stderr;
6799
+ const isTTY = stdin.isTTY === true;
6800
+ if (!isTTY || this.yes) {
6801
+ const errGlyph = ansi.red("\u2715");
6802
+ this.printer.error(
6803
+ tx(BUMP_TEXTS.consentRequiredNonTty, {
6804
+ glyph: errGlyph,
6805
+ hint: ansi.dim(BUMP_TEXTS.consentRequiredNonTtyHint)
6806
+ })
6807
+ );
6808
+ return ExitCode.Error;
6809
+ }
6810
+ const ok = await confirm(
6811
+ BUMP_TEXTS.consentPrompt,
6812
+ { stdin, stderr },
6813
+ { defaultAnswer: "yes" }
6814
+ );
6815
+ if (!ok) {
6816
+ this.printer.info(
6817
+ tx(BUMP_TEXTS.consentAborted, { glyph: ansi.cyan("\u2139") })
6818
+ );
6819
+ return ExitCode.Error;
6820
+ }
6821
+ this.yes = true;
6822
+ return await dispatch();
6377
6823
  }
6378
- return this.#runSingle(persisted.nodes, ctx.cwd, ansi);
6379
6824
  }
6380
6825
  // --- single-node --------------------------------------------------------
6381
6826
  // Complexity is from CLI ergonomics: not-found / abs-path-resolve /
@@ -6427,15 +6872,21 @@ var BumpCommand = class extends SmCommand {
6427
6872
  return ExitCode.Ok;
6428
6873
  }
6429
6874
  const store = new FilesystemSidecarStore();
6875
+ const ctx = defaultRuntimeContext();
6430
6876
  let sidecarPath;
6431
6877
  try {
6432
6878
  for (const w of result.writes ?? []) {
6433
6879
  if (w.kind === "sidecar") {
6434
- await store.applyPatch(w.path, w.changes);
6880
+ await store.applyPatch(w.path, w.changes, {
6881
+ confirm: this.yes,
6882
+ cwd: ctx.cwd,
6883
+ homedir: ctx.homedir
6884
+ });
6435
6885
  sidecarPath = w.path;
6436
6886
  }
6437
6887
  }
6438
6888
  } catch (err) {
6889
+ if (err instanceof EConsentRequiredError) throw err;
6439
6890
  this.printer.error(
6440
6891
  tx(BUMP_TEXTS.bumpFailed, {
6441
6892
  glyph: errGlyph,
@@ -6516,9 +6967,11 @@ var BumpCommand = class extends SmCommand {
6516
6967
  this.printer.info(tx(BUMP_TEXTS.pendingBanner, { count: stale.length }));
6517
6968
  }
6518
6969
  const store = new FilesystemSidecarStore();
6970
+ const ctx = defaultRuntimeContext();
6971
+ const consent = { confirm: this.yes, cwd: ctx.cwd, homedir: ctx.homedir };
6519
6972
  const outcomes = [];
6520
6973
  for (const node of stale) {
6521
- const outcome = await bumpOnePending(node, cwd, this.force, store);
6974
+ const outcome = await bumpOnePending(node, cwd, this.force, store, consent);
6522
6975
  outcomes.push(outcome);
6523
6976
  if (outcome.status === "bumped" && this.staged && outcome.sidecarPath !== void 0) {
6524
6977
  const addErr = stageSidecar(cwd, outcome.sidecarPath);
@@ -6611,7 +7064,7 @@ function invokeBumpFor(node, absPath, force) {
6611
7064
  now: () => /* @__PURE__ */ new Date()
6612
7065
  });
6613
7066
  }
6614
- async function bumpOnePending(node, cwd, force, store) {
7067
+ async function bumpOnePending(node, cwd, force, store, consent) {
6615
7068
  let absPath;
6616
7069
  try {
6617
7070
  assertContained(cwd, node.path);
@@ -6643,11 +7096,12 @@ async function bumpOnePending(node, cwd, force, store) {
6643
7096
  try {
6644
7097
  for (const w of result.writes ?? []) {
6645
7098
  if (w.kind === "sidecar") {
6646
- await store.applyPatch(w.path, w.changes);
7099
+ await store.applyPatch(w.path, w.changes, consent);
6647
7100
  sidecarPath = w.path;
6648
7101
  }
6649
7102
  }
6650
7103
  } catch (err) {
7104
+ if (err instanceof EConsentRequiredError) throw err;
6651
7105
  return {
6652
7106
  nodePath: node.path,
6653
7107
  status: "error",
@@ -7410,7 +7864,18 @@ var LOCKED_PLUGIN_IDS = /* @__PURE__ */ new Set([
7410
7864
  // unclaimed `.md` files"). Disabling it makes every orphan markdown
7411
7865
  // silently invisible — a foot-gun the host product does not want to
7412
7866
  // expose. Lock it in the enabled state.
7413
- "core/markdown"
7867
+ "core/markdown",
7868
+ // `core/annotations` turns the `supersedes` / `supersededBy` /
7869
+ // `requires` / `related` / `conflictsWith` entries of the sidecar
7870
+ // `annotations:` block into the arrows the graph draws between nodes.
7871
+ // It does NOT own the rest of the block (`version`, `stability`,
7872
+ // `tags`, `description` — those live on the node bundle directly and
7873
+ // keep rendering with the plugin off). Disabling it produces a
7874
+ // confusing "edges disappear but the sidecar metadata stays" split
7875
+ // that no operator actually wants; the lock makes the asymmetry
7876
+ // unreachable from CLI / BFF / UI. Re-evaluate if a third-party ever
7877
+ // ships a competing supersession extractor.
7878
+ "core/annotations"
7414
7879
  ]);
7415
7880
  function isPluginLocked(idOrQualified) {
7416
7881
  return LOCKED_PLUGIN_IDS.has(idOrQualified);
@@ -7557,7 +8022,21 @@ function isBundleEntryEnabled(bundle, extId, resolveEnabled) {
7557
8022
  }
7558
8023
  return resolveEnabled(qualifiedExtensionId(bundle.id, extId));
7559
8024
  }
8025
+ function buildGranularityMap(discovered) {
8026
+ const out = /* @__PURE__ */ new Map();
8027
+ for (const plugin of discovered) {
8028
+ out.set(plugin.id, plugin.granularity ?? "bundle");
8029
+ }
8030
+ return out;
8031
+ }
8032
+ function isPluginExtensionEnabled(ext, granularityMap, resolveEnabled) {
8033
+ const granularity = granularityMap.get(ext.pluginId) ?? "bundle";
8034
+ if (granularity === "bundle") return resolveEnabled(ext.pluginId);
8035
+ return resolveEnabled(qualifiedExtensionId(ext.pluginId, ext.id));
8036
+ }
7560
8037
  function composeScanExtensions(opts) {
8038
+ const resolveEnabled = opts.resolveEnabled ?? opts.pluginRuntime.resolveEnabled;
8039
+ const granularityMap = buildGranularityMap(opts.pluginRuntime.discovered);
7561
8040
  const providers = [];
7562
8041
  const extractors = [];
7563
8042
  const analyzers = [];
@@ -7565,13 +8044,21 @@ function composeScanExtensions(opts) {
7565
8044
  if (!opts.noBuiltIns) {
7566
8045
  accumulateBuiltInScanExtensions(
7567
8046
  { providers, extractors, analyzers, hooks },
7568
- opts.pluginRuntime.resolveEnabled
8047
+ resolveEnabled
7569
8048
  );
7570
8049
  }
7571
- providers.push(...opts.pluginRuntime.extensions.providers);
7572
- extractors.push(...opts.pluginRuntime.extensions.extractors);
7573
- analyzers.push(...opts.pluginRuntime.extensions.analyzers);
7574
- hooks.push(...opts.pluginRuntime.extensions.hooks);
8050
+ for (const ext of opts.pluginRuntime.extensions.providers) {
8051
+ if (isPluginExtensionEnabled(ext, granularityMap, resolveEnabled)) providers.push(ext);
8052
+ }
8053
+ for (const ext of opts.pluginRuntime.extensions.extractors) {
8054
+ if (isPluginExtensionEnabled(ext, granularityMap, resolveEnabled)) extractors.push(ext);
8055
+ }
8056
+ for (const ext of opts.pluginRuntime.extensions.analyzers) {
8057
+ if (isPluginExtensionEnabled(ext, granularityMap, resolveEnabled)) analyzers.push(ext);
8058
+ }
8059
+ for (const ext of opts.pluginRuntime.extensions.hooks) {
8060
+ if (isPluginExtensionEnabled(ext, granularityMap, resolveEnabled)) hooks.push(ext);
8061
+ }
7575
8062
  const finalProviders = opts.killSwitches?.providers === true ? [] : providers;
7576
8063
  const finalExtractors = opts.killSwitches?.extractors === true ? [] : extractors;
7577
8064
  const finalAnalyzers = opts.killSwitches?.analyzers === true ? [] : analyzers;
@@ -7616,38 +8103,61 @@ function accumulateBuiltInScanExtensions(buckets, resolveEnabled) {
7616
8103
  }
7617
8104
  function composeFormatters(opts) {
7618
8105
  const noBuiltIns = opts.noBuiltIns ?? false;
8106
+ const resolveEnabled = opts.resolveEnabled ?? opts.pluginRuntime.resolveEnabled;
8107
+ const granularityMap = buildGranularityMap(opts.pluginRuntime.discovered);
7619
8108
  const out = [];
7620
8109
  if (!noBuiltIns) {
7621
8110
  for (const bundle of builtInBundles) {
7622
8111
  for (const ext of bundle.extensions) {
7623
8112
  if (ext.kind !== "formatter") continue;
7624
- if (!isBuiltInExtensionEnabled(bundle, ext, opts.pluginRuntime.resolveEnabled)) continue;
8113
+ if (!isBuiltInExtensionEnabled(bundle, ext, resolveEnabled)) continue;
7625
8114
  out.push(ext);
7626
8115
  }
7627
8116
  }
7628
8117
  }
7629
- out.push(...opts.pluginRuntime.extensions.formatters);
8118
+ for (const ext of opts.pluginRuntime.extensions.formatters) {
8119
+ if (isPluginExtensionEnabled(ext, granularityMap, resolveEnabled)) out.push(ext);
8120
+ }
7630
8121
  return out;
7631
8122
  }
7632
8123
  function registerEnabledExtensions(kernel, pluginRuntime, options = {}) {
7633
8124
  const noBuiltIns = options.noBuiltIns === true;
8125
+ const resolveEnabled = options.resolveEnabled ?? pluginRuntime.resolveEnabled;
8126
+ const granularityMap = buildGranularityMap(pluginRuntime.discovered);
7634
8127
  if (!noBuiltIns) {
7635
- const enabledBuiltIns = filterBuiltInManifests(
7636
- listBuiltIns(),
7637
- pluginRuntime.resolveEnabled
7638
- );
8128
+ const enabledBuiltIns = filterBuiltInManifests(listBuiltIns(), resolveEnabled);
7639
8129
  for (const manifest of enabledBuiltIns) kernel.registry.register(manifest);
7640
8130
  }
7641
- for (const manifest of pluginRuntime.manifests) kernel.registry.register(manifest);
8131
+ for (const manifest of pluginRuntime.manifests) {
8132
+ if (!isPluginExtensionEnabled(manifest, granularityMap, resolveEnabled)) continue;
8133
+ kernel.registry.register(manifest);
8134
+ }
7642
8135
  if (kernel.setRegisteredAnnotationKeys) {
7643
- kernel.setRegisteredAnnotationKeys(pluginRuntime.annotationContributions);
8136
+ const filteredAnnotations = pluginRuntime.annotationContributions.filter(
8137
+ (entry) => (
8138
+ // Annotation contributions live at plugin-id granularity (the
8139
+ // catalog row carries `pluginId`, not `extensionId`), so the
8140
+ // bundle-level toggle gates the entire row. Extension
8141
+ // granularity falls through to the manifest-level filter above
8142
+ // — this surface is bundle-scoped by design.
8143
+ resolveEnabled(entry.pluginId)
8144
+ )
8145
+ );
8146
+ kernel.setRegisteredAnnotationKeys(filteredAnnotations);
7644
8147
  }
7645
8148
  if (kernel.setRegisteredViewContributions) {
7646
- const merged = [...pluginRuntime.viewContributions];
8149
+ const userContribs = pluginRuntime.viewContributions.filter(
8150
+ (entry) => isPluginExtensionEnabled(
8151
+ { pluginId: entry.pluginId, id: entry.extensionId },
8152
+ granularityMap,
8153
+ resolveEnabled
8154
+ )
8155
+ );
8156
+ const merged = [...userContribs];
7647
8157
  if (!noBuiltIns) {
7648
8158
  for (const bundle of builtInBundles) {
7649
8159
  for (const ext of bundle.extensions) {
7650
- if (!isBundleEntryEnabled(bundle, ext.id, pluginRuntime.resolveEnabled)) continue;
8160
+ if (!isBundleEntryEnabled(bundle, ext.id, resolveEnabled)) continue;
7651
8161
  collectViewContributions(ext.pluginId, ext.id, ext, merged);
7652
8162
  }
7653
8163
  }
@@ -8002,6 +8512,15 @@ var CONFIG_TEXTS = {
8002
8512
  */
8003
8513
  userOnlyKeyRejection: '{{glyph}} sm config: "{{key}}" is a user-scope key.\n {{hint}}\n',
8004
8514
  userOnlyKeyRejectionHint: "Rerun with -g to write to ~/.skill-map/settings.json.",
8515
+ /**
8516
+ * Surfaced when a PROJECT_LOCAL_ONLY key (`allowEditSmFiles` /
8517
+ * `scan.includeHome` / `scan.extraRoots` / `scan.referencePaths`)
8518
+ * reaches the writer with `target: 'project'` — defensive only, the
8519
+ * CLI auto-routes to `project-local`, but the helper enforces the
8520
+ * rule for any other caller too.
8521
+ */
8522
+ projectLocalOnlyKeyRejection: '{{glyph}} sm config: "{{key}}" is project-local only and cannot live in committed settings.json.\n {{hint}}\n',
8523
+ projectLocalOnlyKeyRejectionHint: "Writes to .skill-map/settings.local.json (gitignored), or -g for user scope.",
8005
8524
  /**
8006
8525
  * Surfaced when `sm config set` is invoked on a privacy-sensitive
8007
8526
  * key (`scan.includeHome` / `scan.extraRoots` /
@@ -8040,8 +8559,14 @@ var CONFIG_TEXTS = {
8040
8559
 
8041
8560
  // cli/commands/config.ts
8042
8561
  function targetSettingsPath2(target, cwd, home) {
8043
- const root = target === "user" ? home : cwd;
8044
- return defaultSettingsPath(root);
8562
+ const root = target === "user" || target === "user-local" ? home : cwd;
8563
+ return target === "project-local" || target === "user-local" ? defaultLocalSettingsPath(root) : defaultSettingsPath(root);
8564
+ }
8565
+ function resolveWriteTarget(key, global) {
8566
+ if (PROJECT_LOCAL_ONLY_KEYS.has(key)) {
8567
+ return global ? "user" : "project-local";
8568
+ }
8569
+ return global ? "user" : "project";
8045
8570
  }
8046
8571
  function suggestConfigKey(effective, typed, ansi) {
8047
8572
  const candidates = enumerateConfigPaths(effective);
@@ -8393,7 +8918,7 @@ var ConfigSetCommand = class extends SmCommand {
8393
8918
  // eslint-disable-next-line complexity
8394
8919
  async run() {
8395
8920
  const ctx = defaultRuntimeContext();
8396
- const target = this.global ? "user" : "project";
8921
+ const target = resolveWriteTarget(this.key, this.global);
8397
8922
  const path = targetSettingsPath2(target, ctx.cwd, ctx.homedir);
8398
8923
  const stderr = this.context.stderr;
8399
8924
  const stderrAnsi = ansiFor({ isTTY: stderr.isTTY === true, noColorFlag: this.noColor });
@@ -8455,6 +8980,16 @@ var ConfigSetCommand = class extends SmCommand {
8455
8980
  );
8456
8981
  return ExitCode.Error;
8457
8982
  }
8983
+ if (err instanceof ProjectLocalOnlyKeyError) {
8984
+ this.printer.info(
8985
+ tx(CONFIG_TEXTS.projectLocalOnlyKeyRejection, {
8986
+ glyph: errGlyph,
8987
+ key: err.key,
8988
+ hint: stderrAnsi.dim(CONFIG_TEXTS.projectLocalOnlyKeyRejectionHint)
8989
+ })
8990
+ );
8991
+ return ExitCode.Error;
8992
+ }
8458
8993
  if (err instanceof ConfigValidationError) {
8459
8994
  this.printer.info(
8460
8995
  tx(CONFIG_TEXTS.invalidAfterSet, { glyph: errGlyph, errors: err.errors })
@@ -8497,7 +9032,7 @@ var ConfigResetCommand = class extends SmCommand {
8497
9032
  // the value they gate.
8498
9033
  async run() {
8499
9034
  const ctx = defaultRuntimeContext();
8500
- const target = this.global ? "user" : "project";
9035
+ const target = resolveWriteTarget(this.key, this.global);
8501
9036
  const path = targetSettingsPath2(target, ctx.cwd, ctx.homedir);
8502
9037
  const stdout = this.context.stdout;
8503
9038
  const ansi = ansiFor({ isTTY: stdout.isTTY === true, noColorFlag: this.noColor });
@@ -8541,6 +9076,16 @@ var ConfigResetCommand = class extends SmCommand {
8541
9076
  );
8542
9077
  return ExitCode.Error;
8543
9078
  }
9079
+ if (err instanceof ProjectLocalOnlyKeyError) {
9080
+ this.printer.info(
9081
+ tx(CONFIG_TEXTS.projectLocalOnlyKeyRejection, {
9082
+ glyph: ansi.red("\u2715"),
9083
+ key: err.key,
9084
+ hint: ansi.dim(CONFIG_TEXTS.projectLocalOnlyKeyRejectionHint)
9085
+ })
9086
+ );
9087
+ return ExitCode.Error;
9088
+ }
8544
9089
  if (err instanceof ConfigValidationError) {
8545
9090
  this.printer.info(
8546
9091
  tx(CONFIG_TEXTS.invalidAfterSet, { glyph: ansi.red("\u2715"), errors: err.errors })
@@ -9220,21 +9765,6 @@ import { chmod, copyFile, mkdir, rm } from "fs/promises";
9220
9765
  import { dirname as dirname11, join as join7, resolve as resolve18 } from "path";
9221
9766
  import { DatabaseSync as DatabaseSync4 } from "node:sqlite";
9222
9767
 
9223
- // cli/util/confirm.ts
9224
- import { createInterface } from "readline";
9225
- var YES_PATTERN = new RegExp(UTIL_TEXTS.confirmYesPatternSource, "i");
9226
- async function confirm(question, streams) {
9227
- const rl = createInterface({ input: streams.stdin, output: streams.stderr });
9228
- try {
9229
- const answer = await new Promise(
9230
- (resolveP) => rl.question(`${question}${UTIL_TEXTS.confirmPromptSuffix}`, resolveP)
9231
- );
9232
- return YES_PATTERN.test(answer.trim());
9233
- } finally {
9234
- rl.close();
9235
- }
9236
- }
9237
-
9238
9768
  // cli/i18n/db.texts.ts
9239
9769
  var DB_TEXTS = {
9240
9770
  // --- reset -----------------------------------------------------------
@@ -11481,7 +12011,7 @@ async function runScanInternal(_kernel, options) {
11481
12011
  for (const analyzer of exts.analyzers ?? []) {
11482
12012
  if (analyzer.viewContributions === void 0) continue;
11483
12013
  for (const node of walked.nodes) {
11484
- walked.freshlyRunTuples.add(`${analyzer.pluginId}/${analyzer.id}/${node.path}`);
12014
+ walked.freshlyRunTuples.add(`${analyzer.pluginId}\0${analyzer.id}\0${node.path}`);
11485
12015
  }
11486
12016
  }
11487
12017
  for (const issue of walked.frontmatterIssues) issues.push(issue);
@@ -11697,8 +12227,10 @@ function computeCacheDecision(opts) {
11697
12227
  const priorRunsForNode = opts.priorExtractorRuns.get(opts.nodePath) ?? /* @__PURE__ */ new Map();
11698
12228
  for (const ex of applicableExtractors) {
11699
12229
  const qualified = qualifiedExtensionId(ex.pluginId, ex.id);
11700
- const priorBody = priorRunsForNode.get(qualified);
11701
- if (opts.nodeHashCacheEligible && priorBody === opts.bodyHash) {
12230
+ const prior = priorRunsForNode.get(qualified);
12231
+ const bodyMatch = prior !== void 0 && prior.bodyHash === opts.bodyHash;
12232
+ const sidecarOk = prior !== void 0 && prior.sidecarAnnotationsHash === opts.sidecarAnnotationsHash;
12233
+ if (opts.nodeHashCacheEligible && bodyMatch && sidecarOk) {
11702
12234
  cachedQualifiedIds.add(qualified);
11703
12235
  } else {
11704
12236
  missingExtractors.push(ex);
@@ -11743,7 +12275,8 @@ function reusePriorNode(opts) {
11743
12275
  nodePath: opts.priorNode.path,
11744
12276
  extractorId: qualified,
11745
12277
  bodyHashAtRun: opts.bodyHash,
11746
- ranAt
12278
+ ranAt,
12279
+ sidecarAnnotationsHashAtRun: opts.sidecarAnnotationsHash
11747
12280
  });
11748
12281
  }
11749
12282
  return { ...base, extractorRuns };
@@ -11829,11 +12362,22 @@ async function walkAndExtract(opts) {
11829
12362
  }
11830
12363
  claimedPaths.add(raw.path);
11831
12364
  index += 1;
12365
+ const sidecarResolution = resolveSidecarOverlay(
12366
+ raw.path,
12367
+ raw.path,
12368
+ roots,
12369
+ bodyHash,
12370
+ frontmatterHash
12371
+ );
12372
+ const sidecarAnnotationsHash = sha256(
12373
+ canonicalSidecarAnnotations(sidecarResolution.overlay.annotations)
12374
+ );
11832
12375
  const cacheDecision = computeCacheDecision({
11833
12376
  extractors,
11834
12377
  kind,
11835
12378
  nodePath: raw.path,
11836
12379
  bodyHash,
12380
+ sidecarAnnotationsHash,
11837
12381
  nodeHashCacheEligible,
11838
12382
  priorExtractorRuns
11839
12383
  });
@@ -11844,10 +12388,20 @@ async function walkAndExtract(opts) {
11844
12388
  missingExtractors,
11845
12389
  fullCacheHit
11846
12390
  } = cacheDecision;
12391
+ const attachSidecar = (node2) => {
12392
+ node2.sidecar = sidecarResolution.overlay;
12393
+ if (sidecarResolution.parsedRoot !== null) {
12394
+ sidecarRoots.set(node2.path, sidecarResolution.parsedRoot);
12395
+ }
12396
+ return sidecarResolution.issues.map(
12397
+ (i) => i.nodeIds.length > 0 ? i : { ...i, nodeIds: [node2.path] }
12398
+ );
12399
+ };
11847
12400
  if (fullCacheHit && priorNode) {
11848
12401
  const reused = reusePriorNode({
11849
12402
  priorNode,
11850
12403
  bodyHash,
12404
+ sidecarAnnotationsHash,
11851
12405
  strict,
11852
12406
  cachedQualifiedIds,
11853
12407
  applicableQualifiedIds,
@@ -11855,14 +12409,7 @@ async function walkAndExtract(opts) {
11855
12409
  priorLinksByOriginating,
11856
12410
  priorFrontmatterIssuesByNode
11857
12411
  });
11858
- const reusedSidecarIssues = resolveAndApplySidecar(
11859
- reused.node,
11860
- raw.path,
11861
- roots,
11862
- bodyHash,
11863
- frontmatterHash,
11864
- sidecarRoots
11865
- );
12412
+ const reusedSidecarIssues = attachSidecar(reused.node);
11866
12413
  nodes.push(reused.node);
11867
12414
  cachedPaths.add(reused.node.path);
11868
12415
  for (const link2 of reused.internalLinks) internalLinks.push(link2);
@@ -11903,14 +12450,7 @@ async function walkAndExtract(opts) {
11903
12450
  nodes.push(node);
11904
12451
  for (const issue of fresh.frontmatterIssues) frontmatterIssues.push(issue);
11905
12452
  }
11906
- const sidecarIssues = resolveAndApplySidecar(
11907
- node,
11908
- raw.path,
11909
- roots,
11910
- bodyHash,
11911
- frontmatterHash,
11912
- sidecarRoots
11913
- );
12453
+ const sidecarIssues = attachSidecar(node);
11914
12454
  for (const issue of sidecarIssues) frontmatterIssues.push(issue);
11915
12455
  emitter.emit(makeEvent("scan.progress", {
11916
12456
  index,
@@ -11921,7 +12461,7 @@ async function walkAndExtract(opts) {
11921
12461
  }));
11922
12462
  const extractorsToRun = partialCacheHit ? missingExtractors : applicableExtractors;
11923
12463
  for (const ex of extractorsToRun) {
11924
- freshlyRunTuples.add(`${ex.pluginId}/${ex.id}/${node.path}`);
12464
+ freshlyRunTuples.add(`${ex.pluginId}\0${ex.id}\0${node.path}`);
11925
12465
  }
11926
12466
  const extractResult = await runExtractorsForNode({
11927
12467
  extractors: extractorsToRun,
@@ -11945,7 +12485,8 @@ async function walkAndExtract(opts) {
11945
12485
  nodePath: node.path,
11946
12486
  extractorId: qualified,
11947
12487
  bodyHashAtRun: bodyHash,
11948
- ranAt
12488
+ ranAt,
12489
+ sidecarAnnotationsHashAtRun: sidecarAnnotationsHash
11949
12490
  });
11950
12491
  }
11951
12492
  }
@@ -12246,30 +12787,42 @@ function canonicalFrontmatter(parsed, raw) {
12246
12787
  noCompatMode: true
12247
12788
  });
12248
12789
  }
12249
- function resolveAndApplySidecar(node, relativePath2, roots, liveBodyHash, liveFrontmatterHash, sidecarRoots) {
12790
+ function canonicalSidecarAnnotations(annotations) {
12791
+ if (!annotations || typeof annotations !== "object" || Array.isArray(annotations)) {
12792
+ return yaml4.dump({}, { sortKeys: true, lineWidth: -1, noRefs: true, noCompatMode: true });
12793
+ }
12794
+ return yaml4.dump(annotations, {
12795
+ sortKeys: true,
12796
+ lineWidth: -1,
12797
+ noRefs: true,
12798
+ noCompatMode: true
12799
+ });
12800
+ }
12801
+ function resolveSidecarOverlay(relativePath2, nodePathForIssue, roots, liveBodyHash, liveFrontmatterHash) {
12250
12802
  const issues = [];
12251
12803
  const mdAbs = resolveAbsoluteMdPath(relativePath2, roots);
12252
12804
  if (mdAbs === null) {
12253
- node.sidecar = { present: false };
12254
- return issues;
12805
+ return { overlay: { present: false }, issues, parsedRoot: null };
12255
12806
  }
12256
12807
  const result = readSidecarFor(mdAbs);
12257
12808
  if (!result.present) {
12258
- node.sidecar = { present: false };
12259
- return issues;
12809
+ return { overlay: { present: false }, issues, parsedRoot: null };
12260
12810
  }
12261
12811
  if (result.parsed === null) {
12262
- node.sidecar = { present: true, status: null, annotations: null, root: null };
12263
12812
  for (const parseIssue of result.issues) {
12264
12813
  issues.push({
12265
12814
  analyzerId: "invalid-sidecar",
12266
12815
  severity: "warn",
12267
- nodeIds: [node.path],
12816
+ nodeIds: [nodePathForIssue],
12268
12817
  message: parseIssue.message,
12269
12818
  data: { sidecarPath: relativePathFromRoots(mdAbs, roots) }
12270
12819
  });
12271
12820
  }
12272
- return issues;
12821
+ return {
12822
+ overlay: { present: true, status: null, annotations: null, root: null },
12823
+ issues,
12824
+ parsedRoot: null
12825
+ };
12273
12826
  }
12274
12827
  const status = computeDriftStatus({
12275
12828
  storedBodyHash: result.parsed.identityBodyHash,
@@ -12277,14 +12830,22 @@ function resolveAndApplySidecar(node, relativePath2, roots, liveBodyHash, liveFr
12277
12830
  liveBodyHash,
12278
12831
  liveFrontmatterHash
12279
12832
  });
12280
- node.sidecar = {
12281
- present: true,
12282
- status,
12283
- annotations: result.parsed.annotations,
12284
- root: result.parsed.raw
12833
+ return {
12834
+ // R15 closure (2026-05-07) — surface the full parsed root on the
12835
+ // overlay so BFF consumers (UI inspector audit / plugin-contributions
12836
+ // / debug panels) can read `for.*`, `audit.*`, `settings.*`, and
12837
+ // plugin-namespaced sub-keys without re-reading the file. The
12838
+ // `annotations` field above stays — it duplicates `root.annotations`
12839
+ // by design so existing consumers keep working unchanged.
12840
+ overlay: {
12841
+ present: true,
12842
+ status,
12843
+ annotations: result.parsed.annotations,
12844
+ root: result.parsed.raw
12845
+ },
12846
+ issues,
12847
+ parsedRoot: result.parsed.raw
12285
12848
  };
12286
- sidecarRoots.set(node.path, result.parsed.raw);
12287
- return issues;
12288
12849
  }
12289
12850
  function resolveAbsoluteMdPath(relativePath2, roots) {
12290
12851
  if (isAbsolute6(relativePath2)) {
@@ -12951,8 +13512,13 @@ function registerExtensions(kernel, pluginRuntime, opts) {
12951
13512
  pluginRuntime
12952
13513
  };
12953
13514
  if (opts.killSwitches) composeOpts.killSwitches = opts.killSwitches;
13515
+ if (opts.resolveEnabledOverride) composeOpts.resolveEnabled = opts.resolveEnabledOverride;
12954
13516
  const extensions = composeScanExtensions(composeOpts);
12955
- registerEnabledExtensions(kernel, pluginRuntime, { noBuiltIns: opts.noBuiltIns });
13517
+ const registerOpts = {
13518
+ noBuiltIns: opts.noBuiltIns
13519
+ };
13520
+ if (opts.resolveEnabledOverride) registerOpts.resolveEnabled = opts.resolveEnabledOverride;
13521
+ registerEnabledExtensions(kernel, pluginRuntime, registerOpts);
12956
13522
  return extensions;
12957
13523
  }
12958
13524
  function buildScanIgnoreFilter(cfg, cwd) {
@@ -15076,11 +15642,14 @@ function renderListHuman(builtIns2, plugins, ansi) {
15076
15642
  return lines.join("\n") + "\n" + PLUGINS_TEXTS.listTipShow;
15077
15643
  }
15078
15644
  function builtInToListRow(b) {
15645
+ const names = b.granularity === "extension" ? b.extensions.map(
15646
+ (e) => e.enabled ? e.id : `${PLUGINS_TEXTS.rowGlyphOff} ${e.id}`
15647
+ ) : b.extensions.map((e) => e.id);
15079
15648
  return {
15080
15649
  id: b.id,
15081
15650
  enabled: b.enabled,
15082
15651
  source: PLUGINS_TEXTS.sourceBuiltIn,
15083
- names: b.extensions.map((e) => e.id)
15652
+ names
15084
15653
  };
15085
15654
  }
15086
15655
  function pluginToListRow(p) {
@@ -15132,7 +15701,17 @@ var PluginsShowCommand = class extends SmCommand {
15132
15701
  static paths = [["plugins", "show"]];
15133
15702
  static usage = Command16.Usage({
15134
15703
  category: "Plugins",
15135
- description: "Show a single plugin's manifest + loaded extensions."
15704
+ description: "Show a single plugin's manifest + loaded extensions.",
15705
+ details: `
15706
+ Accepts a bundle / plugin id (\`core\`, \`claude\`, \`my-plugin\`)
15707
+ or a qualified extension id (\`core/<ext-id>\`,
15708
+ \`<plugin>/<ext-id>\`). When given a qualified id, validates the
15709
+ extension exists and renders the parent bundle's detail (which
15710
+ lists every extension with per-extension status for
15711
+ granularity=extension bundles like \`core\`). The same id shapes
15712
+ \`sm plugins enable\` and \`sm plugins disable\` accept resolve
15713
+ cleanly here too.
15714
+ `
15136
15715
  });
15137
15716
  id = Option16.String({ required: true });
15138
15717
  pluginDir = Option16.String("--plugin-dir", { required: false });
@@ -15140,16 +15719,22 @@ var PluginsShowCommand = class extends SmCommand {
15140
15719
  const plugins = await loadAll({ global: this.global, pluginDir: this.pluginDir });
15141
15720
  const resolveEnabled = await buildResolver(this.global);
15142
15721
  const builtIns2 = builtInRows(resolveEnabled);
15143
- const builtIn = builtIns2.find((b) => b.id === this.id);
15144
- const match = plugins.find((p) => p.id === this.id);
15722
+ const stderr = this.context.stderr;
15723
+ const stderrAnsi = ansiFor({ isTTY: stderr.isTTY === true, noColorFlag: this.noColor });
15724
+ const lookupResult = resolveShowLookupId(this.id, builtIns2, plugins, stderrAnsi);
15725
+ if ("error" in lookupResult) {
15726
+ this.printer.error(lookupResult.error);
15727
+ return ExitCode.NotFound;
15728
+ }
15729
+ const lookupId = lookupResult.bundleId;
15730
+ const builtIn = builtIns2.find((b) => b.id === lookupId);
15731
+ const match = plugins.find((p) => p.id === lookupId);
15145
15732
  if (!builtIn && !match) {
15146
- const stderr = this.context.stderr;
15147
- const ansi2 = ansiFor({ isTTY: stderr.isTTY === true, noColorFlag: this.noColor });
15148
15733
  this.printer.error(
15149
15734
  tx(PLUGINS_TEXTS.pluginNotFound, {
15150
- glyph: ansi2.red("\u2715"),
15735
+ glyph: stderrAnsi.red("\u2715"),
15151
15736
  id: sanitizeForTerminal(this.id),
15152
- hint: ansi2.dim(PLUGINS_TEXTS.pluginNotFoundHint)
15737
+ hint: stderrAnsi.dim(PLUGINS_TEXTS.pluginNotFoundHint)
15153
15738
  })
15154
15739
  );
15155
15740
  return ExitCode.NotFound;
@@ -15166,6 +15751,44 @@ var PluginsShowCommand = class extends SmCommand {
15166
15751
  return ExitCode.Ok;
15167
15752
  }
15168
15753
  };
15754
+ function resolveShowLookupId(id, builtIns2, plugins, ansi) {
15755
+ if (!id.includes("/")) return { bundleId: id };
15756
+ const errGlyph = ansi.red("\u2715");
15757
+ const [bundleId, extId, ...rest] = id.split("/");
15758
+ if (!bundleId || !extId || rest.length > 0) {
15759
+ return {
15760
+ error: tx(PLUGINS_TEXTS.qualifiedIdUnknownBundle, {
15761
+ glyph: errGlyph,
15762
+ bundleId: sanitizeForTerminal(id),
15763
+ hint: ansi.dim(PLUGINS_TEXTS.qualifiedIdUnknownBundleHint)
15764
+ })
15765
+ };
15766
+ }
15767
+ const builtIn = builtIns2.find((b) => b.id === bundleId);
15768
+ const userPlugin = plugins.find((p) => p.id === bundleId);
15769
+ if (!builtIn && !userPlugin) {
15770
+ return {
15771
+ error: tx(PLUGINS_TEXTS.qualifiedIdUnknownBundle, {
15772
+ glyph: errGlyph,
15773
+ bundleId: sanitizeForTerminal(bundleId),
15774
+ hint: ansi.dim(PLUGINS_TEXTS.qualifiedIdUnknownBundleHint)
15775
+ })
15776
+ };
15777
+ }
15778
+ const knownExts = builtIn ? builtIn.extensions.map((e) => e.id) : userPlugin?.extensions?.map((e) => e.id) ?? [];
15779
+ if (!knownExts.includes(extId)) {
15780
+ return {
15781
+ error: tx(PLUGINS_TEXTS.qualifiedIdNotFound, {
15782
+ glyph: errGlyph,
15783
+ id: sanitizeForTerminal(id),
15784
+ bundleId: sanitizeForTerminal(bundleId),
15785
+ extId: sanitizeForTerminal(extId),
15786
+ hint: ansi.dim(PLUGINS_TEXTS.qualifiedIdNotFoundHint)
15787
+ })
15788
+ };
15789
+ }
15790
+ return { bundleId };
15791
+ }
15169
15792
  function renderBuiltInDetail(b, ansi) {
15170
15793
  const enabled = b.enabled;
15171
15794
  const glyph = enabled ? ansi.green(PLUGINS_TEXTS.rowGlyphOk) : ansi.red(PLUGINS_TEXTS.rowGlyphOff);
@@ -15677,6 +16300,14 @@ var TogglePluginsBase = class extends SmCommand {
15677
16300
  await withSqlite({ databasePath: dbPath, autoBackup: false }, async (adapter) => {
15678
16301
  for (const id of targets) {
15679
16302
  await adapter.pluginConfig.set(id, enabled);
16303
+ if (!enabled) {
16304
+ const slash = id.indexOf("/");
16305
+ if (slash < 0) {
16306
+ await adapter.contributions.purgeByPlugin(id);
16307
+ } else {
16308
+ await adapter.contributions.purgeByPlugin(id.slice(0, slash), id.slice(slash + 1));
16309
+ }
16310
+ }
15680
16311
  }
15681
16312
  });
15682
16313
  const verbPast = enabled ? "enabled" : "disabled";
@@ -15746,7 +16377,7 @@ function omitModule(key, value) {
15746
16377
  var VIEW_SLOTS_CATALOG = [
15747
16378
  { id: "card.title.right", summary: "Small icon marker next to the card title \u2014 language flag, platform glyph." },
15748
16379
  { id: "card.subtitle.left", summary: "Single non-negative integer in the card subtitle row." },
15749
- { id: "card.footer.left.counter", summary: "Counter chip in the left footer of the card." },
16380
+ { id: "card.footer.left", summary: "Counter chip in the left footer of the card." },
15750
16381
  { id: "card.footer.right", summary: "Counter chip in the right footer of the card." },
15751
16382
  { id: "graph.node.alert", summary: "Corner badge decoration on the graph node \u2014 alert / status." },
15752
16383
  { id: "inspector.header.badge.counter", summary: "Counter chip in the inspector header badge cluster." },
@@ -15757,7 +16388,7 @@ var VIEW_SLOTS_CATALOG = [
15757
16388
  { id: "inspector.body.panel.key-values", summary: "Flat key/value pairs (\u2264 50) in the inspector body." },
15758
16389
  { id: "inspector.body.panel.link-list", summary: "Clickable scope-relative paths (\u2264 100) in the inspector body." },
15759
16390
  { id: "inspector.body.panel.markdown", summary: "Sanitized markdown text (\u2264 4096 chars) in the inspector body." },
15760
- { id: "topbar.actions.indicator", summary: "Scope-wide indicator chip in the topbar actions cluster." }
16391
+ { id: "topbar.nav.start", summary: "Scope-wide indicator chip at the start of the topbar nav (before the view-switcher links)." }
15761
16392
  ];
15762
16393
  var INPUT_TYPES_CATALOG = [
15763
16394
  { id: "string-list", summary: "Array of free-form strings." },
@@ -15776,7 +16407,7 @@ var PluginsCreateCommand = class extends SmCommand {
15776
16407
  static usage = Command16.Usage({
15777
16408
  category: "Plugins",
15778
16409
  description: "Scaffold a new plugin directory.",
15779
- details: "Emits plugin.json + extension stub + README. Pre-filled with one view contribution (slot `card.footer.left.counter`) and one setting (`string-list`); edit to taste. Use `sm plugins slots list` to see other options."
16410
+ details: "Emits plugin.json + extension stub + README. Pre-filled with one view contribution (slot `card.footer.left`) and one setting (`string-list`); edit to taste. Use `sm plugins slots list` to see other options."
15780
16411
  });
15781
16412
  pluginId = Option16.String({ required: true, name: "plugin-id" });
15782
16413
  at = Option16.String("--at", { required: false });
@@ -15845,7 +16476,7 @@ function scaffolderExtractorStub(pluginId) {
15845
16476
  * export missing a string \\\`kind\\\` field\`.
15846
16477
  *
15847
16478
  * Declared view contributions (in plugin.json):
15848
- * - 'count' \u2192 slot \`card.footer.left.counter\` (renders as a chip
16479
+ * - 'count' \u2192 slot \`card.footer.left\` (renders as a chip
15849
16480
  * in the left footer of the node card)
15850
16481
  *
15851
16482
  * Declared settings:
@@ -15868,7 +16499,7 @@ export default {
15868
16499
 
15869
16500
  viewContributions: {
15870
16501
  count: {
15871
- slot: 'card.footer.left.counter',
16502
+ slot: 'card.footer.left',
15872
16503
  icon: '\u{1F50D}',
15873
16504
  label: 'kw',
15874
16505
  emitWhenEmpty: false,
@@ -16321,6 +16952,19 @@ var SCAN_TEXTS = {
16321
16952
  // cli/commands/watch.ts
16322
16953
  import { Command as Command18, Option as Option18 } from "clipanion";
16323
16954
 
16955
+ // core/runtime/fresh-resolver.ts
16956
+ async function buildFreshResolver(deps) {
16957
+ const overrides = await tryWithSqlite(
16958
+ { databasePath: deps.databasePath, autoBackup: false },
16959
+ async (adapter) => adapter.pluginConfig.loadOverrideMap()
16960
+ );
16961
+ if (overrides === null) return deps.fallbackResolver;
16962
+ return makeEnabledResolver(deps.effectiveConfig(), overrides);
16963
+ }
16964
+ function composeResolver(effectiveConfig, overrides) {
16965
+ return makeEnabledResolver(effectiveConfig, overrides);
16966
+ }
16967
+
16324
16968
  // core/watcher/i18n/runtime.texts.ts
16325
16969
  var RUNTIME_TEXTS = {
16326
16970
  /**
@@ -16388,8 +17032,16 @@ function createWatcherRuntime(opts) {
16388
17032
  events.onPluginWarning?.(warn);
16389
17033
  }
16390
17034
  const runOnePass = async () => {
17035
+ const resolveEnabledOverride = await buildFreshResolver({
17036
+ databasePath: opts.dbPath,
17037
+ effectiveConfig: () => cfg,
17038
+ fallbackResolver: pluginRuntime.resolveEnabled
17039
+ });
16391
17040
  const kernel = createKernel();
16392
- registerEnabledExtensions(kernel, pluginRuntime, { noBuiltIns: opts.noBuiltIns });
17041
+ registerEnabledExtensions(kernel, pluginRuntime, {
17042
+ noBuiltIns: opts.noBuiltIns,
17043
+ resolveEnabled: resolveEnabledOverride
17044
+ });
16393
17045
  const emitter = opts.emitterFactory();
16394
17046
  const priorState = await tryWithSqlite(
16395
17047
  { databasePath: opts.dbPath, autoBackup: false },
@@ -16411,7 +17063,8 @@ function createWatcherRuntime(opts) {
16411
17063
  );
16412
17064
  const composeOpts = {
16413
17065
  noBuiltIns: opts.noBuiltIns,
16414
- pluginRuntime
17066
+ pluginRuntime,
17067
+ resolveEnabled: resolveEnabledOverride
16415
17068
  };
16416
17069
  if (opts.killSwitches) composeOpts.killSwitches = opts.killSwitches;
16417
17070
  const composed = composeScanExtensions(composeOpts);
@@ -17305,6 +17958,48 @@ import { WebSocketServer } from "ws";
17305
17958
  import { Hono } from "hono";
17306
17959
  import { HTTPException as HTTPException11 } from "hono/http-exception";
17307
17960
 
17961
+ // core/config/service.ts
17962
+ var ConfigService = class {
17963
+ #opts;
17964
+ #cache = null;
17965
+ constructor(opts) {
17966
+ this.#opts = opts;
17967
+ }
17968
+ /**
17969
+ * Return the cached `ILoadedConfig` (loading on first call).
17970
+ * Subsequent calls return the same object reference — callers
17971
+ * MUST treat it as read-only.
17972
+ */
17973
+ get() {
17974
+ if (this.#cache === null) {
17975
+ this.#cache = loadConfig({
17976
+ scope: this.#opts.scope,
17977
+ cwd: this.#opts.cwd,
17978
+ homedir: this.#opts.homedir,
17979
+ ...this.#opts.strict ? { strict: true } : {}
17980
+ });
17981
+ }
17982
+ return this.#cache;
17983
+ }
17984
+ /**
17985
+ * Sugar for `this.get().effective` — the most common consumer pattern
17986
+ * (the `sources` / `warnings` slots are only relevant to the
17987
+ * `GET /api/config` and `sm config show` paths).
17988
+ */
17989
+ effective() {
17990
+ return this.get().effective;
17991
+ }
17992
+ /**
17993
+ * Drop the cached `ILoadedConfig`. Next `get()` re-reads every layer
17994
+ * from disk. Called by routes after a successful `writeConfigValue`
17995
+ * (PATCH preferences / project-preferences) and by the sidecar
17996
+ * consent gate after it flips `allowEditSmFiles` to `true`.
17997
+ */
17998
+ reload() {
17999
+ this.#cache = null;
18000
+ }
18001
+ };
18002
+
17308
18003
  // server/i18n/server.texts.ts
17309
18004
  var SERVER_TEXTS = {
17310
18005
  // Boot banner — printed by the server itself when it begins to listen.
@@ -17383,6 +18078,15 @@ var SERVER_TEXTS = {
17383
18078
  sidecarBodyNotObject: "Request body must be a JSON object.",
17384
18079
  sidecarNodePathRequired: "`nodePath` is required and must be a non-empty string.",
17385
18080
  sidecarForceMustBeBoolean: "`force` must be a boolean when present.",
18081
+ sidecarConfirmMustBeBoolean: "`confirm` must be a boolean when present.",
18082
+ /**
18083
+ * 412 envelope when `POST /api/sidecar/bump` would create a `.sm`
18084
+ * file but `allowEditSmFiles` is still false. The UI's bump
18085
+ * call-path catches `code: 'confirm-required'` and opens a
18086
+ * `ConfirmationService` dialog explaining `.sm` writes; on accept
18087
+ * it retries with `confirm: true` in the body.
18088
+ */
18089
+ sidecarConsentRequired: "consent required to write .sm sidecar files in this project. Retry with `confirm: true` to grant (writes to .skill-map/settings.local.json \u2014 gitignored).",
17386
18090
  // 500 envelope when the built-in bump action ships without an
17387
18091
  // `invoke()` — should be impossible in production but the route
17388
18092
  // throws a typed envelope rather than a bare `Error` so the global
@@ -17422,6 +18126,12 @@ var SERVER_TEXTS = {
17422
18126
  // disabling the toggle.
17423
18127
  pluginsLocked: 'Plugin "{{id}}" is locked by the host and cannot be toggled.',
17424
18128
  pluginsExtensionLocked: 'Extension "{{bundleId}}/{{extensionId}}" is locked by the host and cannot be toggled.',
18129
+ // 400 envelopes specific to the bulk `PATCH /api/plugins` endpoint.
18130
+ // The single-id variants above still apply for per-entry validation
18131
+ // (unknown id, granularity mismatch, lock); these cover the
18132
+ // body-shape level.
18133
+ pluginsChangesRequired: "Request body must include a `changes` array of `{ id, enabled }` entries.",
18134
+ pluginsChangeMalformed: "Each entry in `changes` must have a string `id` and a boolean `enabled`.",
17425
18135
  // ---- preferences route (routes/preferences.ts) --------------------------
17426
18136
  //
17427
18137
  // GET / PATCH /api/preferences. The PATCH body is shaped
@@ -17576,11 +18286,7 @@ function registerConfigRoute(app, deps) {
17576
18286
  app.get("/api/config", (c) => {
17577
18287
  let loaded;
17578
18288
  try {
17579
- loaded = loadConfig({
17580
- scope: deps.options.scope,
17581
- cwd: deps.runtimeContext.cwd,
17582
- homedir: deps.runtimeContext.homedir
17583
- });
18289
+ loaded = deps.configService.get();
17584
18290
  } catch (err) {
17585
18291
  throw new HTTPException(500, { message: formatErrorMessage(err) });
17586
18292
  }
@@ -18109,7 +18815,7 @@ async function groupContributionsByPath(rows) {
18109
18815
  import { HTTPException as HTTPException6 } from "hono/http-exception";
18110
18816
  function registerPluginsRoute(app, deps) {
18111
18817
  app.get("/api/plugins", async (c) => {
18112
- const resolveEnabled = await buildFreshResolver(deps);
18818
+ const resolveEnabled = await buildFreshResolver2(deps);
18113
18819
  const items = listItems(deps, resolveEnabled);
18114
18820
  return c.json(
18115
18821
  buildListEnvelope({
@@ -18176,6 +18882,26 @@ function registerPluginsRoute(app, deps) {
18176
18882
  const body = await parsePatchBody(c.req.raw);
18177
18883
  return await persistAndProject(c, deps, qualified, body.enabled);
18178
18884
  });
18885
+ app.patch("/api/plugins", async (c) => {
18886
+ const changes = await parseBulkBody(c.req.raw);
18887
+ for (const change of changes) {
18888
+ const failure = validateBulkChange(change, deps);
18889
+ if (failure !== null) {
18890
+ return c.json(
18891
+ {
18892
+ ok: false,
18893
+ error: {
18894
+ code: failure.code,
18895
+ message: failure.message,
18896
+ details: { id: change.id }
18897
+ }
18898
+ },
18899
+ failure.status
18900
+ );
18901
+ }
18902
+ }
18903
+ return await persistBulkAndProject(c, deps, changes);
18904
+ });
18179
18905
  }
18180
18906
  function listItems(deps, resolveEnabled) {
18181
18907
  return [
@@ -18230,7 +18956,8 @@ function buildDiscoveredItem(plugin, deps, resolveEnabled) {
18230
18956
  source: classifyPluginSource(plugin.path, deps),
18231
18957
  granularity,
18232
18958
  ...optional,
18233
- ...bundleLocked ? { locked: true } : {}
18959
+ ...bundleLocked ? { locked: true } : {},
18960
+ ...plugin.status === "disabled" ? { startsAsDisabled: true } : {}
18234
18961
  };
18235
18962
  }
18236
18963
  function optionalDiscoveredFields(plugin, extensions) {
@@ -18300,10 +19027,26 @@ async function persistAndProject(c, deps, configKey, enabled) {
18300
19027
  const overrides = await tryWithSqlite(
18301
19028
  { databasePath: deps.options.dbPath, autoBackup: false },
18302
19029
  async (adapter) => {
18303
- await adapter.pluginConfig.set(configKey, enabled);
19030
+ await applyChangeToAdapter(adapter, configKey, enabled);
18304
19031
  return await adapter.pluginConfig.loadOverrideMap();
18305
19032
  }
18306
19033
  );
19034
+ return projectListResponse(c, deps, overrides);
19035
+ }
19036
+ async function applyChangeToAdapter(adapter, configKey, enabled) {
19037
+ await adapter.pluginConfig.set(configKey, enabled);
19038
+ if (enabled) return;
19039
+ const slash = configKey.indexOf("/");
19040
+ if (slash < 0) {
19041
+ await adapter.contributions.purgeByPlugin(configKey);
19042
+ return;
19043
+ }
19044
+ await adapter.contributions.purgeByPlugin(
19045
+ configKey.slice(0, slash),
19046
+ configKey.slice(slash + 1)
19047
+ );
19048
+ }
19049
+ function projectListResponse(c, deps, overrides) {
18307
19050
  if (overrides === null) {
18308
19051
  return c.json(
18309
19052
  {
@@ -18317,7 +19060,7 @@ async function persistAndProject(c, deps, configKey, enabled) {
18317
19060
  500
18318
19061
  );
18319
19062
  }
18320
- const freshResolver = composeResolver(deps, overrides);
19063
+ const freshResolver = composeResolver2(deps, overrides);
18321
19064
  const items = listItems(deps, freshResolver);
18322
19065
  return c.json(
18323
19066
  buildListEnvelope({
@@ -18330,21 +19073,119 @@ async function persistAndProject(c, deps, configKey, enabled) {
18330
19073
  })
18331
19074
  );
18332
19075
  }
18333
- async function buildFreshResolver(deps) {
19076
+ async function parseBulkBody(req) {
19077
+ let raw;
19078
+ try {
19079
+ raw = await req.json();
19080
+ } catch {
19081
+ throw new HTTPException6(400, { message: SERVER_TEXTS.pluginsBodyNotJson });
19082
+ }
19083
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
19084
+ throw new HTTPException6(400, { message: SERVER_TEXTS.pluginsBodyNotObject });
19085
+ }
19086
+ const obj = raw;
19087
+ const changes = obj["changes"];
19088
+ if (!Array.isArray(changes)) {
19089
+ throw new HTTPException6(400, { message: SERVER_TEXTS.pluginsChangesRequired });
19090
+ }
19091
+ const out = [];
19092
+ for (const entry of changes) {
19093
+ if (!isWellShapedBulkEntry(entry)) {
19094
+ throw new HTTPException6(400, { message: SERVER_TEXTS.pluginsChangeMalformed });
19095
+ }
19096
+ out.push({
19097
+ id: entry.id,
19098
+ enabled: entry.enabled
19099
+ });
19100
+ }
19101
+ return out;
19102
+ }
19103
+ function isWellShapedBulkEntry(entry) {
19104
+ if (entry === null || typeof entry !== "object" || Array.isArray(entry)) return false;
19105
+ const obj = entry;
19106
+ return typeof obj["id"] === "string" && typeof obj["enabled"] === "boolean";
19107
+ }
19108
+ function validateBulkChange(change, deps) {
19109
+ const slash = change.id.indexOf("/");
19110
+ if (slash < 0) {
19111
+ const handle2 = findHandle(change.id, deps);
19112
+ if (!handle2) {
19113
+ return {
19114
+ status: 404,
19115
+ code: "not-found",
19116
+ message: tx(SERVER_TEXTS.pluginsUnknown, { id: change.id })
19117
+ };
19118
+ }
19119
+ if (granularityOf(handle2) !== "bundle") {
19120
+ return {
19121
+ status: 400,
19122
+ code: "bad-query",
19123
+ message: tx(SERVER_TEXTS.pluginsGranularityExtensionExpected, { id: change.id })
19124
+ };
19125
+ }
19126
+ if (isPluginLocked(change.id)) {
19127
+ return {
19128
+ status: 403,
19129
+ code: "locked",
19130
+ message: tx(SERVER_TEXTS.pluginsLocked, { id: change.id })
19131
+ };
19132
+ }
19133
+ return null;
19134
+ }
19135
+ const bundleId = change.id.slice(0, slash);
19136
+ const extensionId = change.id.slice(slash + 1);
19137
+ const handle = findHandle(bundleId, deps);
19138
+ if (!handle) {
19139
+ return {
19140
+ status: 404,
19141
+ code: "not-found",
19142
+ message: tx(SERVER_TEXTS.pluginsUnknown, { id: bundleId })
19143
+ };
19144
+ }
19145
+ if (granularityOf(handle) !== "extension") {
19146
+ return {
19147
+ status: 400,
19148
+ code: "bad-query",
19149
+ message: tx(SERVER_TEXTS.pluginsGranularityBundleExpected, { id: bundleId })
19150
+ };
19151
+ }
19152
+ if (!hasExtension(handle, extensionId)) {
19153
+ return {
19154
+ status: 404,
19155
+ code: "not-found",
19156
+ message: tx(SERVER_TEXTS.pluginsExtensionUnknown, { bundleId, extensionId })
19157
+ };
19158
+ }
19159
+ if (isPluginLocked(change.id) || isPluginLocked(bundleId)) {
19160
+ return {
19161
+ status: 403,
19162
+ code: "locked",
19163
+ message: tx(SERVER_TEXTS.pluginsExtensionLocked, { bundleId, extensionId })
19164
+ };
19165
+ }
19166
+ return null;
19167
+ }
19168
+ async function persistBulkAndProject(c, deps, changes) {
18334
19169
  const overrides = await tryWithSqlite(
18335
19170
  { databasePath: deps.options.dbPath, autoBackup: false },
18336
- async (adapter) => adapter.pluginConfig.loadOverrideMap()
19171
+ async (adapter) => {
19172
+ for (const change of changes) {
19173
+ await applyChangeToAdapter(adapter, change.id, change.enabled);
19174
+ }
19175
+ return await adapter.pluginConfig.loadOverrideMap();
19176
+ }
18337
19177
  );
18338
- if (overrides === null) return deps.pluginRuntime.resolveEnabled;
18339
- return composeResolver(deps, overrides);
19178
+ return projectListResponse(c, deps, overrides);
18340
19179
  }
18341
- function composeResolver(deps, overrides) {
18342
- const { effective: cfg } = loadConfig({
18343
- scope: deps.options.scope,
18344
- cwd: deps.runtimeContext.cwd,
18345
- homedir: deps.runtimeContext.homedir
19180
+ async function buildFreshResolver2(deps) {
19181
+ return buildFreshResolver({
19182
+ databasePath: deps.options.dbPath,
19183
+ effectiveConfig: () => deps.configService.effective(),
19184
+ fallbackResolver: deps.pluginRuntime.resolveEnabled
18346
19185
  });
18347
- return makeEnabledResolver(cfg, overrides);
19186
+ }
19187
+ function composeResolver2(deps, overrides) {
19188
+ return composeResolver(deps.configService.effective(), overrides);
18348
19189
  }
18349
19190
  function findHandle(id, deps) {
18350
19191
  const builtIn = builtInBundles.find((b) => b.id === id);
@@ -18387,6 +19228,7 @@ function buildEnvelope(deps) {
18387
19228
  };
18388
19229
  }
18389
19230
  function applyPatch(deps, body) {
19231
+ let wrote = false;
18390
19232
  if (body.updateCheck && typeof body.updateCheck.enabled === "boolean") {
18391
19233
  try {
18392
19234
  writeConfigValue("updateCheck.enabled", body.updateCheck.enabled, {
@@ -18394,6 +19236,7 @@ function applyPatch(deps, body) {
18394
19236
  cwd: deps.runtimeContext.cwd,
18395
19237
  homedir: deps.runtimeContext.homedir
18396
19238
  });
19239
+ wrote = true;
18397
19240
  } catch (err) {
18398
19241
  throw new HTTPException7(400, {
18399
19242
  message: tx(SERVER_TEXTS.preferencesPersistFailed, {
@@ -18402,6 +19245,7 @@ function applyPatch(deps, body) {
18402
19245
  });
18403
19246
  }
18404
19247
  }
19248
+ if (wrote) deps.configService.reload();
18405
19249
  }
18406
19250
  async function parsePatchBody2(req) {
18407
19251
  const obj = await readJsonObject(req);
@@ -18496,7 +19340,7 @@ function applyPatch2(deps, body) {
18496
19340
  }
18497
19341
  for (const w of writes) {
18498
19342
  try {
18499
- writeConfigValue(w.key, w.value, { target: "project", cwd, homedir: homedir4 });
19343
+ writeConfigValue(w.key, w.value, { target: "project-local", cwd, homedir: homedir4 });
18500
19344
  } catch (err) {
18501
19345
  const status = err instanceof ConfigValidationError ? 400 : 400;
18502
19346
  throw new HTTPException8(status, {
@@ -18507,6 +19351,7 @@ function applyPatch2(deps, body) {
18507
19351
  });
18508
19352
  }
18509
19353
  }
19354
+ deps.configService.reload();
18510
19355
  }
18511
19356
  function collectWrites(body) {
18512
19357
  if (!body.scan) return [];
@@ -18732,6 +19577,7 @@ async function runPersistedScan(c, deps) {
18732
19577
  }
18733
19578
  try {
18734
19579
  return await withScanMutex(async () => {
19580
+ const resolveEnabledOverride = await buildBffResolverOverride(deps);
18735
19581
  const outcome = await runScanForCommand({
18736
19582
  roots: [deps.runtimeContext.cwd],
18737
19583
  noBuiltIns: deps.options.noBuiltIns,
@@ -18744,6 +19590,7 @@ async function runPersistedScan(c, deps) {
18744
19590
  stderr: process.stderr,
18745
19591
  ctx: deps.runtimeContext,
18746
19592
  pluginRuntime: deps.pluginRuntime,
19593
+ resolveEnabledOverride,
18747
19594
  printer: bffScanRunnerPrinter,
18748
19595
  emitterFactory: () => buildBroadcasterEmitter(deps.broadcaster)
18749
19596
  });
@@ -18761,6 +19608,13 @@ async function runPersistedScan(c, deps) {
18761
19608
  throw err;
18762
19609
  }
18763
19610
  }
19611
+ async function buildBffResolverOverride(deps) {
19612
+ return buildFreshResolver({
19613
+ databasePath: deps.options.dbPath,
19614
+ effectiveConfig: () => deps.configService.effective(),
19615
+ fallbackResolver: deps.pluginRuntime.resolveEnabled
19616
+ });
19617
+ }
18764
19618
  async function loadPersistedScan(deps) {
18765
19619
  const opened = await tryWithSqlite(
18766
19620
  { databasePath: deps.options.dbPath, autoBackup: false },
@@ -18815,6 +19669,7 @@ async function runFreshScan(deps) {
18815
19669
  if (deps.options.noBuiltIns || deps.options.noPlugins) {
18816
19670
  throw new HTTPException9(400, { message: SERVER_TEXTS.freshScanRequiresPipeline });
18817
19671
  }
19672
+ const resolveEnabledOverride = await buildBffResolverOverride(deps);
18818
19673
  const outcome = await runScanForCommand({
18819
19674
  roots: [deps.runtimeContext.cwd],
18820
19675
  noBuiltIns: deps.options.noBuiltIns,
@@ -18833,6 +19688,7 @@ async function runFreshScan(deps) {
18833
19688
  // discovering new plugins here would surface them in scan output
18834
19689
  // but not in `/api/plugins` or the kindRegistry).
18835
19690
  pluginRuntime: deps.pluginRuntime,
19691
+ resolveEnabledOverride,
18836
19692
  // M8: explicit printer instead of the runner's old stdout=stderr
18837
19693
  // fallback. The fresh-scan response body IS the ScanResult JSON,
18838
19694
  // so `data` is never used here; warn/info/error route through
@@ -18912,12 +19768,20 @@ function registerSidecarRoutes(app, deps) {
18912
19768
  try {
18913
19769
  for (const w of result.writes ?? []) {
18914
19770
  if (w.kind === "sidecar") {
18915
- await store.applyPatch(w.path, w.changes);
19771
+ await store.applyPatch(w.path, w.changes, {
19772
+ confirm: body.confirm,
19773
+ cwd: deps.runtimeContext.cwd,
19774
+ homedir: deps.runtimeContext.homedir
19775
+ });
18916
19776
  }
18917
19777
  }
18918
19778
  } catch (err) {
19779
+ if (err instanceof EConsentRequiredError) throw err;
18919
19780
  throw new HTTPException10(500, { message: formatErrorMessage(err) });
18920
19781
  }
19782
+ if (body.confirm === true) {
19783
+ deps.configService.reload();
19784
+ }
18921
19785
  const newVersion = result.report.version ?? null;
18922
19786
  const eventData = {
18923
19787
  nodePath: node.path,
@@ -18962,9 +19826,14 @@ async function parseBody(req) {
18962
19826
  if (forceRaw !== void 0 && typeof forceRaw !== "boolean") {
18963
19827
  throw new HTTPException10(400, { message: SERVER_TEXTS.sidecarForceMustBeBoolean });
18964
19828
  }
19829
+ const confirmRaw = obj["confirm"];
19830
+ if (confirmRaw !== void 0 && typeof confirmRaw !== "boolean") {
19831
+ throw new HTTPException10(400, { message: SERVER_TEXTS.sidecarConfirmMustBeBoolean });
19832
+ }
18965
19833
  return {
18966
19834
  nodePath: nodePathRaw,
18967
- force: forceRaw === true
19835
+ force: forceRaw === true,
19836
+ confirm: confirmRaw === true
18968
19837
  };
18969
19838
  }
18970
19839
  async function loadNode(deps, nodePath) {
@@ -19169,6 +20038,11 @@ function attachBroadcasterRoute(app, broadcaster) {
19169
20038
  // server/app.ts
19170
20039
  function createApp(deps) {
19171
20040
  const app = new Hono();
20041
+ const configService = new ConfigService({
20042
+ scope: deps.options.scope,
20043
+ cwd: deps.runtimeContext.cwd,
20044
+ homedir: deps.runtimeContext.homedir
20045
+ });
19172
20046
  if (deps.options.devCors) {
19173
20047
  app.use("*", async (c, next) => {
19174
20048
  await next();
@@ -19188,7 +20062,8 @@ function createApp(deps) {
19188
20062
  runtimeContext: deps.runtimeContext,
19189
20063
  kindRegistry: deps.kindRegistry,
19190
20064
  contributionsRegistry: deps.contributionsRegistry,
19191
- pluginRuntime: deps.pluginRuntime
20065
+ pluginRuntime: deps.pluginRuntime,
20066
+ configService
19192
20067
  };
19193
20068
  registerScanRoute(app, { ...routeDeps, broadcaster: deps.broadcaster });
19194
20069
  registerNodesRoutes(app, routeDeps);
@@ -19257,6 +20132,17 @@ function formatError2(err, c) {
19257
20132
  };
19258
20133
  return c.json(envelope2, 400);
19259
20134
  }
20135
+ if (err instanceof EConsentRequiredError) {
20136
+ const envelope2 = {
20137
+ ok: false,
20138
+ error: {
20139
+ code: "confirm-required",
20140
+ message: err.message,
20141
+ details: { key: err.key }
20142
+ }
20143
+ };
20144
+ return c.json(envelope2, 412);
20145
+ }
19260
20146
  const envelope = {
19261
20147
  ok: false,
19262
20148
  error: {
@@ -19696,50 +20582,64 @@ async function assembleBootBundle(options, runtimeContext) {
19696
20582
  for (const warn of pluginRuntime.warnings) {
19697
20583
  log.warn(sanitizeForTerminal(warn));
19698
20584
  }
19699
- const composed = composeScanExtensions({
19700
- noBuiltIns: options.noBuiltIns,
19701
- pluginRuntime
19702
- });
19703
- const kindRegistry = buildKindRegistry(composed?.providers ?? []);
20585
+ const builtInProviders = options.noBuiltIns ? [] : collectBuiltInProviders();
20586
+ const kindRegistry = buildKindRegistry([
20587
+ ...builtInProviders,
20588
+ ...pluginRuntime.extensions.providers
20589
+ ]);
19704
20590
  const kernel = createKernel();
19705
20591
  kernel.setRegisteredAnnotationKeys(pluginRuntime.annotationContributions);
19706
20592
  const mergedViewContributions = mergeBuiltInViewContributions(
19707
20593
  pluginRuntime.viewContributions,
19708
- composed
20594
+ options.noBuiltIns
19709
20595
  );
19710
20596
  kernel.setRegisteredViewContributions(mergedViewContributions);
19711
20597
  const contributionsRegistry = buildContributionsRegistry(kernel);
19712
20598
  return { pluginRuntime, kindRegistry, contributionsRegistry, kernel };
19713
20599
  }
19714
- function mergeBuiltInViewContributions(userPluginContributions, composed) {
20600
+ function collectBuiltInProviders() {
20601
+ const out = [];
20602
+ for (const bundle of builtInBundles) {
20603
+ for (const ext of bundle.extensions) {
20604
+ if (ext.kind === "provider") {
20605
+ out.push(ext);
20606
+ }
20607
+ }
20608
+ }
20609
+ return out;
20610
+ }
20611
+ function mergeBuiltInViewContributions(userPluginContributions, noBuiltIns) {
19715
20612
  const merged = [...userPluginContributions];
19716
- if (!composed) return merged;
20613
+ if (noBuiltIns) return merged;
19717
20614
  const userKey = new Set(
19718
20615
  userPluginContributions.map(
19719
20616
  (c) => `${c.pluginId}/${c.extensionId}/${c.contributionId}`
19720
20617
  )
19721
20618
  );
19722
- for (const ext of [...composed.extractors, ...composed.analyzers]) {
19723
- const raw = ext.viewContributions;
19724
- if (typeof raw !== "object" || raw === null) continue;
19725
- for (const [contributionId, value] of Object.entries(raw)) {
19726
- if (typeof value !== "object" || value === null) continue;
19727
- const v = value;
19728
- if (typeof v.slot !== "string") continue;
19729
- const qualified = `${ext.pluginId}/${ext.id}/${contributionId}`;
19730
- if (userKey.has(qualified)) continue;
19731
- const entry = {
19732
- pluginId: ext.pluginId,
19733
- extensionId: ext.id,
19734
- contributionId,
19735
- slot: v.slot,
19736
- emitWhenEmpty: v.emitWhenEmpty === true
19737
- };
19738
- if (typeof v.label === "string") entry.label = v.label;
19739
- if (typeof v.tooltip === "string") entry.tooltip = v.tooltip;
19740
- if (typeof v.icon === "string") entry.icon = v.icon;
19741
- if (typeof v.emptyText === "string") entry.emptyText = v.emptyText;
19742
- merged.push(entry);
20619
+ for (const bundle of builtInBundles) {
20620
+ for (const ext of bundle.extensions) {
20621
+ if (ext.kind !== "extractor" && ext.kind !== "analyzer") continue;
20622
+ const raw = ext.viewContributions;
20623
+ if (typeof raw !== "object" || raw === null) continue;
20624
+ for (const [contributionId, value] of Object.entries(raw)) {
20625
+ if (typeof value !== "object" || value === null) continue;
20626
+ const v = value;
20627
+ if (typeof v.slot !== "string") continue;
20628
+ const qualified = `${ext.pluginId}/${ext.id}/${contributionId}`;
20629
+ if (userKey.has(qualified)) continue;
20630
+ const entry = {
20631
+ pluginId: ext.pluginId,
20632
+ extensionId: ext.id,
20633
+ contributionId,
20634
+ slot: v.slot,
20635
+ emitWhenEmpty: v.emitWhenEmpty === true
20636
+ };
20637
+ if (typeof v.label === "string") entry.label = v.label;
20638
+ if (typeof v.tooltip === "string") entry.tooltip = v.tooltip;
20639
+ if (typeof v.icon === "string") entry.icon = v.icon;
20640
+ if (typeof v.emptyText === "string") entry.emptyText = v.emptyText;
20641
+ merged.push(entry);
20642
+ }
19743
20643
  }
19744
20644
  }
19745
20645
  return merged;
@@ -20579,10 +21479,9 @@ function rankConfidenceForGrouping(c) {
20579
21479
  }
20580
21480
 
20581
21481
  // cli/commands/sidecar.ts
20582
- import { existsSync as existsSync26, unlinkSync as unlinkSync3, writeFileSync as writeFileSync5 } from "fs";
21482
+ import { existsSync as existsSync26, unlinkSync as unlinkSync3 } from "fs";
20583
21483
  import { resolve as resolve30 } from "path";
20584
21484
  import { Command as Command23, Option as Option23 } from "clipanion";
20585
- import yaml5 from "js-yaml";
20586
21485
 
20587
21486
  // cli/i18n/sidecar.texts.ts
20588
21487
  var SIDECAR_TEXTS = {
@@ -20610,10 +21509,51 @@ var SIDECAR_TEXTS = {
20610
21509
  annotateCreated: "{{glyph}} Created {{sidecarPath}}. Edit it, then run `sm bump {{nodePath}}` to commit the version.\n",
20611
21510
  /** Trailing dim tag for sidecar prune dry-run (matches the orphans pattern). */
20612
21511
  sidecarDryRunTag: " (no changes made)",
20613
- annotateFailed: "{{glyph}} sm sidecar annotate: {{message}}\n"
21512
+ annotateFailed: "{{glyph}} sm sidecar annotate: {{message}}\n",
21513
+ // --- .sm consent gate (shared across refresh + annotate) -----------------
21514
+ /**
21515
+ * Pre-prompt context shown before the interactive `confirm()` so the
21516
+ * operator sees what they are about to opt into. `.skill-map/settings.local.json`
21517
+ * is gitignored — the choice is saved per-checkout, never travels via the repo.
21518
+ */
21519
+ consentPrompt: "skill-map needs your consent to create .sm sidecar files next to your\nsource files in this project. The choice is saved to\n.skill-map/settings.local.json (gitignored, per-checkout) so this prompt\nnever appears again. Decline to abort without persisting the rejection.\n\nAllow .sm sidecar writes in this project?",
21520
+ consentAborted: "{{glyph}} sm sidecar: aborted by user. No .sm sidecar files were written.\n",
21521
+ consentRequiredNonTty: "{{glyph}} sm sidecar: consent required to write .sm sidecar files in this project.\n {{hint}}\n",
21522
+ consentRequiredNonTtyHint: "Pass --yes to grant (writes to .skill-map/settings.local.json \u2014 gitignored)."
20614
21523
  };
20615
21524
 
20616
21525
  // cli/commands/sidecar.ts
21526
+ async function runWithSidecarConsent(bag, ansi, dispatch) {
21527
+ try {
21528
+ return await dispatch();
21529
+ } catch (err) {
21530
+ if (!(err instanceof EConsentRequiredError)) throw err;
21531
+ const isTTY = bag.stdin.isTTY === true;
21532
+ if (!isTTY || bag.yes) {
21533
+ const errGlyph = ansi.red("\u2715");
21534
+ bag.printError(
21535
+ tx(SIDECAR_TEXTS.consentRequiredNonTty, {
21536
+ glyph: errGlyph,
21537
+ hint: ansi.dim(SIDECAR_TEXTS.consentRequiredNonTtyHint)
21538
+ })
21539
+ );
21540
+ return ExitCode.Error;
21541
+ }
21542
+ const ok = await confirm(
21543
+ SIDECAR_TEXTS.consentPrompt,
21544
+ { stdin: bag.stdin, stderr: bag.stderr },
21545
+ { defaultAnswer: "yes" }
21546
+ );
21547
+ if (!ok) {
21548
+ bag.printInfo(
21549
+ tx(SIDECAR_TEXTS.consentAborted, { glyph: ansi.cyan("\u2139") })
21550
+ );
21551
+ return ExitCode.Error;
21552
+ }
21553
+ bag.onAccept();
21554
+ return await dispatch();
21555
+ }
21556
+ }
20617
21557
  var SidecarRefreshCommand = class extends SmCommand {
20618
21558
  static paths = [["sidecar", "refresh"]];
20619
21559
  static usage = Command23.Usage({
@@ -20634,9 +21574,9 @@ var SidecarRefreshCommand = class extends SmCommand {
20634
21574
  ]
20635
21575
  });
20636
21576
  nodePath = Option23.String({ required: true });
20637
- // Complexity is from CLI ergonomics: db-load / not-found / abs-path
20638
- // / no-sidecar / fresh / write-error / json-vs-pretty branches.
20639
- // eslint-disable-next-line complexity
21577
+ yes = Option23.Boolean("--yes", false, {
21578
+ description: "Confirm writing .sm sidecar files in this project (sets allowEditSmFiles=true on first run)."
21579
+ });
20640
21580
  async run() {
20641
21581
  const ctx = defaultRuntimeContext();
20642
21582
  const dbPath = resolveDbPath({ global: this.global, db: this.db, ...ctx });
@@ -20644,6 +21584,27 @@ var SidecarRefreshCommand = class extends SmCommand {
20644
21584
  const ansi = ansiFor({ isTTY: stdout.isTTY === true, noColorFlag: this.noColor });
20645
21585
  const okGlyph = ansi.green("\u2713");
20646
21586
  const errGlyph = ansi.red("\u2715");
21587
+ return runWithSidecarConsent(
21588
+ {
21589
+ stdin: this.context.stdin,
21590
+ stderr: this.context.stderr,
21591
+ yes: this.yes,
21592
+ onAccept: () => {
21593
+ this.yes = true;
21594
+ },
21595
+ printInfo: (s) => this.printer.info(s),
21596
+ printError: (s) => this.printer.error(s)
21597
+ },
21598
+ ansi,
21599
+ () => this.#runOnce(ctx, dbPath, okGlyph, errGlyph, ansi)
21600
+ );
21601
+ }
21602
+ // Inner dispatch — single attempt. The outer `run()` wraps every
21603
+ // call in `runWithSidecarConsent` so an `EConsentRequiredError`
21604
+ // surfaces as an interactive prompt (TTY) or a directed exit
21605
+ // (non-TTY).
21606
+ // eslint-disable-next-line complexity
21607
+ async #runOnce(ctx, dbPath, okGlyph, errGlyph, ansi) {
20647
21608
  const persisted = await tryWithSqlite(
20648
21609
  { databasePath: dbPath, autoBackup: false },
20649
21610
  async (adapter) => adapter.scans.load()
@@ -20698,14 +21659,19 @@ var SidecarRefreshCommand = class extends SmCommand {
20698
21659
  }
20699
21660
  const store = new FilesystemSidecarStore();
20700
21661
  try {
20701
- await store.applyPatch(sidecarAbsPath, {
20702
- identity: {
20703
- path: node.path,
20704
- bodyHash: node.bodyHash,
20705
- frontmatterHash: node.frontmatterHash
20706
- }
20707
- });
21662
+ await store.applyPatch(
21663
+ sidecarAbsPath,
21664
+ {
21665
+ identity: {
21666
+ path: node.path,
21667
+ bodyHash: node.bodyHash,
21668
+ frontmatterHash: node.frontmatterHash
21669
+ }
21670
+ },
21671
+ { confirm: this.yes, cwd: ctx.cwd, homedir: ctx.homedir }
21672
+ );
20708
21673
  } catch (err) {
21674
+ if (err instanceof EConsentRequiredError) throw err;
20709
21675
  this.printer.error(
20710
21676
  tx(SIDECAR_TEXTS.refreshFailed, { glyph: errGlyph, message: formatErrorMessage(err) })
20711
21677
  );
@@ -20892,12 +21858,32 @@ var SidecarAnnotateCommand = class extends SmCommand {
20892
21858
  });
20893
21859
  nodePath = Option23.String({ required: true });
20894
21860
  force = Option23.Boolean("--force", false);
21861
+ yes = Option23.Boolean("--yes", false, {
21862
+ description: "Confirm writing .sm sidecar files in this project (sets allowEditSmFiles=true on first run)."
21863
+ });
20895
21864
  async run() {
20896
21865
  const ctx = defaultRuntimeContext();
20897
21866
  const dbPath = resolveDbPath({ global: this.global, db: this.db, ...ctx });
20898
21867
  const stdout = this.context.stdout;
20899
21868
  const ansi = ansiFor({ isTTY: stdout.isTTY === true, noColorFlag: this.noColor });
20900
21869
  const errGlyph = ansi.red("\u2715");
21870
+ return runWithSidecarConsent(
21871
+ {
21872
+ stdin: this.context.stdin,
21873
+ stderr: this.context.stderr,
21874
+ yes: this.yes,
21875
+ onAccept: () => {
21876
+ this.yes = true;
21877
+ },
21878
+ printInfo: (s) => this.printer.info(s),
21879
+ printError: (s) => this.printer.error(s)
21880
+ },
21881
+ ansi,
21882
+ () => this.#runOnce(ctx, dbPath, errGlyph, ansi)
21883
+ );
21884
+ }
21885
+ // eslint-disable-next-line complexity
21886
+ async #runOnce(ctx, dbPath, errGlyph, ansi) {
20901
21887
  const persisted = await tryWithSqlite(
20902
21888
  { databasePath: dbPath, autoBackup: false },
20903
21889
  async (adapter) => adapter.scans.load()
@@ -20944,10 +21930,25 @@ var SidecarAnnotateCommand = class extends SmCommand {
20944
21930
  );
20945
21931
  return ExitCode.Error;
20946
21932
  }
20947
- const scaffold = scaffoldSidecar(node);
21933
+ if (existsSync26(sidecarAbsPath) && this.force === true) {
21934
+ try {
21935
+ unlinkSync3(sidecarAbsPath);
21936
+ } catch (err) {
21937
+ this.printer.error(
21938
+ tx(SIDECAR_TEXTS.annotateFailed, { glyph: errGlyph, message: formatErrorMessage(err) })
21939
+ );
21940
+ return ExitCode.Error;
21941
+ }
21942
+ }
21943
+ const store = new FilesystemSidecarStore();
20948
21944
  try {
20949
- writeFileSync5(sidecarAbsPath, scaffold, { encoding: "utf8" });
21945
+ await store.applyPatch(
21946
+ sidecarAbsPath,
21947
+ scaffoldSidecarObject(node),
21948
+ { confirm: this.yes, cwd: ctx.cwd, homedir: ctx.homedir }
21949
+ );
20950
21950
  } catch (err) {
21951
+ if (err instanceof EConsentRequiredError) throw err;
20951
21952
  this.printer.error(
20952
21953
  tx(SIDECAR_TEXTS.annotateFailed, { glyph: errGlyph, message: formatErrorMessage(err) })
20953
21954
  );
@@ -20973,8 +21974,8 @@ var SidecarAnnotateCommand = class extends SmCommand {
20973
21974
  return ExitCode.Ok;
20974
21975
  }
20975
21976
  };
20976
- function scaffoldSidecar(node) {
20977
- const root = {
21977
+ function scaffoldSidecarObject(node) {
21978
+ return {
20978
21979
  identity: {
20979
21980
  bodyHash: node.bodyHash,
20980
21981
  frontmatterHash: node.frontmatterHash,
@@ -20982,14 +21983,6 @@ function scaffoldSidecar(node) {
20982
21983
  },
20983
21984
  annotations: {}
20984
21985
  };
20985
- const body = yaml5.dump(root, {
20986
- sortKeys: true,
20987
- lineWidth: -1,
20988
- noRefs: true,
20989
- noCompatMode: true
20990
- });
20991
- const banner = "# Skill-map sidecar \u2014 managed artifact.\n# Comments in .sm are NOT preserved across `sm bump` (the bump action\n# re-serialises the file). Narrative / docs \u2192 the .md body, which is\n# never touched. See spec/cli-contract.md \xA7Sidecar bump for the\n# round-trip contract.\n\n";
20992
- return banner + body;
20993
21986
  }
20994
21987
  var SIDECAR_COMMANDS = [
20995
21988
  SidecarRefreshCommand,