@skill-map/cli 0.37.0 → 0.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // cli/entry.ts
2
- import { existsSync as existsSync33 } from "fs";
2
+ import { existsSync as existsSync30 } from "fs";
3
3
  import { Builtins, Cli as Cli2 } from "clipanion";
4
4
 
5
5
  // kernel/adapters/in-memory-progress.ts
@@ -61,7 +61,7 @@ var DuplicateExtensionError = class extends Error {
61
61
  }
62
62
  };
63
63
  var Registry = class {
64
- /** kind → qualifiedId → Extension. */
64
+ /** kind → qualifiedId → IExtension. */
65
65
  #byKind;
66
66
  constructor() {
67
67
  this.#byKind = new Map(
@@ -429,13 +429,20 @@ var command_schema_default = {
429
429
  properties: {}
430
430
  };
431
431
 
432
+ // plugins/ids.ts
433
+ var CORE_PLUGIN_ID = "core";
434
+ var CLAUDE_PLUGIN_ID = "claude";
435
+ var OPENAI_PLUGIN_ID = "openai";
436
+ var ANTIGRAVITY_PLUGIN_ID = "antigravity";
437
+ var AGENT_SKILLS_PLUGIN_ID = "agent-skills";
438
+
432
439
  // plugins/claude/providers/claude/index.ts
433
440
  var claudeProvider = {
434
441
  id: "claude",
435
- pluginId: "claude",
442
+ pluginId: CLAUDE_PLUGIN_ID,
436
443
  kind: "provider",
437
444
  version: "1.0.0",
438
- description: "Walks Claude Code scope conventions (.claude/{agents,commands,skills}).",
445
+ description: "Classifies files under `.claude/{agents,commands,skills}` as Claude Code agents, commands, and skills.",
439
446
  // Vendor provider: Claude Code only reads its own `.claude/` territory
440
447
  // and ignores `.codex/` / Antigravity layouts at runtime. Gating the
441
448
  // classifier behind the active lens prevents the walker from inventing
@@ -657,9 +664,9 @@ function stripFences(input) {
657
664
  }
658
665
  continue;
659
666
  }
660
- const open = FENCE_RE.exec(line);
661
- if (open?.groups) {
662
- openFence = open.groups["fence"];
667
+ const open2 = FENCE_RE.exec(line);
668
+ if (open2?.groups) {
669
+ openFence = open2.groups["fence"];
663
670
  out.push(blank(line));
664
671
  continue;
665
672
  }
@@ -717,10 +724,10 @@ var AT_RE = /(?:^|[^A-Za-z0-9_@])(@(?:\.{1,2}\/|\/)?[a-z0-9](?:[a-z0-9_\-./]*[a-
717
724
  var FILE_EXT_RE = /\.(md|mdx|js|jsx|ts|tsx|json|yml|yaml|toml|txt|html|css|scss|less|py|rb|go|rs|java|c|cpp|h|hpp|sh|sql|svg|png|jpg|jpeg|gif|webp|pdf)$/i;
718
725
  var atDirectiveExtractor = {
719
726
  id: ID,
720
- pluginId: "claude",
727
+ pluginId: CLAUDE_PLUGIN_ID,
721
728
  kind: "extractor",
722
729
  version: "1.0.0",
723
- description: "Detects `@<token>` directives in a node's body using Claude Code interpretation rules. A bare handle (e.g. `@team`) becomes a `mentions` link; a file-flavoured token (e.g. `@docs/api.md`, `@./readme.md`) becomes a `references` link. Gated by `precondition.provider: ['claude']` so Antigravity / Cursor / Codex apply their own at-directive flavours via their own extractors.",
730
+ description: "Detects `@<token>` directives in a node's body using Claude Code rules. A bare handle (e.g. `@team`) becomes a `mentions` link; a file-flavoured token (e.g. `@docs/api.md`, `@./readme.md`) becomes a `references` link.",
724
731
  scope: "body",
725
732
  precondition: { provider: ["claude"] },
726
733
  // eslint-disable-next-line complexity
@@ -801,15 +808,15 @@ function resolveSourceRelative(sourceDir, bare) {
801
808
  return pathPosix.normalize(joined);
802
809
  }
803
810
 
804
- // plugins/claude/extractors/slash/index.ts
805
- var ID2 = "slash";
811
+ // plugins/claude/extractors/slash-command/index.ts
812
+ var ID2 = "slash-command";
806
813
  var SLASH_RE = /(?<![A-Za-z0-9_/.:?#=&])(\/[a-z0-9][a-z0-9_-]*(?::[a-z0-9][a-z0-9_-]*)?)/gi;
807
- var slashExtractor = {
814
+ var slashCommandExtractor = {
808
815
  id: ID2,
809
- pluginId: "claude",
816
+ pluginId: CLAUDE_PLUGIN_ID,
810
817
  kind: "extractor",
811
818
  version: "1.0.0",
812
- description: "Detects `/command` invocations in a node's body using Claude Code routing rules and turns each one into an arrow between nodes in the graph. Gated by `precondition.provider: ['claude']` so Antigravity / Cursor / Codex apply their own slash flavours (Antigravity ships subagent and skill panels, Codex deprecated user slash commands, etc.) via their own extractors.",
819
+ description: "Turns `/command` invocations in a node's body into arrows that point at the resolved slash command or skill, using Claude Code routing rules.",
813
820
  scope: "body",
814
821
  precondition: { provider: ["claude"] },
815
822
  extract(ctx) {
@@ -856,10 +863,10 @@ var slashExtractor = {
856
863
  // plugins/antigravity/providers/antigravity/index.ts
857
864
  var antigravityProvider = {
858
865
  id: "antigravity",
859
- pluginId: "antigravity",
866
+ pluginId: ANTIGRAVITY_PLUGIN_ID,
860
867
  kind: "provider",
861
868
  version: "1.0.0",
862
- description: "Google Antigravity CLI. Replaces the retired Gemini CLI; skills route through the neutral `agent-skills` Provider via `.agents/skills/`. This Provider contributes lens identity and a reserved-name seed catalog.",
869
+ description: "Declares the Google Antigravity runtime and its reserved built-in names.",
863
870
  // Vendor provider: marked gated for the day Antigravity grows its own
864
871
  // on-disk kind beyond the open standard. Today `kinds: {}` and
865
872
  // `classify` returns `null` for every path, so the flag is inert; the
@@ -889,7 +896,7 @@ var antigravityProvider = {
889
896
  // Gemini CLI's. We mirror the full 38-verb Gemini CLI catalog (plus its
890
897
  // four documented aliases: `dir`, `?`, `exit`, `bashes`) so a user file
891
898
  // that names a skill / command `help`, `clear`, `mcp`, etc. is flagged
892
- // immediately by `core/reserved-name` once the lens activates the catalog.
899
+ // immediately by `core/name-reserved` once the lens activates the catalog.
893
900
  //
894
901
  // The catalog is INACTIVE today: the analyzer keys on `node.provider`
895
902
  // and this Provider's `classify()` returns `null` for every path, so
@@ -1005,10 +1012,10 @@ var agent_schema_default2 = {
1005
1012
  // plugins/openai/providers/openai/index.ts
1006
1013
  var openaiProvider = {
1007
1014
  id: "openai",
1008
- pluginId: "openai",
1015
+ pluginId: OPENAI_PLUGIN_ID,
1009
1016
  kind: "provider",
1010
1017
  version: "1.0.0",
1011
- description: "Walks OpenAI Codex CLI scope conventions (.codex/agents/*.toml).",
1018
+ description: "Classifies files under `.codex/agents/*.toml` as OpenAI Codex CLI sub-agents.",
1012
1019
  // Vendor provider: Codex CLI only reads its own `.codex/` territory.
1013
1020
  // Gating the classifier behind the active lens keeps the walker from
1014
1021
  // claiming Codex agents under a `claude` (or any other) lens, where
@@ -1063,10 +1070,10 @@ var skill_schema_default2 = {
1063
1070
  // plugins/agent-skills/providers/agent-skills/index.ts
1064
1071
  var agentSkillsProvider = {
1065
1072
  id: "agent-skills",
1066
- pluginId: "agent-skills",
1073
+ pluginId: AGENT_SKILLS_PLUGIN_ID,
1067
1074
  kind: "provider",
1068
1075
  version: "1.0.0",
1069
- description: "Agent Skills open standard. Vendor-neutral path `.agents/skills/<name>/SKILL.md` (Anthropic, OpenAI, Google). See agentskills.io.",
1076
+ description: "Classifies files under `.agents/skills/<name>/SKILL.md` as Agent Skills.",
1070
1077
  read: { extensions: [".md"], parser: "frontmatter-yaml" },
1071
1078
  kinds: {
1072
1079
  skill: {
@@ -1111,10 +1118,10 @@ var markdown_schema_default = {
1111
1118
  // plugins/core/providers/core-markdown/index.ts
1112
1119
  var coreMarkdownProvider = {
1113
1120
  id: "markdown",
1114
- pluginId: "core",
1121
+ pluginId: CORE_PLUGIN_ID,
1115
1122
  kind: "provider",
1116
1123
  version: "1.0.0",
1117
- description: "Universal `.md` fallback. Claims any markdown file no vendor-specific Provider classifies.",
1124
+ description: "Universal `.md` fallback. Claims any markdown file that no vendor-specific provider has classified.",
1118
1125
  read: { extensions: [".md"], parser: "frontmatter-yaml" },
1119
1126
  // Per spec § A.6, defaultRefreshAction values MUST be qualified
1120
1127
  // action ids. The summarize-markdown action is not yet implemented
@@ -1162,10 +1169,10 @@ var coreMarkdownProvider = {
1162
1169
  var ID3 = "annotations";
1163
1170
  var annotationsExtractor = {
1164
1171
  id: ID3,
1165
- pluginId: "core",
1172
+ pluginId: CORE_PLUGIN_ID,
1166
1173
  kind: "extractor",
1167
1174
  version: "1.0.0",
1168
- description: "Turns the `supersedes` and `supersededBy` entries you write in a node's `.sm` sidecar into the arrows (edges) shown between nodes in the graph.",
1175
+ description: "Turns the `supersedes` and `supersededBy` entries from a node's `.sm` sidecar into arrows between nodes in the graph.",
1169
1176
  scope: "frontmatter",
1170
1177
  extract(ctx) {
1171
1178
  const sourcePath = ctx.node.path;
@@ -1224,10 +1231,10 @@ var URL_RE = /https?:\/\/[^\s<>"'`)\]]+/g;
1224
1231
  var TRAILING_PUNCT = /[.,;:!?]+$/;
1225
1232
  var externalUrlCounterExtractor = {
1226
1233
  id: ID4,
1227
- pluginId: "core",
1234
+ pluginId: CORE_PLUGIN_ID,
1228
1235
  kind: "extractor",
1229
1236
  version: "1.0.0",
1230
- description: "Counts the distinct external URLs in a node's body and shows the total on the card.",
1237
+ description: "Counts the distinct external URLs in a node's body and shows the count on the card.",
1231
1238
  scope: "body",
1232
1239
  /**
1233
1240
  * Phase 6 / View contribution system, surface the distinct-URL
@@ -1313,10 +1320,10 @@ var LINK_RE = /(?<!!)\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;
1313
1320
  var URL_SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i;
1314
1321
  var markdownLinkExtractor = {
1315
1322
  id: ID5,
1316
- pluginId: "core",
1323
+ pluginId: CORE_PLUGIN_ID,
1317
1324
  kind: "extractor",
1318
1325
  version: "1.0.0",
1319
- description: "Detects markdown links (`[text](path)`) in a node's body and turns each one into an arrow between nodes in the graph.",
1326
+ description: "Turns markdown links (`[text](path)`) in a node's body into arrows between nodes in the graph.",
1320
1327
  scope: "body",
1321
1328
  extract(ctx) {
1322
1329
  const seen = /* @__PURE__ */ new Set();
@@ -1345,7 +1352,7 @@ var markdownLinkExtractor = {
1345
1352
  // explicitly designates an out-link via the brackets +
1346
1353
  // parentheses pair; there is no inference left to discount.
1347
1354
  // Whether the path resolves to a real node is a separate
1348
- // concern (the `core/broken-ref` analyzer flags unresolved
1355
+ // concern (the `core/reference-broken` analyzer flags unresolved
1349
1356
  // targets), not a confidence question.
1350
1357
  confidence: 1,
1351
1358
  rationale: "unambiguous markdown link syntax",
@@ -1375,10 +1382,10 @@ var ID6 = "mcp-tools";
1375
1382
  var MCP_PATTERN = /^mcp__([a-z0-9][a-z0-9_-]*)__[a-z0-9_-]+$/i;
1376
1383
  var mcpToolsExtractor = {
1377
1384
  id: ID6,
1378
- pluginId: "core",
1385
+ pluginId: CORE_PLUGIN_ID,
1379
1386
  kind: "extractor",
1380
1387
  version: "1.0.0",
1381
- description: "Detects `tools: [mcp__<server>__<tool>]` entries in a node's frontmatter and turns each unique server into an MCP node + a reference edge from the source.",
1388
+ description: "Turns `tools: [mcp__<server>__<tool>]` entries in a node's frontmatter into an MCP node per unique server and an arrow from the source to each one.",
1382
1389
  scope: "frontmatter",
1383
1390
  extract(ctx) {
1384
1391
  const raw = ctx.frontmatter["tools"];
@@ -1432,15 +1439,15 @@ function collectMcpServers(tools) {
1432
1439
  return out;
1433
1440
  }
1434
1441
 
1435
- // plugins/core/extractors/tools-count/index.ts
1436
- var ID7 = "tools-count";
1442
+ // plugins/core/extractors/tools-counter/index.ts
1443
+ var ID7 = "tools-counter";
1437
1444
  var TOOLTIP_MAX = 255;
1438
- var toolsCountExtractor = {
1445
+ var toolsCounterExtractor = {
1439
1446
  id: ID7,
1440
- pluginId: "core",
1447
+ pluginId: CORE_PLUGIN_ID,
1441
1448
  kind: "extractor",
1442
1449
  version: "1.0.0",
1443
- description: "Counts the tools an agent declares in its frontmatter and shows the total on the agent card.",
1450
+ description: "Counts the tools an agent declares in its frontmatter and shows the count on the agent card.",
1444
1451
  scope: "frontmatter",
1445
1452
  precondition: { kind: ["claude/agent"] },
1446
1453
  ui: {
@@ -1472,6 +1479,209 @@ function buildTooltip(names) {
1472
1479
  return `${joined.slice(0, TOOLTIP_MAX - 1)}\u2026`;
1473
1480
  }
1474
1481
 
1482
+ // plugins/core/analyzers/annotation-field-unknown/index.ts
1483
+ import { readFileSync } from "fs";
1484
+ import { dirname, resolve } from "path";
1485
+ import { createRequire } from "module";
1486
+ import { Ajv2020 } from "ajv/dist/2020.js";
1487
+
1488
+ // kernel/util/ajv-interop.ts
1489
+ import addFormatsModule from "ajv-formats";
1490
+ var addFormats = addFormatsModule.default ?? addFormatsModule;
1491
+ function applyAjvFormats(ajv) {
1492
+ addFormats(ajv);
1493
+ }
1494
+
1495
+ // plugins/core/analyzers/annotation-field-unknown/text.ts
1496
+ var ANNOTATION_FIELD_UNKNOWN_TEXTS = {
1497
+ /** Key inside `annotations:` is not in the curated catalog. */
1498
+ unknownAnnotationKey: "{{path}}: sidecar annotations contain unknown key '{{key}}' (not in annotations.schema.json catalog).",
1499
+ /** Top-level key is neither reserved, nor a registered plugin namespace, nor a registered root key. */
1500
+ unknownRootKey: "{{path}}: sidecar declares unknown top-level key '{{key}}'; not a reserved block, not a registered plugin namespace, not a registered root contribution.",
1501
+ /** Value under a registered plugin namespace fails the contributed schema. */
1502
+ pluginNamespaceInvalid: "{{path}}: sidecar block '{{pluginId}}.{{key}}' fails the schema contributed by plugin '{{pluginId}}': {{errors}}.",
1503
+ // Tooltips for the per-node view-contribution badges. Singular vs
1504
+ // plural keeps the count grammar correct without a sub-template.
1505
+ alertTooltipSingle: "This node has 1 unknown field in its sidecar. Open the inspector for details.",
1506
+ alertTooltipMany: "This node has {{count}} unknown fields in its sidecar. Open the inspector for details."
1507
+ };
1508
+
1509
+ // plugins/core/analyzers/annotation-field-unknown/index.ts
1510
+ var ID8 = "annotation-field-unknown";
1511
+ var RESERVED_ROOT_BLOCKS = /* @__PURE__ */ new Set(["identity", "annotations", "settings", "audit"]);
1512
+ var annotationFieldUnknownAnalyzer = {
1513
+ id: ID8,
1514
+ pluginId: CORE_PLUGIN_ID,
1515
+ kind: "analyzer",
1516
+ version: "1.0.0",
1517
+ description: "Flags typos or unrecognized keys in sidecars (`.sm`).",
1518
+ mode: "deterministic",
1519
+ ui: {
1520
+ // Corner badge on the graph card; count omitted when there is a
1521
+ // single unknown field (avoids a noisy "icon + 1" chip).
1522
+ alert: {
1523
+ slot: "graph.node.alert",
1524
+ // Filled warning triangle on the corner, matches the broken-ref
1525
+ // alert's "attention-grabbing solid" pattern; the footer chip
1526
+ // below stays outlined for the quieter counter pairing.
1527
+ icon: "fa-solid fa-triangle-exclamation",
1528
+ emitWhenEmpty: false
1529
+ },
1530
+ // Footer chip on the card, `_counter` shape but rendered icon-only
1531
+ // (the analyzer emits `value: 0` so NodeCounter hides the number
1532
+ // and only the glyph shows). PrimeIcons `pi-question-circle` so the
1533
+ // visual weight matches `annotation-stale`'s `pi-clock` chip
1534
+ // sitting next to it on the same footer row. `emitWhenEmpty: true`
1535
+ // is required: with `value: 0` the slot treats the payload as
1536
+ // empty, so the manifest has to opt in to keep the emission.
1537
+ chip: {
1538
+ slot: "card.footer.right",
1539
+ icon: "pi-question-circle",
1540
+ emitWhenEmpty: true,
1541
+ priority: 30
1542
+ }
1543
+ },
1544
+ // Analyzer body iterates every sidecar root and classifies each
1545
+ // key against three buckets (catalog / plugin namespace / unknown
1546
+ // root). The per-key branching IS the classification table; factoring
1547
+ // it out would rebuild the discriminator elsewhere. Per
1548
+ // `context/lint.md` category 7 (recursive type-discriminator walkers).
1549
+ // eslint-disable-next-line complexity
1550
+ evaluate(ctx) {
1551
+ const sidecarRoots = ctx.sidecarRoots;
1552
+ if (!sidecarRoots || sidecarRoots.size === 0) return [];
1553
+ const knownAnnotationKeys = getKnownAnnotationKeys();
1554
+ const contributions = ctx.annotationContributions ?? [];
1555
+ const namespacedByPlugin = indexNamespacedContributions(contributions);
1556
+ const rootKeys = indexRootContributions(contributions);
1557
+ const knownPluginIds = collectPluginIds(contributions);
1558
+ const issues = [];
1559
+ const perNode = /* @__PURE__ */ new Map();
1560
+ const bump2 = (nodePath) => {
1561
+ perNode.set(nodePath, (perNode.get(nodePath) ?? 0) + 1);
1562
+ };
1563
+ for (const node of ctx.nodes) {
1564
+ const root = sidecarRoots.get(node.path);
1565
+ if (!root) continue;
1566
+ const annotations = root["annotations"];
1567
+ if (annotations !== void 0 && annotations !== null && typeof annotations === "object" && !Array.isArray(annotations)) {
1568
+ for (const key of Object.keys(annotations)) {
1569
+ if (!knownAnnotationKeys.has(key)) {
1570
+ issues.push({
1571
+ analyzerId: ID8,
1572
+ severity: "warn",
1573
+ nodeIds: [node.path],
1574
+ message: tx(ANNOTATION_FIELD_UNKNOWN_TEXTS.unknownAnnotationKey, {
1575
+ path: node.path,
1576
+ key
1577
+ }),
1578
+ data: { surface: "annotations", key }
1579
+ });
1580
+ bump2(node.path);
1581
+ }
1582
+ }
1583
+ }
1584
+ for (const key of Object.keys(root)) {
1585
+ if (RESERVED_ROOT_BLOCKS.has(key)) continue;
1586
+ if (rootKeys.has(key)) continue;
1587
+ if (knownPluginIds.has(key)) {
1588
+ const block = root[key];
1589
+ if (block === null || typeof block !== "object" || Array.isArray(block)) continue;
1590
+ const contribsForPlugin = namespacedByPlugin.get(key);
1591
+ if (!contribsForPlugin) continue;
1592
+ for (const [contribKey, validator] of contribsForPlugin) {
1593
+ const value = block[contribKey];
1594
+ if (value === void 0) continue;
1595
+ if (validator(value)) continue;
1596
+ const errors = (validator.errors ?? []).map((e) => `${e.instancePath || "(root)"} ${e.message ?? e.keyword}`).join("; ");
1597
+ issues.push({
1598
+ analyzerId: ID8,
1599
+ severity: "warn",
1600
+ nodeIds: [node.path],
1601
+ message: tx(ANNOTATION_FIELD_UNKNOWN_TEXTS.pluginNamespaceInvalid, {
1602
+ path: node.path,
1603
+ pluginId: key,
1604
+ key: contribKey,
1605
+ errors
1606
+ }),
1607
+ data: { surface: "plugin-namespace", pluginId: key, key: contribKey }
1608
+ });
1609
+ bump2(node.path);
1610
+ }
1611
+ continue;
1612
+ }
1613
+ issues.push({
1614
+ analyzerId: ID8,
1615
+ severity: "warn",
1616
+ nodeIds: [node.path],
1617
+ message: tx(ANNOTATION_FIELD_UNKNOWN_TEXTS.unknownRootKey, {
1618
+ path: node.path,
1619
+ key
1620
+ }),
1621
+ data: { surface: "root", key }
1622
+ });
1623
+ bump2(node.path);
1624
+ }
1625
+ }
1626
+ for (const [nodePath, count] of perNode) {
1627
+ const tooltip = count === 1 ? ANNOTATION_FIELD_UNKNOWN_TEXTS.alertTooltipSingle : tx(ANNOTATION_FIELD_UNKNOWN_TEXTS.alertTooltipMany, { count });
1628
+ ctx.emitContribution(nodePath, "alert", {
1629
+ icon: "fa-solid fa-triangle-exclamation",
1630
+ severity: "warn",
1631
+ tooltip
1632
+ });
1633
+ ctx.emitContribution(nodePath, "chip", {
1634
+ value: 0,
1635
+ severity: "warn",
1636
+ tooltip
1637
+ });
1638
+ }
1639
+ return issues;
1640
+ }
1641
+ };
1642
+ var cachedKnownKeys = null;
1643
+ function getKnownAnnotationKeys() {
1644
+ if (cachedKnownKeys) return cachedKnownKeys;
1645
+ const require2 = createRequire(import.meta.url);
1646
+ const indexPath = require2.resolve("@skill-map/spec/index.json");
1647
+ const specRoot = dirname(indexPath);
1648
+ const schema = JSON.parse(
1649
+ readFileSync(resolve(specRoot, "schemas/annotations.schema.json"), "utf8")
1650
+ );
1651
+ cachedKnownKeys = new Set(Object.keys(schema.properties ?? {}));
1652
+ return cachedKnownKeys;
1653
+ }
1654
+ function indexNamespacedContributions(contributions) {
1655
+ const out = /* @__PURE__ */ new Map();
1656
+ const ajv = new Ajv2020({ strict: false, allErrors: true, allowUnionTypes: true });
1657
+ applyAjvFormats(ajv);
1658
+ for (const entry of contributions) {
1659
+ if (entry.location !== "namespaced") continue;
1660
+ let bucket = out.get(entry.pluginId);
1661
+ if (!bucket) {
1662
+ bucket = /* @__PURE__ */ new Map();
1663
+ out.set(entry.pluginId, bucket);
1664
+ }
1665
+ try {
1666
+ bucket.set(entry.key, ajv.compile(entry.schema));
1667
+ } catch {
1668
+ }
1669
+ }
1670
+ return out;
1671
+ }
1672
+ function indexRootContributions(contributions) {
1673
+ const out = /* @__PURE__ */ new Set();
1674
+ for (const entry of contributions) {
1675
+ if (entry.location === "root") out.add(entry.key);
1676
+ }
1677
+ return out;
1678
+ }
1679
+ function collectPluginIds(contributions) {
1680
+ const out = /* @__PURE__ */ new Set();
1681
+ for (const entry of contributions) out.add(entry.pluginId);
1682
+ return out;
1683
+ }
1684
+
1475
1685
  // plugins/core/analyzers/annotation-orphan/text.ts
1476
1686
  var ANNOTATION_ORPHAN_TEXTS = {
1477
1687
  /** Sidecar `<path>.sm` has no matching `<path>.md`. */
@@ -1479,13 +1689,13 @@ var ANNOTATION_ORPHAN_TEXTS = {
1479
1689
  };
1480
1690
 
1481
1691
  // plugins/core/analyzers/annotation-orphan/index.ts
1482
- var ID8 = "annotation-orphan";
1692
+ var ID9 = "annotation-orphan";
1483
1693
  var annotationOrphanAnalyzer = {
1484
- id: ID8,
1485
- pluginId: "core",
1694
+ id: ID9,
1695
+ pluginId: CORE_PLUGIN_ID,
1486
1696
  kind: "analyzer",
1487
1697
  version: "1.0.0",
1488
- description: "Detects and flags sidecars (`.sm`) whose `.md` no longer exists.",
1698
+ description: "Flags sidecars (`.sm`) whose `.md` file no longer exists.",
1489
1699
  mode: "deterministic",
1490
1700
  evaluate(ctx) {
1491
1701
  const orphans = ctx.orphanSidecars;
@@ -1494,7 +1704,7 @@ var annotationOrphanAnalyzer = {
1494
1704
  for (const orphan of orphans) {
1495
1705
  const expectedMdRelative = orphan.relativePath.endsWith(".sm") ? `${orphan.relativePath.slice(0, -".sm".length)}.md` : `${orphan.relativePath}.md`;
1496
1706
  issues.push({
1497
- analyzerId: ID8,
1707
+ analyzerId: ID9,
1498
1708
  severity: "warn",
1499
1709
  nodeIds: [expectedMdRelative],
1500
1710
  message: tx(ANNOTATION_ORPHAN_TEXTS.message, {
@@ -1531,17 +1741,17 @@ var ANNOTATION_STALE_TEXTS = {
1531
1741
  };
1532
1742
 
1533
1743
  // plugins/core/analyzers/annotation-stale/index.ts
1534
- var ID9 = "annotation-stale";
1744
+ var ID10 = "annotation-stale";
1535
1745
  var annotationStaleAnalyzer = {
1536
- id: ID9,
1537
- pluginId: "core",
1746
+ id: ID10,
1747
+ pluginId: CORE_PLUGIN_ID,
1538
1748
  kind: "analyzer",
1539
1749
  version: "1.0.0",
1540
- description: "Detects and marks sidecars (`.sm`) out of date of their `.md`.",
1750
+ description: "Marks sidecars (`.sm`) that are out of date with their `.md`.",
1541
1751
  mode: "deterministic",
1542
1752
  // The natural fix is to bump the node: refreshes `for` hashes,
1543
1753
  // increments `annotations.version`, and stamps the audit block. The
1544
- // UI surfaces `core/bump` in the node inspector under "Recommended
1754
+ // UI surfaces `core/node-bump` in the node inspector under "Recommended
1545
1755
  // for issues" whenever this analyzer fires.
1546
1756
  ui: {
1547
1757
  // A `pi-clock` chip in the footer-right cluster so the operator
@@ -1568,7 +1778,7 @@ var annotationStaleAnalyzer = {
1568
1778
  if (status === "fresh") continue;
1569
1779
  const message = status === "stale-body" ? tx(ANNOTATION_STALE_TEXTS.bodyDrift, { path: node.path }) : status === "stale-frontmatter" ? tx(ANNOTATION_STALE_TEXTS.frontmatterDrift, { path: node.path }) : tx(ANNOTATION_STALE_TEXTS.bothDrift, { path: node.path });
1570
1780
  issues.push({
1571
- analyzerId: ID9,
1781
+ analyzerId: ID10,
1572
1782
  severity: "warn",
1573
1783
  nodeIds: [node.path],
1574
1784
  message,
@@ -1594,221 +1804,37 @@ function tooltipFor(status) {
1594
1804
  }
1595
1805
  }
1596
1806
 
1597
- // plugins/core/analyzers/broken-ref/index.ts
1598
- import { posix as pathPosix3, resolve } from "path";
1807
+ // plugins/core/analyzers/contribution-orphan/index.ts
1808
+ var ID11 = "contribution-orphan";
1809
+ var contributionOrphanAnalyzer = {
1810
+ id: ID11,
1811
+ pluginId: CORE_PLUGIN_ID,
1812
+ kind: "analyzer",
1813
+ version: "0.0.0",
1814
+ description: "Warns about plugin data referencing nodes renamed or deleted in the latest scan.",
1815
+ mode: "deterministic",
1816
+ evaluate(_ctx) {
1817
+ return [];
1818
+ }
1819
+ };
1599
1820
 
1600
- // plugins/core/analyzers/broken-ref/text.ts
1601
- var BROKEN_REF_TEXTS = {
1602
- /** `Broken <kind> reference from <source> → <target>` */
1603
- message: "Broken {{kind}} reference from {{source}} \u2192 {{target}}",
1604
- // Tooltips for the per-node view-contribution badges. Singular vs
1605
- // plural keeps the count grammar correct without a sub-template.
1606
- alertTooltipSingle: "This node has a broken reference. Open the inspector for details.",
1607
- alertTooltipMany: "This node has {{count}} broken references. Open the inspector for details.",
1608
- // Fix-summary copy when the broken trigger has a same-named file on
1609
- // disk that does not advertise `name:` in its frontmatter. Two
1610
- // variants for single vs multiple candidates; same template family
1611
- // as the alert tooltips above.
1612
- hintSummarySingle: "Add `name: {{name}}` to the frontmatter of {{candidate}} so this reference resolves.",
1613
- hintSummaryMany: "Add `name: {{name}}` to the frontmatter of one of these files so this reference resolves: {{candidates}}."
1821
+ // plugins/core/analyzers/job-file-orphan/text.ts
1822
+ var JOB_FILE_ORPHAN_TEXTS = {
1823
+ /**
1824
+ * `<path>.md` lives under `.skill-map/jobs/` but no `state_jobs.filePath`
1825
+ * row references it. Run `sm job prune --orphan-files` to remove.
1826
+ */
1827
+ message: "Orphan job file: {{filePath}} is not referenced by any state_jobs row. Run `sm job prune --orphan-files` to remove it."
1614
1828
  };
1615
1829
 
1616
- // plugins/core/analyzers/broken-ref/index.ts
1617
- var ID10 = "broken-ref";
1618
- var brokenRefAnalyzer = {
1619
- id: ID10,
1620
- pluginId: "core",
1830
+ // plugins/core/analyzers/job-file-orphan/index.ts
1831
+ var ID12 = "job-file-orphan";
1832
+ var jobFileOrphanAnalyzer = {
1833
+ id: ID12,
1834
+ pluginId: CORE_PLUGIN_ID,
1621
1835
  kind: "analyzer",
1622
1836
  version: "1.0.0",
1623
- description: "Detects and flags arrows pointing at a node not part of the current scan.",
1624
- mode: "deterministic",
1625
- ui: {
1626
- // Corner badge on the graph card; count omitted when there is a
1627
- // single broken ref (avoids a noisy "icon + 1" chip).
1628
- alert: {
1629
- slot: "graph.node.alert",
1630
- icon: "fa-solid fa-circle-xmark",
1631
- emitWhenEmpty: false
1632
- },
1633
- // Footer chip on the card. `_counter` shape, `value` always shows,
1634
- // so the operator sees "how many" at a glance. Renders OUTLINED
1635
- // (`fa-regular`) so the corner alert (filled, attention-grabbing)
1636
- // and the footer chip (quieter, paired with a number) read as two
1637
- // beats of the same signal rather than two identical glyphs.
1638
- chip: {
1639
- slot: "card.footer.right",
1640
- icon: "fa-regular fa-circle-xmark",
1641
- emitWhenEmpty: false,
1642
- priority: 40
1643
- }
1644
- },
1645
- // The resolver, the reference-paths escape hatch, the per-source
1646
- // aggregation, and the dual-slot emit (with single/plural tooltip and
1647
- // optional count) all live in one flow because they share the per-link
1648
- // loop. Splitting them would re-walk `ctx.links` three times.
1649
- // eslint-disable-next-line complexity
1650
- evaluate(ctx) {
1651
- const byPath3 = new Set(ctx.nodes.map((n) => n.path));
1652
- const byNormalizedName = indexByNormalizedName(ctx.nodes);
1653
- const byBasenameWithoutName = indexByBasenameWithoutName(ctx.nodes);
1654
- const refIndex = ctx.referenceablePaths && ctx.referenceablePaths.size > 0 && ctx.cwd ? { paths: ctx.referenceablePaths, cwd: ctx.cwd } : null;
1655
- const issues = [];
1656
- const perNode = /* @__PURE__ */ new Map();
1657
- for (const link of ctx.links) {
1658
- if (isResolved(link, byPath3, byNormalizedName)) continue;
1659
- if (refIndex && resolvesViaReferencePaths(link, refIndex)) continue;
1660
- const candidates = findHintCandidates(link, byBasenameWithoutName);
1661
- issues.push(buildIssue(link, candidates));
1662
- perNode.set(link.source, (perNode.get(link.source) ?? 0) + 1);
1663
- }
1664
- for (const [nodePath, count] of perNode) {
1665
- const tooltip = count === 1 ? BROKEN_REF_TEXTS.alertTooltipSingle : tx(BROKEN_REF_TEXTS.alertTooltipMany, { count });
1666
- const capped = Math.min(count, 99);
1667
- ctx.emitContribution(nodePath, "alert", {
1668
- icon: "fa-solid fa-circle-xmark",
1669
- severity: "danger",
1670
- tooltip
1671
- });
1672
- ctx.emitContribution(nodePath, "chip", {
1673
- value: capped,
1674
- severity: "danger",
1675
- tooltip
1676
- });
1677
- }
1678
- return issues;
1679
- }
1680
- };
1681
- function buildIssue(link, hintCandidates = []) {
1682
- const data = {
1683
- target: link.target,
1684
- kind: link.kind,
1685
- trigger: link.trigger?.normalizedTrigger ?? null
1686
- };
1687
- const issue = {
1688
- analyzerId: ID10,
1689
- severity: "warn",
1690
- nodeIds: [link.source],
1691
- message: tx(BROKEN_REF_TEXTS.message, {
1692
- kind: link.kind,
1693
- source: link.source,
1694
- target: link.target
1695
- }),
1696
- data
1697
- };
1698
- if (hintCandidates.length > 0) {
1699
- const suggestedName = (link.trigger?.normalizedTrigger ?? "").replace(/^[/@]/, "").trim();
1700
- const candidatePaths = hintCandidates.map((n) => n.path);
1701
- data["hint"] = {
1702
- kind: "missing-frontmatter-name",
1703
- suggestedName,
1704
- candidates: candidatePaths
1705
- };
1706
- issue.fix = {
1707
- summary: candidatePaths.length === 1 ? tx(BROKEN_REF_TEXTS.hintSummarySingle, {
1708
- name: suggestedName,
1709
- candidate: candidatePaths[0]
1710
- }) : tx(BROKEN_REF_TEXTS.hintSummaryMany, {
1711
- name: suggestedName,
1712
- candidates: candidatePaths.join(", ")
1713
- }),
1714
- autofixable: false
1715
- };
1716
- }
1717
- return issue;
1718
- }
1719
- function resolvesViaReferencePaths(link, refIndex) {
1720
- if (!isPathStyleLink(link)) return false;
1721
- return refIndex.paths.has(resolve(refIndex.cwd, link.target));
1722
- }
1723
- function indexByNormalizedName(nodes) {
1724
- const out = /* @__PURE__ */ new Map();
1725
- for (const node of nodes) {
1726
- const raw = node.frontmatter?.["name"];
1727
- const name = typeof raw === "string" ? raw : "";
1728
- if (!name) continue;
1729
- const key = normalizeTrigger(name);
1730
- const bucket = out.get(key) ?? [];
1731
- bucket.push(node);
1732
- out.set(key, bucket);
1733
- }
1734
- return out;
1735
- }
1736
- function basenameWithoutExt(path) {
1737
- const base = pathPosix3.basename(path);
1738
- const ext = pathPosix3.extname(base);
1739
- return ext ? base.slice(0, -ext.length) : base;
1740
- }
1741
- function indexByBasenameWithoutName(nodes) {
1742
- const out = /* @__PURE__ */ new Map();
1743
- for (const node of nodes) {
1744
- const raw = node.frontmatter?.["name"];
1745
- const name = typeof raw === "string" ? raw : "";
1746
- if (name) continue;
1747
- const bare = basenameWithoutExt(node.path);
1748
- if (!bare) continue;
1749
- const key = normalizeTrigger(bare);
1750
- if (!key) continue;
1751
- const bucket = out.get(key) ?? [];
1752
- bucket.push(node);
1753
- out.set(key, bucket);
1754
- }
1755
- return out;
1756
- }
1757
- function findHintCandidates(link, idx) {
1758
- const normalized = link.trigger?.normalizedTrigger;
1759
- if (!normalized) return [];
1760
- const sigil = normalized.charAt(0);
1761
- if (sigil !== "/" && sigil !== "@") return [];
1762
- const withoutSigil = normalized.slice(1).trim();
1763
- if (!withoutSigil) return [];
1764
- return idx.get(withoutSigil) ?? [];
1765
- }
1766
- function isResolved(link, byPath3, byNormalizedName) {
1767
- const normalized = link.trigger?.normalizedTrigger;
1768
- if (normalized) {
1769
- const withoutSigil = normalized.replace(/^[/@]/, "").trim();
1770
- if (byNormalizedName.has(withoutSigil)) return true;
1771
- }
1772
- if (byPath3.has(link.target)) return true;
1773
- return false;
1774
- }
1775
- function isPathStyleLink(link) {
1776
- const sigil = link.trigger?.normalizedTrigger?.charAt(0);
1777
- if (sigil === "/" || sigil === "@") return false;
1778
- return true;
1779
- }
1780
-
1781
- // plugins/core/analyzers/contribution-orphan/index.ts
1782
- var ID11 = "contribution-orphan";
1783
- var contributionOrphanAnalyzer = {
1784
- id: ID11,
1785
- pluginId: "core",
1786
- kind: "analyzer",
1787
- version: "0.0.0",
1788
- description: "Detects and warns about plugin data referencing nodes renamed or deleted in the latest scan.",
1789
- mode: "deterministic",
1790
- evaluate(_ctx) {
1791
- return [];
1792
- }
1793
- };
1794
-
1795
- // plugins/core/analyzers/job-orphan-file/text.ts
1796
- var JOB_ORPHAN_FILE_TEXTS = {
1797
- /**
1798
- * `<path>.md` lives under `.skill-map/jobs/` but no `state_jobs.filePath`
1799
- * row references it. Run `sm job prune --orphan-files` to remove.
1800
- */
1801
- message: "Orphan job file: {{filePath}} is not referenced by any state_jobs row. Run `sm job prune --orphan-files` to remove it."
1802
- };
1803
-
1804
- // plugins/core/analyzers/job-orphan-file/index.ts
1805
- var ID12 = "job-orphan-file";
1806
- var jobOrphanFileAnalyzer = {
1807
- id: ID12,
1808
- pluginId: "core",
1809
- kind: "analyzer",
1810
- version: "1.0.0",
1811
- description: "Detects and flags leftover job result files (no live job references them). Cleanup via `sm job prune --orphan-files`.",
1837
+ description: "Flags leftover job result files (no live job references them). Clean up via `sm job prune --orphan-files`.",
1812
1838
  mode: "deterministic",
1813
1839
  evaluate(ctx) {
1814
1840
  const orphans = ctx.orphanJobFiles;
@@ -1819,7 +1845,7 @@ var jobOrphanFileAnalyzer = {
1819
1845
  analyzerId: ID12,
1820
1846
  severity: "warn",
1821
1847
  nodeIds: [filePath],
1822
- message: tx(JOB_ORPHAN_FILE_TEXTS.message, { filePath }),
1848
+ message: tx(JOB_FILE_ORPHAN_TEXTS.message, { filePath }),
1823
1849
  data: { filePath }
1824
1850
  });
1825
1851
  }
@@ -1840,7 +1866,7 @@ var linkConflictAnalyzer = {
1840
1866
  pluginId: "core",
1841
1867
  kind: "analyzer",
1842
1868
  version: "1.0.0",
1843
- description: 'Detects and flags conflicting arrow meanings between extractors (e.g. "references" vs "invokes").',
1869
+ description: "Flags conflicting arrow meanings between extractors (e.g. `references` vs `invokes`).",
1844
1870
  mode: "deterministic",
1845
1871
  // Bucket links by (source, target), then per-bucket detect distinct
1846
1872
  // kinds. The branching is intrinsic to the per-bucket conflict
@@ -1950,11 +1976,11 @@ function resolveLinkTargetToPath(link, nameIndex) {
1950
1976
  return resolved ?? raw;
1951
1977
  }
1952
1978
 
1953
- // plugins/core/analyzers/link-counts/index.ts
1954
- var ID14 = "link-counts";
1955
- var linkCountsAnalyzer = {
1979
+ // plugins/core/analyzers/link-counter/index.ts
1980
+ var ID14 = "link-counter";
1981
+ var linkCounterAnalyzer = {
1956
1982
  id: ID14,
1957
- pluginId: "core",
1983
+ pluginId: CORE_PLUGIN_ID,
1958
1984
  kind: "analyzer",
1959
1985
  version: "1.0.0",
1960
1986
  description: "Counts incoming and outgoing links per node.",
@@ -2017,163 +2043,73 @@ function formatBreakdown(byKind, direction) {
2017
2043
  return [direction, ...lines].join("\n");
2018
2044
  }
2019
2045
 
2020
- // plugins/core/analyzers/redundant-target-reference/text.ts
2021
- var REDUNDANT_TARGET_REFERENCE_TEXTS = {
2046
+ // plugins/core/analyzers/link-self-loop/text.ts
2047
+ var LINK_SELF_LOOP_TEXTS = {
2022
2048
  /**
2023
- * Multi-form / multi-occurrence reference message. Lists each
2024
- * occurrence (trigger + line) so the operator sees the full
2025
- * authorial surface without having to grep the body.
2049
+ * Per-edge warn: a node body references itself via the slash /
2050
+ * at-directive / markdown-link surface (most commonly because the
2051
+ * file's heading IS the invocation token, e.g. `# /deploy` inside
2052
+ * `commands/deploy.md`). The link is structurally valid but rarely
2053
+ * the operator's intent; UI consumers MAY hide it by default and
2054
+ * surface a count.
2026
2055
  */
2027
- message: "{{source}} references {{resolvedTarget}} via {{count}} occurrences: {{occurrences}}. Consider consolidating to a single form to reduce maintenance surface and avoid duplicate inlining at runtime.",
2028
- /** Inline separator between occurrences in the message. */
2029
- occurrenceSeparator: ", ",
2030
- /** Per-occurrence formatting (trigger + line). */
2031
- occurrence: "`{{trigger}}` ({{kind}}, line {{line}})",
2032
- /** Per-occurrence formatting when the extractor did not record a line. */
2033
- occurrenceUnknownLine: "`{{trigger}}` ({{kind}}, unknown line)"
2056
+ message: "{{source}} references itself via `{{trigger}}` ({{kind}}). Self-loops typically come from the file's own heading or label and are noise rather than intent. Either remove the in-body token or treat this finding as expected and acknowledged."
2034
2057
  };
2035
2058
 
2036
- // plugins/core/analyzers/redundant-target-reference/index.ts
2037
- var ID15 = "redundant-target-reference";
2038
- var redundantTargetReferenceAnalyzer = {
2059
+ // plugins/core/analyzers/link-self-loop/index.ts
2060
+ var ID15 = "link-self-loop";
2061
+ var linkSelfLoopAnalyzer = {
2039
2062
  id: ID15,
2040
- pluginId: "core",
2063
+ pluginId: CORE_PLUGIN_ID,
2041
2064
  kind: "analyzer",
2042
2065
  version: "1.0.0",
2043
- description: "Flags when one node references the same resolved target via two or more syntactic surfaces (cross-extractor multi-form OR cross-kind multi-edge). Emits a warn on the source listing every occurrence (kind + trigger + line). Helps consolidate authorial redundancy and avoid duplicate runtime inlining.",
2066
+ description: "Flags links whose source is also their own resolved target (e.g. a body heading like `# /deploy` inside the file that defines `/deploy`).",
2044
2067
  mode: "deterministic",
2045
2068
  evaluate(ctx) {
2046
2069
  if (ctx.links.length === 0) return [];
2047
- const byPath3 = /* @__PURE__ */ new Map();
2048
- for (const node of ctx.nodes) byPath3.set(node.path, node);
2049
- const byName = buildNameIndex2(ctx.nodes);
2050
- const groups = /* @__PURE__ */ new Map();
2051
- for (const link of ctx.links) {
2052
- const resolved = resolveTargetPath(link, byPath3, byName);
2053
- if (!resolved) continue;
2054
- const key = `${link.source}\0${resolved}`;
2055
- const bucket = groups.get(key);
2056
- if (bucket) bucket.push(link);
2057
- else groups.set(key, [link]);
2058
- }
2059
2070
  const issues = [];
2060
- for (const [key, links] of groups) {
2061
- const totalOccurrences = links.reduce((acc, l) => acc + (l.occurrences?.length ?? 1), 0);
2062
- if (totalOccurrences < 2) continue;
2063
- const [source, resolvedTarget] = key.split("\0");
2064
- const flat = flattenOccurrences(links);
2071
+ for (const link of ctx.links) {
2072
+ if (!isSelfLoop(link)) continue;
2065
2073
  issues.push({
2066
2074
  analyzerId: ID15,
2067
2075
  severity: "warn",
2068
- nodeIds: [source],
2069
- message: tx(REDUNDANT_TARGET_REFERENCE_TEXTS.message, {
2070
- source,
2071
- resolvedTarget,
2072
- count: flat.length,
2073
- occurrences: flat.map(formatOccurrence).join(REDUNDANT_TARGET_REFERENCE_TEXTS.occurrenceSeparator)
2076
+ nodeIds: [link.source],
2077
+ message: tx(LINK_SELF_LOOP_TEXTS.message, {
2078
+ source: link.source,
2079
+ trigger: link.trigger?.originalTrigger ?? link.target,
2080
+ kind: link.kind
2074
2081
  }),
2075
2082
  data: {
2076
- target: resolvedTarget,
2077
- resolvedTarget,
2078
- occurrences: flat.map((o) => ({
2079
- kind: o.kind,
2080
- trigger: o.originalTrigger,
2081
- line: o.line ?? null,
2082
- extractor: o.extractor
2083
- }))
2083
+ target: link.target,
2084
+ resolvedTarget: link.resolvedTarget ?? link.target,
2085
+ kind: link.kind,
2086
+ // Mark explicitly so UI / downstream consumers can read this
2087
+ // single field instead of re-computing the `source === target`
2088
+ // predicate themselves.
2089
+ selfLoop: true
2084
2090
  }
2085
2091
  });
2086
2092
  }
2087
2093
  return issues;
2088
2094
  }
2089
2095
  };
2090
- function flattenOccurrences(links) {
2096
+ function isSelfLoop(link) {
2097
+ if (link.source === link.target) return true;
2098
+ if (link.resolvedTarget && link.source === link.resolvedTarget) return true;
2099
+ return false;
2100
+ }
2101
+
2102
+ // kernel/orchestrator/node-identifiers.ts
2103
+ import { posix as pathPosix3 } from "path";
2104
+ function deriveNodeIdentifiers(node, kindDescriptor) {
2105
+ const sources = kindDescriptor?.identifiers;
2106
+ if (!sources || sources.length === 0) return [];
2091
2107
  const out = [];
2092
- for (const link of links) {
2093
- if (link.occurrences && link.occurrences.length > 0) {
2094
- for (const occ of link.occurrences) {
2095
- out.push({
2096
- kind: link.kind,
2097
- originalTrigger: occ.originalTrigger,
2098
- extractor: occ.extractor,
2099
- line: occ.location?.line ?? null
2100
- });
2101
- }
2102
- continue;
2103
- }
2104
- const trigger = link.trigger?.originalTrigger ?? link.target;
2105
- out.push({
2106
- kind: link.kind,
2107
- originalTrigger: trigger,
2108
- extractor: link.sources[0] ?? "unknown",
2109
- line: link.location?.line ?? null
2110
- });
2111
- }
2112
- out.sort((a, b) => {
2113
- const la = a.line ?? Number.MAX_SAFE_INTEGER;
2114
- const lb = b.line ?? Number.MAX_SAFE_INTEGER;
2115
- if (la !== lb) return la - lb;
2116
- return a.originalTrigger.localeCompare(b.originalTrigger);
2117
- });
2118
- return out;
2119
- }
2120
- function formatOccurrence(occ) {
2121
- if (occ.line === null) {
2122
- return tx(REDUNDANT_TARGET_REFERENCE_TEXTS.occurrenceUnknownLine, { trigger: occ.originalTrigger, kind: occ.kind });
2123
- }
2124
- return tx(REDUNDANT_TARGET_REFERENCE_TEXTS.occurrence, { trigger: occ.originalTrigger, kind: occ.kind, line: occ.line });
2125
- }
2126
- function buildNameIndex2(nodes) {
2127
- const out = /* @__PURE__ */ new Map();
2128
- for (const node of nodes) {
2129
- for (const candidate of collectIdentifiers(node)) {
2130
- const normalised = normalizeTrigger(candidate);
2131
- if (!normalised) continue;
2132
- const bucket = out.get(normalised);
2133
- if (bucket) bucket.push(node.path);
2134
- else out.set(normalised, [node.path]);
2135
- }
2136
- }
2137
- return out;
2138
- }
2139
- function collectIdentifiers(node) {
2140
- const out = [];
2141
- const fmName = node.frontmatter?.["name"];
2142
- if (typeof fmName === "string" && fmName.length > 0) out.push(fmName);
2143
- const segs = node.path.split("/");
2144
- const last = segs[segs.length - 1] ?? "";
2145
- if (last) {
2146
- const stem = last.replace(/\.[^.]+$/, "");
2147
- if (stem) out.push(stem);
2148
- }
2149
- if (segs.length >= 2) {
2150
- const dirBase = segs[segs.length - 2];
2151
- if (dirBase) out.push(dirBase);
2152
- }
2153
- return out;
2154
- }
2155
- function resolveTargetPath(link, byPath3, byName) {
2156
- if (byPath3.has(link.target)) return link.target;
2157
- const trigger = link.trigger?.normalizedTrigger;
2158
- if (!trigger) return null;
2159
- const stripped = trigger.replace(/^[/@]/, "").trim();
2160
- if (!stripped) return null;
2161
- const candidates = byName.get(stripped);
2162
- if (!candidates || candidates.length === 0) return null;
2163
- return candidates[0] ?? null;
2164
- }
2165
-
2166
- // kernel/orchestrator/node-identifiers.ts
2167
- import { posix as pathPosix4 } from "path";
2168
- function deriveNodeIdentifiers(node, kindDescriptor) {
2169
- const sources = kindDescriptor?.identifiers;
2170
- if (!sources || sources.length === 0) return [];
2171
- const out = [];
2172
- for (const source of sources) {
2173
- const raw = readIdentifier(source, node);
2174
- if (!raw) continue;
2175
- const normalised = normalizeTrigger(raw);
2176
- if (normalised) out.push(normalised);
2108
+ for (const source of sources) {
2109
+ const raw = readIdentifier(source, node);
2110
+ if (!raw) continue;
2111
+ const normalised = normalizeTrigger(raw);
2112
+ if (normalised) out.push(normalised);
2177
2113
  }
2178
2114
  return out;
2179
2115
  }
@@ -2188,16 +2124,16 @@ function readFrontmatterName(node) {
2188
2124
  return raw.length > 0 ? raw : null;
2189
2125
  }
2190
2126
  function readFilenameBasename(node) {
2191
- const base = pathPosix4.basename(node.path);
2127
+ const base = pathPosix3.basename(node.path);
2192
2128
  if (!base) return null;
2193
- const ext = pathPosix4.extname(base);
2129
+ const ext = pathPosix3.extname(base);
2194
2130
  const stem = ext ? base.slice(0, -ext.length) : base;
2195
2131
  return stem.length > 0 ? stem : null;
2196
2132
  }
2197
2133
  function readDirname(node) {
2198
- const dir = pathPosix4.dirname(node.path);
2134
+ const dir = pathPosix3.dirname(node.path);
2199
2135
  if (!dir || dir === "." || dir === "/") return null;
2200
- const base = pathPosix4.basename(dir);
2136
+ const base = pathPosix3.basename(dir);
2201
2137
  return base.length > 0 ? base : null;
2202
2138
  }
2203
2139
 
@@ -2266,8 +2202,8 @@ function kindKey(node) {
2266
2202
  return `${node.provider}/${node.kind}`;
2267
2203
  }
2268
2204
 
2269
- // plugins/core/analyzers/reserved-name/text.ts
2270
- var RESERVED_NAME_TEXTS = {
2205
+ // plugins/core/analyzers/name-reserved/text.ts
2206
+ var NAME_RESERVED_TEXTS = {
2271
2207
  /**
2272
2208
  * Target-side message: emitted on the user file that collides with
2273
2209
  * a runtime built-in. Same wording skill-map shipped before the
@@ -2284,14 +2220,14 @@ var RESERVED_NAME_TEXTS = {
2284
2220
  linkMessage: "Link `{{kind}} {{target}}` resolves to a name reserved by the {{provider}} runtime ({{reservedKind}} `{{reservedPath}}`). The runtime shadows the user file, so this edge is downgraded to confidence {{confidence}} instead of 1.0. Rename the target file or its `frontmatter.name` to a non-reserved value."
2285
2221
  };
2286
2222
 
2287
- // plugins/core/analyzers/reserved-name/index.ts
2288
- var ID16 = "reserved-name";
2289
- var reservedNameAnalyzer = {
2223
+ // plugins/core/analyzers/name-reserved/index.ts
2224
+ var ID16 = "name-reserved";
2225
+ var nameReservedAnalyzer = {
2290
2226
  id: ID16,
2291
- pluginId: "core",
2227
+ pluginId: CORE_PLUGIN_ID,
2292
2228
  kind: "analyzer",
2293
2229
  version: "1.0.0",
2294
- description: "Flags reserved-name collisions on two surfaces. Target side: a user file whose name collides with a Provider runtime's built-in invocable (the runtime shadows the file silently). Source side: a link that resolves to a reserved name, which the post-walk lift transform downgrades to the sentinel `RESERVED_TARGET_CONFIDENCE` (0.1). The two findings share the analyzer id so consumers can group by root cause; the source-side issue carries `data.target` matching the link so UIs can correlate per-row.",
2230
+ description: "Flags two kinds of reserved-name collision: a file whose name shadows a built-in command of the active runtime, and a link that resolves to one of those reserved names.",
2295
2231
  mode: "deterministic",
2296
2232
  // eslint-disable-next-line complexity
2297
2233
  evaluate(ctx) {
@@ -2307,7 +2243,7 @@ var reservedNameAnalyzer = {
2307
2243
  analyzerId: ID16,
2308
2244
  severity: "warn",
2309
2245
  nodeIds: [node.path],
2310
- message: tx(RESERVED_NAME_TEXTS.message, {
2246
+ message: tx(NAME_RESERVED_TEXTS.message, {
2311
2247
  path: node.path,
2312
2248
  provider: node.provider,
2313
2249
  kind: node.kind
@@ -2323,7 +2259,7 @@ var reservedNameAnalyzer = {
2323
2259
  analyzerId: ID16,
2324
2260
  severity: "warn",
2325
2261
  nodeIds: [link.source],
2326
- message: tx(RESERVED_NAME_TEXTS.linkMessage, {
2262
+ message: tx(NAME_RESERVED_TEXTS.linkMessage, {
2327
2263
  kind: link.kind,
2328
2264
  target: link.target,
2329
2265
  provider: reservedNode.provider,
@@ -2380,206 +2316,16 @@ function normaliseId(raw) {
2380
2316
  return raw.normalize("NFD").replace(new RegExp("\\p{Mn}+", "gu"), "").toLowerCase().replace(/[-_\s]+/g, " ").replace(/ +/g, " ").trim();
2381
2317
  }
2382
2318
 
2383
- // plugins/core/analyzers/self-loop/text.ts
2384
- var SELF_LOOP_TEXTS = {
2385
- /**
2386
- * Per-edge warn: a node body references itself via the slash /
2387
- * at-directive / markdown-link surface (most commonly because the
2388
- * file's heading IS the invocation token, e.g. `# /deploy` inside
2389
- * `commands/deploy.md`). The link is structurally valid but rarely
2390
- * the operator's intent; UI consumers MAY hide it by default and
2391
- * surface a count.
2392
- */
2393
- 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."
2394
- };
2395
-
2396
- // plugins/core/analyzers/self-loop/index.ts
2397
- var ID17 = "self-loop";
2398
- var selfLoopAnalyzer = {
2399
- id: ID17,
2400
- pluginId: "core",
2401
- kind: "analyzer",
2402
- version: "1.0.0",
2403
- 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.",
2404
- mode: "deterministic",
2405
- evaluate(ctx) {
2406
- if (ctx.links.length === 0) return [];
2407
- const issues = [];
2408
- for (const link of ctx.links) {
2409
- if (!isSelfLoop(link)) continue;
2410
- issues.push({
2411
- analyzerId: ID17,
2412
- severity: "warn",
2413
- nodeIds: [link.source],
2414
- message: tx(SELF_LOOP_TEXTS.message, {
2415
- source: link.source,
2416
- trigger: link.trigger?.originalTrigger ?? link.target,
2417
- kind: link.kind
2418
- }),
2419
- data: {
2420
- target: link.target,
2421
- resolvedTarget: link.resolvedTarget ?? link.target,
2422
- kind: link.kind,
2423
- // Mark explicitly so UI / downstream consumers can read this
2424
- // single field instead of re-computing the `source === target`
2425
- // predicate themselves.
2426
- selfLoop: true
2427
- }
2428
- });
2429
- }
2430
- return issues;
2431
- }
2432
- };
2433
- function isSelfLoop(link) {
2434
- if (link.source === link.target) return true;
2435
- if (link.resolvedTarget && link.source === link.resolvedTarget) return true;
2436
- return false;
2437
- }
2438
-
2439
- // plugins/core/analyzers/signal-collision/text.ts
2440
- var SIGNAL_COLLISION_TEXTS = {
2441
- /**
2442
- * Per-Signal warn issue: two extractors detected something at
2443
- * overlapping byte ranges within the same node and the resolver
2444
- * dropped the loser. Surfaces WHO lost, WHO won, and the tiebreak
2445
- * reason so the operator can understand why a candidate edge did NOT
2446
- * become a Link.
2447
- *
2448
- * Placeholders are deliberately verbose because this is one of the
2449
- * few diagnostic surfaces where the operator may need to disambiguate
2450
- * a confusing graph (e.g. a `[link](path)` followed by `@path` inside
2451
- * the same paragraph, the markdown-link wins and the at-directive
2452
- * silently disappears without this warning).
2453
- */
2454
- 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.",
2455
- /**
2456
- * Same warn but for the rare case the resolver rejected a Signal
2457
- * because the operator disabled its extractor via
2458
- * `plugins.<id>.extensions.<extId>.enabled`. Phase 4+ stub: today the
2459
- * filter is not wired so this template is unreachable from the
2460
- * resolver; documented now so the analyzer stays forward-compatible
2461
- * with the upcoming filter pass.
2462
- */
2463
- 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.",
2464
- /**
2465
- * Same warn but for the future confidence floor case. Phase 4+ stub:
2466
- * today the resolver materialises every winning candidate regardless
2467
- * of confidence, so this template is unreachable; documented for
2468
- * forward compatibility.
2469
- */
2470
- messageBelowFloor: "Detection `{{loserRaw}}` (offset {{loserRange}}, confidence {{confidence}}) fell below the configured threshold {{threshold}} and was dropped."
2471
- };
2472
-
2473
- // plugins/core/analyzers/signal-collision/index.ts
2474
- var ID18 = "signal-collision";
2475
- var signalCollisionAnalyzer = {
2476
- id: ID18,
2477
- pluginId: "core",
2478
- kind: "analyzer",
2479
- version: "1.0.0",
2480
- description: "Surfaces Signal IR resolver rejections (range-overlap losers, disabled extractors, below-floor candidates) as warn issues attached to the Signal's source node.",
2481
- mode: "deterministic",
2482
- evaluate(ctx) {
2483
- const signals = ctx.signals;
2484
- if (!signals || signals.length === 0) return [];
2485
- const issues = [];
2486
- for (const signal of signals) {
2487
- const issue = makeIssue(signal);
2488
- if (issue) issues.push(issue);
2489
- }
2490
- return issues;
2491
- }
2492
- };
2493
- function makeIssue(signal) {
2494
- const resolution = signal.resolution;
2495
- if (!resolution || resolution.outcome !== "rejected") return null;
2496
- if (resolution.rejectedBy) {
2497
- const winner = resolution.rejectedBy;
2498
- const winnerCandidate = signal.candidates[resolution.winnerIndex ?? 0];
2499
- const loserRange = signal.range ? `${signal.range.start}-${signal.range.end}` : "unknown";
2500
- const winnerRange = `${winner.range.start}-${winner.range.end}`;
2501
- return {
2502
- analyzerId: ID18,
2503
- severity: "warn",
2504
- nodeIds: [signal.source],
2505
- message: tx(SIGNAL_COLLISION_TEXTS.message, {
2506
- loserExtractor: winnerCandidate.extractorId,
2507
- loserRaw: signal.raw,
2508
- loserRange,
2509
- winnerExtractor: winner.extractorId,
2510
- winnerRange,
2511
- reason: winner.reason
2512
- }),
2513
- data: {
2514
- loser: {
2515
- extractorId: winnerCandidate.extractorId,
2516
- raw: signal.raw,
2517
- range: signal.range ?? null,
2518
- candidate: {
2519
- kind: winnerCandidate.kind,
2520
- target: winnerCandidate.target,
2521
- confidence: winnerCandidate.confidence
2522
- }
2523
- },
2524
- winner: {
2525
- extractorId: winner.extractorId,
2526
- range: winner.range
2527
- },
2528
- reason: winner.reason
2529
- }
2530
- };
2531
- }
2532
- if (resolution.extractorDisabled) {
2533
- const loserRange = signal.range ? `${signal.range.start}-${signal.range.end}` : "unknown";
2534
- return {
2535
- analyzerId: ID18,
2536
- severity: "warn",
2537
- nodeIds: [signal.source],
2538
- message: tx(SIGNAL_COLLISION_TEXTS.messageExtractorDisabled, {
2539
- extractorId: resolution.extractorDisabled.extractorId,
2540
- loserRaw: signal.raw,
2541
- loserRange
2542
- }),
2543
- data: {
2544
- extractorDisabled: resolution.extractorDisabled,
2545
- raw: signal.raw,
2546
- range: signal.range ?? null
2547
- }
2548
- };
2549
- }
2550
- if (resolution.belowFloor) {
2551
- const loserRange = signal.range ? `${signal.range.start}-${signal.range.end}` : "unknown";
2552
- const topCandidate = signal.candidates[0];
2553
- return {
2554
- analyzerId: ID18,
2555
- severity: "warn",
2556
- nodeIds: [signal.source],
2557
- message: tx(SIGNAL_COLLISION_TEXTS.messageBelowFloor, {
2558
- loserRaw: signal.raw,
2559
- loserRange,
2560
- confidence: topCandidate.confidence,
2561
- threshold: resolution.belowFloor.threshold
2562
- }),
2563
- data: {
2564
- belowFloor: resolution.belowFloor,
2565
- raw: signal.raw,
2566
- range: signal.range ?? null
2567
- }
2568
- };
2569
- }
2570
- return null;
2571
- }
2572
-
2573
- // plugins/core/analyzers/stability/index.ts
2574
- var ID19 = "stability";
2319
+ // plugins/core/analyzers/node-stability/index.ts
2320
+ var ID17 = "node-stability";
2575
2321
  var EXPERIMENTAL_TOOLTIP = "Experimental: API may change";
2576
2322
  var DEPRECATED_TOOLTIP = "Deprecated: avoid in new code";
2577
- var stabilityAnalyzer = {
2578
- id: ID19,
2579
- pluginId: "core",
2323
+ var nodeStabilityAnalyzer = {
2324
+ id: ID17,
2325
+ pluginId: CORE_PLUGIN_ID,
2580
2326
  kind: "analyzer",
2581
2327
  version: "1.0.0",
2582
- description: "Reports node lifecycle stage (`experimental`, `deprecated`) on the card.",
2328
+ description: "Reports a node's stability stage (`experimental`, `deprecated`) on the card.",
2583
2329
  mode: "deterministic",
2584
2330
  ui: {
2585
2331
  experimental: {
@@ -2607,7 +2353,7 @@ var stabilityAnalyzer = {
2607
2353
  tooltip: EXPERIMENTAL_TOOLTIP
2608
2354
  });
2609
2355
  issues.push({
2610
- analyzerId: ID19,
2356
+ analyzerId: ID17,
2611
2357
  severity: "info",
2612
2358
  nodeIds: [node.path],
2613
2359
  message: `Node '${node.path}' is marked experimental: API may change.`,
@@ -2620,7 +2366,7 @@ var stabilityAnalyzer = {
2620
2366
  severity: "warn"
2621
2367
  });
2622
2368
  issues.push({
2623
- analyzerId: ID19,
2369
+ analyzerId: ID17,
2624
2370
  severity: "warn",
2625
2371
  nodeIds: [node.path],
2626
2372
  message: `Node '${node.path}' is marked deprecated: avoid in new code.`,
@@ -2647,20 +2393,20 @@ function isStability(value) {
2647
2393
  return value === "experimental" || value === "deprecated" || value === "stable";
2648
2394
  }
2649
2395
 
2650
- // plugins/core/analyzers/superseded/text.ts
2651
- var SUPERSEDED_TEXTS = {
2396
+ // plugins/core/analyzers/node-superseded/text.ts
2397
+ var NODE_SUPERSEDED_TEXTS = {
2652
2398
  /** `<path> is superseded by <supersededBy>` */
2653
2399
  message: "{{path}} is superseded by {{supersededBy}}"
2654
2400
  };
2655
2401
 
2656
- // plugins/core/analyzers/superseded/index.ts
2657
- var ID20 = "superseded";
2658
- var supersededAnalyzer = {
2659
- id: ID20,
2660
- pluginId: "core",
2402
+ // plugins/core/analyzers/node-superseded/index.ts
2403
+ var ID18 = "node-superseded";
2404
+ var nodeSupersededAnalyzer = {
2405
+ id: ID18,
2406
+ pluginId: CORE_PLUGIN_ID,
2661
2407
  kind: "analyzer",
2662
2408
  version: "1.0.0",
2663
- description: "Detects and marks nodes replaced by a newer one via `supersededBy`.",
2409
+ description: "Marks nodes replaced by a newer one via `supersededBy`.",
2664
2410
  mode: "deterministic",
2665
2411
  evaluate(ctx) {
2666
2412
  const issues = [];
@@ -2668,10 +2414,10 @@ var supersededAnalyzer = {
2668
2414
  const supersededBy = pickSupersededBy(node);
2669
2415
  if (supersededBy === null) continue;
2670
2416
  issues.push({
2671
- analyzerId: ID20,
2417
+ analyzerId: ID18,
2672
2418
  severity: "info",
2673
2419
  nodeIds: [node.path],
2674
- message: tx(SUPERSEDED_TEXTS.message, {
2420
+ message: tx(NODE_SUPERSEDED_TEXTS.message, {
2675
2421
  path: node.path,
2676
2422
  supersededBy
2677
2423
  }),
@@ -2691,351 +2437,334 @@ function pickSupersededBy(node) {
2691
2437
  return value;
2692
2438
  }
2693
2439
 
2694
- // plugins/core/analyzers/trigger-collision/text.ts
2695
- var TRIGGER_COLLISION_TEXTS = {
2696
- /**
2697
- * Top-level message when `analyzeTriggerBucket` accumulated exactly one
2698
- * cause part. Used for the advertiser-ambiguous-only, invocation-
2699
- * ambiguous-only, and cross-kind-only branches.
2700
- */
2701
- messageOnePart: 'Trigger "{{normalized}}" has {{part}}.',
2702
- /**
2703
- * Top-level message when `analyzeTriggerBucket` accumulated two cause
2704
- * parts (advertiser-ambiguous AND invocation-ambiguous fire together).
2705
- * The joiner lives inside the template so future locales can adapt it
2706
- * (e.g. `'; y '` in Spanish) without touching the rule code.
2707
- */
2708
- messageTwoParts: 'Trigger "{{normalized}}" has {{first}}; and {{second}}.',
2709
- /** `<n> nodes advertise it: <list>` part, fires on the advertiser-ambiguous branch. */
2710
- partAdvertisers: "{{count}} nodes advertise it: {{paths}}",
2711
- /** `<n> distinct invocation forms: <list>` part, fires on the invocation-ambiguous branch. */
2712
- partInvocations: "{{count}} distinct invocation forms: {{forms}}",
2713
- /** Singular cross-kind cause: `non-canonical invocation <form> against advertiser <path>`. */
2714
- partNonCanonicalSingular: "non-canonical invocation {{forms}} against advertiser {{advertiser}}",
2715
- /** Plural cross-kind cause: `non-canonical invocations <forms> against advertiser <path>`. */
2716
- partNonCanonicalPlural: "non-canonical invocations {{forms}} against advertiser {{advertiser}}"
2717
- };
2718
-
2719
- // plugins/core/analyzers/trigger-collision/index.ts
2720
- var ID21 = "trigger-collision";
2721
- var ADVERTISING_KINDS = /* @__PURE__ */ new Set([
2722
- "command",
2723
- "skill",
2724
- "agent"
2725
- ]);
2726
- var triggerCollisionAnalyzer = {
2727
- id: ID21,
2728
- pluginId: "core",
2729
- kind: "analyzer",
2730
- mode: "deterministic",
2731
- version: "1.0.0",
2732
- description: "Detects and flags two or more nodes claiming the same `/command` or `@agent` name.",
2733
- // Two claim-collection passes (advertisement + invocation) feeding
2734
- // the bucket map. Per-bucket analysis lives in `analyzeTriggerBucket`.
2735
- // eslint-disable-next-line complexity
2736
- evaluate(ctx) {
2737
- const buckets = /* @__PURE__ */ new Map();
2738
- const push = (key, claim) => {
2739
- const bucket = buckets.get(key) ?? [];
2740
- bucket.push(claim);
2741
- buckets.set(key, bucket);
2742
- };
2743
- for (const node of ctx.nodes) {
2744
- if (!ADVERTISING_KINDS.has(node.kind)) continue;
2745
- const raw = node.frontmatter?.["name"];
2746
- if (typeof raw !== "string" || raw.length === 0) continue;
2747
- const normalized = `/${normalizeTrigger(raw)}`;
2748
- if (normalized === "/") continue;
2749
- push(normalized, {
2750
- kind: "advertiser",
2751
- token: node.path,
2752
- nodeId: node.path,
2753
- canonicalForm: `/${raw}`
2754
- });
2755
- }
2756
- for (const link of ctx.links) {
2757
- const normalized = link.trigger?.normalizedTrigger;
2758
- if (!normalized) continue;
2759
- push(normalized, {
2760
- kind: "invocation",
2761
- token: link.target,
2762
- nodeId: link.source
2763
- });
2764
- }
2765
- const issues = [];
2766
- for (const [normalized, claims] of buckets) {
2767
- const issue = analyzeTriggerBucket(normalized, claims);
2768
- if (issue) issues.push(issue);
2769
- }
2770
- return issues;
2771
- }
2772
- };
2773
- function analyzeTriggerBucket(normalized, claims) {
2774
- const advertiserPaths = [
2775
- ...new Set(claims.filter((c) => c.kind === "advertiser").map((c) => c.token))
2776
- ].sort();
2777
- const invocationTargets = [
2778
- ...new Set(claims.filter((c) => c.kind === "invocation").map((c) => c.token))
2779
- ].sort();
2780
- const advertisers = claims.filter(
2781
- (c) => c.kind === "advertiser"
2782
- );
2783
- const advertiserAmbiguous = advertiserPaths.length >= 2;
2784
- const invocationAmbiguous = invocationTargets.length >= 2;
2785
- const canonicalForms = new Set(advertisers.map((a) => a.canonicalForm));
2786
- const nonCanonicalInvocations = invocationTargets.filter((t) => !canonicalForms.has(t));
2787
- const crossKindAmbiguous = advertiserPaths.length === 1 && nonCanonicalInvocations.length >= 1;
2788
- if (!advertiserAmbiguous && !invocationAmbiguous && !crossKindAmbiguous) {
2789
- return null;
2790
- }
2791
- const nodeIds = [...new Set(claims.map((c) => c.nodeId))].sort();
2792
- const parts = [];
2793
- if (advertiserAmbiguous) {
2794
- parts.push(
2795
- tx(TRIGGER_COLLISION_TEXTS.partAdvertisers, {
2796
- count: advertiserPaths.length,
2797
- paths: advertiserPaths.join(", ")
2798
- })
2799
- );
2800
- }
2801
- if (invocationAmbiguous) {
2802
- parts.push(
2803
- tx(TRIGGER_COLLISION_TEXTS.partInvocations, {
2804
- count: invocationTargets.length,
2805
- forms: invocationTargets.join(", ")
2806
- })
2807
- );
2808
- } else if (crossKindAmbiguous) {
2809
- const template = nonCanonicalInvocations.length > 1 ? TRIGGER_COLLISION_TEXTS.partNonCanonicalPlural : TRIGGER_COLLISION_TEXTS.partNonCanonicalSingular;
2810
- parts.push(
2811
- tx(template, {
2812
- forms: nonCanonicalInvocations.join(", "),
2813
- advertiser: advertiserPaths[0]
2814
- })
2815
- );
2816
- }
2817
- const message = parts.length === 2 ? tx(TRIGGER_COLLISION_TEXTS.messageTwoParts, {
2818
- normalized,
2819
- first: parts[0],
2820
- second: parts[1]
2821
- }) : tx(TRIGGER_COLLISION_TEXTS.messageOnePart, {
2822
- normalized,
2823
- part: parts[0]
2824
- });
2825
- return {
2826
- analyzerId: ID21,
2827
- severity: "error",
2828
- nodeIds,
2829
- message,
2830
- data: {
2831
- normalizedTrigger: normalized,
2832
- invocationTargets,
2833
- advertiserPaths
2834
- }
2835
- };
2836
- }
2837
-
2838
- // plugins/core/analyzers/unknown-field/index.ts
2839
- import { readFileSync } from "fs";
2840
- import { dirname, resolve as resolve3 } from "path";
2841
- import { createRequire } from "module";
2842
- import { Ajv2020 } from "ajv/dist/2020.js";
2843
-
2844
- // kernel/util/ajv-interop.ts
2845
- import addFormatsModule from "ajv-formats";
2846
- var addFormats = addFormatsModule.default ?? addFormatsModule;
2847
- function applyAjvFormats(ajv) {
2848
- addFormats(ajv);
2849
- }
2440
+ // plugins/core/analyzers/reference-broken/index.ts
2441
+ import { posix as pathPosix4, resolve as resolve3 } from "path";
2850
2442
 
2851
- // plugins/core/analyzers/unknown-field/text.ts
2852
- var UNKNOWN_FIELD_TEXTS = {
2853
- /** Key inside `annotations:` is not in the curated catalog. */
2854
- unknownAnnotationKey: "{{path}}: sidecar annotations contain unknown key '{{key}}' (not in annotations.schema.json catalog).",
2855
- /** Top-level key is neither reserved, nor a registered plugin namespace, nor a registered root key. */
2856
- unknownRootKey: "{{path}}: sidecar declares unknown top-level key '{{key}}'; not a reserved block, not a registered plugin namespace, not a registered root contribution.",
2857
- /** Value under a registered plugin namespace fails the contributed schema. */
2858
- pluginNamespaceInvalid: "{{path}}: sidecar block '{{pluginId}}.{{key}}' fails the schema contributed by plugin '{{pluginId}}': {{errors}}.",
2443
+ // plugins/core/analyzers/reference-broken/text.ts
2444
+ var REFERENCE_BROKEN_TEXTS = {
2445
+ /** `Broken <kind> reference from <source> <target>` */
2446
+ message: "Broken {{kind}} reference from {{source}} \u2192 {{target}}",
2859
2447
  // Tooltips for the per-node view-contribution badges. Singular vs
2860
2448
  // plural keeps the count grammar correct without a sub-template.
2861
- alertTooltipSingle: "This node has 1 unknown field in its sidecar. Open the inspector for details.",
2862
- alertTooltipMany: "This node has {{count}} unknown fields in its sidecar. Open the inspector for details."
2449
+ alertTooltipSingle: "This node has a broken reference. Open the inspector for details.",
2450
+ alertTooltipMany: "This node has {{count}} broken references. Open the inspector for details.",
2451
+ // Fix-summary copy when the broken trigger has a same-named file on
2452
+ // disk that does not advertise `name:` in its frontmatter. Two
2453
+ // variants for single vs multiple candidates; same template family
2454
+ // as the alert tooltips above.
2455
+ hintSummarySingle: "Add `name: {{name}}` to the frontmatter of {{candidate}} so this reference resolves.",
2456
+ hintSummaryMany: "Add `name: {{name}}` to the frontmatter of one of these files so this reference resolves: {{candidates}}."
2863
2457
  };
2864
2458
 
2865
- // plugins/core/analyzers/unknown-field/index.ts
2866
- var ID22 = "unknown-field";
2867
- var RESERVED_ROOT_BLOCKS = /* @__PURE__ */ new Set(["identity", "annotations", "settings", "audit"]);
2868
- var unknownFieldAnalyzer = {
2869
- id: ID22,
2870
- pluginId: "core",
2459
+ // plugins/core/analyzers/reference-broken/index.ts
2460
+ var ID19 = "reference-broken";
2461
+ var referenceBrokenAnalyzer = {
2462
+ id: ID19,
2463
+ pluginId: CORE_PLUGIN_ID,
2871
2464
  kind: "analyzer",
2872
2465
  version: "1.0.0",
2873
- description: "Detects and flags typos or unrecognized keys in sidecars (`.sm`).",
2466
+ description: "Flags arrows pointing at a node not part of the current scan.",
2874
2467
  mode: "deterministic",
2875
2468
  ui: {
2876
2469
  // Corner badge on the graph card; count omitted when there is a
2877
- // single unknown field (avoids a noisy "icon + 1" chip).
2470
+ // single broken ref (avoids a noisy "icon + 1" chip).
2878
2471
  alert: {
2879
2472
  slot: "graph.node.alert",
2880
- // Filled warning triangle on the corner, matches the broken-ref
2881
- // alert's "attention-grabbing solid" pattern; the footer chip
2882
- // below stays outlined for the quieter counter pairing.
2883
- icon: "fa-solid fa-triangle-exclamation",
2473
+ icon: "fa-solid fa-circle-xmark",
2884
2474
  emitWhenEmpty: false
2885
2475
  },
2886
- // Footer chip on the card, `_counter` shape but rendered icon-only
2887
- // (the analyzer emits `value: 0` so NodeCounter hides the number
2888
- // and only the glyph shows). PrimeIcons `pi-question-circle` so the
2889
- // visual weight matches `annotation-stale`'s `pi-clock` chip
2890
- // sitting next to it on the same footer row. `emitWhenEmpty: true`
2891
- // is required: with `value: 0` the slot treats the payload as
2892
- // empty, so the manifest has to opt in to keep the emission.
2476
+ // Footer chip on the card. `_counter` shape, `value` always shows,
2477
+ // so the operator sees "how many" at a glance. Renders OUTLINED
2478
+ // (`fa-regular`) so the corner alert (filled, attention-grabbing)
2479
+ // and the footer chip (quieter, paired with a number) read as two
2480
+ // beats of the same signal rather than two identical glyphs.
2893
2481
  chip: {
2894
2482
  slot: "card.footer.right",
2895
- icon: "pi-question-circle",
2896
- emitWhenEmpty: true,
2897
- priority: 30
2483
+ icon: "fa-regular fa-circle-xmark",
2484
+ emitWhenEmpty: false,
2485
+ priority: 40
2898
2486
  }
2899
2487
  },
2900
- // Analyzer body iterates every sidecar root and classifies each
2901
- // key against three buckets (catalog / plugin namespace / unknown
2902
- // root). The per-key branching IS the classification table; factoring
2903
- // it out would rebuild the discriminator elsewhere. Per
2904
- // `context/lint.md` category 7 (recursive type-discriminator walkers).
2488
+ // The resolver, the reference-paths escape hatch, the per-source
2489
+ // aggregation, and the dual-slot emit (with single/plural tooltip and
2490
+ // optional count) all live in one flow because they share the per-link
2491
+ // loop. Splitting them would re-walk `ctx.links` three times.
2905
2492
  // eslint-disable-next-line complexity
2906
2493
  evaluate(ctx) {
2907
- const sidecarRoots = ctx.sidecarRoots;
2908
- if (!sidecarRoots || sidecarRoots.size === 0) return [];
2909
- const knownAnnotationKeys = getKnownAnnotationKeys();
2910
- const contributions = ctx.annotationContributions ?? [];
2911
- const namespacedByPlugin = indexNamespacedContributions(contributions);
2912
- const rootKeys = indexRootContributions(contributions);
2913
- const knownPluginIds = collectPluginIds(contributions);
2494
+ const byPath3 = new Set(ctx.nodes.map((n) => n.path));
2495
+ const byNormalizedName = indexByNormalizedName(ctx.nodes);
2496
+ const byBasenameWithoutName = indexByBasenameWithoutName(ctx.nodes);
2497
+ const refIndex = ctx.referenceablePaths && ctx.referenceablePaths.size > 0 && ctx.cwd ? { paths: ctx.referenceablePaths, cwd: ctx.cwd } : null;
2914
2498
  const issues = [];
2915
2499
  const perNode = /* @__PURE__ */ new Map();
2916
- const bump2 = (nodePath) => {
2917
- perNode.set(nodePath, (perNode.get(nodePath) ?? 0) + 1);
2500
+ for (const link of ctx.links) {
2501
+ if (isResolved(link, byPath3, byNormalizedName)) continue;
2502
+ if (refIndex && resolvesViaReferencePaths(link, refIndex)) continue;
2503
+ const candidates = findHintCandidates(link, byBasenameWithoutName);
2504
+ issues.push(buildIssue(link, candidates));
2505
+ perNode.set(link.source, (perNode.get(link.source) ?? 0) + 1);
2506
+ }
2507
+ for (const [nodePath, count] of perNode) {
2508
+ const tooltip = count === 1 ? REFERENCE_BROKEN_TEXTS.alertTooltipSingle : tx(REFERENCE_BROKEN_TEXTS.alertTooltipMany, { count });
2509
+ const capped = Math.min(count, 99);
2510
+ ctx.emitContribution(nodePath, "alert", {
2511
+ icon: "fa-solid fa-circle-xmark",
2512
+ severity: "danger",
2513
+ tooltip
2514
+ });
2515
+ ctx.emitContribution(nodePath, "chip", {
2516
+ value: capped,
2517
+ severity: "danger",
2518
+ tooltip
2519
+ });
2520
+ }
2521
+ return issues;
2522
+ }
2523
+ };
2524
+ function buildIssue(link, hintCandidates = []) {
2525
+ const data = {
2526
+ target: link.target,
2527
+ kind: link.kind,
2528
+ trigger: link.trigger?.normalizedTrigger ?? null
2529
+ };
2530
+ const issue = {
2531
+ analyzerId: ID19,
2532
+ severity: "warn",
2533
+ nodeIds: [link.source],
2534
+ message: tx(REFERENCE_BROKEN_TEXTS.message, {
2535
+ kind: link.kind,
2536
+ source: link.source,
2537
+ target: link.target
2538
+ }),
2539
+ data
2540
+ };
2541
+ if (hintCandidates.length > 0) {
2542
+ const suggestedName = (link.trigger?.normalizedTrigger ?? "").replace(/^[/@]/, "").trim();
2543
+ const candidatePaths = hintCandidates.map((n) => n.path);
2544
+ data["hint"] = {
2545
+ kind: "missing-frontmatter-name",
2546
+ suggestedName,
2547
+ candidates: candidatePaths
2918
2548
  };
2919
- for (const node of ctx.nodes) {
2920
- const root = sidecarRoots.get(node.path);
2921
- if (!root) continue;
2922
- const annotations = root["annotations"];
2923
- if (annotations !== void 0 && annotations !== null && typeof annotations === "object" && !Array.isArray(annotations)) {
2924
- for (const key of Object.keys(annotations)) {
2925
- if (!knownAnnotationKeys.has(key)) {
2926
- issues.push({
2927
- analyzerId: ID22,
2928
- severity: "warn",
2929
- nodeIds: [node.path],
2930
- message: tx(UNKNOWN_FIELD_TEXTS.unknownAnnotationKey, {
2931
- path: node.path,
2932
- key
2933
- }),
2934
- data: { surface: "annotations", key }
2935
- });
2936
- bump2(node.path);
2937
- }
2938
- }
2939
- }
2940
- for (const key of Object.keys(root)) {
2941
- if (RESERVED_ROOT_BLOCKS.has(key)) continue;
2942
- if (rootKeys.has(key)) continue;
2943
- if (knownPluginIds.has(key)) {
2944
- const block = root[key];
2945
- if (block === null || typeof block !== "object" || Array.isArray(block)) continue;
2946
- const contribsForPlugin = namespacedByPlugin.get(key);
2947
- if (!contribsForPlugin) continue;
2948
- for (const [contribKey, validator] of contribsForPlugin) {
2949
- const value = block[contribKey];
2950
- if (value === void 0) continue;
2951
- if (validator(value)) continue;
2952
- const errors = (validator.errors ?? []).map((e) => `${e.instancePath || "(root)"} ${e.message ?? e.keyword}`).join("; ");
2953
- issues.push({
2954
- analyzerId: ID22,
2955
- severity: "warn",
2956
- nodeIds: [node.path],
2957
- message: tx(UNKNOWN_FIELD_TEXTS.pluginNamespaceInvalid, {
2958
- path: node.path,
2959
- pluginId: key,
2960
- key: contribKey,
2961
- errors
2962
- }),
2963
- data: { surface: "plugin-namespace", pluginId: key, key: contribKey }
2964
- });
2965
- bump2(node.path);
2966
- }
2967
- continue;
2968
- }
2969
- issues.push({
2970
- analyzerId: ID22,
2971
- severity: "warn",
2972
- nodeIds: [node.path],
2973
- message: tx(UNKNOWN_FIELD_TEXTS.unknownRootKey, {
2974
- path: node.path,
2975
- key
2976
- }),
2977
- data: { surface: "root", key }
2978
- });
2979
- bump2(node.path);
2980
- }
2549
+ issue.fix = {
2550
+ summary: candidatePaths.length === 1 ? tx(REFERENCE_BROKEN_TEXTS.hintSummarySingle, {
2551
+ name: suggestedName,
2552
+ candidate: candidatePaths[0]
2553
+ }) : tx(REFERENCE_BROKEN_TEXTS.hintSummaryMany, {
2554
+ name: suggestedName,
2555
+ candidates: candidatePaths.join(", ")
2556
+ }),
2557
+ autofixable: false
2558
+ };
2559
+ }
2560
+ return issue;
2561
+ }
2562
+ function resolvesViaReferencePaths(link, refIndex) {
2563
+ if (!isPathStyleLink(link)) return false;
2564
+ return refIndex.paths.has(resolve3(refIndex.cwd, link.target));
2565
+ }
2566
+ function indexByNormalizedName(nodes) {
2567
+ const out = /* @__PURE__ */ new Map();
2568
+ for (const node of nodes) {
2569
+ const raw = node.frontmatter?.["name"];
2570
+ const name = typeof raw === "string" ? raw : "";
2571
+ if (!name) continue;
2572
+ const key = normalizeTrigger(name);
2573
+ const bucket = out.get(key) ?? [];
2574
+ bucket.push(node);
2575
+ out.set(key, bucket);
2576
+ }
2577
+ return out;
2578
+ }
2579
+ function basenameWithoutExt(path) {
2580
+ const base = pathPosix4.basename(path);
2581
+ const ext = pathPosix4.extname(base);
2582
+ return ext ? base.slice(0, -ext.length) : base;
2583
+ }
2584
+ function indexByBasenameWithoutName(nodes) {
2585
+ const out = /* @__PURE__ */ new Map();
2586
+ for (const node of nodes) {
2587
+ const raw = node.frontmatter?.["name"];
2588
+ const name = typeof raw === "string" ? raw : "";
2589
+ if (name) continue;
2590
+ const bare = basenameWithoutExt(node.path);
2591
+ if (!bare) continue;
2592
+ const key = normalizeTrigger(bare);
2593
+ if (!key) continue;
2594
+ const bucket = out.get(key) ?? [];
2595
+ bucket.push(node);
2596
+ out.set(key, bucket);
2597
+ }
2598
+ return out;
2599
+ }
2600
+ function findHintCandidates(link, idx) {
2601
+ const normalized = link.trigger?.normalizedTrigger;
2602
+ if (!normalized) return [];
2603
+ const sigil = normalized.charAt(0);
2604
+ if (sigil !== "/" && sigil !== "@") return [];
2605
+ const withoutSigil = normalized.slice(1).trim();
2606
+ if (!withoutSigil) return [];
2607
+ return idx.get(withoutSigil) ?? [];
2608
+ }
2609
+ function isResolved(link, byPath3, byNormalizedName) {
2610
+ const normalized = link.trigger?.normalizedTrigger;
2611
+ if (normalized) {
2612
+ const withoutSigil = normalized.replace(/^[/@]/, "").trim();
2613
+ if (byNormalizedName.has(withoutSigil)) return true;
2614
+ }
2615
+ if (byPath3.has(link.target)) return true;
2616
+ return false;
2617
+ }
2618
+ function isPathStyleLink(link) {
2619
+ const sigil = link.trigger?.normalizedTrigger?.charAt(0);
2620
+ if (sigil === "/" || sigil === "@") return false;
2621
+ return true;
2622
+ }
2623
+
2624
+ // plugins/core/analyzers/reference-redundant/text.ts
2625
+ var REFERENCE_REDUNDANT_TEXTS = {
2626
+ /**
2627
+ * Multi-form / multi-occurrence reference message. Lists each
2628
+ * occurrence (trigger + line) so the operator sees the full
2629
+ * authorial surface without having to grep the body.
2630
+ */
2631
+ message: "{{source}} references {{resolvedTarget}} via {{count}} occurrences: {{occurrences}}. Consider consolidating to a single form to reduce maintenance surface and avoid duplicate inlining at runtime.",
2632
+ /** Inline separator between occurrences in the message. */
2633
+ occurrenceSeparator: ", ",
2634
+ /** Per-occurrence formatting (trigger + line). */
2635
+ occurrence: "`{{trigger}}` ({{kind}}, line {{line}})",
2636
+ /** Per-occurrence formatting when the extractor did not record a line. */
2637
+ occurrenceUnknownLine: "`{{trigger}}` ({{kind}}, unknown line)"
2638
+ };
2639
+
2640
+ // plugins/core/analyzers/reference-redundant/index.ts
2641
+ var ID20 = "reference-redundant";
2642
+ var referenceRedundantAnalyzer = {
2643
+ id: ID20,
2644
+ pluginId: CORE_PLUGIN_ID,
2645
+ kind: "analyzer",
2646
+ version: "1.0.0",
2647
+ description: "Flags when one node references the same target through two or more different links (e.g. a markdown link plus a `references:` entry).",
2648
+ mode: "deterministic",
2649
+ evaluate(ctx) {
2650
+ if (ctx.links.length === 0) return [];
2651
+ const byPath3 = /* @__PURE__ */ new Map();
2652
+ for (const node of ctx.nodes) byPath3.set(node.path, node);
2653
+ const byName = buildNameIndex2(ctx.nodes);
2654
+ const groups = /* @__PURE__ */ new Map();
2655
+ for (const link of ctx.links) {
2656
+ const resolved = resolveTargetPath(link, byPath3, byName);
2657
+ if (!resolved) continue;
2658
+ const key = `${link.source}\0${resolved}`;
2659
+ const bucket = groups.get(key);
2660
+ if (bucket) bucket.push(link);
2661
+ else groups.set(key, [link]);
2981
2662
  }
2982
- for (const [nodePath, count] of perNode) {
2983
- const tooltip = count === 1 ? UNKNOWN_FIELD_TEXTS.alertTooltipSingle : tx(UNKNOWN_FIELD_TEXTS.alertTooltipMany, { count });
2984
- ctx.emitContribution(nodePath, "alert", {
2985
- icon: "fa-solid fa-triangle-exclamation",
2986
- severity: "warn",
2987
- tooltip
2988
- });
2989
- ctx.emitContribution(nodePath, "chip", {
2990
- value: 0,
2663
+ const issues = [];
2664
+ for (const [key, links] of groups) {
2665
+ const totalOccurrences = links.reduce((acc, l) => acc + (l.occurrences?.length ?? 1), 0);
2666
+ if (totalOccurrences < 2) continue;
2667
+ const [source, resolvedTarget] = key.split("\0");
2668
+ const flat = flattenOccurrences(links);
2669
+ issues.push({
2670
+ analyzerId: ID20,
2991
2671
  severity: "warn",
2992
- tooltip
2672
+ nodeIds: [source],
2673
+ message: tx(REFERENCE_REDUNDANT_TEXTS.message, {
2674
+ source,
2675
+ resolvedTarget,
2676
+ count: flat.length,
2677
+ occurrences: flat.map(formatOccurrence).join(REFERENCE_REDUNDANT_TEXTS.occurrenceSeparator)
2678
+ }),
2679
+ data: {
2680
+ target: resolvedTarget,
2681
+ resolvedTarget,
2682
+ occurrences: flat.map((o) => ({
2683
+ kind: o.kind,
2684
+ trigger: o.originalTrigger,
2685
+ line: o.line ?? null,
2686
+ extractor: o.extractor
2687
+ }))
2688
+ }
2993
2689
  });
2994
2690
  }
2995
2691
  return issues;
2996
2692
  }
2997
2693
  };
2998
- var cachedKnownKeys = null;
2999
- function getKnownAnnotationKeys() {
3000
- if (cachedKnownKeys) return cachedKnownKeys;
3001
- const require2 = createRequire(import.meta.url);
3002
- const indexPath = require2.resolve("@skill-map/spec/index.json");
3003
- const specRoot = dirname(indexPath);
3004
- const schema = JSON.parse(
3005
- readFileSync(resolve3(specRoot, "schemas/annotations.schema.json"), "utf8")
3006
- );
3007
- cachedKnownKeys = new Set(Object.keys(schema.properties ?? {}));
3008
- return cachedKnownKeys;
2694
+ function flattenOccurrences(links) {
2695
+ const out = [];
2696
+ for (const link of links) {
2697
+ if (link.occurrences && link.occurrences.length > 0) {
2698
+ for (const occ of link.occurrences) {
2699
+ out.push({
2700
+ kind: link.kind,
2701
+ originalTrigger: occ.originalTrigger,
2702
+ extractor: occ.extractor,
2703
+ line: occ.location?.line ?? null
2704
+ });
2705
+ }
2706
+ continue;
2707
+ }
2708
+ const trigger = link.trigger?.originalTrigger ?? link.target;
2709
+ out.push({
2710
+ kind: link.kind,
2711
+ originalTrigger: trigger,
2712
+ extractor: link.sources[0] ?? "unknown",
2713
+ line: link.location?.line ?? null
2714
+ });
2715
+ }
2716
+ out.sort((a, b) => {
2717
+ const la = a.line ?? Number.MAX_SAFE_INTEGER;
2718
+ const lb = b.line ?? Number.MAX_SAFE_INTEGER;
2719
+ if (la !== lb) return la - lb;
2720
+ return a.originalTrigger.localeCompare(b.originalTrigger);
2721
+ });
2722
+ return out;
3009
2723
  }
3010
- function indexNamespacedContributions(contributions) {
2724
+ function formatOccurrence(occ) {
2725
+ if (occ.line === null) {
2726
+ return tx(REFERENCE_REDUNDANT_TEXTS.occurrenceUnknownLine, { trigger: occ.originalTrigger, kind: occ.kind });
2727
+ }
2728
+ return tx(REFERENCE_REDUNDANT_TEXTS.occurrence, { trigger: occ.originalTrigger, kind: occ.kind, line: occ.line });
2729
+ }
2730
+ function buildNameIndex2(nodes) {
3011
2731
  const out = /* @__PURE__ */ new Map();
3012
- const ajv = new Ajv2020({ strict: false, allErrors: true, allowUnionTypes: true });
3013
- applyAjvFormats(ajv);
3014
- for (const entry of contributions) {
3015
- if (entry.location !== "namespaced") continue;
3016
- let bucket = out.get(entry.pluginId);
3017
- if (!bucket) {
3018
- bucket = /* @__PURE__ */ new Map();
3019
- out.set(entry.pluginId, bucket);
3020
- }
3021
- try {
3022
- bucket.set(entry.key, ajv.compile(entry.schema));
3023
- } catch {
2732
+ for (const node of nodes) {
2733
+ for (const candidate of collectIdentifiers(node)) {
2734
+ const normalised = normalizeTrigger(candidate);
2735
+ if (!normalised) continue;
2736
+ const bucket = out.get(normalised);
2737
+ if (bucket) bucket.push(node.path);
2738
+ else out.set(normalised, [node.path]);
3024
2739
  }
3025
2740
  }
3026
2741
  return out;
3027
2742
  }
3028
- function indexRootContributions(contributions) {
3029
- const out = /* @__PURE__ */ new Set();
3030
- for (const entry of contributions) {
3031
- if (entry.location === "root") out.add(entry.key);
2743
+ function collectIdentifiers(node) {
2744
+ const out = [];
2745
+ const fmName = node.frontmatter?.["name"];
2746
+ if (typeof fmName === "string" && fmName.length > 0) out.push(fmName);
2747
+ const segs = node.path.split("/");
2748
+ const last = segs[segs.length - 1] ?? "";
2749
+ if (last) {
2750
+ const stem = last.replace(/\.[^.]+$/, "");
2751
+ if (stem) out.push(stem);
2752
+ }
2753
+ if (segs.length >= 2) {
2754
+ const dirBase = segs[segs.length - 2];
2755
+ if (dirBase) out.push(dirBase);
3032
2756
  }
3033
2757
  return out;
3034
2758
  }
3035
- function collectPluginIds(contributions) {
3036
- const out = /* @__PURE__ */ new Set();
3037
- for (const entry of contributions) out.add(entry.pluginId);
3038
- return out;
2759
+ function resolveTargetPath(link, byPath3, byName) {
2760
+ if (byPath3.has(link.target)) return link.target;
2761
+ const trigger = link.trigger?.normalizedTrigger;
2762
+ if (!trigger) return null;
2763
+ const stripped = trigger.replace(/^[/@]/, "").trim();
2764
+ if (!stripped) return null;
2765
+ const candidates = byName.get(stripped);
2766
+ if (!candidates || candidates.length === 0) return null;
2767
+ return candidates[0] ?? null;
3039
2768
  }
3040
2769
 
3041
2770
  // kernel/adapters/schema-validators.ts
@@ -3224,185 +2953,463 @@ function registerProviderAuxiliarySchemas(ajv, providers) {
3224
2953
  ajv.addSchema(aux);
3225
2954
  }
3226
2955
  }
3227
- }
3228
- function resolveSpecRoot() {
3229
- const require2 = createRequire2(import.meta.url);
3230
- try {
3231
- const indexPath = require2.resolve("@skill-map/spec/index.json");
3232
- return dirname2(indexPath);
3233
- } catch {
3234
- throw new Error(
3235
- "@skill-map/spec not resolvable: ensure the workspace is linked or the package is installed."
3236
- );
2956
+ }
2957
+ function resolveSpecRoot() {
2958
+ const require2 = createRequire2(import.meta.url);
2959
+ try {
2960
+ const indexPath = require2.resolve("@skill-map/spec/index.json");
2961
+ return dirname2(indexPath);
2962
+ } catch {
2963
+ throw new Error(
2964
+ "@skill-map/spec not resolvable: ensure the workspace is linked or the package is installed."
2965
+ );
2966
+ }
2967
+ }
2968
+ function existsSyncSafe(path) {
2969
+ try {
2970
+ readFileSync2(path, "utf8");
2971
+ return true;
2972
+ } catch {
2973
+ return false;
2974
+ }
2975
+ }
2976
+
2977
+ // plugins/core/analyzers/schema-violation/text.ts
2978
+ var SCHEMA_VIOLATION_TEXTS = {
2979
+ /** `Node <path> failed schema validation: <errors>` */
2980
+ nodeFailure: "Node {{path}} failed schema validation: {{errors}}",
2981
+ /** `Link <source> → <target> failed schema validation: <errors>` */
2982
+ linkFailure: "Link {{source}} \u2192 {{target}} failed schema validation: {{errors}}",
2983
+ /** `Node <path> is missing required frontmatter fields: <missing>` */
2984
+ frontmatterBaseFailure: "Node {{path}} is missing required frontmatter fields: {{missing}}.",
2985
+ /** Singular tooltip on the alert / chip when a node has exactly one validation failure. */
2986
+ alertTooltipSingle: "Frontmatter or schema validation failed.",
2987
+ /** Plural tooltip; `{{count}}` capped at 99 in the chip badge but the tooltip text shows the raw count. */
2988
+ alertTooltipMany: "{{count}} schema validation issues on this node."
2989
+ };
2990
+
2991
+ // plugins/core/analyzers/schema-violation/index.ts
2992
+ var ID21 = "schema-violation";
2993
+ var schemaViolationAnalyzer = {
2994
+ id: ID21,
2995
+ pluginId: CORE_PLUGIN_ID,
2996
+ kind: "analyzer",
2997
+ version: "1.0.0",
2998
+ description: "Flags nodes or links that violate the project schemas.",
2999
+ mode: "deterministic",
3000
+ ui: {
3001
+ // Corner badge on the graph card; surfaces when the node body /
3002
+ // frontmatter fails schema validation (parse error, missing
3003
+ // `name`/`description`, malformed YAML, etc.). Same visual
3004
+ // chassis as `core/reference-broken`, danger severity.
3005
+ alert: {
3006
+ slot: "graph.node.alert",
3007
+ icon: "fa-solid fa-triangle-exclamation",
3008
+ emitWhenEmpty: false
3009
+ },
3010
+ // Footer chip that mirrors the corner alert with the actual
3011
+ // count so the operator can scan the cards and prioritise.
3012
+ // Outlined (vs the filled corner alert) per the broken-ref
3013
+ // pattern: two beats of the same signal.
3014
+ chip: {
3015
+ slot: "card.footer.right",
3016
+ icon: "fa-regular fa-triangle-exclamation",
3017
+ emitWhenEmpty: false,
3018
+ priority: 35
3019
+ }
3020
+ },
3021
+ evaluate(ctx) {
3022
+ const validators = loadSchemaValidators();
3023
+ const findings = [];
3024
+ const perNode = /* @__PURE__ */ new Map();
3025
+ for (const node of ctx.nodes) {
3026
+ const before = findings.length;
3027
+ collectNodeFindings(validators, node, findings);
3028
+ collectFrontmatterBaseFindings(node, findings);
3029
+ if (findings.length > before) {
3030
+ perNode.set(node.path, (perNode.get(node.path) ?? 0) + (findings.length - before));
3031
+ }
3032
+ }
3033
+ for (const link of ctx.links) {
3034
+ collectLinkFindings(validators, link, findings);
3035
+ }
3036
+ for (const [nodePath, count] of perNode) {
3037
+ const tooltip = count === 1 ? SCHEMA_VIOLATION_TEXTS.alertTooltipSingle : tx(SCHEMA_VIOLATION_TEXTS.alertTooltipMany, { count });
3038
+ const capped = Math.min(count, 99);
3039
+ ctx.emitContribution(nodePath, "alert", {
3040
+ icon: "fa-solid fa-triangle-exclamation",
3041
+ severity: "danger",
3042
+ tooltip
3043
+ });
3044
+ ctx.emitContribution(nodePath, "chip", {
3045
+ value: capped,
3046
+ severity: "danger",
3047
+ tooltip
3048
+ });
3049
+ }
3050
+ return findings;
3051
+ }
3052
+ };
3053
+ function collectNodeFindings(v, node, out) {
3054
+ const result = v.validate("node", toNodeForSchema(node));
3055
+ if (result.ok) return;
3056
+ out.push({
3057
+ analyzerId: ID21,
3058
+ severity: "error",
3059
+ nodeIds: [node.path],
3060
+ message: tx(SCHEMA_VIOLATION_TEXTS.nodeFailure, {
3061
+ path: node.path,
3062
+ errors: result.errors
3063
+ }),
3064
+ data: { target: "node", path: node.path }
3065
+ });
3066
+ }
3067
+ function collectFrontmatterBaseFindings(node, out) {
3068
+ if (node.provider === "markdown") return;
3069
+ if (node.bytes.frontmatter === 0) return;
3070
+ const fm = node.frontmatter ?? {};
3071
+ const missing = [];
3072
+ if (isMissingStringField(fm, "name")) missing.push("name");
3073
+ if (isMissingStringField(fm, "description")) missing.push("description");
3074
+ if (missing.length === 0) return;
3075
+ out.push({
3076
+ analyzerId: ID21,
3077
+ // `warn` (not `error`) so the default `sm scan` exit code stays
3078
+ // 0 even when nodes are missing frontmatter base fields. Strict
3079
+ // mode (`sm scan --strict`) still escalates to exit 1. Matches
3080
+ // the `frontmatter-invalid` severity policy of the orchestrator.
3081
+ severity: "warn",
3082
+ nodeIds: [node.path],
3083
+ message: tx(SCHEMA_VIOLATION_TEXTS.frontmatterBaseFailure, {
3084
+ path: node.path,
3085
+ missing: missing.join(", ")
3086
+ }),
3087
+ data: { target: "frontmatter", path: node.path, missing }
3088
+ });
3089
+ }
3090
+ function isMissingStringField(fm, field) {
3091
+ const v = fm[field];
3092
+ return typeof v !== "string" || v.length === 0;
3093
+ }
3094
+ function collectLinkFindings(v, link, out) {
3095
+ const result = v.validate("link", toLinkForSchema(link));
3096
+ if (result.ok) return;
3097
+ out.push({
3098
+ analyzerId: ID21,
3099
+ severity: "error",
3100
+ nodeIds: [link.source],
3101
+ message: tx(SCHEMA_VIOLATION_TEXTS.linkFailure, {
3102
+ source: link.source,
3103
+ target: link.target,
3104
+ errors: result.errors
3105
+ }),
3106
+ data: { target: "link", source: link.source, to: link.target }
3107
+ });
3108
+ }
3109
+ function toNodeForSchema(node) {
3110
+ return {
3111
+ path: node.path,
3112
+ kind: node.kind,
3113
+ provider: node.provider,
3114
+ bodyHash: node.bodyHash,
3115
+ frontmatterHash: node.frontmatterHash,
3116
+ bytes: node.bytes,
3117
+ tokens: node.tokens ?? void 0,
3118
+ linksOutCount: node.linksOutCount,
3119
+ linksInCount: node.linksInCount,
3120
+ externalRefsCount: node.externalRefsCount,
3121
+ frontmatter: node.frontmatter ?? {},
3122
+ sidecar: node.sidecar ?? void 0
3123
+ };
3124
+ }
3125
+ function toLinkForSchema(link) {
3126
+ return {
3127
+ source: link.source,
3128
+ target: link.target,
3129
+ kind: link.kind,
3130
+ confidence: link.confidence,
3131
+ sources: link.sources,
3132
+ trigger: link.trigger ?? void 0,
3133
+ location: link.location ?? void 0,
3134
+ raw: link.raw ?? void 0
3135
+ };
3136
+ }
3137
+
3138
+ // plugins/core/analyzers/signal-collision/text.ts
3139
+ var SIGNAL_COLLISION_TEXTS = {
3140
+ /**
3141
+ * Per-Signal warn issue: two extractors detected something at
3142
+ * overlapping byte ranges within the same node and the resolver
3143
+ * dropped the loser. Surfaces WHO lost, WHO won, and the tiebreak
3144
+ * reason so the operator can understand why a candidate edge did NOT
3145
+ * become a Link.
3146
+ *
3147
+ * Placeholders are deliberately verbose because this is one of the
3148
+ * few diagnostic surfaces where the operator may need to disambiguate
3149
+ * a confusing graph (e.g. a `[link](path)` followed by `@path` inside
3150
+ * the same paragraph, the markdown-link wins and the at-directive
3151
+ * silently disappears without this warning).
3152
+ */
3153
+ message: "{{loserExtractor}} detected `{{loserRaw}}` at offset {{loserRange}} but {{winnerExtractor}}'s detection at {{winnerRange}} won the overlap collision ({{reason}}). The graph shows the winning edge only; the loser is not persisted.",
3154
+ /**
3155
+ * Same warn but for the rare case the resolver rejected a Signal
3156
+ * because the operator disabled its extractor via
3157
+ * `plugins.<id>.extensions.<extId>.enabled`. Phase 4+ stub: today the
3158
+ * filter is not wired so this template is unreachable from the
3159
+ * resolver; documented now so the analyzer stays forward-compatible
3160
+ * with the upcoming filter pass.
3161
+ */
3162
+ messageExtractorDisabled: "Extension `{{extractorId}}` is disabled; its detection `{{loserRaw}}` (offset {{loserRange}}) did not produce a Link. Re-enable the extension in Settings or via `sm plugins enable` to surface its edges.",
3163
+ /**
3164
+ * Same warn but for the future confidence floor case. Phase 4+ stub:
3165
+ * today the resolver materialises every winning candidate regardless
3166
+ * of confidence, so this template is unreachable; documented for
3167
+ * forward compatibility.
3168
+ */
3169
+ messageBelowFloor: "Detection `{{loserRaw}}` (offset {{loserRange}}, confidence {{confidence}}) fell below the configured threshold {{threshold}} and was dropped."
3170
+ };
3171
+
3172
+ // plugins/core/analyzers/signal-collision/index.ts
3173
+ var ID22 = "signal-collision";
3174
+ var signalCollisionAnalyzer = {
3175
+ id: ID22,
3176
+ pluginId: CORE_PLUGIN_ID,
3177
+ kind: "analyzer",
3178
+ version: "1.0.0",
3179
+ description: "Reports when two extractors fight over the same span of body text, or when a candidate link is dropped (extractor disabled, confidence too low) before it reaches the graph.",
3180
+ mode: "deterministic",
3181
+ evaluate(ctx) {
3182
+ const signals = ctx.signals;
3183
+ if (!signals || signals.length === 0) return [];
3184
+ const issues = [];
3185
+ for (const signal of signals) {
3186
+ const issue = makeIssue(signal);
3187
+ if (issue) issues.push(issue);
3188
+ }
3189
+ return issues;
3190
+ }
3191
+ };
3192
+ function makeIssue(signal) {
3193
+ const resolution = signal.resolution;
3194
+ if (!resolution || resolution.outcome !== "rejected") return null;
3195
+ if (resolution.rejectedBy) {
3196
+ const winner = resolution.rejectedBy;
3197
+ const winnerCandidate = signal.candidates[resolution.winnerIndex ?? 0];
3198
+ const loserRange = signal.range ? `${signal.range.start}-${signal.range.end}` : "unknown";
3199
+ const winnerRange = `${winner.range.start}-${winner.range.end}`;
3200
+ return {
3201
+ analyzerId: ID22,
3202
+ severity: "warn",
3203
+ nodeIds: [signal.source],
3204
+ message: tx(SIGNAL_COLLISION_TEXTS.message, {
3205
+ loserExtractor: winnerCandidate.extractorId,
3206
+ loserRaw: signal.raw,
3207
+ loserRange,
3208
+ winnerExtractor: winner.extractorId,
3209
+ winnerRange,
3210
+ reason: winner.reason
3211
+ }),
3212
+ data: {
3213
+ loser: {
3214
+ extractorId: winnerCandidate.extractorId,
3215
+ raw: signal.raw,
3216
+ range: signal.range ?? null,
3217
+ candidate: {
3218
+ kind: winnerCandidate.kind,
3219
+ target: winnerCandidate.target,
3220
+ confidence: winnerCandidate.confidence
3221
+ }
3222
+ },
3223
+ winner: {
3224
+ extractorId: winner.extractorId,
3225
+ range: winner.range
3226
+ },
3227
+ reason: winner.reason
3228
+ }
3229
+ };
3230
+ }
3231
+ if (resolution.extractorDisabled) {
3232
+ const loserRange = signal.range ? `${signal.range.start}-${signal.range.end}` : "unknown";
3233
+ return {
3234
+ analyzerId: ID22,
3235
+ severity: "warn",
3236
+ nodeIds: [signal.source],
3237
+ message: tx(SIGNAL_COLLISION_TEXTS.messageExtractorDisabled, {
3238
+ extractorId: resolution.extractorDisabled.extractorId,
3239
+ loserRaw: signal.raw,
3240
+ loserRange
3241
+ }),
3242
+ data: {
3243
+ extractorDisabled: resolution.extractorDisabled,
3244
+ raw: signal.raw,
3245
+ range: signal.range ?? null
3246
+ }
3247
+ };
3237
3248
  }
3238
- }
3239
- function existsSyncSafe(path) {
3240
- try {
3241
- readFileSync2(path, "utf8");
3242
- return true;
3243
- } catch {
3244
- return false;
3249
+ if (resolution.belowFloor) {
3250
+ const loserRange = signal.range ? `${signal.range.start}-${signal.range.end}` : "unknown";
3251
+ const topCandidate = signal.candidates[0];
3252
+ return {
3253
+ analyzerId: ID22,
3254
+ severity: "warn",
3255
+ nodeIds: [signal.source],
3256
+ message: tx(SIGNAL_COLLISION_TEXTS.messageBelowFloor, {
3257
+ loserRaw: signal.raw,
3258
+ loserRange,
3259
+ confidence: topCandidate.confidence,
3260
+ threshold: resolution.belowFloor.threshold
3261
+ }),
3262
+ data: {
3263
+ belowFloor: resolution.belowFloor,
3264
+ raw: signal.raw,
3265
+ range: signal.range ?? null
3266
+ }
3267
+ };
3245
3268
  }
3269
+ return null;
3246
3270
  }
3247
3271
 
3248
- // plugins/core/analyzers/validate-all/text.ts
3249
- var VALIDATE_ALL_TEXTS = {
3250
- /** `Node <path> failed schema validation: <errors>` */
3251
- nodeFailure: "Node {{path}} failed schema validation: {{errors}}",
3252
- /** `Link <source> <target> failed schema validation: <errors>` */
3253
- linkFailure: "Link {{source}} \u2192 {{target}} failed schema validation: {{errors}}",
3254
- /** `Node <path> is missing required frontmatter fields: <missing>` */
3255
- frontmatterBaseFailure: "Node {{path}} is missing required frontmatter fields: {{missing}}.",
3256
- /** Singular tooltip on the alert / chip when a node has exactly one validation failure. */
3257
- alertTooltipSingle: "Frontmatter or schema validation failed.",
3258
- /** Plural tooltip; `{{count}}` capped at 99 in the chip badge but the tooltip text shows the raw count. */
3259
- alertTooltipMany: "{{count}} schema validation issues on this node."
3272
+ // plugins/core/analyzers/trigger-collision/text.ts
3273
+ var TRIGGER_COLLISION_TEXTS = {
3274
+ /**
3275
+ * Top-level message when `analyzeTriggerBucket` accumulated exactly one
3276
+ * cause part. Used for the advertiser-ambiguous-only, invocation-
3277
+ * ambiguous-only, and cross-kind-only branches.
3278
+ */
3279
+ messageOnePart: 'Trigger "{{normalized}}" has {{part}}.',
3280
+ /**
3281
+ * Top-level message when `analyzeTriggerBucket` accumulated two cause
3282
+ * parts (advertiser-ambiguous AND invocation-ambiguous fire together).
3283
+ * The joiner lives inside the template so future locales can adapt it
3284
+ * (e.g. `'; y '` in Spanish) without touching the rule code.
3285
+ */
3286
+ messageTwoParts: 'Trigger "{{normalized}}" has {{first}}; and {{second}}.',
3287
+ /** `<n> nodes advertise it: <list>` part, fires on the advertiser-ambiguous branch. */
3288
+ partAdvertisers: "{{count}} nodes advertise it: {{paths}}",
3289
+ /** `<n> distinct invocation forms: <list>` part, fires on the invocation-ambiguous branch. */
3290
+ partInvocations: "{{count}} distinct invocation forms: {{forms}}",
3291
+ /** Singular cross-kind cause: `non-canonical invocation <form> against advertiser <path>`. */
3292
+ partNonCanonicalSingular: "non-canonical invocation {{forms}} against advertiser {{advertiser}}",
3293
+ /** Plural cross-kind cause: `non-canonical invocations <forms> against advertiser <path>`. */
3294
+ partNonCanonicalPlural: "non-canonical invocations {{forms}} against advertiser {{advertiser}}"
3260
3295
  };
3261
3296
 
3262
- // plugins/core/analyzers/validate-all/index.ts
3263
- var ID23 = "validate-all";
3264
- var validateAllAnalyzer = {
3297
+ // plugins/core/analyzers/trigger-collision/index.ts
3298
+ var ID23 = "trigger-collision";
3299
+ var ADVERTISING_KINDS = /* @__PURE__ */ new Set([
3300
+ "command",
3301
+ "skill",
3302
+ "agent"
3303
+ ]);
3304
+ var triggerCollisionAnalyzer = {
3265
3305
  id: ID23,
3266
- pluginId: "core",
3306
+ pluginId: CORE_PLUGIN_ID,
3267
3307
  kind: "analyzer",
3268
- version: "1.0.0",
3269
- description: "Detects and flags nodes or links violating the project schemas.",
3270
3308
  mode: "deterministic",
3271
- ui: {
3272
- // Corner badge on the graph card; surfaces when the node body /
3273
- // frontmatter fails schema validation (parse error, missing
3274
- // `name`/`description`, malformed YAML, etc.). Same visual
3275
- // chassis as `core/broken-ref`, danger severity.
3276
- alert: {
3277
- slot: "graph.node.alert",
3278
- icon: "fa-solid fa-triangle-exclamation",
3279
- emitWhenEmpty: false
3280
- },
3281
- // Footer chip that mirrors the corner alert with the actual
3282
- // count so the operator can scan the cards and prioritise.
3283
- // Outlined (vs the filled corner alert) per the broken-ref
3284
- // pattern: two beats of the same signal.
3285
- chip: {
3286
- slot: "card.footer.right",
3287
- icon: "fa-regular fa-triangle-exclamation",
3288
- emitWhenEmpty: false,
3289
- priority: 35
3290
- }
3291
- },
3309
+ version: "1.0.0",
3310
+ description: "Flags two or more nodes that claim the same `/command` or `@agent` name.",
3311
+ // Two claim-collection passes (advertisement + invocation) feeding
3312
+ // the bucket map. Per-bucket analysis lives in `analyzeTriggerBucket`.
3313
+ // eslint-disable-next-line complexity
3292
3314
  evaluate(ctx) {
3293
- const validators = loadSchemaValidators();
3294
- const findings = [];
3295
- const perNode = /* @__PURE__ */ new Map();
3315
+ const buckets = /* @__PURE__ */ new Map();
3316
+ const push = (key, claim) => {
3317
+ const bucket = buckets.get(key) ?? [];
3318
+ bucket.push(claim);
3319
+ buckets.set(key, bucket);
3320
+ };
3296
3321
  for (const node of ctx.nodes) {
3297
- const before = findings.length;
3298
- collectNodeFindings(validators, node, findings);
3299
- collectFrontmatterBaseFindings(node, findings);
3300
- if (findings.length > before) {
3301
- perNode.set(node.path, (perNode.get(node.path) ?? 0) + (findings.length - before));
3302
- }
3322
+ if (!ADVERTISING_KINDS.has(node.kind)) continue;
3323
+ const raw = node.frontmatter?.["name"];
3324
+ if (typeof raw !== "string" || raw.length === 0) continue;
3325
+ const normalized = `/${normalizeTrigger(raw)}`;
3326
+ if (normalized === "/") continue;
3327
+ push(normalized, {
3328
+ kind: "advertiser",
3329
+ token: node.path,
3330
+ nodeId: node.path,
3331
+ canonicalForm: `/${raw}`
3332
+ });
3303
3333
  }
3304
3334
  for (const link of ctx.links) {
3305
- collectLinkFindings(validators, link, findings);
3306
- }
3307
- for (const [nodePath, count] of perNode) {
3308
- const tooltip = count === 1 ? VALIDATE_ALL_TEXTS.alertTooltipSingle : tx(VALIDATE_ALL_TEXTS.alertTooltipMany, { count });
3309
- const capped = Math.min(count, 99);
3310
- ctx.emitContribution(nodePath, "alert", {
3311
- icon: "fa-solid fa-triangle-exclamation",
3312
- severity: "danger",
3313
- tooltip
3314
- });
3315
- ctx.emitContribution(nodePath, "chip", {
3316
- value: capped,
3317
- severity: "danger",
3318
- tooltip
3335
+ const normalized = link.trigger?.normalizedTrigger;
3336
+ if (!normalized) continue;
3337
+ push(normalized, {
3338
+ kind: "invocation",
3339
+ token: link.target,
3340
+ nodeId: link.source
3319
3341
  });
3320
3342
  }
3321
- return findings;
3343
+ const issues = [];
3344
+ for (const [normalized, claims] of buckets) {
3345
+ const issue = analyzeTriggerBucket(normalized, claims);
3346
+ if (issue) issues.push(issue);
3347
+ }
3348
+ return issues;
3322
3349
  }
3323
3350
  };
3324
- function collectNodeFindings(v, node, out) {
3325
- const result = v.validate("node", toNodeForSchema(node));
3326
- if (result.ok) return;
3327
- out.push({
3328
- analyzerId: ID23,
3329
- severity: "error",
3330
- nodeIds: [node.path],
3331
- message: tx(VALIDATE_ALL_TEXTS.nodeFailure, {
3332
- path: node.path,
3333
- errors: result.errors
3334
- }),
3335
- data: { target: "node", path: node.path }
3336
- });
3337
- }
3338
- function collectFrontmatterBaseFindings(node, out) {
3339
- if (node.provider === "markdown") return;
3340
- if (node.bytes.frontmatter === 0) return;
3341
- const fm = node.frontmatter ?? {};
3342
- const missing = [];
3343
- if (isMissingStringField(fm, "name")) missing.push("name");
3344
- if (isMissingStringField(fm, "description")) missing.push("description");
3345
- if (missing.length === 0) return;
3346
- out.push({
3347
- analyzerId: ID23,
3348
- // `warn` (not `error`) so the default `sm scan` exit code stays
3349
- // 0 even when nodes are missing frontmatter base fields. Strict
3350
- // mode (`sm scan --strict`) still escalates to exit 1. Matches
3351
- // the `frontmatter-invalid` severity policy of the orchestrator.
3352
- severity: "warn",
3353
- nodeIds: [node.path],
3354
- message: tx(VALIDATE_ALL_TEXTS.frontmatterBaseFailure, {
3355
- path: node.path,
3356
- missing: missing.join(", ")
3357
- }),
3358
- data: { target: "frontmatter", path: node.path, missing }
3351
+ function analyzeTriggerBucket(normalized, claims) {
3352
+ const advertiserPaths = [
3353
+ ...new Set(claims.filter((c) => c.kind === "advertiser").map((c) => c.token))
3354
+ ].sort();
3355
+ const invocationTargets = [
3356
+ ...new Set(claims.filter((c) => c.kind === "invocation").map((c) => c.token))
3357
+ ].sort();
3358
+ const advertisers = claims.filter(
3359
+ (c) => c.kind === "advertiser"
3360
+ );
3361
+ const advertiserAmbiguous = advertiserPaths.length >= 2;
3362
+ const invocationAmbiguous = invocationTargets.length >= 2;
3363
+ const canonicalForms = new Set(advertisers.map((a) => a.canonicalForm));
3364
+ const nonCanonicalInvocations = invocationTargets.filter((t) => !canonicalForms.has(t));
3365
+ const crossKindAmbiguous = advertiserPaths.length === 1 && nonCanonicalInvocations.length >= 1;
3366
+ if (!advertiserAmbiguous && !invocationAmbiguous && !crossKindAmbiguous) {
3367
+ return null;
3368
+ }
3369
+ const nodeIds = [...new Set(claims.map((c) => c.nodeId))].sort();
3370
+ const parts = [];
3371
+ if (advertiserAmbiguous) {
3372
+ parts.push(
3373
+ tx(TRIGGER_COLLISION_TEXTS.partAdvertisers, {
3374
+ count: advertiserPaths.length,
3375
+ paths: advertiserPaths.join(", ")
3376
+ })
3377
+ );
3378
+ }
3379
+ if (invocationAmbiguous) {
3380
+ parts.push(
3381
+ tx(TRIGGER_COLLISION_TEXTS.partInvocations, {
3382
+ count: invocationTargets.length,
3383
+ forms: invocationTargets.join(", ")
3384
+ })
3385
+ );
3386
+ } else if (crossKindAmbiguous) {
3387
+ const template = nonCanonicalInvocations.length > 1 ? TRIGGER_COLLISION_TEXTS.partNonCanonicalPlural : TRIGGER_COLLISION_TEXTS.partNonCanonicalSingular;
3388
+ parts.push(
3389
+ tx(template, {
3390
+ forms: nonCanonicalInvocations.join(", "),
3391
+ advertiser: advertiserPaths[0]
3392
+ })
3393
+ );
3394
+ }
3395
+ const message = parts.length === 2 ? tx(TRIGGER_COLLISION_TEXTS.messageTwoParts, {
3396
+ normalized,
3397
+ first: parts[0],
3398
+ second: parts[1]
3399
+ }) : tx(TRIGGER_COLLISION_TEXTS.messageOnePart, {
3400
+ normalized,
3401
+ part: parts[0]
3359
3402
  });
3360
- }
3361
- function isMissingStringField(fm, field) {
3362
- const v = fm[field];
3363
- return typeof v !== "string" || v.length === 0;
3364
- }
3365
- function collectLinkFindings(v, link, out) {
3366
- const result = v.validate("link", toLinkForSchema(link));
3367
- if (result.ok) return;
3368
- out.push({
3403
+ return {
3369
3404
  analyzerId: ID23,
3370
3405
  severity: "error",
3371
- nodeIds: [link.source],
3372
- message: tx(VALIDATE_ALL_TEXTS.linkFailure, {
3373
- source: link.source,
3374
- target: link.target,
3375
- errors: result.errors
3376
- }),
3377
- data: { target: "link", source: link.source, to: link.target }
3378
- });
3379
- }
3380
- function toNodeForSchema(node) {
3381
- return {
3382
- path: node.path,
3383
- kind: node.kind,
3384
- provider: node.provider,
3385
- bodyHash: node.bodyHash,
3386
- frontmatterHash: node.frontmatterHash,
3387
- bytes: node.bytes,
3388
- tokens: node.tokens ?? void 0,
3389
- linksOutCount: node.linksOutCount,
3390
- linksInCount: node.linksInCount,
3391
- externalRefsCount: node.externalRefsCount,
3392
- frontmatter: node.frontmatter ?? {},
3393
- sidecar: node.sidecar ?? void 0
3394
- };
3395
- }
3396
- function toLinkForSchema(link) {
3397
- return {
3398
- source: link.source,
3399
- target: link.target,
3400
- kind: link.kind,
3401
- confidence: link.confidence,
3402
- sources: link.sources,
3403
- trigger: link.trigger ?? void 0,
3404
- location: link.location ?? void 0,
3405
- raw: link.raw ?? void 0
3406
+ nodeIds,
3407
+ message,
3408
+ data: {
3409
+ normalizedTrigger: normalized,
3410
+ invocationTargets,
3411
+ advertiserPaths
3412
+ }
3406
3413
  };
3407
3414
  }
3408
3415
 
@@ -3438,11 +3445,11 @@ var ID24 = "ascii";
3438
3445
  var KIND_ORDER = ["agent", "command", "skill", "markdown"];
3439
3446
  var asciiFormatter = {
3440
3447
  id: ID24,
3441
- pluginId: "core",
3448
+ pluginId: CORE_PLUGIN_ID,
3442
3449
  kind: "formatter",
3443
3450
  formatId: ID24,
3444
3451
  version: "1.0.0",
3445
- description: "Renders the scan as plain text, grouped by kind, arrows, and issues. Used by `sm scan --format=ascii`.",
3452
+ description: "Renders the scan as plain text in three sections: nodes (grouped by kind), arrows, and issues. Used by `sm scan --format ascii`.",
3446
3453
  // ASCII tree formatter, header + per-kind sections + per-issue
3447
3454
  // section. Each section iterates and renders; splitting per section
3448
3455
  // would multiply the for-loop boilerplate.
@@ -3538,10 +3545,10 @@ function renderSection(out, kind, group) {
3538
3545
  var ID25 = "json";
3539
3546
  var jsonFormatter = {
3540
3547
  id: ID25,
3541
- pluginId: "core",
3548
+ pluginId: CORE_PLUGIN_ID,
3542
3549
  kind: "formatter",
3543
3550
  version: "1.0.0",
3544
- description: "Renders the persisted scan as JSON (conforms to `scan-result.schema.json` when the full ScanResult is available). Used by `sm graph --format json` and `GET /api/graph?format=json`.",
3551
+ description: "Renders the persisted scan as JSON (conforms to `scan-result.schema.json`). Used by `sm graph --format json` and `GET /api/graph?format=json`.",
3545
3552
  formatId: ID25,
3546
3553
  format(ctx) {
3547
3554
  if (ctx.scanResult !== void 0) {
@@ -3680,15 +3687,14 @@ function resolveSpecRoot2() {
3680
3687
  }
3681
3688
  }
3682
3689
 
3683
- // plugins/core/actions/bump/index.ts
3684
- var ID26 = "bump";
3685
- var PLUGIN_ID = "core";
3686
- var bumpAction = {
3690
+ // plugins/core/actions/node-bump/index.ts
3691
+ var ID26 = "node-bump";
3692
+ var nodeBumpAction = {
3687
3693
  id: ID26,
3688
- pluginId: PLUGIN_ID,
3694
+ pluginId: CORE_PLUGIN_ID,
3689
3695
  kind: "action",
3690
3696
  version: "1.0.0",
3691
- description: "Marks a node as updated: bumps version, refreshes sidecar hashes, records the timestamp.",
3697
+ description: "Marks a node as updated: bumps `annotations.version`, refreshes sidecar hashes, and records the timestamp.",
3692
3698
  mode: "deterministic",
3693
3699
  // The runtime contract uses generic <TInput, TReport>; bump narrows
3694
3700
  // both. The cast is the standard pattern for built-ins that want
@@ -3742,15 +3748,14 @@ function pickCurrentVersion(overlay) {
3742
3748
  return typeof v === "number" && Number.isFinite(v) ? v : 0;
3743
3749
  }
3744
3750
 
3745
- // plugins/core/actions/mark-superseded/index.ts
3746
- var ID27 = "mark-superseded";
3747
- var PLUGIN_ID2 = "core";
3748
- var markSupersededAction = {
3751
+ // plugins/core/actions/node-supersede/index.ts
3752
+ var ID27 = "node-supersede";
3753
+ var nodeSupersedeAction = {
3749
3754
  id: ID27,
3750
- pluginId: PLUGIN_ID2,
3755
+ pluginId: CORE_PLUGIN_ID,
3751
3756
  kind: "action",
3752
3757
  version: "0.0.0",
3753
- description: "Declares the current node as superseded by another (writes `supersededBy` to the sidecar). Paired with the `core/superseded` analyzer.",
3758
+ description: "Declares the current node as superseded by another (writes `supersededBy` to the sidecar).",
3754
3759
  mode: "deterministic",
3755
3760
  invoke(_input, _ctx) {
3756
3761
  const report = { ok: true, noop: true };
@@ -3856,7 +3861,7 @@ var UPDATE_CHECK_TEXTS = {
3856
3861
  // package.json
3857
3862
  var package_default = {
3858
3863
  name: "@skill-map/cli",
3859
- version: "0.37.0",
3864
+ version: "0.39.0",
3860
3865
  description: "skill-map reference implementation \u2014 kernel + CLI + adapters.",
3861
3866
  license: "MIT",
3862
3867
  type: "module",
@@ -3939,7 +3944,7 @@ var package_default = {
3939
3944
  semver: "7.7.4",
3940
3945
  "smol-toml": "1.6.1",
3941
3946
  typanion: "3.14.0",
3942
- ws: "8.20.0"
3947
+ ws: "8.21.0"
3943
3948
  },
3944
3949
  devDependencies: {
3945
3950
  "@eslint/js": "10.0.1",
@@ -4006,10 +4011,67 @@ function ansiFor(opts) {
4006
4011
  }
4007
4012
 
4008
4013
  // cli/util/user-settings-store.ts
4009
- import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync4, writeFileSync } from "fs";
4014
+ import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync5 } from "fs";
4010
4015
  import { homedir } from "os";
4011
4016
  import { join as join2 } from "path";
4012
4017
 
4018
+ // core/config/atomic-write.ts
4019
+ import {
4020
+ closeSync,
4021
+ constants as fsConstants,
4022
+ existsSync as existsSync2,
4023
+ mkdirSync,
4024
+ openSync,
4025
+ readFileSync as readFileSync4,
4026
+ renameSync,
4027
+ unlinkSync,
4028
+ writeSync
4029
+ } from "fs";
4030
+ import { randomBytes } from "crypto";
4031
+ import { dirname as dirname4 } from "path";
4032
+ function readJsonObjectOrEmpty(path) {
4033
+ if (!existsSync2(path)) return {};
4034
+ try {
4035
+ const raw = JSON.parse(readFileSync4(path, "utf8"));
4036
+ if (raw && typeof raw === "object" && !Array.isArray(raw)) {
4037
+ return raw;
4038
+ }
4039
+ } catch {
4040
+ }
4041
+ return {};
4042
+ }
4043
+ function writeFileAtomicExclusive(path, content, mode = 384) {
4044
+ const tmp = `${path}.tmp.${process.pid}.${randomBytes(8).toString("hex")}`;
4045
+ let fd = null;
4046
+ try {
4047
+ fd = openSync(
4048
+ tmp,
4049
+ fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_NOFOLLOW,
4050
+ mode
4051
+ );
4052
+ writeSync(fd, content);
4053
+ closeSync(fd);
4054
+ fd = null;
4055
+ renameSync(tmp, path);
4056
+ } catch (err) {
4057
+ if (fd !== null) {
4058
+ try {
4059
+ closeSync(fd);
4060
+ } catch {
4061
+ }
4062
+ }
4063
+ try {
4064
+ unlinkSync(tmp);
4065
+ } catch {
4066
+ }
4067
+ throw err;
4068
+ }
4069
+ }
4070
+ function writeJsonAtomic(path, content) {
4071
+ mkdirSync(dirname4(path), { recursive: true });
4072
+ writeFileAtomicExclusive(path, JSON.stringify(content, null, 2) + "\n");
4073
+ }
4074
+
4013
4075
  // core/paths/db-path.ts
4014
4076
  import { join, resolve as resolve6 } from "path";
4015
4077
  var SKILL_MAP_DIR = ".skill-map";
@@ -4067,9 +4129,9 @@ function readUserSettings() {
4067
4129
  }
4068
4130
  function readParsedFile() {
4069
4131
  const path = userSettingsFilePath();
4070
- if (!existsSync2(path)) return null;
4132
+ if (!existsSync3(path)) return null;
4071
4133
  try {
4072
- const parsed = JSON.parse(readFileSync4(path, "utf8"));
4134
+ const parsed = JSON.parse(readFileSync5(path, "utf8"));
4073
4135
  if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
4074
4136
  return null;
4075
4137
  }
@@ -4102,8 +4164,8 @@ function writeUserSettings(patch) {
4102
4164
  const result = validators.validate("user-settings", merged);
4103
4165
  if (!result.ok) return;
4104
4166
  }
4105
- mkdirSync(dir, { recursive: true });
4106
- writeFileSync(path, JSON.stringify(merged, null, 2) + "\n");
4167
+ mkdirSync2(dir, { recursive: true, mode: 448 });
4168
+ writeFileAtomicExclusive(path, JSON.stringify(merged, null, 2) + "\n");
4107
4169
  } catch {
4108
4170
  }
4109
4171
  }
@@ -4214,10 +4276,10 @@ ${footer}
4214
4276
  // plugins/core/hooks/update-check/index.ts
4215
4277
  var updateCheckHook = {
4216
4278
  id: "update-check",
4217
- pluginId: "core",
4279
+ pluginId: CORE_PLUGIN_ID,
4218
4280
  kind: "hook",
4219
4281
  version: "1.0.0",
4220
- description: "Checks daily for a newer skill-map version on npm. Shows an `update available` banner when one is found.",
4282
+ description: "Checks daily for a newer `skill-map` version on npm. Shows an `update available` banner when one is found.",
4221
4283
  triggers: ["boot"],
4222
4284
  async on(ctx) {
4223
4285
  const payload = ctx.event.data ?? {};
@@ -4234,7 +4296,7 @@ var updateCheckHook = {
4234
4296
  // plugins/built-ins.ts
4235
4297
  var claudeProvider2 = { ...claudeProvider, pluginId: "claude" };
4236
4298
  var atDirectiveExtractor2 = { ...atDirectiveExtractor, pluginId: "claude" };
4237
- var slashExtractor2 = { ...slashExtractor, pluginId: "claude" };
4299
+ var slashCommandExtractor2 = { ...slashCommandExtractor, pluginId: "claude" };
4238
4300
  var antigravityProvider2 = { ...antigravityProvider, pluginId: "antigravity" };
4239
4301
  var openaiProvider2 = { ...openaiProvider, pluginId: "openai" };
4240
4302
  var agentSkillsProvider2 = { ...agentSkillsProvider, pluginId: "agent-skills" };
@@ -4243,43 +4305,43 @@ var annotationsExtractor2 = { ...annotationsExtractor, pluginId: "core" };
4243
4305
  var externalUrlCounterExtractor2 = { ...externalUrlCounterExtractor, pluginId: "core" };
4244
4306
  var markdownLinkExtractor2 = { ...markdownLinkExtractor, pluginId: "core" };
4245
4307
  var mcpToolsExtractor2 = { ...mcpToolsExtractor, pluginId: "core" };
4246
- var toolsCountExtractor2 = { ...toolsCountExtractor, pluginId: "core" };
4308
+ var toolsCounterExtractor2 = { ...toolsCounterExtractor, pluginId: "core" };
4309
+ var annotationFieldUnknownAnalyzer2 = { ...annotationFieldUnknownAnalyzer, pluginId: "core" };
4247
4310
  var annotationOrphanAnalyzer2 = { ...annotationOrphanAnalyzer, pluginId: "core" };
4248
4311
  var annotationStaleAnalyzer2 = { ...annotationStaleAnalyzer, pluginId: "core" };
4249
- var brokenRefAnalyzer2 = { ...brokenRefAnalyzer, pluginId: "core" };
4250
4312
  var contributionOrphanAnalyzer2 = { ...contributionOrphanAnalyzer, pluginId: "core" };
4251
- var jobOrphanFileAnalyzer2 = { ...jobOrphanFileAnalyzer, pluginId: "core" };
4313
+ var jobFileOrphanAnalyzer2 = { ...jobFileOrphanAnalyzer, pluginId: "core" };
4252
4314
  var linkConflictAnalyzer2 = { ...linkConflictAnalyzer, pluginId: "core" };
4253
- var linkCountsAnalyzer2 = { ...linkCountsAnalyzer, pluginId: "core" };
4254
- var redundantTargetReferenceAnalyzer2 = { ...redundantTargetReferenceAnalyzer, pluginId: "core" };
4255
- var reservedNameAnalyzer2 = { ...reservedNameAnalyzer, pluginId: "core" };
4256
- var selfLoopAnalyzer2 = { ...selfLoopAnalyzer, pluginId: "core" };
4315
+ var linkCounterAnalyzer2 = { ...linkCounterAnalyzer, pluginId: "core" };
4316
+ var linkSelfLoopAnalyzer2 = { ...linkSelfLoopAnalyzer, pluginId: "core" };
4317
+ var nameReservedAnalyzer2 = { ...nameReservedAnalyzer, pluginId: "core" };
4318
+ var nodeStabilityAnalyzer2 = { ...nodeStabilityAnalyzer, pluginId: "core" };
4319
+ var nodeSupersededAnalyzer2 = { ...nodeSupersededAnalyzer, pluginId: "core" };
4320
+ var referenceBrokenAnalyzer2 = { ...referenceBrokenAnalyzer, pluginId: "core" };
4321
+ var referenceRedundantAnalyzer2 = { ...referenceRedundantAnalyzer, pluginId: "core" };
4322
+ var schemaViolationAnalyzer2 = { ...schemaViolationAnalyzer, pluginId: "core" };
4257
4323
  var signalCollisionAnalyzer2 = { ...signalCollisionAnalyzer, pluginId: "core" };
4258
- var stabilityAnalyzer2 = { ...stabilityAnalyzer, pluginId: "core" };
4259
- var supersededAnalyzer2 = { ...supersededAnalyzer, pluginId: "core" };
4260
4324
  var triggerCollisionAnalyzer2 = { ...triggerCollisionAnalyzer, pluginId: "core" };
4261
- var unknownFieldAnalyzer2 = { ...unknownFieldAnalyzer, pluginId: "core" };
4262
- var validateAllAnalyzer2 = { ...validateAllAnalyzer, pluginId: "core" };
4263
4325
  var asciiFormatter2 = { ...asciiFormatter, pluginId: "core" };
4264
4326
  var jsonFormatter2 = { ...jsonFormatter, pluginId: "core" };
4265
- var bumpAction2 = { ...bumpAction, pluginId: "core" };
4266
- var markSupersededAction2 = { ...markSupersededAction, pluginId: "core" };
4327
+ var nodeBumpAction2 = { ...nodeBumpAction, pluginId: "core" };
4328
+ var nodeSupersedeAction2 = { ...nodeSupersedeAction, pluginId: "core" };
4267
4329
  var updateCheckHook2 = { ...updateCheckHook, pluginId: "core" };
4268
4330
  var builtInBundles = [
4269
4331
  {
4270
4332
  id: "claude",
4271
4333
  granularity: "bundle",
4272
- description: "Claude Code platform integration. Classifies files under `.claude/{agents,commands,skills}` and parses Claude-flavored frontmatter.",
4334
+ description: "Claude Code platform integration. Classifies files under `.claude/{agents,commands,skills}` and detects Claude-flavored slash commands and at-directives in their bodies.",
4273
4335
  extensions: [
4274
4336
  claudeProvider2,
4275
4337
  atDirectiveExtractor2,
4276
- slashExtractor2
4338
+ slashCommandExtractor2
4277
4339
  ]
4278
4340
  },
4279
4341
  {
4280
4342
  id: "antigravity",
4281
4343
  granularity: "bundle",
4282
- description: "Google Antigravity CLI platform integration (released 2026-05-19, replaces the retired Gemini CLI). Antigravity adopted the open-standard `.agents/` layout, so skills are classified by the neutral `agent-skills` Provider; this bundle contributes lens identity + a reserved-name seed catalog for future Antigravity built-in invocables.",
4344
+ description: "Google Antigravity CLI platform integration (replaces the retired Gemini CLI). Antigravity adopted the open-standard `.agents/` layout, so skills are classified by the neutral `agent-skills` provider; this plugin contributes the Antigravity runtime identity and a seed list of reserved built-in names.",
4283
4345
  extensions: [
4284
4346
  antigravityProvider2
4285
4347
  ]
@@ -4287,7 +4349,7 @@ var builtInBundles = [
4287
4349
  {
4288
4350
  id: "openai",
4289
4351
  granularity: "bundle",
4290
- description: "OpenAI Codex CLI platform integration. Classifies TOML sub-agent definitions under `.codex/agents/*.toml` and (future) walks the hierarchical AGENTS.md cascade. Provider for the active-lens `openai` runtime.",
4352
+ description: "OpenAI Codex CLI platform integration. Classifies TOML sub-agent definitions under `.codex/agents/*.toml`.",
4291
4353
  extensions: [
4292
4354
  openaiProvider2
4293
4355
  ]
@@ -4295,7 +4357,7 @@ var builtInBundles = [
4295
4357
  {
4296
4358
  id: "agent-skills",
4297
4359
  granularity: "bundle",
4298
- description: "Agent Skills open standard. Vendor-neutral path `.agents/skills/<name>/SKILL.md` (Anthropic, OpenAI, Google). See agentskills.io.",
4360
+ description: "Open-standard Agent Skills layout. Classifies skills under the vendor-neutral path `.agents/skills/<name>/SKILL.md` (adopted by Anthropic, OpenAI, Google). See agentskills.io.",
4299
4361
  extensions: [
4300
4362
  agentSkillsProvider2
4301
4363
  ]
@@ -4303,34 +4365,34 @@ var builtInBundles = [
4303
4365
  {
4304
4366
  id: "core",
4305
4367
  granularity: "extension",
4306
- description: "Core extensions shared across providers: extractors, analyzers, formatters, the bump action, and the universal `.md` fallback Provider.",
4368
+ description: "Core extensions shared across providers: parsers, extractors, analyzers, actions, hooks, formatters, and the universal `.md` fallback provider.",
4307
4369
  extensions: [
4308
4370
  coreMarkdownProvider2,
4309
4371
  annotationsExtractor2,
4310
4372
  externalUrlCounterExtractor2,
4311
4373
  markdownLinkExtractor2,
4312
4374
  mcpToolsExtractor2,
4313
- toolsCountExtractor2,
4375
+ toolsCounterExtractor2,
4376
+ annotationFieldUnknownAnalyzer2,
4314
4377
  annotationOrphanAnalyzer2,
4315
4378
  annotationStaleAnalyzer2,
4316
- brokenRefAnalyzer2,
4317
4379
  contributionOrphanAnalyzer2,
4318
- jobOrphanFileAnalyzer2,
4380
+ jobFileOrphanAnalyzer2,
4319
4381
  linkConflictAnalyzer2,
4320
- linkCountsAnalyzer2,
4321
- redundantTargetReferenceAnalyzer2,
4322
- reservedNameAnalyzer2,
4323
- selfLoopAnalyzer2,
4382
+ linkCounterAnalyzer2,
4383
+ linkSelfLoopAnalyzer2,
4384
+ nameReservedAnalyzer2,
4385
+ nodeStabilityAnalyzer2,
4386
+ nodeSupersededAnalyzer2,
4387
+ referenceBrokenAnalyzer2,
4388
+ referenceRedundantAnalyzer2,
4389
+ schemaViolationAnalyzer2,
4324
4390
  signalCollisionAnalyzer2,
4325
- stabilityAnalyzer2,
4326
- supersededAnalyzer2,
4327
4391
  triggerCollisionAnalyzer2,
4328
- unknownFieldAnalyzer2,
4329
- validateAllAnalyzer2,
4330
4392
  asciiFormatter2,
4331
4393
  jsonFormatter2,
4332
- bumpAction2,
4333
- markSupersededAction2,
4394
+ nodeBumpAction2,
4395
+ nodeSupersedeAction2,
4334
4396
  updateCheckHook2
4335
4397
  ]
4336
4398
  }
@@ -4457,21 +4519,42 @@ function localTimeFromIso(iso) {
4457
4519
  const ss = String(d.getSeconds()).padStart(2, "0");
4458
4520
  return `${hh}:${mm}:${ss}`;
4459
4521
  }
4460
- var defaultFormat = (record) => {
4522
+ function paintLevelPrefix(level, ansi) {
4523
+ const label = level.toUpperCase().padEnd(5);
4524
+ switch (level) {
4525
+ case "error":
4526
+ return `${ansi.red("\u2715")} ${ansi.red(label)}`;
4527
+ case "warn":
4528
+ return `${ansi.yellow("\u26A0")} ${ansi.yellow(label)}`;
4529
+ case "info":
4530
+ return `${ansi.cyan("\u2139")} ${ansi.cyan(label)}`;
4531
+ case "debug":
4532
+ case "trace":
4533
+ return `${ansi.dim("\xB7")} ${ansi.dim(label)}`;
4534
+ }
4535
+ }
4536
+ var defaultFormat = (record, ansi) => {
4461
4537
  const time = localTimeFromIso(record.timestamp);
4462
- const level = record.level.toUpperCase().padEnd(5);
4463
- const ctx = record.context && Object.keys(record.context).length > 0 ? ` | ${JSON.stringify(record.context)}` : "";
4464
- return `${time} | ${level} | ${record.message}${ctx}
4538
+ const prefix = paintLevelPrefix(record.level, ansi);
4539
+ const ctx = record.context && Object.keys(record.context).length > 0 ? ` ${ansi.dim("|")} ${ansi.dim(JSON.stringify(record.context))}` : "";
4540
+ return `${ansi.dim(time)} ${prefix} ${record.message}${ctx}
4465
4541
  `;
4466
4542
  };
4467
4543
  var Logger = class {
4468
4544
  #level;
4469
4545
  #stream;
4470
4546
  #format;
4547
+ #ansi;
4471
4548
  constructor(opts) {
4472
4549
  this.#level = opts.level;
4473
4550
  this.#stream = opts.stream;
4474
4551
  this.#format = opts.format ?? defaultFormat;
4552
+ const streamTty = opts.stream;
4553
+ this.#ansi = ansiFor({
4554
+ isTTY: streamTty.isTTY === true,
4555
+ noColorFlag: opts.noColorFlag === true,
4556
+ ...opts.env !== void 0 ? { env: opts.env } : {}
4557
+ });
4475
4558
  }
4476
4559
  setLevel(level) {
4477
4560
  this.#level = level;
@@ -4502,7 +4585,7 @@ var Logger = class {
4502
4585
  message,
4503
4586
  ...context !== void 0 ? { context } : {}
4504
4587
  };
4505
- this.#stream.write(this.#format(record));
4588
+ this.#stream.write(this.#format(record, this.#ansi));
4506
4589
  }
4507
4590
  };
4508
4591
  function resolveLogLevel(opts) {
@@ -4546,7 +4629,7 @@ function extractLogLevelFlag(argv) {
4546
4629
  var LOGGER_ENV_VAR = ENV_VAR;
4547
4630
 
4548
4631
  // cli/util/db-path.ts
4549
- import { existsSync as existsSync3 } from "fs";
4632
+ import { existsSync as existsSync4 } from "fs";
4550
4633
 
4551
4634
  // cli/i18n/util.texts.ts
4552
4635
  var UTIL_TEXTS = {
@@ -4589,7 +4672,7 @@ var ExitCode = {
4589
4672
 
4590
4673
  // cli/util/db-path.ts
4591
4674
  function assertDbExists(path, stderr) {
4592
- if (path === ":memory:" || existsSync3(path)) return true;
4675
+ if (path === ":memory:" || existsSync4(path)) return true;
4593
4676
  const stderrTty = stderr;
4594
4677
  const ansi = ansiFor({ isTTY: stderrTty.isTTY === true, noColorFlag: false });
4595
4678
  stderr.write(
@@ -4784,10 +4867,10 @@ import { Command as Command2, Option as Option2 } from "clipanion";
4784
4867
 
4785
4868
  // core/config/helper.ts
4786
4869
  import { homedir as osHomedir } from "os";
4787
- import { isAbsolute, join as join4, resolve as resolve7 } from "path";
4870
+ import { isAbsolute, join as join4, resolve as resolve7, sep } from "path";
4788
4871
 
4789
4872
  // kernel/config/loader.ts
4790
- import { existsSync as existsSync4, readFileSync as readFileSync5 } from "fs";
4873
+ import { existsSync as existsSync5, readFileSync as readFileSync6 } from "fs";
4791
4874
 
4792
4875
  // kernel/i18n/config-loader.texts.ts
4793
4876
  var CONFIG_LOADER_TEXTS = {
@@ -4869,7 +4952,7 @@ function loadConfig(opts) {
4869
4952
  { path: kernelLocalSettingsPath(cwd), layer: "project-local" }
4870
4953
  ];
4871
4954
  for (const { path, layer } of filePairs) {
4872
- if (!existsSync4(path)) continue;
4955
+ if (!existsSync5(path)) continue;
4873
4956
  const partial = readJsonSafe(path, layer, warnings, strict);
4874
4957
  if (partial === null) continue;
4875
4958
  const cleaned = validateAndStrip(validators, partial, layer, warnings, strict);
@@ -4890,7 +4973,7 @@ function loadConfig(opts) {
4890
4973
  function readJsonSafe(path, layer, warnings, strict) {
4891
4974
  let text;
4892
4975
  try {
4893
- text = readFileSync5(path, "utf8");
4976
+ text = readFileSync6(path, "utf8");
4894
4977
  } catch (err) {
4895
4978
  return reportAndSkip(
4896
4979
  tx(CONFIG_LOADER_TEXTS.readFailure, { layer, path, message: formatErrorMessage(err) }),
@@ -5132,63 +5215,6 @@ function enumerateConfigPaths(obj, prefix = "") {
5132
5215
  return out;
5133
5216
  }
5134
5217
 
5135
- // core/config/atomic-write.ts
5136
- import {
5137
- closeSync,
5138
- constants as fsConstants,
5139
- existsSync as existsSync5,
5140
- mkdirSync as mkdirSync2,
5141
- openSync,
5142
- readFileSync as readFileSync6,
5143
- renameSync,
5144
- unlinkSync,
5145
- writeSync
5146
- } from "fs";
5147
- import { randomBytes } from "crypto";
5148
- import { dirname as dirname4 } from "path";
5149
- function readJsonObjectOrEmpty(path) {
5150
- if (!existsSync5(path)) return {};
5151
- try {
5152
- const raw = JSON.parse(readFileSync6(path, "utf8"));
5153
- if (raw && typeof raw === "object" && !Array.isArray(raw)) {
5154
- return raw;
5155
- }
5156
- } catch {
5157
- }
5158
- return {};
5159
- }
5160
- function writeFileAtomicExclusive(path, content, mode = 384) {
5161
- const tmp = `${path}.tmp.${process.pid}.${randomBytes(8).toString("hex")}`;
5162
- let fd = null;
5163
- try {
5164
- fd = openSync(
5165
- tmp,
5166
- fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_NOFOLLOW,
5167
- mode
5168
- );
5169
- writeSync(fd, content);
5170
- closeSync(fd);
5171
- fd = null;
5172
- renameSync(tmp, path);
5173
- } catch (err) {
5174
- if (fd !== null) {
5175
- try {
5176
- closeSync(fd);
5177
- } catch {
5178
- }
5179
- }
5180
- try {
5181
- unlinkSync(tmp);
5182
- } catch {
5183
- }
5184
- throw err;
5185
- }
5186
- }
5187
- function writeJsonAtomic(path, content) {
5188
- mkdirSync2(dirname4(path), { recursive: true });
5189
- writeFileAtomicExclusive(path, JSON.stringify(content, null, 2) + "\n");
5190
- }
5191
-
5192
5218
  // core/config/helper.ts
5193
5219
  var PRIVACY_SENSITIVE_KEYS = /* @__PURE__ */ new Set([
5194
5220
  "scan.referencePaths"
@@ -5282,7 +5308,7 @@ function resolveScanPathForExposure(raw, cwd) {
5282
5308
  }
5283
5309
  function isUnderProject(absPath, cwd) {
5284
5310
  const projectRoot = resolve7(cwd);
5285
- return absPath === projectRoot || absPath.startsWith(`${projectRoot}/`);
5311
+ return absPath === projectRoot || absPath.startsWith(`${projectRoot}${sep}`);
5286
5312
  }
5287
5313
 
5288
5314
  // core/config/sidecar-consent.ts
@@ -5674,7 +5700,11 @@ var SmCommand = class extends Command {
5674
5700
  this.printer = createPrinter({
5675
5701
  stdout: this.context.stdout,
5676
5702
  stderr: this.context.stderr,
5677
- quietInfo: this.quiet
5703
+ // `--json` suppresses info banners even on stderr: users piping
5704
+ // JSON through `jq` (or asserting machine output in tests) don't
5705
+ // want decorative lines polluting either channel. Aligns CLI
5706
+ // behaviour with the printer docstring.
5707
+ quietInfo: this.quiet || this.json
5678
5708
  });
5679
5709
  try {
5680
5710
  return await this.run();
@@ -7237,9 +7267,9 @@ async function sweepPerTupleContributions(trx, contributions, freshlyRunTuples)
7237
7267
  const bufferKeys = buildContributionsBufferKeys(contributions);
7238
7268
  const tuplesByPluginExt = groupFreshlyRunTuplesByPluginExt(freshlyRunTuples);
7239
7269
  for (const [pe, nodes] of tuplesByPluginExt) {
7240
- const sep6 = pe.indexOf("\0");
7241
- if (sep6 < 0) continue;
7242
- await deleteStaleTupleRows(trx, pe.slice(0, sep6), pe.slice(sep6 + 1), [...nodes], bufferKeys);
7270
+ const sep8 = pe.indexOf("\0");
7271
+ if (sep8 < 0) continue;
7272
+ await deleteStaleTupleRows(trx, pe.slice(0, sep8), pe.slice(sep8 + 1), [...nodes], bufferKeys);
7243
7273
  }
7244
7274
  }
7245
7275
  function buildContributionsBufferKeys(contributions) {
@@ -8018,8 +8048,24 @@ function applyIssueFilters(query, filter) {
8018
8048
  )
8019
8049
  );
8020
8050
  }
8051
+ if (filter.nodePaths !== void 0) {
8052
+ q = applyNodePathsFilter(q, filter.nodePaths);
8053
+ }
8021
8054
  return q;
8022
8055
  }
8056
+ function applyNodePathsFilter(query, nodePaths) {
8057
+ if (nodePaths.length === 0) {
8058
+ return query.where(sql3`0`, "=", 1);
8059
+ }
8060
+ const targets = [...nodePaths];
8061
+ return query.where(
8062
+ ({ exists, selectFrom }) => exists(
8063
+ selectFrom(
8064
+ sql3`json_each(scan_issues.node_ids_json)`.as("je")
8065
+ ).select(sql3`1`.as("one")).where(sql3.ref("je.value"), "in", targets)
8066
+ )
8067
+ );
8068
+ }
8023
8069
  async function findActiveIssues(db, predicate) {
8024
8070
  const rows = await db.selectFrom("scan_issues").selectAll().execute();
8025
8071
  const out = [];
@@ -8308,15 +8354,32 @@ async function tryWithSqlite(options, fn) {
8308
8354
  import { resolve as resolve14 } from "path";
8309
8355
 
8310
8356
  // core/paths/path-guard.ts
8311
- import { isAbsolute as isAbsolute2, resolve as resolve13, sep } from "path";
8357
+ import { lstatSync } from "fs";
8358
+ import { isAbsolute as isAbsolute2, resolve as resolve13, sep as sep2 } from "path";
8312
8359
  function assertContained(cwd, rel) {
8313
8360
  if (isAbsolute2(rel)) {
8314
8361
  throw new Error(`node path is absolute, refusing to read: ${rel}`);
8315
8362
  }
8316
8363
  const abs = resolve13(cwd, rel);
8317
- if (abs !== cwd && !abs.startsWith(cwd + sep)) {
8364
+ if (abs !== cwd && !abs.startsWith(cwd + sep2)) {
8318
8365
  throw new Error(`node path escapes repo root: ${rel}`);
8319
8366
  }
8367
+ let isSymlink;
8368
+ try {
8369
+ isSymlink = lstatSync(abs).isSymbolicLink();
8370
+ } catch (err) {
8371
+ if (isAllowedLstatError(err)) return;
8372
+ throw err;
8373
+ }
8374
+ if (isSymlink) {
8375
+ throw new Error(`node path is a symlink, refusing to dereference: ${rel}`);
8376
+ }
8377
+ }
8378
+ var ALLOWED_LSTAT_ERROR_CODES = /* @__PURE__ */ new Set(["ENOENT", "ENOTDIR"]);
8379
+ function isAllowedLstatError(err) {
8380
+ if (err === null || typeof err !== "object") return false;
8381
+ const code = err.code;
8382
+ return typeof code === "string" && ALLOWED_LSTAT_ERROR_CODES.has(code);
8320
8383
  }
8321
8384
 
8322
8385
  // cli/commands/bump-plan.ts
@@ -8364,12 +8427,12 @@ function planOne(node, options) {
8364
8427
  };
8365
8428
  }
8366
8429
  function invokeBumpFor(node, absPath, force) {
8367
- if (!bumpAction.invoke) {
8430
+ if (!nodeBumpAction.invoke) {
8368
8431
  throw new Error("built-in bump action is missing its invoke()");
8369
8432
  }
8370
8433
  const input = {};
8371
8434
  if (force) input.force = true;
8372
- return bumpAction.invoke(input, {
8435
+ return nodeBumpAction.invoke(input, {
8373
8436
  node,
8374
8437
  nodeAbsolutePath: absPath,
8375
8438
  invoker: "cli",
@@ -8385,7 +8448,7 @@ var BumpCommand = class extends SmCommand {
8385
8448
  category: "Actions",
8386
8449
  description: "Bump a node's sidecar (`<basename>.sm`): increment annotations.version, refresh hashes, stamp audit.",
8387
8450
  details: `
8388
- Wraps the built-in deterministic \`core/bump\` Action. Single-node
8451
+ Wraps the built-in deterministic \`core/node-bump\` Action. Single-node
8389
8452
  mode bumps one path; \`--pending\` walks every node whose sidecar
8390
8453
  overlay reports drift and bumps them all.
8391
8454
 
@@ -9259,9 +9322,9 @@ function providerKindFailure(opts, status, fileName, errDescription) {
9259
9322
  }
9260
9323
  };
9261
9324
  }
9262
- function isDirectorySafe(path, statSync13) {
9325
+ function isDirectorySafe(path, statSync12) {
9263
9326
  try {
9264
- return statSync13(path).isDirectory();
9327
+ return statSync12(path).isDirectory();
9265
9328
  } catch {
9266
9329
  return false;
9267
9330
  }
@@ -9781,14 +9844,14 @@ var LOCKED_PLUGIN_IDS = /* @__PURE__ */ new Set([
9781
9844
  // unreachable from CLI / BFF / UI. Re-evaluate if a third-party ever
9782
9845
  // ships a competing supersession extractor.
9783
9846
  "core/annotations",
9784
- // `core/validate-all` validates every scanned Node against
9847
+ // `core/schema-violation` validates every scanned Node against
9785
9848
  // `node.schema.json` and every Link against `link.schema.json` (the
9786
9849
  // authoritative @skill-map/spec). Disabling it makes the system
9787
9850
  // persist non-conformant content silently, breaking the spec
9788
9851
  // invariant "what reaches the DB conforms to the spec". The check is
9789
9852
  // foundational, not advisory; lock it on so the guarantee holds
9790
9853
  // regardless of user / DB / settings hand-edits.
9791
- "core/validate-all",
9854
+ "core/schema-violation",
9792
9855
  // `core/ascii` is the only built-in Formatter today and the default
9793
9856
  // for `sm graph` (`--format ascii`). Disabling it breaks the verb
9794
9857
  // entirely (`composeFormatters` returns the empty list, the CLI
@@ -9824,7 +9887,8 @@ function isBuiltInExtensionEnabled(bundle, ext, resolveEnabled) {
9824
9887
  }
9825
9888
  function isBundleEntryEnabled(bundle, extId, resolveEnabled) {
9826
9889
  if (bundle.granularity === "bundle") {
9827
- return resolveEnabled(bundle.id);
9890
+ if (!resolveEnabled(bundle.id)) return false;
9891
+ return resolveEnabled(qualifiedExtensionId(bundle.id, extId));
9828
9892
  }
9829
9893
  return resolveEnabled(qualifiedExtensionId(bundle.id, extId));
9830
9894
  }
@@ -9837,7 +9901,10 @@ function buildGranularityMap(discovered) {
9837
9901
  }
9838
9902
  function isPluginExtensionEnabled(ext, granularityMap, resolveEnabled) {
9839
9903
  const granularity = granularityMap.get(ext.pluginId) ?? "bundle";
9840
- if (granularity === "bundle") return resolveEnabled(ext.pluginId);
9904
+ if (granularity === "bundle") {
9905
+ if (!resolveEnabled(ext.pluginId)) return false;
9906
+ return resolveEnabled(qualifiedExtensionId(ext.pluginId, ext.id));
9907
+ }
9841
9908
  return resolveEnabled(qualifiedExtensionId(ext.pluginId, ext.id));
9842
9909
  }
9843
9910
  async function buildEnabledResolver(ctx) {
@@ -9855,7 +9922,7 @@ async function buildEnabledResolver(ctx) {
9855
9922
 
9856
9923
  // kernel/scan/walk-content.ts
9857
9924
  import { readFile, readdir, lstat } from "fs/promises";
9858
- import { join as join9, relative as relative2, sep as sep2 } from "path";
9925
+ import { join as join9, relative as relative2, sep as sep3 } from "path";
9859
9926
 
9860
9927
  // kernel/scan/ignore.ts
9861
9928
  import { existsSync as existsSync13, readFileSync as readFileSync13 } from "fs";
@@ -10036,7 +10103,7 @@ async function* walkContent(roots, options) {
10036
10103
  const extensions = options.extensions;
10037
10104
  for (const root of roots) {
10038
10105
  for await (const file of walkRoot(root, root, filter, extensions)) {
10039
- const relPath = relative2(root, file).split(sep2).join("/");
10106
+ const relPath = relative2(root, file).split(sep3).join("/");
10040
10107
  let raw;
10041
10108
  try {
10042
10109
  raw = await readFile(file, "utf8");
@@ -10068,7 +10135,7 @@ async function* walkRoot(root, current, filter, extensions) {
10068
10135
  for (const entry of entries) {
10069
10136
  const name = entry.name;
10070
10137
  const full = join9(current, name);
10071
- const rel = relative2(root, full).split(sep2).join("/");
10138
+ const rel = relative2(root, full).split(sep3).join("/");
10072
10139
  if (filter.ignores(rel)) continue;
10073
10140
  if (entry.isSymbolicLink()) continue;
10074
10141
  if (entry.isDirectory()) {
@@ -10508,7 +10575,7 @@ var CheckCommand = class extends SmCommand {
10508
10575
  ["Print every current issue", "$0 check"],
10509
10576
  ["Machine-readable issue list", "$0 check --json"],
10510
10577
  ["Restrict to a single node", "$0 check -n .claude/agents/architect.md"],
10511
- ["Restrict to specific rules", "$0 check --analyzers core/broken-ref,core/validate-all"],
10578
+ ["Restrict to specific rules", "$0 check --analyzers core/reference-broken,core/schema-violation"],
10512
10579
  ["Opt in to probabilistic analyzers (stub until Step 10)", "$0 check --include-prob"],
10513
10580
  ["Use a non-default DB file", "$0 check --db /path/to/skill-map.db"]
10514
10581
  ]
@@ -11489,6 +11556,52 @@ function disableEnv(setup) {
11489
11556
  if (setup?.disableAllAnalyzers) env["SKILL_MAP_DISABLE_ALL_ANALYZERS"] = "1";
11490
11557
  return env;
11491
11558
  }
11559
+ var SAFE_CONFORMANCE_ENV_KEYS = [
11560
+ "PATH",
11561
+ "HOME",
11562
+ "USERPROFILE",
11563
+ "TMPDIR",
11564
+ "TMP",
11565
+ "TEMP",
11566
+ "SystemRoot",
11567
+ "SystemDrive",
11568
+ "OS",
11569
+ "COMSPEC",
11570
+ "PATHEXT",
11571
+ "NODE_OPTIONS",
11572
+ "NODE_PATH",
11573
+ "NODE_NO_WARNINGS",
11574
+ "NODE_DEBUG",
11575
+ "LANG",
11576
+ "TERM",
11577
+ "COLORTERM",
11578
+ "NO_COLOR",
11579
+ "FORCE_COLOR",
11580
+ "CI"
11581
+ ];
11582
+ var SAFE_CONFORMANCE_ENV_PREFIXES = [
11583
+ "LC_",
11584
+ "SKILL_MAP_",
11585
+ "SM_"
11586
+ ];
11587
+ function pickSafeEnv(source) {
11588
+ const out = {};
11589
+ for (const key of SAFE_CONFORMANCE_ENV_KEYS) {
11590
+ const value = source[key];
11591
+ if (value !== void 0) out[key] = value;
11592
+ }
11593
+ for (const key of Object.keys(source)) {
11594
+ if (out[key] !== void 0) continue;
11595
+ for (const prefix of SAFE_CONFORMANCE_ENV_PREFIXES) {
11596
+ if (key.startsWith(prefix)) {
11597
+ const value = source[key];
11598
+ if (value !== void 0) out[key] = value;
11599
+ break;
11600
+ }
11601
+ }
11602
+ }
11603
+ return out;
11604
+ }
11492
11605
  function runConformanceCase(options) {
11493
11606
  const raw = readFileSync14(options.casePath, "utf8");
11494
11607
  const c = JSON.parse(raw);
@@ -11508,7 +11621,7 @@ function runConformanceCase(options) {
11508
11621
  if (c.invoke.flags) argv.push(...c.invoke.flags);
11509
11622
  const child = spawnSync2(process.execPath, [options.binary, ...argv], {
11510
11623
  cwd: scope,
11511
- env: { ...process.env, ...options.env, ...setupEnv },
11624
+ env: { ...pickSafeEnv(process.env), ...options.env, ...setupEnv },
11512
11625
  encoding: "utf8"
11513
11626
  });
11514
11627
  const stdout = child.stdout ?? "";
@@ -11536,7 +11649,7 @@ function runPriorScansSetup(c, options, scope, fixturesRoot, setupEnv) {
11536
11649
  const stepArgv = ["scan", ...step.flags ?? []];
11537
11650
  const stepChild = spawnSync2(process.execPath, [options.binary, ...stepArgv], {
11538
11651
  cwd: scope,
11539
- env: { ...process.env, ...options.env, ...setupEnv },
11652
+ env: { ...pickSafeEnv(process.env), ...options.env, ...setupEnv },
11540
11653
  encoding: "utf8"
11541
11654
  });
11542
11655
  if ((stepChild.status ?? 0) !== 0) {
@@ -14035,14 +14148,7 @@ function registeredVerbPaths(cli2) {
14035
14148
  }
14036
14149
 
14037
14150
  // cli/commands/hooks.ts
14038
- import {
14039
- chmodSync,
14040
- existsSync as existsSync19,
14041
- mkdirSync as mkdirSync5,
14042
- readFileSync as readFileSync17,
14043
- statSync as statSync5,
14044
- writeFileSync as writeFileSync2
14045
- } from "fs";
14151
+ import { chmod as chmod2, mkdir as mkdir3, readFile as readFile2, stat as stat2, writeFile } from "fs/promises";
14046
14152
  import { dirname as dirname16, resolve as resolve27 } from "path";
14047
14153
  import { Command as Command16, Option as Option15 } from "clipanion";
14048
14154
 
@@ -14136,7 +14242,7 @@ var HooksInstallCommand = class extends SmCommand {
14136
14242
  return ExitCode.Error;
14137
14243
  }
14138
14244
  const ctx = defaultRuntimeContext();
14139
- const repoRoot = findGitRepoRoot(ctx.cwd);
14245
+ const repoRoot = await findGitRepoRoot(ctx.cwd);
14140
14246
  if (repoRoot === null) {
14141
14247
  this.printer.error(
14142
14248
  tx(HOOKS_TEXTS.notInGitRepo, {
@@ -14148,7 +14254,7 @@ var HooksInstallCommand = class extends SmCommand {
14148
14254
  }
14149
14255
  const hooksDir = resolve27(repoRoot, ".git", "hooks");
14150
14256
  const hookPath = resolve27(hooksDir, "pre-commit");
14151
- const existing = existsSync19(hookPath) ? readFileSync17(hookPath, "utf8") : null;
14257
+ const existing = await pathExists(hookPath) ? await readFile2(hookPath, "utf8") : null;
14152
14258
  const planned2 = computePlannedHookContent(existing);
14153
14259
  if (planned2.kind === "already-installed") {
14154
14260
  this.printer.info(tx(HOOKS_TEXTS.alreadyInstalled, { glyph: okGlyph, hookPath }));
@@ -14174,9 +14280,9 @@ var HooksInstallCommand = class extends SmCommand {
14174
14280
  return ExitCode.Ok;
14175
14281
  }
14176
14282
  try {
14177
- if (!existsSync19(hooksDir)) mkdirSync5(hooksDir, { recursive: true });
14178
- writeFileSync2(hookPath, planned2.content, { encoding: "utf8" });
14179
- ensureExecutableBit(hookPath);
14283
+ if (!await pathExists(hooksDir)) await mkdir3(hooksDir, { recursive: true });
14284
+ await writeFile(hookPath, planned2.content, { encoding: "utf8" });
14285
+ await ensureExecutableBit(hookPath);
14180
14286
  } catch (err) {
14181
14287
  this.printer.error(
14182
14288
  tx(HOOKS_TEXTS.installFailed, { glyph: errGlyph, message: formatErrorMessage(err) })
@@ -14202,10 +14308,10 @@ var HooksInstallCommand = class extends SmCommand {
14202
14308
  return ExitCode.Ok;
14203
14309
  }
14204
14310
  };
14205
- function findGitRepoRoot(cwd) {
14311
+ async function findGitRepoRoot(cwd) {
14206
14312
  let current = cwd;
14207
14313
  while (true) {
14208
- if (existsSync19(resolve27(current, ".git"))) return current;
14314
+ if (await pathExists(resolve27(current, ".git"))) return current;
14209
14315
  const parent = dirname16(current);
14210
14316
  if (parent === current) return null;
14211
14317
  current = parent;
@@ -14216,22 +14322,22 @@ function computePlannedHookContent(existing) {
14216
14322
  if (existing.includes(SKILL_MAP_MARKER)) {
14217
14323
  return { kind: "already-installed", content: existing };
14218
14324
  }
14219
- const sep6 = existing.endsWith("\n") ? "" : "\n";
14220
- return { kind: "chained", content: existing + sep6 + "\n" + SKILL_MAP_BLOCK };
14325
+ const sep8 = existing.endsWith("\n") ? "" : "\n";
14326
+ return { kind: "chained", content: existing + sep8 + "\n" + SKILL_MAP_BLOCK };
14221
14327
  }
14222
- function ensureExecutableBit(path) {
14223
- const mode = statSync5(path).mode;
14224
- chmodSync(path, mode | 73);
14328
+ async function ensureExecutableBit(path) {
14329
+ const mode = (await stat2(path)).mode;
14330
+ await chmod2(path, mode | 73);
14225
14331
  }
14226
14332
  var HOOKS_COMMANDS = [HooksInstallCommand];
14227
14333
 
14228
14334
  // cli/commands/init.ts
14229
- import { mkdir as mkdir3, readFile as readFile2, unlink, writeFile } from "fs/promises";
14335
+ import { mkdir as mkdir4, readFile as readFile3, unlink, writeFile as writeFile2 } from "fs/promises";
14230
14336
  import { join as join17 } from "path";
14231
14337
  import { Command as Command17, Option as Option16 } from "clipanion";
14232
14338
 
14233
14339
  // kernel/orchestrator/index.ts
14234
- import { existsSync as existsSync22, statSync as statSync7 } from "fs";
14340
+ import { existsSync as existsSync21, statSync as statSync6 } from "fs";
14235
14341
  import { isAbsolute as isAbsolute7, resolve as resolve28 } from "path";
14236
14342
  import { Tiktoken as Tiktoken2 } from "js-tiktoken/lite";
14237
14343
  import cl100k_base from "js-tiktoken/ranks/cl100k_base";
@@ -14592,7 +14698,8 @@ function recomputeLinkCounts(nodes, links) {
14592
14698
  for (const link of links) {
14593
14699
  const source = byPath3.get(link.source);
14594
14700
  if (source) source.linksOutCount += 1;
14595
- const target = byPath3.get(link.target);
14701
+ const targetKey = link.resolvedTarget ?? link.target;
14702
+ const target = byPath3.get(targetKey);
14596
14703
  if (target) target.linksInCount += 1;
14597
14704
  }
14598
14705
  }
@@ -15265,8 +15372,8 @@ function computeDriftStatus(args2) {
15265
15372
  }
15266
15373
 
15267
15374
  // kernel/sidecar/discover-orphans.ts
15268
- import { existsSync as existsSync20, readdirSync as readdirSync7, statSync as statSync6 } from "fs";
15269
- import { join as join13, relative as relative4, sep as sep3 } from "path";
15375
+ import { existsSync as existsSync19, readdirSync as readdirSync7, statSync as statSync5 } from "fs";
15376
+ import { join as join13, relative as relative4, sep as sep4 } from "path";
15270
15377
  function discoverOrphanSidecars(roots, shouldSkip) {
15271
15378
  const out = [];
15272
15379
  for (const root of roots) {
@@ -15283,7 +15390,7 @@ function walk(root, current, shouldSkip, out) {
15283
15390
  }
15284
15391
  for (const entry of entries) {
15285
15392
  const full = join13(current, entry.name);
15286
- const rel = relative4(root, full).split(sep3).join("/");
15393
+ const rel = relative4(root, full).split(sep4).join("/");
15287
15394
  if (shouldSkip(rel)) continue;
15288
15395
  if (entry.isSymbolicLink()) continue;
15289
15396
  if (entry.isDirectory()) {
@@ -15293,13 +15400,13 @@ function walk(root, current, shouldSkip, out) {
15293
15400
  if (!entry.isFile()) continue;
15294
15401
  if (!entry.name.endsWith(".sm")) continue;
15295
15402
  const expectedMd = `${full.slice(0, -".sm".length)}.md`;
15296
- if (existsSync20(expectedMd) && safeIsFile(expectedMd)) continue;
15403
+ if (existsSync19(expectedMd) && safeIsFile(expectedMd)) continue;
15297
15404
  out.push({ sidecarPath: full, relativePath: rel, expectedMdPath: expectedMd });
15298
15405
  }
15299
15406
  }
15300
15407
  function safeIsFile(path) {
15301
15408
  try {
15302
- return statSync6(path).isFile();
15409
+ return statSync5(path).isFile();
15303
15410
  } catch {
15304
15411
  return false;
15305
15412
  }
@@ -15307,7 +15414,7 @@ function safeIsFile(path) {
15307
15414
 
15308
15415
  // kernel/orchestrator/node-build.ts
15309
15416
  import { createHash } from "crypto";
15310
- import { existsSync as existsSync21 } from "fs";
15417
+ import { existsSync as existsSync20 } from "fs";
15311
15418
  import { isAbsolute as isAbsolute6, resolve as resolvePath } from "path";
15312
15419
  import "js-tiktoken/lite";
15313
15420
  import yaml4 from "js-yaml";
@@ -15471,11 +15578,11 @@ function resolveSidecarOverlay(relativePath2, nodePathForIssue, roots, liveBodyH
15471
15578
  }
15472
15579
  function resolveAbsoluteMdPath(relativePath2, roots) {
15473
15580
  if (isAbsolute6(relativePath2)) {
15474
- return existsSync21(relativePath2) ? relativePath2 : null;
15581
+ return existsSync20(relativePath2) ? relativePath2 : null;
15475
15582
  }
15476
15583
  for (const root of roots) {
15477
15584
  const candidate = resolvePath(root, relativePath2);
15478
- if (existsSync21(candidate)) return candidate;
15585
+ if (existsSync20(candidate)) return candidate;
15479
15586
  }
15480
15587
  return null;
15481
15588
  }
@@ -16016,7 +16123,7 @@ function validateRoots(roots) {
16016
16123
  throw new Error(ORCHESTRATOR_TEXTS.runScanRootEmptyArray);
16017
16124
  }
16018
16125
  for (const root of roots) {
16019
- if (!existsSync22(root) || !statSync7(root).isDirectory()) {
16126
+ if (!existsSync21(root) || !statSync6(root).isDirectory()) {
16020
16127
  throw new Error(tx(ORCHESTRATOR_TEXTS.runScanRootMissing, { root }));
16021
16128
  }
16022
16129
  }
@@ -16025,7 +16132,7 @@ function resolveActiveProviderOption(optionValue, roots) {
16025
16132
  if (optionValue !== void 0) return optionValue;
16026
16133
  for (const root of roots) {
16027
16134
  const absRoot = isAbsolute7(root) ? root : resolve28(root);
16028
- if (!existsSync22(absRoot)) continue;
16135
+ if (!existsSync21(absRoot)) continue;
16029
16136
  const detected = resolveActiveProvider(absRoot).resolved;
16030
16137
  if (detected !== null) return detected;
16031
16138
  }
@@ -16033,7 +16140,7 @@ function resolveActiveProviderOption(optionValue, roots) {
16033
16140
  }
16034
16141
 
16035
16142
  // kernel/scan/watcher.ts
16036
- import { resolve as resolve29, relative as relative5, sep as sep4 } from "path";
16143
+ import { resolve as resolve29, relative as relative5, sep as sep5 } from "path";
16037
16144
  import chokidar from "chokidar";
16038
16145
  function createChokidarWatcher(opts) {
16039
16146
  const absRoots = opts.roots.map((r) => resolve29(opts.cwd, r));
@@ -16131,8 +16238,8 @@ function relativePathFromRoots2(absolute, absRoots) {
16131
16238
  for (const root of absRoots) {
16132
16239
  const rel = relative5(root, absolute);
16133
16240
  if (rel === "" || rel === ".") return "";
16134
- if (!rel.startsWith("..") && !rel.startsWith(`..${sep4}`)) {
16135
- return rel.split(sep4).join("/");
16241
+ if (!rel.startsWith("..") && !rel.startsWith(`..${sep5}`)) {
16242
+ return rel.split(sep5).join("/");
16136
16243
  }
16137
16244
  }
16138
16245
  return null;
@@ -16254,13 +16361,13 @@ function createKernel() {
16254
16361
  }
16255
16362
 
16256
16363
  // kernel/jobs/orphan-files.ts
16257
- import { readdirSync as readdirSync8, statSync as statSync8 } from "fs";
16364
+ import { readdirSync as readdirSync8, statSync as statSync7 } from "fs";
16258
16365
  import { join as join14, resolve as resolve30 } from "path";
16259
16366
  function findOrphanJobFiles(jobsDir, referencedPaths) {
16260
16367
  let entries;
16261
16368
  try {
16262
- const stat2 = statSync8(jobsDir);
16263
- if (!stat2.isDirectory()) {
16369
+ const stat3 = statSync7(jobsDir);
16370
+ if (!stat3.isDirectory()) {
16264
16371
  return { orphanFilePaths: [], referencedCount: referencedPaths.size };
16265
16372
  }
16266
16373
  entries = readdirSync8(jobsDir, { withFileTypes: true });
@@ -16328,7 +16435,7 @@ var SCAN_RUNNER_TEXTS = {
16328
16435
  priorSchemaValidationFailed: "prior scan-result loaded from DB failed schema validation: {{errors}}. Run `sm db backup` then re-scan without --strict to rebuild from disk.",
16329
16436
  /**
16330
16437
  * Reference-paths walker hit `REFERENCE_WALK_MAX_FILES` and stopped
16331
- * early. The set may be incomplete for link validation; `core/broken-ref`
16438
+ * early. The set may be incomplete for link validation; `core/reference-broken`
16332
16439
  * still works against whatever made it in.
16333
16440
  */
16334
16441
  referenceWalkTruncated: "scan.referencePaths: walker truncated at the 50000-file safety cap. Some link targets may flag as broken even though they exist on disk. Trim the configured paths to dirs you actually need to validate against.",
@@ -16422,7 +16529,7 @@ function resolveScanRoots(inputs) {
16422
16529
  }
16423
16530
 
16424
16531
  // core/runtime/reference-paths-walker.ts
16425
- import { readdirSync as readdirSync9, statSync as statSync9 } from "fs";
16532
+ import { readdirSync as readdirSync9, statSync as statSync8 } from "fs";
16426
16533
  import { homedir as osHomedir2 } from "os";
16427
16534
  import { isAbsolute as isAbsolute8, join as join15, resolve as resolve31 } from "path";
16428
16535
  var REFERENCE_WALK_MAX_FILES = 5e4;
@@ -16444,8 +16551,8 @@ function walkReferencePaths(rawRoots, cwd) {
16444
16551
  for (const raw of rawRoots) {
16445
16552
  if (truncated) break;
16446
16553
  const root = resolveScanPath(raw, cwd);
16447
- const stat2 = safeStat(root);
16448
- if (!stat2 || !stat2.isDirectory()) {
16554
+ const stat3 = safeStat(root);
16555
+ if (!stat3 || !stat3.isDirectory()) {
16449
16556
  missingRoots.push(root);
16450
16557
  continue;
16451
16558
  }
@@ -16476,7 +16583,7 @@ function walkInto(dir, out) {
16476
16583
  }
16477
16584
  function safeStat(path) {
16478
16585
  try {
16479
- return statSync9(path);
16586
+ return statSync8(path);
16480
16587
  } catch {
16481
16588
  return null;
16482
16589
  }
@@ -16979,7 +17086,7 @@ var InitCommand = class extends SmCommand {
16979
17086
  const printer = this.printer ?? createPrinter({
16980
17087
  stdout: this.context.stdout,
16981
17088
  stderr: this.context.stderr,
16982
- quietInfo: this.quiet
17089
+ quietInfo: this.quiet || this.json
16983
17090
  });
16984
17091
  if (this.dryRun) {
16985
17092
  await writeDryRunPlan(printer, {
@@ -16994,7 +17101,7 @@ var InitCommand = class extends SmCommand {
16994
17101
  });
16995
17102
  return ExitCode.Ok;
16996
17103
  }
16997
- await mkdir3(skillMapDir, { recursive: true });
17104
+ await mkdir4(skillMapDir, { recursive: true });
16998
17105
  writeFileAtomicExclusive(settingsPath, JSON.stringify({ schemaVersion: 1 }, null, 2) + "\n");
16999
17106
  if (!await pathExists(localPath) || this.force) {
17000
17107
  writeFileAtomicExclusive(localPath, "{}\n");
@@ -17168,7 +17275,7 @@ async function runFirstScan(scopeRoot, strict, printer, stderr, stdin, ansi) {
17168
17275
  }
17169
17276
  async function previewGitignoreEntries(scopeRoot, entries) {
17170
17277
  const path = join17(scopeRoot, ".gitignore");
17171
- const body = await pathExists(path) ? await readFile2(path, "utf8") : "";
17278
+ const body = await pathExists(path) ? await readFile3(path, "utf8") : "";
17172
17279
  const present = new Set(
17173
17280
  body.split("\n").map((line) => line.trim()).filter((line) => line.length > 0 && !line.startsWith("#"))
17174
17281
  );
@@ -17178,7 +17285,7 @@ async function ensureGitignoreEntries(scopeRoot, entries) {
17178
17285
  const path = join17(scopeRoot, ".gitignore");
17179
17286
  let body = "";
17180
17287
  if (await pathExists(path)) {
17181
- body = await readFile2(path, "utf8");
17288
+ body = await readFile3(path, "utf8");
17182
17289
  }
17183
17290
  const present = new Set(
17184
17291
  body.split("\n").map((line) => line.trim()).filter((line) => line.length > 0 && !line.startsWith("#"))
@@ -17192,7 +17299,7 @@ async function ensureGitignoreEntries(scopeRoot, entries) {
17192
17299
  present.add(entry);
17193
17300
  changed = true;
17194
17301
  }
17195
- if (changed) await writeFile(path, body);
17302
+ if (changed) await writeFile2(path, body);
17196
17303
  return changed;
17197
17304
  }
17198
17305
 
@@ -18894,6 +19001,27 @@ var PLUGINS_TEXTS = {
18894
19001
  slotsListTipText: "Tip: full spec at spec/view-slots.md and spec/input-types.md."
18895
19002
  };
18896
19003
 
19004
+ // plugins/presentation-order.ts
19005
+ var BUILT_IN_BUNDLE_PRESENTATION_ORDER = [
19006
+ "core",
19007
+ "claude",
19008
+ "antigravity",
19009
+ "openai",
19010
+ "agent-skills"
19011
+ ];
19012
+ function sortBundlesForPresentation(bundles) {
19013
+ const orderIndex = (id) => {
19014
+ const idx = BUILT_IN_BUNDLE_PRESENTATION_ORDER.indexOf(id);
19015
+ return idx >= 0 ? idx : BUILT_IN_BUNDLE_PRESENTATION_ORDER.length;
19016
+ };
19017
+ return [...bundles].sort((a, b) => {
19018
+ const ai = orderIndex(a.id);
19019
+ const bi = orderIndex(b.id);
19020
+ if (ai !== bi) return ai - bi;
19021
+ return a.id.localeCompare(b.id);
19022
+ });
19023
+ }
19024
+
18897
19025
  // cli/commands/plugins/shared.ts
18898
19026
  import { resolve as resolve32 } from "path";
18899
19027
  function resolveSearchPaths2(opts, cwd) {
@@ -18923,7 +19051,7 @@ async function loadAll(opts) {
18923
19051
  return loader.discoverAndLoadAll();
18924
19052
  }
18925
19053
  function builtInRows(resolveEnabled) {
18926
- return builtInBundles.map((bundle) => {
19054
+ return sortBundlesForPresentation(builtInBundles).map((bundle) => {
18927
19055
  const bundleEnabled = resolveEnabled(bundle.id);
18928
19056
  const extensions = bundle.extensions.map((ext) => extensionRowFromBuiltIn(ext, bundle, bundleEnabled, resolveEnabled));
18929
19057
  const manifestSummary = bundle.extensions.map((ext) => `${ext.kind}:${qualifiedExtensionId(bundle.id, ext.id)}@${ext.version}`).join(", ");
@@ -18938,11 +19066,12 @@ function builtInRows(resolveEnabled) {
18938
19066
  });
18939
19067
  }
18940
19068
  function extensionRowFromBuiltIn(ext, bundle, bundleEnabled, resolveEnabled) {
19069
+ const qualifiedEnabled = resolveEnabled(qualifiedExtensionId(bundle.id, ext.id));
18941
19070
  const row = {
18942
19071
  id: ext.id,
18943
19072
  kind: ext.kind,
18944
19073
  version: ext.version,
18945
- enabled: bundle.granularity === "bundle" ? bundleEnabled : resolveEnabled(qualifiedExtensionId(bundle.id, ext.id)),
19074
+ enabled: bundle.granularity === "bundle" ? bundleEnabled && qualifiedEnabled : qualifiedEnabled,
18946
19075
  description: ext.description ?? ""
18947
19076
  };
18948
19077
  if (ext.entry !== void 0) row.entry = ext.entry;
@@ -19058,10 +19187,10 @@ function pluginToListRow(p) {
19058
19187
  }
19059
19188
  function wrapNames(names, indent, maxWidth) {
19060
19189
  const out = [];
19061
- const sep6 = ", ";
19190
+ const sep8 = ", ";
19062
19191
  let current = "";
19063
19192
  for (const name of names) {
19064
- const candidate = current === "" ? name : `${current}${sep6}${name}`;
19193
+ const candidate = current === "" ? name : `${current}${sep8}${name}`;
19065
19194
  if (indent.length + candidate.length > maxWidth && current !== "") {
19066
19195
  out.push(`${current},`);
19067
19196
  current = name;
@@ -19629,10 +19758,13 @@ function extensionInstance(ext) {
19629
19758
  }
19630
19759
  function collectKnownKinds(plugins) {
19631
19760
  const known = /* @__PURE__ */ new Set();
19632
- forEachProviderInstance(plugins, ({ instance }) => {
19761
+ forEachProviderInstance(plugins, ({ pluginId, instance }) => {
19633
19762
  const map = instance["kinds"];
19634
19763
  if (map === null || typeof map !== "object") return;
19635
- for (const k of Object.keys(map)) known.add(k);
19764
+ for (const k of Object.keys(map)) {
19765
+ known.add(k);
19766
+ known.add(qualifiedExtensionId(pluginId, k));
19767
+ }
19636
19768
  });
19637
19769
  return known;
19638
19770
  }
@@ -19881,7 +20013,7 @@ var TogglePluginsBase = class extends SmCommand {
19881
20013
  * the plugin's `scan_contributions` rows immediately (matches the
19882
20014
  * BFF route, see `server/routes/plugins.ts:applyChangeToAdapter`).
19883
20015
  * `targets` carries either a bare bundle id (e.g. `claude`) or a
19884
- * qualified `<bundle>/<ext>` (e.g. `core/slash`); the split mirrors
20016
+ * qualified `<bundle>/<ext>` (e.g. `core/slash-command`); the split mirrors
19885
20017
  * how the catalog sweep groups rows.
19886
20018
  */
19887
20019
  async #persistTargets(targets, enabled) {
@@ -20066,7 +20198,7 @@ function resolveBareToggle(id, catalogue, verb, ansi) {
20066
20198
  }
20067
20199
 
20068
20200
  // cli/commands/plugins/create.ts
20069
- import { existsSync as existsSync23, mkdirSync as mkdirSync6, writeFileSync as writeFileSync3 } from "fs";
20201
+ import { existsSync as existsSync22, mkdirSync as mkdirSync5, writeFileSync } from "fs";
20070
20202
  import { join as join18, resolve as resolve33 } from "path";
20071
20203
  import { Command as Command26, Option as Option25 } from "clipanion";
20072
20204
  var PluginsCreateCommand = class extends SmCommand {
@@ -20095,7 +20227,7 @@ var PluginsCreateCommand = class extends SmCommand {
20095
20227
  const ctx = defaultRuntimeContext();
20096
20228
  const baseDir = defaultProjectPluginsDir(ctx);
20097
20229
  const targetDir = this.at ? resolve33(this.at) : join18(baseDir, this.pluginId);
20098
- if (existsSync23(targetDir) && !this.force) {
20230
+ if (existsSync22(targetDir) && !this.force) {
20099
20231
  this.printer.error(
20100
20232
  tx(PLUGINS_TEXTS.createRefuseOverwrite, {
20101
20233
  glyph: errGlyph,
@@ -20106,7 +20238,7 @@ var PluginsCreateCommand = class extends SmCommand {
20106
20238
  return ExitCode.Error;
20107
20239
  }
20108
20240
  const extractorName = `${this.pluginId}-extractor`;
20109
- mkdirSync6(join18(targetDir, "extractors", extractorName), { recursive: true });
20241
+ mkdirSync5(join18(targetDir, "extractors", extractorName), { recursive: true });
20110
20242
  const specVersion = installedSpecVersion();
20111
20243
  const manifest = {
20112
20244
  id: this.pluginId,
@@ -20125,15 +20257,15 @@ var PluginsCreateCommand = class extends SmCommand {
20125
20257
  }
20126
20258
  }
20127
20259
  };
20128
- writeFileSync3(
20260
+ writeFileSync(
20129
20261
  join18(targetDir, "plugin.json"),
20130
20262
  JSON.stringify(manifest, null, 2) + "\n"
20131
20263
  );
20132
- writeFileSync3(
20264
+ writeFileSync(
20133
20265
  join18(targetDir, "extractors", extractorName, "index.js"),
20134
20266
  scaffolderExtractorStub(extractorName)
20135
20267
  );
20136
- writeFileSync3(join18(targetDir, "README.md"), scaffolderReadme(this.pluginId));
20268
+ writeFileSync(join18(targetDir, "README.md"), scaffolderReadme(this.pluginId));
20137
20269
  this.printer.data(
20138
20270
  tx(PLUGINS_TEXTS.createSuccess, {
20139
20271
  targetDir: sanitizeForTerminal(targetDir),
@@ -20335,7 +20467,7 @@ var PLUGIN_COMMANDS = [
20335
20467
  ];
20336
20468
 
20337
20469
  // cli/commands/refresh.ts
20338
- import { readFile as readFile3 } from "fs/promises";
20470
+ import { readFile as readFile4 } from "fs/promises";
20339
20471
  import { resolve as resolve34 } from "path";
20340
20472
  import { Command as Command29, Option as Option27 } from "clipanion";
20341
20473
 
@@ -20642,7 +20774,7 @@ var RefreshCommand = class extends SmCommand {
20642
20774
  let body;
20643
20775
  try {
20644
20776
  assertContained(cwd, node.path);
20645
- const raw = await readFile3(resolve34(cwd, node.path), "utf8");
20777
+ const raw = await readFile4(resolve34(cwd, node.path), "utf8");
20646
20778
  body = stripFrontmatterFence(raw);
20647
20779
  } catch (err) {
20648
20780
  if (!this.json) {
@@ -21491,7 +21623,7 @@ var ScanCommand = class extends SmCommand {
21491
21623
  }
21492
21624
  return null;
21493
21625
  }
21494
- /** Render the failure branch of `IScanRunResult` to stderr. */
21626
+ /** Render the failure branch of `TScanRunResult` to stderr. */
21495
21627
  renderFailure(outcome) {
21496
21628
  const ansi = this.ansiFor("stderr");
21497
21629
  const errGlyph = ansi.red("\u2715");
@@ -21570,7 +21702,7 @@ var ScanCommand = class extends SmCommand {
21570
21702
  this.printer.info(
21571
21703
  tx(SCAN_TEXTS.jsonSelfValidationFailed, {
21572
21704
  glyph: ansi.red("\u2715"),
21573
- errors: validation.errors
21705
+ errors: JSON.stringify(validation.errors, null, 2)
21574
21706
  })
21575
21707
  );
21576
21708
  return ExitCode.Error;
@@ -21591,7 +21723,7 @@ function plural(count, word) {
21591
21723
  }
21592
21724
 
21593
21725
  // cli/commands/scan-compare.ts
21594
- import { existsSync as existsSync24, readFileSync as readFileSync18 } from "fs";
21726
+ import { access, readFile as readFile5 } from "fs/promises";
21595
21727
  import { Command as Command32, Option as Option30 } from "clipanion";
21596
21728
  var ScanCompareCommand = class extends SmCommand {
21597
21729
  static paths = [["scan", "compare-with"]];
@@ -21644,7 +21776,7 @@ var ScanCompareCommand = class extends SmCommand {
21644
21776
  const roots = this.roots.length > 0 ? this.roots : ["."];
21645
21777
  let prior;
21646
21778
  try {
21647
- prior = loadAndValidateDump(this.dump);
21779
+ prior = await loadAndValidateDump(this.dump);
21648
21780
  } catch (err) {
21649
21781
  const message = formatErrorMessage(err);
21650
21782
  this.printer.info(tx(SCAN_TEXTS.compareErrorPrefix, { message }));
@@ -21702,13 +21834,15 @@ var ScanCompareCommand = class extends SmCommand {
21702
21834
  return exitCode2;
21703
21835
  }
21704
21836
  };
21705
- function loadAndValidateDump(path) {
21706
- if (!existsSync24(path)) {
21837
+ async function loadAndValidateDump(path) {
21838
+ try {
21839
+ await access(path);
21840
+ } catch {
21707
21841
  throw new Error(tx(SCAN_TEXTS.compareDumpNotFound, { path }));
21708
21842
  }
21709
21843
  let raw;
21710
21844
  try {
21711
- raw = readFileSync18(path, "utf8");
21845
+ raw = await readFile5(path, "utf8");
21712
21846
  } catch (err) {
21713
21847
  const message = formatErrorMessage(err);
21714
21848
  throw new Error(tx(SCAN_TEXTS.compareDumpReadFailed, { path, message }), { cause: err });
@@ -21833,9 +21967,21 @@ function renderDeltaIssues(issues) {
21833
21967
 
21834
21968
  // cli/commands/serve.ts
21835
21969
  import { spawn as spawn2 } from "child_process";
21836
- import { existsSync as existsSync30 } from "fs";
21970
+ import { existsSync as existsSync28 } from "fs";
21837
21971
  import { Command as Command33, Option as Option31 } from "clipanion";
21838
21972
 
21973
+ // kernel/util/dev-mode.ts
21974
+ import { sep as sep6 } from "path";
21975
+ import { fileURLToPath as fileURLToPath5 } from "url";
21976
+ var SELF_PATH = fileURLToPath5(import.meta.url);
21977
+ var IS_DEV_BUILD = isDevBuildFromPath(SELF_PATH, sep6);
21978
+ function isDevBuildFromPath(filePath, separator = sep6) {
21979
+ return !filePath.includes(`${separator}node_modules${separator}`);
21980
+ }
21981
+ function isDevBuild() {
21982
+ return IS_DEV_BUILD;
21983
+ }
21984
+
21839
21985
  // cli/util/browser-launch.ts
21840
21986
  function validateBrowserUrl(url) {
21841
21987
  if (typeof url !== "string" || url.length === 0) return false;
@@ -22459,7 +22605,7 @@ function registerFavoritesRoutes(app, deps) {
22459
22605
  );
22460
22606
  if (!result || !result.found) {
22461
22607
  throw new HTTPException4(404, {
22462
- message: tx(SERVER_TEXTS.nodeNotFound, { path: nodePath })
22608
+ message: tx(SERVER_TEXTS.nodeNotFound, { path: sanitizeForTerminal(nodePath) })
22463
22609
  });
22464
22610
  }
22465
22611
  return c.body(null, 204);
@@ -22546,17 +22692,21 @@ function contentTypeFor(format) {
22546
22692
  }
22547
22693
 
22548
22694
  // server/health.ts
22549
- import { existsSync as existsSync25 } from "fs";
22695
+ import { existsSync as existsSync23 } from "fs";
22550
22696
  var FALLBACK_SCHEMA_VERSION = "1";
22551
22697
  function buildHealth(deps) {
22698
+ const dev = isDevBuild();
22552
22699
  return {
22553
22700
  ok: true,
22554
22701
  schemaVersion: FALLBACK_SCHEMA_VERSION,
22555
22702
  specVersion: deps.specVersion,
22556
22703
  implVersion: VERSION,
22557
- db: existsSync25(deps.dbPath) ? "present" : "missing",
22704
+ db: existsSync23(deps.dbPath) ? "present" : "missing",
22558
22705
  cwd: deps.cwd,
22559
- dbPath: deps.dbPath
22706
+ dbPath: deps.dbPath,
22707
+ // Only emit when truthy so a published install keeps the wire
22708
+ // shape lean and consumers branch on presence alone.
22709
+ ...dev ? { dev: true } : {}
22560
22710
  };
22561
22711
  }
22562
22712
  var cachedSpecVersion = null;
@@ -22586,47 +22736,60 @@ function registerHealthRoute(app, deps) {
22586
22736
  });
22587
22737
  }
22588
22738
 
22589
- // server/routes/issues.ts
22739
+ // server/limits.ts
22590
22740
  var DEFAULT_LIMIT = 100;
22591
22741
  var MAX_LIMIT = 1e3;
22742
+ var BFF_MAX_BULK_CONTRIBUTIONS = 200;
22743
+
22744
+ // server/routes/issues.ts
22592
22745
  function registerIssuesRoute(app, deps) {
22593
22746
  app.get("/api/issues", async (c) => {
22594
- const severityFilter = parseCsv(c.req.query("severity"));
22595
- const analyzerFilter = parseCsv(c.req.query("analyzerId"));
22596
- const nodePath = c.req.query("node") ?? null;
22597
- const { offset, limit } = parsePagination(c.req.query(), {
22598
- limit: DEFAULT_LIMIT,
22599
- max: MAX_LIMIT
22600
- });
22747
+ const inputs = parseIssuesQuery(c.req.query());
22601
22748
  const result = await tryWithSqlite(
22602
22749
  { databasePath: deps.options.dbPath, autoBackup: false },
22603
- (adapter) => adapter.issues.list({
22604
- severities: severityFilter,
22605
- analyzerIds: analyzerFilter,
22606
- nodePath,
22607
- offset,
22608
- limit
22609
- })
22750
+ (adapter) => adapter.issues.list(inputs.filter)
22610
22751
  );
22611
- const items = result?.items ?? [];
22612
- const total = result?.total ?? 0;
22613
22752
  return c.json(
22614
22753
  buildListEnvelope({
22615
22754
  kind: "issues",
22616
- items,
22617
- filters: {
22618
- severity: severityFilter.length > 0 ? severityFilter : null,
22619
- analyzerId: analyzerFilter.length > 0 ? analyzerFilter : null,
22620
- node: nodePath
22621
- },
22622
- total,
22623
- page: { offset, limit },
22755
+ items: result?.items ?? [],
22756
+ filters: inputs.echo,
22757
+ total: result?.total ?? 0,
22758
+ page: { offset: inputs.filter.offset, limit: inputs.filter.limit },
22624
22759
  kindRegistry: deps.kindRegistry,
22625
22760
  contributionsRegistry: deps.contributionsRegistry
22626
22761
  })
22627
22762
  );
22628
22763
  });
22629
22764
  }
22765
+ function parseIssuesQuery(query) {
22766
+ const severityFilter = parseCsv(query["severity"]);
22767
+ const analyzerFilter = parseCsv(query["analyzerId"]);
22768
+ const nodePath = query["node"] ?? null;
22769
+ const nodesRaw = parseCsv(query["nodes"]);
22770
+ const nodesFilter = nodesRaw.length > 0 ? nodesRaw : null;
22771
+ const { offset, limit } = parsePagination(query, {
22772
+ limit: DEFAULT_LIMIT,
22773
+ max: MAX_LIMIT
22774
+ });
22775
+ const filter = {
22776
+ severities: severityFilter,
22777
+ analyzerIds: analyzerFilter,
22778
+ nodePath,
22779
+ offset,
22780
+ limit
22781
+ };
22782
+ if (nodesFilter) filter.nodePaths = nodesFilter;
22783
+ return {
22784
+ filter,
22785
+ echo: {
22786
+ severity: severityFilter.length > 0 ? severityFilter : null,
22787
+ analyzerId: analyzerFilter.length > 0 ? analyzerFilter : null,
22788
+ node: nodePath,
22789
+ nodes: nodesFilter
22790
+ }
22791
+ };
22792
+ }
22630
22793
 
22631
22794
  // server/routes/links.ts
22632
22795
  function registerLinksRoute(app, deps) {
@@ -22666,22 +22829,30 @@ function registerLinksRoute(app, deps) {
22666
22829
  import { HTTPException as HTTPException6 } from "hono/http-exception";
22667
22830
 
22668
22831
  // server/node-body.ts
22669
- import { readFile as readFile4 } from "fs/promises";
22670
- import { isAbsolute as isAbsolute10, resolve as resolvePath2, relative as relativePath, sep as sep5 } from "path";
22832
+ import { constants as fsConstants2 } from "fs";
22833
+ import { open } from "fs/promises";
22834
+ import { isAbsolute as isAbsolute10, resolve as resolvePath2, relative as relativePath, sep as sep7 } from "path";
22671
22835
  async function readNodeBody(cwd, relPath) {
22672
22836
  if (isAbsolute10(relPath)) return null;
22673
22837
  const absRoot = resolvePath2(cwd);
22674
22838
  const absFile = resolvePath2(absRoot, relPath);
22675
22839
  const rel = relativePath(absRoot, absFile);
22676
- if (rel.startsWith("..") || rel.startsWith(sep5) || rel.length === 0) {
22840
+ if (rel.startsWith("..") || rel.startsWith(sep7) || rel.length === 0) {
22677
22841
  return null;
22678
22842
  }
22679
22843
  let raw;
22844
+ let handle = null;
22680
22845
  try {
22681
- raw = await readFile4(absFile, "utf-8");
22846
+ handle = await open(absFile, fsConstants2.O_RDONLY | fsConstants2.O_NOFOLLOW);
22847
+ raw = await handle.readFile("utf-8");
22682
22848
  } catch (err) {
22683
22849
  if (isExpectedFsError(err)) return null;
22684
22850
  throw err;
22851
+ } finally {
22852
+ if (handle !== null) {
22853
+ await handle.close().catch(() => {
22854
+ });
22855
+ }
22685
22856
  }
22686
22857
  return stripFrontmatter(raw);
22687
22858
  }
@@ -22691,7 +22862,16 @@ function stripFrontmatter(raw) {
22691
22862
  if (!match) return raw;
22692
22863
  return raw.slice(match[0].length);
22693
22864
  }
22694
- var EXPECTED_FS_ERROR_CODES = /* @__PURE__ */ new Set(["ENOENT", "EACCES", "EISDIR", "ENOTDIR"]);
22865
+ var EXPECTED_FS_ERROR_CODES = /* @__PURE__ */ new Set([
22866
+ "ENOENT",
22867
+ "EACCES",
22868
+ "EISDIR",
22869
+ "ENOTDIR",
22870
+ // `O_NOFOLLOW` opens fail with `ELOOP` on Linux / macOS when the
22871
+ // leaf is a symlink. Treat the same as "body unavailable" so the
22872
+ // BFF returns null, not a 500.
22873
+ "ELOOP"
22874
+ ]);
22695
22875
  function isExpectedFsError(err) {
22696
22876
  if (err === null || typeof err !== "object") return false;
22697
22877
  const code = err.code;
@@ -22749,9 +22929,6 @@ function splitCsv(raw) {
22749
22929
  }
22750
22930
 
22751
22931
  // server/routes/nodes.ts
22752
- var DEFAULT_LIMIT2 = 100;
22753
- var MAX_LIMIT2 = 1e3;
22754
- var BFF_MAX_BULK_CONTRIBUTIONS = 200;
22755
22932
  function registerNodesRoutes(app, deps) {
22756
22933
  app.get("/api/nodes/:pathB64", async (c) => {
22757
22934
  const pathB64 = c.req.param("pathB64");
@@ -22813,8 +22990,8 @@ function registerNodesRoutes(app, deps) {
22813
22990
  const params = new URL(c.req.url).searchParams;
22814
22991
  const { query, filters } = urlParamsToExportQuery(params);
22815
22992
  const { offset, limit } = parsePagination(c.req.query(), {
22816
- limit: DEFAULT_LIMIT2,
22817
- max: MAX_LIMIT2
22993
+ limit: DEFAULT_LIMIT,
22994
+ max: MAX_LIMIT
22818
22995
  });
22819
22996
  const opened = await tryWithSqlite(
22820
22997
  { databasePath: deps.options.dbPath, autoBackup: false },
@@ -23100,7 +23277,7 @@ function listItems(deps, resolveEnabled) {
23100
23277
  ];
23101
23278
  }
23102
23279
  function buildBuiltInItems(resolveEnabled) {
23103
- return builtInBundles.map((bundle) => {
23280
+ return sortBundlesForPresentation(builtInBundles).map((bundle) => {
23104
23281
  const bundleEnabled = resolveEnabled(bundle.id);
23105
23282
  const bundleLocked = isPluginLocked(bundle.id);
23106
23283
  const extensions = bundle.extensions.map((ext) => {
@@ -23389,15 +23566,15 @@ var parsePatchBody2 = makeBodyValidator(PATCH_BODY_SCHEMA, {
23389
23566
  import { HTTPException as HTTPException10 } from "hono/http-exception";
23390
23567
 
23391
23568
  // server/util/skillmapignore-io.ts
23392
- import { existsSync as existsSync26, readFileSync as readFileSync19, writeFileSync as writeFileSync4 } from "fs";
23569
+ import { existsSync as existsSync24, readFileSync as readFileSync17, writeFileSync as writeFileSync2 } from "fs";
23393
23570
  import { resolve as resolve35 } from "path";
23394
23571
  var IGNORE_FILENAME2 = ".skillmapignore";
23395
23572
  function readPatterns(cwd) {
23396
23573
  const path = resolve35(cwd, IGNORE_FILENAME2);
23397
- if (!existsSync26(path)) return [];
23574
+ if (!existsSync24(path)) return [];
23398
23575
  let raw;
23399
23576
  try {
23400
- raw = readFileSync19(path, "utf8");
23577
+ raw = readFileSync17(path, "utf8");
23401
23578
  } catch {
23402
23579
  return [];
23403
23580
  }
@@ -23405,13 +23582,13 @@ function readPatterns(cwd) {
23405
23582
  }
23406
23583
  function writePatterns(cwd, nextPatterns) {
23407
23584
  const path = resolve35(cwd, IGNORE_FILENAME2);
23408
- const prior = existsSync26(path) ? safeRead(path) : "";
23585
+ const prior = existsSync24(path) ? safeRead(path) : "";
23409
23586
  const content = buildContent(prior, nextPatterns);
23410
- writeFileSync4(path, content, "utf8");
23587
+ writeFileSync2(path, content, "utf8");
23411
23588
  }
23412
23589
  function safeRead(path) {
23413
23590
  try {
23414
- return readFileSync19(path, "utf8");
23591
+ return readFileSync17(path, "utf8");
23415
23592
  } catch {
23416
23593
  return "";
23417
23594
  }
@@ -23566,7 +23743,7 @@ var parsePatchBody3 = makeBodyValidator(PATCH_BODY_SCHEMA2, {
23566
23743
  });
23567
23744
 
23568
23745
  // server/routes/project-preferences.ts
23569
- import { statSync as statSync10 } from "fs";
23746
+ import { statSync as statSync9 } from "fs";
23570
23747
  import { HTTPException as HTTPException11 } from "hono/http-exception";
23571
23748
  function registerProjectPreferencesRoute(app, deps) {
23572
23749
  app.get("/api/project-preferences", (c) => {
@@ -23715,7 +23892,7 @@ function formatPathDetail(path, cwd) {
23715
23892
  function isExistingDirectory(entry, cwd) {
23716
23893
  const abs = resolveScanPath(entry, cwd);
23717
23894
  try {
23718
- return statSync10(abs).isDirectory();
23895
+ return statSync9(abs).isDirectory();
23719
23896
  } catch {
23720
23897
  return false;
23721
23898
  }
@@ -23755,7 +23932,7 @@ var parsePatchBody4 = makeBodyValidator(PATCH_BODY_SCHEMA3, {
23755
23932
  });
23756
23933
 
23757
23934
  // server/routes/active-provider.ts
23758
- import { existsSync as existsSync27 } from "fs";
23935
+ import { existsSync as existsSync25 } from "fs";
23759
23936
  import { HTTPException as HTTPException12 } from "hono/http-exception";
23760
23937
  function registerActiveProviderRoute(app, deps) {
23761
23938
  app.get("/api/active-provider", (c) => {
@@ -23788,7 +23965,7 @@ function applyLensSwitch(deps, newValue) {
23788
23965
  });
23789
23966
  }
23790
23967
  const dbPath = resolveDbPath({ db: void 0, cwd });
23791
- if (!existsSync27(dbPath)) return { dropped: null };
23968
+ if (!existsSync25(dbPath)) return { dropped: null };
23792
23969
  const dropResult = dropScanZone(dbPath);
23793
23970
  return {
23794
23971
  dropped: {
@@ -24180,7 +24357,7 @@ function registerSidecarRoutes(app, deps) {
24180
24357
  assertContained(deps.runtimeContext.cwd, node.path);
24181
24358
  absPath = resolve36(deps.runtimeContext.cwd, node.path);
24182
24359
  } catch (err) {
24183
- throw new HTTPException14(500, { message: formatErrorMessage(err) });
24360
+ throw new HTTPException14(400, { message: formatErrorMessage(err) });
24184
24361
  }
24185
24362
  const result = invokeBump2(node, absPath, body);
24186
24363
  if (result.report.ok === false && result.report.reason === "fresh") {
@@ -24255,12 +24432,12 @@ async function loadNode(deps, nodePath) {
24255
24432
  return node;
24256
24433
  }
24257
24434
  function invokeBump2(node, absPath, body) {
24258
- if (!bumpAction.invoke) {
24435
+ if (!nodeBumpAction.invoke) {
24259
24436
  throw new HTTPException14(500, { message: SERVER_TEXTS.sidecarBumpInvokeMissing });
24260
24437
  }
24261
24438
  const input = {};
24262
24439
  if (body.force === true) input.force = true;
24263
- return bumpAction.invoke(input, {
24440
+ return nodeBumpAction.invoke(input, {
24264
24441
  node,
24265
24442
  nodeAbsolutePath: absPath,
24266
24443
  invoker: "ui",
@@ -24302,8 +24479,8 @@ function registerUpdateStatusRoute(app, deps) {
24302
24479
  }
24303
24480
 
24304
24481
  // server/static.ts
24305
- import { existsSync as existsSync28 } from "fs";
24306
- import { readFile as readFile5 } from "fs/promises";
24482
+ import { existsSync as existsSync26 } from "fs";
24483
+ import { readFile as readFile6 } from "fs/promises";
24307
24484
  import { extname, join as join19 } from "path";
24308
24485
  import { serveStatic } from "@hono/node-server/serve-static";
24309
24486
  var INDEX_HTML = "index.html";
@@ -24357,7 +24534,7 @@ function createSpaFallback(opts) {
24357
24534
  if (c.req.method !== "GET" && c.req.method !== "HEAD") return c.notFound();
24358
24535
  if (opts.uiDist === null) return htmlResponse(c, placeholder);
24359
24536
  const indexPath = join19(opts.uiDist, INDEX_HTML);
24360
- if (!existsSync28(indexPath)) return htmlResponse(c, placeholder);
24537
+ if (!existsSync26(indexPath)) return htmlResponse(c, placeholder);
24361
24538
  return fileResponse(c, indexPath);
24362
24539
  };
24363
24540
  }
@@ -24399,7 +24576,7 @@ function htmlResponse(c, html) {
24399
24576
  return c.body(html, 200, { "content-type": "text/html; charset=UTF-8" });
24400
24577
  }
24401
24578
  async function fileResponse(c, absPath) {
24402
- const buf = await readFile5(absPath);
24579
+ const buf = await readFile6(absPath);
24403
24580
  return c.body(buf, 200, { "content-type": mimeFor(absPath) });
24404
24581
  }
24405
24582
 
@@ -24929,9 +25106,9 @@ function validateNoUi(noUi, uiDist) {
24929
25106
  }
24930
25107
 
24931
25108
  // server/paths.ts
24932
- import { existsSync as existsSync29, statSync as statSync11 } from "fs";
25109
+ import { existsSync as existsSync27, statSync as statSync10 } from "fs";
24933
25110
  import { dirname as dirname18, isAbsolute as isAbsolute11, join as join20, resolve as resolve37 } from "path";
24934
- import { fileURLToPath as fileURLToPath5 } from "url";
25111
+ import { fileURLToPath as fileURLToPath6 } from "url";
24935
25112
  var DEFAULT_UI_REL = join20("ui", "dist", "ui", "browser");
24936
25113
  var PACKAGE_UI_REL = "ui";
24937
25114
  var INDEX_HTML2 = "index.html";
@@ -24944,10 +25121,10 @@ function resolveExplicitUiDist(ctx, raw) {
24944
25121
  return isAbsolute11(raw) ? raw : resolve37(ctx.cwd, raw);
24945
25122
  }
24946
25123
  function isUiBundleDir(path) {
24947
- if (!existsSync29(path)) return false;
25124
+ if (!existsSync27(path)) return false;
24948
25125
  try {
24949
- if (!statSync11(path).isDirectory()) return false;
24950
- return existsSync29(join20(path, INDEX_HTML2));
25126
+ if (!statSync10(path).isDirectory()) return false;
25127
+ return existsSync27(join20(path, INDEX_HTML2));
24951
25128
  } catch {
24952
25129
  return false;
24953
25130
  }
@@ -24955,7 +25132,7 @@ function isUiBundleDir(path) {
24955
25132
  function resolvePackageBundledUi() {
24956
25133
  let here;
24957
25134
  try {
24958
- here = dirname18(fileURLToPath5(import.meta.url));
25135
+ here = dirname18(fileURLToPath6(import.meta.url));
24959
25136
  } catch {
24960
25137
  return null;
24961
25138
  }
@@ -25247,7 +25424,9 @@ var ESC2 = {
25247
25424
  /** 256-color violet (xterm 141). */
25248
25425
  violet: "\x1B[38;5;141m",
25249
25426
  /** 256-color green (xterm 42). */
25250
- green: "\x1B[38;5;42m"
25427
+ green: "\x1B[38;5;42m",
25428
+ /** 256-color yellow (xterm 214), matches `cli/util/ansi.ts:yellow`. */
25429
+ yellow: "\x1B[38;5;214m"
25251
25430
  };
25252
25431
  var LOGO_LINES = [
25253
25432
  " ____ _ _ _ _ __ __ ",
@@ -25267,7 +25446,8 @@ function renderBanner(input) {
25267
25446
  host: input.host,
25268
25447
  port: input.port,
25269
25448
  dbPath: input.dbPath,
25270
- openBrowser: input.openBrowser
25449
+ openBrowser: input.openBrowser,
25450
+ dev: input.dev === true
25271
25451
  });
25272
25452
  }
25273
25453
  return renderFiglet({
@@ -25277,7 +25457,8 @@ function renderBanner(input) {
25277
25457
  pathDisplay: formatCwdPath(input.cwd),
25278
25458
  browserLine,
25279
25459
  colorEnabled: input.colorEnabled,
25280
- referencePaths: input.referencePaths ?? []
25460
+ referencePaths: input.referencePaths ?? [],
25461
+ dev: input.dev === true
25281
25462
  });
25282
25463
  }
25283
25464
  function resolveColorEnabled(opts) {
@@ -25292,8 +25473,9 @@ function renderFlat(input) {
25292
25473
  const safeHost = sanitizeForTerminal(input.host);
25293
25474
  const safeDb = sanitizeForTerminal(input.dbPath);
25294
25475
  const url = `http://${safeHost}:${input.port}`;
25476
+ const devSuffix = input.dev ? " [dev]" : "";
25295
25477
  const linesOut = [];
25296
- linesOut.push(`sm serve: listening on ${url} (db=${safeDb})`);
25478
+ linesOut.push(`sm serve${devSuffix}: listening on ${url} (db=${safeDb})`);
25297
25479
  if (input.openBrowser) {
25298
25480
  linesOut.push(`sm serve: opening ${url}/ in your browser. Press Ctrl+C to stop.`);
25299
25481
  } else {
@@ -25320,12 +25502,16 @@ function renderFiglet(input) {
25320
25502
  greenUnderline,
25321
25503
  greenUnderlineClose,
25322
25504
  violetOpen,
25323
- violetClose
25505
+ violetClose,
25506
+ yellowOpen,
25507
+ yellowClose
25324
25508
  } = resolveAnsi(input.colorEnabled);
25325
25509
  const logoLines = LOGO_LINES.map((line) => `${violetOpen}${line}${violetClose}`);
25326
25510
  const versionText = `v${input.version}`;
25327
- const versionPad = Math.max(0, LOGO_WIDTH - versionText.length);
25328
- const versionLine = `${" ".repeat(versionPad)}${dimOpen}${versionText}${dimClose}`;
25511
+ const devText = "[dev]";
25512
+ const versionWidth = input.dev ? devText.length : versionText.length;
25513
+ const versionPad = Math.max(0, LOGO_WIDTH - versionWidth);
25514
+ const versionLine = input.dev ? `${" ".repeat(versionPad)}${yellowOpen}${devText}${yellowClose}` : `${" ".repeat(versionPad)}${dimOpen}${versionText}${dimClose}`;
25329
25515
  const lines = [];
25330
25516
  lines.push(...logoLines);
25331
25517
  lines.push("");
@@ -25357,7 +25543,9 @@ var EMPTY_ANSI = {
25357
25543
  greenUnderline: "",
25358
25544
  greenUnderlineClose: "",
25359
25545
  violetOpen: "",
25360
- violetClose: ""
25546
+ violetClose: "",
25547
+ yellowOpen: "",
25548
+ yellowClose: ""
25361
25549
  };
25362
25550
  var ENABLED_ANSI = {
25363
25551
  dimOpen: ESC2.dim,
@@ -25365,7 +25553,9 @@ var ENABLED_ANSI = {
25365
25553
  greenUnderline: `${ESC2.green}${ESC2.underline}`,
25366
25554
  greenUnderlineClose: ESC2.reset,
25367
25555
  violetOpen: ESC2.violet,
25368
- violetClose: ESC2.reset
25556
+ violetClose: ESC2.reset,
25557
+ yellowOpen: ESC2.yellow,
25558
+ yellowClose: ESC2.reset
25369
25559
  };
25370
25560
  function resolveAnsi(colorEnabled) {
25371
25561
  return colorEnabled ? ENABLED_ANSI : EMPTY_ANSI;
@@ -25478,7 +25668,7 @@ var ServeCommand = class extends SmCommand {
25478
25668
  return ExitCode.Error;
25479
25669
  }
25480
25670
  const dbPath = resolveDbPath({ db: this.db, ...runtimeCtx });
25481
- if (this.db !== void 0 && !existsSync30(dbPath)) {
25671
+ if (this.db !== void 0 && !existsSync28(dbPath)) {
25482
25672
  this.printer.info(
25483
25673
  tx(SERVE_TEXTS.dbNotFound, {
25484
25674
  glyph: errGlyph,
@@ -25586,7 +25776,8 @@ var ServeCommand = class extends SmCommand {
25586
25776
  openBrowser: validation.options.open,
25587
25777
  isTTY,
25588
25778
  colorEnabled,
25589
- referencePaths
25779
+ referencePaths,
25780
+ dev: isDevBuild()
25590
25781
  })
25591
25782
  );
25592
25783
  if (validation.options.open) {
@@ -26028,7 +26219,7 @@ function rankConfidenceForGrouping(c) {
26028
26219
  }
26029
26220
 
26030
26221
  // cli/commands/sidecar.ts
26031
- import { existsSync as existsSync31, unlinkSync as unlinkSync2 } from "fs";
26222
+ import { unlink as unlink3 } from "fs/promises";
26032
26223
  import { resolve as resolve38 } from "path";
26033
26224
  import { Command as Command35, Option as Option33 } from "clipanion";
26034
26225
 
@@ -26320,7 +26511,7 @@ var SidecarPruneCommand = class extends SmCommand {
26320
26511
  continue;
26321
26512
  }
26322
26513
  try {
26323
- unlinkSync2(orphan.sidecarPath);
26514
+ await unlink3(orphan.sidecarPath);
26324
26515
  items.push({
26325
26516
  sidecarPath: orphan.sidecarPath,
26326
26517
  expectedMd: orphan.expectedMdPath,
@@ -26477,7 +26668,8 @@ var SidecarAnnotateCommand = class extends SmCommand {
26477
26668
  return ExitCode.Error;
26478
26669
  }
26479
26670
  const sidecarAbsPath = sidecarPathFor(absPath);
26480
- if (existsSync31(sidecarAbsPath) && this.force !== true) {
26671
+ const sidecarExists = await pathExists(sidecarAbsPath);
26672
+ if (sidecarExists && this.force !== true) {
26481
26673
  this.printer.error(
26482
26674
  tx(SIDECAR_TEXTS.annotateExists, {
26483
26675
  glyph: errGlyph,
@@ -26487,9 +26679,9 @@ var SidecarAnnotateCommand = class extends SmCommand {
26487
26679
  );
26488
26680
  return ExitCode.Error;
26489
26681
  }
26490
- if (existsSync31(sidecarAbsPath) && this.force === true) {
26682
+ if (sidecarExists && this.force === true) {
26491
26683
  try {
26492
- unlinkSync2(sidecarAbsPath);
26684
+ await unlink3(sidecarAbsPath);
26493
26685
  } catch (err) {
26494
26686
  this.printer.error(
26495
26687
  tx(SIDECAR_TEXTS.annotateFailed, { glyph: errGlyph, message: formatErrorMessage(err) })
@@ -26717,9 +26909,9 @@ var STUB_COMMANDS = [
26717
26909
  ];
26718
26910
 
26719
26911
  // cli/commands/tutorial.ts
26720
- import { cpSync as cpSync2, existsSync as existsSync32, mkdirSync as mkdirSync7, rmSync as rmSync2, statSync as statSync12 } from "fs";
26912
+ import { cpSync as cpSync2, existsSync as existsSync29, mkdirSync as mkdirSync6, rmSync as rmSync2, statSync as statSync11 } from "fs";
26721
26913
  import { dirname as dirname19, join as join21, resolve as resolve39 } from "path";
26722
- import { fileURLToPath as fileURLToPath6 } from "url";
26914
+ import { fileURLToPath as fileURLToPath7 } from "url";
26723
26915
  import { Command as Command37, Option as Option35 } from "clipanion";
26724
26916
 
26725
26917
  // cli/i18n/tutorial.texts.ts
@@ -26816,7 +27008,7 @@ var TutorialCommand = class extends SmCommand {
26816
27008
  const spec = VARIANT_SPECS[variant];
26817
27009
  const targetDir = join21(ctx.cwd, ".claude", "skills", spec.slug);
26818
27010
  const targetDisplay = `.claude/skills/${spec.slug}/`;
26819
- if (existsSync32(targetDir) && !this.force) {
27011
+ if (existsSync29(targetDir) && !this.force) {
26820
27012
  this.printer.error(
26821
27013
  tx(TUTORIAL_TEXTS.alreadyExists, {
26822
27014
  glyph: errGlyph,
@@ -26842,7 +27034,7 @@ var TutorialCommand = class extends SmCommand {
26842
27034
  }
26843
27035
  try {
26844
27036
  rmSync2(targetDir, { recursive: true, force: true });
26845
- mkdirSync7(dirname19(targetDir), { recursive: true });
27037
+ mkdirSync6(dirname19(targetDir), { recursive: true });
26846
27038
  cpSync2(sourceDir, targetDir, { recursive: true });
26847
27039
  } catch (err) {
26848
27040
  this.printer.error(
@@ -26889,7 +27081,7 @@ function resolveSkillSourceDir(variant) {
26889
27081
  const cached = cachedSourceDirs.get(variant);
26890
27082
  if (cached !== void 0) return cached;
26891
27083
  const spec = VARIANT_SPECS[variant];
26892
- const here = dirname19(fileURLToPath6(import.meta.url));
27084
+ const here = dirname19(fileURLToPath7(import.meta.url));
26893
27085
  const candidates = [
26894
27086
  // dev: src/cli/commands/ → repo-root .claude/skills/<slug>/
26895
27087
  resolve39(here, "../../..", spec.sourceDir),
@@ -26899,7 +27091,7 @@ function resolveSkillSourceDir(variant) {
26899
27091
  resolve39(here, "../cli/tutorial", spec.slug)
26900
27092
  ];
26901
27093
  for (const candidate of candidates) {
26902
- if (existsSync32(candidate) && statSync12(candidate).isDirectory()) {
27094
+ if (existsSync29(candidate) && statSync11(candidate).isDirectory()) {
26903
27095
  cachedSourceDirs.set(variant, candidate);
26904
27096
  return candidate;
26905
27097
  }
@@ -26937,6 +27129,7 @@ var VersionCommand = class extends SmCommand {
26937
27129
  const kernelVersion = VERSION;
26938
27130
  const specVersion = await resolveSpecVersion3();
26939
27131
  const dbSchema = await resolveDbSchemaVersion();
27132
+ const dev = isDevBuild();
26940
27133
  if (this.json) {
26941
27134
  const payload = {
26942
27135
  sm: VERSION,
@@ -26944,17 +27137,19 @@ var VersionCommand = class extends SmCommand {
26944
27137
  spec: specVersion,
26945
27138
  dbSchema
26946
27139
  };
27140
+ if (dev) payload["dev"] = true;
26947
27141
  this.printer.data(JSON.stringify(payload) + "\n");
26948
27142
  return ExitCode.Ok;
26949
27143
  }
27144
+ const ansi = this.ansiFor("stdout");
27145
+ const smValue = dev ? `${VERSION} ${ansi.yellow("[dev]")}` : VERSION;
26950
27146
  const lines = [
26951
- ["sm", VERSION],
27147
+ ["sm", smValue],
26952
27148
  ["kernel", kernelVersion],
26953
27149
  ["spec", specVersion],
26954
27150
  ["runtime", runtime],
26955
27151
  ["db-schema", dbSchema]
26956
27152
  ];
26957
- const ansi = this.ansiFor("stdout");
26958
27153
  const pad = Math.max(...lines.map(([k]) => k.length));
26959
27154
  for (const [k, v] of lines) {
26960
27155
  this.printer.data(
@@ -27076,7 +27271,7 @@ await lifecycleDispatcher.dispatch(
27076
27271
  process.exit(exitCode);
27077
27272
  function resolveBareDefault() {
27078
27273
  const ctx = defaultRuntimeContext();
27079
- if (existsSync33(defaultProjectDbPath(ctx))) {
27274
+ if (existsSync30(defaultProjectDbPath(ctx))) {
27080
27275
  return ["serve"];
27081
27276
  }
27082
27277
  const stderr = process.stderr;