@skill-map/cli 0.38.0 → 0.40.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
@@ -441,8 +441,7 @@ var claudeProvider = {
441
441
  id: "claude",
442
442
  pluginId: CLAUDE_PLUGIN_ID,
443
443
  kind: "provider",
444
- version: "1.0.0",
445
- description: "Walks Claude Code scope conventions (.claude/{agents,commands,skills}).",
444
+ description: "Classifies files under `.claude/{agents,commands,skills}` as Claude Code agents, commands, and skills.",
446
445
  // Vendor provider: Claude Code only reads its own `.claude/` territory
447
446
  // and ignores `.codex/` / Antigravity layouts at runtime. Gating the
448
447
  // classifier behind the active lens prevents the walker from inventing
@@ -726,8 +725,7 @@ var atDirectiveExtractor = {
726
725
  id: ID,
727
726
  pluginId: CLAUDE_PLUGIN_ID,
728
727
  kind: "extractor",
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.",
728
+ 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
729
  scope: "body",
732
730
  precondition: { provider: ["claude"] },
733
731
  // eslint-disable-next-line complexity
@@ -808,15 +806,14 @@ function resolveSourceRelative(sourceDir, bare) {
808
806
  return pathPosix.normalize(joined);
809
807
  }
810
808
 
811
- // plugins/claude/extractors/slash/index.ts
812
- var ID2 = "slash";
809
+ // plugins/claude/extractors/slash-command/index.ts
810
+ var ID2 = "slash-command";
813
811
  var SLASH_RE = /(?<![A-Za-z0-9_/.:?#=&])(\/[a-z0-9][a-z0-9_-]*(?::[a-z0-9][a-z0-9_-]*)?)/gi;
814
- var slashExtractor = {
812
+ var slashCommandExtractor = {
815
813
  id: ID2,
816
814
  pluginId: CLAUDE_PLUGIN_ID,
817
815
  kind: "extractor",
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.",
816
+ 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
817
  scope: "body",
821
818
  precondition: { provider: ["claude"] },
822
819
  extract(ctx) {
@@ -865,8 +862,7 @@ var antigravityProvider = {
865
862
  id: "antigravity",
866
863
  pluginId: ANTIGRAVITY_PLUGIN_ID,
867
864
  kind: "provider",
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.",
865
+ description: "Declares the Google Antigravity runtime and its reserved built-in names.",
870
866
  // Vendor provider: marked gated for the day Antigravity grows its own
871
867
  // on-disk kind beyond the open standard. Today `kinds: {}` and
872
868
  // `classify` returns `null` for every path, so the flag is inert; the
@@ -896,7 +892,7 @@ var antigravityProvider = {
896
892
  // Gemini CLI's. We mirror the full 38-verb Gemini CLI catalog (plus its
897
893
  // four documented aliases: `dir`, `?`, `exit`, `bashes`) so a user file
898
894
  // that names a skill / command `help`, `clear`, `mcp`, etc. is flagged
899
- // immediately by `core/reserved-name` once the lens activates the catalog.
895
+ // immediately by `core/name-reserved` once the lens activates the catalog.
900
896
  //
901
897
  // The catalog is INACTIVE today: the analyzer keys on `node.provider`
902
898
  // and this Provider's `classify()` returns `null` for every path, so
@@ -1014,8 +1010,7 @@ var openaiProvider = {
1014
1010
  id: "openai",
1015
1011
  pluginId: OPENAI_PLUGIN_ID,
1016
1012
  kind: "provider",
1017
- version: "1.0.0",
1018
- description: "Walks OpenAI Codex CLI scope conventions (.codex/agents/*.toml).",
1013
+ description: "Classifies files under `.codex/agents/*.toml` as OpenAI Codex CLI sub-agents.",
1019
1014
  // Vendor provider: Codex CLI only reads its own `.codex/` territory.
1020
1015
  // Gating the classifier behind the active lens keeps the walker from
1021
1016
  // claiming Codex agents under a `claude` (or any other) lens, where
@@ -1072,8 +1067,7 @@ var agentSkillsProvider = {
1072
1067
  id: "agent-skills",
1073
1068
  pluginId: AGENT_SKILLS_PLUGIN_ID,
1074
1069
  kind: "provider",
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.",
1070
+ description: "Classifies files under `.agents/skills/<name>/SKILL.md` as Agent Skills.",
1077
1071
  read: { extensions: [".md"], parser: "frontmatter-yaml" },
1078
1072
  kinds: {
1079
1073
  skill: {
@@ -1120,8 +1114,7 @@ var coreMarkdownProvider = {
1120
1114
  id: "markdown",
1121
1115
  pluginId: CORE_PLUGIN_ID,
1122
1116
  kind: "provider",
1123
- version: "1.0.0",
1124
- description: "Universal `.md` fallback. Claims any markdown file no vendor-specific Provider classifies.",
1117
+ description: "Universal `.md` fallback. Claims any markdown file that no vendor-specific provider has classified.",
1125
1118
  read: { extensions: [".md"], parser: "frontmatter-yaml" },
1126
1119
  // Per spec § A.6, defaultRefreshAction values MUST be qualified
1127
1120
  // action ids. The summarize-markdown action is not yet implemented
@@ -1156,10 +1149,14 @@ var coreMarkdownProvider = {
1156
1149
  },
1157
1150
  // No `resolution`: `core/markdown` is the universal fallback Provider,
1158
1151
  // it does not declare an invocation surface of its own. Mentions /
1159
- // slashes sourced from markdown bodies still resolve via OTHER
1160
- // Providers' resolution maps (the lookup keys on the source node's
1161
- // Provider id, not on `core/markdown`). Leaving the field absent keeps
1162
- // the contract narrow.
1152
+ // slashes sourced from markdown bodies are still resolved by the
1153
+ // post-walk transform, the lookup keys on the ACTIVE PROVIDER LENS
1154
+ // (per `spec/architecture.md` §Provider · resolution rules), mirroring
1155
+ // the extractor gate that authorised the emission in the first place.
1156
+ // Leaving this field absent therefore has no resolver-side impact
1157
+ // under any lens that DOES declare a resolution map; it would only
1158
+ // matter the day `markdown` itself becomes a lens (which is not on
1159
+ // the roadmap, the format is provider-agnostic by design).
1163
1160
  classify() {
1164
1161
  return "markdown";
1165
1162
  }
@@ -1171,8 +1168,7 @@ var annotationsExtractor = {
1171
1168
  id: ID3,
1172
1169
  pluginId: CORE_PLUGIN_ID,
1173
1170
  kind: "extractor",
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.",
1171
+ description: "Turns the `supersedes` and `supersededBy` entries from a node's `.sm` sidecar into arrows between nodes in the graph.",
1176
1172
  scope: "frontmatter",
1177
1173
  extract(ctx) {
1178
1174
  const sourcePath = ctx.node.path;
@@ -1233,8 +1229,7 @@ var externalUrlCounterExtractor = {
1233
1229
  id: ID4,
1234
1230
  pluginId: CORE_PLUGIN_ID,
1235
1231
  kind: "extractor",
1236
- version: "1.0.0",
1237
- description: "Counts the distinct external URLs in a node's body and shows the total on the card.",
1232
+ description: "Counts the distinct external URLs in a node's body and shows the count on the card.",
1238
1233
  scope: "body",
1239
1234
  /**
1240
1235
  * Phase 6 / View contribution system, surface the distinct-URL
@@ -1322,8 +1317,7 @@ var markdownLinkExtractor = {
1322
1317
  id: ID5,
1323
1318
  pluginId: CORE_PLUGIN_ID,
1324
1319
  kind: "extractor",
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.",
1320
+ description: "Turns markdown links (`[text](path)`) in a node's body into arrows between nodes in the graph.",
1327
1321
  scope: "body",
1328
1322
  extract(ctx) {
1329
1323
  const seen = /* @__PURE__ */ new Set();
@@ -1352,7 +1346,7 @@ var markdownLinkExtractor = {
1352
1346
  // explicitly designates an out-link via the brackets +
1353
1347
  // parentheses pair; there is no inference left to discount.
1354
1348
  // Whether the path resolves to a real node is a separate
1355
- // concern (the `core/broken-ref` analyzer flags unresolved
1349
+ // concern (the `core/reference-broken` analyzer flags unresolved
1356
1350
  // targets), not a confidence question.
1357
1351
  confidence: 1,
1358
1352
  rationale: "unambiguous markdown link syntax",
@@ -1384,8 +1378,7 @@ var mcpToolsExtractor = {
1384
1378
  id: ID6,
1385
1379
  pluginId: CORE_PLUGIN_ID,
1386
1380
  kind: "extractor",
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.",
1381
+ 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
1382
  scope: "frontmatter",
1390
1383
  extract(ctx) {
1391
1384
  const raw = ctx.frontmatter["tools"];
@@ -1439,15 +1432,14 @@ function collectMcpServers(tools) {
1439
1432
  return out;
1440
1433
  }
1441
1434
 
1442
- // plugins/core/extractors/tools-count/index.ts
1443
- var ID7 = "tools-count";
1435
+ // plugins/core/extractors/tools-counter/index.ts
1436
+ var ID7 = "tools-counter";
1444
1437
  var TOOLTIP_MAX = 255;
1445
- var toolsCountExtractor = {
1438
+ var toolsCounterExtractor = {
1446
1439
  id: ID7,
1447
1440
  pluginId: CORE_PLUGIN_ID,
1448
1441
  kind: "extractor",
1449
- version: "1.0.0",
1450
- description: "Counts the tools an agent declares in its frontmatter and shows the total on the agent card.",
1442
+ description: "Counts the tools an agent declares in its frontmatter and shows the count on the agent card.",
1451
1443
  scope: "frontmatter",
1452
1444
  precondition: { kind: ["claude/agent"] },
1453
1445
  ui: {
@@ -1479,6 +1471,178 @@ function buildTooltip(names) {
1479
1471
  return `${joined.slice(0, TOOLTIP_MAX - 1)}\u2026`;
1480
1472
  }
1481
1473
 
1474
+ // plugins/core/analyzers/annotation-field-unknown/index.ts
1475
+ import { readFileSync } from "fs";
1476
+ import { dirname, resolve } from "path";
1477
+ import { createRequire } from "module";
1478
+ import { Ajv2020 } from "ajv/dist/2020.js";
1479
+
1480
+ // kernel/util/ajv-interop.ts
1481
+ import addFormatsModule from "ajv-formats";
1482
+ var addFormats = addFormatsModule.default ?? addFormatsModule;
1483
+ function applyAjvFormats(ajv) {
1484
+ addFormats(ajv);
1485
+ }
1486
+
1487
+ // plugins/core/analyzers/annotation-field-unknown/text.ts
1488
+ var ANNOTATION_FIELD_UNKNOWN_TEXTS = {
1489
+ /** Key inside `annotations:` is not in the curated catalog. */
1490
+ unknownAnnotationKey: "{{path}}: sidecar annotations contain unknown key '{{key}}' (not in annotations.schema.json catalog).",
1491
+ /** Top-level key is neither reserved, nor a registered plugin namespace, nor a registered root key. */
1492
+ unknownRootKey: "{{path}}: sidecar declares unknown top-level key '{{key}}'; not a reserved block, not a registered plugin namespace, not a registered root contribution.",
1493
+ /** Value under a registered plugin namespace fails the contributed schema. */
1494
+ pluginNamespaceInvalid: "{{path}}: sidecar block '{{pluginId}}.{{key}}' fails the schema contributed by plugin '{{pluginId}}': {{errors}}.",
1495
+ // Tooltips for the per-node view-contribution badges. Singular vs
1496
+ // plural keeps the count grammar correct without a sub-template.
1497
+ alertTooltipSingle: "This node has 1 unknown field in its sidecar. Open the inspector for details.",
1498
+ alertTooltipMany: "This node has {{count}} unknown fields in its sidecar. Open the inspector for details."
1499
+ };
1500
+
1501
+ // plugins/core/analyzers/annotation-field-unknown/index.ts
1502
+ var ID8 = "annotation-field-unknown";
1503
+ var RESERVED_ROOT_BLOCKS = /* @__PURE__ */ new Set(["identity", "annotations", "settings", "audit"]);
1504
+ var annotationFieldUnknownAnalyzer = {
1505
+ id: ID8,
1506
+ pluginId: CORE_PLUGIN_ID,
1507
+ kind: "analyzer",
1508
+ description: "Flags typos or unrecognized keys in sidecars (`.sm`).",
1509
+ mode: "deterministic",
1510
+ // No `ui` declaration: the per-node icon-only chip used to surface
1511
+ // "this sidecar declares unknown keys" on `card.footer.right`, but
1512
+ // its severity (`warn`) is now folded into the aggregate counters
1513
+ // emitted by `core/issue-counter`. The detection logic stays, the
1514
+ // findings still ship as `Issue` records (visible in the inspector,
1515
+ // `sm check`, etc.).
1516
+ ui: {},
1517
+ // Analyzer body iterates every sidecar root and classifies each
1518
+ // key against three buckets (catalog / plugin namespace / unknown
1519
+ // root). The per-key branching IS the classification table; factoring
1520
+ // it out would rebuild the discriminator elsewhere. Per
1521
+ // `context/lint.md` category 7 (recursive type-discriminator walkers).
1522
+ // eslint-disable-next-line complexity
1523
+ evaluate(ctx) {
1524
+ const sidecarRoots = ctx.sidecarRoots;
1525
+ if (!sidecarRoots || sidecarRoots.size === 0) return [];
1526
+ const knownAnnotationKeys = getKnownAnnotationKeys();
1527
+ const contributions = ctx.annotationContributions ?? [];
1528
+ const namespacedByPlugin = indexNamespacedContributions(contributions);
1529
+ const rootKeys = indexRootContributions(contributions);
1530
+ const knownPluginIds = collectPluginIds(contributions);
1531
+ const issues = [];
1532
+ const perNode = /* @__PURE__ */ new Map();
1533
+ const bump2 = (nodePath) => {
1534
+ perNode.set(nodePath, (perNode.get(nodePath) ?? 0) + 1);
1535
+ };
1536
+ for (const node of ctx.nodes) {
1537
+ const root = sidecarRoots.get(node.path);
1538
+ if (!root) continue;
1539
+ const annotations = root["annotations"];
1540
+ if (annotations !== void 0 && annotations !== null && typeof annotations === "object" && !Array.isArray(annotations)) {
1541
+ for (const key of Object.keys(annotations)) {
1542
+ if (!knownAnnotationKeys.has(key)) {
1543
+ issues.push({
1544
+ analyzerId: ID8,
1545
+ severity: "warn",
1546
+ nodeIds: [node.path],
1547
+ message: tx(ANNOTATION_FIELD_UNKNOWN_TEXTS.unknownAnnotationKey, {
1548
+ path: node.path,
1549
+ key
1550
+ }),
1551
+ data: { surface: "annotations", key }
1552
+ });
1553
+ bump2(node.path);
1554
+ }
1555
+ }
1556
+ }
1557
+ for (const key of Object.keys(root)) {
1558
+ if (RESERVED_ROOT_BLOCKS.has(key)) continue;
1559
+ if (rootKeys.has(key)) continue;
1560
+ if (knownPluginIds.has(key)) {
1561
+ const block = root[key];
1562
+ if (block === null || typeof block !== "object" || Array.isArray(block)) continue;
1563
+ const contribsForPlugin = namespacedByPlugin.get(key);
1564
+ if (!contribsForPlugin) continue;
1565
+ for (const [contribKey, validator] of contribsForPlugin) {
1566
+ const value = block[contribKey];
1567
+ if (value === void 0) continue;
1568
+ if (validator(value)) continue;
1569
+ const errors = (validator.errors ?? []).map((e) => `${e.instancePath || "(root)"} ${e.message ?? e.keyword}`).join("; ");
1570
+ issues.push({
1571
+ analyzerId: ID8,
1572
+ severity: "warn",
1573
+ nodeIds: [node.path],
1574
+ message: tx(ANNOTATION_FIELD_UNKNOWN_TEXTS.pluginNamespaceInvalid, {
1575
+ path: node.path,
1576
+ pluginId: key,
1577
+ key: contribKey,
1578
+ errors
1579
+ }),
1580
+ data: { surface: "plugin-namespace", pluginId: key, key: contribKey }
1581
+ });
1582
+ bump2(node.path);
1583
+ }
1584
+ continue;
1585
+ }
1586
+ issues.push({
1587
+ analyzerId: ID8,
1588
+ severity: "warn",
1589
+ nodeIds: [node.path],
1590
+ message: tx(ANNOTATION_FIELD_UNKNOWN_TEXTS.unknownRootKey, {
1591
+ path: node.path,
1592
+ key
1593
+ }),
1594
+ data: { surface: "root", key }
1595
+ });
1596
+ bump2(node.path);
1597
+ }
1598
+ }
1599
+ void perNode;
1600
+ return issues;
1601
+ }
1602
+ };
1603
+ var cachedKnownKeys = null;
1604
+ function getKnownAnnotationKeys() {
1605
+ if (cachedKnownKeys) return cachedKnownKeys;
1606
+ const require2 = createRequire(import.meta.url);
1607
+ const indexPath = require2.resolve("@skill-map/spec/index.json");
1608
+ const specRoot = dirname(indexPath);
1609
+ const schema = JSON.parse(
1610
+ readFileSync(resolve(specRoot, "schemas/annotations.schema.json"), "utf8")
1611
+ );
1612
+ cachedKnownKeys = new Set(Object.keys(schema.properties ?? {}));
1613
+ return cachedKnownKeys;
1614
+ }
1615
+ function indexNamespacedContributions(contributions) {
1616
+ const out = /* @__PURE__ */ new Map();
1617
+ const ajv = new Ajv2020({ strict: false, allErrors: true, allowUnionTypes: true });
1618
+ applyAjvFormats(ajv);
1619
+ for (const entry of contributions) {
1620
+ if (entry.location !== "namespaced") continue;
1621
+ let bucket = out.get(entry.pluginId);
1622
+ if (!bucket) {
1623
+ bucket = /* @__PURE__ */ new Map();
1624
+ out.set(entry.pluginId, bucket);
1625
+ }
1626
+ try {
1627
+ bucket.set(entry.key, ajv.compile(entry.schema));
1628
+ } catch {
1629
+ }
1630
+ }
1631
+ return out;
1632
+ }
1633
+ function indexRootContributions(contributions) {
1634
+ const out = /* @__PURE__ */ new Set();
1635
+ for (const entry of contributions) {
1636
+ if (entry.location === "root") out.add(entry.key);
1637
+ }
1638
+ return out;
1639
+ }
1640
+ function collectPluginIds(contributions) {
1641
+ const out = /* @__PURE__ */ new Set();
1642
+ for (const entry of contributions) out.add(entry.pluginId);
1643
+ return out;
1644
+ }
1645
+
1482
1646
  // plugins/core/analyzers/annotation-orphan/text.ts
1483
1647
  var ANNOTATION_ORPHAN_TEXTS = {
1484
1648
  /** Sidecar `<path>.sm` has no matching `<path>.md`. */
@@ -1486,13 +1650,12 @@ var ANNOTATION_ORPHAN_TEXTS = {
1486
1650
  };
1487
1651
 
1488
1652
  // plugins/core/analyzers/annotation-orphan/index.ts
1489
- var ID8 = "annotation-orphan";
1653
+ var ID9 = "annotation-orphan";
1490
1654
  var annotationOrphanAnalyzer = {
1491
- id: ID8,
1655
+ id: ID9,
1492
1656
  pluginId: CORE_PLUGIN_ID,
1493
1657
  kind: "analyzer",
1494
- version: "1.0.0",
1495
- description: "Detects and flags sidecars (`.sm`) whose `.md` no longer exists.",
1658
+ description: "Flags sidecars (`.sm`) whose `.md` file no longer exists.",
1496
1659
  mode: "deterministic",
1497
1660
  evaluate(ctx) {
1498
1661
  const orphans = ctx.orphanSidecars;
@@ -1501,7 +1664,7 @@ var annotationOrphanAnalyzer = {
1501
1664
  for (const orphan of orphans) {
1502
1665
  const expectedMdRelative = orphan.relativePath.endsWith(".sm") ? `${orphan.relativePath.slice(0, -".sm".length)}.md` : `${orphan.relativePath}.md`;
1503
1666
  issues.push({
1504
- analyzerId: ID8,
1667
+ analyzerId: ID9,
1505
1668
  severity: "warn",
1506
1669
  nodeIds: [expectedMdRelative],
1507
1670
  message: tx(ANNOTATION_ORPHAN_TEXTS.message, {
@@ -1538,17 +1701,16 @@ var ANNOTATION_STALE_TEXTS = {
1538
1701
  };
1539
1702
 
1540
1703
  // plugins/core/analyzers/annotation-stale/index.ts
1541
- var ID9 = "annotation-stale";
1704
+ var ID10 = "annotation-stale";
1542
1705
  var annotationStaleAnalyzer = {
1543
- id: ID9,
1706
+ id: ID10,
1544
1707
  pluginId: CORE_PLUGIN_ID,
1545
1708
  kind: "analyzer",
1546
- version: "1.0.0",
1547
- description: "Detects and marks sidecars (`.sm`) out of date of their `.md`.",
1709
+ description: "Marks sidecars (`.sm`) that are out of date with their `.md`.",
1548
1710
  mode: "deterministic",
1549
1711
  // The natural fix is to bump the node: refreshes `for` hashes,
1550
1712
  // increments `annotations.version`, and stamps the audit block. The
1551
- // UI surfaces `core/bump` in the node inspector under "Recommended
1713
+ // UI surfaces `core/node-bump` in the node inspector under "Recommended
1552
1714
  // for issues" whenever this analyzer fires.
1553
1715
  ui: {
1554
1716
  // A `pi-clock` chip in the footer-right cluster so the operator
@@ -1564,7 +1726,10 @@ var annotationStaleAnalyzer = {
1564
1726
  slot: "card.footer.right",
1565
1727
  icon: "pi-clock",
1566
1728
  emitWhenEmpty: true,
1567
- priority: 20
1729
+ // First in the footer-right cluster: drift is the operator's
1730
+ // entry point for "this node disagrees with its sidecar",
1731
+ // followed by stability, then the severity counters.
1732
+ priority: 10
1568
1733
  }
1569
1734
  },
1570
1735
  evaluate(ctx) {
@@ -1575,7 +1740,7 @@ var annotationStaleAnalyzer = {
1575
1740
  if (status === "fresh") continue;
1576
1741
  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
1742
  issues.push({
1578
- analyzerId: ID9,
1743
+ analyzerId: ID10,
1579
1744
  severity: "warn",
1580
1745
  nodeIds: [node.path],
1581
1746
  message,
@@ -1601,233 +1766,130 @@ function tooltipFor(status) {
1601
1766
  }
1602
1767
  }
1603
1768
 
1604
- // plugins/core/analyzers/broken-ref/index.ts
1605
- import { posix as pathPosix3, resolve } from "path";
1769
+ // plugins/core/analyzers/contribution-orphan/index.ts
1770
+ var ID11 = "contribution-orphan";
1771
+ var contributionOrphanAnalyzer = {
1772
+ id: ID11,
1773
+ pluginId: CORE_PLUGIN_ID,
1774
+ kind: "analyzer",
1775
+ description: "Warns about plugin data referencing nodes renamed or deleted in the latest scan.",
1776
+ mode: "deterministic",
1777
+ evaluate(_ctx) {
1778
+ return [];
1779
+ }
1780
+ };
1606
1781
 
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}}."
1782
+ // plugins/core/analyzers/issue-counter/text.ts
1783
+ var ISSUE_COUNTER_TEXTS = {
1784
+ errorTooltipSingle: "1 error",
1785
+ errorTooltipMany: "{{count}} errors",
1786
+ warnTooltipSingle: "1 warning",
1787
+ warnTooltipMany: "{{count}} warnings"
1621
1788
  };
1622
1789
 
1623
- // plugins/core/analyzers/broken-ref/index.ts
1624
- var ID10 = "broken-ref";
1625
- var brokenRefAnalyzer = {
1626
- id: ID10,
1790
+ // plugins/core/analyzers/issue-counter/index.ts
1791
+ var ID12 = "issue-counter";
1792
+ function countByTier(issues) {
1793
+ const errors = /* @__PURE__ */ new Map();
1794
+ const warns = /* @__PURE__ */ new Map();
1795
+ for (const issue of issues) {
1796
+ const bucket = issue.severity === "error" ? errors : issue.severity === "warn" ? warns : null;
1797
+ if (!bucket) continue;
1798
+ for (const nodeId of issue.nodeIds) {
1799
+ bucket.set(nodeId, (bucket.get(nodeId) ?? 0) + 1);
1800
+ }
1801
+ }
1802
+ return { errors, warns };
1803
+ }
1804
+ function emitTierChips(ctx, contributionId, severity, counts, singleTooltip, manyTooltip) {
1805
+ for (const [nodePath, count] of counts) {
1806
+ const capped = Math.min(count, 99);
1807
+ ctx.emitContribution(nodePath, contributionId, {
1808
+ value: capped,
1809
+ severity,
1810
+ tooltip: count === 1 ? singleTooltip : tx(manyTooltip, { count })
1811
+ });
1812
+ }
1813
+ }
1814
+ var issueCounterAnalyzer = {
1815
+ id: ID12,
1627
1816
  pluginId: CORE_PLUGIN_ID,
1628
1817
  kind: "analyzer",
1629
- version: "1.0.0",
1630
- description: "Detects and flags arrows pointing at a node not part of the current scan.",
1818
+ description: "Emits one aggregate severity chip per node (error + warn counts) from the live issue accumulator.",
1631
1819
  mode: "deterministic",
1820
+ phase: "aggregate",
1632
1821
  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
1822
+ // Third in the footer-right cluster, after the drift chip
1823
+ // (priority 10) and the stability badge (priority 20). The warn
1824
+ // counter sits before the error counter so the operator reads
1825
+ // "advisory → blocking" left-to-right.
1826
+ warnCount: {
1827
+ slot: "card.footer.right",
1828
+ icon: "pi-exclamation-triangle",
1829
+ emitWhenEmpty: false,
1830
+ priority: 30
1639
1831
  },
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: {
1832
+ // Last in the cluster, the red chip pins to the right edge so the
1833
+ // most severe signal anchors the row's reading position.
1834
+ errorCount: {
1646
1835
  slot: "card.footer.right",
1647
- icon: "fa-regular fa-circle-xmark",
1836
+ icon: "pi-times-circle",
1648
1837
  emitWhenEmpty: false,
1649
1838
  priority: 40
1650
1839
  }
1651
1840
  },
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
1841
  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;
1842
+ const accumulator = ctx.accumulatedIssues ?? [];
1843
+ if (accumulator.length === 0) return [];
1844
+ const { errors, warns } = countByTier(accumulator);
1845
+ emitTierChips(
1846
+ ctx,
1847
+ "errorCount",
1848
+ "danger",
1849
+ errors,
1850
+ ISSUE_COUNTER_TEXTS.errorTooltipSingle,
1851
+ ISSUE_COUNTER_TEXTS.errorTooltipMany
1852
+ );
1853
+ emitTierChips(
1854
+ ctx,
1855
+ "warnCount",
1856
+ "warn",
1857
+ warns,
1858
+ ISSUE_COUNTER_TEXTS.warnTooltipSingle,
1859
+ ISSUE_COUNTER_TEXTS.warnTooltipMany
1860
+ );
1861
+ return [];
1862
+ }
1863
+ };
1864
+
1865
+ // plugins/core/analyzers/job-file-orphan/text.ts
1866
+ var JOB_FILE_ORPHAN_TEXTS = {
1867
+ /**
1868
+ * `<path>.md` lives under `.skill-map/jobs/` but no `state_jobs.filePath`
1869
+ * row references it. Run `sm job prune --orphan-files` to remove.
1870
+ */
1871
+ message: "Orphan job file: {{filePath}} is not referenced by any state_jobs row. Run `sm job prune --orphan-files` to remove it."
1872
+ };
1873
+
1874
+ // plugins/core/analyzers/job-file-orphan/index.ts
1875
+ var ID13 = "job-file-orphan";
1876
+ var jobFileOrphanAnalyzer = {
1877
+ id: ID13,
1878
+ pluginId: CORE_PLUGIN_ID,
1879
+ kind: "analyzer",
1880
+ description: "Flags leftover job result files (no live job references them). Clean up via `sm job prune --orphan-files`.",
1881
+ mode: "deterministic",
1882
+ evaluate(ctx) {
1883
+ const orphans = ctx.orphanJobFiles;
1884
+ if (!orphans || orphans.length === 0) return [];
1662
1885
  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 [];
1823
- const issues = [];
1824
- for (const filePath of orphans) {
1825
- issues.push({
1826
- analyzerId: ID12,
1827
- severity: "warn",
1828
- nodeIds: [filePath],
1829
- message: tx(JOB_ORPHAN_FILE_TEXTS.message, { filePath }),
1830
- data: { filePath }
1886
+ for (const filePath of orphans) {
1887
+ issues.push({
1888
+ analyzerId: ID13,
1889
+ severity: "warn",
1890
+ nodeIds: [filePath],
1891
+ message: tx(JOB_FILE_ORPHAN_TEXTS.message, { filePath }),
1892
+ data: { filePath }
1831
1893
  });
1832
1894
  }
1833
1895
  return issues;
@@ -1841,13 +1903,12 @@ var LINK_CONFLICT_TEXTS = {
1841
1903
  };
1842
1904
 
1843
1905
  // plugins/core/analyzers/link-conflict/index.ts
1844
- var ID13 = "link-conflict";
1906
+ var ID14 = "link-conflict";
1845
1907
  var linkConflictAnalyzer = {
1846
- id: ID13,
1908
+ id: ID14,
1847
1909
  pluginId: "core",
1848
1910
  kind: "analyzer",
1849
- version: "1.0.0",
1850
- description: 'Detects and flags conflicting arrow meanings between extractors (e.g. "references" vs "invokes").',
1911
+ description: "Flags conflicting arrow meanings between extractors (e.g. `references` vs `invokes`).",
1851
1912
  mode: "deterministic",
1852
1913
  // Bucket links by (source, target), then per-bucket detect distinct
1853
1914
  // kinds. The branching is intrinsic to the per-bucket conflict
@@ -1891,7 +1952,7 @@ var linkConflictAnalyzer = {
1891
1952
  const [source, target] = key.split("\0");
1892
1953
  const kindList = variants.map((v) => v.kind).join(" / ");
1893
1954
  issues.push({
1894
- analyzerId: ID13,
1955
+ analyzerId: ID14,
1895
1956
  severity: "warn",
1896
1957
  nodeIds: [source, target],
1897
1958
  message: tx(LINK_CONFLICT_TEXTS.message, {
@@ -1957,13 +2018,12 @@ function resolveLinkTargetToPath(link, nameIndex) {
1957
2018
  return resolved ?? raw;
1958
2019
  }
1959
2020
 
1960
- // plugins/core/analyzers/link-counts/index.ts
1961
- var ID14 = "link-counts";
1962
- var linkCountsAnalyzer = {
1963
- id: ID14,
2021
+ // plugins/core/analyzers/link-counter/index.ts
2022
+ var ID15 = "link-counter";
2023
+ var linkCounterAnalyzer = {
2024
+ id: ID15,
1964
2025
  pluginId: CORE_PLUGIN_ID,
1965
2026
  kind: "analyzer",
1966
- version: "1.0.0",
1967
2027
  description: "Counts incoming and outgoing links per node.",
1968
2028
  mode: "deterministic",
1969
2029
  ui: {
@@ -2024,154 +2084,63 @@ function formatBreakdown(byKind, direction) {
2024
2084
  return [direction, ...lines].join("\n");
2025
2085
  }
2026
2086
 
2027
- // plugins/core/analyzers/redundant-target-reference/text.ts
2028
- var REDUNDANT_TARGET_REFERENCE_TEXTS = {
2087
+ // plugins/core/analyzers/link-self-loop/text.ts
2088
+ var LINK_SELF_LOOP_TEXTS = {
2029
2089
  /**
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.
2090
+ * Per-edge warn: a node body references itself via the slash /
2091
+ * at-directive / markdown-link surface (most commonly because the
2092
+ * file's heading IS the invocation token, e.g. `# /deploy` inside
2093
+ * `commands/deploy.md`). The link is structurally valid but rarely
2094
+ * the operator's intent; UI consumers MAY hide it by default and
2095
+ * surface a count.
2033
2096
  */
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)"
2097
+ 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
2098
  };
2042
2099
 
2043
- // plugins/core/analyzers/redundant-target-reference/index.ts
2044
- var ID15 = "redundant-target-reference";
2045
- var redundantTargetReferenceAnalyzer = {
2046
- id: ID15,
2100
+ // plugins/core/analyzers/link-self-loop/index.ts
2101
+ var ID16 = "link-self-loop";
2102
+ var linkSelfLoopAnalyzer = {
2103
+ id: ID16,
2047
2104
  pluginId: CORE_PLUGIN_ID,
2048
2105
  kind: "analyzer",
2049
- 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.",
2106
+ 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
2107
  mode: "deterministic",
2052
2108
  evaluate(ctx) {
2053
2109
  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
2110
  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);
2111
+ for (const link of ctx.links) {
2112
+ if (!isSelfLoop(link)) continue;
2072
2113
  issues.push({
2073
- analyzerId: ID15,
2114
+ analyzerId: ID16,
2074
2115
  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)
2116
+ nodeIds: [link.source],
2117
+ message: tx(LINK_SELF_LOOP_TEXTS.message, {
2118
+ source: link.source,
2119
+ trigger: link.trigger?.originalTrigger ?? link.target,
2120
+ kind: link.kind
2081
2121
  }),
2082
2122
  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
- }))
2123
+ target: link.target,
2124
+ resolvedTarget: link.resolvedTarget ?? link.target,
2125
+ kind: link.kind,
2126
+ // Mark explicitly so UI / downstream consumers can read this
2127
+ // single field instead of re-computing the `source === target`
2128
+ // predicate themselves.
2129
+ selfLoop: true
2091
2130
  }
2092
2131
  });
2093
2132
  }
2094
2133
  return issues;
2095
2134
  }
2096
2135
  };
2097
- function flattenOccurrences(links) {
2098
- 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
- });
2118
- }
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
- return out;
2126
- }
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;
2136
+ function isSelfLoop(link) {
2137
+ if (link.source === link.target) return true;
2138
+ if (link.resolvedTarget && link.source === link.resolvedTarget) return true;
2139
+ return false;
2171
2140
  }
2172
2141
 
2173
2142
  // kernel/orchestrator/node-identifiers.ts
2174
- import { posix as pathPosix4 } from "path";
2143
+ import { posix as pathPosix3 } from "path";
2175
2144
  function deriveNodeIdentifiers(node, kindDescriptor) {
2176
2145
  const sources = kindDescriptor?.identifiers;
2177
2146
  if (!sources || sources.length === 0) return [];
@@ -2195,16 +2164,16 @@ function readFrontmatterName(node) {
2195
2164
  return raw.length > 0 ? raw : null;
2196
2165
  }
2197
2166
  function readFilenameBasename(node) {
2198
- const base = pathPosix4.basename(node.path);
2167
+ const base = pathPosix3.basename(node.path);
2199
2168
  if (!base) return null;
2200
- const ext = pathPosix4.extname(base);
2169
+ const ext = pathPosix3.extname(base);
2201
2170
  const stem = ext ? base.slice(0, -ext.length) : base;
2202
2171
  return stem.length > 0 ? stem : null;
2203
2172
  }
2204
2173
  function readDirname(node) {
2205
- const dir = pathPosix4.dirname(node.path);
2174
+ const dir = pathPosix3.dirname(node.path);
2206
2175
  if (!dir || dir === "." || dir === "/") return null;
2207
- const base = pathPosix4.basename(dir);
2176
+ const base = pathPosix3.basename(dir);
2208
2177
  return base.length > 0 ? base : null;
2209
2178
  }
2210
2179
 
@@ -2246,10 +2215,9 @@ function resolveByName(link, indexes, ctx) {
2246
2215
  const winner = candidates.find((c) => allowedKinds.includes(c.kind));
2247
2216
  return winner ? winner.path : "none";
2248
2217
  }
2249
- function lookupAllowedKinds(link, indexes, ctx) {
2250
- const sourceNode = indexes.nodeByPath.get(link.source);
2251
- if (!sourceNode) return void 0;
2252
- return ctx.providerResolution.get(sourceNode.provider)?.[link.kind];
2218
+ function lookupAllowedKinds(link, _indexes, ctx) {
2219
+ if (ctx.activeProvider === null) return void 0;
2220
+ return ctx.providerResolution.get(ctx.activeProvider)?.[link.kind];
2253
2221
  }
2254
2222
  function stripTriggerSigil(normalized) {
2255
2223
  if (!normalized) return null;
@@ -2273,8 +2241,8 @@ function kindKey(node) {
2273
2241
  return `${node.provider}/${node.kind}`;
2274
2242
  }
2275
2243
 
2276
- // plugins/core/analyzers/reserved-name/text.ts
2277
- var RESERVED_NAME_TEXTS = {
2244
+ // plugins/core/analyzers/name-reserved/text.ts
2245
+ var NAME_RESERVED_TEXTS = {
2278
2246
  /**
2279
2247
  * Target-side message: emitted on the user file that collides with
2280
2248
  * a runtime built-in. Same wording skill-map shipped before the
@@ -2291,14 +2259,13 @@ var RESERVED_NAME_TEXTS = {
2291
2259
  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
2260
  };
2293
2261
 
2294
- // plugins/core/analyzers/reserved-name/index.ts
2295
- var ID16 = "reserved-name";
2296
- var reservedNameAnalyzer = {
2297
- id: ID16,
2262
+ // plugins/core/analyzers/name-reserved/index.ts
2263
+ var ID17 = "name-reserved";
2264
+ var nameReservedAnalyzer = {
2265
+ id: ID17,
2298
2266
  pluginId: CORE_PLUGIN_ID,
2299
2267
  kind: "analyzer",
2300
- 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.",
2268
+ 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
2269
  mode: "deterministic",
2303
2270
  // eslint-disable-next-line complexity
2304
2271
  evaluate(ctx) {
@@ -2311,10 +2278,10 @@ var reservedNameAnalyzer = {
2311
2278
  const node = byPath3.get(path);
2312
2279
  if (!node) continue;
2313
2280
  issues.push({
2314
- analyzerId: ID16,
2281
+ analyzerId: ID17,
2315
2282
  severity: "warn",
2316
2283
  nodeIds: [node.path],
2317
- message: tx(RESERVED_NAME_TEXTS.message, {
2284
+ message: tx(NAME_RESERVED_TEXTS.message, {
2318
2285
  path: node.path,
2319
2286
  provider: node.provider,
2320
2287
  kind: node.kind
@@ -2327,10 +2294,10 @@ var reservedNameAnalyzer = {
2327
2294
  const reservedNode = findReservedNodeForLink(link, reserved, byPath3);
2328
2295
  if (!reservedNode) continue;
2329
2296
  issues.push({
2330
- analyzerId: ID16,
2297
+ analyzerId: ID17,
2331
2298
  severity: "warn",
2332
2299
  nodeIds: [link.source],
2333
- message: tx(RESERVED_NAME_TEXTS.linkMessage, {
2300
+ message: tx(NAME_RESERVED_TEXTS.linkMessage, {
2334
2301
  kind: link.kind,
2335
2302
  target: link.target,
2336
2303
  provider: reservedNode.provider,
@@ -2387,221 +2354,33 @@ function normaliseId(raw) {
2387
2354
  return raw.normalize("NFD").replace(new RegExp("\\p{Mn}+", "gu"), "").toLowerCase().replace(/[-_\s]+/g, " ").replace(/ +/g, " ").trim();
2388
2355
  }
2389
2356
 
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";
2357
+ // plugins/core/analyzers/node-stability/index.ts
2358
+ var ID18 = "node-stability";
2582
2359
  var EXPERIMENTAL_TOOLTIP = "Experimental: API may change";
2583
2360
  var DEPRECATED_TOOLTIP = "Deprecated: avoid in new code";
2584
- var stabilityAnalyzer = {
2585
- id: ID19,
2361
+ var nodeStabilityAnalyzer = {
2362
+ id: ID18,
2586
2363
  pluginId: CORE_PLUGIN_ID,
2587
2364
  kind: "analyzer",
2588
- version: "1.0.0",
2589
- description: "Reports node lifecycle stage (`experimental`, `deprecated`) on the card.",
2365
+ description: "Reports a node's stability stage (`experimental`, `deprecated`) on the card.",
2590
2366
  mode: "deterministic",
2591
2367
  ui: {
2368
+ // Second in the footer-right cluster, after the drift chip and
2369
+ // before the severity counters. Stability is a state badge, not a
2370
+ // count, so its priority sits between the two semantic zones.
2592
2371
  experimental: {
2593
2372
  slot: "card.footer.right",
2594
2373
  icon: "fa-solid fa-flask",
2595
2374
  label: "experimental",
2596
2375
  emitWhenEmpty: false,
2597
- priority: 10
2376
+ priority: 20
2598
2377
  },
2599
2378
  deprecated: {
2600
2379
  slot: "card.footer.right",
2601
2380
  icon: "pi-ban",
2602
2381
  label: "deprecated",
2603
2382
  emitWhenEmpty: false,
2604
- priority: 10
2383
+ priority: 20
2605
2384
  }
2606
2385
  },
2607
2386
  evaluate(ctx) {
@@ -2614,7 +2393,7 @@ var stabilityAnalyzer = {
2614
2393
  tooltip: EXPERIMENTAL_TOOLTIP
2615
2394
  });
2616
2395
  issues.push({
2617
- analyzerId: ID19,
2396
+ analyzerId: ID18,
2618
2397
  severity: "info",
2619
2398
  nodeIds: [node.path],
2620
2399
  message: `Node '${node.path}' is marked experimental: API may change.`,
@@ -2627,7 +2406,7 @@ var stabilityAnalyzer = {
2627
2406
  severity: "warn"
2628
2407
  });
2629
2408
  issues.push({
2630
- analyzerId: ID19,
2409
+ analyzerId: ID18,
2631
2410
  severity: "warn",
2632
2411
  nodeIds: [node.path],
2633
2412
  message: `Node '${node.path}' is marked deprecated: avoid in new code.`,
@@ -2654,20 +2433,19 @@ function isStability(value) {
2654
2433
  return value === "experimental" || value === "deprecated" || value === "stable";
2655
2434
  }
2656
2435
 
2657
- // plugins/core/analyzers/superseded/text.ts
2658
- var SUPERSEDED_TEXTS = {
2436
+ // plugins/core/analyzers/node-superseded/text.ts
2437
+ var NODE_SUPERSEDED_TEXTS = {
2659
2438
  /** `<path> is superseded by <supersededBy>` */
2660
2439
  message: "{{path}} is superseded by {{supersededBy}}"
2661
2440
  };
2662
2441
 
2663
- // plugins/core/analyzers/superseded/index.ts
2664
- var ID20 = "superseded";
2665
- var supersededAnalyzer = {
2666
- id: ID20,
2442
+ // plugins/core/analyzers/node-superseded/index.ts
2443
+ var ID19 = "node-superseded";
2444
+ var nodeSupersededAnalyzer = {
2445
+ id: ID19,
2667
2446
  pluginId: CORE_PLUGIN_ID,
2668
2447
  kind: "analyzer",
2669
- version: "1.0.0",
2670
- description: "Detects and marks nodes replaced by a newer one via `supersededBy`.",
2448
+ description: "Marks nodes replaced by a newer one via `supersededBy`.",
2671
2449
  mode: "deterministic",
2672
2450
  evaluate(ctx) {
2673
2451
  const issues = [];
@@ -2675,10 +2453,10 @@ var supersededAnalyzer = {
2675
2453
  const supersededBy = pickSupersededBy(node);
2676
2454
  if (supersededBy === null) continue;
2677
2455
  issues.push({
2678
- analyzerId: ID20,
2456
+ analyzerId: ID19,
2679
2457
  severity: "info",
2680
2458
  nodeIds: [node.path],
2681
- message: tx(SUPERSEDED_TEXTS.message, {
2459
+ message: tx(NODE_SUPERSEDED_TEXTS.message, {
2682
2460
  path: node.path,
2683
2461
  supersededBy
2684
2462
  }),
@@ -2698,351 +2476,307 @@ function pickSupersededBy(node) {
2698
2476
  return value;
2699
2477
  }
2700
2478
 
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}}"
2479
+ // plugins/core/analyzers/reference-broken/index.ts
2480
+ import { posix as pathPosix4, resolve as resolve3 } from "path";
2481
+
2482
+ // plugins/core/analyzers/reference-broken/text.ts
2483
+ var REFERENCE_BROKEN_TEXTS = {
2484
+ /** `Broken <kind> reference from <source> → <target>` */
2485
+ message: "Broken {{kind}} reference from {{source}} \u2192 {{target}}",
2486
+ // Tooltips for the per-node view-contribution badges. Singular vs
2487
+ // plural keeps the count grammar correct without a sub-template.
2488
+ alertTooltipSingle: "This node has a broken reference. Open the inspector for details.",
2489
+ alertTooltipMany: "This node has {{count}} broken references. Open the inspector for details.",
2490
+ // Fix-summary copy when the broken trigger has a same-named file on
2491
+ // disk that does not advertise `name:` in its frontmatter. Two
2492
+ // variants for single vs multiple candidates; same template family
2493
+ // as the alert tooltips above.
2494
+ hintSummarySingle: "Add `name: {{name}}` to the frontmatter of {{candidate}} so this reference resolves.",
2495
+ hintSummaryMany: "Add `name: {{name}}` to the frontmatter of one of these files so this reference resolves: {{candidates}}."
2724
2496
  };
2725
2497
 
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,
2498
+ // plugins/core/analyzers/reference-broken/index.ts
2499
+ var ID20 = "reference-broken";
2500
+ var referenceBrokenAnalyzer = {
2501
+ id: ID20,
2735
2502
  pluginId: CORE_PLUGIN_ID,
2736
2503
  kind: "analyzer",
2504
+ description: "Flags arrows pointing at a node not part of the current scan.",
2737
2505
  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
2506
+ // No `ui` declaration: this analyzer used to emit a per-finding
2507
+ // counter chip on `card.footer.right`, but that chip duplicated the
2508
+ // aggregate severity counters now owned by `core/issue-counter`. The
2509
+ // detection logic stays intact, only the chip emission is gone.
2510
+ ui: {},
2511
+ // The resolver, the reference-paths escape hatch, and the hint
2512
+ // index all share the per-link loop, splitting would re-walk
2513
+ // `ctx.links` once per concern. The per-source aggregation that
2514
+ // historically lived alongside (driving the now-retired chip
2515
+ // emission) moved into `core/issue-counter`.
2743
2516
  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
- }
2517
+ const byPath3 = new Set(ctx.nodes.map((n) => n.path));
2518
+ const byNormalizedName = indexByNormalizedName(ctx.nodes);
2519
+ const byBasenameWithoutName = indexByBasenameWithoutName(ctx.nodes);
2520
+ const refIndex = ctx.referenceablePaths && ctx.referenceablePaths.size > 0 && ctx.cwd ? { paths: ctx.referenceablePaths, cwd: ctx.cwd } : null;
2521
+ const issues = [];
2763
2522
  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);
2523
+ if (isResolved(link, byPath3, byNormalizedName)) continue;
2524
+ if (refIndex && resolvesViaReferencePaths(link, refIndex)) continue;
2525
+ const candidates = findHintCandidates(link, byBasenameWithoutName);
2526
+ issues.push(buildIssue(link, candidates));
2776
2527
  }
2777
2528
  return issues;
2778
2529
  }
2779
2530
  };
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;
2531
+ function buildIssue(link, hintCandidates = []) {
2532
+ const data = {
2533
+ target: link.target,
2534
+ kind: link.kind,
2535
+ trigger: link.trigger?.normalizedTrigger ?? null
2536
+ };
2537
+ const issue = {
2538
+ analyzerId: ID20,
2539
+ // `error`, not `warn`: a link whose target is not in the scan is a
2540
+ // structural defect the operator must notice, and the card chip
2541
+ // paints `danger` (red) to match. Per the chip-vs-issue policy in
2542
+ // `context/view-slots.md`, a `danger` chip MUST be backed by an
2543
+ // `error` Issue so the visual signal lines up with the exit code
2544
+ // and the global error count on the card.
2545
+ severity: "error",
2546
+ nodeIds: [link.source],
2547
+ message: tx(REFERENCE_BROKEN_TEXTS.message, {
2548
+ kind: link.kind,
2549
+ source: link.source,
2550
+ target: link.target
2551
+ }),
2552
+ data
2553
+ };
2554
+ if (hintCandidates.length > 0) {
2555
+ const suggestedName = (link.trigger?.normalizedTrigger ?? "").replace(/^[/@]/, "").trim();
2556
+ const candidatePaths = hintCandidates.map((n) => n.path);
2557
+ data["hint"] = {
2558
+ kind: "missing-frontmatter-name",
2559
+ suggestedName,
2560
+ candidates: candidatePaths
2561
+ };
2562
+ issue.fix = {
2563
+ summary: candidatePaths.length === 1 ? tx(REFERENCE_BROKEN_TEXTS.hintSummarySingle, {
2564
+ name: suggestedName,
2565
+ candidate: candidatePaths[0]
2566
+ }) : tx(REFERENCE_BROKEN_TEXTS.hintSummaryMany, {
2567
+ name: suggestedName,
2568
+ candidates: candidatePaths.join(", ")
2569
+ }),
2570
+ autofixable: false
2571
+ };
2797
2572
  }
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
- );
2573
+ return issue;
2574
+ }
2575
+ function resolvesViaReferencePaths(link, refIndex) {
2576
+ if (!isPathStyleLink(link)) return false;
2577
+ return refIndex.paths.has(resolve3(refIndex.cwd, link.target));
2578
+ }
2579
+ function indexByNormalizedName(nodes) {
2580
+ const out = /* @__PURE__ */ new Map();
2581
+ for (const node of nodes) {
2582
+ const raw = node.frontmatter?.["name"];
2583
+ const name = typeof raw === "string" ? raw : "";
2584
+ if (!name) continue;
2585
+ const key = normalizeTrigger(name);
2586
+ const bucket = out.get(key) ?? [];
2587
+ bucket.push(node);
2588
+ out.set(key, bucket);
2807
2589
  }
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
- );
2590
+ return out;
2591
+ }
2592
+ function basenameWithoutExt(path) {
2593
+ const base = pathPosix4.basename(path);
2594
+ const ext = pathPosix4.extname(base);
2595
+ return ext ? base.slice(0, -ext.length) : base;
2596
+ }
2597
+ function indexByBasenameWithoutName(nodes) {
2598
+ const out = /* @__PURE__ */ new Map();
2599
+ for (const node of nodes) {
2600
+ const raw = node.frontmatter?.["name"];
2601
+ const name = typeof raw === "string" ? raw : "";
2602
+ if (name) continue;
2603
+ const bare = basenameWithoutExt(node.path);
2604
+ if (!bare) continue;
2605
+ const key = normalizeTrigger(bare);
2606
+ if (!key) continue;
2607
+ const bucket = out.get(key) ?? [];
2608
+ bucket.push(node);
2609
+ out.set(key, bucket);
2823
2610
  }
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
- };
2611
+ return out;
2843
2612
  }
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";
2850
-
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);
2613
+ function findHintCandidates(link, idx) {
2614
+ const normalized = link.trigger?.normalizedTrigger;
2615
+ if (!normalized) return [];
2616
+ const sigil = normalized.charAt(0);
2617
+ if (sigil !== "/" && sigil !== "@") return [];
2618
+ const withoutSigil = normalized.slice(1).trim();
2619
+ if (!withoutSigil) return [];
2620
+ return idx.get(withoutSigil) ?? [];
2621
+ }
2622
+ function isResolved(link, byPath3, byNormalizedName) {
2623
+ const normalized = link.trigger?.normalizedTrigger;
2624
+ if (normalized) {
2625
+ const withoutSigil = normalized.replace(/^[/@]/, "").trim();
2626
+ if (byNormalizedName.has(withoutSigil)) return true;
2627
+ }
2628
+ if (byPath3.has(link.target)) return true;
2629
+ return false;
2630
+ }
2631
+ function isPathStyleLink(link) {
2632
+ const sigil = link.trigger?.normalizedTrigger?.charAt(0);
2633
+ if (sigil === "/" || sigil === "@") return false;
2634
+ return true;
2856
2635
  }
2857
2636
 
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}}.",
2866
- // Tooltips for the per-node view-contribution badges. Singular vs
2867
- // 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."
2637
+ // plugins/core/analyzers/reference-redundant/text.ts
2638
+ var REFERENCE_REDUNDANT_TEXTS = {
2639
+ /**
2640
+ * Multi-form / multi-occurrence reference message. Lists each
2641
+ * occurrence (trigger + line) so the operator sees the full
2642
+ * authorial surface without having to grep the body.
2643
+ */
2644
+ message: "{{source}} references {{resolvedTarget}} via {{count}} occurrences: {{occurrences}}. Consider consolidating to a single form to reduce maintenance surface and avoid duplicate inlining at runtime.",
2645
+ /** Inline separator between occurrences in the message. */
2646
+ occurrenceSeparator: ", ",
2647
+ /** Per-occurrence formatting (trigger + line). */
2648
+ occurrence: "`{{trigger}}` ({{kind}}, line {{line}})",
2649
+ /** Per-occurrence formatting when the extractor did not record a line. */
2650
+ occurrenceUnknownLine: "`{{trigger}}` ({{kind}}, unknown line)"
2870
2651
  };
2871
2652
 
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,
2653
+ // plugins/core/analyzers/reference-redundant/index.ts
2654
+ var ID21 = "reference-redundant";
2655
+ var referenceRedundantAnalyzer = {
2656
+ id: ID21,
2877
2657
  pluginId: CORE_PLUGIN_ID,
2878
2658
  kind: "analyzer",
2879
- version: "1.0.0",
2880
- description: "Detects and flags typos or unrecognized keys in sidecars (`.sm`).",
2659
+ description: "Flags when one node references the same target through two or more different links (e.g. a markdown link plus a `references:` entry).",
2881
2660
  mode: "deterministic",
2882
- ui: {
2883
- // Corner badge on the graph card; count omitted when there is a
2884
- // single unknown field (avoids a noisy "icon + 1" chip).
2885
- alert: {
2886
- 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",
2891
- emitWhenEmpty: false
2892
- },
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.
2900
- chip: {
2901
- slot: "card.footer.right",
2902
- icon: "pi-question-circle",
2903
- emitWhenEmpty: true,
2904
- priority: 30
2905
- }
2906
- },
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).
2912
- // eslint-disable-next-line complexity
2913
2661
  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);
2921
- const issues = [];
2922
- const perNode = /* @__PURE__ */ new Map();
2923
- const bump2 = (nodePath) => {
2924
- perNode.set(nodePath, (perNode.get(nodePath) ?? 0) + 1);
2925
- };
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
- }
2662
+ if (ctx.links.length === 0) return [];
2663
+ const byPath3 = /* @__PURE__ */ new Map();
2664
+ for (const node of ctx.nodes) byPath3.set(node.path, node);
2665
+ const byName = buildNameIndex2(ctx.nodes);
2666
+ const groups = /* @__PURE__ */ new Map();
2667
+ for (const link of ctx.links) {
2668
+ const resolved = resolveTargetPath(link, byPath3, byName);
2669
+ if (!resolved) continue;
2670
+ const key = `${link.source}\0${resolved}`;
2671
+ const bucket = groups.get(key);
2672
+ if (bucket) bucket.push(link);
2673
+ else groups.set(key, [link]);
2988
2674
  }
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,
2675
+ const issues = [];
2676
+ for (const [key, links] of groups) {
2677
+ const totalOccurrences = links.reduce((acc, l) => acc + (l.occurrences?.length ?? 1), 0);
2678
+ if (totalOccurrences < 2) continue;
2679
+ const [source, resolvedTarget] = key.split("\0");
2680
+ const flat = flattenOccurrences(links);
2681
+ issues.push({
2682
+ analyzerId: ID21,
2998
2683
  severity: "warn",
2999
- tooltip
2684
+ nodeIds: [source],
2685
+ message: tx(REFERENCE_REDUNDANT_TEXTS.message, {
2686
+ source,
2687
+ resolvedTarget,
2688
+ count: flat.length,
2689
+ occurrences: flat.map(formatOccurrence).join(REFERENCE_REDUNDANT_TEXTS.occurrenceSeparator)
2690
+ }),
2691
+ data: {
2692
+ target: resolvedTarget,
2693
+ resolvedTarget,
2694
+ occurrences: flat.map((o) => ({
2695
+ kind: o.kind,
2696
+ trigger: o.originalTrigger,
2697
+ line: o.line ?? null,
2698
+ extractor: o.extractor
2699
+ }))
2700
+ }
3000
2701
  });
3001
2702
  }
3002
2703
  return issues;
3003
2704
  }
3004
2705
  };
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;
2706
+ function flattenOccurrences(links) {
2707
+ const out = [];
2708
+ for (const link of links) {
2709
+ if (link.occurrences && link.occurrences.length > 0) {
2710
+ for (const occ of link.occurrences) {
2711
+ out.push({
2712
+ kind: link.kind,
2713
+ originalTrigger: occ.originalTrigger,
2714
+ extractor: occ.extractor,
2715
+ line: occ.location?.line ?? null
2716
+ });
2717
+ }
2718
+ continue;
2719
+ }
2720
+ const trigger = link.trigger?.originalTrigger ?? link.target;
2721
+ out.push({
2722
+ kind: link.kind,
2723
+ originalTrigger: trigger,
2724
+ extractor: link.sources[0] ?? "unknown",
2725
+ line: link.location?.line ?? null
2726
+ });
2727
+ }
2728
+ out.sort((a, b) => {
2729
+ const la = a.line ?? Number.MAX_SAFE_INTEGER;
2730
+ const lb = b.line ?? Number.MAX_SAFE_INTEGER;
2731
+ if (la !== lb) return la - lb;
2732
+ return a.originalTrigger.localeCompare(b.originalTrigger);
2733
+ });
2734
+ return out;
2735
+ }
2736
+ function formatOccurrence(occ) {
2737
+ if (occ.line === null) {
2738
+ return tx(REFERENCE_REDUNDANT_TEXTS.occurrenceUnknownLine, { trigger: occ.originalTrigger, kind: occ.kind });
2739
+ }
2740
+ return tx(REFERENCE_REDUNDANT_TEXTS.occurrence, { trigger: occ.originalTrigger, kind: occ.kind, line: occ.line });
3016
2741
  }
3017
- function indexNamespacedContributions(contributions) {
2742
+ function buildNameIndex2(nodes) {
3018
2743
  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 {
2744
+ for (const node of nodes) {
2745
+ for (const candidate of collectIdentifiers(node)) {
2746
+ const normalised = normalizeTrigger(candidate);
2747
+ if (!normalised) continue;
2748
+ const bucket = out.get(normalised);
2749
+ if (bucket) bucket.push(node.path);
2750
+ else out.set(normalised, [node.path]);
3031
2751
  }
3032
2752
  }
3033
2753
  return out;
3034
2754
  }
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);
2755
+ function collectIdentifiers(node) {
2756
+ const out = [];
2757
+ const fmName = node.frontmatter?.["name"];
2758
+ if (typeof fmName === "string" && fmName.length > 0) out.push(fmName);
2759
+ const segs = node.path.split("/");
2760
+ const last = segs[segs.length - 1] ?? "";
2761
+ if (last) {
2762
+ const stem = last.replace(/\.[^.]+$/, "");
2763
+ if (stem) out.push(stem);
2764
+ }
2765
+ if (segs.length >= 2) {
2766
+ const dirBase = segs[segs.length - 2];
2767
+ if (dirBase) out.push(dirBase);
3039
2768
  }
3040
2769
  return out;
3041
2770
  }
3042
- function collectPluginIds(contributions) {
3043
- const out = /* @__PURE__ */ new Set();
3044
- for (const entry of contributions) out.add(entry.pluginId);
3045
- return out;
2771
+ function resolveTargetPath(link, byPath3, byName) {
2772
+ if (byPath3.has(link.target)) return link.target;
2773
+ const trigger = link.trigger?.normalizedTrigger;
2774
+ if (!trigger) return null;
2775
+ const stripped = trigger.replace(/^[/@]/, "").trim();
2776
+ if (!stripped) return null;
2777
+ const candidates = byName.get(stripped);
2778
+ if (!candidates || candidates.length === 0) return null;
2779
+ return candidates[0] ?? null;
3046
2780
  }
3047
2781
 
3048
2782
  // kernel/adapters/schema-validators.ts
@@ -3231,185 +2965,449 @@ function registerProviderAuxiliarySchemas(ajv, providers) {
3231
2965
  ajv.addSchema(aux);
3232
2966
  }
3233
2967
  }
3234
- }
3235
- function resolveSpecRoot() {
3236
- const require2 = createRequire2(import.meta.url);
3237
- try {
3238
- const indexPath = require2.resolve("@skill-map/spec/index.json");
3239
- return dirname2(indexPath);
3240
- } catch {
3241
- throw new Error(
3242
- "@skill-map/spec not resolvable: ensure the workspace is linked or the package is installed."
3243
- );
2968
+ }
2969
+ function resolveSpecRoot() {
2970
+ const require2 = createRequire2(import.meta.url);
2971
+ try {
2972
+ const indexPath = require2.resolve("@skill-map/spec/index.json");
2973
+ return dirname2(indexPath);
2974
+ } catch {
2975
+ throw new Error(
2976
+ "@skill-map/spec not resolvable: ensure the workspace is linked or the package is installed."
2977
+ );
2978
+ }
2979
+ }
2980
+ function existsSyncSafe(path) {
2981
+ try {
2982
+ readFileSync2(path, "utf8");
2983
+ return true;
2984
+ } catch {
2985
+ return false;
2986
+ }
2987
+ }
2988
+
2989
+ // plugins/core/analyzers/schema-violation/text.ts
2990
+ var SCHEMA_VIOLATION_TEXTS = {
2991
+ /** `Node <path> failed schema validation: <errors>` */
2992
+ nodeFailure: "Node {{path}} failed schema validation: {{errors}}",
2993
+ /** `Link <source> → <target> failed schema validation: <errors>` */
2994
+ linkFailure: "Link {{source}} \u2192 {{target}} failed schema validation: {{errors}}",
2995
+ /** `Node <path> is missing required frontmatter fields: <missing>` */
2996
+ frontmatterBaseFailure: "Node {{path}} is missing required frontmatter fields: {{missing}}.",
2997
+ /** Singular tooltip on the alert / chip when a node has exactly one validation failure. */
2998
+ alertTooltipSingle: "Frontmatter or schema validation failed.",
2999
+ /** Plural tooltip; `{{count}}` capped at 99 in the chip badge but the tooltip text shows the raw count. */
3000
+ alertTooltipMany: "{{count}} schema validation issues on this node."
3001
+ };
3002
+
3003
+ // plugins/core/analyzers/schema-violation/index.ts
3004
+ var ID22 = "schema-violation";
3005
+ var schemaViolationAnalyzer = {
3006
+ id: ID22,
3007
+ pluginId: CORE_PLUGIN_ID,
3008
+ kind: "analyzer",
3009
+ description: "Flags nodes or links that violate the project schemas.",
3010
+ mode: "deterministic",
3011
+ // No `ui` declaration: the per-node failure-count chip used to live
3012
+ // on `card.footer.right`, but its information is now folded into the
3013
+ // aggregate severity counters emitted by `core/issue-counter`. The
3014
+ // findings still emit as `Issue` records, so `sm check` / inspector
3015
+ // unchanged.
3016
+ ui: {},
3017
+ // Pre-existing complexity: validates every node + every link
3018
+ // against multiple schemas with per-severity aggregation. The
3019
+ // branching mirrors the schema catalog and splitting scatters the
3020
+ // validation contract. Tracked as tech-debt; surfaced when an
3021
+ // unrelated change touched the lint cache.
3022
+ // eslint-disable-next-line complexity
3023
+ evaluate(ctx) {
3024
+ const validators = loadSchemaValidators();
3025
+ const findings = [];
3026
+ const perNode = /* @__PURE__ */ new Map();
3027
+ for (const node of ctx.nodes) {
3028
+ const before = findings.length;
3029
+ collectNodeFindings(validators, node, findings);
3030
+ collectFrontmatterBaseFindings(node, findings);
3031
+ if (findings.length > before) {
3032
+ let worst = "warn";
3033
+ for (let i = before; i < findings.length; i++) {
3034
+ if (findings[i].severity === "error") {
3035
+ worst = "danger";
3036
+ break;
3037
+ }
3038
+ }
3039
+ const prev = perNode.get(node.path);
3040
+ perNode.set(node.path, {
3041
+ count: (prev?.count ?? 0) + (findings.length - before),
3042
+ worst: prev?.worst === "danger" ? "danger" : worst
3043
+ });
3044
+ }
3045
+ }
3046
+ for (const link of ctx.links) {
3047
+ collectLinkFindings(validators, link, findings);
3048
+ }
3049
+ void perNode;
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: ID22,
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: ID22,
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: ID22,
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 ID23 = "signal-collision";
3174
+ var signalCollisionAnalyzer = {
3175
+ id: ID23,
3176
+ pluginId: CORE_PLUGIN_ID,
3177
+ kind: "analyzer",
3178
+ 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.",
3179
+ mode: "deterministic",
3180
+ evaluate(ctx) {
3181
+ const signals = ctx.signals;
3182
+ if (!signals || signals.length === 0) return [];
3183
+ const issues = [];
3184
+ for (const signal of signals) {
3185
+ const issue = makeIssue(signal);
3186
+ if (issue) issues.push(issue);
3187
+ }
3188
+ return issues;
3189
+ }
3190
+ };
3191
+ function makeIssue(signal) {
3192
+ const resolution = signal.resolution;
3193
+ if (!resolution || resolution.outcome !== "rejected") return null;
3194
+ if (resolution.rejectedBy) {
3195
+ const winner = resolution.rejectedBy;
3196
+ const winnerCandidate = signal.candidates[resolution.winnerIndex ?? 0];
3197
+ const loserRange = signal.range ? `${signal.range.start}-${signal.range.end}` : "unknown";
3198
+ const winnerRange = `${winner.range.start}-${winner.range.end}`;
3199
+ return {
3200
+ analyzerId: ID23,
3201
+ severity: "warn",
3202
+ nodeIds: [signal.source],
3203
+ message: tx(SIGNAL_COLLISION_TEXTS.message, {
3204
+ loserExtractor: winnerCandidate.extractorId,
3205
+ loserRaw: signal.raw,
3206
+ loserRange,
3207
+ winnerExtractor: winner.extractorId,
3208
+ winnerRange,
3209
+ reason: winner.reason
3210
+ }),
3211
+ data: {
3212
+ loser: {
3213
+ extractorId: winnerCandidate.extractorId,
3214
+ raw: signal.raw,
3215
+ range: signal.range ?? null,
3216
+ candidate: {
3217
+ kind: winnerCandidate.kind,
3218
+ target: winnerCandidate.target,
3219
+ confidence: winnerCandidate.confidence
3220
+ }
3221
+ },
3222
+ winner: {
3223
+ extractorId: winner.extractorId,
3224
+ range: winner.range
3225
+ },
3226
+ reason: winner.reason
3227
+ }
3228
+ };
3229
+ }
3230
+ if (resolution.extractorDisabled) {
3231
+ const loserRange = signal.range ? `${signal.range.start}-${signal.range.end}` : "unknown";
3232
+ return {
3233
+ analyzerId: ID23,
3234
+ severity: "warn",
3235
+ nodeIds: [signal.source],
3236
+ message: tx(SIGNAL_COLLISION_TEXTS.messageExtractorDisabled, {
3237
+ extractorId: resolution.extractorDisabled.extractorId,
3238
+ loserRaw: signal.raw,
3239
+ loserRange
3240
+ }),
3241
+ data: {
3242
+ extractorDisabled: resolution.extractorDisabled,
3243
+ raw: signal.raw,
3244
+ range: signal.range ?? null
3245
+ }
3246
+ };
3244
3247
  }
3245
- }
3246
- function existsSyncSafe(path) {
3247
- try {
3248
- readFileSync2(path, "utf8");
3249
- return true;
3250
- } catch {
3251
- return false;
3248
+ if (resolution.belowFloor) {
3249
+ const loserRange = signal.range ? `${signal.range.start}-${signal.range.end}` : "unknown";
3250
+ const topCandidate = signal.candidates[0];
3251
+ return {
3252
+ analyzerId: ID23,
3253
+ severity: "warn",
3254
+ nodeIds: [signal.source],
3255
+ message: tx(SIGNAL_COLLISION_TEXTS.messageBelowFloor, {
3256
+ loserRaw: signal.raw,
3257
+ loserRange,
3258
+ confidence: topCandidate.confidence,
3259
+ threshold: resolution.belowFloor.threshold
3260
+ }),
3261
+ data: {
3262
+ belowFloor: resolution.belowFloor,
3263
+ raw: signal.raw,
3264
+ range: signal.range ?? null
3265
+ }
3266
+ };
3252
3267
  }
3268
+ return null;
3253
3269
  }
3254
3270
 
3255
- // plugins/core/analyzers/validate-all/text.ts
3256
- var VALIDATE_ALL_TEXTS = {
3257
- /** `Node <path> failed schema validation: <errors>` */
3258
- nodeFailure: "Node {{path}} failed schema validation: {{errors}}",
3259
- /** `Link <source> <target> failed schema validation: <errors>` */
3260
- linkFailure: "Link {{source}} \u2192 {{target}} failed schema validation: {{errors}}",
3261
- /** `Node <path> is missing required frontmatter fields: <missing>` */
3262
- frontmatterBaseFailure: "Node {{path}} is missing required frontmatter fields: {{missing}}.",
3263
- /** Singular tooltip on the alert / chip when a node has exactly one validation failure. */
3264
- alertTooltipSingle: "Frontmatter or schema validation failed.",
3265
- /** Plural tooltip; `{{count}}` capped at 99 in the chip badge but the tooltip text shows the raw count. */
3266
- alertTooltipMany: "{{count}} schema validation issues on this node."
3271
+ // plugins/core/analyzers/trigger-collision/text.ts
3272
+ var TRIGGER_COLLISION_TEXTS = {
3273
+ /**
3274
+ * Top-level message when `analyzeTriggerBucket` accumulated exactly one
3275
+ * cause part. Used for the advertiser-ambiguous-only, invocation-
3276
+ * ambiguous-only, and cross-kind-only branches.
3277
+ */
3278
+ messageOnePart: 'Trigger "{{normalized}}" has {{part}}.',
3279
+ /**
3280
+ * Top-level message when `analyzeTriggerBucket` accumulated two cause
3281
+ * parts (advertiser-ambiguous AND invocation-ambiguous fire together).
3282
+ * The joiner lives inside the template so future locales can adapt it
3283
+ * (e.g. `'; y '` in Spanish) without touching the rule code.
3284
+ */
3285
+ messageTwoParts: 'Trigger "{{normalized}}" has {{first}}; and {{second}}.',
3286
+ /** `<n> nodes advertise it: <list>` part, fires on the advertiser-ambiguous branch. */
3287
+ partAdvertisers: "{{count}} nodes advertise it: {{paths}}",
3288
+ /** `<n> distinct invocation forms: <list>` part, fires on the invocation-ambiguous branch. */
3289
+ partInvocations: "{{count}} distinct invocation forms: {{forms}}",
3290
+ /** Singular cross-kind cause: `non-canonical invocation <form> against advertiser <path>`. */
3291
+ partNonCanonicalSingular: "non-canonical invocation {{forms}} against advertiser {{advertiser}}",
3292
+ /** Plural cross-kind cause: `non-canonical invocations <forms> against advertiser <path>`. */
3293
+ partNonCanonicalPlural: "non-canonical invocations {{forms}} against advertiser {{advertiser}}"
3267
3294
  };
3268
3295
 
3269
- // plugins/core/analyzers/validate-all/index.ts
3270
- var ID23 = "validate-all";
3271
- var validateAllAnalyzer = {
3272
- id: ID23,
3296
+ // plugins/core/analyzers/trigger-collision/index.ts
3297
+ var ID24 = "trigger-collision";
3298
+ var ADVERTISING_KINDS = /* @__PURE__ */ new Set([
3299
+ "command",
3300
+ "skill",
3301
+ "agent"
3302
+ ]);
3303
+ var triggerCollisionAnalyzer = {
3304
+ id: ID24,
3273
3305
  pluginId: CORE_PLUGIN_ID,
3274
3306
  kind: "analyzer",
3275
- version: "1.0.0",
3276
- description: "Detects and flags nodes or links violating the project schemas.",
3277
3307
  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
- },
3308
+ description: "Flags two or more nodes that claim the same `/command` or `@agent` name.",
3309
+ // Two claim-collection passes (advertisement + invocation) feeding
3310
+ // the bucket map. Per-bucket analysis lives in `analyzeTriggerBucket`.
3311
+ // eslint-disable-next-line complexity
3299
3312
  evaluate(ctx) {
3300
- const validators = loadSchemaValidators();
3301
- const findings = [];
3302
- const perNode = /* @__PURE__ */ new Map();
3313
+ const buckets = /* @__PURE__ */ new Map();
3314
+ const push = (key, claim) => {
3315
+ const bucket = buckets.get(key) ?? [];
3316
+ bucket.push(claim);
3317
+ buckets.set(key, bucket);
3318
+ };
3303
3319
  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
- }
3320
+ if (!ADVERTISING_KINDS.has(node.kind)) continue;
3321
+ const raw = node.frontmatter?.["name"];
3322
+ if (typeof raw !== "string" || raw.length === 0) continue;
3323
+ const normalized = `/${normalizeTrigger(raw)}`;
3324
+ if (normalized === "/") continue;
3325
+ push(normalized, {
3326
+ kind: "advertiser",
3327
+ token: node.path,
3328
+ nodeId: node.path,
3329
+ canonicalForm: `/${raw}`
3330
+ });
3310
3331
  }
3311
3332
  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
3333
+ const normalized = link.trigger?.normalizedTrigger;
3334
+ if (!normalized) continue;
3335
+ push(normalized, {
3336
+ kind: "invocation",
3337
+ token: link.target,
3338
+ nodeId: link.source
3326
3339
  });
3327
3340
  }
3328
- return findings;
3341
+ const issues = [];
3342
+ for (const [normalized, claims] of buckets) {
3343
+ const issue = analyzeTriggerBucket(normalized, claims);
3344
+ if (issue) issues.push(issue);
3345
+ }
3346
+ return issues;
3329
3347
  }
3330
3348
  };
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 }
3366
- });
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({
3376
- analyzerId: ID23,
3377
- 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 }
3349
+ function analyzeTriggerBucket(normalized, claims) {
3350
+ const advertiserPaths = [
3351
+ ...new Set(claims.filter((c) => c.kind === "advertiser").map((c) => c.token))
3352
+ ].sort();
3353
+ const invocationTargets = [
3354
+ ...new Set(claims.filter((c) => c.kind === "invocation").map((c) => c.token))
3355
+ ].sort();
3356
+ const advertisers = claims.filter(
3357
+ (c) => c.kind === "advertiser"
3358
+ );
3359
+ const advertiserAmbiguous = advertiserPaths.length >= 2;
3360
+ const invocationAmbiguous = invocationTargets.length >= 2;
3361
+ const canonicalForms = new Set(advertisers.map((a) => a.canonicalForm));
3362
+ const nonCanonicalInvocations = invocationTargets.filter((t) => !canonicalForms.has(t));
3363
+ const crossKindAmbiguous = advertiserPaths.length === 1 && nonCanonicalInvocations.length >= 1;
3364
+ if (!advertiserAmbiguous && !invocationAmbiguous && !crossKindAmbiguous) {
3365
+ return null;
3366
+ }
3367
+ const nodeIds = [...new Set(claims.map((c) => c.nodeId))].sort();
3368
+ const parts = [];
3369
+ if (advertiserAmbiguous) {
3370
+ parts.push(
3371
+ tx(TRIGGER_COLLISION_TEXTS.partAdvertisers, {
3372
+ count: advertiserPaths.length,
3373
+ paths: advertiserPaths.join(", ")
3374
+ })
3375
+ );
3376
+ }
3377
+ if (invocationAmbiguous) {
3378
+ parts.push(
3379
+ tx(TRIGGER_COLLISION_TEXTS.partInvocations, {
3380
+ count: invocationTargets.length,
3381
+ forms: invocationTargets.join(", ")
3382
+ })
3383
+ );
3384
+ } else if (crossKindAmbiguous) {
3385
+ const template = nonCanonicalInvocations.length > 1 ? TRIGGER_COLLISION_TEXTS.partNonCanonicalPlural : TRIGGER_COLLISION_TEXTS.partNonCanonicalSingular;
3386
+ parts.push(
3387
+ tx(template, {
3388
+ forms: nonCanonicalInvocations.join(", "),
3389
+ advertiser: advertiserPaths[0]
3390
+ })
3391
+ );
3392
+ }
3393
+ const message = parts.length === 2 ? tx(TRIGGER_COLLISION_TEXTS.messageTwoParts, {
3394
+ normalized,
3395
+ first: parts[0],
3396
+ second: parts[1]
3397
+ }) : tx(TRIGGER_COLLISION_TEXTS.messageOnePart, {
3398
+ normalized,
3399
+ part: parts[0]
3385
3400
  });
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
3401
  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
3402
+ analyzerId: ID24,
3403
+ severity: "error",
3404
+ nodeIds,
3405
+ message,
3406
+ data: {
3407
+ normalizedTrigger: normalized,
3408
+ invocationTargets,
3409
+ advertiserPaths
3410
+ }
3413
3411
  };
3414
3412
  }
3415
3413
 
@@ -3441,15 +3439,14 @@ var ASCII_FORMATTER_TEXTS = {
3441
3439
  };
3442
3440
 
3443
3441
  // plugins/core/formatters/ascii/index.ts
3444
- var ID24 = "ascii";
3442
+ var ID25 = "ascii";
3445
3443
  var KIND_ORDER = ["agent", "command", "skill", "markdown"];
3446
3444
  var asciiFormatter = {
3447
- id: ID24,
3445
+ id: ID25,
3448
3446
  pluginId: CORE_PLUGIN_ID,
3449
3447
  kind: "formatter",
3450
- formatId: ID24,
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`.",
3448
+ formatId: ID25,
3449
+ description: "Renders the scan as plain text in three sections: nodes (grouped by kind), arrows, and issues. Used by `sm scan --format ascii`.",
3453
3450
  // ASCII tree formatter, header + per-kind sections + per-issue
3454
3451
  // section. Each section iterates and renders; splitting per section
3455
3452
  // would multiply the for-loop boilerplate.
@@ -3542,14 +3539,13 @@ function renderSection(out, kind, group) {
3542
3539
  }
3543
3540
 
3544
3541
  // plugins/core/formatters/json/index.ts
3545
- var ID25 = "json";
3542
+ var ID26 = "json";
3546
3543
  var jsonFormatter = {
3547
- id: ID25,
3544
+ id: ID26,
3548
3545
  pluginId: CORE_PLUGIN_ID,
3549
3546
  kind: "formatter",
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`.",
3552
- formatId: ID25,
3547
+ 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`.",
3548
+ formatId: ID26,
3553
3549
  format(ctx) {
3554
3550
  if (ctx.scanResult !== void 0) {
3555
3551
  return JSON.stringify(ctx.scanResult);
@@ -3687,14 +3683,13 @@ function resolveSpecRoot2() {
3687
3683
  }
3688
3684
  }
3689
3685
 
3690
- // plugins/core/actions/bump/index.ts
3691
- var ID26 = "bump";
3692
- var bumpAction = {
3693
- id: ID26,
3686
+ // plugins/core/actions/node-bump/index.ts
3687
+ var ID27 = "node-bump";
3688
+ var nodeBumpAction = {
3689
+ id: ID27,
3694
3690
  pluginId: CORE_PLUGIN_ID,
3695
3691
  kind: "action",
3696
- version: "1.0.0",
3697
- description: "Marks a node as updated: bumps version, refreshes sidecar hashes, records the timestamp.",
3692
+ description: "Marks a node as updated: bumps `annotations.version`, refreshes sidecar hashes, and records the timestamp.",
3698
3693
  mode: "deterministic",
3699
3694
  // The runtime contract uses generic <TInput, TReport>; bump narrows
3700
3695
  // both. The cast is the standard pattern for built-ins that want
@@ -3748,14 +3743,13 @@ function pickCurrentVersion(overlay) {
3748
3743
  return typeof v === "number" && Number.isFinite(v) ? v : 0;
3749
3744
  }
3750
3745
 
3751
- // plugins/core/actions/mark-superseded/index.ts
3752
- var ID27 = "mark-superseded";
3753
- var markSupersededAction = {
3754
- id: ID27,
3746
+ // plugins/core/actions/node-supersede/index.ts
3747
+ var ID28 = "node-supersede";
3748
+ var nodeSupersedeAction = {
3749
+ id: ID28,
3755
3750
  pluginId: CORE_PLUGIN_ID,
3756
3751
  kind: "action",
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.",
3752
+ description: "Declares the current node as superseded by another (writes `supersededBy` to the sidecar).",
3759
3753
  mode: "deterministic",
3760
3754
  invoke(_input, _ctx) {
3761
3755
  const report = { ok: true, noop: true };
@@ -3861,7 +3855,7 @@ var UPDATE_CHECK_TEXTS = {
3861
3855
  // package.json
3862
3856
  var package_default = {
3863
3857
  name: "@skill-map/cli",
3864
- version: "0.38.0",
3858
+ version: "0.40.0",
3865
3859
  description: "skill-map reference implementation \u2014 kernel + CLI + adapters.",
3866
3860
  license: "MIT",
3867
3861
  type: "module",
@@ -4278,8 +4272,7 @@ var updateCheckHook = {
4278
4272
  id: "update-check",
4279
4273
  pluginId: CORE_PLUGIN_ID,
4280
4274
  kind: "hook",
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.",
4275
+ description: "Checks daily for a newer `skill-map` version on npm. Shows an `update available` banner when one is found.",
4283
4276
  triggers: ["boot"],
4284
4277
  async on(ctx) {
4285
4278
  const payload = ctx.event.data ?? {};
@@ -4294,105 +4287,102 @@ var updateCheckHook = {
4294
4287
  };
4295
4288
 
4296
4289
  // plugins/built-ins.ts
4297
- var claudeProvider2 = { ...claudeProvider, pluginId: "claude" };
4298
- var atDirectiveExtractor2 = { ...atDirectiveExtractor, pluginId: "claude" };
4299
- var slashExtractor2 = { ...slashExtractor, pluginId: "claude" };
4300
- var antigravityProvider2 = { ...antigravityProvider, pluginId: "antigravity" };
4301
- var openaiProvider2 = { ...openaiProvider, pluginId: "openai" };
4302
- var agentSkillsProvider2 = { ...agentSkillsProvider, pluginId: "agent-skills" };
4303
- var coreMarkdownProvider2 = { ...coreMarkdownProvider, pluginId: "core" };
4304
- var annotationsExtractor2 = { ...annotationsExtractor, pluginId: "core" };
4305
- var externalUrlCounterExtractor2 = { ...externalUrlCounterExtractor, pluginId: "core" };
4306
- var markdownLinkExtractor2 = { ...markdownLinkExtractor, pluginId: "core" };
4307
- var mcpToolsExtractor2 = { ...mcpToolsExtractor, pluginId: "core" };
4308
- var toolsCountExtractor2 = { ...toolsCountExtractor, pluginId: "core" };
4309
- var annotationOrphanAnalyzer2 = { ...annotationOrphanAnalyzer, pluginId: "core" };
4310
- var annotationStaleAnalyzer2 = { ...annotationStaleAnalyzer, pluginId: "core" };
4311
- var brokenRefAnalyzer2 = { ...brokenRefAnalyzer, pluginId: "core" };
4312
- var contributionOrphanAnalyzer2 = { ...contributionOrphanAnalyzer, pluginId: "core" };
4313
- var jobOrphanFileAnalyzer2 = { ...jobOrphanFileAnalyzer, pluginId: "core" };
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" };
4319
- var signalCollisionAnalyzer2 = { ...signalCollisionAnalyzer, pluginId: "core" };
4320
- var stabilityAnalyzer2 = { ...stabilityAnalyzer, pluginId: "core" };
4321
- var supersededAnalyzer2 = { ...supersededAnalyzer, pluginId: "core" };
4322
- var triggerCollisionAnalyzer2 = { ...triggerCollisionAnalyzer, pluginId: "core" };
4323
- var unknownFieldAnalyzer2 = { ...unknownFieldAnalyzer, pluginId: "core" };
4324
- var validateAllAnalyzer2 = { ...validateAllAnalyzer, pluginId: "core" };
4325
- var asciiFormatter2 = { ...asciiFormatter, pluginId: "core" };
4326
- var jsonFormatter2 = { ...jsonFormatter, pluginId: "core" };
4327
- var bumpAction2 = { ...bumpAction, pluginId: "core" };
4328
- var markSupersededAction2 = { ...markSupersededAction, pluginId: "core" };
4329
- var updateCheckHook2 = { ...updateCheckHook, pluginId: "core" };
4290
+ var claudeProvider2 = { ...claudeProvider, pluginId: "claude", version: "0.40.0" };
4291
+ var atDirectiveExtractor2 = { ...atDirectiveExtractor, pluginId: "claude", version: "0.40.0" };
4292
+ var slashCommandExtractor2 = { ...slashCommandExtractor, pluginId: "claude", version: "0.40.0" };
4293
+ var antigravityProvider2 = { ...antigravityProvider, pluginId: "antigravity", version: "0.40.0" };
4294
+ var openaiProvider2 = { ...openaiProvider, pluginId: "openai", version: "0.40.0" };
4295
+ var agentSkillsProvider2 = { ...agentSkillsProvider, pluginId: "agent-skills", version: "0.40.0" };
4296
+ var coreMarkdownProvider2 = { ...coreMarkdownProvider, pluginId: "core", version: "0.40.0" };
4297
+ var annotationsExtractor2 = { ...annotationsExtractor, pluginId: "core", version: "0.40.0" };
4298
+ var externalUrlCounterExtractor2 = { ...externalUrlCounterExtractor, pluginId: "core", version: "0.40.0" };
4299
+ var markdownLinkExtractor2 = { ...markdownLinkExtractor, pluginId: "core", version: "0.40.0" };
4300
+ var mcpToolsExtractor2 = { ...mcpToolsExtractor, pluginId: "core", version: "0.40.0" };
4301
+ var toolsCounterExtractor2 = { ...toolsCounterExtractor, pluginId: "core", version: "0.40.0" };
4302
+ var annotationFieldUnknownAnalyzer2 = { ...annotationFieldUnknownAnalyzer, pluginId: "core", version: "0.40.0" };
4303
+ var annotationOrphanAnalyzer2 = { ...annotationOrphanAnalyzer, pluginId: "core", version: "0.40.0" };
4304
+ var annotationStaleAnalyzer2 = { ...annotationStaleAnalyzer, pluginId: "core", version: "0.40.0" };
4305
+ var contributionOrphanAnalyzer2 = { ...contributionOrphanAnalyzer, pluginId: "core", version: "0.40.0" };
4306
+ var issueCounterAnalyzer2 = { ...issueCounterAnalyzer, pluginId: "core", version: "0.40.0" };
4307
+ var jobFileOrphanAnalyzer2 = { ...jobFileOrphanAnalyzer, pluginId: "core", version: "0.40.0" };
4308
+ var linkConflictAnalyzer2 = { ...linkConflictAnalyzer, pluginId: "core", version: "0.40.0" };
4309
+ var linkCounterAnalyzer2 = { ...linkCounterAnalyzer, pluginId: "core", version: "0.40.0" };
4310
+ var linkSelfLoopAnalyzer2 = { ...linkSelfLoopAnalyzer, pluginId: "core", version: "0.40.0" };
4311
+ var nameReservedAnalyzer2 = { ...nameReservedAnalyzer, pluginId: "core", version: "0.40.0" };
4312
+ var nodeStabilityAnalyzer2 = { ...nodeStabilityAnalyzer, pluginId: "core", version: "0.40.0" };
4313
+ var nodeSupersededAnalyzer2 = { ...nodeSupersededAnalyzer, pluginId: "core", version: "0.40.0" };
4314
+ var referenceBrokenAnalyzer2 = { ...referenceBrokenAnalyzer, pluginId: "core", version: "0.40.0" };
4315
+ var referenceRedundantAnalyzer2 = { ...referenceRedundantAnalyzer, pluginId: "core", version: "0.40.0" };
4316
+ var schemaViolationAnalyzer2 = { ...schemaViolationAnalyzer, pluginId: "core", version: "0.40.0" };
4317
+ var signalCollisionAnalyzer2 = { ...signalCollisionAnalyzer, pluginId: "core", version: "0.40.0" };
4318
+ var triggerCollisionAnalyzer2 = { ...triggerCollisionAnalyzer, pluginId: "core", version: "0.40.0" };
4319
+ var asciiFormatter2 = { ...asciiFormatter, pluginId: "core", version: "0.40.0" };
4320
+ var jsonFormatter2 = { ...jsonFormatter, pluginId: "core", version: "0.40.0" };
4321
+ var nodeBumpAction2 = { ...nodeBumpAction, pluginId: "core", version: "0.40.0" };
4322
+ var nodeSupersedeAction2 = { ...nodeSupersedeAction, pluginId: "core", version: "0.40.0" };
4323
+ var updateCheckHook2 = { ...updateCheckHook, pluginId: "core", version: "0.40.0" };
4330
4324
  var builtInBundles = [
4331
4325
  {
4332
4326
  id: "claude",
4333
- granularity: "bundle",
4334
- description: "Claude Code platform integration. Classifies files under `.claude/{agents,commands,skills}` and parses Claude-flavored frontmatter.",
4327
+ 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
4328
  extensions: [
4336
4329
  claudeProvider2,
4337
4330
  atDirectiveExtractor2,
4338
- slashExtractor2
4331
+ slashCommandExtractor2
4339
4332
  ]
4340
4333
  },
4341
4334
  {
4342
4335
  id: "antigravity",
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.",
4336
+ 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
4337
  extensions: [
4346
4338
  antigravityProvider2
4347
4339
  ]
4348
4340
  },
4349
4341
  {
4350
4342
  id: "openai",
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.",
4343
+ description: "OpenAI Codex CLI platform integration. Classifies TOML sub-agent definitions under `.codex/agents/*.toml`.",
4353
4344
  extensions: [
4354
4345
  openaiProvider2
4355
4346
  ]
4356
4347
  },
4357
4348
  {
4358
4349
  id: "agent-skills",
4359
- granularity: "bundle",
4360
- description: "Agent Skills open standard. Vendor-neutral path `.agents/skills/<name>/SKILL.md` (Anthropic, OpenAI, Google). See agentskills.io.",
4350
+ 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
4351
  extensions: [
4362
4352
  agentSkillsProvider2
4363
4353
  ]
4364
4354
  },
4365
4355
  {
4366
4356
  id: "core",
4367
- granularity: "extension",
4368
- description: "Core extensions shared across providers: extractors, analyzers, formatters, the bump action, and the universal `.md` fallback Provider.",
4357
+ description: "Core extensions shared across providers: parsers, extractors, analyzers, actions, hooks, formatters, and the universal `.md` fallback provider.",
4369
4358
  extensions: [
4370
4359
  coreMarkdownProvider2,
4371
4360
  annotationsExtractor2,
4372
4361
  externalUrlCounterExtractor2,
4373
4362
  markdownLinkExtractor2,
4374
4363
  mcpToolsExtractor2,
4375
- toolsCountExtractor2,
4364
+ toolsCounterExtractor2,
4365
+ annotationFieldUnknownAnalyzer2,
4376
4366
  annotationOrphanAnalyzer2,
4377
4367
  annotationStaleAnalyzer2,
4378
- brokenRefAnalyzer2,
4379
4368
  contributionOrphanAnalyzer2,
4380
- jobOrphanFileAnalyzer2,
4369
+ issueCounterAnalyzer2,
4370
+ jobFileOrphanAnalyzer2,
4381
4371
  linkConflictAnalyzer2,
4382
- linkCountsAnalyzer2,
4383
- redundantTargetReferenceAnalyzer2,
4384
- reservedNameAnalyzer2,
4385
- selfLoopAnalyzer2,
4372
+ linkCounterAnalyzer2,
4373
+ linkSelfLoopAnalyzer2,
4374
+ nameReservedAnalyzer2,
4375
+ nodeStabilityAnalyzer2,
4376
+ nodeSupersededAnalyzer2,
4377
+ referenceBrokenAnalyzer2,
4378
+ referenceRedundantAnalyzer2,
4379
+ schemaViolationAnalyzer2,
4386
4380
  signalCollisionAnalyzer2,
4387
- stabilityAnalyzer2,
4388
- supersededAnalyzer2,
4389
4381
  triggerCollisionAnalyzer2,
4390
- unknownFieldAnalyzer2,
4391
- validateAllAnalyzer2,
4392
4382
  asciiFormatter2,
4393
4383
  jsonFormatter2,
4394
- bumpAction2,
4395
- markSupersededAction2,
4384
+ nodeBumpAction2,
4385
+ nodeSupersedeAction2,
4396
4386
  updateCheckHook2
4397
4387
  ]
4398
4388
  }
@@ -4519,21 +4509,42 @@ function localTimeFromIso(iso) {
4519
4509
  const ss = String(d.getSeconds()).padStart(2, "0");
4520
4510
  return `${hh}:${mm}:${ss}`;
4521
4511
  }
4522
- var defaultFormat = (record) => {
4512
+ function paintLevelPrefix(level, ansi) {
4513
+ const label = level.toUpperCase().padEnd(5);
4514
+ switch (level) {
4515
+ case "error":
4516
+ return `${ansi.red("\u2715")} ${ansi.red(label)}`;
4517
+ case "warn":
4518
+ return `${ansi.yellow("\u26A0")} ${ansi.yellow(label)}`;
4519
+ case "info":
4520
+ return `${ansi.cyan("\u2139")} ${ansi.cyan(label)}`;
4521
+ case "debug":
4522
+ case "trace":
4523
+ return `${ansi.dim("\xB7")} ${ansi.dim(label)}`;
4524
+ }
4525
+ }
4526
+ var defaultFormat = (record, ansi) => {
4523
4527
  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}
4528
+ const prefix = paintLevelPrefix(record.level, ansi);
4529
+ const ctx = record.context && Object.keys(record.context).length > 0 ? ` ${ansi.dim("|")} ${ansi.dim(JSON.stringify(record.context))}` : "";
4530
+ return `${ansi.dim(time)} ${prefix} ${record.message}${ctx}
4527
4531
  `;
4528
4532
  };
4529
4533
  var Logger = class {
4530
4534
  #level;
4531
4535
  #stream;
4532
4536
  #format;
4537
+ #ansi;
4533
4538
  constructor(opts) {
4534
4539
  this.#level = opts.level;
4535
4540
  this.#stream = opts.stream;
4536
4541
  this.#format = opts.format ?? defaultFormat;
4542
+ const streamTty = opts.stream;
4543
+ this.#ansi = ansiFor({
4544
+ isTTY: streamTty.isTTY === true,
4545
+ noColorFlag: opts.noColorFlag === true,
4546
+ ...opts.env !== void 0 ? { env: opts.env } : {}
4547
+ });
4537
4548
  }
4538
4549
  setLevel(level) {
4539
4550
  this.#level = level;
@@ -4564,7 +4575,7 @@ var Logger = class {
4564
4575
  message,
4565
4576
  ...context !== void 0 ? { context } : {}
4566
4577
  };
4567
- this.#stream.write(this.#format(record));
4578
+ this.#stream.write(this.#format(record, this.#ansi));
4568
4579
  }
4569
4580
  };
4570
4581
  function resolveLogLevel(opts) {
@@ -7246,9 +7257,9 @@ async function sweepPerTupleContributions(trx, contributions, freshlyRunTuples)
7246
7257
  const bufferKeys = buildContributionsBufferKeys(contributions);
7247
7258
  const tuplesByPluginExt = groupFreshlyRunTuplesByPluginExt(freshlyRunTuples);
7248
7259
  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);
7260
+ const sep8 = pe.indexOf("\0");
7261
+ if (sep8 < 0) continue;
7262
+ await deleteStaleTupleRows(trx, pe.slice(0, sep8), pe.slice(sep8 + 1), [...nodes], bufferKeys);
7252
7263
  }
7253
7264
  }
7254
7265
  function buildContributionsBufferKeys(contributions) {
@@ -8406,12 +8417,12 @@ function planOne(node, options) {
8406
8417
  };
8407
8418
  }
8408
8419
  function invokeBumpFor(node, absPath, force) {
8409
- if (!bumpAction.invoke) {
8420
+ if (!nodeBumpAction.invoke) {
8410
8421
  throw new Error("built-in bump action is missing its invoke()");
8411
8422
  }
8412
8423
  const input = {};
8413
8424
  if (force) input.force = true;
8414
- return bumpAction.invoke(input, {
8425
+ return nodeBumpAction.invoke(input, {
8415
8426
  node,
8416
8427
  nodeAbsolutePath: absPath,
8417
8428
  invoker: "cli",
@@ -8427,7 +8438,7 @@ var BumpCommand = class extends SmCommand {
8427
8438
  category: "Actions",
8428
8439
  description: "Bump a node's sidecar (`<basename>.sm`): increment annotations.version, refresh hashes, stamp audit.",
8429
8440
  details: `
8430
- Wraps the built-in deterministic \`core/bump\` Action. Single-node
8441
+ Wraps the built-in deterministic \`core/node-bump\` Action. Single-node
8431
8442
  mode bumps one path; \`--pending\` walks every node whose sidecar
8432
8443
  overlay reports drift and bumps them all.
8433
8444
 
@@ -8912,7 +8923,16 @@ var CHECK_TEXTS = {
8912
8923
  tipLine: "\nTip: `sm refresh <node>` to revalidate a file after fixes.\n",
8913
8924
  // --- prob stub advisory ---------------------------------------------------
8914
8925
  probStubAdvisory: "sm check --include-prob: probabilistic Analyzer dispatch requires the job subsystem (Step 10). Stub: skipped {{count}} probabilistic analyzer(s): {{analyzerIds}}. Deterministic analyzers ran as usual; full dispatch lands when the job subsystem ships.\n",
8915
- probStubAdvisoryAsync: "sm check --include-prob --async: probabilistic Analyzer dispatch requires the job subsystem (Step 10). Stub: skipped {{count}} probabilistic analyzer(s): {{analyzerIds}}. The --async flag is reserved for future encoding (returns job ids without waiting once jobs land); today it is a no-op. Deterministic analyzers ran as usual.\n"
8926
+ probStubAdvisoryAsync: "sm check --include-prob --async: probabilistic Analyzer dispatch requires the job subsystem (Step 10). Stub: skipped {{count}} probabilistic analyzer(s): {{analyzerIds}}. The --async flag is reserved for future encoding (returns job ids without waiting once jobs land); today it is a no-op. Deterministic analyzers ran as usual.\n",
8927
+ /**
8928
+ * Emitted on stderr when `--analyzers` lists one or more ids the
8929
+ * loaded analyzer registry does not know. The user almost always
8930
+ * mistyped (e.g. `broken-ref` instead of `reference-broken`); listing
8931
+ * the valid ids inline lets them fix the call without a second round
8932
+ * trip through `sm plugins list`. Exit code is `ExitCode.Error` (2):
8933
+ * bad usage, not "no issues found".
8934
+ */
8935
+ unknownAnalyzerIds: "sm check: unknown analyzer id(s) in --analyzers: {{unknown}}.\nValid ids (qualified or short form accepted):\n{{known}}\n"
8916
8936
  };
8917
8937
 
8918
8938
  // cli/util/conformance-env.ts
@@ -9453,20 +9473,17 @@ var PluginLoader = class {
9453
9473
  * into a helper would scatter the return-on-failure pattern without
9454
9474
  * making the orchestration clearer.
9455
9475
  */
9456
- // eslint-disable-next-line complexity
9457
9476
  async loadOne(pluginPath) {
9458
9477
  const pluginId = pathId(pluginPath);
9459
9478
  const manifestResult = this.#parseAndValidateManifest(pluginPath, pluginId);
9460
9479
  if (!manifestResult.ok) return manifestResult.failure;
9461
9480
  const manifest = manifestResult.manifest;
9462
- const granularity = manifest.granularity ?? "extension";
9463
9481
  if (this.#options.resolveEnabled && !this.#options.resolveEnabled(pluginId)) {
9464
9482
  return {
9465
9483
  path: pluginPath,
9466
9484
  id: pluginId,
9467
9485
  status: "disabled",
9468
9486
  manifest,
9469
- granularity,
9470
9487
  reason: PLUGIN_LOADER_TEXTS.disabledByConfig
9471
9488
  };
9472
9489
  }
@@ -9488,7 +9505,6 @@ var PluginLoader = class {
9488
9505
  id: pluginId,
9489
9506
  status: "enabled",
9490
9507
  manifest,
9491
- granularity,
9492
9508
  extensions: loaded,
9493
9509
  ...storageSchemasResult.schemas ? { storageSchemas: storageSchemasResult.schemas } : {}
9494
9510
  };
@@ -9546,7 +9562,6 @@ var PluginLoader = class {
9546
9562
  id: pluginId,
9547
9563
  status: "incompatible-spec",
9548
9564
  manifest,
9549
- granularity: manifest.granularity ?? "extension",
9550
9565
  reason: tx(PLUGIN_LOADER_TEXTS.incompatibleSpec, {
9551
9566
  installedSpecVersion: this.#options.specVersion,
9552
9567
  specCompat: manifest.specCompat
@@ -9823,14 +9838,14 @@ var LOCKED_PLUGIN_IDS = /* @__PURE__ */ new Set([
9823
9838
  // unreachable from CLI / BFF / UI. Re-evaluate if a third-party ever
9824
9839
  // ships a competing supersession extractor.
9825
9840
  "core/annotations",
9826
- // `core/validate-all` validates every scanned Node against
9841
+ // `core/schema-violation` validates every scanned Node against
9827
9842
  // `node.schema.json` and every Link against `link.schema.json` (the
9828
9843
  // authoritative @skill-map/spec). Disabling it makes the system
9829
9844
  // persist non-conformant content silently, breaking the spec
9830
9845
  // invariant "what reaches the DB conforms to the spec". The check is
9831
9846
  // foundational, not advisory; lock it on so the guarantee holds
9832
9847
  // regardless of user / DB / settings hand-edits.
9833
- "core/validate-all",
9848
+ "core/schema-violation",
9834
9849
  // `core/ascii` is the only built-in Formatter today and the default
9835
9850
  // for `sm graph` (`--format ascii`). Disabling it breaks the verb
9836
9851
  // entirely (`composeFormatters` returns the empty list, the CLI
@@ -9865,21 +9880,9 @@ function isBuiltInExtensionEnabled(bundle, ext, resolveEnabled) {
9865
9880
  return isBundleEntryEnabled(bundle, ext.id, resolveEnabled);
9866
9881
  }
9867
9882
  function isBundleEntryEnabled(bundle, extId, resolveEnabled) {
9868
- if (bundle.granularity === "bundle") {
9869
- return resolveEnabled(bundle.id);
9870
- }
9871
9883
  return resolveEnabled(qualifiedExtensionId(bundle.id, extId));
9872
9884
  }
9873
- function buildGranularityMap(discovered) {
9874
- const out = /* @__PURE__ */ new Map();
9875
- for (const plugin of discovered) {
9876
- out.set(plugin.id, plugin.granularity ?? "bundle");
9877
- }
9878
- return out;
9879
- }
9880
- function isPluginExtensionEnabled(ext, granularityMap, resolveEnabled) {
9881
- const granularity = granularityMap.get(ext.pluginId) ?? "bundle";
9882
- if (granularity === "bundle") return resolveEnabled(ext.pluginId);
9885
+ function isPluginExtensionEnabled(ext, resolveEnabled) {
9883
9886
  return resolveEnabled(qualifiedExtensionId(ext.pluginId, ext.id));
9884
9887
  }
9885
9888
  async function buildEnabledResolver(ctx) {
@@ -10398,7 +10401,6 @@ function filterBuiltInManifests(manifests, resolveEnabled) {
10398
10401
  // core/runtime/plugin-runtime/composer.ts
10399
10402
  function composeScanExtensions(opts) {
10400
10403
  const resolveEnabled = opts.resolveEnabled ?? opts.pluginRuntime.resolveEnabled;
10401
- const granularityMap = buildGranularityMap(opts.pluginRuntime.discovered);
10402
10404
  const providers = [];
10403
10405
  const extractors = [];
10404
10406
  const analyzers = [];
@@ -10410,16 +10412,16 @@ function composeScanExtensions(opts) {
10410
10412
  );
10411
10413
  }
10412
10414
  for (const ext of opts.pluginRuntime.extensions.providers) {
10413
- if (isPluginExtensionEnabled(ext, granularityMap, resolveEnabled)) providers.push(ext);
10415
+ if (isPluginExtensionEnabled(ext, resolveEnabled)) providers.push(ext);
10414
10416
  }
10415
10417
  for (const ext of opts.pluginRuntime.extensions.extractors) {
10416
- if (isPluginExtensionEnabled(ext, granularityMap, resolveEnabled)) extractors.push(ext);
10418
+ if (isPluginExtensionEnabled(ext, resolveEnabled)) extractors.push(ext);
10417
10419
  }
10418
10420
  for (const ext of opts.pluginRuntime.extensions.analyzers) {
10419
- if (isPluginExtensionEnabled(ext, granularityMap, resolveEnabled)) analyzers.push(ext);
10421
+ if (isPluginExtensionEnabled(ext, resolveEnabled)) analyzers.push(ext);
10420
10422
  }
10421
10423
  for (const ext of opts.pluginRuntime.extensions.hooks) {
10422
- if (isPluginExtensionEnabled(ext, granularityMap, resolveEnabled)) hooks.push(ext);
10424
+ if (isPluginExtensionEnabled(ext, resolveEnabled)) hooks.push(ext);
10423
10425
  }
10424
10426
  const finalProviders = opts.killSwitches?.providers === true ? [] : providers;
10425
10427
  const finalExtractors = opts.killSwitches?.extractors === true ? [] : extractors;
@@ -10466,7 +10468,6 @@ function accumulateBuiltInScanExtensions(buckets, resolveEnabled) {
10466
10468
  function composeFormatters(opts) {
10467
10469
  const noBuiltIns = opts.noBuiltIns ?? false;
10468
10470
  const resolveEnabled = opts.resolveEnabled ?? opts.pluginRuntime.resolveEnabled;
10469
- const granularityMap = buildGranularityMap(opts.pluginRuntime.discovered);
10470
10471
  const out = [];
10471
10472
  if (!noBuiltIns) {
10472
10473
  for (const bundle of builtInBundles) {
@@ -10478,32 +10479,24 @@ function composeFormatters(opts) {
10478
10479
  }
10479
10480
  }
10480
10481
  for (const ext of opts.pluginRuntime.extensions.formatters) {
10481
- if (isPluginExtensionEnabled(ext, granularityMap, resolveEnabled)) out.push(ext);
10482
+ if (isPluginExtensionEnabled(ext, resolveEnabled)) out.push(ext);
10482
10483
  }
10483
10484
  return out;
10484
10485
  }
10485
10486
  function registerEnabledExtensions(kernel, pluginRuntime, options = {}) {
10486
10487
  const noBuiltIns = options.noBuiltIns === true;
10487
10488
  const resolveEnabled = options.resolveEnabled ?? pluginRuntime.resolveEnabled;
10488
- const granularityMap = buildGranularityMap(pluginRuntime.discovered);
10489
10489
  if (!noBuiltIns) {
10490
10490
  const enabledBuiltIns = filterBuiltInManifests(listBuiltIns(), resolveEnabled);
10491
10491
  for (const manifest of enabledBuiltIns) kernel.registry.register(manifest);
10492
10492
  }
10493
10493
  for (const manifest of pluginRuntime.manifests) {
10494
- if (!isPluginExtensionEnabled(manifest, granularityMap, resolveEnabled)) continue;
10494
+ if (!isPluginExtensionEnabled(manifest, resolveEnabled)) continue;
10495
10495
  kernel.registry.register(manifest);
10496
10496
  }
10497
10497
  if (kernel.setRegisteredAnnotationKeys) {
10498
10498
  const filteredAnnotations = pluginRuntime.annotationContributions.filter(
10499
- (entry) => (
10500
- // Annotation contributions live at plugin-id granularity (the
10501
- // catalog row carries `pluginId`, not `extensionId`), so the
10502
- // bundle-level toggle gates the entire row. Extension
10503
- // granularity falls through to the manifest-level filter above
10504
- // this surface is bundle-scoped by design.
10505
- resolveEnabled(entry.pluginId)
10506
- )
10499
+ (entry) => resolveEnabled(entry.pluginId)
10507
10500
  );
10508
10501
  kernel.setRegisteredAnnotationKeys(filteredAnnotations);
10509
10502
  }
@@ -10511,7 +10504,6 @@ function registerEnabledExtensions(kernel, pluginRuntime, options = {}) {
10511
10504
  const userContribs = pluginRuntime.viewContributions.filter(
10512
10505
  (entry) => isPluginExtensionEnabled(
10513
10506
  { pluginId: entry.pluginId, id: entry.extensionId },
10514
- granularityMap,
10515
10507
  resolveEnabled
10516
10508
  )
10517
10509
  );
@@ -10550,7 +10542,7 @@ var CheckCommand = class extends SmCommand {
10550
10542
  ["Print every current issue", "$0 check"],
10551
10543
  ["Machine-readable issue list", "$0 check --json"],
10552
10544
  ["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"],
10545
+ ["Restrict to specific rules", "$0 check --analyzers core/reference-broken,core/schema-violation"],
10554
10546
  ["Opt in to probabilistic analyzers (stub until Step 10)", "$0 check --include-prob"],
10555
10547
  ["Use a non-default DB file", "$0 check --db /path/to/skill-map.db"]
10556
10548
  ]
@@ -10577,22 +10569,8 @@ var CheckCommand = class extends SmCommand {
10577
10569
  const exit = requireDbOrExit(dbPath, this.context.stderr);
10578
10570
  if (exit !== null) return exit;
10579
10571
  const analyzerFilter = parseAnalyzersFlag(this.analyzers);
10580
- if (this.includeProb) {
10581
- const probAnalyzerIds = await detectProbAnalyzerIds({
10582
- noPlugins: this.noPlugins,
10583
- analyzerFilter,
10584
- printer: this.printer
10585
- });
10586
- if (probAnalyzerIds.length > 0) {
10587
- const template = this.async ? CHECK_TEXTS.probStubAdvisoryAsync : CHECK_TEXTS.probStubAdvisory;
10588
- this.printer.info(
10589
- tx(template, {
10590
- count: probAnalyzerIds.length,
10591
- analyzerIds: probAnalyzerIds.join(", ")
10592
- })
10593
- );
10594
- }
10595
- }
10572
+ const preflight = await this.#preflightAnalyzerCatalog(analyzerFilter);
10573
+ if (preflight.exit !== null) return preflight.exit;
10596
10574
  return withSqlite({ databasePath: dbPath, autoBackup: false }, async (adapter) => {
10597
10575
  let issues = await adapter.issues.listAll();
10598
10576
  if (this.node !== void 0) {
@@ -10615,6 +10593,53 @@ var CheckCommand = class extends SmCommand {
10615
10593
  return issues.some((i) => i.severity === "error") ? ExitCode.Issues : ExitCode.Ok;
10616
10594
  });
10617
10595
  }
10596
+ /**
10597
+ * Either an explicit `--analyzers` list or `--include-prob` forces a
10598
+ * load of the live Analyzer catalog: the first needs it to validate
10599
+ * the user-supplied ids against the registry, the second to enumerate
10600
+ * registered probabilistic analyzers. Sharing a single load keeps
10601
+ * `sm check` from paying for two passes when both flags are present.
10602
+ *
10603
+ * Returns `{ exit: <code> }` to short-circuit `run()` when the
10604
+ * validation rejects an unknown id (the only path that aborts before
10605
+ * the DB read). Successful runs return `{ exit: null }`.
10606
+ */
10607
+ async #preflightAnalyzerCatalog(analyzerFilter) {
10608
+ const needsCatalog = analyzerFilter !== void 0 || this.includeProb;
10609
+ if (!needsCatalog) return { exit: null };
10610
+ const analyzers = await loadAnalyzerCatalog({
10611
+ noPlugins: this.noPlugins,
10612
+ printer: this.printer
10613
+ });
10614
+ if (analyzerFilter !== void 0) {
10615
+ const validation = validateAnalyzerFilter(analyzerFilter, analyzers);
10616
+ if (validation !== null) {
10617
+ this.printer.error(validation);
10618
+ return { exit: ExitCode.Error };
10619
+ }
10620
+ }
10621
+ if (this.includeProb) {
10622
+ this.#emitProbAdvisory(analyzers, analyzerFilter);
10623
+ }
10624
+ return { exit: null };
10625
+ }
10626
+ /**
10627
+ * Walk the loaded catalog for probabilistic analyzers honouring the
10628
+ * `--analyzers` filter and, when any survive, emit the stub stderr
10629
+ * advisory naming them. Extracted so `run()` does not carry the
10630
+ * branching for the `--include-prob` / `--async` advisory shapes.
10631
+ */
10632
+ #emitProbAdvisory(analyzers, analyzerFilter) {
10633
+ const probAnalyzerIds = detectProbAnalyzerIds(analyzers, analyzerFilter);
10634
+ if (probAnalyzerIds.length === 0) return;
10635
+ const template = this.async ? CHECK_TEXTS.probStubAdvisoryAsync : CHECK_TEXTS.probStubAdvisory;
10636
+ this.printer.info(
10637
+ tx(template, {
10638
+ count: probAnalyzerIds.length,
10639
+ analyzerIds: probAnalyzerIds.join(", ")
10640
+ })
10641
+ );
10642
+ }
10618
10643
  };
10619
10644
  function parseAnalyzersFlag(raw) {
10620
10645
  if (raw === void 0) return void 0;
@@ -10622,7 +10647,7 @@ function parseAnalyzersFlag(raw) {
10622
10647
  if (ids.length === 0) return void 0;
10623
10648
  return ids;
10624
10649
  }
10625
- async function detectProbAnalyzerIds(opts) {
10650
+ async function loadAnalyzerCatalog(opts) {
10626
10651
  const pluginRuntime = opts.noPlugins ? emptyPluginRuntime() : await loadPluginRuntime();
10627
10652
  pluginRuntime.emitWarnings(opts.printer);
10628
10653
  const composed = composeScanExtensions({
@@ -10630,12 +10655,30 @@ async function detectProbAnalyzerIds(opts) {
10630
10655
  pluginRuntime,
10631
10656
  killSwitches: readConformanceKillSwitches()
10632
10657
  });
10633
- const analyzers = composed?.analyzers ?? [];
10658
+ return composed?.analyzers ?? [];
10659
+ }
10660
+ function validateAnalyzerFilter(filter, analyzers) {
10661
+ const knownQualified = /* @__PURE__ */ new Set();
10662
+ const knownShort = /* @__PURE__ */ new Set();
10663
+ for (const analyzer of analyzers) {
10664
+ const qualified = qualifiedExtensionId(analyzer.pluginId, analyzer.id);
10665
+ knownQualified.add(qualified);
10666
+ knownShort.add(analyzer.id);
10667
+ }
10668
+ const unknown = filter.filter((id) => !knownQualified.has(id) && !knownShort.has(id));
10669
+ if (unknown.length === 0) return null;
10670
+ const knownList = [...knownQualified].sort().map((id) => ` ${id}`).join("\n");
10671
+ return tx(CHECK_TEXTS.unknownAnalyzerIds, {
10672
+ unknown: unknown.join(", "),
10673
+ known: knownList
10674
+ });
10675
+ }
10676
+ function detectProbAnalyzerIds(analyzers, analyzerFilter) {
10634
10677
  const probIds = [];
10635
10678
  for (const analyzer of analyzers) {
10636
10679
  if (analyzer.mode !== "probabilistic") continue;
10637
10680
  const qualified = qualifiedExtensionId(analyzer.pluginId, analyzer.id);
10638
- if (opts.analyzerFilter && !matchesAnalyzerFilter(qualified, opts.analyzerFilter)) continue;
10681
+ if (analyzerFilter && !matchesAnalyzerFilter(qualified, analyzerFilter)) continue;
10639
10682
  probIds.push(qualified);
10640
10683
  }
10641
10684
  probIds.sort();
@@ -14297,8 +14340,8 @@ function computePlannedHookContent(existing) {
14297
14340
  if (existing.includes(SKILL_MAP_MARKER)) {
14298
14341
  return { kind: "already-installed", content: existing };
14299
14342
  }
14300
- const sep7 = existing.endsWith("\n") ? "" : "\n";
14301
- return { kind: "chained", content: existing + sep7 + "\n" + SKILL_MAP_BLOCK };
14343
+ const sep8 = existing.endsWith("\n") ? "" : "\n";
14344
+ return { kind: "chained", content: existing + sep8 + "\n" + SKILL_MAP_BLOCK };
14302
14345
  }
14303
14346
  async function ensureExecutableBit(path) {
14304
14347
  const mode = (await stat2(path)).mode;
@@ -14673,7 +14716,8 @@ function recomputeLinkCounts(nodes, links) {
14673
14716
  for (const link of links) {
14674
14717
  const source = byPath3.get(link.source);
14675
14718
  if (source) source.linksOutCount += 1;
14676
- const target = byPath3.get(link.target);
14719
+ const targetKey = link.resolvedTarget ?? link.target;
14720
+ const target = byPath3.get(targetKey);
14677
14721
  if (target) target.linksInCount += 1;
14678
14722
  }
14679
14723
  }
@@ -14707,8 +14751,8 @@ function isExternalUrlLink(link) {
14707
14751
  }
14708
14752
 
14709
14753
  // kernel/orchestrator/analyzers.ts
14710
- async function runAnalyzers(analyzers, nodes, internalLinks, orphanSidecars, sidecarRoots, annotationContributions, viewContributions, orphanJobFiles, referenceablePaths, cwd, registeredActionIds, emitter, hookDispatcher, reservedNodePaths, signals) {
14711
- const issues = [];
14754
+ async function runAnalyzers(analyzers, nodes, internalLinks, orphanSidecars, sidecarRoots, annotationContributions, viewContributions, orphanJobFiles, referenceablePaths, cwd, registeredActionIds, emitter, hookDispatcher, reservedNodePaths, signals, seedIssues = []) {
14755
+ const issues = [...seedIssues];
14712
14756
  const contributions = [];
14713
14757
  const validators = loadSchemaValidators();
14714
14758
  void registeredActionIds;
@@ -14716,7 +14760,8 @@ async function runAnalyzers(analyzers, nodes, internalLinks, orphanSidecars, sid
14716
14760
  relativePath: o.relativePath,
14717
14761
  expectedMdPath: o.expectedMdPath
14718
14762
  }));
14719
- for (const analyzer of analyzers) {
14763
+ const scheduled = orderAnalyzersByPhase(analyzers);
14764
+ for (const analyzer of scheduled) {
14720
14765
  const qualifiedId2 = qualifiedExtensionId(analyzer.pluginId, analyzer.id);
14721
14766
  const declaredContributions = readDeclaredContributions(analyzer);
14722
14767
  const emitContribution = (nodePath, contributionId, payload) => {
@@ -14769,6 +14814,11 @@ async function runAnalyzers(analyzers, nodes, internalLinks, orphanSidecars, sid
14769
14814
  annotationContributions,
14770
14815
  viewContributions,
14771
14816
  orphanJobFiles,
14817
+ // `issues` is the live accumulator, mutated by `issues.push(...)`
14818
+ // below as each analyzer's emission lands. Late-phase analyzers
14819
+ // (`core/issue-counter`) read it to compute cross-analyzer
14820
+ // aggregates. Treat as read-only on the analyzer side.
14821
+ accumulatedIssues: issues,
14772
14822
  ...referenceablePaths ? { referenceablePaths } : {},
14773
14823
  ...cwd ? { cwd } : {},
14774
14824
  ...reservedNodePaths ? { reservedNodePaths } : {},
@@ -14785,6 +14835,12 @@ async function runAnalyzers(analyzers, nodes, internalLinks, orphanSidecars, sid
14785
14835
  }
14786
14836
  return { issues, contributions };
14787
14837
  }
14838
+ function orderAnalyzersByPhase(analyzers) {
14839
+ return analyzers.slice().sort((a, b) => phaseRank(a) - phaseRank(b));
14840
+ }
14841
+ function phaseRank(a) {
14842
+ return a.phase === "aggregate" ? 1 : 0;
14843
+ }
14788
14844
  function validateIssue(analyzer, issue, emitter) {
14789
14845
  const severity = issue.severity;
14790
14846
  if (severity !== "error" && severity !== "warn" && severity !== "info") {
@@ -15929,7 +15985,7 @@ async function runScanInternal(_kernel, options) {
15929
15985
  else walked.internalLinks.push(link);
15930
15986
  }
15931
15987
  walked.signals = resolved.resolvedSignals;
15932
- const postWalkCtx = buildPostWalkTransformCtx(exts.providers, walked.nodes);
15988
+ const postWalkCtx = buildPostWalkTransformCtx(exts.providers, walked.nodes, activeProviderId);
15933
15989
  walked.internalLinks = applyPostWalkTransforms(walked.internalLinks, walked.nodes, postWalkCtx);
15934
15990
  recomputeLinkCounts(walked.nodes, walked.internalLinks);
15935
15991
  recomputeExternalRefsCount(walked.nodes, walked.externalLinks, walked.cachedPaths);
@@ -15952,11 +16008,15 @@ async function runScanInternal(_kernel, options) {
15952
16008
  emitter,
15953
16009
  hookDispatcher,
15954
16010
  postWalkCtx.reservedNodePaths,
15955
- walked.signals
16011
+ walked.signals,
16012
+ // Seed the accumulator with orchestrator-emitted frontmatter
16013
+ // issues so the aggregate phase (`core/issue-counter`) counts
16014
+ // them on the per-node chip. The seeds are echoed back on
16015
+ // `analyzerResult.issues`, no explicit push is needed below.
16016
+ walked.frontmatterIssues
15956
16017
  );
15957
16018
  mergeAnalyzerEmissions(walked, analyzerResult, exts.analyzers);
15958
16019
  const issues = analyzerResult.issues;
15959
- for (const issue of walked.frontmatterIssues) issues.push(issue);
15960
16020
  const silenced = options.ignoreFilter ? (path) => options.ignoreFilter.ignores(path) : void 0;
15961
16021
  const renameOps = prior ? detectRenamesAndOrphans(prior, walked.nodes, issues, silenced) : [];
15962
16022
  const stats = buildScanStats(walked, issues, start);
@@ -15965,14 +16025,14 @@ async function runScanInternal(_kernel, options) {
15965
16025
  await hookDispatcher.dispatch("scan.completed", scanCompletedEvent);
15966
16026
  return buildScanReturn(walked, issues, renameOps, stats, options, setup);
15967
16027
  }
15968
- function buildPostWalkTransformCtx(providers, nodes) {
16028
+ function buildPostWalkTransformCtx(providers, nodes, activeProvider) {
15969
16029
  const { kindRegistry, providerResolution, reservedNamesByProviderKind } = buildProviderIndexes(providers);
15970
16030
  const reservedNodePaths = buildReservedNodePaths(
15971
16031
  nodes,
15972
16032
  kindRegistry,
15973
16033
  reservedNamesByProviderKind
15974
16034
  );
15975
- return { kindRegistry, providerResolution, reservedNodePaths };
16035
+ return { kindRegistry, providerResolution, activeProvider, reservedNodePaths };
15976
16036
  }
15977
16037
  function buildProviderIndexes(providers) {
15978
16038
  const kindRegistry = /* @__PURE__ */ new Map();
@@ -16409,7 +16469,7 @@ var SCAN_RUNNER_TEXTS = {
16409
16469
  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
16470
  /**
16411
16471
  * Reference-paths walker hit `REFERENCE_WALK_MAX_FILES` and stopped
16412
- * early. The set may be incomplete for link validation; `core/broken-ref`
16472
+ * early. The set may be incomplete for link validation; `core/reference-broken`
16413
16473
  * still works against whatever made it in.
16414
16474
  */
16415
16475
  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.",
@@ -16424,9 +16484,18 @@ var SCAN_RUNNER_TEXTS = {
16424
16484
  * markers (`.claude/`, `.codex/`, `AGENTS.md`, `.cursor/`) anywhere
16425
16485
  * under cwd or the effective scan roots. Plain-markdown projects
16426
16486
  * keep scanning fine; provider-specific extractors silently no-op
16427
- * for this scan.
16487
+ * for this scan. Follows `context/cli-output-style.md` §3.1b
16488
+ * (two-line block, glyph + dim hint):
16489
+ * - line 1: `{{glyph}}` (yellow `⚠`) + headline naming the
16490
+ * missing markers,
16491
+ * - line 2 (indent 3): `{{hint}}`, dim, names the consequence
16492
+ * and the actionable next step.
16493
+ * Both the full block AND the bare hint are catalog-side so the
16494
+ * caller can wrap the hint in `ansi.dim(...)` without splitting
16495
+ * the template manually.
16428
16496
  */
16429
- activeProviderNoMarkerWarning: "No provider markers detected (.claude/, .codex/, AGENTS.md, .cursor/). Scanning as universal markdown only; provider-specific link types (e.g. claude @-directives, /-commands) will not appear in the graph. Set `activeProvider` in .skill-map/settings.json or install a provider plugin to enable them.",
16497
+ activeProviderNoMarkerWarning: "{{glyph}} No provider markers detected (.claude/, .codex/, AGENTS.md, .cursor/).\n {{hint}}\n",
16498
+ activeProviderNoMarkerWarningHint: "Scanning as universal markdown only; provider-specific link types (e.g. claude @-directives, /-commands) will not appear. Set `activeProvider` in .skill-map/settings.json or install a provider plugin to enable them.",
16430
16499
  /**
16431
16500
  * Active-provider bootstrap: filesystem auto-detect found exactly
16432
16501
  * one marker and persisted the detected id to project settings.
@@ -16579,7 +16648,14 @@ async function bootstrapActiveProvider(opts) {
16579
16648
  }
16580
16649
  const detected = aggregateDetected(opts.cwd, opts.effectiveRoots, fromCwd.detected);
16581
16650
  if (detected.length === 0) {
16582
- opts.printer.warn(SCAN_RUNNER_TEXTS.activeProviderNoMarkerWarning);
16651
+ const warnGlyph = opts.style?.warnGlyph ?? "\u26A0";
16652
+ const dim = opts.style?.dim ?? ((s) => s);
16653
+ opts.printer.warn(
16654
+ tx(SCAN_RUNNER_TEXTS.activeProviderNoMarkerWarning, {
16655
+ glyph: warnGlyph,
16656
+ hint: dim(SCAN_RUNNER_TEXTS.activeProviderNoMarkerWarningHint)
16657
+ })
16658
+ );
16583
16659
  return { kind: "ok", activeProvider: null, source: "none" };
16584
16660
  }
16585
16661
  if (detected.length === 1) {
@@ -18788,21 +18864,6 @@ import { Command as Command22, Option as Option21 } from "clipanion";
18788
18864
  // cli/i18n/plugins.texts.ts
18789
18865
  var PLUGINS_TEXTS = {
18790
18866
  // --- enable / disable error guidance --------------------------------
18791
- // Spec § A.7, granularity validation. The CLI rejects mismatched ids
18792
- // up front (instead of silently writing a config_plugins row that the
18793
- // runtime would later ignore) so the user learns the model immediately.
18794
- /**
18795
- * Granularity-mismatch errors share a structured shape:
18796
- * ✕ <headline>
18797
- * <fix-line>
18798
- * <hint-line>
18799
- * Glyph + indent + dim hint applied at the call site so all four
18800
- * "wrong shape" advisories read the same way.
18801
- */
18802
- granularityBundleRejectsQualified: "{{glyph}} '{{bundleId}}' has granularity=bundle.\n Use `sm plugins {{verb}} {{bundleId}}` to {{verb}} the whole bundle.\n {{hint}}\n",
18803
- granularityBundleRejectsQualifiedHint: "Individual extensions inside a bundle-granularity plugin cannot be toggled.",
18804
- granularityExtensionRejectsBundleId: "{{glyph}} '{{bundleId}}' has granularity=extension.\n Use `sm plugins {{verb}} {{bundleId}}/<ext-id>` to {{verb}} a single extension.\n {{hint}}\n",
18805
- granularityExtensionRejectsBundleIdHint: "Run `sm plugins list` for the per-extension qualified ids.",
18806
18867
  pluginNotFound: "{{glyph}} Plugin not found: {{id}}\n {{hint}}\n",
18807
18868
  pluginNotFoundHint: "Run `sm plugins list` for discovered ids and the qualified extension ids.",
18808
18869
  pluginLocked: '{{glyph}} Plugin "{{id}}" is locked by the host and cannot be toggled.\n {{hint}}\n',
@@ -18833,8 +18894,14 @@ var PLUGINS_TEXTS = {
18833
18894
  // --- list verb -------------------------------------------------------
18834
18895
  listEmpty: "No plugins discovered.\n",
18835
18896
  // --- doctor verb -----------------------------------------------------
18836
- /** One-line summary that opens the human doctor output. */
18837
- doctorSummary: "plugins doctor: {{enabled}} enabled \xB7 {{issues}} issue{{issuesPlural}} \xB7 {{warnings}} warning{{warningsPlural}}\n\n",
18897
+ /**
18898
+ * One-line summary that opens the human doctor output. `enabled` is
18899
+ * the count of enabled extensions across every bundle (every
18900
+ * extension is independently toggle-able by its qualified id); the
18901
+ * value matches the row count rendered by `sm plugins list` once
18902
+ * disabled extensions are filtered out.
18903
+ */
18904
+ doctorSummary: "plugins doctor: {{enabled}} enabled extension{{enabledPlural}} \xB7 {{issues}} issue{{issuesPlural}} \xB7 {{warnings}} warning{{warningsPlural}}\n\n",
18838
18905
  /** Source breakdown row (built-in vs user). Indented 4 to match the status rows. */
18839
18906
  doctorSourceRow: " {{label}} {{count}}\n",
18840
18907
  /** Status breakdown table heading. */
@@ -18868,8 +18935,35 @@ var PLUGINS_TEXTS = {
18868
18935
  toggleNeitherIdNorAllHint: "Examples: `sm plugins {{verb}} <id1> <id2>` (explicit set), `sm plugins {{verb}} --all` (every discovered plugin).",
18869
18936
  toggleResolveError: "{{error}}",
18870
18937
  toggleAppliedSingle: "{{verbPast}}: {{id}}\n",
18871
- toggleAppliedManyHeader: "{{verbPast}}: {{count}} plugin(s)\n",
18938
+ toggleAppliedManyHeader: "{{verbPast}}: {{count}} extension(s)\n",
18872
18939
  toggleAppliedManyRow: " - {{id}}\n",
18940
+ /**
18941
+ * Macro expansion summary printed on stderr before the confirm
18942
+ * prompt (or before the `--yes` rejection). The block lists every
18943
+ * qualified extension id the bare bundle id resolves to, so the
18944
+ * user sees the exact set that would flip.
18945
+ */
18946
+ bundleMacroHeader: "sm plugins {{verb}} {{bundleId}}: this will affect {{count}} extensions:\n",
18947
+ bundleMacroRow: " - {{id}}\n",
18948
+ /**
18949
+ * Interactive prompt rendered to a TTY by the macro path. The
18950
+ * `confirm` helper appends the `[y/N]` suffix from UTIL_TEXTS.
18951
+ */
18952
+ bundleMacroConfirmPrompt: "Apply this {{verb}} to every listed extension?",
18953
+ /**
18954
+ * Stderr advisory when a TTY user answers no to the macro prompt.
18955
+ * The verb exits non-zero (ExitCode.Error) so callers can detect
18956
+ * the cancellation.
18957
+ */
18958
+ bundleMacroCancelled: "Cancelled.\n",
18959
+ /**
18960
+ * Non-TTY rejection path: pipes / CI cannot prompt, so the verb
18961
+ * refuses unless `--yes` is set. The body lines come from
18962
+ * `bundleMacroHeader` / `bundleMacroRow` above; this template adds
18963
+ * the directed re-run hint.
18964
+ */
18965
+ bundleMacroRequiresYes: "{{glyph}} Refusing to {{verb}} multiple extensions without confirmation.\n {{hint}}\n",
18966
+ bundleMacroRequiresYesHint: "Re-run with --yes to apply, or pass a qualified id `<bundle>/<extension>` for a single extension.",
18873
18967
  // --- list / show renderers ------------------------------------------
18874
18968
  rowStatusOk: "ok",
18875
18969
  rowStatusOff: "off",
@@ -18915,17 +19009,15 @@ var PLUGINS_TEXTS = {
18915
19009
  /** Extensions block heading, separated from the header by a blank line. */
18916
19010
  detailExtensionsBlock: "\n",
18917
19011
  /**
18918
- * Extension row WITH per-extension glyph (granularity=extension).
18919
- * Used by built-in `core` and any user plugin that opts in. Padding
18920
- * for {{kind}} and {{name}} is computed at render time so columns
18921
- * align inside the block.
18922
- */
18923
- detailExtensionRowGlyph: " {{glyph}} {{kind}} {{name}} v{{version}}\n",
18924
- /**
18925
- * Extension row WITHOUT per-extension glyph (granularity=bundle).
18926
- * The bundle is the only toggle; per-extension status is implicit.
19012
+ * Extension row inside the bundle detail. Every extension is
19013
+ * independently toggle-able, so every row carries its own glyph
19014
+ * (✓ / ✕). Padding for {{kind}} and {{name}} is computed at render
19015
+ * time so columns align inside the block. `{{versionSuffix}}` is
19016
+ * either ` v<x.y.z>` (user plugins) or empty (built-in bundles,
19017
+ * which inherit the CLI version and do not maintain per-extension
19018
+ * versions of their own).
18927
19019
  */
18928
- detailExtensionRowBare: " {{kind}} {{name}} v{{version}}\n",
19020
+ detailExtensionRowGlyph: " {{glyph}} {{kind}} {{name}}{{versionSuffix}}\n",
18929
19021
  detailVersionUnknown: "?",
18930
19022
  detailCompatUnknown: "?",
18931
19023
  /**
@@ -18975,6 +19067,27 @@ var PLUGINS_TEXTS = {
18975
19067
  slotsListTipText: "Tip: full spec at spec/view-slots.md and spec/input-types.md."
18976
19068
  };
18977
19069
 
19070
+ // plugins/presentation-order.ts
19071
+ var BUILT_IN_BUNDLE_PRESENTATION_ORDER = [
19072
+ "core",
19073
+ "claude",
19074
+ "antigravity",
19075
+ "openai",
19076
+ "agent-skills"
19077
+ ];
19078
+ function sortBundlesForPresentation(bundles) {
19079
+ const orderIndex = (id) => {
19080
+ const idx = BUILT_IN_BUNDLE_PRESENTATION_ORDER.indexOf(id);
19081
+ return idx >= 0 ? idx : BUILT_IN_BUNDLE_PRESENTATION_ORDER.length;
19082
+ };
19083
+ return [...bundles].sort((a, b) => {
19084
+ const ai = orderIndex(a.id);
19085
+ const bi = orderIndex(b.id);
19086
+ if (ai !== bi) return ai - bi;
19087
+ return a.id.localeCompare(b.id);
19088
+ });
19089
+ }
19090
+
18978
19091
  // cli/commands/plugins/shared.ts
18979
19092
  import { resolve as resolve32 } from "path";
18980
19093
  function resolveSearchPaths2(opts, cwd) {
@@ -19004,26 +19117,24 @@ async function loadAll(opts) {
19004
19117
  return loader.discoverAndLoadAll();
19005
19118
  }
19006
19119
  function builtInRows(resolveEnabled) {
19007
- return builtInBundles.map((bundle) => {
19008
- const bundleEnabled = resolveEnabled(bundle.id);
19009
- const extensions = bundle.extensions.map((ext) => extensionRowFromBuiltIn(ext, bundle, bundleEnabled, resolveEnabled));
19120
+ return sortBundlesForPresentation(builtInBundles).map((bundle) => {
19121
+ const extensions = bundle.extensions.map((ext) => extensionRowFromBuiltIn(ext, bundle, resolveEnabled));
19010
19122
  const manifestSummary = bundle.extensions.map((ext) => `${ext.kind}:${qualifiedExtensionId(bundle.id, ext.id)}@${ext.version}`).join(", ");
19011
19123
  return {
19012
19124
  id: bundle.id,
19013
- granularity: bundle.granularity,
19014
- enabled: bundleEnabled,
19125
+ enabled: extensions.some((e) => e.enabled),
19015
19126
  description: bundle.description,
19016
19127
  extensions,
19017
19128
  manifestSummary
19018
19129
  };
19019
19130
  });
19020
19131
  }
19021
- function extensionRowFromBuiltIn(ext, bundle, bundleEnabled, resolveEnabled) {
19132
+ function extensionRowFromBuiltIn(ext, bundle, resolveEnabled) {
19022
19133
  const row = {
19023
19134
  id: ext.id,
19024
19135
  kind: ext.kind,
19025
19136
  version: ext.version,
19026
- enabled: bundle.granularity === "bundle" ? bundleEnabled : resolveEnabled(qualifiedExtensionId(bundle.id, ext.id)),
19137
+ enabled: resolveEnabled(qualifiedExtensionId(bundle.id, ext.id)),
19027
19138
  description: ext.description ?? ""
19028
19139
  };
19029
19140
  if (ext.entry !== void 0) row.entry = ext.entry;
@@ -19077,14 +19188,14 @@ var PluginsListCommand = class extends SmCommand {
19077
19188
  return ExitCode.Ok;
19078
19189
  }
19079
19190
  const ansi = this.ansiFor("stdout");
19080
- this.printer.data(renderListHuman(builtIns2, plugins, ansi));
19191
+ this.printer.data(renderListHuman(builtIns2, plugins, resolveEnabled, ansi));
19081
19192
  return ExitCode.Ok;
19082
19193
  }
19083
19194
  };
19084
- function renderListHuman(builtIns2, plugins, ansi) {
19195
+ function renderListHuman(builtIns2, plugins, resolveEnabled, ansi) {
19085
19196
  const rows = [
19086
19197
  ...builtIns2.map(builtInToListRow),
19087
- ...plugins.map(pluginToListRow)
19198
+ ...plugins.map((p) => pluginToListRow(p, resolveEnabled))
19088
19199
  ];
19089
19200
  const idWidth = Math.max(...rows.map((r) => r.id.length));
19090
19201
  const countWidth = Math.max(
@@ -19115,9 +19226,9 @@ function renderListHuman(builtIns2, plugins, ansi) {
19115
19226
  return lines.join("\n") + "\n" + PLUGINS_TEXTS.listTipShow;
19116
19227
  }
19117
19228
  function builtInToListRow(b) {
19118
- const names = b.granularity === "extension" ? b.extensions.map(
19229
+ const names = b.extensions.map(
19119
19230
  (e) => e.enabled ? e.id : `${PLUGINS_TEXTS.rowGlyphOff} ${e.id}`
19120
- ) : b.extensions.map((e) => e.id);
19231
+ );
19121
19232
  return {
19122
19233
  id: b.id,
19123
19234
  enabled: b.enabled,
@@ -19125,9 +19236,14 @@ function builtInToListRow(b) {
19125
19236
  names
19126
19237
  };
19127
19238
  }
19128
- function pluginToListRow(p) {
19129
- const enabled = p.status === "enabled";
19130
- const names = p.extensions?.map((e) => sanitizeForTerminal(e.id)) ?? [];
19239
+ function pluginToListRow(p, resolveEnabled) {
19240
+ const isLoaded = p.status === "enabled";
19241
+ const extensions = p.extensions ?? [];
19242
+ const enabled = isLoaded ? extensions.length === 0 || extensions.some((e) => resolveEnabled(qualifiedExtensionId(p.id, e.id))) : false;
19243
+ const names = extensions.map((e) => {
19244
+ const safeId = sanitizeForTerminal(e.id);
19245
+ return resolveEnabled(qualifiedExtensionId(p.id, e.id)) ? safeId : `${PLUGINS_TEXTS.rowGlyphOff} ${safeId}`;
19246
+ });
19131
19247
  const reason = p.status === "enabled" ? void 0 : sanitizeForTerminal(p.reason ?? "") || void 0;
19132
19248
  return {
19133
19249
  id: sanitizeForTerminal(p.id),
@@ -19139,10 +19255,10 @@ function pluginToListRow(p) {
19139
19255
  }
19140
19256
  function wrapNames(names, indent, maxWidth) {
19141
19257
  const out = [];
19142
- const sep7 = ", ";
19258
+ const sep8 = ", ";
19143
19259
  let current = "";
19144
19260
  for (const name of names) {
19145
- const candidate = current === "" ? name : `${current}${sep7}${name}`;
19261
+ const candidate = current === "" ? name : `${current}${sep8}${name}`;
19146
19262
  if (indent.length + candidate.length > maxWidth && current !== "") {
19147
19263
  out.push(`${current},`);
19148
19264
  current = name;
@@ -19165,11 +19281,10 @@ var PluginsShowCommand = class extends SmCommand {
19165
19281
  Accepts a bundle / plugin id (\`core\`, \`claude\`, \`my-plugin\`)
19166
19282
  or a qualified extension id (\`core/<ext-id>\`,
19167
19283
  \`<plugin>/<ext-id>\`). When given a qualified id, validates the
19168
- extension exists and renders the parent bundle's detail (which
19169
- lists every extension with per-extension status for
19170
- granularity=extension bundles like \`core\`). The same id shapes
19171
- \`sm plugins enable\` and \`sm plugins disable\` accept resolve
19172
- cleanly here too.
19284
+ extension exists and renders a single-extension detail block.
19285
+ The bare form renders the parent bundle's detail with per-extension
19286
+ status. The same id shapes \`sm plugins enable\` and
19287
+ \`sm plugins disable\` accept resolve cleanly here too.
19173
19288
  `
19174
19289
  });
19175
19290
  id = Option22.String({ required: true });
@@ -19300,16 +19415,13 @@ function sortExtensionsCanonical(exts) {
19300
19415
  });
19301
19416
  }
19302
19417
  function renderBuiltInDetail(b, ansi) {
19303
- const enabled = b.enabled;
19304
- const glyph = enabled ? ansi.green(PLUGINS_TEXTS.rowGlyphOk) : ansi.red(PLUGINS_TEXTS.rowGlyphOff);
19418
+ const glyph = b.enabled ? ansi.green(PLUGINS_TEXTS.rowGlyphOk) : ansi.red(PLUGINS_TEXTS.rowGlyphOff);
19305
19419
  const count = b.extensions.length;
19306
- const qualify = b.granularity === "extension";
19307
19420
  const sorted = sortExtensionsCanonical(b.extensions);
19308
19421
  const items = sorted.map((ext) => ({
19309
- glyph: b.granularity === "extension" ? ext.enabled ? ansi.green(PLUGINS_TEXTS.rowGlyphOk) : ansi.red(PLUGINS_TEXTS.rowGlyphOff) : null,
19422
+ glyph: ext.enabled ? ansi.green(PLUGINS_TEXTS.rowGlyphOk) : ansi.red(PLUGINS_TEXTS.rowGlyphOff),
19310
19423
  kind: ext.kind,
19311
- name: qualify ? `${b.id}/${ext.id}` : ext.id,
19312
- version: ext.version
19424
+ name: `${b.id}/${ext.id}`
19313
19425
  }));
19314
19426
  return tx(PLUGINS_TEXTS.detailHeaderBuiltIn, {
19315
19427
  glyph,
@@ -19377,15 +19489,18 @@ function renderPluginDetailFields(match) {
19377
19489
  function collectPluginExtensionItems(match, ansi) {
19378
19490
  const enabled = match.status === "enabled";
19379
19491
  if (!enabled || !match.extensions) return [];
19380
- const qualify = match.granularity === "extension";
19381
19492
  const safeBundleId = sanitizeForTerminal(match.id);
19382
19493
  const sorted = sortExtensionsCanonical(match.extensions);
19383
19494
  return sorted.map((ext) => {
19384
19495
  const safeExtId = sanitizeForTerminal(ext.id);
19385
19496
  return {
19386
- glyph: match.granularity === "extension" ? ansi.green(PLUGINS_TEXTS.rowGlyphOk) : null,
19497
+ // User plugins surfaced via `loadAll` already filter on the
19498
+ // resolver, so a reachable extension on this surface is enabled
19499
+ // by construction. The disabled path goes through the bundle
19500
+ // status header above (✕ on the row).
19501
+ glyph: ansi.green(PLUGINS_TEXTS.rowGlyphOk),
19387
19502
  kind: sanitizeForTerminal(ext.kind),
19388
- name: qualify ? `${safeBundleId}/${safeExtId}` : safeExtId,
19503
+ name: `${safeBundleId}/${safeExtId}`,
19389
19504
  version: sanitizeForTerminal(ext.version)
19390
19505
  };
19391
19506
  });
@@ -19393,29 +19508,21 @@ function collectPluginExtensionItems(match, ansi) {
19393
19508
  function renderExtensionItems(items) {
19394
19509
  if (items.length === 0) return "";
19395
19510
  const kindWidth = Math.max(...items.map((i) => i.kind.length));
19396
- const nameWidth = Math.max(...items.map((i) => i.name.length));
19511
+ const anyVersion = items.some((i) => i.version !== void 0);
19512
+ const nameWidth = anyVersion ? Math.max(...items.map((i) => i.name.length)) : 0;
19397
19513
  const out = [];
19398
19514
  for (const item of items) {
19399
19515
  const kind = item.kind.padEnd(kindWidth);
19400
- const name = item.name.padEnd(nameWidth);
19401
- if (item.glyph !== null) {
19402
- out.push(
19403
- tx(PLUGINS_TEXTS.detailExtensionRowGlyph, {
19404
- glyph: item.glyph,
19405
- kind,
19406
- name,
19407
- version: item.version
19408
- })
19409
- );
19410
- } else {
19411
- out.push(
19412
- tx(PLUGINS_TEXTS.detailExtensionRowBare, {
19413
- kind,
19414
- name,
19415
- version: item.version
19416
- })
19417
- );
19418
- }
19516
+ const name = anyVersion ? item.name.padEnd(nameWidth) : item.name;
19517
+ const versionSuffix = item.version ? ` v${item.version}` : "";
19518
+ out.push(
19519
+ tx(PLUGINS_TEXTS.detailExtensionRowGlyph, {
19520
+ glyph: item.glyph,
19521
+ kind,
19522
+ name,
19523
+ versionSuffix
19524
+ })
19525
+ );
19419
19526
  }
19420
19527
  return out.join("");
19421
19528
  }
@@ -19426,7 +19533,7 @@ function renderBuiltInExtensionDetail(bundleId, ext, ansi) {
19426
19533
  qualifiedId: sanitizeForTerminal(`${bundleId}/${ext.id}`),
19427
19534
  source: ansi.dim(PLUGINS_TEXTS.sourceBuiltIn)
19428
19535
  });
19429
- const meta = { kind: ext.kind, version: ext.version };
19536
+ const meta = { kind: ext.kind };
19430
19537
  if (ext.description) meta.description = ext.description;
19431
19538
  if (ext.entry !== void 0) meta.entry = ext.entry;
19432
19539
  return header + "\n" + renderExtensionFields(meta);
@@ -19465,7 +19572,12 @@ function readInstanceMeta(instance) {
19465
19572
  function renderExtensionFields(meta) {
19466
19573
  const fields = [];
19467
19574
  fields.push({ label: PLUGINS_TEXTS.detailFieldKind, value: sanitizeForTerminal(meta.kind) });
19468
- fields.push({ label: PLUGINS_TEXTS.detailFieldVersion, value: sanitizeForTerminal(meta.version) });
19575
+ if (meta.version) {
19576
+ fields.push({
19577
+ label: PLUGINS_TEXTS.detailFieldVersion,
19578
+ value: sanitizeForTerminal(meta.version)
19579
+ });
19580
+ }
19469
19581
  if (meta.stability) {
19470
19582
  fields.push({
19471
19583
  label: PLUGINS_TEXTS.detailFieldStability,
@@ -19519,7 +19631,7 @@ var PluginsDoctorCommand = class extends SmCommand {
19519
19631
  const plugins = await loadAll({ pluginDir: this.pluginDir });
19520
19632
  const resolveEnabled = await buildResolver();
19521
19633
  const builtIns2 = builtInRows(resolveEnabled);
19522
- const counts = countByStatus(builtIns2, plugins);
19634
+ const counts = countByStatus(builtIns2, plugins, resolveEnabled);
19523
19635
  const knownKinds = collectKnownKinds(plugins);
19524
19636
  const applicableKindWarnings = collectApplicableKindWarnings(plugins, knownKinds);
19525
19637
  const unknownSlotWarnings = collectUnknownSlotWarnings(plugins, KNOWN_SLOT_NAMES);
@@ -19554,6 +19666,7 @@ var PluginsDoctorCommand = class extends SmCommand {
19554
19666
  this.printer.data(
19555
19667
  tx(PLUGINS_TEXTS.doctorSummary, {
19556
19668
  enabled,
19669
+ enabledPlural: enabled === 1 ? "" : "s",
19557
19670
  issues: badCount,
19558
19671
  issuesPlural: badCount === 1 ? "" : "s",
19559
19672
  warnings,
@@ -19651,7 +19764,7 @@ var PluginsDoctorCommand = class extends SmCommand {
19651
19764
  }
19652
19765
  }
19653
19766
  };
19654
- function countByStatus(builtIns2, plugins) {
19767
+ function countByStatus(builtIns2, plugins, resolveEnabled) {
19655
19768
  const counts = {
19656
19769
  enabled: 0,
19657
19770
  disabled: 0,
@@ -19662,15 +19775,20 @@ function countByStatus(builtIns2, plugins) {
19662
19775
  "id-collision": 0
19663
19776
  };
19664
19777
  for (const b of builtIns2) {
19665
- if (b.granularity === "bundle") {
19666
- counts[b.enabled ? "enabled" : "disabled"]++;
19667
- } else {
19668
- for (const ext of b.extensions) {
19669
- counts[ext.enabled ? "enabled" : "disabled"]++;
19670
- }
19778
+ for (const ext of b.extensions) {
19779
+ counts[ext.enabled ? "enabled" : "disabled"]++;
19780
+ }
19781
+ }
19782
+ for (const p of plugins) {
19783
+ if (p.status !== "enabled" || !p.extensions) {
19784
+ counts[p.status]++;
19785
+ continue;
19786
+ }
19787
+ for (const ext of p.extensions) {
19788
+ const enabled = resolveEnabled(`${p.id}/${ext.id}`);
19789
+ counts[enabled ? "enabled" : "disabled"]++;
19671
19790
  }
19672
19791
  }
19673
- for (const p of plugins) counts[p.status]++;
19674
19792
  return counts;
19675
19793
  }
19676
19794
  function forEachProviderInstance(plugins, callback) {
@@ -19710,10 +19828,13 @@ function extensionInstance(ext) {
19710
19828
  }
19711
19829
  function collectKnownKinds(plugins) {
19712
19830
  const known = /* @__PURE__ */ new Set();
19713
- forEachProviderInstance(plugins, ({ instance }) => {
19831
+ forEachProviderInstance(plugins, ({ pluginId, instance }) => {
19714
19832
  const map = instance["kinds"];
19715
19833
  if (map === null || typeof map !== "object") return;
19716
- for (const k of Object.keys(map)) known.add(k);
19834
+ for (const k of Object.keys(map)) {
19835
+ known.add(k);
19836
+ known.add(qualifiedExtensionId(pluginId, k));
19837
+ }
19717
19838
  });
19718
19839
  return known;
19719
19840
  }
@@ -19860,6 +19981,9 @@ function buildDoctorJsonEnvelope(args2) {
19860
19981
  import { Command as Command25, Option as Option24 } from "clipanion";
19861
19982
  var TogglePluginsBase = class extends SmCommand {
19862
19983
  all = Option24.Boolean("--all", false);
19984
+ yes = Option24.Boolean("--yes,-y", false, {
19985
+ description: "Skip the interactive confirm when a bare bundle id (or --all) fans the toggle out across multiple extensions."
19986
+ });
19863
19987
  ids = Option24.Rest({ name: "ids" });
19864
19988
  async toggle(enabled) {
19865
19989
  const verb = enabled ? "enable" : "disable";
@@ -19868,14 +19992,17 @@ var TogglePluginsBase = class extends SmCommand {
19868
19992
  if (argError !== null) return argError;
19869
19993
  const plugins = await loadAll({ pluginDir: void 0 });
19870
19994
  const catalogue = bundleCatalogue(plugins);
19871
- const targetsResult = this.#pickTargets(catalogue, verb, stderrAnsi);
19995
+ const targetsResult = this.#pickTargets(catalogue, stderrAnsi);
19872
19996
  if (typeof targetsResult === "number") return targetsResult;
19873
19997
  let targets = targetsResult;
19998
+ const macroOk = await this.#confirmMacroIfNeeded(targets, verb, stderrAnsi);
19999
+ if (!macroOk) return ExitCode.Error;
19874
20000
  const lockError = this.#applyLockGate(targets, stderrAnsi);
19875
20001
  if (typeof lockError === "number") return lockError;
19876
20002
  targets = lockError;
19877
- await this.#persistTargets(targets, enabled);
19878
- this.#renderSuccess(targets, enabled);
20003
+ const keys = expandToKeys(targets);
20004
+ await this.#persistKeys(keys, enabled);
20005
+ this.#renderSuccess(keys, enabled);
19879
20006
  return ExitCode.Ok;
19880
20007
  }
19881
20008
  /**
@@ -19908,87 +20035,166 @@ var TogglePluginsBase = class extends SmCommand {
19908
20035
  /**
19909
20036
  * Resolve `<id>...` against the catalogue or fan out via `--all`.
19910
20037
  * Returns the target list on success, or the exit code on a
19911
- * directed-error path (unknown id, granularity mismatch).
19912
- *
19913
- * `--all` is a macro on bundle ids: every plugin / bundle the user
19914
- * can see. We deliberately do NOT expand to qualified
19915
- * <bundle>/<ext> keys, that would silently flip a granularity
19916
- * policy. For granularity=extension bundles the user already hits
19917
- * the directed error message when they try the bundle id directly,
19918
- * so `--all` skips them here too and the real "disable every core
19919
- * extension" intent is served by `--no-built-ins` on `sm scan`.
20038
+ * directed-error path (unknown id, malformed qualified id).
19920
20039
  *
19921
- * Variadic mode is all-or-nothing: the first bad id aborts the
19922
- * batch before any DB write, so the user never lands in a partial
19923
- * state. Repeated ids in the same call are deduped.
20040
+ * Repeated ids in the same call are deduped at the target level
20041
+ * (`origin === 'bare'` and `origin === 'qualified'` rows stay
20042
+ * distinct so the macro-confirm path can address each correctly).
20043
+ * The first unknown id aborts the batch before any DB write so the
20044
+ * user never lands in a partial state.
19924
20045
  */
19925
- #pickTargets(catalogue, verb, ansi) {
20046
+ #pickTargets(catalogue, ansi) {
19926
20047
  if (this.all) {
19927
- return catalogue.filter((b) => b.granularity === "bundle").map((b) => b.id);
20048
+ return catalogue.map((b) => ({
20049
+ origin: "bare",
20050
+ bundleId: b.id,
20051
+ keys: b.extensionIds.map((extId) => qualifiedExtensionId(b.id, extId))
20052
+ }));
19928
20053
  }
19929
- const keys = [];
20054
+ const out = [];
20055
+ const seen = /* @__PURE__ */ new Set();
19930
20056
  for (const rawId of this.ids) {
19931
- const resolved = resolveToggleTarget(rawId, catalogue, verb, ansi);
20057
+ const resolved = resolveToggleTarget(rawId, catalogue, ansi);
19932
20058
  if ("error" in resolved) {
19933
20059
  this.printer.error(tx(PLUGINS_TEXTS.toggleResolveError, { error: resolved.error }));
19934
20060
  return ExitCode.NotFound;
19935
20061
  }
19936
- keys.push(resolved.key);
20062
+ const novelKeys = resolved.keys.filter((k) => !seen.has(k));
20063
+ if (novelKeys.length === 0) continue;
20064
+ for (const k of novelKeys) seen.add(k);
20065
+ out.push({ ...resolved, keys: novelKeys });
20066
+ }
20067
+ return out;
20068
+ }
20069
+ /**
20070
+ * Macro gate: when the request would fan a single user input out
20071
+ * across more than one extension (either `--all` or a bare bundle
20072
+ * id whose bundle holds ≥2 extensions), confirm the intent.
20073
+ *
20074
+ * Resolution order:
20075
+ * 1. `--yes` flag: skip the prompt entirely.
20076
+ * 2. TTY stdin: render the list + ask interactively (`[y/N]`).
20077
+ * 3. Non-TTY (CI / pipe / agent harness): refuse with a directed
20078
+ * message that names the extensions and points at `--yes`.
20079
+ *
20080
+ * Returns `true` when the verb should proceed, `false` when it
20081
+ * should abort. Single-extension targets (bare bundle id mapping to
20082
+ * one child, or qualified ids) skip the gate uniformly.
20083
+ */
20084
+ // Cyclomatic count comes from the three-stage gate (--yes shortcut,
20085
+ // TTY interactive path, non-TTY rejection) folded over the targets
20086
+ // loop. Splitting them scatters the contract without making the
20087
+ // algorithm clearer.
20088
+ // eslint-disable-next-line complexity
20089
+ async #confirmMacroIfNeeded(targets, verb, ansi) {
20090
+ const macroTargets = targets.filter((t) => requiresMacroConfirm(t));
20091
+ if (macroTargets.length === 0) return true;
20092
+ if (this.yes) return true;
20093
+ const isTty = Boolean(this.context.stdin && "isTTY" in this.context.stdin && this.context.stdin.isTTY);
20094
+ for (const target of macroTargets) {
20095
+ const bundleLabel = target.origin === "bare" ? target.bundleId ?? "--all" : "--all";
20096
+ this.printer.info(
20097
+ tx(PLUGINS_TEXTS.bundleMacroHeader, {
20098
+ verb,
20099
+ bundleId: sanitizeForTerminal(bundleLabel),
20100
+ count: target.keys.length
20101
+ })
20102
+ );
20103
+ for (const key of target.keys) {
20104
+ this.printer.info(tx(PLUGINS_TEXTS.bundleMacroRow, { id: sanitizeForTerminal(key) }));
20105
+ }
20106
+ }
20107
+ if (!isTty) {
20108
+ this.printer.error(
20109
+ tx(PLUGINS_TEXTS.bundleMacroRequiresYes, {
20110
+ glyph: ansi.red("\u2715"),
20111
+ verb,
20112
+ hint: ansi.dim(PLUGINS_TEXTS.bundleMacroRequiresYesHint)
20113
+ })
20114
+ );
20115
+ return false;
20116
+ }
20117
+ const ok = await confirm(
20118
+ tx(PLUGINS_TEXTS.bundleMacroConfirmPrompt, { verb }),
20119
+ { stdin: this.context.stdin, stderr: this.context.stderr }
20120
+ );
20121
+ if (!ok) {
20122
+ this.printer.info(PLUGINS_TEXTS.bundleMacroCancelled);
19937
20123
  }
19938
- return [...new Set(keys)];
20124
+ return ok;
19939
20125
  }
19940
20126
  /**
19941
20127
  * Host lock, see `src/kernel/config/locked-plugins.ts`. Bulk modes
19942
- * (`--all` or an explicit batch of >1 ids) silently skip locked
19943
- * targets so the user can still toggle the rest. Single-id mode
19944
- * surfaces a directed exit-5 message so the user knows their one
19945
- * intended target was refused.
20128
+ * (`--all`, an explicit batch of >1 targets, or a macro expansion
20129
+ * with >1 keys) silently skip locked extensions so the user can
20130
+ * still toggle the rest. Single-extension mode surfaces a directed
20131
+ * exit-5 message so the user knows their one intended target was
20132
+ * refused.
19946
20133
  */
19947
20134
  #applyLockGate(targets, ansi) {
19948
- if (this.all || this.ids.length > 1) return targets.filter((id) => !isPluginLocked(id));
19949
- const lockedHit = targets.find((id) => isPluginLocked(id));
19950
- if (!lockedHit) return targets;
20135
+ const totalKeys = targets.reduce((acc, t) => acc + t.keys.length, 0);
20136
+ const bulk = this.all || this.ids.length > 1 || totalKeys > 1;
20137
+ if (bulk) {
20138
+ return targets.map((t) => ({ ...t, keys: t.keys.filter((k) => !isPluginLocked(k)) }));
20139
+ }
20140
+ const onlyKey = targets[0]?.keys[0];
20141
+ if (!onlyKey || !isPluginLocked(onlyKey)) return targets;
19951
20142
  this.printer.error(
19952
20143
  tx(PLUGINS_TEXTS.pluginLocked, {
19953
20144
  glyph: ansi.red("\u2715"),
19954
- id: sanitizeForTerminal(lockedHit),
20145
+ id: sanitizeForTerminal(onlyKey),
19955
20146
  hint: ansi.dim(PLUGINS_TEXTS.pluginLockedHint)
19956
20147
  })
19957
20148
  );
19958
20149
  return ExitCode.NotFound;
19959
20150
  }
19960
20151
  /**
19961
- * Persist the toggle in `config_plugins`. On disable, also purge
19962
- * the plugin's `scan_contributions` rows immediately (matches the
19963
- * BFF route, see `server/routes/plugins.ts:applyChangeToAdapter`).
19964
- * `targets` carries either a bare bundle id (e.g. `claude`) or a
19965
- * qualified `<bundle>/<ext>` (e.g. `core/slash`); the split mirrors
19966
- * how the catalog sweep groups rows.
20152
+ * Persist every qualified id in `config_plugins`. On disable, also
20153
+ * purge the plugin's `scan_contributions` rows immediately (matches
20154
+ * the BFF route, see `server/routes/plugins.ts:applyChangeToAdapter`).
20155
+ * Every key is `<bundle>/<ext>` shape so the contribution purge can
20156
+ * split into `(pluginId, extensionId)` cleanly.
19967
20157
  */
19968
- async #persistTargets(targets, enabled) {
20158
+ async #persistKeys(keys, enabled) {
19969
20159
  const ctx = defaultRuntimeContext();
19970
20160
  const dbPath = resolveDbPath({ db: void 0, cwd: ctx.cwd });
19971
20161
  await withSqlite({ databasePath: dbPath, autoBackup: false }, async (adapter) => {
19972
- for (const id of targets) {
20162
+ for (const id of keys) {
19973
20163
  await adapter.pluginConfig.set(id, enabled);
19974
20164
  if (!enabled) await purgeContributionsFor(adapter, id);
19975
20165
  }
19976
20166
  });
19977
20167
  }
19978
- #renderSuccess(targets, enabled) {
20168
+ #renderSuccess(keys, enabled) {
19979
20169
  const verbPast = enabled ? "enabled" : "disabled";
19980
- if (targets.length === 1) {
19981
- this.printer.data(tx(PLUGINS_TEXTS.toggleAppliedSingle, { verbPast, id: targets[0] }));
20170
+ if (keys.length === 1) {
20171
+ this.printer.data(tx(PLUGINS_TEXTS.toggleAppliedSingle, { verbPast, id: keys[0] }));
19982
20172
  } else {
19983
20173
  this.printer.data(
19984
- tx(PLUGINS_TEXTS.toggleAppliedManyHeader, { verbPast, count: targets.length })
20174
+ tx(PLUGINS_TEXTS.toggleAppliedManyHeader, { verbPast, count: keys.length })
19985
20175
  );
19986
- for (const id of targets) {
20176
+ for (const id of keys) {
19987
20177
  this.printer.data(tx(PLUGINS_TEXTS.toggleAppliedManyRow, { id }));
19988
20178
  }
19989
20179
  }
19990
20180
  }
19991
20181
  };
20182
+ function requiresMacroConfirm(target) {
20183
+ if (target.origin !== "bare") return false;
20184
+ return target.keys.length >= 2;
20185
+ }
20186
+ function expandToKeys(targets) {
20187
+ const out = [];
20188
+ const seen = /* @__PURE__ */ new Set();
20189
+ for (const t of targets) {
20190
+ for (const k of t.keys) {
20191
+ if (seen.has(k)) continue;
20192
+ seen.add(k);
20193
+ out.push(k);
20194
+ }
20195
+ }
20196
+ return out;
20197
+ }
19992
20198
  async function purgeContributionsFor(adapter, id) {
19993
20199
  const slash = id.indexOf("/");
19994
20200
  if (slash < 0) {
@@ -20001,25 +20207,24 @@ var PluginsEnableCommand = class extends TogglePluginsBase {
20001
20207
  static paths = [["plugins", "enable"]];
20002
20208
  static usage = Command25.Usage({
20003
20209
  category: "Plugins",
20004
- description: "Enable one or more plugins (or --all). Persists in config_plugins.",
20210
+ description: "Enable one or more extensions (or --all). Persists in config_plugins.",
20005
20211
  details: `
20006
- Writes a row to config_plugins with enabled=1 per id. Takes
20007
- precedence over the team-shared baseline at
20212
+ Writes a row to config_plugins with enabled=1 per qualified
20213
+ extension id. Takes precedence over the team-shared baseline at
20008
20214
  settings.json#/plugins/<id>/enabled. Use sm plugins disable to
20009
20215
  flip; sm config reset plugins.<id>.enabled drops the settings.json
20010
20216
  baseline.
20011
20217
 
20012
- Accepts one or more ids in one call, e.g.
20013
- 'sm plugins enable claude antigravity openai'. Batches are
20014
- all-or-nothing: a single unknown / mismatched id aborts before
20015
- any write. Repeated ids are deduped. Locked plugins inside a
20016
- batch are silently skipped.
20218
+ Accepts qualified ids (\`claude/at-directive\`) and bare bundle
20219
+ ids (\`claude\`, which fans the toggle out across every extension
20220
+ inside the bundle). Multi-extension bundles need --yes (or an
20221
+ interactive TTY confirm) to avoid flipping 27 core extensions by
20222
+ accident. Single-extension bundles (openai, agent-skills,
20223
+ antigravity) apply without prompting.
20017
20224
 
20018
- Granularity: a bundle-granularity plugin (default for user plugins,
20019
- and the built-in 'claude' bundle) accepts only the bundle id. An
20020
- extension-granularity plugin (the built-in 'core' bundle) accepts
20021
- only qualified ids '<bundle>/<ext-id>'. Mismatches are rejected
20022
- with directed guidance.
20225
+ Batches are all-or-nothing: a single unknown id aborts before
20226
+ any write. Repeated ids are deduped. Locked extensions inside a
20227
+ batch are silently skipped.
20023
20228
  `
20024
20229
  });
20025
20230
  async run() {
@@ -20030,24 +20235,23 @@ var PluginsDisableCommand = class extends TogglePluginsBase {
20030
20235
  static paths = [["plugins", "disable"]];
20031
20236
  static usage = Command25.Usage({
20032
20237
  category: "Plugins",
20033
- description: "Disable one or more plugins (or --all). Persists in config_plugins; does not delete files.",
20238
+ description: "Disable one or more extensions (or --all). Persists in config_plugins; does not delete files.",
20034
20239
  details: `
20035
- Writes a row to config_plugins with enabled=0 per id. Discovery
20036
- still surfaces the plugin in sm plugins list, but with
20037
- status=disabled; its extensions are not imported and the kernel
20038
- will not run them.
20039
-
20040
- Accepts one or more ids in one call, e.g.
20041
- 'sm plugins disable antigravity openai agent-skills'. Batches are
20042
- all-or-nothing: a single unknown / mismatched id aborts before
20043
- any write. Repeated ids are deduped. Locked plugins inside a
20240
+ Writes a row to config_plugins with enabled=0 per qualified
20241
+ extension id. Discovery still surfaces the plugin in
20242
+ sm plugins list, but with status=disabled; the kernel will not
20243
+ run any of its disabled extensions.
20244
+
20245
+ Accepts qualified ids (\`core/markdown-link\`) and bare bundle
20246
+ ids (\`core\`, which fans the toggle out across every extension
20247
+ inside the bundle). Multi-extension bundles need --yes (or an
20248
+ interactive TTY confirm) to avoid flipping 27 core extensions by
20249
+ accident. Single-extension bundles (openai, agent-skills,
20250
+ antigravity) apply without prompting.
20251
+
20252
+ Batches are all-or-nothing: a single unknown id aborts before
20253
+ any write. Repeated ids are deduped. Locked extensions inside a
20044
20254
  batch are silently skipped.
20045
-
20046
- Granularity: a bundle-granularity plugin (default for user plugins,
20047
- and the built-in 'claude' bundle) accepts only the bundle id. An
20048
- extension-granularity plugin (the built-in 'core' bundle) accepts
20049
- only qualified ids '<bundle>/<ext-id>'. Mismatches are rejected
20050
- with directed guidance.
20051
20255
  `
20052
20256
  });
20053
20257
  async run() {
@@ -20059,23 +20263,21 @@ function bundleCatalogue(plugins) {
20059
20263
  for (const bundle of builtInBundles) {
20060
20264
  out.push({
20061
20265
  id: bundle.id,
20062
- granularity: bundle.granularity,
20063
20266
  extensionIds: bundle.extensions.map((e) => e.id)
20064
20267
  });
20065
20268
  }
20066
20269
  for (const p of plugins) {
20067
20270
  out.push({
20068
20271
  id: p.id,
20069
- granularity: p.granularity ?? "bundle",
20070
20272
  extensionIds: p.extensions?.map((e) => e.id) ?? []
20071
20273
  });
20072
20274
  }
20073
20275
  return out;
20074
20276
  }
20075
- function resolveToggleTarget(id, catalogue, verb, ansi) {
20076
- return id.includes("/") ? resolveQualifiedToggle(id, catalogue, verb, ansi) : resolveBareToggle(id, catalogue, verb, ansi);
20277
+ function resolveToggleTarget(id, catalogue, ansi) {
20278
+ return id.includes("/") ? resolveQualifiedToggle(id, catalogue, ansi) : resolveBareToggle(id, catalogue);
20077
20279
  }
20078
- function resolveQualifiedToggle(id, catalogue, verb, ansi) {
20280
+ function resolveQualifiedToggle(id, catalogue, ansi) {
20079
20281
  const errGlyph = ansi.red("\u2715");
20080
20282
  const [bundleId, extId, ...rest] = id.split("/");
20081
20283
  if (!bundleId || !extId || rest.length > 0) {
@@ -20097,17 +20299,6 @@ function resolveQualifiedToggle(id, catalogue, verb, ansi) {
20097
20299
  })
20098
20300
  };
20099
20301
  }
20100
- if (bundle.granularity === "bundle") {
20101
- return {
20102
- error: tx(PLUGINS_TEXTS.granularityBundleRejectsQualified, {
20103
- glyph: errGlyph,
20104
- bundleId: sanitizeForTerminal(bundleId),
20105
- extId: sanitizeForTerminal(extId),
20106
- verb,
20107
- hint: ansi.dim(PLUGINS_TEXTS.granularityBundleRejectsQualifiedHint)
20108
- })
20109
- };
20110
- }
20111
20302
  if (!bundle.extensionIds.includes(extId)) {
20112
20303
  return {
20113
20304
  error: tx(PLUGINS_TEXTS.qualifiedIdNotFound, {
@@ -20119,31 +20310,27 @@ function resolveQualifiedToggle(id, catalogue, verb, ansi) {
20119
20310
  })
20120
20311
  };
20121
20312
  }
20122
- return { key: qualifiedExtensionId(bundleId, extId) };
20313
+ return {
20314
+ origin: "qualified",
20315
+ keys: [qualifiedExtensionId(bundleId, extId)]
20316
+ };
20123
20317
  }
20124
- function resolveBareToggle(id, catalogue, verb, ansi) {
20125
- const errGlyph = ansi.red("\u2715");
20318
+ function resolveBareToggle(id, catalogue) {
20126
20319
  const bundle = catalogue.find((b) => b.id === id);
20127
20320
  if (!bundle) {
20128
20321
  return {
20129
20322
  error: tx(PLUGINS_TEXTS.pluginNotFound, {
20130
- glyph: errGlyph,
20323
+ glyph: "\u2715",
20131
20324
  id: sanitizeForTerminal(id),
20132
- hint: ansi.dim(PLUGINS_TEXTS.pluginNotFoundHint)
20133
- })
20134
- };
20135
- }
20136
- if (bundle.granularity === "extension") {
20137
- return {
20138
- error: tx(PLUGINS_TEXTS.granularityExtensionRejectsBundleId, {
20139
- glyph: errGlyph,
20140
- bundleId: sanitizeForTerminal(id),
20141
- verb,
20142
- hint: ansi.dim(PLUGINS_TEXTS.granularityExtensionRejectsBundleIdHint)
20325
+ hint: PLUGINS_TEXTS.pluginNotFoundHint
20143
20326
  })
20144
20327
  };
20145
20328
  }
20146
- return { key: bundle.id };
20329
+ return {
20330
+ origin: "bare",
20331
+ bundleId: bundle.id,
20332
+ keys: bundle.extensionIds.map((extId) => qualifiedExtensionId(bundle.id, extId))
20333
+ };
20147
20334
  }
20148
20335
 
20149
20336
  // cli/commands/plugins/create.ts
@@ -20194,7 +20381,6 @@ var PluginsCreateCommand = class extends SmCommand {
20194
20381
  version: "0.1.0",
20195
20382
  specCompat: `^${specVersion}`,
20196
20383
  catalogCompat: "^1.0.0",
20197
- granularity: "bundle",
20198
20384
  description: "Generated by `sm plugins create`. Edit to taste.",
20199
20385
  settings: {
20200
20386
  keywords: {
@@ -20310,7 +20496,7 @@ var VIEW_SLOTS_CATALOG = [
20310
20496
  { id: "card.subtitle.left", summary: "Single non-negative integer in the card subtitle row." },
20311
20497
  { id: "card.footer.left", summary: "Counter chip in the left footer of the card." },
20312
20498
  { id: "card.footer.right", summary: "Counter chip in the right footer of the card." },
20313
- { id: "graph.node.alert", summary: "Corner badge decoration on the graph node (alert / status)." },
20499
+ { id: "graph.node.alert", summary: 'Reserved corner badge on the graph node, special-case signals only. No core analyzer emits here; routine "this node has a problem" findings belong in `card.footer.right`.' },
20314
20500
  { id: "inspector.header.badge.counter", summary: "Counter chip in the inspector header badge cluster." },
20315
20501
  { id: "inspector.header.badge.tag", summary: "Qualitative tag chip in the inspector header badge cluster." },
20316
20502
  { id: "inspector.body.panel.breakdown", summary: "Top-N labeled values rendered as a bar chart in the inspector body." },
@@ -21608,13 +21794,12 @@ var ScanCommand = class extends SmCommand {
21608
21794
  const ansi = this.ansiFor("stdout");
21609
21795
  const cwd = defaultRuntimeContext().cwd;
21610
21796
  const hasErrors = exitCode2 === ExitCode.Issues;
21611
- const issuesCount = result.stats.issuesCount;
21612
- const glyph = hasErrors ? ansi.red("\u2715") : ansi.green("\u2713");
21797
+ const severityCounts = countBySeverity(result.issues);
21798
+ const glyph = hasErrors ? " " : ansi.green("\u2713");
21613
21799
  const counts = formatScanCounts({
21614
21800
  nodes: result.stats.nodesCount,
21615
21801
  links: result.stats.linksCount,
21616
- issues: issuesCount,
21617
- hasErrors,
21802
+ severities: severityCounts,
21618
21803
  ansi
21619
21804
  });
21620
21805
  const duration = ansi.dim(`in ${result.stats.durationMs}ms`);
@@ -21661,11 +21846,49 @@ var ScanCommand = class extends SmCommand {
21661
21846
  return exitCode2;
21662
21847
  }
21663
21848
  };
21849
+ function countBySeverity(issues) {
21850
+ const buckets = {
21851
+ error: /* @__PURE__ */ new Set(),
21852
+ warn: /* @__PURE__ */ new Set(),
21853
+ info: /* @__PURE__ */ new Set()
21854
+ };
21855
+ for (const i of issues) {
21856
+ const tier = i.severity;
21857
+ const bucket = buckets[tier];
21858
+ if (!bucket) continue;
21859
+ fillSeverityBucket(bucket, i.nodeIds);
21860
+ }
21861
+ return { errors: buckets.error.size, warns: buckets.warn.size, info: buckets.info.size };
21862
+ }
21863
+ function fillSeverityBucket(bucket, nodeIds) {
21864
+ const ids = nodeIds ?? [];
21865
+ if (ids.length === 0) {
21866
+ bucket.add("");
21867
+ return;
21868
+ }
21869
+ for (const id of ids) bucket.add(id);
21870
+ }
21664
21871
  function formatScanCounts(opts) {
21665
- const { nodes, links, issues, hasErrors, ansi } = opts;
21666
- const issuesText = `${issues} ${plural(issues, "issue")}`;
21667
- const issuesColored = issues === 0 ? ansi.dim(issuesText) : hasErrors ? ansi.red(issuesText) : ansi.yellow(issuesText);
21668
- return `${nodes} ${plural(nodes, "node")} \xB7 ${links} ${plural(links, "link")} \xB7 ${issuesColored}`;
21872
+ const { nodes, links, severities, ansi } = opts;
21873
+ const parts = [
21874
+ `${nodes} ${plural(nodes, "node")}`,
21875
+ `${links} ${plural(links, "link")}`
21876
+ ];
21877
+ const total = severities.errors + severities.warns + severities.info;
21878
+ if (total === 0) {
21879
+ parts.push(ansi.dim("0 issues"));
21880
+ } else {
21881
+ if (severities.errors > 0) {
21882
+ parts.push(ansi.red(`${severities.errors} ${plural(severities.errors, "error")}`));
21883
+ }
21884
+ if (severities.warns > 0) {
21885
+ parts.push(ansi.yellow(`${severities.warns} ${plural(severities.warns, "warning")}`));
21886
+ }
21887
+ if (severities.info > 0) {
21888
+ parts.push(ansi.dim(`${severities.info} info`));
21889
+ }
21890
+ }
21891
+ return parts.join(" \xB7 ");
21669
21892
  }
21670
21893
  function plural(count, word) {
21671
21894
  return count === 1 ? word : `${word}s`;
@@ -21919,6 +22142,18 @@ import { spawn as spawn2 } from "child_process";
21919
22142
  import { existsSync as existsSync28 } from "fs";
21920
22143
  import { Command as Command33, Option as Option31 } from "clipanion";
21921
22144
 
22145
+ // kernel/util/dev-mode.ts
22146
+ import { sep as sep6 } from "path";
22147
+ import { fileURLToPath as fileURLToPath5 } from "url";
22148
+ var SELF_PATH = fileURLToPath5(import.meta.url);
22149
+ var IS_DEV_BUILD = isDevBuildFromPath(SELF_PATH, sep6);
22150
+ function isDevBuildFromPath(filePath, separator = sep6) {
22151
+ return !filePath.includes(`${separator}node_modules${separator}`);
22152
+ }
22153
+ function isDevBuild() {
22154
+ return IS_DEV_BUILD;
22155
+ }
22156
+
21922
22157
  // cli/util/browser-launch.ts
21923
22158
  function validateBrowserUrl(url) {
21924
22159
  if (typeof url !== "string" || url.length === 0) return false;
@@ -22136,10 +22371,10 @@ var SERVER_TEXTS = {
22136
22371
  pluginsBodyNotJson: "Request body must be valid JSON.",
22137
22372
  pluginsBodyNotObject: "Request body must be a JSON object.",
22138
22373
  pluginsEnabledRequired: "`enabled` is required and must be a boolean.",
22139
- // 400, granularity mismatch. Two flavours so the message is useful
22140
- // when the operator hits the wrong route by hand.
22141
- pluginsGranularityExtensionExpected: 'Plugin "{{id}}" has granularity:"extension"; toggle individual extensions via PATCH /api/plugins/{{id}}/extensions/<extensionId>.',
22142
- pluginsGranularityBundleExpected: 'Plugin "{{id}}" has granularity:"bundle"; toggle the whole bundle via PATCH /api/plugins/{{id}}.',
22374
+ // 400, cascade route rejects qualified ids: the bare-id PATCH is the
22375
+ // bundle macro endpoint. Anything containing `/` needs the dedicated
22376
+ // per-extension route below.
22377
+ pluginsCascadeRouteQualifiedRejected: 'Plugin id "{{id}}" contains "/"; toggle individual extensions via PATCH /api/plugins/<bundle>/extensions/<extensionId>.',
22143
22378
  // 404, unknown plugin / extension.
22144
22379
  pluginsUnknown: 'No plugin with id "{{id}}".',
22145
22380
  pluginsExtensionUnknown: 'Plugin "{{bundleId}}" has no extension named "{{extensionId}}".',
@@ -22632,6 +22867,7 @@ function contentTypeFor(format) {
22632
22867
  import { existsSync as existsSync23 } from "fs";
22633
22868
  var FALLBACK_SCHEMA_VERSION = "1";
22634
22869
  function buildHealth(deps) {
22870
+ const dev = isDevBuild();
22635
22871
  return {
22636
22872
  ok: true,
22637
22873
  schemaVersion: FALLBACK_SCHEMA_VERSION,
@@ -22639,7 +22875,10 @@ function buildHealth(deps) {
22639
22875
  implVersion: VERSION,
22640
22876
  db: existsSync23(deps.dbPath) ? "present" : "missing",
22641
22877
  cwd: deps.cwd,
22642
- dbPath: deps.dbPath
22878
+ dbPath: deps.dbPath,
22879
+ // Only emit when truthy so a published install keeps the wire
22880
+ // shape lean and consumers branch on presence alone.
22881
+ ...dev ? { dev: true } : {}
22643
22882
  };
22644
22883
  }
22645
22884
  var cachedSpecVersion = null;
@@ -22764,13 +23003,13 @@ import { HTTPException as HTTPException6 } from "hono/http-exception";
22764
23003
  // server/node-body.ts
22765
23004
  import { constants as fsConstants2 } from "fs";
22766
23005
  import { open } from "fs/promises";
22767
- import { isAbsolute as isAbsolute10, resolve as resolvePath2, relative as relativePath, sep as sep6 } from "path";
23006
+ import { isAbsolute as isAbsolute10, resolve as resolvePath2, relative as relativePath, sep as sep7 } from "path";
22768
23007
  async function readNodeBody(cwd, relPath) {
22769
23008
  if (isAbsolute10(relPath)) return null;
22770
23009
  const absRoot = resolvePath2(cwd);
22771
23010
  const absFile = resolvePath2(absRoot, relPath);
22772
23011
  const rel = relativePath(absRoot, absFile);
22773
- if (rel.startsWith("..") || rel.startsWith(sep6) || rel.length === 0) {
23012
+ if (rel.startsWith("..") || rel.startsWith(sep7) || rel.length === 0) {
22774
23013
  return null;
22775
23014
  }
22776
23015
  let raw;
@@ -23142,7 +23381,7 @@ function registerPluginsRoute(app, deps) {
23142
23381
  const id = c.req.param("id");
23143
23382
  if (id.includes("/")) {
23144
23383
  throw new HTTPException8(400, {
23145
- message: tx(SERVER_TEXTS.pluginsGranularityExtensionExpected, { id })
23384
+ message: tx(SERVER_TEXTS.pluginsCascadeRouteQualifiedRejected, { id })
23146
23385
  });
23147
23386
  }
23148
23387
  const handle = findHandle(id, deps);
@@ -23151,18 +23390,15 @@ function registerPluginsRoute(app, deps) {
23151
23390
  message: tx(SERVER_TEXTS.pluginsUnknown, { id })
23152
23391
  });
23153
23392
  }
23154
- if (granularityOf(handle) !== "bundle") {
23155
- throw new HTTPException8(400, {
23156
- message: tx(SERVER_TEXTS.pluginsGranularityExtensionExpected, { id })
23157
- });
23158
- }
23159
23393
  if (isPluginLocked(id)) {
23160
23394
  throw new HTTPException8(403, {
23161
23395
  message: tx(SERVER_TEXTS.pluginsLocked, { id })
23162
23396
  });
23163
23397
  }
23164
23398
  const body = await parsePatchBody(c.req.raw);
23165
- return await persistAndProject(c, deps, id, body.enabled);
23399
+ const childIds = bundleExtensionIds(handle).map((extId) => qualifiedExtensionId(id, extId));
23400
+ const writable = childIds.filter((q) => !isPluginLocked(q));
23401
+ return await persistManyAndProject(c, deps, writable, body.enabled);
23166
23402
  });
23167
23403
  app.patch("/api/plugins/:bundleId/extensions/:extensionId", async (c) => {
23168
23404
  const bundleId = c.req.param("bundleId");
@@ -23210,8 +23446,7 @@ function listItems(deps, resolveEnabled) {
23210
23446
  ];
23211
23447
  }
23212
23448
  function buildBuiltInItems(resolveEnabled) {
23213
- return builtInBundles.map((bundle) => {
23214
- const bundleEnabled = resolveEnabled(bundle.id);
23449
+ return sortBundlesForPresentation(builtInBundles).map((bundle) => {
23215
23450
  const bundleLocked = isPluginLocked(bundle.id);
23216
23451
  const extensions = bundle.extensions.map((ext) => {
23217
23452
  const qualified = qualifiedExtensionId(bundle.id, ext.id);
@@ -23225,6 +23460,7 @@ function buildBuiltInItems(resolveEnabled) {
23225
23460
  ...extLocked ? { locked: true } : {}
23226
23461
  };
23227
23462
  });
23463
+ const bundleEnabled = extensions.some((e) => e.enabled);
23228
23464
  return {
23229
23465
  id: bundle.id,
23230
23466
  version: firstVersion(bundle.extensions),
@@ -23232,7 +23468,6 @@ function buildBuiltInItems(resolveEnabled) {
23232
23468
  status: bundleEnabled ? "enabled" : "disabled",
23233
23469
  reason: null,
23234
23470
  source: "built-in",
23235
- granularity: bundle.granularity,
23236
23471
  description: bundle.description,
23237
23472
  ...extensions.length > 0 ? { extensions } : {},
23238
23473
  ...bundleLocked ? { locked: true } : {}
@@ -23243,9 +23478,8 @@ function buildDiscoveredItems(discovered, deps, resolveEnabled) {
23243
23478
  return discovered.map((plugin) => buildDiscoveredItem(plugin, deps, resolveEnabled));
23244
23479
  }
23245
23480
  function buildDiscoveredItem(plugin, deps, resolveEnabled) {
23246
- const granularity = plugin.granularity ?? "bundle";
23247
23481
  const bundleLocked = isPluginLocked(plugin.id);
23248
- const extensions = projectExtensionRows(plugin, granularity, resolveEnabled, bundleLocked);
23482
+ const extensions = projectExtensionRows(plugin, resolveEnabled, bundleLocked);
23249
23483
  const optional = optionalDiscoveredFields(plugin, extensions);
23250
23484
  return {
23251
23485
  id: plugin.id,
@@ -23254,7 +23488,6 @@ function buildDiscoveredItem(plugin, deps, resolveEnabled) {
23254
23488
  status: projectStatus(plugin, resolveEnabled),
23255
23489
  reason: plugin.reason ?? null,
23256
23490
  source: classifyPluginSource(plugin.path, deps),
23257
- granularity,
23258
23491
  ...optional,
23259
23492
  ...bundleLocked ? { locked: true } : {},
23260
23493
  ...plugin.status === "disabled" ? { startsAsDisabled: true } : {}
@@ -23267,7 +23500,7 @@ function optionalDiscoveredFields(plugin, extensions) {
23267
23500
  if (extensions) out.extensions = extensions;
23268
23501
  return out;
23269
23502
  }
23270
- function projectExtensionRows(plugin, _granularity, resolveEnabled, bundleLocked) {
23503
+ function projectExtensionRows(plugin, resolveEnabled, bundleLocked) {
23271
23504
  if (!plugin.extensions || plugin.extensions.length === 0) return void 0;
23272
23505
  return plugin.extensions.map((ext) => {
23273
23506
  const description = readInstanceDescription(ext.instance);
@@ -23316,6 +23549,18 @@ async function persistAndProject(c, deps, configKey, enabled) {
23316
23549
  );
23317
23550
  return projectListResponse(c, deps, overrides);
23318
23551
  }
23552
+ async function persistManyAndProject(c, deps, keys, enabled) {
23553
+ const overrides = await tryWithSqlite(
23554
+ { databasePath: deps.options.dbPath, autoBackup: false },
23555
+ async (adapter) => {
23556
+ for (const key of keys) {
23557
+ await applyChangeToAdapter(adapter, key, enabled);
23558
+ }
23559
+ return await adapter.pluginConfig.loadOverrideMap();
23560
+ }
23561
+ );
23562
+ return projectListResponse(c, deps, overrides);
23563
+ }
23319
23564
  async function applyChangeToAdapter(adapter, configKey, enabled) {
23320
23565
  await adapter.pluginConfig.set(configKey, enabled);
23321
23566
  if (enabled) return;
@@ -23359,13 +23604,6 @@ function validateBulkChange(change, deps) {
23359
23604
  message: tx(SERVER_TEXTS.pluginsUnknown, { id: change.id })
23360
23605
  };
23361
23606
  }
23362
- if (granularityOf(handle2) !== "bundle") {
23363
- return {
23364
- status: 400,
23365
- code: "bad-query",
23366
- message: tx(SERVER_TEXTS.pluginsGranularityExtensionExpected, { id: change.id })
23367
- };
23368
- }
23369
23607
  if (isPluginLocked(change.id)) {
23370
23608
  return {
23371
23609
  status: 403,
@@ -23406,13 +23644,22 @@ async function persistBulkAndProject(c, deps, changes) {
23406
23644
  { databasePath: deps.options.dbPath, autoBackup: false },
23407
23645
  async (adapter) => {
23408
23646
  for (const change of changes) {
23409
- await applyChangeToAdapter(adapter, change.id, change.enabled);
23647
+ const writeKeys = expandBulkChangeKeys(change, deps);
23648
+ for (const key of writeKeys) {
23649
+ await applyChangeToAdapter(adapter, key, change.enabled);
23650
+ }
23410
23651
  }
23411
23652
  return await adapter.pluginConfig.loadOverrideMap();
23412
23653
  }
23413
23654
  );
23414
23655
  return projectListResponse(c, deps, overrides);
23415
23656
  }
23657
+ function expandBulkChangeKeys(change, deps) {
23658
+ if (change.id.includes("/")) return [change.id];
23659
+ const handle = findHandle(change.id, deps);
23660
+ if (!handle) return [];
23661
+ return bundleExtensionIds(handle).map((extId) => qualifiedExtensionId(change.id, extId)).filter((q) => !isPluginLocked(q));
23662
+ }
23416
23663
  async function buildFreshResolver2(deps) {
23417
23664
  return buildFreshResolver({
23418
23665
  databasePath: deps.options.dbPath,
@@ -23430,8 +23677,11 @@ function findHandle(id, deps) {
23430
23677
  if (discovered) return { kind: "discovered", plugin: discovered };
23431
23678
  return null;
23432
23679
  }
23433
- function granularityOf(handle) {
23434
- return handle.kind === "built-in" ? handle.bundle.granularity : handle.plugin.granularity ?? "bundle";
23680
+ function bundleExtensionIds(handle) {
23681
+ if (handle.kind === "built-in") {
23682
+ return handle.bundle.extensions.map((e) => e.id);
23683
+ }
23684
+ return (handle.plugin.extensions ?? []).map((e) => e.id);
23435
23685
  }
23436
23686
  function hasExtension(handle, extensionId) {
23437
23687
  if (handle.kind === "built-in") {
@@ -24365,12 +24615,12 @@ async function loadNode(deps, nodePath) {
24365
24615
  return node;
24366
24616
  }
24367
24617
  function invokeBump2(node, absPath, body) {
24368
- if (!bumpAction.invoke) {
24618
+ if (!nodeBumpAction.invoke) {
24369
24619
  throw new HTTPException14(500, { message: SERVER_TEXTS.sidecarBumpInvokeMissing });
24370
24620
  }
24371
24621
  const input = {};
24372
24622
  if (body.force === true) input.force = true;
24373
- return bumpAction.invoke(input, {
24623
+ return nodeBumpAction.invoke(input, {
24374
24624
  node,
24375
24625
  nodeAbsolutePath: absPath,
24376
24626
  invoker: "ui",
@@ -25041,7 +25291,7 @@ function validateNoUi(noUi, uiDist) {
25041
25291
  // server/paths.ts
25042
25292
  import { existsSync as existsSync27, statSync as statSync10 } from "fs";
25043
25293
  import { dirname as dirname18, isAbsolute as isAbsolute11, join as join20, resolve as resolve37 } from "path";
25044
- import { fileURLToPath as fileURLToPath5 } from "url";
25294
+ import { fileURLToPath as fileURLToPath6 } from "url";
25045
25295
  var DEFAULT_UI_REL = join20("ui", "dist", "ui", "browser");
25046
25296
  var PACKAGE_UI_REL = "ui";
25047
25297
  var INDEX_HTML2 = "index.html";
@@ -25065,7 +25315,7 @@ function isUiBundleDir(path) {
25065
25315
  function resolvePackageBundledUi() {
25066
25316
  let here;
25067
25317
  try {
25068
- here = dirname18(fileURLToPath5(import.meta.url));
25318
+ here = dirname18(fileURLToPath6(import.meta.url));
25069
25319
  } catch {
25070
25320
  return null;
25071
25321
  }
@@ -25357,7 +25607,9 @@ var ESC2 = {
25357
25607
  /** 256-color violet (xterm 141). */
25358
25608
  violet: "\x1B[38;5;141m",
25359
25609
  /** 256-color green (xterm 42). */
25360
- green: "\x1B[38;5;42m"
25610
+ green: "\x1B[38;5;42m",
25611
+ /** 256-color yellow (xterm 214), matches `cli/util/ansi.ts:yellow`. */
25612
+ yellow: "\x1B[38;5;214m"
25361
25613
  };
25362
25614
  var LOGO_LINES = [
25363
25615
  " ____ _ _ _ _ __ __ ",
@@ -25377,7 +25629,8 @@ function renderBanner(input) {
25377
25629
  host: input.host,
25378
25630
  port: input.port,
25379
25631
  dbPath: input.dbPath,
25380
- openBrowser: input.openBrowser
25632
+ openBrowser: input.openBrowser,
25633
+ dev: input.dev === true
25381
25634
  });
25382
25635
  }
25383
25636
  return renderFiglet({
@@ -25387,7 +25640,8 @@ function renderBanner(input) {
25387
25640
  pathDisplay: formatCwdPath(input.cwd),
25388
25641
  browserLine,
25389
25642
  colorEnabled: input.colorEnabled,
25390
- referencePaths: input.referencePaths ?? []
25643
+ referencePaths: input.referencePaths ?? [],
25644
+ dev: input.dev === true
25391
25645
  });
25392
25646
  }
25393
25647
  function resolveColorEnabled(opts) {
@@ -25402,8 +25656,9 @@ function renderFlat(input) {
25402
25656
  const safeHost = sanitizeForTerminal(input.host);
25403
25657
  const safeDb = sanitizeForTerminal(input.dbPath);
25404
25658
  const url = `http://${safeHost}:${input.port}`;
25659
+ const devSuffix = input.dev ? " [dev]" : "";
25405
25660
  const linesOut = [];
25406
- linesOut.push(`sm serve: listening on ${url} (db=${safeDb})`);
25661
+ linesOut.push(`sm serve${devSuffix}: listening on ${url} (db=${safeDb})`);
25407
25662
  if (input.openBrowser) {
25408
25663
  linesOut.push(`sm serve: opening ${url}/ in your browser. Press Ctrl+C to stop.`);
25409
25664
  } else {
@@ -25430,12 +25685,16 @@ function renderFiglet(input) {
25430
25685
  greenUnderline,
25431
25686
  greenUnderlineClose,
25432
25687
  violetOpen,
25433
- violetClose
25688
+ violetClose,
25689
+ yellowOpen,
25690
+ yellowClose
25434
25691
  } = resolveAnsi(input.colorEnabled);
25435
25692
  const logoLines = LOGO_LINES.map((line) => `${violetOpen}${line}${violetClose}`);
25436
25693
  const versionText = `v${input.version}`;
25437
- const versionPad = Math.max(0, LOGO_WIDTH - versionText.length);
25438
- const versionLine = `${" ".repeat(versionPad)}${dimOpen}${versionText}${dimClose}`;
25694
+ const devText = "[dev]";
25695
+ const versionWidth = input.dev ? devText.length : versionText.length;
25696
+ const versionPad = Math.max(0, LOGO_WIDTH - versionWidth);
25697
+ const versionLine = input.dev ? `${" ".repeat(versionPad)}${yellowOpen}${devText}${yellowClose}` : `${" ".repeat(versionPad)}${dimOpen}${versionText}${dimClose}`;
25439
25698
  const lines = [];
25440
25699
  lines.push(...logoLines);
25441
25700
  lines.push("");
@@ -25467,7 +25726,9 @@ var EMPTY_ANSI = {
25467
25726
  greenUnderline: "",
25468
25727
  greenUnderlineClose: "",
25469
25728
  violetOpen: "",
25470
- violetClose: ""
25729
+ violetClose: "",
25730
+ yellowOpen: "",
25731
+ yellowClose: ""
25471
25732
  };
25472
25733
  var ENABLED_ANSI = {
25473
25734
  dimOpen: ESC2.dim,
@@ -25475,7 +25736,9 @@ var ENABLED_ANSI = {
25475
25736
  greenUnderline: `${ESC2.green}${ESC2.underline}`,
25476
25737
  greenUnderlineClose: ESC2.reset,
25477
25738
  violetOpen: ESC2.violet,
25478
- violetClose: ESC2.reset
25739
+ violetClose: ESC2.reset,
25740
+ yellowOpen: ESC2.yellow,
25741
+ yellowClose: ESC2.reset
25479
25742
  };
25480
25743
  function resolveAnsi(colorEnabled) {
25481
25744
  return colorEnabled ? ENABLED_ANSI : EMPTY_ANSI;
@@ -25696,7 +25959,8 @@ var ServeCommand = class extends SmCommand {
25696
25959
  openBrowser: validation.options.open,
25697
25960
  isTTY,
25698
25961
  colorEnabled,
25699
- referencePaths
25962
+ referencePaths,
25963
+ dev: isDevBuild()
25700
25964
  })
25701
25965
  );
25702
25966
  if (validation.options.open) {
@@ -26830,7 +27094,7 @@ var STUB_COMMANDS = [
26830
27094
  // cli/commands/tutorial.ts
26831
27095
  import { cpSync as cpSync2, existsSync as existsSync29, mkdirSync as mkdirSync6, rmSync as rmSync2, statSync as statSync11 } from "fs";
26832
27096
  import { dirname as dirname19, join as join21, resolve as resolve39 } from "path";
26833
- import { fileURLToPath as fileURLToPath6 } from "url";
27097
+ import { fileURLToPath as fileURLToPath7 } from "url";
26834
27098
  import { Command as Command37, Option as Option35 } from "clipanion";
26835
27099
 
26836
27100
  // cli/i18n/tutorial.texts.ts
@@ -27000,7 +27264,7 @@ function resolveSkillSourceDir(variant) {
27000
27264
  const cached = cachedSourceDirs.get(variant);
27001
27265
  if (cached !== void 0) return cached;
27002
27266
  const spec = VARIANT_SPECS[variant];
27003
- const here = dirname19(fileURLToPath6(import.meta.url));
27267
+ const here = dirname19(fileURLToPath7(import.meta.url));
27004
27268
  const candidates = [
27005
27269
  // dev: src/cli/commands/ → repo-root .claude/skills/<slug>/
27006
27270
  resolve39(here, "../../..", spec.sourceDir),
@@ -27048,6 +27312,7 @@ var VersionCommand = class extends SmCommand {
27048
27312
  const kernelVersion = VERSION;
27049
27313
  const specVersion = await resolveSpecVersion3();
27050
27314
  const dbSchema = await resolveDbSchemaVersion();
27315
+ const dev = isDevBuild();
27051
27316
  if (this.json) {
27052
27317
  const payload = {
27053
27318
  sm: VERSION,
@@ -27055,17 +27320,19 @@ var VersionCommand = class extends SmCommand {
27055
27320
  spec: specVersion,
27056
27321
  dbSchema
27057
27322
  };
27323
+ if (dev) payload["dev"] = true;
27058
27324
  this.printer.data(JSON.stringify(payload) + "\n");
27059
27325
  return ExitCode.Ok;
27060
27326
  }
27327
+ const ansi = this.ansiFor("stdout");
27328
+ const smValue = dev ? `${VERSION} ${ansi.yellow("[dev]")}` : VERSION;
27061
27329
  const lines = [
27062
- ["sm", VERSION],
27330
+ ["sm", smValue],
27063
27331
  ["kernel", kernelVersion],
27064
27332
  ["spec", specVersion],
27065
27333
  ["runtime", runtime],
27066
27334
  ["db-schema", dbSchema]
27067
27335
  ];
27068
- const ansi = this.ansiFor("stdout");
27069
27336
  const pad = Math.max(...lines.map(([k]) => k.length));
27070
27337
  for (const [k, v] of lines) {
27071
27338
  this.printer.data(