@skill-map/cli 0.38.0 → 0.39.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.
package/dist/cli.js CHANGED
@@ -442,7 +442,7 @@ var claudeProvider = {
442
442
  pluginId: CLAUDE_PLUGIN_ID,
443
443
  kind: "provider",
444
444
  version: "1.0.0",
445
- description: "Walks Claude Code scope conventions (.claude/{agents,commands,skills}).",
445
+ description: "Classifies files under `.claude/{agents,commands,skills}` as Claude Code agents, commands, and skills.",
446
446
  // Vendor provider: Claude Code only reads its own `.claude/` territory
447
447
  // and ignores `.codex/` / Antigravity layouts at runtime. Gating the
448
448
  // classifier behind the active lens prevents the walker from inventing
@@ -727,7 +727,7 @@ var atDirectiveExtractor = {
727
727
  pluginId: CLAUDE_PLUGIN_ID,
728
728
  kind: "extractor",
729
729
  version: "1.0.0",
730
- description: "Detects `@<token>` directives in a node's body using Claude Code interpretation rules. A bare handle (e.g. `@team`) becomes a `mentions` link; a file-flavoured token (e.g. `@docs/api.md`, `@./readme.md`) becomes a `references` link. Gated by `precondition.provider: ['claude']` so Antigravity / Cursor / Codex apply their own at-directive flavours via their own extractors.",
730
+ description: "Detects `@<token>` directives in a node's body using Claude Code rules. A bare handle (e.g. `@team`) becomes a `mentions` link; a file-flavoured token (e.g. `@docs/api.md`, `@./readme.md`) becomes a `references` link.",
731
731
  scope: "body",
732
732
  precondition: { provider: ["claude"] },
733
733
  // eslint-disable-next-line complexity
@@ -808,15 +808,15 @@ function resolveSourceRelative(sourceDir, bare) {
808
808
  return pathPosix.normalize(joined);
809
809
  }
810
810
 
811
- // plugins/claude/extractors/slash/index.ts
812
- var ID2 = "slash";
811
+ // plugins/claude/extractors/slash-command/index.ts
812
+ var ID2 = "slash-command";
813
813
  var SLASH_RE = /(?<![A-Za-z0-9_/.:?#=&])(\/[a-z0-9][a-z0-9_-]*(?::[a-z0-9][a-z0-9_-]*)?)/gi;
814
- var slashExtractor = {
814
+ var slashCommandExtractor = {
815
815
  id: ID2,
816
816
  pluginId: CLAUDE_PLUGIN_ID,
817
817
  kind: "extractor",
818
818
  version: "1.0.0",
819
- description: "Detects `/command` invocations in a node's body using Claude Code routing rules and turns each one into an arrow between nodes in the graph. Gated by `precondition.provider: ['claude']` so Antigravity / Cursor / Codex apply their own slash flavours (Antigravity ships subagent and skill panels, Codex deprecated user slash commands, etc.) via their own extractors.",
819
+ description: "Turns `/command` invocations in a node's body into arrows that point at the resolved slash command or skill, using Claude Code routing rules.",
820
820
  scope: "body",
821
821
  precondition: { provider: ["claude"] },
822
822
  extract(ctx) {
@@ -866,7 +866,7 @@ var antigravityProvider = {
866
866
  pluginId: ANTIGRAVITY_PLUGIN_ID,
867
867
  kind: "provider",
868
868
  version: "1.0.0",
869
- description: "Google Antigravity CLI. Replaces the retired Gemini CLI; skills route through the neutral `agent-skills` Provider via `.agents/skills/`. This Provider contributes lens identity and a reserved-name seed catalog.",
869
+ description: "Declares the Google Antigravity runtime and its reserved built-in names.",
870
870
  // Vendor provider: marked gated for the day Antigravity grows its own
871
871
  // on-disk kind beyond the open standard. Today `kinds: {}` and
872
872
  // `classify` returns `null` for every path, so the flag is inert; the
@@ -896,7 +896,7 @@ var antigravityProvider = {
896
896
  // Gemini CLI's. We mirror the full 38-verb Gemini CLI catalog (plus its
897
897
  // four documented aliases: `dir`, `?`, `exit`, `bashes`) so a user file
898
898
  // that names a skill / command `help`, `clear`, `mcp`, etc. is flagged
899
- // immediately by `core/reserved-name` once the lens activates the catalog.
899
+ // immediately by `core/name-reserved` once the lens activates the catalog.
900
900
  //
901
901
  // The catalog is INACTIVE today: the analyzer keys on `node.provider`
902
902
  // and this Provider's `classify()` returns `null` for every path, so
@@ -1015,7 +1015,7 @@ var openaiProvider = {
1015
1015
  pluginId: OPENAI_PLUGIN_ID,
1016
1016
  kind: "provider",
1017
1017
  version: "1.0.0",
1018
- description: "Walks OpenAI Codex CLI scope conventions (.codex/agents/*.toml).",
1018
+ description: "Classifies files under `.codex/agents/*.toml` as OpenAI Codex CLI sub-agents.",
1019
1019
  // Vendor provider: Codex CLI only reads its own `.codex/` territory.
1020
1020
  // Gating the classifier behind the active lens keeps the walker from
1021
1021
  // claiming Codex agents under a `claude` (or any other) lens, where
@@ -1073,7 +1073,7 @@ var agentSkillsProvider = {
1073
1073
  pluginId: AGENT_SKILLS_PLUGIN_ID,
1074
1074
  kind: "provider",
1075
1075
  version: "1.0.0",
1076
- description: "Agent Skills open standard. Vendor-neutral path `.agents/skills/<name>/SKILL.md` (Anthropic, OpenAI, Google). See agentskills.io.",
1076
+ description: "Classifies files under `.agents/skills/<name>/SKILL.md` as Agent Skills.",
1077
1077
  read: { extensions: [".md"], parser: "frontmatter-yaml" },
1078
1078
  kinds: {
1079
1079
  skill: {
@@ -1121,7 +1121,7 @@ var coreMarkdownProvider = {
1121
1121
  pluginId: CORE_PLUGIN_ID,
1122
1122
  kind: "provider",
1123
1123
  version: "1.0.0",
1124
- description: "Universal `.md` fallback. Claims any markdown file no vendor-specific Provider classifies.",
1124
+ description: "Universal `.md` fallback. Claims any markdown file that no vendor-specific provider has classified.",
1125
1125
  read: { extensions: [".md"], parser: "frontmatter-yaml" },
1126
1126
  // Per spec § A.6, defaultRefreshAction values MUST be qualified
1127
1127
  // action ids. The summarize-markdown action is not yet implemented
@@ -1172,7 +1172,7 @@ var annotationsExtractor = {
1172
1172
  pluginId: CORE_PLUGIN_ID,
1173
1173
  kind: "extractor",
1174
1174
  version: "1.0.0",
1175
- description: "Turns the `supersedes` and `supersededBy` entries you write in a node's `.sm` sidecar into the arrows (edges) shown between nodes in the graph.",
1175
+ description: "Turns the `supersedes` and `supersededBy` entries from a node's `.sm` sidecar into arrows between nodes in the graph.",
1176
1176
  scope: "frontmatter",
1177
1177
  extract(ctx) {
1178
1178
  const sourcePath = ctx.node.path;
@@ -1234,7 +1234,7 @@ var externalUrlCounterExtractor = {
1234
1234
  pluginId: CORE_PLUGIN_ID,
1235
1235
  kind: "extractor",
1236
1236
  version: "1.0.0",
1237
- description: "Counts the distinct external URLs in a node's body and shows the total on the card.",
1237
+ description: "Counts the distinct external URLs in a node's body and shows the count on the card.",
1238
1238
  scope: "body",
1239
1239
  /**
1240
1240
  * Phase 6 / View contribution system, surface the distinct-URL
@@ -1323,7 +1323,7 @@ var markdownLinkExtractor = {
1323
1323
  pluginId: CORE_PLUGIN_ID,
1324
1324
  kind: "extractor",
1325
1325
  version: "1.0.0",
1326
- description: "Detects markdown links (`[text](path)`) in a node's body and turns each one into an arrow between nodes in the graph.",
1326
+ description: "Turns markdown links (`[text](path)`) in a node's body into arrows between nodes in the graph.",
1327
1327
  scope: "body",
1328
1328
  extract(ctx) {
1329
1329
  const seen = /* @__PURE__ */ new Set();
@@ -1352,7 +1352,7 @@ var markdownLinkExtractor = {
1352
1352
  // explicitly designates an out-link via the brackets +
1353
1353
  // parentheses pair; there is no inference left to discount.
1354
1354
  // Whether the path resolves to a real node is a separate
1355
- // concern (the `core/broken-ref` analyzer flags unresolved
1355
+ // concern (the `core/reference-broken` analyzer flags unresolved
1356
1356
  // targets), not a confidence question.
1357
1357
  confidence: 1,
1358
1358
  rationale: "unambiguous markdown link syntax",
@@ -1385,7 +1385,7 @@ var mcpToolsExtractor = {
1385
1385
  pluginId: CORE_PLUGIN_ID,
1386
1386
  kind: "extractor",
1387
1387
  version: "1.0.0",
1388
- description: "Detects `tools: [mcp__<server>__<tool>]` entries in a node's frontmatter and turns each unique server into an MCP node + a reference edge from the source.",
1388
+ description: "Turns `tools: [mcp__<server>__<tool>]` entries in a node's frontmatter into an MCP node per unique server and an arrow from the source to each one.",
1389
1389
  scope: "frontmatter",
1390
1390
  extract(ctx) {
1391
1391
  const raw = ctx.frontmatter["tools"];
@@ -1439,15 +1439,15 @@ function collectMcpServers(tools) {
1439
1439
  return out;
1440
1440
  }
1441
1441
 
1442
- // plugins/core/extractors/tools-count/index.ts
1443
- var ID7 = "tools-count";
1442
+ // plugins/core/extractors/tools-counter/index.ts
1443
+ var ID7 = "tools-counter";
1444
1444
  var TOOLTIP_MAX = 255;
1445
- var toolsCountExtractor = {
1445
+ var toolsCounterExtractor = {
1446
1446
  id: ID7,
1447
1447
  pluginId: CORE_PLUGIN_ID,
1448
1448
  kind: "extractor",
1449
1449
  version: "1.0.0",
1450
- description: "Counts the tools an agent declares in its frontmatter and shows the total on the agent card.",
1450
+ description: "Counts the tools an agent declares in its frontmatter and shows the count on the agent card.",
1451
1451
  scope: "frontmatter",
1452
1452
  precondition: { kind: ["claude/agent"] },
1453
1453
  ui: {
@@ -1479,6 +1479,209 @@ function buildTooltip(names) {
1479
1479
  return `${joined.slice(0, TOOLTIP_MAX - 1)}\u2026`;
1480
1480
  }
1481
1481
 
1482
+ // plugins/core/analyzers/annotation-field-unknown/index.ts
1483
+ import { readFileSync } from "fs";
1484
+ import { dirname, resolve } from "path";
1485
+ import { createRequire } from "module";
1486
+ import { Ajv2020 } from "ajv/dist/2020.js";
1487
+
1488
+ // kernel/util/ajv-interop.ts
1489
+ import addFormatsModule from "ajv-formats";
1490
+ var addFormats = addFormatsModule.default ?? addFormatsModule;
1491
+ function applyAjvFormats(ajv) {
1492
+ addFormats(ajv);
1493
+ }
1494
+
1495
+ // plugins/core/analyzers/annotation-field-unknown/text.ts
1496
+ var ANNOTATION_FIELD_UNKNOWN_TEXTS = {
1497
+ /** Key inside `annotations:` is not in the curated catalog. */
1498
+ unknownAnnotationKey: "{{path}}: sidecar annotations contain unknown key '{{key}}' (not in annotations.schema.json catalog).",
1499
+ /** Top-level key is neither reserved, nor a registered plugin namespace, nor a registered root key. */
1500
+ unknownRootKey: "{{path}}: sidecar declares unknown top-level key '{{key}}'; not a reserved block, not a registered plugin namespace, not a registered root contribution.",
1501
+ /** Value under a registered plugin namespace fails the contributed schema. */
1502
+ pluginNamespaceInvalid: "{{path}}: sidecar block '{{pluginId}}.{{key}}' fails the schema contributed by plugin '{{pluginId}}': {{errors}}.",
1503
+ // Tooltips for the per-node view-contribution badges. Singular vs
1504
+ // plural keeps the count grammar correct without a sub-template.
1505
+ alertTooltipSingle: "This node has 1 unknown field in its sidecar. Open the inspector for details.",
1506
+ alertTooltipMany: "This node has {{count}} unknown fields in its sidecar. Open the inspector for details."
1507
+ };
1508
+
1509
+ // plugins/core/analyzers/annotation-field-unknown/index.ts
1510
+ var ID8 = "annotation-field-unknown";
1511
+ var RESERVED_ROOT_BLOCKS = /* @__PURE__ */ new Set(["identity", "annotations", "settings", "audit"]);
1512
+ var annotationFieldUnknownAnalyzer = {
1513
+ id: ID8,
1514
+ pluginId: CORE_PLUGIN_ID,
1515
+ kind: "analyzer",
1516
+ version: "1.0.0",
1517
+ description: "Flags typos or unrecognized keys in sidecars (`.sm`).",
1518
+ mode: "deterministic",
1519
+ ui: {
1520
+ // Corner badge on the graph card; count omitted when there is a
1521
+ // single unknown field (avoids a noisy "icon + 1" chip).
1522
+ alert: {
1523
+ slot: "graph.node.alert",
1524
+ // Filled warning triangle on the corner, matches the broken-ref
1525
+ // alert's "attention-grabbing solid" pattern; the footer chip
1526
+ // below stays outlined for the quieter counter pairing.
1527
+ icon: "fa-solid fa-triangle-exclamation",
1528
+ emitWhenEmpty: false
1529
+ },
1530
+ // Footer chip on the card, `_counter` shape but rendered icon-only
1531
+ // (the analyzer emits `value: 0` so NodeCounter hides the number
1532
+ // and only the glyph shows). PrimeIcons `pi-question-circle` so the
1533
+ // visual weight matches `annotation-stale`'s `pi-clock` chip
1534
+ // sitting next to it on the same footer row. `emitWhenEmpty: true`
1535
+ // is required: with `value: 0` the slot treats the payload as
1536
+ // empty, so the manifest has to opt in to keep the emission.
1537
+ chip: {
1538
+ slot: "card.footer.right",
1539
+ icon: "pi-question-circle",
1540
+ emitWhenEmpty: true,
1541
+ priority: 30
1542
+ }
1543
+ },
1544
+ // Analyzer body iterates every sidecar root and classifies each
1545
+ // key against three buckets (catalog / plugin namespace / unknown
1546
+ // root). The per-key branching IS the classification table; factoring
1547
+ // it out would rebuild the discriminator elsewhere. Per
1548
+ // `context/lint.md` category 7 (recursive type-discriminator walkers).
1549
+ // eslint-disable-next-line complexity
1550
+ evaluate(ctx) {
1551
+ const sidecarRoots = ctx.sidecarRoots;
1552
+ if (!sidecarRoots || sidecarRoots.size === 0) return [];
1553
+ const knownAnnotationKeys = getKnownAnnotationKeys();
1554
+ const contributions = ctx.annotationContributions ?? [];
1555
+ const namespacedByPlugin = indexNamespacedContributions(contributions);
1556
+ const rootKeys = indexRootContributions(contributions);
1557
+ const knownPluginIds = collectPluginIds(contributions);
1558
+ const issues = [];
1559
+ const perNode = /* @__PURE__ */ new Map();
1560
+ const bump2 = (nodePath) => {
1561
+ perNode.set(nodePath, (perNode.get(nodePath) ?? 0) + 1);
1562
+ };
1563
+ for (const node of ctx.nodes) {
1564
+ const root = sidecarRoots.get(node.path);
1565
+ if (!root) continue;
1566
+ const annotations = root["annotations"];
1567
+ if (annotations !== void 0 && annotations !== null && typeof annotations === "object" && !Array.isArray(annotations)) {
1568
+ for (const key of Object.keys(annotations)) {
1569
+ if (!knownAnnotationKeys.has(key)) {
1570
+ issues.push({
1571
+ analyzerId: ID8,
1572
+ severity: "warn",
1573
+ nodeIds: [node.path],
1574
+ message: tx(ANNOTATION_FIELD_UNKNOWN_TEXTS.unknownAnnotationKey, {
1575
+ path: node.path,
1576
+ key
1577
+ }),
1578
+ data: { surface: "annotations", key }
1579
+ });
1580
+ bump2(node.path);
1581
+ }
1582
+ }
1583
+ }
1584
+ for (const key of Object.keys(root)) {
1585
+ if (RESERVED_ROOT_BLOCKS.has(key)) continue;
1586
+ if (rootKeys.has(key)) continue;
1587
+ if (knownPluginIds.has(key)) {
1588
+ const block = root[key];
1589
+ if (block === null || typeof block !== "object" || Array.isArray(block)) continue;
1590
+ const contribsForPlugin = namespacedByPlugin.get(key);
1591
+ if (!contribsForPlugin) continue;
1592
+ for (const [contribKey, validator] of contribsForPlugin) {
1593
+ const value = block[contribKey];
1594
+ if (value === void 0) continue;
1595
+ if (validator(value)) continue;
1596
+ const errors = (validator.errors ?? []).map((e) => `${e.instancePath || "(root)"} ${e.message ?? e.keyword}`).join("; ");
1597
+ issues.push({
1598
+ analyzerId: ID8,
1599
+ severity: "warn",
1600
+ nodeIds: [node.path],
1601
+ message: tx(ANNOTATION_FIELD_UNKNOWN_TEXTS.pluginNamespaceInvalid, {
1602
+ path: node.path,
1603
+ pluginId: key,
1604
+ key: contribKey,
1605
+ errors
1606
+ }),
1607
+ data: { surface: "plugin-namespace", pluginId: key, key: contribKey }
1608
+ });
1609
+ bump2(node.path);
1610
+ }
1611
+ continue;
1612
+ }
1613
+ issues.push({
1614
+ analyzerId: ID8,
1615
+ severity: "warn",
1616
+ nodeIds: [node.path],
1617
+ message: tx(ANNOTATION_FIELD_UNKNOWN_TEXTS.unknownRootKey, {
1618
+ path: node.path,
1619
+ key
1620
+ }),
1621
+ data: { surface: "root", key }
1622
+ });
1623
+ bump2(node.path);
1624
+ }
1625
+ }
1626
+ for (const [nodePath, count] of perNode) {
1627
+ const tooltip = count === 1 ? ANNOTATION_FIELD_UNKNOWN_TEXTS.alertTooltipSingle : tx(ANNOTATION_FIELD_UNKNOWN_TEXTS.alertTooltipMany, { count });
1628
+ ctx.emitContribution(nodePath, "alert", {
1629
+ icon: "fa-solid fa-triangle-exclamation",
1630
+ severity: "warn",
1631
+ tooltip
1632
+ });
1633
+ ctx.emitContribution(nodePath, "chip", {
1634
+ value: 0,
1635
+ severity: "warn",
1636
+ tooltip
1637
+ });
1638
+ }
1639
+ return issues;
1640
+ }
1641
+ };
1642
+ var cachedKnownKeys = null;
1643
+ function getKnownAnnotationKeys() {
1644
+ if (cachedKnownKeys) return cachedKnownKeys;
1645
+ const require2 = createRequire(import.meta.url);
1646
+ const indexPath = require2.resolve("@skill-map/spec/index.json");
1647
+ const specRoot = dirname(indexPath);
1648
+ const schema = JSON.parse(
1649
+ readFileSync(resolve(specRoot, "schemas/annotations.schema.json"), "utf8")
1650
+ );
1651
+ cachedKnownKeys = new Set(Object.keys(schema.properties ?? {}));
1652
+ return cachedKnownKeys;
1653
+ }
1654
+ function indexNamespacedContributions(contributions) {
1655
+ const out = /* @__PURE__ */ new Map();
1656
+ const ajv = new Ajv2020({ strict: false, allErrors: true, allowUnionTypes: true });
1657
+ applyAjvFormats(ajv);
1658
+ for (const entry of contributions) {
1659
+ if (entry.location !== "namespaced") continue;
1660
+ let bucket = out.get(entry.pluginId);
1661
+ if (!bucket) {
1662
+ bucket = /* @__PURE__ */ new Map();
1663
+ out.set(entry.pluginId, bucket);
1664
+ }
1665
+ try {
1666
+ bucket.set(entry.key, ajv.compile(entry.schema));
1667
+ } catch {
1668
+ }
1669
+ }
1670
+ return out;
1671
+ }
1672
+ function indexRootContributions(contributions) {
1673
+ const out = /* @__PURE__ */ new Set();
1674
+ for (const entry of contributions) {
1675
+ if (entry.location === "root") out.add(entry.key);
1676
+ }
1677
+ return out;
1678
+ }
1679
+ function collectPluginIds(contributions) {
1680
+ const out = /* @__PURE__ */ new Set();
1681
+ for (const entry of contributions) out.add(entry.pluginId);
1682
+ return out;
1683
+ }
1684
+
1482
1685
  // plugins/core/analyzers/annotation-orphan/text.ts
1483
1686
  var ANNOTATION_ORPHAN_TEXTS = {
1484
1687
  /** Sidecar `<path>.sm` has no matching `<path>.md`. */
@@ -1486,13 +1689,13 @@ var ANNOTATION_ORPHAN_TEXTS = {
1486
1689
  };
1487
1690
 
1488
1691
  // plugins/core/analyzers/annotation-orphan/index.ts
1489
- var ID8 = "annotation-orphan";
1692
+ var ID9 = "annotation-orphan";
1490
1693
  var annotationOrphanAnalyzer = {
1491
- id: ID8,
1694
+ id: ID9,
1492
1695
  pluginId: CORE_PLUGIN_ID,
1493
1696
  kind: "analyzer",
1494
1697
  version: "1.0.0",
1495
- description: "Detects and flags sidecars (`.sm`) whose `.md` no longer exists.",
1698
+ description: "Flags sidecars (`.sm`) whose `.md` file no longer exists.",
1496
1699
  mode: "deterministic",
1497
1700
  evaluate(ctx) {
1498
1701
  const orphans = ctx.orphanSidecars;
@@ -1501,7 +1704,7 @@ var annotationOrphanAnalyzer = {
1501
1704
  for (const orphan of orphans) {
1502
1705
  const expectedMdRelative = orphan.relativePath.endsWith(".sm") ? `${orphan.relativePath.slice(0, -".sm".length)}.md` : `${orphan.relativePath}.md`;
1503
1706
  issues.push({
1504
- analyzerId: ID8,
1707
+ analyzerId: ID9,
1505
1708
  severity: "warn",
1506
1709
  nodeIds: [expectedMdRelative],
1507
1710
  message: tx(ANNOTATION_ORPHAN_TEXTS.message, {
@@ -1538,17 +1741,17 @@ var ANNOTATION_STALE_TEXTS = {
1538
1741
  };
1539
1742
 
1540
1743
  // plugins/core/analyzers/annotation-stale/index.ts
1541
- var ID9 = "annotation-stale";
1744
+ var ID10 = "annotation-stale";
1542
1745
  var annotationStaleAnalyzer = {
1543
- id: ID9,
1746
+ id: ID10,
1544
1747
  pluginId: CORE_PLUGIN_ID,
1545
1748
  kind: "analyzer",
1546
1749
  version: "1.0.0",
1547
- description: "Detects and marks sidecars (`.sm`) out of date of their `.md`.",
1750
+ description: "Marks sidecars (`.sm`) that are out of date with their `.md`.",
1548
1751
  mode: "deterministic",
1549
1752
  // The natural fix is to bump the node: refreshes `for` hashes,
1550
1753
  // increments `annotations.version`, and stamps the audit block. The
1551
- // UI surfaces `core/bump` in the node inspector under "Recommended
1754
+ // UI surfaces `core/node-bump` in the node inspector under "Recommended
1552
1755
  // for issues" whenever this analyzer fires.
1553
1756
  ui: {
1554
1757
  // A `pi-clock` chip in the footer-right cluster so the operator
@@ -1575,7 +1778,7 @@ var annotationStaleAnalyzer = {
1575
1778
  if (status === "fresh") continue;
1576
1779
  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 });
1577
1780
  issues.push({
1578
- analyzerId: ID9,
1781
+ analyzerId: ID10,
1579
1782
  severity: "warn",
1580
1783
  nodeIds: [node.path],
1581
1784
  message,
@@ -1601,232 +1804,48 @@ function tooltipFor(status) {
1601
1804
  }
1602
1805
  }
1603
1806
 
1604
- // plugins/core/analyzers/broken-ref/index.ts
1605
- import { posix as pathPosix3, resolve } from "path";
1807
+ // plugins/core/analyzers/contribution-orphan/index.ts
1808
+ var ID11 = "contribution-orphan";
1809
+ var contributionOrphanAnalyzer = {
1810
+ id: ID11,
1811
+ pluginId: CORE_PLUGIN_ID,
1812
+ kind: "analyzer",
1813
+ version: "0.0.0",
1814
+ description: "Warns about plugin data referencing nodes renamed or deleted in the latest scan.",
1815
+ mode: "deterministic",
1816
+ evaluate(_ctx) {
1817
+ return [];
1818
+ }
1819
+ };
1606
1820
 
1607
- // plugins/core/analyzers/broken-ref/text.ts
1608
- var BROKEN_REF_TEXTS = {
1609
- /** `Broken <kind> reference from <source> → <target>` */
1610
- message: "Broken {{kind}} reference from {{source}} \u2192 {{target}}",
1611
- // Tooltips for the per-node view-contribution badges. Singular vs
1612
- // plural keeps the count grammar correct without a sub-template.
1613
- alertTooltipSingle: "This node has a broken reference. Open the inspector for details.",
1614
- alertTooltipMany: "This node has {{count}} broken references. Open the inspector for details.",
1615
- // Fix-summary copy when the broken trigger has a same-named file on
1616
- // disk that does not advertise `name:` in its frontmatter. Two
1617
- // variants for single vs multiple candidates; same template family
1618
- // as the alert tooltips above.
1619
- hintSummarySingle: "Add `name: {{name}}` to the frontmatter of {{candidate}} so this reference resolves.",
1620
- hintSummaryMany: "Add `name: {{name}}` to the frontmatter of one of these files so this reference resolves: {{candidates}}."
1821
+ // plugins/core/analyzers/job-file-orphan/text.ts
1822
+ var JOB_FILE_ORPHAN_TEXTS = {
1823
+ /**
1824
+ * `<path>.md` lives under `.skill-map/jobs/` but no `state_jobs.filePath`
1825
+ * row references it. Run `sm job prune --orphan-files` to remove.
1826
+ */
1827
+ message: "Orphan job file: {{filePath}} is not referenced by any state_jobs row. Run `sm job prune --orphan-files` to remove it."
1621
1828
  };
1622
1829
 
1623
- // plugins/core/analyzers/broken-ref/index.ts
1624
- var ID10 = "broken-ref";
1625
- var brokenRefAnalyzer = {
1626
- id: ID10,
1830
+ // plugins/core/analyzers/job-file-orphan/index.ts
1831
+ var ID12 = "job-file-orphan";
1832
+ var jobFileOrphanAnalyzer = {
1833
+ id: ID12,
1627
1834
  pluginId: CORE_PLUGIN_ID,
1628
1835
  kind: "analyzer",
1629
1836
  version: "1.0.0",
1630
- description: "Detects and flags arrows pointing at a node not part of the current scan.",
1837
+ description: "Flags leftover job result files (no live job references them). Clean up via `sm job prune --orphan-files`.",
1631
1838
  mode: "deterministic",
1632
- ui: {
1633
- // Corner badge on the graph card; count omitted when there is a
1634
- // single broken ref (avoids a noisy "icon + 1" chip).
1635
- alert: {
1636
- slot: "graph.node.alert",
1637
- icon: "fa-solid fa-circle-xmark",
1638
- emitWhenEmpty: false
1639
- },
1640
- // Footer chip on the card. `_counter` shape, `value` always shows,
1641
- // so the operator sees "how many" at a glance. Renders OUTLINED
1642
- // (`fa-regular`) so the corner alert (filled, attention-grabbing)
1643
- // and the footer chip (quieter, paired with a number) read as two
1644
- // beats of the same signal rather than two identical glyphs.
1645
- chip: {
1646
- slot: "card.footer.right",
1647
- icon: "fa-regular fa-circle-xmark",
1648
- emitWhenEmpty: false,
1649
- priority: 40
1650
- }
1651
- },
1652
- // The resolver, the reference-paths escape hatch, the per-source
1653
- // aggregation, and the dual-slot emit (with single/plural tooltip and
1654
- // optional count) all live in one flow because they share the per-link
1655
- // loop. Splitting them would re-walk `ctx.links` three times.
1656
- // eslint-disable-next-line complexity
1657
1839
  evaluate(ctx) {
1658
- const byPath3 = new Set(ctx.nodes.map((n) => n.path));
1659
- const byNormalizedName = indexByNormalizedName(ctx.nodes);
1660
- const byBasenameWithoutName = indexByBasenameWithoutName(ctx.nodes);
1661
- const refIndex = ctx.referenceablePaths && ctx.referenceablePaths.size > 0 && ctx.cwd ? { paths: ctx.referenceablePaths, cwd: ctx.cwd } : null;
1662
- const issues = [];
1663
- const perNode = /* @__PURE__ */ new Map();
1664
- for (const link of ctx.links) {
1665
- if (isResolved(link, byPath3, byNormalizedName)) continue;
1666
- if (refIndex && resolvesViaReferencePaths(link, refIndex)) continue;
1667
- const candidates = findHintCandidates(link, byBasenameWithoutName);
1668
- issues.push(buildIssue(link, candidates));
1669
- perNode.set(link.source, (perNode.get(link.source) ?? 0) + 1);
1670
- }
1671
- for (const [nodePath, count] of perNode) {
1672
- const tooltip = count === 1 ? BROKEN_REF_TEXTS.alertTooltipSingle : tx(BROKEN_REF_TEXTS.alertTooltipMany, { count });
1673
- const capped = Math.min(count, 99);
1674
- ctx.emitContribution(nodePath, "alert", {
1675
- icon: "fa-solid fa-circle-xmark",
1676
- severity: "danger",
1677
- tooltip
1678
- });
1679
- ctx.emitContribution(nodePath, "chip", {
1680
- value: capped,
1681
- severity: "danger",
1682
- tooltip
1683
- });
1684
- }
1685
- return issues;
1686
- }
1687
- };
1688
- function buildIssue(link, hintCandidates = []) {
1689
- const data = {
1690
- target: link.target,
1691
- kind: link.kind,
1692
- trigger: link.trigger?.normalizedTrigger ?? null
1693
- };
1694
- const issue = {
1695
- analyzerId: ID10,
1696
- severity: "warn",
1697
- nodeIds: [link.source],
1698
- message: tx(BROKEN_REF_TEXTS.message, {
1699
- kind: link.kind,
1700
- source: link.source,
1701
- target: link.target
1702
- }),
1703
- data
1704
- };
1705
- if (hintCandidates.length > 0) {
1706
- const suggestedName = (link.trigger?.normalizedTrigger ?? "").replace(/^[/@]/, "").trim();
1707
- const candidatePaths = hintCandidates.map((n) => n.path);
1708
- data["hint"] = {
1709
- kind: "missing-frontmatter-name",
1710
- suggestedName,
1711
- candidates: candidatePaths
1712
- };
1713
- issue.fix = {
1714
- summary: candidatePaths.length === 1 ? tx(BROKEN_REF_TEXTS.hintSummarySingle, {
1715
- name: suggestedName,
1716
- candidate: candidatePaths[0]
1717
- }) : tx(BROKEN_REF_TEXTS.hintSummaryMany, {
1718
- name: suggestedName,
1719
- candidates: candidatePaths.join(", ")
1720
- }),
1721
- autofixable: false
1722
- };
1723
- }
1724
- return issue;
1725
- }
1726
- function resolvesViaReferencePaths(link, refIndex) {
1727
- if (!isPathStyleLink(link)) return false;
1728
- return refIndex.paths.has(resolve(refIndex.cwd, link.target));
1729
- }
1730
- function indexByNormalizedName(nodes) {
1731
- const out = /* @__PURE__ */ new Map();
1732
- for (const node of nodes) {
1733
- const raw = node.frontmatter?.["name"];
1734
- const name = typeof raw === "string" ? raw : "";
1735
- if (!name) continue;
1736
- const key = normalizeTrigger(name);
1737
- const bucket = out.get(key) ?? [];
1738
- bucket.push(node);
1739
- out.set(key, bucket);
1740
- }
1741
- return out;
1742
- }
1743
- function basenameWithoutExt(path) {
1744
- const base = pathPosix3.basename(path);
1745
- const ext = pathPosix3.extname(base);
1746
- return ext ? base.slice(0, -ext.length) : base;
1747
- }
1748
- function indexByBasenameWithoutName(nodes) {
1749
- const out = /* @__PURE__ */ new Map();
1750
- for (const node of nodes) {
1751
- const raw = node.frontmatter?.["name"];
1752
- const name = typeof raw === "string" ? raw : "";
1753
- if (name) continue;
1754
- const bare = basenameWithoutExt(node.path);
1755
- if (!bare) continue;
1756
- const key = normalizeTrigger(bare);
1757
- if (!key) continue;
1758
- const bucket = out.get(key) ?? [];
1759
- bucket.push(node);
1760
- out.set(key, bucket);
1761
- }
1762
- return out;
1763
- }
1764
- function findHintCandidates(link, idx) {
1765
- const normalized = link.trigger?.normalizedTrigger;
1766
- if (!normalized) return [];
1767
- const sigil = normalized.charAt(0);
1768
- if (sigil !== "/" && sigil !== "@") return [];
1769
- const withoutSigil = normalized.slice(1).trim();
1770
- if (!withoutSigil) return [];
1771
- return idx.get(withoutSigil) ?? [];
1772
- }
1773
- function isResolved(link, byPath3, byNormalizedName) {
1774
- const normalized = link.trigger?.normalizedTrigger;
1775
- if (normalized) {
1776
- const withoutSigil = normalized.replace(/^[/@]/, "").trim();
1777
- if (byNormalizedName.has(withoutSigil)) return true;
1778
- }
1779
- if (byPath3.has(link.target)) return true;
1780
- return false;
1781
- }
1782
- function isPathStyleLink(link) {
1783
- const sigil = link.trigger?.normalizedTrigger?.charAt(0);
1784
- if (sigil === "/" || sigil === "@") return false;
1785
- return true;
1786
- }
1787
-
1788
- // plugins/core/analyzers/contribution-orphan/index.ts
1789
- var ID11 = "contribution-orphan";
1790
- var contributionOrphanAnalyzer = {
1791
- id: ID11,
1792
- pluginId: CORE_PLUGIN_ID,
1793
- kind: "analyzer",
1794
- version: "0.0.0",
1795
- description: "Detects and warns about plugin data referencing nodes renamed or deleted in the latest scan.",
1796
- mode: "deterministic",
1797
- evaluate(_ctx) {
1798
- return [];
1799
- }
1800
- };
1801
-
1802
- // plugins/core/analyzers/job-orphan-file/text.ts
1803
- var JOB_ORPHAN_FILE_TEXTS = {
1804
- /**
1805
- * `<path>.md` lives under `.skill-map/jobs/` but no `state_jobs.filePath`
1806
- * row references it. Run `sm job prune --orphan-files` to remove.
1807
- */
1808
- message: "Orphan job file: {{filePath}} is not referenced by any state_jobs row. Run `sm job prune --orphan-files` to remove it."
1809
- };
1810
-
1811
- // plugins/core/analyzers/job-orphan-file/index.ts
1812
- var ID12 = "job-orphan-file";
1813
- var jobOrphanFileAnalyzer = {
1814
- id: ID12,
1815
- pluginId: CORE_PLUGIN_ID,
1816
- kind: "analyzer",
1817
- version: "1.0.0",
1818
- description: "Detects and flags leftover job result files (no live job references them). Cleanup via `sm job prune --orphan-files`.",
1819
- mode: "deterministic",
1820
- evaluate(ctx) {
1821
- const orphans = ctx.orphanJobFiles;
1822
- if (!orphans || orphans.length === 0) return [];
1840
+ const orphans = ctx.orphanJobFiles;
1841
+ if (!orphans || orphans.length === 0) return [];
1823
1842
  const issues = [];
1824
1843
  for (const filePath of orphans) {
1825
1844
  issues.push({
1826
1845
  analyzerId: ID12,
1827
1846
  severity: "warn",
1828
1847
  nodeIds: [filePath],
1829
- message: tx(JOB_ORPHAN_FILE_TEXTS.message, { filePath }),
1848
+ message: tx(JOB_FILE_ORPHAN_TEXTS.message, { filePath }),
1830
1849
  data: { filePath }
1831
1850
  });
1832
1851
  }
@@ -1847,7 +1866,7 @@ var linkConflictAnalyzer = {
1847
1866
  pluginId: "core",
1848
1867
  kind: "analyzer",
1849
1868
  version: "1.0.0",
1850
- description: 'Detects and flags conflicting arrow meanings between extractors (e.g. "references" vs "invokes").',
1869
+ description: "Flags conflicting arrow meanings between extractors (e.g. `references` vs `invokes`).",
1851
1870
  mode: "deterministic",
1852
1871
  // Bucket links by (source, target), then per-bucket detect distinct
1853
1872
  // kinds. The branching is intrinsic to the per-bucket conflict
@@ -1957,9 +1976,9 @@ function resolveLinkTargetToPath(link, nameIndex) {
1957
1976
  return resolved ?? raw;
1958
1977
  }
1959
1978
 
1960
- // plugins/core/analyzers/link-counts/index.ts
1961
- var ID14 = "link-counts";
1962
- var linkCountsAnalyzer = {
1979
+ // plugins/core/analyzers/link-counter/index.ts
1980
+ var ID14 = "link-counter";
1981
+ var linkCounterAnalyzer = {
1963
1982
  id: ID14,
1964
1983
  pluginId: CORE_PLUGIN_ID,
1965
1984
  kind: "analyzer",
@@ -2024,170 +2043,80 @@ function formatBreakdown(byKind, direction) {
2024
2043
  return [direction, ...lines].join("\n");
2025
2044
  }
2026
2045
 
2027
- // plugins/core/analyzers/redundant-target-reference/text.ts
2028
- var REDUNDANT_TARGET_REFERENCE_TEXTS = {
2046
+ // plugins/core/analyzers/link-self-loop/text.ts
2047
+ var LINK_SELF_LOOP_TEXTS = {
2029
2048
  /**
2030
- * Multi-form / multi-occurrence reference message. Lists each
2031
- * occurrence (trigger + line) so the operator sees the full
2032
- * authorial surface without having to grep the body.
2049
+ * Per-edge warn: a node body references itself via the slash /
2050
+ * at-directive / markdown-link surface (most commonly because the
2051
+ * file's heading IS the invocation token, e.g. `# /deploy` inside
2052
+ * `commands/deploy.md`). The link is structurally valid but rarely
2053
+ * the operator's intent; UI consumers MAY hide it by default and
2054
+ * surface a count.
2033
2055
  */
2034
- message: "{{source}} references {{resolvedTarget}} via {{count}} occurrences: {{occurrences}}. Consider consolidating to a single form to reduce maintenance surface and avoid duplicate inlining at runtime.",
2035
- /** Inline separator between occurrences in the message. */
2036
- occurrenceSeparator: ", ",
2037
- /** Per-occurrence formatting (trigger + line). */
2038
- occurrence: "`{{trigger}}` ({{kind}}, line {{line}})",
2039
- /** Per-occurrence formatting when the extractor did not record a line. */
2040
- occurrenceUnknownLine: "`{{trigger}}` ({{kind}}, unknown line)"
2056
+ message: "{{source}} references itself via `{{trigger}}` ({{kind}}). Self-loops typically come from the file's own heading or label and are noise rather than intent. Either remove the in-body token or treat this finding as expected and acknowledged."
2041
2057
  };
2042
2058
 
2043
- // plugins/core/analyzers/redundant-target-reference/index.ts
2044
- var ID15 = "redundant-target-reference";
2045
- var redundantTargetReferenceAnalyzer = {
2059
+ // plugins/core/analyzers/link-self-loop/index.ts
2060
+ var ID15 = "link-self-loop";
2061
+ var linkSelfLoopAnalyzer = {
2046
2062
  id: ID15,
2047
2063
  pluginId: CORE_PLUGIN_ID,
2048
2064
  kind: "analyzer",
2049
2065
  version: "1.0.0",
2050
- description: "Flags when one node references the same resolved target via two or more syntactic surfaces (cross-extractor multi-form OR cross-kind multi-edge). Emits a warn on the source listing every occurrence (kind + trigger + line). Helps consolidate authorial redundancy and avoid duplicate runtime inlining.",
2066
+ description: "Flags links whose source is also their own resolved target (e.g. a body heading like `# /deploy` inside the file that defines `/deploy`).",
2051
2067
  mode: "deterministic",
2052
2068
  evaluate(ctx) {
2053
2069
  if (ctx.links.length === 0) return [];
2054
- const byPath3 = /* @__PURE__ */ new Map();
2055
- for (const node of ctx.nodes) byPath3.set(node.path, node);
2056
- const byName = buildNameIndex2(ctx.nodes);
2057
- const groups = /* @__PURE__ */ new Map();
2058
- for (const link of ctx.links) {
2059
- const resolved = resolveTargetPath(link, byPath3, byName);
2060
- if (!resolved) continue;
2061
- const key = `${link.source}\0${resolved}`;
2062
- const bucket = groups.get(key);
2063
- if (bucket) bucket.push(link);
2064
- else groups.set(key, [link]);
2065
- }
2066
2070
  const issues = [];
2067
- for (const [key, links] of groups) {
2068
- const totalOccurrences = links.reduce((acc, l) => acc + (l.occurrences?.length ?? 1), 0);
2069
- if (totalOccurrences < 2) continue;
2070
- const [source, resolvedTarget] = key.split("\0");
2071
- const flat = flattenOccurrences(links);
2071
+ for (const link of ctx.links) {
2072
+ if (!isSelfLoop(link)) continue;
2072
2073
  issues.push({
2073
2074
  analyzerId: ID15,
2074
2075
  severity: "warn",
2075
- nodeIds: [source],
2076
- message: tx(REDUNDANT_TARGET_REFERENCE_TEXTS.message, {
2077
- source,
2078
- resolvedTarget,
2079
- count: flat.length,
2080
- occurrences: flat.map(formatOccurrence).join(REDUNDANT_TARGET_REFERENCE_TEXTS.occurrenceSeparator)
2076
+ nodeIds: [link.source],
2077
+ message: tx(LINK_SELF_LOOP_TEXTS.message, {
2078
+ source: link.source,
2079
+ trigger: link.trigger?.originalTrigger ?? link.target,
2080
+ kind: link.kind
2081
2081
  }),
2082
2082
  data: {
2083
- target: resolvedTarget,
2084
- resolvedTarget,
2085
- occurrences: flat.map((o) => ({
2086
- kind: o.kind,
2087
- trigger: o.originalTrigger,
2088
- line: o.line ?? null,
2089
- extractor: o.extractor
2090
- }))
2083
+ target: link.target,
2084
+ resolvedTarget: link.resolvedTarget ?? link.target,
2085
+ kind: link.kind,
2086
+ // Mark explicitly so UI / downstream consumers can read this
2087
+ // single field instead of re-computing the `source === target`
2088
+ // predicate themselves.
2089
+ selfLoop: true
2091
2090
  }
2092
2091
  });
2093
2092
  }
2094
2093
  return issues;
2095
2094
  }
2096
2095
  };
2097
- function flattenOccurrences(links) {
2096
+ function isSelfLoop(link) {
2097
+ if (link.source === link.target) return true;
2098
+ if (link.resolvedTarget && link.source === link.resolvedTarget) return true;
2099
+ return false;
2100
+ }
2101
+
2102
+ // kernel/orchestrator/node-identifiers.ts
2103
+ import { posix as pathPosix3 } from "path";
2104
+ function deriveNodeIdentifiers(node, kindDescriptor) {
2105
+ const sources = kindDescriptor?.identifiers;
2106
+ if (!sources || sources.length === 0) return [];
2098
2107
  const out = [];
2099
- for (const link of links) {
2100
- if (link.occurrences && link.occurrences.length > 0) {
2101
- for (const occ of link.occurrences) {
2102
- out.push({
2103
- kind: link.kind,
2104
- originalTrigger: occ.originalTrigger,
2105
- extractor: occ.extractor,
2106
- line: occ.location?.line ?? null
2107
- });
2108
- }
2109
- continue;
2110
- }
2111
- const trigger = link.trigger?.originalTrigger ?? link.target;
2112
- out.push({
2113
- kind: link.kind,
2114
- originalTrigger: trigger,
2115
- extractor: link.sources[0] ?? "unknown",
2116
- line: link.location?.line ?? null
2117
- });
2108
+ for (const source of sources) {
2109
+ const raw = readIdentifier(source, node);
2110
+ if (!raw) continue;
2111
+ const normalised = normalizeTrigger(raw);
2112
+ if (normalised) out.push(normalised);
2118
2113
  }
2119
- out.sort((a, b) => {
2120
- const la = a.line ?? Number.MAX_SAFE_INTEGER;
2121
- const lb = b.line ?? Number.MAX_SAFE_INTEGER;
2122
- if (la !== lb) return la - lb;
2123
- return a.originalTrigger.localeCompare(b.originalTrigger);
2124
- });
2125
2114
  return out;
2126
2115
  }
2127
- function formatOccurrence(occ) {
2128
- if (occ.line === null) {
2129
- return tx(REDUNDANT_TARGET_REFERENCE_TEXTS.occurrenceUnknownLine, { trigger: occ.originalTrigger, kind: occ.kind });
2130
- }
2131
- return tx(REDUNDANT_TARGET_REFERENCE_TEXTS.occurrence, { trigger: occ.originalTrigger, kind: occ.kind, line: occ.line });
2132
- }
2133
- function buildNameIndex2(nodes) {
2134
- const out = /* @__PURE__ */ new Map();
2135
- for (const node of nodes) {
2136
- for (const candidate of collectIdentifiers(node)) {
2137
- const normalised = normalizeTrigger(candidate);
2138
- if (!normalised) continue;
2139
- const bucket = out.get(normalised);
2140
- if (bucket) bucket.push(node.path);
2141
- else out.set(normalised, [node.path]);
2142
- }
2143
- }
2144
- return out;
2145
- }
2146
- function collectIdentifiers(node) {
2147
- const out = [];
2148
- const fmName = node.frontmatter?.["name"];
2149
- if (typeof fmName === "string" && fmName.length > 0) out.push(fmName);
2150
- const segs = node.path.split("/");
2151
- const last = segs[segs.length - 1] ?? "";
2152
- if (last) {
2153
- const stem = last.replace(/\.[^.]+$/, "");
2154
- if (stem) out.push(stem);
2155
- }
2156
- if (segs.length >= 2) {
2157
- const dirBase = segs[segs.length - 2];
2158
- if (dirBase) out.push(dirBase);
2159
- }
2160
- return out;
2161
- }
2162
- function resolveTargetPath(link, byPath3, byName) {
2163
- if (byPath3.has(link.target)) return link.target;
2164
- const trigger = link.trigger?.normalizedTrigger;
2165
- if (!trigger) return null;
2166
- const stripped = trigger.replace(/^[/@]/, "").trim();
2167
- if (!stripped) return null;
2168
- const candidates = byName.get(stripped);
2169
- if (!candidates || candidates.length === 0) return null;
2170
- return candidates[0] ?? null;
2171
- }
2172
-
2173
- // kernel/orchestrator/node-identifiers.ts
2174
- import { posix as pathPosix4 } from "path";
2175
- function deriveNodeIdentifiers(node, kindDescriptor) {
2176
- const sources = kindDescriptor?.identifiers;
2177
- if (!sources || sources.length === 0) return [];
2178
- const out = [];
2179
- for (const source of sources) {
2180
- const raw = readIdentifier(source, node);
2181
- if (!raw) continue;
2182
- const normalised = normalizeTrigger(raw);
2183
- if (normalised) out.push(normalised);
2184
- }
2185
- return out;
2186
- }
2187
- function readIdentifier(source, node) {
2188
- if (source === "frontmatter.name") return readFrontmatterName(node);
2189
- if (source === "filename-basename") return readFilenameBasename(node);
2190
- return readDirname(node);
2116
+ function readIdentifier(source, node) {
2117
+ if (source === "frontmatter.name") return readFrontmatterName(node);
2118
+ if (source === "filename-basename") return readFilenameBasename(node);
2119
+ return readDirname(node);
2191
2120
  }
2192
2121
  function readFrontmatterName(node) {
2193
2122
  const raw = node.frontmatter?.["name"];
@@ -2195,16 +2124,16 @@ function readFrontmatterName(node) {
2195
2124
  return raw.length > 0 ? raw : null;
2196
2125
  }
2197
2126
  function readFilenameBasename(node) {
2198
- const base = pathPosix4.basename(node.path);
2127
+ const base = pathPosix3.basename(node.path);
2199
2128
  if (!base) return null;
2200
- const ext = pathPosix4.extname(base);
2129
+ const ext = pathPosix3.extname(base);
2201
2130
  const stem = ext ? base.slice(0, -ext.length) : base;
2202
2131
  return stem.length > 0 ? stem : null;
2203
2132
  }
2204
2133
  function readDirname(node) {
2205
- const dir = pathPosix4.dirname(node.path);
2134
+ const dir = pathPosix3.dirname(node.path);
2206
2135
  if (!dir || dir === "." || dir === "/") return null;
2207
- const base = pathPosix4.basename(dir);
2136
+ const base = pathPosix3.basename(dir);
2208
2137
  return base.length > 0 ? base : null;
2209
2138
  }
2210
2139
 
@@ -2273,8 +2202,8 @@ function kindKey(node) {
2273
2202
  return `${node.provider}/${node.kind}`;
2274
2203
  }
2275
2204
 
2276
- // plugins/core/analyzers/reserved-name/text.ts
2277
- var RESERVED_NAME_TEXTS = {
2205
+ // plugins/core/analyzers/name-reserved/text.ts
2206
+ var NAME_RESERVED_TEXTS = {
2278
2207
  /**
2279
2208
  * Target-side message: emitted on the user file that collides with
2280
2209
  * a runtime built-in. Same wording skill-map shipped before the
@@ -2291,14 +2220,14 @@ var RESERVED_NAME_TEXTS = {
2291
2220
  linkMessage: "Link `{{kind}} {{target}}` resolves to a name reserved by the {{provider}} runtime ({{reservedKind}} `{{reservedPath}}`). The runtime shadows the user file, so this edge is downgraded to confidence {{confidence}} instead of 1.0. Rename the target file or its `frontmatter.name` to a non-reserved value."
2292
2221
  };
2293
2222
 
2294
- // plugins/core/analyzers/reserved-name/index.ts
2295
- var ID16 = "reserved-name";
2296
- var reservedNameAnalyzer = {
2223
+ // plugins/core/analyzers/name-reserved/index.ts
2224
+ var ID16 = "name-reserved";
2225
+ var nameReservedAnalyzer = {
2297
2226
  id: ID16,
2298
2227
  pluginId: CORE_PLUGIN_ID,
2299
2228
  kind: "analyzer",
2300
2229
  version: "1.0.0",
2301
- description: "Flags reserved-name collisions on two surfaces. Target side: a user file whose name collides with a Provider runtime's built-in invocable (the runtime shadows the file silently). Source side: a link that resolves to a reserved name, which the post-walk lift transform downgrades to the sentinel `RESERVED_TARGET_CONFIDENCE` (0.1). The two findings share the analyzer id so consumers can group by root cause; the source-side issue carries `data.target` matching the link so UIs can correlate per-row.",
2230
+ description: "Flags two kinds of reserved-name collision: a file whose name shadows a built-in command of the active runtime, and a link that resolves to one of those reserved names.",
2302
2231
  mode: "deterministic",
2303
2232
  // eslint-disable-next-line complexity
2304
2233
  evaluate(ctx) {
@@ -2314,7 +2243,7 @@ var reservedNameAnalyzer = {
2314
2243
  analyzerId: ID16,
2315
2244
  severity: "warn",
2316
2245
  nodeIds: [node.path],
2317
- message: tx(RESERVED_NAME_TEXTS.message, {
2246
+ message: tx(NAME_RESERVED_TEXTS.message, {
2318
2247
  path: node.path,
2319
2248
  provider: node.provider,
2320
2249
  kind: node.kind
@@ -2330,7 +2259,7 @@ var reservedNameAnalyzer = {
2330
2259
  analyzerId: ID16,
2331
2260
  severity: "warn",
2332
2261
  nodeIds: [link.source],
2333
- message: tx(RESERVED_NAME_TEXTS.linkMessage, {
2262
+ message: tx(NAME_RESERVED_TEXTS.linkMessage, {
2334
2263
  kind: link.kind,
2335
2264
  target: link.target,
2336
2265
  provider: reservedNode.provider,
@@ -2387,206 +2316,16 @@ function normaliseId(raw) {
2387
2316
  return raw.normalize("NFD").replace(new RegExp("\\p{Mn}+", "gu"), "").toLowerCase().replace(/[-_\s]+/g, " ").replace(/ +/g, " ").trim();
2388
2317
  }
2389
2318
 
2390
- // plugins/core/analyzers/self-loop/text.ts
2391
- var SELF_LOOP_TEXTS = {
2392
- /**
2393
- * Per-edge warn: a node body references itself via the slash /
2394
- * at-directive / markdown-link surface (most commonly because the
2395
- * file's heading IS the invocation token, e.g. `# /deploy` inside
2396
- * `commands/deploy.md`). The link is structurally valid but rarely
2397
- * the operator's intent; UI consumers MAY hide it by default and
2398
- * surface a count.
2399
- */
2400
- message: "{{source}} references itself via `{{trigger}}` ({{kind}}). Self-loops typically come from the file's own heading or label and are noise rather than intent. Either remove the in-body token or treat this finding as expected and acknowledged."
2401
- };
2402
-
2403
- // plugins/core/analyzers/self-loop/index.ts
2404
- var ID17 = "self-loop";
2405
- var selfLoopAnalyzer = {
2406
- id: ID17,
2407
- pluginId: CORE_PLUGIN_ID,
2408
- kind: "analyzer",
2409
- version: "1.0.0",
2410
- description: "Flags links whose source is its own resolved target (a body heading like `# /deploy` inside the file that defines `/deploy`). One warn per self-looping link, attached to the source. UI consumers hide self-loops by default; this analyzer is the authoritative detector.",
2411
- mode: "deterministic",
2412
- evaluate(ctx) {
2413
- if (ctx.links.length === 0) return [];
2414
- const issues = [];
2415
- for (const link of ctx.links) {
2416
- if (!isSelfLoop(link)) continue;
2417
- issues.push({
2418
- analyzerId: ID17,
2419
- severity: "warn",
2420
- nodeIds: [link.source],
2421
- message: tx(SELF_LOOP_TEXTS.message, {
2422
- source: link.source,
2423
- trigger: link.trigger?.originalTrigger ?? link.target,
2424
- kind: link.kind
2425
- }),
2426
- data: {
2427
- target: link.target,
2428
- resolvedTarget: link.resolvedTarget ?? link.target,
2429
- kind: link.kind,
2430
- // Mark explicitly so UI / downstream consumers can read this
2431
- // single field instead of re-computing the `source === target`
2432
- // predicate themselves.
2433
- selfLoop: true
2434
- }
2435
- });
2436
- }
2437
- return issues;
2438
- }
2439
- };
2440
- function isSelfLoop(link) {
2441
- if (link.source === link.target) return true;
2442
- if (link.resolvedTarget && link.source === link.resolvedTarget) return true;
2443
- return false;
2444
- }
2445
-
2446
- // plugins/core/analyzers/signal-collision/text.ts
2447
- var SIGNAL_COLLISION_TEXTS = {
2448
- /**
2449
- * Per-Signal warn issue: two extractors detected something at
2450
- * overlapping byte ranges within the same node and the resolver
2451
- * dropped the loser. Surfaces WHO lost, WHO won, and the tiebreak
2452
- * reason so the operator can understand why a candidate edge did NOT
2453
- * become a Link.
2454
- *
2455
- * Placeholders are deliberately verbose because this is one of the
2456
- * few diagnostic surfaces where the operator may need to disambiguate
2457
- * a confusing graph (e.g. a `[link](path)` followed by `@path` inside
2458
- * the same paragraph, the markdown-link wins and the at-directive
2459
- * silently disappears without this warning).
2460
- */
2461
- message: "{{loserExtractor}} detected `{{loserRaw}}` at offset {{loserRange}} but {{winnerExtractor}}'s detection at {{winnerRange}} won the overlap collision ({{reason}}). The graph shows the winning edge only; the loser is not persisted.",
2462
- /**
2463
- * Same warn but for the rare case the resolver rejected a Signal
2464
- * because the operator disabled its extractor via
2465
- * `plugins.<id>.extensions.<extId>.enabled`. Phase 4+ stub: today the
2466
- * filter is not wired so this template is unreachable from the
2467
- * resolver; documented now so the analyzer stays forward-compatible
2468
- * with the upcoming filter pass.
2469
- */
2470
- messageExtractorDisabled: "Extension `{{extractorId}}` is disabled; its detection `{{loserRaw}}` (offset {{loserRange}}) did not produce a Link. Re-enable the extension in Settings or via `sm plugins enable` to surface its edges.",
2471
- /**
2472
- * Same warn but for the future confidence floor case. Phase 4+ stub:
2473
- * today the resolver materialises every winning candidate regardless
2474
- * of confidence, so this template is unreachable; documented for
2475
- * forward compatibility.
2476
- */
2477
- messageBelowFloor: "Detection `{{loserRaw}}` (offset {{loserRange}}, confidence {{confidence}}) fell below the configured threshold {{threshold}} and was dropped."
2478
- };
2479
-
2480
- // plugins/core/analyzers/signal-collision/index.ts
2481
- var ID18 = "signal-collision";
2482
- var signalCollisionAnalyzer = {
2483
- id: ID18,
2484
- pluginId: CORE_PLUGIN_ID,
2485
- kind: "analyzer",
2486
- version: "1.0.0",
2487
- description: "Surfaces Signal IR resolver rejections (range-overlap losers, disabled extractors, below-floor candidates) as warn issues attached to the Signal's source node.",
2488
- mode: "deterministic",
2489
- evaluate(ctx) {
2490
- const signals = ctx.signals;
2491
- if (!signals || signals.length === 0) return [];
2492
- const issues = [];
2493
- for (const signal of signals) {
2494
- const issue = makeIssue(signal);
2495
- if (issue) issues.push(issue);
2496
- }
2497
- return issues;
2498
- }
2499
- };
2500
- function makeIssue(signal) {
2501
- const resolution = signal.resolution;
2502
- if (!resolution || resolution.outcome !== "rejected") return null;
2503
- if (resolution.rejectedBy) {
2504
- const winner = resolution.rejectedBy;
2505
- const winnerCandidate = signal.candidates[resolution.winnerIndex ?? 0];
2506
- const loserRange = signal.range ? `${signal.range.start}-${signal.range.end}` : "unknown";
2507
- const winnerRange = `${winner.range.start}-${winner.range.end}`;
2508
- return {
2509
- analyzerId: ID18,
2510
- severity: "warn",
2511
- nodeIds: [signal.source],
2512
- message: tx(SIGNAL_COLLISION_TEXTS.message, {
2513
- loserExtractor: winnerCandidate.extractorId,
2514
- loserRaw: signal.raw,
2515
- loserRange,
2516
- winnerExtractor: winner.extractorId,
2517
- winnerRange,
2518
- reason: winner.reason
2519
- }),
2520
- data: {
2521
- loser: {
2522
- extractorId: winnerCandidate.extractorId,
2523
- raw: signal.raw,
2524
- range: signal.range ?? null,
2525
- candidate: {
2526
- kind: winnerCandidate.kind,
2527
- target: winnerCandidate.target,
2528
- confidence: winnerCandidate.confidence
2529
- }
2530
- },
2531
- winner: {
2532
- extractorId: winner.extractorId,
2533
- range: winner.range
2534
- },
2535
- reason: winner.reason
2536
- }
2537
- };
2538
- }
2539
- if (resolution.extractorDisabled) {
2540
- const loserRange = signal.range ? `${signal.range.start}-${signal.range.end}` : "unknown";
2541
- return {
2542
- analyzerId: ID18,
2543
- severity: "warn",
2544
- nodeIds: [signal.source],
2545
- message: tx(SIGNAL_COLLISION_TEXTS.messageExtractorDisabled, {
2546
- extractorId: resolution.extractorDisabled.extractorId,
2547
- loserRaw: signal.raw,
2548
- loserRange
2549
- }),
2550
- data: {
2551
- extractorDisabled: resolution.extractorDisabled,
2552
- raw: signal.raw,
2553
- range: signal.range ?? null
2554
- }
2555
- };
2556
- }
2557
- if (resolution.belowFloor) {
2558
- const loserRange = signal.range ? `${signal.range.start}-${signal.range.end}` : "unknown";
2559
- const topCandidate = signal.candidates[0];
2560
- return {
2561
- analyzerId: ID18,
2562
- severity: "warn",
2563
- nodeIds: [signal.source],
2564
- message: tx(SIGNAL_COLLISION_TEXTS.messageBelowFloor, {
2565
- loserRaw: signal.raw,
2566
- loserRange,
2567
- confidence: topCandidate.confidence,
2568
- threshold: resolution.belowFloor.threshold
2569
- }),
2570
- data: {
2571
- belowFloor: resolution.belowFloor,
2572
- raw: signal.raw,
2573
- range: signal.range ?? null
2574
- }
2575
- };
2576
- }
2577
- return null;
2578
- }
2579
-
2580
- // plugins/core/analyzers/stability/index.ts
2581
- var ID19 = "stability";
2319
+ // plugins/core/analyzers/node-stability/index.ts
2320
+ var ID17 = "node-stability";
2582
2321
  var EXPERIMENTAL_TOOLTIP = "Experimental: API may change";
2583
2322
  var DEPRECATED_TOOLTIP = "Deprecated: avoid in new code";
2584
- var stabilityAnalyzer = {
2585
- id: ID19,
2323
+ var nodeStabilityAnalyzer = {
2324
+ id: ID17,
2586
2325
  pluginId: CORE_PLUGIN_ID,
2587
2326
  kind: "analyzer",
2588
2327
  version: "1.0.0",
2589
- description: "Reports node lifecycle stage (`experimental`, `deprecated`) on the card.",
2328
+ description: "Reports a node's stability stage (`experimental`, `deprecated`) on the card.",
2590
2329
  mode: "deterministic",
2591
2330
  ui: {
2592
2331
  experimental: {
@@ -2614,7 +2353,7 @@ var stabilityAnalyzer = {
2614
2353
  tooltip: EXPERIMENTAL_TOOLTIP
2615
2354
  });
2616
2355
  issues.push({
2617
- analyzerId: ID19,
2356
+ analyzerId: ID17,
2618
2357
  severity: "info",
2619
2358
  nodeIds: [node.path],
2620
2359
  message: `Node '${node.path}' is marked experimental: API may change.`,
@@ -2627,7 +2366,7 @@ var stabilityAnalyzer = {
2627
2366
  severity: "warn"
2628
2367
  });
2629
2368
  issues.push({
2630
- analyzerId: ID19,
2369
+ analyzerId: ID17,
2631
2370
  severity: "warn",
2632
2371
  nodeIds: [node.path],
2633
2372
  message: `Node '${node.path}' is marked deprecated: avoid in new code.`,
@@ -2654,20 +2393,20 @@ function isStability(value) {
2654
2393
  return value === "experimental" || value === "deprecated" || value === "stable";
2655
2394
  }
2656
2395
 
2657
- // plugins/core/analyzers/superseded/text.ts
2658
- var SUPERSEDED_TEXTS = {
2396
+ // plugins/core/analyzers/node-superseded/text.ts
2397
+ var NODE_SUPERSEDED_TEXTS = {
2659
2398
  /** `<path> is superseded by <supersededBy>` */
2660
2399
  message: "{{path}} is superseded by {{supersededBy}}"
2661
2400
  };
2662
2401
 
2663
- // plugins/core/analyzers/superseded/index.ts
2664
- var ID20 = "superseded";
2665
- var supersededAnalyzer = {
2666
- id: ID20,
2402
+ // plugins/core/analyzers/node-superseded/index.ts
2403
+ var ID18 = "node-superseded";
2404
+ var nodeSupersededAnalyzer = {
2405
+ id: ID18,
2667
2406
  pluginId: CORE_PLUGIN_ID,
2668
2407
  kind: "analyzer",
2669
2408
  version: "1.0.0",
2670
- description: "Detects and marks nodes replaced by a newer one via `supersededBy`.",
2409
+ description: "Marks nodes replaced by a newer one via `supersededBy`.",
2671
2410
  mode: "deterministic",
2672
2411
  evaluate(ctx) {
2673
2412
  const issues = [];
@@ -2675,10 +2414,10 @@ var supersededAnalyzer = {
2675
2414
  const supersededBy = pickSupersededBy(node);
2676
2415
  if (supersededBy === null) continue;
2677
2416
  issues.push({
2678
- analyzerId: ID20,
2417
+ analyzerId: ID18,
2679
2418
  severity: "info",
2680
2419
  nodeIds: [node.path],
2681
- message: tx(SUPERSEDED_TEXTS.message, {
2420
+ message: tx(NODE_SUPERSEDED_TEXTS.message, {
2682
2421
  path: node.path,
2683
2422
  supersededBy
2684
2423
  }),
@@ -2698,353 +2437,336 @@ function pickSupersededBy(node) {
2698
2437
  return value;
2699
2438
  }
2700
2439
 
2701
- // plugins/core/analyzers/trigger-collision/text.ts
2702
- var TRIGGER_COLLISION_TEXTS = {
2703
- /**
2704
- * Top-level message when `analyzeTriggerBucket` accumulated exactly one
2705
- * cause part. Used for the advertiser-ambiguous-only, invocation-
2706
- * ambiguous-only, and cross-kind-only branches.
2707
- */
2708
- messageOnePart: 'Trigger "{{normalized}}" has {{part}}.',
2709
- /**
2710
- * Top-level message when `analyzeTriggerBucket` accumulated two cause
2711
- * parts (advertiser-ambiguous AND invocation-ambiguous fire together).
2712
- * The joiner lives inside the template so future locales can adapt it
2713
- * (e.g. `'; y '` in Spanish) without touching the rule code.
2714
- */
2715
- messageTwoParts: 'Trigger "{{normalized}}" has {{first}}; and {{second}}.',
2716
- /** `<n> nodes advertise it: <list>` part, fires on the advertiser-ambiguous branch. */
2717
- partAdvertisers: "{{count}} nodes advertise it: {{paths}}",
2718
- /** `<n> distinct invocation forms: <list>` part, fires on the invocation-ambiguous branch. */
2719
- partInvocations: "{{count}} distinct invocation forms: {{forms}}",
2720
- /** Singular cross-kind cause: `non-canonical invocation <form> against advertiser <path>`. */
2721
- partNonCanonicalSingular: "non-canonical invocation {{forms}} against advertiser {{advertiser}}",
2722
- /** Plural cross-kind cause: `non-canonical invocations <forms> against advertiser <path>`. */
2723
- partNonCanonicalPlural: "non-canonical invocations {{forms}} against advertiser {{advertiser}}"
2724
- };
2725
-
2726
- // plugins/core/analyzers/trigger-collision/index.ts
2727
- var ID21 = "trigger-collision";
2728
- var ADVERTISING_KINDS = /* @__PURE__ */ new Set([
2729
- "command",
2730
- "skill",
2731
- "agent"
2732
- ]);
2733
- var triggerCollisionAnalyzer = {
2734
- id: ID21,
2735
- pluginId: CORE_PLUGIN_ID,
2736
- kind: "analyzer",
2737
- mode: "deterministic",
2738
- version: "1.0.0",
2739
- description: "Detects and flags two or more nodes claiming the same `/command` or `@agent` name.",
2740
- // Two claim-collection passes (advertisement + invocation) feeding
2741
- // the bucket map. Per-bucket analysis lives in `analyzeTriggerBucket`.
2742
- // eslint-disable-next-line complexity
2743
- evaluate(ctx) {
2744
- const buckets = /* @__PURE__ */ new Map();
2745
- const push = (key, claim) => {
2746
- const bucket = buckets.get(key) ?? [];
2747
- bucket.push(claim);
2748
- buckets.set(key, bucket);
2749
- };
2750
- for (const node of ctx.nodes) {
2751
- if (!ADVERTISING_KINDS.has(node.kind)) continue;
2752
- const raw = node.frontmatter?.["name"];
2753
- if (typeof raw !== "string" || raw.length === 0) continue;
2754
- const normalized = `/${normalizeTrigger(raw)}`;
2755
- if (normalized === "/") continue;
2756
- push(normalized, {
2757
- kind: "advertiser",
2758
- token: node.path,
2759
- nodeId: node.path,
2760
- canonicalForm: `/${raw}`
2761
- });
2762
- }
2763
- for (const link of ctx.links) {
2764
- const normalized = link.trigger?.normalizedTrigger;
2765
- if (!normalized) continue;
2766
- push(normalized, {
2767
- kind: "invocation",
2768
- token: link.target,
2769
- nodeId: link.source
2770
- });
2771
- }
2772
- const issues = [];
2773
- for (const [normalized, claims] of buckets) {
2774
- const issue = analyzeTriggerBucket(normalized, claims);
2775
- if (issue) issues.push(issue);
2776
- }
2777
- return issues;
2778
- }
2779
- };
2780
- function analyzeTriggerBucket(normalized, claims) {
2781
- const advertiserPaths = [
2782
- ...new Set(claims.filter((c) => c.kind === "advertiser").map((c) => c.token))
2783
- ].sort();
2784
- const invocationTargets = [
2785
- ...new Set(claims.filter((c) => c.kind === "invocation").map((c) => c.token))
2786
- ].sort();
2787
- const advertisers = claims.filter(
2788
- (c) => c.kind === "advertiser"
2789
- );
2790
- const advertiserAmbiguous = advertiserPaths.length >= 2;
2791
- const invocationAmbiguous = invocationTargets.length >= 2;
2792
- const canonicalForms = new Set(advertisers.map((a) => a.canonicalForm));
2793
- const nonCanonicalInvocations = invocationTargets.filter((t) => !canonicalForms.has(t));
2794
- const crossKindAmbiguous = advertiserPaths.length === 1 && nonCanonicalInvocations.length >= 1;
2795
- if (!advertiserAmbiguous && !invocationAmbiguous && !crossKindAmbiguous) {
2796
- return null;
2797
- }
2798
- const nodeIds = [...new Set(claims.map((c) => c.nodeId))].sort();
2799
- const parts = [];
2800
- if (advertiserAmbiguous) {
2801
- parts.push(
2802
- tx(TRIGGER_COLLISION_TEXTS.partAdvertisers, {
2803
- count: advertiserPaths.length,
2804
- paths: advertiserPaths.join(", ")
2805
- })
2806
- );
2807
- }
2808
- if (invocationAmbiguous) {
2809
- parts.push(
2810
- tx(TRIGGER_COLLISION_TEXTS.partInvocations, {
2811
- count: invocationTargets.length,
2812
- forms: invocationTargets.join(", ")
2813
- })
2814
- );
2815
- } else if (crossKindAmbiguous) {
2816
- const template = nonCanonicalInvocations.length > 1 ? TRIGGER_COLLISION_TEXTS.partNonCanonicalPlural : TRIGGER_COLLISION_TEXTS.partNonCanonicalSingular;
2817
- parts.push(
2818
- tx(template, {
2819
- forms: nonCanonicalInvocations.join(", "),
2820
- advertiser: advertiserPaths[0]
2821
- })
2822
- );
2823
- }
2824
- const message = parts.length === 2 ? tx(TRIGGER_COLLISION_TEXTS.messageTwoParts, {
2825
- normalized,
2826
- first: parts[0],
2827
- second: parts[1]
2828
- }) : tx(TRIGGER_COLLISION_TEXTS.messageOnePart, {
2829
- normalized,
2830
- part: parts[0]
2831
- });
2832
- return {
2833
- analyzerId: ID21,
2834
- severity: "error",
2835
- nodeIds,
2836
- message,
2837
- data: {
2838
- normalizedTrigger: normalized,
2839
- invocationTargets,
2840
- advertiserPaths
2841
- }
2842
- };
2843
- }
2844
-
2845
- // plugins/core/analyzers/unknown-field/index.ts
2846
- import { readFileSync } from "fs";
2847
- import { dirname, resolve as resolve3 } from "path";
2848
- import { createRequire } from "module";
2849
- import { Ajv2020 } from "ajv/dist/2020.js";
2440
+ // plugins/core/analyzers/reference-broken/index.ts
2441
+ import { posix as pathPosix4, resolve as resolve3 } from "path";
2850
2442
 
2851
- // kernel/util/ajv-interop.ts
2852
- import addFormatsModule from "ajv-formats";
2853
- var addFormats = addFormatsModule.default ?? addFormatsModule;
2854
- function applyAjvFormats(ajv) {
2855
- addFormats(ajv);
2856
- }
2857
-
2858
- // plugins/core/analyzers/unknown-field/text.ts
2859
- var UNKNOWN_FIELD_TEXTS = {
2860
- /** Key inside `annotations:` is not in the curated catalog. */
2861
- unknownAnnotationKey: "{{path}}: sidecar annotations contain unknown key '{{key}}' (not in annotations.schema.json catalog).",
2862
- /** Top-level key is neither reserved, nor a registered plugin namespace, nor a registered root key. */
2863
- unknownRootKey: "{{path}}: sidecar declares unknown top-level key '{{key}}'; not a reserved block, not a registered plugin namespace, not a registered root contribution.",
2864
- /** Value under a registered plugin namespace fails the contributed schema. */
2865
- pluginNamespaceInvalid: "{{path}}: sidecar block '{{pluginId}}.{{key}}' fails the schema contributed by plugin '{{pluginId}}': {{errors}}.",
2443
+ // plugins/core/analyzers/reference-broken/text.ts
2444
+ var REFERENCE_BROKEN_TEXTS = {
2445
+ /** `Broken <kind> reference from <source> → <target>` */
2446
+ message: "Broken {{kind}} reference from {{source}} \u2192 {{target}}",
2866
2447
  // Tooltips for the per-node view-contribution badges. Singular vs
2867
2448
  // plural keeps the count grammar correct without a sub-template.
2868
- alertTooltipSingle: "This node has 1 unknown field in its sidecar. Open the inspector for details.",
2869
- alertTooltipMany: "This node has {{count}} unknown fields in its sidecar. Open the inspector for details."
2449
+ alertTooltipSingle: "This node has a broken reference. Open the inspector for details.",
2450
+ alertTooltipMany: "This node has {{count}} broken references. Open the inspector for details.",
2451
+ // Fix-summary copy when the broken trigger has a same-named file on
2452
+ // disk that does not advertise `name:` in its frontmatter. Two
2453
+ // variants for single vs multiple candidates; same template family
2454
+ // as the alert tooltips above.
2455
+ hintSummarySingle: "Add `name: {{name}}` to the frontmatter of {{candidate}} so this reference resolves.",
2456
+ hintSummaryMany: "Add `name: {{name}}` to the frontmatter of one of these files so this reference resolves: {{candidates}}."
2870
2457
  };
2871
2458
 
2872
- // plugins/core/analyzers/unknown-field/index.ts
2873
- var ID22 = "unknown-field";
2874
- var RESERVED_ROOT_BLOCKS = /* @__PURE__ */ new Set(["identity", "annotations", "settings", "audit"]);
2875
- var unknownFieldAnalyzer = {
2876
- id: ID22,
2459
+ // plugins/core/analyzers/reference-broken/index.ts
2460
+ var ID19 = "reference-broken";
2461
+ var referenceBrokenAnalyzer = {
2462
+ id: ID19,
2877
2463
  pluginId: CORE_PLUGIN_ID,
2878
2464
  kind: "analyzer",
2879
2465
  version: "1.0.0",
2880
- description: "Detects and flags typos or unrecognized keys in sidecars (`.sm`).",
2466
+ description: "Flags arrows pointing at a node not part of the current scan.",
2881
2467
  mode: "deterministic",
2882
2468
  ui: {
2883
2469
  // Corner badge on the graph card; count omitted when there is a
2884
- // single unknown field (avoids a noisy "icon + 1" chip).
2470
+ // single broken ref (avoids a noisy "icon + 1" chip).
2885
2471
  alert: {
2886
2472
  slot: "graph.node.alert",
2887
- // Filled warning triangle on the corner, matches the broken-ref
2888
- // alert's "attention-grabbing solid" pattern; the footer chip
2889
- // below stays outlined for the quieter counter pairing.
2890
- icon: "fa-solid fa-triangle-exclamation",
2473
+ icon: "fa-solid fa-circle-xmark",
2891
2474
  emitWhenEmpty: false
2892
2475
  },
2893
- // Footer chip on the card, `_counter` shape but rendered icon-only
2894
- // (the analyzer emits `value: 0` so NodeCounter hides the number
2895
- // and only the glyph shows). PrimeIcons `pi-question-circle` so the
2896
- // visual weight matches `annotation-stale`'s `pi-clock` chip
2897
- // sitting next to it on the same footer row. `emitWhenEmpty: true`
2898
- // is required: with `value: 0` the slot treats the payload as
2899
- // empty, so the manifest has to opt in to keep the emission.
2476
+ // Footer chip on the card. `_counter` shape, `value` always shows,
2477
+ // so the operator sees "how many" at a glance. Renders OUTLINED
2478
+ // (`fa-regular`) so the corner alert (filled, attention-grabbing)
2479
+ // and the footer chip (quieter, paired with a number) read as two
2480
+ // beats of the same signal rather than two identical glyphs.
2900
2481
  chip: {
2901
2482
  slot: "card.footer.right",
2902
- icon: "pi-question-circle",
2903
- emitWhenEmpty: true,
2904
- priority: 30
2483
+ icon: "fa-regular fa-circle-xmark",
2484
+ emitWhenEmpty: false,
2485
+ priority: 40
2905
2486
  }
2906
2487
  },
2907
- // Analyzer body iterates every sidecar root and classifies each
2908
- // key against three buckets (catalog / plugin namespace / unknown
2909
- // root). The per-key branching IS the classification table; factoring
2910
- // it out would rebuild the discriminator elsewhere. Per
2911
- // `context/lint.md` category 7 (recursive type-discriminator walkers).
2488
+ // The resolver, the reference-paths escape hatch, the per-source
2489
+ // aggregation, and the dual-slot emit (with single/plural tooltip and
2490
+ // optional count) all live in one flow because they share the per-link
2491
+ // loop. Splitting them would re-walk `ctx.links` three times.
2912
2492
  // eslint-disable-next-line complexity
2913
2493
  evaluate(ctx) {
2914
- const sidecarRoots = ctx.sidecarRoots;
2915
- if (!sidecarRoots || sidecarRoots.size === 0) return [];
2916
- const knownAnnotationKeys = getKnownAnnotationKeys();
2917
- const contributions = ctx.annotationContributions ?? [];
2918
- const namespacedByPlugin = indexNamespacedContributions(contributions);
2919
- const rootKeys = indexRootContributions(contributions);
2920
- const knownPluginIds = collectPluginIds(contributions);
2494
+ const byPath3 = new Set(ctx.nodes.map((n) => n.path));
2495
+ const byNormalizedName = indexByNormalizedName(ctx.nodes);
2496
+ const byBasenameWithoutName = indexByBasenameWithoutName(ctx.nodes);
2497
+ const refIndex = ctx.referenceablePaths && ctx.referenceablePaths.size > 0 && ctx.cwd ? { paths: ctx.referenceablePaths, cwd: ctx.cwd } : null;
2921
2498
  const issues = [];
2922
2499
  const perNode = /* @__PURE__ */ new Map();
2923
- const bump2 = (nodePath) => {
2924
- perNode.set(nodePath, (perNode.get(nodePath) ?? 0) + 1);
2500
+ for (const link of ctx.links) {
2501
+ if (isResolved(link, byPath3, byNormalizedName)) continue;
2502
+ if (refIndex && resolvesViaReferencePaths(link, refIndex)) continue;
2503
+ const candidates = findHintCandidates(link, byBasenameWithoutName);
2504
+ issues.push(buildIssue(link, candidates));
2505
+ perNode.set(link.source, (perNode.get(link.source) ?? 0) + 1);
2506
+ }
2507
+ for (const [nodePath, count] of perNode) {
2508
+ const tooltip = count === 1 ? REFERENCE_BROKEN_TEXTS.alertTooltipSingle : tx(REFERENCE_BROKEN_TEXTS.alertTooltipMany, { count });
2509
+ const capped = Math.min(count, 99);
2510
+ ctx.emitContribution(nodePath, "alert", {
2511
+ icon: "fa-solid fa-circle-xmark",
2512
+ severity: "danger",
2513
+ tooltip
2514
+ });
2515
+ ctx.emitContribution(nodePath, "chip", {
2516
+ value: capped,
2517
+ severity: "danger",
2518
+ tooltip
2519
+ });
2520
+ }
2521
+ return issues;
2522
+ }
2523
+ };
2524
+ function buildIssue(link, hintCandidates = []) {
2525
+ const data = {
2526
+ target: link.target,
2527
+ kind: link.kind,
2528
+ trigger: link.trigger?.normalizedTrigger ?? null
2529
+ };
2530
+ const issue = {
2531
+ analyzerId: ID19,
2532
+ severity: "warn",
2533
+ nodeIds: [link.source],
2534
+ message: tx(REFERENCE_BROKEN_TEXTS.message, {
2535
+ kind: link.kind,
2536
+ source: link.source,
2537
+ target: link.target
2538
+ }),
2539
+ data
2540
+ };
2541
+ if (hintCandidates.length > 0) {
2542
+ const suggestedName = (link.trigger?.normalizedTrigger ?? "").replace(/^[/@]/, "").trim();
2543
+ const candidatePaths = hintCandidates.map((n) => n.path);
2544
+ data["hint"] = {
2545
+ kind: "missing-frontmatter-name",
2546
+ suggestedName,
2547
+ candidates: candidatePaths
2925
2548
  };
2926
- for (const node of ctx.nodes) {
2927
- const root = sidecarRoots.get(node.path);
2928
- if (!root) continue;
2929
- const annotations = root["annotations"];
2930
- if (annotations !== void 0 && annotations !== null && typeof annotations === "object" && !Array.isArray(annotations)) {
2931
- for (const key of Object.keys(annotations)) {
2932
- if (!knownAnnotationKeys.has(key)) {
2933
- issues.push({
2934
- analyzerId: ID22,
2935
- severity: "warn",
2936
- nodeIds: [node.path],
2937
- message: tx(UNKNOWN_FIELD_TEXTS.unknownAnnotationKey, {
2938
- path: node.path,
2939
- key
2940
- }),
2941
- data: { surface: "annotations", key }
2942
- });
2943
- bump2(node.path);
2944
- }
2945
- }
2946
- }
2947
- for (const key of Object.keys(root)) {
2948
- if (RESERVED_ROOT_BLOCKS.has(key)) continue;
2949
- if (rootKeys.has(key)) continue;
2950
- if (knownPluginIds.has(key)) {
2951
- const block = root[key];
2952
- if (block === null || typeof block !== "object" || Array.isArray(block)) continue;
2953
- const contribsForPlugin = namespacedByPlugin.get(key);
2954
- if (!contribsForPlugin) continue;
2955
- for (const [contribKey, validator] of contribsForPlugin) {
2956
- const value = block[contribKey];
2957
- if (value === void 0) continue;
2958
- if (validator(value)) continue;
2959
- const errors = (validator.errors ?? []).map((e) => `${e.instancePath || "(root)"} ${e.message ?? e.keyword}`).join("; ");
2960
- issues.push({
2961
- analyzerId: ID22,
2962
- severity: "warn",
2963
- nodeIds: [node.path],
2964
- message: tx(UNKNOWN_FIELD_TEXTS.pluginNamespaceInvalid, {
2965
- path: node.path,
2966
- pluginId: key,
2967
- key: contribKey,
2968
- errors
2969
- }),
2970
- data: { surface: "plugin-namespace", pluginId: key, key: contribKey }
2971
- });
2972
- bump2(node.path);
2973
- }
2974
- continue;
2975
- }
2976
- issues.push({
2977
- analyzerId: ID22,
2978
- severity: "warn",
2979
- nodeIds: [node.path],
2980
- message: tx(UNKNOWN_FIELD_TEXTS.unknownRootKey, {
2981
- path: node.path,
2982
- key
2983
- }),
2984
- data: { surface: "root", key }
2985
- });
2986
- bump2(node.path);
2987
- }
2549
+ issue.fix = {
2550
+ summary: candidatePaths.length === 1 ? tx(REFERENCE_BROKEN_TEXTS.hintSummarySingle, {
2551
+ name: suggestedName,
2552
+ candidate: candidatePaths[0]
2553
+ }) : tx(REFERENCE_BROKEN_TEXTS.hintSummaryMany, {
2554
+ name: suggestedName,
2555
+ candidates: candidatePaths.join(", ")
2556
+ }),
2557
+ autofixable: false
2558
+ };
2559
+ }
2560
+ return issue;
2561
+ }
2562
+ function resolvesViaReferencePaths(link, refIndex) {
2563
+ if (!isPathStyleLink(link)) return false;
2564
+ return refIndex.paths.has(resolve3(refIndex.cwd, link.target));
2565
+ }
2566
+ function indexByNormalizedName(nodes) {
2567
+ const out = /* @__PURE__ */ new Map();
2568
+ for (const node of nodes) {
2569
+ const raw = node.frontmatter?.["name"];
2570
+ const name = typeof raw === "string" ? raw : "";
2571
+ if (!name) continue;
2572
+ const key = normalizeTrigger(name);
2573
+ const bucket = out.get(key) ?? [];
2574
+ bucket.push(node);
2575
+ out.set(key, bucket);
2576
+ }
2577
+ return out;
2578
+ }
2579
+ function basenameWithoutExt(path) {
2580
+ const base = pathPosix4.basename(path);
2581
+ const ext = pathPosix4.extname(base);
2582
+ return ext ? base.slice(0, -ext.length) : base;
2583
+ }
2584
+ function indexByBasenameWithoutName(nodes) {
2585
+ const out = /* @__PURE__ */ new Map();
2586
+ for (const node of nodes) {
2587
+ const raw = node.frontmatter?.["name"];
2588
+ const name = typeof raw === "string" ? raw : "";
2589
+ if (name) continue;
2590
+ const bare = basenameWithoutExt(node.path);
2591
+ if (!bare) continue;
2592
+ const key = normalizeTrigger(bare);
2593
+ if (!key) continue;
2594
+ const bucket = out.get(key) ?? [];
2595
+ bucket.push(node);
2596
+ out.set(key, bucket);
2597
+ }
2598
+ return out;
2599
+ }
2600
+ function findHintCandidates(link, idx) {
2601
+ const normalized = link.trigger?.normalizedTrigger;
2602
+ if (!normalized) return [];
2603
+ const sigil = normalized.charAt(0);
2604
+ if (sigil !== "/" && sigil !== "@") return [];
2605
+ const withoutSigil = normalized.slice(1).trim();
2606
+ if (!withoutSigil) return [];
2607
+ return idx.get(withoutSigil) ?? [];
2608
+ }
2609
+ function isResolved(link, byPath3, byNormalizedName) {
2610
+ const normalized = link.trigger?.normalizedTrigger;
2611
+ if (normalized) {
2612
+ const withoutSigil = normalized.replace(/^[/@]/, "").trim();
2613
+ if (byNormalizedName.has(withoutSigil)) return true;
2614
+ }
2615
+ if (byPath3.has(link.target)) return true;
2616
+ return false;
2617
+ }
2618
+ function isPathStyleLink(link) {
2619
+ const sigil = link.trigger?.normalizedTrigger?.charAt(0);
2620
+ if (sigil === "/" || sigil === "@") return false;
2621
+ return true;
2622
+ }
2623
+
2624
+ // plugins/core/analyzers/reference-redundant/text.ts
2625
+ var REFERENCE_REDUNDANT_TEXTS = {
2626
+ /**
2627
+ * Multi-form / multi-occurrence reference message. Lists each
2628
+ * occurrence (trigger + line) so the operator sees the full
2629
+ * authorial surface without having to grep the body.
2630
+ */
2631
+ message: "{{source}} references {{resolvedTarget}} via {{count}} occurrences: {{occurrences}}. Consider consolidating to a single form to reduce maintenance surface and avoid duplicate inlining at runtime.",
2632
+ /** Inline separator between occurrences in the message. */
2633
+ occurrenceSeparator: ", ",
2634
+ /** Per-occurrence formatting (trigger + line). */
2635
+ occurrence: "`{{trigger}}` ({{kind}}, line {{line}})",
2636
+ /** Per-occurrence formatting when the extractor did not record a line. */
2637
+ occurrenceUnknownLine: "`{{trigger}}` ({{kind}}, unknown line)"
2638
+ };
2639
+
2640
+ // plugins/core/analyzers/reference-redundant/index.ts
2641
+ var ID20 = "reference-redundant";
2642
+ var referenceRedundantAnalyzer = {
2643
+ id: ID20,
2644
+ pluginId: CORE_PLUGIN_ID,
2645
+ kind: "analyzer",
2646
+ version: "1.0.0",
2647
+ description: "Flags when one node references the same target through two or more different links (e.g. a markdown link plus a `references:` entry).",
2648
+ mode: "deterministic",
2649
+ evaluate(ctx) {
2650
+ if (ctx.links.length === 0) return [];
2651
+ const byPath3 = /* @__PURE__ */ new Map();
2652
+ for (const node of ctx.nodes) byPath3.set(node.path, node);
2653
+ const byName = buildNameIndex2(ctx.nodes);
2654
+ const groups = /* @__PURE__ */ new Map();
2655
+ for (const link of ctx.links) {
2656
+ const resolved = resolveTargetPath(link, byPath3, byName);
2657
+ if (!resolved) continue;
2658
+ const key = `${link.source}\0${resolved}`;
2659
+ const bucket = groups.get(key);
2660
+ if (bucket) bucket.push(link);
2661
+ else groups.set(key, [link]);
2988
2662
  }
2989
- for (const [nodePath, count] of perNode) {
2990
- const tooltip = count === 1 ? UNKNOWN_FIELD_TEXTS.alertTooltipSingle : tx(UNKNOWN_FIELD_TEXTS.alertTooltipMany, { count });
2991
- ctx.emitContribution(nodePath, "alert", {
2992
- icon: "fa-solid fa-triangle-exclamation",
2993
- severity: "warn",
2994
- tooltip
2995
- });
2996
- ctx.emitContribution(nodePath, "chip", {
2997
- value: 0,
2663
+ const issues = [];
2664
+ for (const [key, links] of groups) {
2665
+ const totalOccurrences = links.reduce((acc, l) => acc + (l.occurrences?.length ?? 1), 0);
2666
+ if (totalOccurrences < 2) continue;
2667
+ const [source, resolvedTarget] = key.split("\0");
2668
+ const flat = flattenOccurrences(links);
2669
+ issues.push({
2670
+ analyzerId: ID20,
2998
2671
  severity: "warn",
2999
- tooltip
2672
+ nodeIds: [source],
2673
+ message: tx(REFERENCE_REDUNDANT_TEXTS.message, {
2674
+ source,
2675
+ resolvedTarget,
2676
+ count: flat.length,
2677
+ occurrences: flat.map(formatOccurrence).join(REFERENCE_REDUNDANT_TEXTS.occurrenceSeparator)
2678
+ }),
2679
+ data: {
2680
+ target: resolvedTarget,
2681
+ resolvedTarget,
2682
+ occurrences: flat.map((o) => ({
2683
+ kind: o.kind,
2684
+ trigger: o.originalTrigger,
2685
+ line: o.line ?? null,
2686
+ extractor: o.extractor
2687
+ }))
2688
+ }
3000
2689
  });
3001
2690
  }
3002
2691
  return issues;
3003
2692
  }
3004
2693
  };
3005
- var cachedKnownKeys = null;
3006
- function getKnownAnnotationKeys() {
3007
- if (cachedKnownKeys) return cachedKnownKeys;
3008
- const require2 = createRequire(import.meta.url);
3009
- const indexPath = require2.resolve("@skill-map/spec/index.json");
3010
- const specRoot = dirname(indexPath);
3011
- const schema = JSON.parse(
3012
- readFileSync(resolve3(specRoot, "schemas/annotations.schema.json"), "utf8")
3013
- );
3014
- cachedKnownKeys = new Set(Object.keys(schema.properties ?? {}));
3015
- return cachedKnownKeys;
3016
- }
3017
- function indexNamespacedContributions(contributions) {
3018
- const out = /* @__PURE__ */ new Map();
3019
- const ajv = new Ajv2020({ strict: false, allErrors: true, allowUnionTypes: true });
3020
- applyAjvFormats(ajv);
3021
- for (const entry of contributions) {
3022
- if (entry.location !== "namespaced") continue;
3023
- let bucket = out.get(entry.pluginId);
3024
- if (!bucket) {
3025
- bucket = /* @__PURE__ */ new Map();
3026
- out.set(entry.pluginId, bucket);
3027
- }
3028
- try {
3029
- bucket.set(entry.key, ajv.compile(entry.schema));
3030
- } catch {
2694
+ function flattenOccurrences(links) {
2695
+ const out = [];
2696
+ for (const link of links) {
2697
+ if (link.occurrences && link.occurrences.length > 0) {
2698
+ for (const occ of link.occurrences) {
2699
+ out.push({
2700
+ kind: link.kind,
2701
+ originalTrigger: occ.originalTrigger,
2702
+ extractor: occ.extractor,
2703
+ line: occ.location?.line ?? null
2704
+ });
2705
+ }
2706
+ continue;
3031
2707
  }
2708
+ const trigger = link.trigger?.originalTrigger ?? link.target;
2709
+ out.push({
2710
+ kind: link.kind,
2711
+ originalTrigger: trigger,
2712
+ extractor: link.sources[0] ?? "unknown",
2713
+ line: link.location?.line ?? null
2714
+ });
3032
2715
  }
2716
+ out.sort((a, b) => {
2717
+ const la = a.line ?? Number.MAX_SAFE_INTEGER;
2718
+ const lb = b.line ?? Number.MAX_SAFE_INTEGER;
2719
+ if (la !== lb) return la - lb;
2720
+ return a.originalTrigger.localeCompare(b.originalTrigger);
2721
+ });
3033
2722
  return out;
3034
2723
  }
3035
- function indexRootContributions(contributions) {
3036
- const out = /* @__PURE__ */ new Set();
3037
- for (const entry of contributions) {
3038
- if (entry.location === "root") out.add(entry.key);
2724
+ function formatOccurrence(occ) {
2725
+ if (occ.line === null) {
2726
+ return tx(REFERENCE_REDUNDANT_TEXTS.occurrenceUnknownLine, { trigger: occ.originalTrigger, kind: occ.kind });
3039
2727
  }
3040
- return out;
2728
+ return tx(REFERENCE_REDUNDANT_TEXTS.occurrence, { trigger: occ.originalTrigger, kind: occ.kind, line: occ.line });
3041
2729
  }
3042
- function collectPluginIds(contributions) {
3043
- const out = /* @__PURE__ */ new Set();
3044
- for (const entry of contributions) out.add(entry.pluginId);
2730
+ function buildNameIndex2(nodes) {
2731
+ const out = /* @__PURE__ */ new Map();
2732
+ for (const node of nodes) {
2733
+ for (const candidate of collectIdentifiers(node)) {
2734
+ const normalised = normalizeTrigger(candidate);
2735
+ if (!normalised) continue;
2736
+ const bucket = out.get(normalised);
2737
+ if (bucket) bucket.push(node.path);
2738
+ else out.set(normalised, [node.path]);
2739
+ }
2740
+ }
3045
2741
  return out;
3046
2742
  }
3047
-
2743
+ function collectIdentifiers(node) {
2744
+ const out = [];
2745
+ const fmName = node.frontmatter?.["name"];
2746
+ if (typeof fmName === "string" && fmName.length > 0) out.push(fmName);
2747
+ const segs = node.path.split("/");
2748
+ const last = segs[segs.length - 1] ?? "";
2749
+ if (last) {
2750
+ const stem = last.replace(/\.[^.]+$/, "");
2751
+ if (stem) out.push(stem);
2752
+ }
2753
+ if (segs.length >= 2) {
2754
+ const dirBase = segs[segs.length - 2];
2755
+ if (dirBase) out.push(dirBase);
2756
+ }
2757
+ return out;
2758
+ }
2759
+ function resolveTargetPath(link, byPath3, byName) {
2760
+ if (byPath3.has(link.target)) return link.target;
2761
+ const trigger = link.trigger?.normalizedTrigger;
2762
+ if (!trigger) return null;
2763
+ const stripped = trigger.replace(/^[/@]/, "").trim();
2764
+ if (!stripped) return null;
2765
+ const candidates = byName.get(stripped);
2766
+ if (!candidates || candidates.length === 0) return null;
2767
+ return candidates[0] ?? null;
2768
+ }
2769
+
3048
2770
  // kernel/adapters/schema-validators.ts
3049
2771
  import { readFileSync as readFileSync2 } from "fs";
3050
2772
  import { dirname as dirname2, resolve as resolve4 } from "path";
@@ -3252,8 +2974,8 @@ function existsSyncSafe(path) {
3252
2974
  }
3253
2975
  }
3254
2976
 
3255
- // plugins/core/analyzers/validate-all/text.ts
3256
- var VALIDATE_ALL_TEXTS = {
2977
+ // plugins/core/analyzers/schema-violation/text.ts
2978
+ var SCHEMA_VIOLATION_TEXTS = {
3257
2979
  /** `Node <path> failed schema validation: <errors>` */
3258
2980
  nodeFailure: "Node {{path}} failed schema validation: {{errors}}",
3259
2981
  /** `Link <source> → <target> failed schema validation: <errors>` */
@@ -3266,150 +2988,428 @@ var VALIDATE_ALL_TEXTS = {
3266
2988
  alertTooltipMany: "{{count}} schema validation issues on this node."
3267
2989
  };
3268
2990
 
3269
- // plugins/core/analyzers/validate-all/index.ts
3270
- var ID23 = "validate-all";
3271
- var validateAllAnalyzer = {
2991
+ // plugins/core/analyzers/schema-violation/index.ts
2992
+ var ID21 = "schema-violation";
2993
+ var schemaViolationAnalyzer = {
2994
+ id: ID21,
2995
+ pluginId: CORE_PLUGIN_ID,
2996
+ kind: "analyzer",
2997
+ version: "1.0.0",
2998
+ description: "Flags nodes or links that violate the project schemas.",
2999
+ mode: "deterministic",
3000
+ ui: {
3001
+ // Corner badge on the graph card; surfaces when the node body /
3002
+ // frontmatter fails schema validation (parse error, missing
3003
+ // `name`/`description`, malformed YAML, etc.). Same visual
3004
+ // chassis as `core/reference-broken`, danger severity.
3005
+ alert: {
3006
+ slot: "graph.node.alert",
3007
+ icon: "fa-solid fa-triangle-exclamation",
3008
+ emitWhenEmpty: false
3009
+ },
3010
+ // Footer chip that mirrors the corner alert with the actual
3011
+ // count so the operator can scan the cards and prioritise.
3012
+ // Outlined (vs the filled corner alert) per the broken-ref
3013
+ // pattern: two beats of the same signal.
3014
+ chip: {
3015
+ slot: "card.footer.right",
3016
+ icon: "fa-regular fa-triangle-exclamation",
3017
+ emitWhenEmpty: false,
3018
+ priority: 35
3019
+ }
3020
+ },
3021
+ evaluate(ctx) {
3022
+ const validators = loadSchemaValidators();
3023
+ const findings = [];
3024
+ const perNode = /* @__PURE__ */ new Map();
3025
+ for (const node of ctx.nodes) {
3026
+ const before = findings.length;
3027
+ collectNodeFindings(validators, node, findings);
3028
+ collectFrontmatterBaseFindings(node, findings);
3029
+ if (findings.length > before) {
3030
+ perNode.set(node.path, (perNode.get(node.path) ?? 0) + (findings.length - before));
3031
+ }
3032
+ }
3033
+ for (const link of ctx.links) {
3034
+ collectLinkFindings(validators, link, findings);
3035
+ }
3036
+ for (const [nodePath, count] of perNode) {
3037
+ const tooltip = count === 1 ? SCHEMA_VIOLATION_TEXTS.alertTooltipSingle : tx(SCHEMA_VIOLATION_TEXTS.alertTooltipMany, { count });
3038
+ const capped = Math.min(count, 99);
3039
+ ctx.emitContribution(nodePath, "alert", {
3040
+ icon: "fa-solid fa-triangle-exclamation",
3041
+ severity: "danger",
3042
+ tooltip
3043
+ });
3044
+ ctx.emitContribution(nodePath, "chip", {
3045
+ value: capped,
3046
+ severity: "danger",
3047
+ tooltip
3048
+ });
3049
+ }
3050
+ return findings;
3051
+ }
3052
+ };
3053
+ function collectNodeFindings(v, node, out) {
3054
+ const result = v.validate("node", toNodeForSchema(node));
3055
+ if (result.ok) return;
3056
+ out.push({
3057
+ analyzerId: ID21,
3058
+ severity: "error",
3059
+ nodeIds: [node.path],
3060
+ message: tx(SCHEMA_VIOLATION_TEXTS.nodeFailure, {
3061
+ path: node.path,
3062
+ errors: result.errors
3063
+ }),
3064
+ data: { target: "node", path: node.path }
3065
+ });
3066
+ }
3067
+ function collectFrontmatterBaseFindings(node, out) {
3068
+ if (node.provider === "markdown") return;
3069
+ if (node.bytes.frontmatter === 0) return;
3070
+ const fm = node.frontmatter ?? {};
3071
+ const missing = [];
3072
+ if (isMissingStringField(fm, "name")) missing.push("name");
3073
+ if (isMissingStringField(fm, "description")) missing.push("description");
3074
+ if (missing.length === 0) return;
3075
+ out.push({
3076
+ analyzerId: ID21,
3077
+ // `warn` (not `error`) so the default `sm scan` exit code stays
3078
+ // 0 even when nodes are missing frontmatter base fields. Strict
3079
+ // mode (`sm scan --strict`) still escalates to exit 1. Matches
3080
+ // the `frontmatter-invalid` severity policy of the orchestrator.
3081
+ severity: "warn",
3082
+ nodeIds: [node.path],
3083
+ message: tx(SCHEMA_VIOLATION_TEXTS.frontmatterBaseFailure, {
3084
+ path: node.path,
3085
+ missing: missing.join(", ")
3086
+ }),
3087
+ data: { target: "frontmatter", path: node.path, missing }
3088
+ });
3089
+ }
3090
+ function isMissingStringField(fm, field) {
3091
+ const v = fm[field];
3092
+ return typeof v !== "string" || v.length === 0;
3093
+ }
3094
+ function collectLinkFindings(v, link, out) {
3095
+ const result = v.validate("link", toLinkForSchema(link));
3096
+ if (result.ok) return;
3097
+ out.push({
3098
+ analyzerId: ID21,
3099
+ severity: "error",
3100
+ nodeIds: [link.source],
3101
+ message: tx(SCHEMA_VIOLATION_TEXTS.linkFailure, {
3102
+ source: link.source,
3103
+ target: link.target,
3104
+ errors: result.errors
3105
+ }),
3106
+ data: { target: "link", source: link.source, to: link.target }
3107
+ });
3108
+ }
3109
+ function toNodeForSchema(node) {
3110
+ return {
3111
+ path: node.path,
3112
+ kind: node.kind,
3113
+ provider: node.provider,
3114
+ bodyHash: node.bodyHash,
3115
+ frontmatterHash: node.frontmatterHash,
3116
+ bytes: node.bytes,
3117
+ tokens: node.tokens ?? void 0,
3118
+ linksOutCount: node.linksOutCount,
3119
+ linksInCount: node.linksInCount,
3120
+ externalRefsCount: node.externalRefsCount,
3121
+ frontmatter: node.frontmatter ?? {},
3122
+ sidecar: node.sidecar ?? void 0
3123
+ };
3124
+ }
3125
+ function toLinkForSchema(link) {
3126
+ return {
3127
+ source: link.source,
3128
+ target: link.target,
3129
+ kind: link.kind,
3130
+ confidence: link.confidence,
3131
+ sources: link.sources,
3132
+ trigger: link.trigger ?? void 0,
3133
+ location: link.location ?? void 0,
3134
+ raw: link.raw ?? void 0
3135
+ };
3136
+ }
3137
+
3138
+ // plugins/core/analyzers/signal-collision/text.ts
3139
+ var SIGNAL_COLLISION_TEXTS = {
3140
+ /**
3141
+ * Per-Signal warn issue: two extractors detected something at
3142
+ * overlapping byte ranges within the same node and the resolver
3143
+ * dropped the loser. Surfaces WHO lost, WHO won, and the tiebreak
3144
+ * reason so the operator can understand why a candidate edge did NOT
3145
+ * become a Link.
3146
+ *
3147
+ * Placeholders are deliberately verbose because this is one of the
3148
+ * few diagnostic surfaces where the operator may need to disambiguate
3149
+ * a confusing graph (e.g. a `[link](path)` followed by `@path` inside
3150
+ * the same paragraph, the markdown-link wins and the at-directive
3151
+ * silently disappears without this warning).
3152
+ */
3153
+ message: "{{loserExtractor}} detected `{{loserRaw}}` at offset {{loserRange}} but {{winnerExtractor}}'s detection at {{winnerRange}} won the overlap collision ({{reason}}). The graph shows the winning edge only; the loser is not persisted.",
3154
+ /**
3155
+ * Same warn but for the rare case the resolver rejected a Signal
3156
+ * because the operator disabled its extractor via
3157
+ * `plugins.<id>.extensions.<extId>.enabled`. Phase 4+ stub: today the
3158
+ * filter is not wired so this template is unreachable from the
3159
+ * resolver; documented now so the analyzer stays forward-compatible
3160
+ * with the upcoming filter pass.
3161
+ */
3162
+ messageExtractorDisabled: "Extension `{{extractorId}}` is disabled; its detection `{{loserRaw}}` (offset {{loserRange}}) did not produce a Link. Re-enable the extension in Settings or via `sm plugins enable` to surface its edges.",
3163
+ /**
3164
+ * Same warn but for the future confidence floor case. Phase 4+ stub:
3165
+ * today the resolver materialises every winning candidate regardless
3166
+ * of confidence, so this template is unreachable; documented for
3167
+ * forward compatibility.
3168
+ */
3169
+ messageBelowFloor: "Detection `{{loserRaw}}` (offset {{loserRange}}, confidence {{confidence}}) fell below the configured threshold {{threshold}} and was dropped."
3170
+ };
3171
+
3172
+ // plugins/core/analyzers/signal-collision/index.ts
3173
+ var ID22 = "signal-collision";
3174
+ var signalCollisionAnalyzer = {
3175
+ id: ID22,
3176
+ pluginId: CORE_PLUGIN_ID,
3177
+ kind: "analyzer",
3178
+ version: "1.0.0",
3179
+ description: "Reports when two extractors fight over the same span of body text, or when a candidate link is dropped (extractor disabled, confidence too low) before it reaches the graph.",
3180
+ mode: "deterministic",
3181
+ evaluate(ctx) {
3182
+ const signals = ctx.signals;
3183
+ if (!signals || signals.length === 0) return [];
3184
+ const issues = [];
3185
+ for (const signal of signals) {
3186
+ const issue = makeIssue(signal);
3187
+ if (issue) issues.push(issue);
3188
+ }
3189
+ return issues;
3190
+ }
3191
+ };
3192
+ function makeIssue(signal) {
3193
+ const resolution = signal.resolution;
3194
+ if (!resolution || resolution.outcome !== "rejected") return null;
3195
+ if (resolution.rejectedBy) {
3196
+ const winner = resolution.rejectedBy;
3197
+ const winnerCandidate = signal.candidates[resolution.winnerIndex ?? 0];
3198
+ const loserRange = signal.range ? `${signal.range.start}-${signal.range.end}` : "unknown";
3199
+ const winnerRange = `${winner.range.start}-${winner.range.end}`;
3200
+ return {
3201
+ analyzerId: ID22,
3202
+ severity: "warn",
3203
+ nodeIds: [signal.source],
3204
+ message: tx(SIGNAL_COLLISION_TEXTS.message, {
3205
+ loserExtractor: winnerCandidate.extractorId,
3206
+ loserRaw: signal.raw,
3207
+ loserRange,
3208
+ winnerExtractor: winner.extractorId,
3209
+ winnerRange,
3210
+ reason: winner.reason
3211
+ }),
3212
+ data: {
3213
+ loser: {
3214
+ extractorId: winnerCandidate.extractorId,
3215
+ raw: signal.raw,
3216
+ range: signal.range ?? null,
3217
+ candidate: {
3218
+ kind: winnerCandidate.kind,
3219
+ target: winnerCandidate.target,
3220
+ confidence: winnerCandidate.confidence
3221
+ }
3222
+ },
3223
+ winner: {
3224
+ extractorId: winner.extractorId,
3225
+ range: winner.range
3226
+ },
3227
+ reason: winner.reason
3228
+ }
3229
+ };
3230
+ }
3231
+ if (resolution.extractorDisabled) {
3232
+ const loserRange = signal.range ? `${signal.range.start}-${signal.range.end}` : "unknown";
3233
+ return {
3234
+ analyzerId: ID22,
3235
+ severity: "warn",
3236
+ nodeIds: [signal.source],
3237
+ message: tx(SIGNAL_COLLISION_TEXTS.messageExtractorDisabled, {
3238
+ extractorId: resolution.extractorDisabled.extractorId,
3239
+ loserRaw: signal.raw,
3240
+ loserRange
3241
+ }),
3242
+ data: {
3243
+ extractorDisabled: resolution.extractorDisabled,
3244
+ raw: signal.raw,
3245
+ range: signal.range ?? null
3246
+ }
3247
+ };
3248
+ }
3249
+ if (resolution.belowFloor) {
3250
+ const loserRange = signal.range ? `${signal.range.start}-${signal.range.end}` : "unknown";
3251
+ const topCandidate = signal.candidates[0];
3252
+ return {
3253
+ analyzerId: ID22,
3254
+ severity: "warn",
3255
+ nodeIds: [signal.source],
3256
+ message: tx(SIGNAL_COLLISION_TEXTS.messageBelowFloor, {
3257
+ loserRaw: signal.raw,
3258
+ loserRange,
3259
+ confidence: topCandidate.confidence,
3260
+ threshold: resolution.belowFloor.threshold
3261
+ }),
3262
+ data: {
3263
+ belowFloor: resolution.belowFloor,
3264
+ raw: signal.raw,
3265
+ range: signal.range ?? null
3266
+ }
3267
+ };
3268
+ }
3269
+ return null;
3270
+ }
3271
+
3272
+ // plugins/core/analyzers/trigger-collision/text.ts
3273
+ var TRIGGER_COLLISION_TEXTS = {
3274
+ /**
3275
+ * Top-level message when `analyzeTriggerBucket` accumulated exactly one
3276
+ * cause part. Used for the advertiser-ambiguous-only, invocation-
3277
+ * ambiguous-only, and cross-kind-only branches.
3278
+ */
3279
+ messageOnePart: 'Trigger "{{normalized}}" has {{part}}.',
3280
+ /**
3281
+ * Top-level message when `analyzeTriggerBucket` accumulated two cause
3282
+ * parts (advertiser-ambiguous AND invocation-ambiguous fire together).
3283
+ * The joiner lives inside the template so future locales can adapt it
3284
+ * (e.g. `'; y '` in Spanish) without touching the rule code.
3285
+ */
3286
+ messageTwoParts: 'Trigger "{{normalized}}" has {{first}}; and {{second}}.',
3287
+ /** `<n> nodes advertise it: <list>` part, fires on the advertiser-ambiguous branch. */
3288
+ partAdvertisers: "{{count}} nodes advertise it: {{paths}}",
3289
+ /** `<n> distinct invocation forms: <list>` part, fires on the invocation-ambiguous branch. */
3290
+ partInvocations: "{{count}} distinct invocation forms: {{forms}}",
3291
+ /** Singular cross-kind cause: `non-canonical invocation <form> against advertiser <path>`. */
3292
+ partNonCanonicalSingular: "non-canonical invocation {{forms}} against advertiser {{advertiser}}",
3293
+ /** Plural cross-kind cause: `non-canonical invocations <forms> against advertiser <path>`. */
3294
+ partNonCanonicalPlural: "non-canonical invocations {{forms}} against advertiser {{advertiser}}"
3295
+ };
3296
+
3297
+ // plugins/core/analyzers/trigger-collision/index.ts
3298
+ var ID23 = "trigger-collision";
3299
+ var ADVERTISING_KINDS = /* @__PURE__ */ new Set([
3300
+ "command",
3301
+ "skill",
3302
+ "agent"
3303
+ ]);
3304
+ var triggerCollisionAnalyzer = {
3272
3305
  id: ID23,
3273
3306
  pluginId: CORE_PLUGIN_ID,
3274
3307
  kind: "analyzer",
3275
- version: "1.0.0",
3276
- description: "Detects and flags nodes or links violating the project schemas.",
3277
3308
  mode: "deterministic",
3278
- ui: {
3279
- // Corner badge on the graph card; surfaces when the node body /
3280
- // frontmatter fails schema validation (parse error, missing
3281
- // `name`/`description`, malformed YAML, etc.). Same visual
3282
- // chassis as `core/broken-ref`, danger severity.
3283
- alert: {
3284
- slot: "graph.node.alert",
3285
- icon: "fa-solid fa-triangle-exclamation",
3286
- emitWhenEmpty: false
3287
- },
3288
- // Footer chip that mirrors the corner alert with the actual
3289
- // count so the operator can scan the cards and prioritise.
3290
- // Outlined (vs the filled corner alert) per the broken-ref
3291
- // pattern: two beats of the same signal.
3292
- chip: {
3293
- slot: "card.footer.right",
3294
- icon: "fa-regular fa-triangle-exclamation",
3295
- emitWhenEmpty: false,
3296
- priority: 35
3297
- }
3298
- },
3309
+ version: "1.0.0",
3310
+ description: "Flags two or more nodes that claim the same `/command` or `@agent` name.",
3311
+ // Two claim-collection passes (advertisement + invocation) feeding
3312
+ // the bucket map. Per-bucket analysis lives in `analyzeTriggerBucket`.
3313
+ // eslint-disable-next-line complexity
3299
3314
  evaluate(ctx) {
3300
- const validators = loadSchemaValidators();
3301
- const findings = [];
3302
- const perNode = /* @__PURE__ */ new Map();
3315
+ const buckets = /* @__PURE__ */ new Map();
3316
+ const push = (key, claim) => {
3317
+ const bucket = buckets.get(key) ?? [];
3318
+ bucket.push(claim);
3319
+ buckets.set(key, bucket);
3320
+ };
3303
3321
  for (const node of ctx.nodes) {
3304
- const before = findings.length;
3305
- collectNodeFindings(validators, node, findings);
3306
- collectFrontmatterBaseFindings(node, findings);
3307
- if (findings.length > before) {
3308
- perNode.set(node.path, (perNode.get(node.path) ?? 0) + (findings.length - before));
3309
- }
3322
+ if (!ADVERTISING_KINDS.has(node.kind)) continue;
3323
+ const raw = node.frontmatter?.["name"];
3324
+ if (typeof raw !== "string" || raw.length === 0) continue;
3325
+ const normalized = `/${normalizeTrigger(raw)}`;
3326
+ if (normalized === "/") continue;
3327
+ push(normalized, {
3328
+ kind: "advertiser",
3329
+ token: node.path,
3330
+ nodeId: node.path,
3331
+ canonicalForm: `/${raw}`
3332
+ });
3310
3333
  }
3311
3334
  for (const link of ctx.links) {
3312
- collectLinkFindings(validators, link, findings);
3313
- }
3314
- for (const [nodePath, count] of perNode) {
3315
- const tooltip = count === 1 ? VALIDATE_ALL_TEXTS.alertTooltipSingle : tx(VALIDATE_ALL_TEXTS.alertTooltipMany, { count });
3316
- const capped = Math.min(count, 99);
3317
- ctx.emitContribution(nodePath, "alert", {
3318
- icon: "fa-solid fa-triangle-exclamation",
3319
- severity: "danger",
3320
- tooltip
3321
- });
3322
- ctx.emitContribution(nodePath, "chip", {
3323
- value: capped,
3324
- severity: "danger",
3325
- tooltip
3335
+ const normalized = link.trigger?.normalizedTrigger;
3336
+ if (!normalized) continue;
3337
+ push(normalized, {
3338
+ kind: "invocation",
3339
+ token: link.target,
3340
+ nodeId: link.source
3326
3341
  });
3327
3342
  }
3328
- return findings;
3343
+ const issues = [];
3344
+ for (const [normalized, claims] of buckets) {
3345
+ const issue = analyzeTriggerBucket(normalized, claims);
3346
+ if (issue) issues.push(issue);
3347
+ }
3348
+ return issues;
3329
3349
  }
3330
3350
  };
3331
- function collectNodeFindings(v, node, out) {
3332
- const result = v.validate("node", toNodeForSchema(node));
3333
- if (result.ok) return;
3334
- out.push({
3335
- analyzerId: ID23,
3336
- severity: "error",
3337
- nodeIds: [node.path],
3338
- message: tx(VALIDATE_ALL_TEXTS.nodeFailure, {
3339
- path: node.path,
3340
- errors: result.errors
3341
- }),
3342
- data: { target: "node", path: node.path }
3343
- });
3344
- }
3345
- function collectFrontmatterBaseFindings(node, out) {
3346
- if (node.provider === "markdown") return;
3347
- if (node.bytes.frontmatter === 0) return;
3348
- const fm = node.frontmatter ?? {};
3349
- const missing = [];
3350
- if (isMissingStringField(fm, "name")) missing.push("name");
3351
- if (isMissingStringField(fm, "description")) missing.push("description");
3352
- if (missing.length === 0) return;
3353
- out.push({
3354
- analyzerId: ID23,
3355
- // `warn` (not `error`) so the default `sm scan` exit code stays
3356
- // 0 even when nodes are missing frontmatter base fields. Strict
3357
- // mode (`sm scan --strict`) still escalates to exit 1. Matches
3358
- // the `frontmatter-invalid` severity policy of the orchestrator.
3359
- severity: "warn",
3360
- nodeIds: [node.path],
3361
- message: tx(VALIDATE_ALL_TEXTS.frontmatterBaseFailure, {
3362
- path: node.path,
3363
- missing: missing.join(", ")
3364
- }),
3365
- data: { target: "frontmatter", path: node.path, missing }
3351
+ function analyzeTriggerBucket(normalized, claims) {
3352
+ const advertiserPaths = [
3353
+ ...new Set(claims.filter((c) => c.kind === "advertiser").map((c) => c.token))
3354
+ ].sort();
3355
+ const invocationTargets = [
3356
+ ...new Set(claims.filter((c) => c.kind === "invocation").map((c) => c.token))
3357
+ ].sort();
3358
+ const advertisers = claims.filter(
3359
+ (c) => c.kind === "advertiser"
3360
+ );
3361
+ const advertiserAmbiguous = advertiserPaths.length >= 2;
3362
+ const invocationAmbiguous = invocationTargets.length >= 2;
3363
+ const canonicalForms = new Set(advertisers.map((a) => a.canonicalForm));
3364
+ const nonCanonicalInvocations = invocationTargets.filter((t) => !canonicalForms.has(t));
3365
+ const crossKindAmbiguous = advertiserPaths.length === 1 && nonCanonicalInvocations.length >= 1;
3366
+ if (!advertiserAmbiguous && !invocationAmbiguous && !crossKindAmbiguous) {
3367
+ return null;
3368
+ }
3369
+ const nodeIds = [...new Set(claims.map((c) => c.nodeId))].sort();
3370
+ const parts = [];
3371
+ if (advertiserAmbiguous) {
3372
+ parts.push(
3373
+ tx(TRIGGER_COLLISION_TEXTS.partAdvertisers, {
3374
+ count: advertiserPaths.length,
3375
+ paths: advertiserPaths.join(", ")
3376
+ })
3377
+ );
3378
+ }
3379
+ if (invocationAmbiguous) {
3380
+ parts.push(
3381
+ tx(TRIGGER_COLLISION_TEXTS.partInvocations, {
3382
+ count: invocationTargets.length,
3383
+ forms: invocationTargets.join(", ")
3384
+ })
3385
+ );
3386
+ } else if (crossKindAmbiguous) {
3387
+ const template = nonCanonicalInvocations.length > 1 ? TRIGGER_COLLISION_TEXTS.partNonCanonicalPlural : TRIGGER_COLLISION_TEXTS.partNonCanonicalSingular;
3388
+ parts.push(
3389
+ tx(template, {
3390
+ forms: nonCanonicalInvocations.join(", "),
3391
+ advertiser: advertiserPaths[0]
3392
+ })
3393
+ );
3394
+ }
3395
+ const message = parts.length === 2 ? tx(TRIGGER_COLLISION_TEXTS.messageTwoParts, {
3396
+ normalized,
3397
+ first: parts[0],
3398
+ second: parts[1]
3399
+ }) : tx(TRIGGER_COLLISION_TEXTS.messageOnePart, {
3400
+ normalized,
3401
+ part: parts[0]
3366
3402
  });
3367
- }
3368
- function isMissingStringField(fm, field) {
3369
- const v = fm[field];
3370
- return typeof v !== "string" || v.length === 0;
3371
- }
3372
- function collectLinkFindings(v, link, out) {
3373
- const result = v.validate("link", toLinkForSchema(link));
3374
- if (result.ok) return;
3375
- out.push({
3403
+ return {
3376
3404
  analyzerId: ID23,
3377
3405
  severity: "error",
3378
- nodeIds: [link.source],
3379
- message: tx(VALIDATE_ALL_TEXTS.linkFailure, {
3380
- source: link.source,
3381
- target: link.target,
3382
- errors: result.errors
3383
- }),
3384
- data: { target: "link", source: link.source, to: link.target }
3385
- });
3386
- }
3387
- function toNodeForSchema(node) {
3388
- return {
3389
- path: node.path,
3390
- kind: node.kind,
3391
- provider: node.provider,
3392
- bodyHash: node.bodyHash,
3393
- frontmatterHash: node.frontmatterHash,
3394
- bytes: node.bytes,
3395
- tokens: node.tokens ?? void 0,
3396
- linksOutCount: node.linksOutCount,
3397
- linksInCount: node.linksInCount,
3398
- externalRefsCount: node.externalRefsCount,
3399
- frontmatter: node.frontmatter ?? {},
3400
- sidecar: node.sidecar ?? void 0
3401
- };
3402
- }
3403
- function toLinkForSchema(link) {
3404
- return {
3405
- source: link.source,
3406
- target: link.target,
3407
- kind: link.kind,
3408
- confidence: link.confidence,
3409
- sources: link.sources,
3410
- trigger: link.trigger ?? void 0,
3411
- location: link.location ?? void 0,
3412
- raw: link.raw ?? void 0
3406
+ nodeIds,
3407
+ message,
3408
+ data: {
3409
+ normalizedTrigger: normalized,
3410
+ invocationTargets,
3411
+ advertiserPaths
3412
+ }
3413
3413
  };
3414
3414
  }
3415
3415
 
@@ -3449,7 +3449,7 @@ var asciiFormatter = {
3449
3449
  kind: "formatter",
3450
3450
  formatId: ID24,
3451
3451
  version: "1.0.0",
3452
- description: "Renders the scan as plain text, grouped by kind, arrows, and issues. Used by `sm scan --format=ascii`.",
3452
+ description: "Renders the scan as plain text in three sections: nodes (grouped by kind), arrows, and issues. Used by `sm scan --format ascii`.",
3453
3453
  // ASCII tree formatter, header + per-kind sections + per-issue
3454
3454
  // section. Each section iterates and renders; splitting per section
3455
3455
  // would multiply the for-loop boilerplate.
@@ -3548,7 +3548,7 @@ var jsonFormatter = {
3548
3548
  pluginId: CORE_PLUGIN_ID,
3549
3549
  kind: "formatter",
3550
3550
  version: "1.0.0",
3551
- description: "Renders the persisted scan as JSON (conforms to `scan-result.schema.json` when the full ScanResult is available). Used by `sm graph --format json` and `GET /api/graph?format=json`.",
3551
+ description: "Renders the persisted scan as JSON (conforms to `scan-result.schema.json`). Used by `sm graph --format json` and `GET /api/graph?format=json`.",
3552
3552
  formatId: ID25,
3553
3553
  format(ctx) {
3554
3554
  if (ctx.scanResult !== void 0) {
@@ -3687,14 +3687,14 @@ function resolveSpecRoot2() {
3687
3687
  }
3688
3688
  }
3689
3689
 
3690
- // plugins/core/actions/bump/index.ts
3691
- var ID26 = "bump";
3692
- var bumpAction = {
3690
+ // plugins/core/actions/node-bump/index.ts
3691
+ var ID26 = "node-bump";
3692
+ var nodeBumpAction = {
3693
3693
  id: ID26,
3694
3694
  pluginId: CORE_PLUGIN_ID,
3695
3695
  kind: "action",
3696
3696
  version: "1.0.0",
3697
- description: "Marks a node as updated: bumps version, refreshes sidecar hashes, records the timestamp.",
3697
+ description: "Marks a node as updated: bumps `annotations.version`, refreshes sidecar hashes, and records the timestamp.",
3698
3698
  mode: "deterministic",
3699
3699
  // The runtime contract uses generic <TInput, TReport>; bump narrows
3700
3700
  // both. The cast is the standard pattern for built-ins that want
@@ -3748,14 +3748,14 @@ function pickCurrentVersion(overlay) {
3748
3748
  return typeof v === "number" && Number.isFinite(v) ? v : 0;
3749
3749
  }
3750
3750
 
3751
- // plugins/core/actions/mark-superseded/index.ts
3752
- var ID27 = "mark-superseded";
3753
- var markSupersededAction = {
3751
+ // plugins/core/actions/node-supersede/index.ts
3752
+ var ID27 = "node-supersede";
3753
+ var nodeSupersedeAction = {
3754
3754
  id: ID27,
3755
3755
  pluginId: CORE_PLUGIN_ID,
3756
3756
  kind: "action",
3757
3757
  version: "0.0.0",
3758
- description: "Declares the current node as superseded by another (writes `supersededBy` to the sidecar). Paired with the `core/superseded` analyzer.",
3758
+ description: "Declares the current node as superseded by another (writes `supersededBy` to the sidecar).",
3759
3759
  mode: "deterministic",
3760
3760
  invoke(_input, _ctx) {
3761
3761
  const report = { ok: true, noop: true };
@@ -3861,7 +3861,7 @@ var UPDATE_CHECK_TEXTS = {
3861
3861
  // package.json
3862
3862
  var package_default = {
3863
3863
  name: "@skill-map/cli",
3864
- version: "0.38.0",
3864
+ version: "0.39.0",
3865
3865
  description: "skill-map reference implementation \u2014 kernel + CLI + adapters.",
3866
3866
  license: "MIT",
3867
3867
  type: "module",
@@ -4279,7 +4279,7 @@ var updateCheckHook = {
4279
4279
  pluginId: CORE_PLUGIN_ID,
4280
4280
  kind: "hook",
4281
4281
  version: "1.0.0",
4282
- description: "Checks daily for a newer skill-map version on npm. Shows an `update available` banner when one is found.",
4282
+ description: "Checks daily for a newer `skill-map` version on npm. Shows an `update available` banner when one is found.",
4283
4283
  triggers: ["boot"],
4284
4284
  async on(ctx) {
4285
4285
  const payload = ctx.event.data ?? {};
@@ -4296,7 +4296,7 @@ var updateCheckHook = {
4296
4296
  // plugins/built-ins.ts
4297
4297
  var claudeProvider2 = { ...claudeProvider, pluginId: "claude" };
4298
4298
  var atDirectiveExtractor2 = { ...atDirectiveExtractor, pluginId: "claude" };
4299
- var slashExtractor2 = { ...slashExtractor, pluginId: "claude" };
4299
+ var slashCommandExtractor2 = { ...slashCommandExtractor, pluginId: "claude" };
4300
4300
  var antigravityProvider2 = { ...antigravityProvider, pluginId: "antigravity" };
4301
4301
  var openaiProvider2 = { ...openaiProvider, pluginId: "openai" };
4302
4302
  var agentSkillsProvider2 = { ...agentSkillsProvider, pluginId: "agent-skills" };
@@ -4305,43 +4305,43 @@ var annotationsExtractor2 = { ...annotationsExtractor, pluginId: "core" };
4305
4305
  var externalUrlCounterExtractor2 = { ...externalUrlCounterExtractor, pluginId: "core" };
4306
4306
  var markdownLinkExtractor2 = { ...markdownLinkExtractor, pluginId: "core" };
4307
4307
  var mcpToolsExtractor2 = { ...mcpToolsExtractor, pluginId: "core" };
4308
- var toolsCountExtractor2 = { ...toolsCountExtractor, pluginId: "core" };
4308
+ var toolsCounterExtractor2 = { ...toolsCounterExtractor, pluginId: "core" };
4309
+ var annotationFieldUnknownAnalyzer2 = { ...annotationFieldUnknownAnalyzer, pluginId: "core" };
4309
4310
  var annotationOrphanAnalyzer2 = { ...annotationOrphanAnalyzer, pluginId: "core" };
4310
4311
  var annotationStaleAnalyzer2 = { ...annotationStaleAnalyzer, pluginId: "core" };
4311
- var brokenRefAnalyzer2 = { ...brokenRefAnalyzer, pluginId: "core" };
4312
4312
  var contributionOrphanAnalyzer2 = { ...contributionOrphanAnalyzer, pluginId: "core" };
4313
- var jobOrphanFileAnalyzer2 = { ...jobOrphanFileAnalyzer, pluginId: "core" };
4313
+ var jobFileOrphanAnalyzer2 = { ...jobFileOrphanAnalyzer, pluginId: "core" };
4314
4314
  var linkConflictAnalyzer2 = { ...linkConflictAnalyzer, pluginId: "core" };
4315
- var linkCountsAnalyzer2 = { ...linkCountsAnalyzer, pluginId: "core" };
4316
- var redundantTargetReferenceAnalyzer2 = { ...redundantTargetReferenceAnalyzer, pluginId: "core" };
4317
- var reservedNameAnalyzer2 = { ...reservedNameAnalyzer, pluginId: "core" };
4318
- var selfLoopAnalyzer2 = { ...selfLoopAnalyzer, pluginId: "core" };
4315
+ var linkCounterAnalyzer2 = { ...linkCounterAnalyzer, pluginId: "core" };
4316
+ var linkSelfLoopAnalyzer2 = { ...linkSelfLoopAnalyzer, pluginId: "core" };
4317
+ var nameReservedAnalyzer2 = { ...nameReservedAnalyzer, pluginId: "core" };
4318
+ var nodeStabilityAnalyzer2 = { ...nodeStabilityAnalyzer, pluginId: "core" };
4319
+ var nodeSupersededAnalyzer2 = { ...nodeSupersededAnalyzer, pluginId: "core" };
4320
+ var referenceBrokenAnalyzer2 = { ...referenceBrokenAnalyzer, pluginId: "core" };
4321
+ var referenceRedundantAnalyzer2 = { ...referenceRedundantAnalyzer, pluginId: "core" };
4322
+ var schemaViolationAnalyzer2 = { ...schemaViolationAnalyzer, pluginId: "core" };
4319
4323
  var signalCollisionAnalyzer2 = { ...signalCollisionAnalyzer, pluginId: "core" };
4320
- var stabilityAnalyzer2 = { ...stabilityAnalyzer, pluginId: "core" };
4321
- var supersededAnalyzer2 = { ...supersededAnalyzer, pluginId: "core" };
4322
4324
  var triggerCollisionAnalyzer2 = { ...triggerCollisionAnalyzer, pluginId: "core" };
4323
- var unknownFieldAnalyzer2 = { ...unknownFieldAnalyzer, pluginId: "core" };
4324
- var validateAllAnalyzer2 = { ...validateAllAnalyzer, pluginId: "core" };
4325
4325
  var asciiFormatter2 = { ...asciiFormatter, pluginId: "core" };
4326
4326
  var jsonFormatter2 = { ...jsonFormatter, pluginId: "core" };
4327
- var bumpAction2 = { ...bumpAction, pluginId: "core" };
4328
- var markSupersededAction2 = { ...markSupersededAction, pluginId: "core" };
4327
+ var nodeBumpAction2 = { ...nodeBumpAction, pluginId: "core" };
4328
+ var nodeSupersedeAction2 = { ...nodeSupersedeAction, pluginId: "core" };
4329
4329
  var updateCheckHook2 = { ...updateCheckHook, pluginId: "core" };
4330
4330
  var builtInBundles = [
4331
4331
  {
4332
4332
  id: "claude",
4333
4333
  granularity: "bundle",
4334
- description: "Claude Code platform integration. Classifies files under `.claude/{agents,commands,skills}` and parses Claude-flavored frontmatter.",
4334
+ description: "Claude Code platform integration. Classifies files under `.claude/{agents,commands,skills}` and detects Claude-flavored slash commands and at-directives in their bodies.",
4335
4335
  extensions: [
4336
4336
  claudeProvider2,
4337
4337
  atDirectiveExtractor2,
4338
- slashExtractor2
4338
+ slashCommandExtractor2
4339
4339
  ]
4340
4340
  },
4341
4341
  {
4342
4342
  id: "antigravity",
4343
4343
  granularity: "bundle",
4344
- description: "Google Antigravity CLI platform integration (released 2026-05-19, replaces the retired Gemini CLI). Antigravity adopted the open-standard `.agents/` layout, so skills are classified by the neutral `agent-skills` Provider; this bundle contributes lens identity + a reserved-name seed catalog for future Antigravity built-in invocables.",
4344
+ description: "Google Antigravity CLI platform integration (replaces the retired Gemini CLI). Antigravity adopted the open-standard `.agents/` layout, so skills are classified by the neutral `agent-skills` provider; this plugin contributes the Antigravity runtime identity and a seed list of reserved built-in names.",
4345
4345
  extensions: [
4346
4346
  antigravityProvider2
4347
4347
  ]
@@ -4349,7 +4349,7 @@ var builtInBundles = [
4349
4349
  {
4350
4350
  id: "openai",
4351
4351
  granularity: "bundle",
4352
- description: "OpenAI Codex CLI platform integration. Classifies TOML sub-agent definitions under `.codex/agents/*.toml` and (future) walks the hierarchical AGENTS.md cascade. Provider for the active-lens `openai` runtime.",
4352
+ description: "OpenAI Codex CLI platform integration. Classifies TOML sub-agent definitions under `.codex/agents/*.toml`.",
4353
4353
  extensions: [
4354
4354
  openaiProvider2
4355
4355
  ]
@@ -4357,7 +4357,7 @@ var builtInBundles = [
4357
4357
  {
4358
4358
  id: "agent-skills",
4359
4359
  granularity: "bundle",
4360
- description: "Agent Skills open standard. Vendor-neutral path `.agents/skills/<name>/SKILL.md` (Anthropic, OpenAI, Google). See agentskills.io.",
4360
+ description: "Open-standard Agent Skills layout. Classifies skills under the vendor-neutral path `.agents/skills/<name>/SKILL.md` (adopted by Anthropic, OpenAI, Google). See agentskills.io.",
4361
4361
  extensions: [
4362
4362
  agentSkillsProvider2
4363
4363
  ]
@@ -4365,34 +4365,34 @@ var builtInBundles = [
4365
4365
  {
4366
4366
  id: "core",
4367
4367
  granularity: "extension",
4368
- description: "Core extensions shared across providers: extractors, analyzers, formatters, the bump action, and the universal `.md` fallback Provider.",
4368
+ description: "Core extensions shared across providers: parsers, extractors, analyzers, actions, hooks, formatters, and the universal `.md` fallback provider.",
4369
4369
  extensions: [
4370
4370
  coreMarkdownProvider2,
4371
4371
  annotationsExtractor2,
4372
4372
  externalUrlCounterExtractor2,
4373
4373
  markdownLinkExtractor2,
4374
4374
  mcpToolsExtractor2,
4375
- toolsCountExtractor2,
4375
+ toolsCounterExtractor2,
4376
+ annotationFieldUnknownAnalyzer2,
4376
4377
  annotationOrphanAnalyzer2,
4377
4378
  annotationStaleAnalyzer2,
4378
- brokenRefAnalyzer2,
4379
4379
  contributionOrphanAnalyzer2,
4380
- jobOrphanFileAnalyzer2,
4380
+ jobFileOrphanAnalyzer2,
4381
4381
  linkConflictAnalyzer2,
4382
- linkCountsAnalyzer2,
4383
- redundantTargetReferenceAnalyzer2,
4384
- reservedNameAnalyzer2,
4385
- selfLoopAnalyzer2,
4382
+ linkCounterAnalyzer2,
4383
+ linkSelfLoopAnalyzer2,
4384
+ nameReservedAnalyzer2,
4385
+ nodeStabilityAnalyzer2,
4386
+ nodeSupersededAnalyzer2,
4387
+ referenceBrokenAnalyzer2,
4388
+ referenceRedundantAnalyzer2,
4389
+ schemaViolationAnalyzer2,
4386
4390
  signalCollisionAnalyzer2,
4387
- stabilityAnalyzer2,
4388
- supersededAnalyzer2,
4389
4391
  triggerCollisionAnalyzer2,
4390
- unknownFieldAnalyzer2,
4391
- validateAllAnalyzer2,
4392
4392
  asciiFormatter2,
4393
4393
  jsonFormatter2,
4394
- bumpAction2,
4395
- markSupersededAction2,
4394
+ nodeBumpAction2,
4395
+ nodeSupersedeAction2,
4396
4396
  updateCheckHook2
4397
4397
  ]
4398
4398
  }
@@ -4519,21 +4519,42 @@ function localTimeFromIso(iso) {
4519
4519
  const ss = String(d.getSeconds()).padStart(2, "0");
4520
4520
  return `${hh}:${mm}:${ss}`;
4521
4521
  }
4522
- var defaultFormat = (record) => {
4522
+ function paintLevelPrefix(level, ansi) {
4523
+ const label = level.toUpperCase().padEnd(5);
4524
+ switch (level) {
4525
+ case "error":
4526
+ return `${ansi.red("\u2715")} ${ansi.red(label)}`;
4527
+ case "warn":
4528
+ return `${ansi.yellow("\u26A0")} ${ansi.yellow(label)}`;
4529
+ case "info":
4530
+ return `${ansi.cyan("\u2139")} ${ansi.cyan(label)}`;
4531
+ case "debug":
4532
+ case "trace":
4533
+ return `${ansi.dim("\xB7")} ${ansi.dim(label)}`;
4534
+ }
4535
+ }
4536
+ var defaultFormat = (record, ansi) => {
4523
4537
  const time = localTimeFromIso(record.timestamp);
4524
- const level = record.level.toUpperCase().padEnd(5);
4525
- const ctx = record.context && Object.keys(record.context).length > 0 ? ` | ${JSON.stringify(record.context)}` : "";
4526
- return `${time} | ${level} | ${record.message}${ctx}
4538
+ const prefix = paintLevelPrefix(record.level, ansi);
4539
+ const ctx = record.context && Object.keys(record.context).length > 0 ? ` ${ansi.dim("|")} ${ansi.dim(JSON.stringify(record.context))}` : "";
4540
+ return `${ansi.dim(time)} ${prefix} ${record.message}${ctx}
4527
4541
  `;
4528
4542
  };
4529
4543
  var Logger = class {
4530
4544
  #level;
4531
4545
  #stream;
4532
4546
  #format;
4547
+ #ansi;
4533
4548
  constructor(opts) {
4534
4549
  this.#level = opts.level;
4535
4550
  this.#stream = opts.stream;
4536
4551
  this.#format = opts.format ?? defaultFormat;
4552
+ const streamTty = opts.stream;
4553
+ this.#ansi = ansiFor({
4554
+ isTTY: streamTty.isTTY === true,
4555
+ noColorFlag: opts.noColorFlag === true,
4556
+ ...opts.env !== void 0 ? { env: opts.env } : {}
4557
+ });
4537
4558
  }
4538
4559
  setLevel(level) {
4539
4560
  this.#level = level;
@@ -4564,7 +4585,7 @@ var Logger = class {
4564
4585
  message,
4565
4586
  ...context !== void 0 ? { context } : {}
4566
4587
  };
4567
- this.#stream.write(this.#format(record));
4588
+ this.#stream.write(this.#format(record, this.#ansi));
4568
4589
  }
4569
4590
  };
4570
4591
  function resolveLogLevel(opts) {
@@ -7246,9 +7267,9 @@ async function sweepPerTupleContributions(trx, contributions, freshlyRunTuples)
7246
7267
  const bufferKeys = buildContributionsBufferKeys(contributions);
7247
7268
  const tuplesByPluginExt = groupFreshlyRunTuplesByPluginExt(freshlyRunTuples);
7248
7269
  for (const [pe, nodes] of tuplesByPluginExt) {
7249
- const sep7 = pe.indexOf("\0");
7250
- if (sep7 < 0) continue;
7251
- await deleteStaleTupleRows(trx, pe.slice(0, sep7), pe.slice(sep7 + 1), [...nodes], bufferKeys);
7270
+ const sep8 = pe.indexOf("\0");
7271
+ if (sep8 < 0) continue;
7272
+ await deleteStaleTupleRows(trx, pe.slice(0, sep8), pe.slice(sep8 + 1), [...nodes], bufferKeys);
7252
7273
  }
7253
7274
  }
7254
7275
  function buildContributionsBufferKeys(contributions) {
@@ -8406,12 +8427,12 @@ function planOne(node, options) {
8406
8427
  };
8407
8428
  }
8408
8429
  function invokeBumpFor(node, absPath, force) {
8409
- if (!bumpAction.invoke) {
8430
+ if (!nodeBumpAction.invoke) {
8410
8431
  throw new Error("built-in bump action is missing its invoke()");
8411
8432
  }
8412
8433
  const input = {};
8413
8434
  if (force) input.force = true;
8414
- return bumpAction.invoke(input, {
8435
+ return nodeBumpAction.invoke(input, {
8415
8436
  node,
8416
8437
  nodeAbsolutePath: absPath,
8417
8438
  invoker: "cli",
@@ -8427,7 +8448,7 @@ var BumpCommand = class extends SmCommand {
8427
8448
  category: "Actions",
8428
8449
  description: "Bump a node's sidecar (`<basename>.sm`): increment annotations.version, refresh hashes, stamp audit.",
8429
8450
  details: `
8430
- Wraps the built-in deterministic \`core/bump\` Action. Single-node
8451
+ Wraps the built-in deterministic \`core/node-bump\` Action. Single-node
8431
8452
  mode bumps one path; \`--pending\` walks every node whose sidecar
8432
8453
  overlay reports drift and bumps them all.
8433
8454
 
@@ -9823,14 +9844,14 @@ var LOCKED_PLUGIN_IDS = /* @__PURE__ */ new Set([
9823
9844
  // unreachable from CLI / BFF / UI. Re-evaluate if a third-party ever
9824
9845
  // ships a competing supersession extractor.
9825
9846
  "core/annotations",
9826
- // `core/validate-all` validates every scanned Node against
9847
+ // `core/schema-violation` validates every scanned Node against
9827
9848
  // `node.schema.json` and every Link against `link.schema.json` (the
9828
9849
  // authoritative @skill-map/spec). Disabling it makes the system
9829
9850
  // persist non-conformant content silently, breaking the spec
9830
9851
  // invariant "what reaches the DB conforms to the spec". The check is
9831
9852
  // foundational, not advisory; lock it on so the guarantee holds
9832
9853
  // regardless of user / DB / settings hand-edits.
9833
- "core/validate-all",
9854
+ "core/schema-violation",
9834
9855
  // `core/ascii` is the only built-in Formatter today and the default
9835
9856
  // for `sm graph` (`--format ascii`). Disabling it breaks the verb
9836
9857
  // entirely (`composeFormatters` returns the empty list, the CLI
@@ -9866,7 +9887,8 @@ function isBuiltInExtensionEnabled(bundle, ext, resolveEnabled) {
9866
9887
  }
9867
9888
  function isBundleEntryEnabled(bundle, extId, resolveEnabled) {
9868
9889
  if (bundle.granularity === "bundle") {
9869
- return resolveEnabled(bundle.id);
9890
+ if (!resolveEnabled(bundle.id)) return false;
9891
+ return resolveEnabled(qualifiedExtensionId(bundle.id, extId));
9870
9892
  }
9871
9893
  return resolveEnabled(qualifiedExtensionId(bundle.id, extId));
9872
9894
  }
@@ -9879,7 +9901,10 @@ function buildGranularityMap(discovered) {
9879
9901
  }
9880
9902
  function isPluginExtensionEnabled(ext, granularityMap, resolveEnabled) {
9881
9903
  const granularity = granularityMap.get(ext.pluginId) ?? "bundle";
9882
- if (granularity === "bundle") return resolveEnabled(ext.pluginId);
9904
+ if (granularity === "bundle") {
9905
+ if (!resolveEnabled(ext.pluginId)) return false;
9906
+ return resolveEnabled(qualifiedExtensionId(ext.pluginId, ext.id));
9907
+ }
9883
9908
  return resolveEnabled(qualifiedExtensionId(ext.pluginId, ext.id));
9884
9909
  }
9885
9910
  async function buildEnabledResolver(ctx) {
@@ -10550,7 +10575,7 @@ var CheckCommand = class extends SmCommand {
10550
10575
  ["Print every current issue", "$0 check"],
10551
10576
  ["Machine-readable issue list", "$0 check --json"],
10552
10577
  ["Restrict to a single node", "$0 check -n .claude/agents/architect.md"],
10553
- ["Restrict to specific rules", "$0 check --analyzers core/broken-ref,core/validate-all"],
10578
+ ["Restrict to specific rules", "$0 check --analyzers core/reference-broken,core/schema-violation"],
10554
10579
  ["Opt in to probabilistic analyzers (stub until Step 10)", "$0 check --include-prob"],
10555
10580
  ["Use a non-default DB file", "$0 check --db /path/to/skill-map.db"]
10556
10581
  ]
@@ -14297,8 +14322,8 @@ function computePlannedHookContent(existing) {
14297
14322
  if (existing.includes(SKILL_MAP_MARKER)) {
14298
14323
  return { kind: "already-installed", content: existing };
14299
14324
  }
14300
- const sep7 = existing.endsWith("\n") ? "" : "\n";
14301
- return { kind: "chained", content: existing + sep7 + "\n" + SKILL_MAP_BLOCK };
14325
+ const sep8 = existing.endsWith("\n") ? "" : "\n";
14326
+ return { kind: "chained", content: existing + sep8 + "\n" + SKILL_MAP_BLOCK };
14302
14327
  }
14303
14328
  async function ensureExecutableBit(path) {
14304
14329
  const mode = (await stat2(path)).mode;
@@ -14673,7 +14698,8 @@ function recomputeLinkCounts(nodes, links) {
14673
14698
  for (const link of links) {
14674
14699
  const source = byPath3.get(link.source);
14675
14700
  if (source) source.linksOutCount += 1;
14676
- const target = byPath3.get(link.target);
14701
+ const targetKey = link.resolvedTarget ?? link.target;
14702
+ const target = byPath3.get(targetKey);
14677
14703
  if (target) target.linksInCount += 1;
14678
14704
  }
14679
14705
  }
@@ -16409,7 +16435,7 @@ var SCAN_RUNNER_TEXTS = {
16409
16435
  priorSchemaValidationFailed: "prior scan-result loaded from DB failed schema validation: {{errors}}. Run `sm db backup` then re-scan without --strict to rebuild from disk.",
16410
16436
  /**
16411
16437
  * Reference-paths walker hit `REFERENCE_WALK_MAX_FILES` and stopped
16412
- * early. The set may be incomplete for link validation; `core/broken-ref`
16438
+ * early. The set may be incomplete for link validation; `core/reference-broken`
16413
16439
  * still works against whatever made it in.
16414
16440
  */
16415
16441
  referenceWalkTruncated: "scan.referencePaths: walker truncated at the 50000-file safety cap. Some link targets may flag as broken even though they exist on disk. Trim the configured paths to dirs you actually need to validate against.",
@@ -18975,6 +19001,27 @@ var PLUGINS_TEXTS = {
18975
19001
  slotsListTipText: "Tip: full spec at spec/view-slots.md and spec/input-types.md."
18976
19002
  };
18977
19003
 
19004
+ // plugins/presentation-order.ts
19005
+ var BUILT_IN_BUNDLE_PRESENTATION_ORDER = [
19006
+ "core",
19007
+ "claude",
19008
+ "antigravity",
19009
+ "openai",
19010
+ "agent-skills"
19011
+ ];
19012
+ function sortBundlesForPresentation(bundles) {
19013
+ const orderIndex = (id) => {
19014
+ const idx = BUILT_IN_BUNDLE_PRESENTATION_ORDER.indexOf(id);
19015
+ return idx >= 0 ? idx : BUILT_IN_BUNDLE_PRESENTATION_ORDER.length;
19016
+ };
19017
+ return [...bundles].sort((a, b) => {
19018
+ const ai = orderIndex(a.id);
19019
+ const bi = orderIndex(b.id);
19020
+ if (ai !== bi) return ai - bi;
19021
+ return a.id.localeCompare(b.id);
19022
+ });
19023
+ }
19024
+
18978
19025
  // cli/commands/plugins/shared.ts
18979
19026
  import { resolve as resolve32 } from "path";
18980
19027
  function resolveSearchPaths2(opts, cwd) {
@@ -19004,7 +19051,7 @@ async function loadAll(opts) {
19004
19051
  return loader.discoverAndLoadAll();
19005
19052
  }
19006
19053
  function builtInRows(resolveEnabled) {
19007
- return builtInBundles.map((bundle) => {
19054
+ return sortBundlesForPresentation(builtInBundles).map((bundle) => {
19008
19055
  const bundleEnabled = resolveEnabled(bundle.id);
19009
19056
  const extensions = bundle.extensions.map((ext) => extensionRowFromBuiltIn(ext, bundle, bundleEnabled, resolveEnabled));
19010
19057
  const manifestSummary = bundle.extensions.map((ext) => `${ext.kind}:${qualifiedExtensionId(bundle.id, ext.id)}@${ext.version}`).join(", ");
@@ -19019,11 +19066,12 @@ function builtInRows(resolveEnabled) {
19019
19066
  });
19020
19067
  }
19021
19068
  function extensionRowFromBuiltIn(ext, bundle, bundleEnabled, resolveEnabled) {
19069
+ const qualifiedEnabled = resolveEnabled(qualifiedExtensionId(bundle.id, ext.id));
19022
19070
  const row = {
19023
19071
  id: ext.id,
19024
19072
  kind: ext.kind,
19025
19073
  version: ext.version,
19026
- enabled: bundle.granularity === "bundle" ? bundleEnabled : resolveEnabled(qualifiedExtensionId(bundle.id, ext.id)),
19074
+ enabled: bundle.granularity === "bundle" ? bundleEnabled && qualifiedEnabled : qualifiedEnabled,
19027
19075
  description: ext.description ?? ""
19028
19076
  };
19029
19077
  if (ext.entry !== void 0) row.entry = ext.entry;
@@ -19139,10 +19187,10 @@ function pluginToListRow(p) {
19139
19187
  }
19140
19188
  function wrapNames(names, indent, maxWidth) {
19141
19189
  const out = [];
19142
- const sep7 = ", ";
19190
+ const sep8 = ", ";
19143
19191
  let current = "";
19144
19192
  for (const name of names) {
19145
- const candidate = current === "" ? name : `${current}${sep7}${name}`;
19193
+ const candidate = current === "" ? name : `${current}${sep8}${name}`;
19146
19194
  if (indent.length + candidate.length > maxWidth && current !== "") {
19147
19195
  out.push(`${current},`);
19148
19196
  current = name;
@@ -19710,10 +19758,13 @@ function extensionInstance(ext) {
19710
19758
  }
19711
19759
  function collectKnownKinds(plugins) {
19712
19760
  const known = /* @__PURE__ */ new Set();
19713
- forEachProviderInstance(plugins, ({ instance }) => {
19761
+ forEachProviderInstance(plugins, ({ pluginId, instance }) => {
19714
19762
  const map = instance["kinds"];
19715
19763
  if (map === null || typeof map !== "object") return;
19716
- for (const k of Object.keys(map)) known.add(k);
19764
+ for (const k of Object.keys(map)) {
19765
+ known.add(k);
19766
+ known.add(qualifiedExtensionId(pluginId, k));
19767
+ }
19717
19768
  });
19718
19769
  return known;
19719
19770
  }
@@ -19962,7 +20013,7 @@ var TogglePluginsBase = class extends SmCommand {
19962
20013
  * the plugin's `scan_contributions` rows immediately (matches the
19963
20014
  * BFF route, see `server/routes/plugins.ts:applyChangeToAdapter`).
19964
20015
  * `targets` carries either a bare bundle id (e.g. `claude`) or a
19965
- * qualified `<bundle>/<ext>` (e.g. `core/slash`); the split mirrors
20016
+ * qualified `<bundle>/<ext>` (e.g. `core/slash-command`); the split mirrors
19966
20017
  * how the catalog sweep groups rows.
19967
20018
  */
19968
20019
  async #persistTargets(targets, enabled) {
@@ -21919,6 +21970,18 @@ import { spawn as spawn2 } from "child_process";
21919
21970
  import { existsSync as existsSync28 } from "fs";
21920
21971
  import { Command as Command33, Option as Option31 } from "clipanion";
21921
21972
 
21973
+ // kernel/util/dev-mode.ts
21974
+ import { sep as sep6 } from "path";
21975
+ import { fileURLToPath as fileURLToPath5 } from "url";
21976
+ var SELF_PATH = fileURLToPath5(import.meta.url);
21977
+ var IS_DEV_BUILD = isDevBuildFromPath(SELF_PATH, sep6);
21978
+ function isDevBuildFromPath(filePath, separator = sep6) {
21979
+ return !filePath.includes(`${separator}node_modules${separator}`);
21980
+ }
21981
+ function isDevBuild() {
21982
+ return IS_DEV_BUILD;
21983
+ }
21984
+
21922
21985
  // cli/util/browser-launch.ts
21923
21986
  function validateBrowserUrl(url) {
21924
21987
  if (typeof url !== "string" || url.length === 0) return false;
@@ -22632,6 +22695,7 @@ function contentTypeFor(format) {
22632
22695
  import { existsSync as existsSync23 } from "fs";
22633
22696
  var FALLBACK_SCHEMA_VERSION = "1";
22634
22697
  function buildHealth(deps) {
22698
+ const dev = isDevBuild();
22635
22699
  return {
22636
22700
  ok: true,
22637
22701
  schemaVersion: FALLBACK_SCHEMA_VERSION,
@@ -22639,7 +22703,10 @@ function buildHealth(deps) {
22639
22703
  implVersion: VERSION,
22640
22704
  db: existsSync23(deps.dbPath) ? "present" : "missing",
22641
22705
  cwd: deps.cwd,
22642
- dbPath: deps.dbPath
22706
+ dbPath: deps.dbPath,
22707
+ // Only emit when truthy so a published install keeps the wire
22708
+ // shape lean and consumers branch on presence alone.
22709
+ ...dev ? { dev: true } : {}
22643
22710
  };
22644
22711
  }
22645
22712
  var cachedSpecVersion = null;
@@ -22764,13 +22831,13 @@ import { HTTPException as HTTPException6 } from "hono/http-exception";
22764
22831
  // server/node-body.ts
22765
22832
  import { constants as fsConstants2 } from "fs";
22766
22833
  import { open } from "fs/promises";
22767
- import { isAbsolute as isAbsolute10, resolve as resolvePath2, relative as relativePath, sep as sep6 } from "path";
22834
+ import { isAbsolute as isAbsolute10, resolve as resolvePath2, relative as relativePath, sep as sep7 } from "path";
22768
22835
  async function readNodeBody(cwd, relPath) {
22769
22836
  if (isAbsolute10(relPath)) return null;
22770
22837
  const absRoot = resolvePath2(cwd);
22771
22838
  const absFile = resolvePath2(absRoot, relPath);
22772
22839
  const rel = relativePath(absRoot, absFile);
22773
- if (rel.startsWith("..") || rel.startsWith(sep6) || rel.length === 0) {
22840
+ if (rel.startsWith("..") || rel.startsWith(sep7) || rel.length === 0) {
22774
22841
  return null;
22775
22842
  }
22776
22843
  let raw;
@@ -23210,7 +23277,7 @@ function listItems(deps, resolveEnabled) {
23210
23277
  ];
23211
23278
  }
23212
23279
  function buildBuiltInItems(resolveEnabled) {
23213
- return builtInBundles.map((bundle) => {
23280
+ return sortBundlesForPresentation(builtInBundles).map((bundle) => {
23214
23281
  const bundleEnabled = resolveEnabled(bundle.id);
23215
23282
  const bundleLocked = isPluginLocked(bundle.id);
23216
23283
  const extensions = bundle.extensions.map((ext) => {
@@ -24365,12 +24432,12 @@ async function loadNode(deps, nodePath) {
24365
24432
  return node;
24366
24433
  }
24367
24434
  function invokeBump2(node, absPath, body) {
24368
- if (!bumpAction.invoke) {
24435
+ if (!nodeBumpAction.invoke) {
24369
24436
  throw new HTTPException14(500, { message: SERVER_TEXTS.sidecarBumpInvokeMissing });
24370
24437
  }
24371
24438
  const input = {};
24372
24439
  if (body.force === true) input.force = true;
24373
- return bumpAction.invoke(input, {
24440
+ return nodeBumpAction.invoke(input, {
24374
24441
  node,
24375
24442
  nodeAbsolutePath: absPath,
24376
24443
  invoker: "ui",
@@ -25041,7 +25108,7 @@ function validateNoUi(noUi, uiDist) {
25041
25108
  // server/paths.ts
25042
25109
  import { existsSync as existsSync27, statSync as statSync10 } from "fs";
25043
25110
  import { dirname as dirname18, isAbsolute as isAbsolute11, join as join20, resolve as resolve37 } from "path";
25044
- import { fileURLToPath as fileURLToPath5 } from "url";
25111
+ import { fileURLToPath as fileURLToPath6 } from "url";
25045
25112
  var DEFAULT_UI_REL = join20("ui", "dist", "ui", "browser");
25046
25113
  var PACKAGE_UI_REL = "ui";
25047
25114
  var INDEX_HTML2 = "index.html";
@@ -25065,7 +25132,7 @@ function isUiBundleDir(path) {
25065
25132
  function resolvePackageBundledUi() {
25066
25133
  let here;
25067
25134
  try {
25068
- here = dirname18(fileURLToPath5(import.meta.url));
25135
+ here = dirname18(fileURLToPath6(import.meta.url));
25069
25136
  } catch {
25070
25137
  return null;
25071
25138
  }
@@ -25357,7 +25424,9 @@ var ESC2 = {
25357
25424
  /** 256-color violet (xterm 141). */
25358
25425
  violet: "\x1B[38;5;141m",
25359
25426
  /** 256-color green (xterm 42). */
25360
- green: "\x1B[38;5;42m"
25427
+ green: "\x1B[38;5;42m",
25428
+ /** 256-color yellow (xterm 214), matches `cli/util/ansi.ts:yellow`. */
25429
+ yellow: "\x1B[38;5;214m"
25361
25430
  };
25362
25431
  var LOGO_LINES = [
25363
25432
  " ____ _ _ _ _ __ __ ",
@@ -25377,7 +25446,8 @@ function renderBanner(input) {
25377
25446
  host: input.host,
25378
25447
  port: input.port,
25379
25448
  dbPath: input.dbPath,
25380
- openBrowser: input.openBrowser
25449
+ openBrowser: input.openBrowser,
25450
+ dev: input.dev === true
25381
25451
  });
25382
25452
  }
25383
25453
  return renderFiglet({
@@ -25387,7 +25457,8 @@ function renderBanner(input) {
25387
25457
  pathDisplay: formatCwdPath(input.cwd),
25388
25458
  browserLine,
25389
25459
  colorEnabled: input.colorEnabled,
25390
- referencePaths: input.referencePaths ?? []
25460
+ referencePaths: input.referencePaths ?? [],
25461
+ dev: input.dev === true
25391
25462
  });
25392
25463
  }
25393
25464
  function resolveColorEnabled(opts) {
@@ -25402,8 +25473,9 @@ function renderFlat(input) {
25402
25473
  const safeHost = sanitizeForTerminal(input.host);
25403
25474
  const safeDb = sanitizeForTerminal(input.dbPath);
25404
25475
  const url = `http://${safeHost}:${input.port}`;
25476
+ const devSuffix = input.dev ? " [dev]" : "";
25405
25477
  const linesOut = [];
25406
- linesOut.push(`sm serve: listening on ${url} (db=${safeDb})`);
25478
+ linesOut.push(`sm serve${devSuffix}: listening on ${url} (db=${safeDb})`);
25407
25479
  if (input.openBrowser) {
25408
25480
  linesOut.push(`sm serve: opening ${url}/ in your browser. Press Ctrl+C to stop.`);
25409
25481
  } else {
@@ -25430,12 +25502,16 @@ function renderFiglet(input) {
25430
25502
  greenUnderline,
25431
25503
  greenUnderlineClose,
25432
25504
  violetOpen,
25433
- violetClose
25505
+ violetClose,
25506
+ yellowOpen,
25507
+ yellowClose
25434
25508
  } = resolveAnsi(input.colorEnabled);
25435
25509
  const logoLines = LOGO_LINES.map((line) => `${violetOpen}${line}${violetClose}`);
25436
25510
  const versionText = `v${input.version}`;
25437
- const versionPad = Math.max(0, LOGO_WIDTH - versionText.length);
25438
- const versionLine = `${" ".repeat(versionPad)}${dimOpen}${versionText}${dimClose}`;
25511
+ const devText = "[dev]";
25512
+ const versionWidth = input.dev ? devText.length : versionText.length;
25513
+ const versionPad = Math.max(0, LOGO_WIDTH - versionWidth);
25514
+ const versionLine = input.dev ? `${" ".repeat(versionPad)}${yellowOpen}${devText}${yellowClose}` : `${" ".repeat(versionPad)}${dimOpen}${versionText}${dimClose}`;
25439
25515
  const lines = [];
25440
25516
  lines.push(...logoLines);
25441
25517
  lines.push("");
@@ -25467,7 +25543,9 @@ var EMPTY_ANSI = {
25467
25543
  greenUnderline: "",
25468
25544
  greenUnderlineClose: "",
25469
25545
  violetOpen: "",
25470
- violetClose: ""
25546
+ violetClose: "",
25547
+ yellowOpen: "",
25548
+ yellowClose: ""
25471
25549
  };
25472
25550
  var ENABLED_ANSI = {
25473
25551
  dimOpen: ESC2.dim,
@@ -25475,7 +25553,9 @@ var ENABLED_ANSI = {
25475
25553
  greenUnderline: `${ESC2.green}${ESC2.underline}`,
25476
25554
  greenUnderlineClose: ESC2.reset,
25477
25555
  violetOpen: ESC2.violet,
25478
- violetClose: ESC2.reset
25556
+ violetClose: ESC2.reset,
25557
+ yellowOpen: ESC2.yellow,
25558
+ yellowClose: ESC2.reset
25479
25559
  };
25480
25560
  function resolveAnsi(colorEnabled) {
25481
25561
  return colorEnabled ? ENABLED_ANSI : EMPTY_ANSI;
@@ -25696,7 +25776,8 @@ var ServeCommand = class extends SmCommand {
25696
25776
  openBrowser: validation.options.open,
25697
25777
  isTTY,
25698
25778
  colorEnabled,
25699
- referencePaths
25779
+ referencePaths,
25780
+ dev: isDevBuild()
25700
25781
  })
25701
25782
  );
25702
25783
  if (validation.options.open) {
@@ -26830,7 +26911,7 @@ var STUB_COMMANDS = [
26830
26911
  // cli/commands/tutorial.ts
26831
26912
  import { cpSync as cpSync2, existsSync as existsSync29, mkdirSync as mkdirSync6, rmSync as rmSync2, statSync as statSync11 } from "fs";
26832
26913
  import { dirname as dirname19, join as join21, resolve as resolve39 } from "path";
26833
- import { fileURLToPath as fileURLToPath6 } from "url";
26914
+ import { fileURLToPath as fileURLToPath7 } from "url";
26834
26915
  import { Command as Command37, Option as Option35 } from "clipanion";
26835
26916
 
26836
26917
  // cli/i18n/tutorial.texts.ts
@@ -27000,7 +27081,7 @@ function resolveSkillSourceDir(variant) {
27000
27081
  const cached = cachedSourceDirs.get(variant);
27001
27082
  if (cached !== void 0) return cached;
27002
27083
  const spec = VARIANT_SPECS[variant];
27003
- const here = dirname19(fileURLToPath6(import.meta.url));
27084
+ const here = dirname19(fileURLToPath7(import.meta.url));
27004
27085
  const candidates = [
27005
27086
  // dev: src/cli/commands/ → repo-root .claude/skills/<slug>/
27006
27087
  resolve39(here, "../../..", spec.sourceDir),
@@ -27048,6 +27129,7 @@ var VersionCommand = class extends SmCommand {
27048
27129
  const kernelVersion = VERSION;
27049
27130
  const specVersion = await resolveSpecVersion3();
27050
27131
  const dbSchema = await resolveDbSchemaVersion();
27132
+ const dev = isDevBuild();
27051
27133
  if (this.json) {
27052
27134
  const payload = {
27053
27135
  sm: VERSION,
@@ -27055,17 +27137,19 @@ var VersionCommand = class extends SmCommand {
27055
27137
  spec: specVersion,
27056
27138
  dbSchema
27057
27139
  };
27140
+ if (dev) payload["dev"] = true;
27058
27141
  this.printer.data(JSON.stringify(payload) + "\n");
27059
27142
  return ExitCode.Ok;
27060
27143
  }
27144
+ const ansi = this.ansiFor("stdout");
27145
+ const smValue = dev ? `${VERSION} ${ansi.yellow("[dev]")}` : VERSION;
27061
27146
  const lines = [
27062
- ["sm", VERSION],
27147
+ ["sm", smValue],
27063
27148
  ["kernel", kernelVersion],
27064
27149
  ["spec", specVersion],
27065
27150
  ["runtime", runtime],
27066
27151
  ["db-schema", dbSchema]
27067
27152
  ];
27068
- const ansi = this.ansiFor("stdout");
27069
27153
  const pad = Math.max(...lines.map(([k]) => k.length));
27070
27154
  for (const [k, v] of lines) {
27071
27155
  this.printer.data(