@skill-map/cli 0.22.0 → 0.23.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
@@ -606,7 +606,7 @@ var geminiProvider = {
606
606
  // registry entries (they ship later under the Gemini bundle), but
607
607
  // the qualified form is the contract.
608
608
  //
609
- // UI presentation: kind visuals are normalised across Providers every
609
+ // UI presentation: kind visuals are normalised across Providers, every
610
610
  // Provider that contributes `agent` declares the same color + icon as
611
611
  // Claude, every Provider that contributes `skill` declares the same
612
612
  // color + icon as Claude, etc. The declaration STAYS per-Provider (the
@@ -665,7 +665,7 @@ var agentSkillsProvider = {
665
665
  pluginId: "agent-skills",
666
666
  kind: "provider",
667
667
  version: "1.0.0",
668
- description: "Walks the open-standard `.agents/skills/<name>/SKILL.md` convention (Anthropic / OpenAI / Google).",
668
+ description: "Agent Skills open standard. Vendor-neutral path `.agents/skills/<name>/SKILL.md` (Anthropic, OpenAI, Google). See agentskills.io.",
669
669
  stability: "stable",
670
670
  read: { extensions: [".md"], parser: "frontmatter-yaml" },
671
671
  kinds: {
@@ -706,7 +706,7 @@ var coreMarkdownProvider = {
706
706
  pluginId: "core",
707
707
  kind: "provider",
708
708
  version: "1.0.0",
709
- description: "Universal `.md` fallback \u2014 claims any markdown file no vendor-specific Provider classifies.",
709
+ description: "Universal `.md` fallback. Claims any markdown file no vendor-specific Provider classifies.",
710
710
  stability: "stable",
711
711
  read: { extensions: [".md"], parser: "frontmatter-yaml" },
712
712
  // Per spec § A.6, defaultRefreshAction values MUST be qualified
@@ -825,7 +825,7 @@ var slashExtractor = {
825
825
  pluginId: "core",
826
826
  kind: "extractor",
827
827
  version: "1.0.0",
828
- description: "Detects `/command` invocations in a node's body and turns each one into an arrow (edge) between nodes in the graph.",
828
+ description: "Detects `/command` invocations in a node's body and turns each one into an arrow between nodes in the graph.",
829
829
  stability: "stable",
830
830
  emitsLinkKinds: ["invokes"],
831
831
  defaultConfidence: "medium",
@@ -860,7 +860,7 @@ var atDirectiveExtractor = {
860
860
  pluginId: "core",
861
861
  kind: "extractor",
862
862
  version: "1.0.0",
863
- description: "Detects `@agent-name` mentions in a node's body and turns each one into an arrow (edge) between nodes in the graph.",
863
+ description: "Detects `@agent-name` mentions in a node's body and turns each one into an arrow between nodes in the graph.",
864
864
  stability: "stable",
865
865
  emitsLinkKinds: ["mentions"],
866
866
  defaultConfidence: "medium",
@@ -896,24 +896,24 @@ var externalUrlCounterExtractor = {
896
896
  pluginId: "core",
897
897
  kind: "extractor",
898
898
  version: "1.0.0",
899
- description: "Counts the distinct external URLs (`http://` / `https://`) in a node's body and shows the total as a chip on the card.",
899
+ description: "Counts the distinct external URLs in a node's body and shows the total on the card.",
900
900
  stability: "stable",
901
901
  emitsLinkKinds: ["references"],
902
902
  defaultConfidence: "low",
903
903
  scope: "body",
904
904
  /**
905
- * Phase 6 / View contribution system surface the distinct-URL
905
+ * Phase 6 / View contribution system, surface the distinct-URL
906
906
  * count as a card-footer-left chip alongside the in/out link
907
907
  * counters and the tools-count wrench. The chip is silent when
908
908
  * zero URLs were emitted (`emitWhenEmpty: false`), so unrelated
909
909
  * nodes do not gain a `link 0` decoration. The counter rides on
910
910
  * exactly the same data the orchestrator was already going to
911
- * count there is no second pass.
911
+ * count, there is no second pass.
912
912
  *
913
913
  * Icon is the PrimeIcons `pi-link` glyph (declared as the bare
914
914
  * `'link'` per `IconString` rules in `view-slots.schema.json`).
915
915
  * Mirrors the look of the legacy hardcoded `pi pi-link` chip in
916
- * `node-card.html` it replaced same icon font, same sizing
916
+ * `node-card.html` it replaced, same icon font, same sizing
917
917
  * inherited from the footer `.sm-gnode__stat` styles cloned by
918
918
  * the `NodeCounter` renderer.
919
919
  */
@@ -997,7 +997,7 @@ var markdownLinkExtractor = {
997
997
  pluginId: "core",
998
998
  kind: "extractor",
999
999
  version: "1.0.0",
1000
- description: "Detects markdown links (`[text](path)`) in a node's body and turns each one into an arrow (edge) between nodes in the graph.",
1000
+ description: "Detects markdown links (`[text](path)`) in a node's body and turns each one into an arrow between nodes in the graph.",
1001
1001
  stability: "stable",
1002
1002
  emitsLinkKinds: ["references"],
1003
1003
  defaultConfidence: "high",
@@ -1059,14 +1059,14 @@ function lineFor2(lineStarts, offset) {
1059
1059
 
1060
1060
  // built-in-plugins/analyzers/stability/index.ts
1061
1061
  var ID6 = "stability";
1062
- var EXPERIMENTAL_TOOLTIP = "Experimental \u2014 API may change";
1063
- var DEPRECATED_TOOLTIP = "Deprecated \u2014 avoid in new code";
1062
+ var EXPERIMENTAL_TOOLTIP = "Experimental: API may change";
1063
+ var DEPRECATED_TOOLTIP = "Deprecated: avoid in new code";
1064
1064
  var stabilityAnalyzer = {
1065
1065
  id: ID6,
1066
1066
  pluginId: "core",
1067
1067
  kind: "analyzer",
1068
1068
  version: "1.0.0",
1069
- description: "Surfaces the node lifecycle stage (`stability: experimental | deprecated`) as a chip on the card footer and as an Issue (`deprecated \u2192 warn`, `experimental \u2192 info`) in `sm check`. Reads `annotations.stability` from the sidecar, falling back to legacy frontmatter `metadata.stability`.",
1069
+ description: "Reports node lifecycle stage (`experimental`, `deprecated`) on the card.",
1070
1070
  stability: "stable",
1071
1071
  mode: "deterministic",
1072
1072
  viewContributions: {
@@ -1098,7 +1098,7 @@ var stabilityAnalyzer = {
1098
1098
  analyzerId: ID6,
1099
1099
  severity: "info",
1100
1100
  nodeIds: [node.path],
1101
- message: `Node '${node.path}' is marked experimental \u2014 API may change.`,
1101
+ message: `Node '${node.path}' is marked experimental: API may change.`,
1102
1102
  data: { stability }
1103
1103
  });
1104
1104
  } else if (stability === "deprecated") {
@@ -1111,7 +1111,7 @@ var stabilityAnalyzer = {
1111
1111
  analyzerId: ID6,
1112
1112
  severity: "warn",
1113
1113
  nodeIds: [node.path],
1114
- message: `Node '${node.path}' is marked deprecated \u2014 avoid in new code.`,
1114
+ message: `Node '${node.path}' is marked deprecated: avoid in new code.`,
1115
1115
  data: { stability }
1116
1116
  });
1117
1117
  }
@@ -1143,7 +1143,7 @@ var toolsCountExtractor = {
1143
1143
  pluginId: "core",
1144
1144
  kind: "extractor",
1145
1145
  version: "1.0.0",
1146
- description: "Counts the tools an agent declares in its frontmatter and shows the total as a wrench chip on the agent card.",
1146
+ description: "Counts the tools an agent declares in its frontmatter and shows the total on the agent card.",
1147
1147
  stability: "stable",
1148
1148
  emitsLinkKinds: [],
1149
1149
  defaultConfidence: "high",
@@ -1193,9 +1193,9 @@ var TRIGGER_COLLISION_TEXTS = {
1193
1193
  * (e.g. `'; y '` in Spanish) without touching the rule code.
1194
1194
  */
1195
1195
  messageTwoParts: 'Trigger "{{normalized}}" has {{first}}; and {{second}}.',
1196
- /** `<n> nodes advertise it: <list>` part fires on the advertiser-ambiguous branch. */
1196
+ /** `<n> nodes advertise it: <list>` part, fires on the advertiser-ambiguous branch. */
1197
1197
  partAdvertisers: "{{count}} nodes advertise it: {{paths}}",
1198
- /** `<n> distinct invocation forms: <list>` part fires on the invocation-ambiguous branch. */
1198
+ /** `<n> distinct invocation forms: <list>` part, fires on the invocation-ambiguous branch. */
1199
1199
  partInvocations: "{{count}} distinct invocation forms: {{forms}}",
1200
1200
  /** Singular cross-kind cause: `non-canonical invocation <form> against advertiser <path>`. */
1201
1201
  partNonCanonicalSingular: "non-canonical invocation {{forms}} against advertiser {{advertiser}}",
@@ -1216,7 +1216,7 @@ var triggerCollisionAnalyzer = {
1216
1216
  kind: "analyzer",
1217
1217
  mode: "deterministic",
1218
1218
  version: "1.0.0",
1219
- description: "Flags when two or more nodes claim the same `/command` or `@agent` name \u2014 either by their `name` field or by how they are invoked elsewhere.",
1219
+ description: "Detects and flags two or more nodes claiming the same `/command` or `@agent` name.",
1220
1220
  stability: "stable",
1221
1221
  // Two claim-collection passes (advertisement + invocation) feeding
1222
1222
  // the bucket map. Per-bucket analysis lives in `analyzeTriggerBucket`.
@@ -1343,7 +1343,7 @@ var brokenRefAnalyzer = {
1343
1343
  pluginId: "core",
1344
1344
  kind: "analyzer",
1345
1345
  version: "1.0.0",
1346
- description: "Flags arrows pointing at a node that is not part of the current scan (broken link).",
1346
+ description: "Detects and flags arrows pointing at a node not part of the current scan.",
1347
1347
  stability: "stable",
1348
1348
  mode: "deterministic",
1349
1349
  viewContributions: {
@@ -1354,7 +1354,7 @@ var brokenRefAnalyzer = {
1354
1354
  icon: "fa-solid fa-circle-xmark",
1355
1355
  emitWhenEmpty: false
1356
1356
  },
1357
- // Footer chip on the card. `_counter` shape `value` always shows,
1357
+ // Footer chip on the card. `_counter` shape, `value` always shows,
1358
1358
  // so the operator sees "how many" at a glance. Renders OUTLINED
1359
1359
  // (`fa-regular`) so the corner alert (filled, attention-grabbing)
1360
1360
  // and the footer chip (quieter, paired with a number) read as two
@@ -1462,7 +1462,7 @@ var supersededAnalyzer = {
1462
1462
  pluginId: "core",
1463
1463
  kind: "analyzer",
1464
1464
  version: "1.0.0",
1465
- description: "Marks nodes that have been replaced by a newer one (the sidecar declares `supersededBy`).",
1465
+ description: "Detects and marks nodes replaced by a newer one via `supersededBy`.",
1466
1466
  stability: "stable",
1467
1467
  mode: "deterministic",
1468
1468
  evaluate(ctx) {
@@ -1507,7 +1507,7 @@ var linkConflictAnalyzer = {
1507
1507
  pluginId: "core",
1508
1508
  kind: "analyzer",
1509
1509
  version: "1.0.0",
1510
- description: 'Flags when two extractors disagree on the meaning of the same arrow (e.g. one says "references", the other says "invokes").',
1510
+ description: 'Detects and flags conflicting arrow meanings between extractors (e.g. "references" vs "invokes").',
1511
1511
  stability: "stable",
1512
1512
  mode: "deterministic",
1513
1513
  // Bucket links by (source, target), then per-bucket detect distinct
@@ -1586,9 +1586,9 @@ var ANNOTATION_STALE_TEXTS = {
1586
1586
  /** both body and frontmatter changed */
1587
1587
  bothDrift: "{{path}}: sidecar `.sm` is stale (body and frontmatter changed since last bump).",
1588
1588
  // Tooltips for the `card.footer.right` clock chip emitted alongside
1589
- // the issue. Lists only the drifted face(s) in-sync faces are
1589
+ // the issue. Lists only the drifted face(s), in-sync faces are
1590
1590
  // omitted so the operator immediately sees what's modified without
1591
- // scanning prose. No `{{path}}` placeholder the chip already sits
1591
+ // scanning prose. No `{{path}}` placeholder, the chip already sits
1592
1592
  // on the affected node. The hint `sm bump <path>` keeps `<path>` as
1593
1593
  // a literal placeholder the operator substitutes.
1594
1594
  bodyTooltip: "Sidecar drift since last bump:\n \u2022 body\nRun `sm bump <path>` to refresh.",
@@ -1603,14 +1603,19 @@ var annotationStaleAnalyzer = {
1603
1603
  pluginId: "core",
1604
1604
  kind: "analyzer",
1605
1605
  version: "1.0.0",
1606
- description: "Marks nodes whose `.sm` sidecar is out of date \u2014 the `.md` content changed since the last sidecar bump. Surfaces an Issue (panel) plus a `pi-clock` chip in the card footer.",
1606
+ description: "Detects and marks sidecars (`.sm`) out of date of their `.md`.",
1607
1607
  stability: "stable",
1608
1608
  mode: "deterministic",
1609
+ // The natural fix is to bump the node: refreshes `for` hashes,
1610
+ // increments `annotations.version`, and stamps the audit block. The
1611
+ // UI surfaces `core/bump` in the node inspector under "Recommended
1612
+ // for issues" whenever this analyzer fires.
1613
+ recommendedActions: ["core/bump"],
1609
1614
  viewContributions: {
1610
1615
  // A `pi-clock` chip in the footer-right cluster so the operator
1611
1616
  // spots drift in the list / inspector view (and on the graph card
1612
1617
  // body). Emitted with `value: 0` and `emitWhenEmpty: true` so the
1613
- // renderer treats it as icon-only drift severity is binary at
1618
+ // renderer treats it as icon-only, drift severity is binary at
1614
1619
  // this surface (the tooltip carries the per-face detail body /
1615
1620
  // frontmatter / both). The corner badge on `graph.node.alert` was
1616
1621
  // dropped on purpose: a tooltip on the footer chip is enough, and
@@ -1670,7 +1675,7 @@ var annotationOrphanAnalyzer = {
1670
1675
  pluginId: "core",
1671
1676
  kind: "analyzer",
1672
1677
  version: "1.0.0",
1673
- description: "Flags `.sm` sidecars whose matching `.md` file no longer exists on disk.",
1678
+ description: "Detects and flags sidecars (`.sm`) whose `.md` no longer exists.",
1674
1679
  stability: "stable",
1675
1680
  mode: "deterministic",
1676
1681
  evaluate(ctx) {
@@ -1713,7 +1718,7 @@ var jobOrphanFileAnalyzer = {
1713
1718
  pluginId: "core",
1714
1719
  kind: "analyzer",
1715
1720
  version: "1.0.0",
1716
- description: "Flags leftover job result files in `.skill-map/jobs/` that no live job references. Cleanup via `sm job prune --orphan-files`.",
1721
+ description: "Detects and flags leftover job result files (no live job references them). Cleanup via `sm job prune --orphan-files`.",
1717
1722
  stability: "stable",
1718
1723
  mode: "deterministic",
1719
1724
  evaluate(ctx) {
@@ -1751,9 +1756,9 @@ var UNKNOWN_FIELD_TEXTS = {
1751
1756
  /** Key inside `annotations:` is not in the curated catalog. */
1752
1757
  unknownAnnotationKey: "{{path}}: sidecar annotations contain unknown key '{{key}}' (not in annotations.schema.json catalog).",
1753
1758
  /** Top-level key is neither reserved, nor a registered plugin namespace, nor a registered root key. */
1754
- unknownRootKey: "{{path}}: sidecar declares unknown top-level key '{{key}}' \u2014 not a reserved block, not a registered plugin namespace, not a registered root contribution.",
1759
+ unknownRootKey: "{{path}}: sidecar declares unknown top-level key '{{key}}'; not a reserved block, not a registered plugin namespace, not a registered root contribution.",
1755
1760
  /** Value under a registered plugin namespace fails the contributed schema. */
1756
- pluginNamespaceInvalid: "{{path}}: sidecar block '{{pluginId}}.{{key}}' fails the schema contributed by plugin '{{pluginId}}' \u2014 {{errors}}.",
1761
+ pluginNamespaceInvalid: "{{path}}: sidecar block '{{pluginId}}.{{key}}' fails the schema contributed by plugin '{{pluginId}}': {{errors}}.",
1757
1762
  // Tooltips for the per-node view-contribution badges. Singular vs
1758
1763
  // plural keeps the count grammar correct without a sub-template.
1759
1764
  alertTooltipSingle: "This node has 1 unknown field in its sidecar. Open the inspector for details.",
@@ -1768,7 +1773,7 @@ var unknownFieldAnalyzer = {
1768
1773
  pluginId: "core",
1769
1774
  kind: "analyzer",
1770
1775
  version: "1.0.0",
1771
- description: "Catches typos and unrecognized keys inside `.sm` sidecars, including plugin-contributed annotation fields that fail their own schema.",
1776
+ description: "Detects and flags typos or unrecognized keys in sidecars (`.sm`).",
1772
1777
  stability: "stable",
1773
1778
  mode: "deterministic",
1774
1779
  viewContributions: {
@@ -1776,13 +1781,13 @@ var unknownFieldAnalyzer = {
1776
1781
  // single unknown field (avoids a noisy "icon + 1" chip).
1777
1782
  alert: {
1778
1783
  slot: "graph.node.alert",
1779
- // Filled warning triangle on the corner matches the broken-ref
1784
+ // Filled warning triangle on the corner, matches the broken-ref
1780
1785
  // alert's "attention-grabbing solid" pattern; the footer chip
1781
1786
  // below stays outlined for the quieter counter pairing.
1782
1787
  icon: "fa-solid fa-triangle-exclamation",
1783
1788
  emitWhenEmpty: false
1784
1789
  },
1785
- // Footer chip on the card `_counter` shape but rendered icon-only
1790
+ // Footer chip on the card, `_counter` shape but rendered icon-only
1786
1791
  // (the analyzer emits `value: 0` so NodeCounter hides the number
1787
1792
  // and only the glyph shows). PrimeIcons `pi-question-circle` so the
1788
1793
  // visual weight matches `annotation-stale`'s `pi-clock` chip
@@ -1796,6 +1801,11 @@ var unknownFieldAnalyzer = {
1796
1801
  priority: 30
1797
1802
  }
1798
1803
  },
1804
+ // Analyzer body iterates every sidecar root and classifies each
1805
+ // key against three buckets (catalog / plugin namespace / unknown
1806
+ // root). The per-key branching IS the classification table; factoring
1807
+ // it out would rebuild the discriminator elsewhere. Per
1808
+ // `context/lint.md` category 7 (recursive type-discriminator walkers).
1799
1809
  // eslint-disable-next-line complexity
1800
1810
  evaluate(ctx) {
1801
1811
  const sidecarRoots = ctx.sidecarRoots;
@@ -1932,64 +1942,14 @@ function collectPluginIds(contributions) {
1932
1942
  return out;
1933
1943
  }
1934
1944
 
1935
- // built-in-plugins/analyzers/unknown-slot/index.ts
1936
- var ID16 = "unknown-slot";
1937
- var KNOWN_SLOTS = /* @__PURE__ */ new Set([
1938
- "card.title.right",
1939
- "card.subtitle.left",
1940
- "card.footer.left",
1941
- "card.footer.right",
1942
- "graph.node.alert",
1943
- "inspector.header.badge.counter",
1944
- "inspector.header.badge.tag",
1945
- "inspector.body.panel.breakdown",
1946
- "inspector.body.panel.records",
1947
- "inspector.body.panel.tree",
1948
- "inspector.body.panel.key-values",
1949
- "inspector.body.panel.link-list",
1950
- "inspector.body.panel.markdown",
1951
- "topbar.nav.start"
1952
- ]);
1953
- var unknownSlotAnalyzer = {
1954
- id: ID16,
1955
- pluginId: "core",
1956
- kind: "analyzer",
1957
- version: "1.0.0",
1958
- description: "Warns when a plugin tries to render in a UI position that does not exist (typo or removed in a newer skill-map version).",
1959
- stability: "stable",
1960
- mode: "deterministic",
1961
- evaluate(ctx) {
1962
- const contributions = ctx.viewContributions;
1963
- if (!contributions || contributions.length === 0) return [];
1964
- const issues = [];
1965
- for (const c of contributions) {
1966
- if (KNOWN_SLOTS.has(c.slot)) continue;
1967
- const qualified = `${c.pluginId}/${c.extensionId}/${c.contributionId}`;
1968
- issues.push({
1969
- analyzerId: ID16,
1970
- severity: "warn",
1971
- nodeIds: [],
1972
- message: `Plugin ${qualified} declares unknown slot '${c.slot}'. Run \`sm plugins upgrade ${c.pluginId}\` or update the plugin to a slot in the current catalog (\`sm plugins slots list\`).`,
1973
- data: {
1974
- pluginId: c.pluginId,
1975
- extensionId: c.extensionId,
1976
- contributionId: c.contributionId,
1977
- slot: c.slot
1978
- }
1979
- });
1980
- }
1981
- return issues;
1982
- }
1983
- };
1984
-
1985
1945
  // built-in-plugins/analyzers/contribution-orphan/index.ts
1986
- var ID17 = "contribution-orphan";
1946
+ var ID16 = "contribution-orphan";
1987
1947
  var contributionOrphanAnalyzer = {
1988
- id: ID17,
1948
+ id: ID16,
1989
1949
  pluginId: "core",
1990
1950
  kind: "analyzer",
1991
- version: "1.0.0",
1992
- description: "Warns when a plugin's per-node chips reference a node that was renamed or deleted in the latest scan.",
1951
+ version: "0.0.0",
1952
+ description: "Detects and warns about plugin data referencing nodes renamed or deleted in the latest scan.",
1993
1953
  stability: "experimental",
1994
1954
  mode: "deterministic",
1995
1955
  evaluate(_ctx) {
@@ -2006,14 +1966,14 @@ function sanitizeForTerminal(text) {
2006
1966
 
2007
1967
  // built-in-plugins/i18n/ascii.texts.ts
2008
1968
  var ASCII_FORMATTER_TEXTS = {
2009
- /** Header line: `skill-map graph N nodes, M links, K issues`. */
2010
- header: "skill-map graph \u2014 {{nodes}} nodes, {{links}} links, {{issues}} issues",
1969
+ /** Header line: `skill-map graph: N nodes, M links, K issues`. */
1970
+ header: "skill-map graph: {{nodes}} nodes, {{links}} links, {{issues}} issues",
2011
1971
  /** Per-node-kind section header: `## <kind> (<count>)`. */
2012
1972
  kindSectionHeader: "## {{kind}} ({{count}})",
2013
1973
  /** Plain node bullet: `- <path>`. */
2014
1974
  nodeBullet: "- {{path}}",
2015
- /** Node bullet with title suffix: `- <path> "<title>"`. */
2016
- nodeBulletWithTitle: '- {{path}} \u2014 "{{title}}"',
1975
+ /** Node bullet with title suffix: `- <path>: "<title>"`. */
1976
+ nodeBulletWithTitle: '- {{path}}: "{{title}}"',
2017
1977
  /** `## links (<count>)` section header. */
2018
1978
  linksSectionHeader: "## links ({{count}})",
2019
1979
  /** Link bullet: `- <source> --<kind>--> <target> [<confidence>]`. */
@@ -2025,17 +1985,17 @@ var ASCII_FORMATTER_TEXTS = {
2025
1985
  };
2026
1986
 
2027
1987
  // built-in-plugins/formatters/ascii/index.ts
2028
- var ID18 = "ascii";
1988
+ var ID17 = "ascii";
2029
1989
  var KIND_ORDER = ["agent", "command", "skill", "markdown"];
2030
1990
  var asciiFormatter = {
2031
- id: ID18,
1991
+ id: ID17,
2032
1992
  pluginId: "core",
2033
1993
  kind: "formatter",
2034
1994
  version: "1.0.0",
2035
- description: "Plain-text dump of the scan grouped by kind, then arrows, then issues. Used by `sm scan --format=ascii`.",
1995
+ description: "Renders the scan as plain text, grouped by kind, arrows, and issues. Used by `sm scan --format=ascii`.",
2036
1996
  stability: "stable",
2037
1997
  formatId: "ascii",
2038
- // ASCII tree formatter header + per-kind sections + per-issue
1998
+ // ASCII tree formatter, header + per-kind sections + per-issue
2039
1999
  // section. Each section iterates and renders; splitting per section
2040
2000
  // would multiply the for-loop boilerplate.
2041
2001
  // eslint-disable-next-line complexity
@@ -2126,11 +2086,54 @@ function renderSection(out, kind, group) {
2126
2086
  out.push("");
2127
2087
  }
2128
2088
 
2089
+ // built-in-plugins/formatters/json/index.ts
2090
+ var ID18 = "json";
2091
+ var jsonFormatter = {
2092
+ id: ID18,
2093
+ pluginId: "core",
2094
+ kind: "formatter",
2095
+ version: "1.0.0",
2096
+ 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`.",
2097
+ stability: "stable",
2098
+ formatId: ID18,
2099
+ format(ctx) {
2100
+ if (ctx.scanResult !== void 0) {
2101
+ return JSON.stringify(ctx.scanResult);
2102
+ }
2103
+ return JSON.stringify({
2104
+ nodes: ctx.nodes,
2105
+ links: ctx.links,
2106
+ issues: ctx.issues
2107
+ });
2108
+ }
2109
+ };
2110
+
2129
2111
  // kernel/adapters/schema-validators.ts
2130
2112
  import { readFileSync as readFileSync2 } from "fs";
2131
2113
  import { dirname as dirname2, resolve as resolve3 } from "path";
2132
2114
  import { createRequire as createRequire2 } from "module";
2133
2115
  import { Ajv2020 as Ajv20202 } from "ajv/dist/2020.js";
2116
+
2117
+ // kernel/types/view-catalog.ts
2118
+ var ALL_SLOT_NAMES = [
2119
+ "card.title.right",
2120
+ "card.subtitle.left",
2121
+ "card.footer.left",
2122
+ "card.footer.right",
2123
+ "graph.node.alert",
2124
+ "inspector.header.badge.counter",
2125
+ "inspector.header.badge.tag",
2126
+ "inspector.body.panel.breakdown",
2127
+ "inspector.body.panel.records",
2128
+ "inspector.body.panel.tree",
2129
+ "inspector.body.panel.key-values",
2130
+ "inspector.body.panel.link-list",
2131
+ "inspector.body.panel.markdown",
2132
+ "topbar.nav.start"
2133
+ ];
2134
+ var KNOWN_SLOT_NAMES = new Set(ALL_SLOT_NAMES);
2135
+
2136
+ // kernel/adapters/schema-validators.ts
2134
2137
  var SCHEMA_FILES = {
2135
2138
  node: "schemas/node.schema.json",
2136
2139
  link: "schemas/link.schema.json",
@@ -2198,24 +2201,8 @@ function buildSchemaValidators() {
2198
2201
  });
2199
2202
  const contributionValidators = /* @__PURE__ */ new Map();
2200
2203
  const VIEW_SLOTS_ID = "https://skill-map.dev/spec/v0/view-slots.schema.json";
2201
- const KNOWN_SLOTS2 = /* @__PURE__ */ new Set([
2202
- "card.title.right",
2203
- "card.subtitle.left",
2204
- "card.footer.left",
2205
- "card.footer.right",
2206
- "graph.node.alert",
2207
- "inspector.header.badge.counter",
2208
- "inspector.header.badge.tag",
2209
- "inspector.body.panel.breakdown",
2210
- "inspector.body.panel.records",
2211
- "inspector.body.panel.tree",
2212
- "inspector.body.panel.key-values",
2213
- "inspector.body.panel.link-list",
2214
- "inspector.body.panel.markdown",
2215
- "topbar.nav.start"
2216
- ]);
2217
2204
  function getContributionValidator(slot) {
2218
- if (!KNOWN_SLOTS2.has(slot)) return null;
2205
+ if (!KNOWN_SLOT_NAMES.has(slot)) return null;
2219
2206
  const existing = contributionValidators.get(slot);
2220
2207
  if (existing) return existing;
2221
2208
  const ref = `${VIEW_SLOTS_ID}#/$defs/payloads/${slot}`;
@@ -2313,7 +2300,7 @@ function resolveSpecRoot() {
2313
2300
  return dirname2(indexPath);
2314
2301
  } catch {
2315
2302
  throw new Error(
2316
- "@skill-map/spec not resolvable \u2014 ensure the workspace is linked or the package is installed."
2303
+ "@skill-map/spec not resolvable: ensure the workspace is linked or the package is installed."
2317
2304
  );
2318
2305
  }
2319
2306
  }
@@ -2341,7 +2328,7 @@ var validateAllAnalyzer = {
2341
2328
  pluginId: "core",
2342
2329
  kind: "analyzer",
2343
2330
  version: "1.0.0",
2344
- description: "Validates every scanned node / link against the authoritative @skill-map/spec schemas.",
2331
+ description: "Detects and flags nodes or links violating the project schemas.",
2345
2332
  stability: "stable",
2346
2333
  mode: "deterministic",
2347
2334
  evaluate(ctx) {
@@ -2421,7 +2408,7 @@ var linkCountsAnalyzer = {
2421
2408
  pluginId: "core",
2422
2409
  kind: "analyzer",
2423
2410
  version: "1.0.0",
2424
- description: "Counts incoming and outgoing links per node and surfaces them as paired footer chips.",
2411
+ description: "Counts incoming and outgoing links per node.",
2425
2412
  stability: "stable",
2426
2413
  mode: "deterministic",
2427
2414
  viewContributions: {
@@ -2485,6 +2472,29 @@ import { dirname as dirname3, resolve as resolve4 } from "path";
2485
2472
  import { createRequire as createRequire3 } from "module";
2486
2473
  import { Ajv2020 as Ajv20203 } from "ajv/dist/2020.js";
2487
2474
  import yaml from "js-yaml";
2475
+
2476
+ // kernel/util/strip-prototype-pollution.ts
2477
+ var FORBIDDEN_KEYS = /* @__PURE__ */ new Set([
2478
+ "__proto__",
2479
+ "constructor",
2480
+ "prototype"
2481
+ ]);
2482
+ function stripPrototypePollution(value) {
2483
+ return strip(value);
2484
+ }
2485
+ function strip(value) {
2486
+ if (value === null || value === void 0) return value;
2487
+ if (typeof value !== "object") return value;
2488
+ if (Array.isArray(value)) return value.map(strip);
2489
+ const out = {};
2490
+ for (const [k, v] of Object.entries(value)) {
2491
+ if (FORBIDDEN_KEYS.has(k)) continue;
2492
+ out[k] = strip(v);
2493
+ }
2494
+ return out;
2495
+ }
2496
+
2497
+ // kernel/sidecar/parse.ts
2488
2498
  function readSidecarFor(mdAbsolutePath) {
2489
2499
  const sidecarPath = sidecarPathFor(mdAbsolutePath);
2490
2500
  if (!existsSync(sidecarPath)) {
@@ -2510,6 +2520,7 @@ function readSidecarFor(mdAbsolutePath) {
2510
2520
  issues: [{ message: `malformed YAML in ${sidecarPath}: ${err.message}` }]
2511
2521
  };
2512
2522
  }
2523
+ parsedYaml = stripPrototypePollution(parsedYaml);
2513
2524
  if (!isPlainObject(parsedYaml)) {
2514
2525
  return {
2515
2526
  parsed: null,
@@ -2575,7 +2586,7 @@ function resolveSpecRoot2() {
2575
2586
  return dirname3(indexPath);
2576
2587
  } catch {
2577
2588
  throw new Error(
2578
- "@skill-map/spec not resolvable \u2014 sidecar reader cannot load schemas."
2589
+ "@skill-map/spec not resolvable: sidecar reader cannot load schemas."
2579
2590
  );
2580
2591
  }
2581
2592
  }
@@ -2588,7 +2599,7 @@ var bumpAction = {
2588
2599
  pluginId: PLUGIN_ID,
2589
2600
  kind: "action",
2590
2601
  version: "1.0.0",
2591
- description: "Marks a node as updated \u2014 bumps its version, refreshes the sidecar hashes, and records the timestamp. Refuses on a fresh node unless `force: true` is passed.",
2602
+ description: "Marks a node as updated: bumps version, refreshes sidecar hashes, records the timestamp.",
2592
2603
  stability: "stable",
2593
2604
  mode: "deterministic",
2594
2605
  reportSchemaRef: "https://skill-map.dev/spec/v0/bump-report.schema.json",
@@ -2644,6 +2655,24 @@ function pickCurrentVersion(overlay) {
2644
2655
  return typeof v === "number" && Number.isFinite(v) ? v : 0;
2645
2656
  }
2646
2657
 
2658
+ // built-in-plugins/actions/mark-superseded/index.ts
2659
+ var ID22 = "mark-superseded";
2660
+ var PLUGIN_ID2 = "core";
2661
+ var markSupersededAction = {
2662
+ id: ID22,
2663
+ pluginId: PLUGIN_ID2,
2664
+ kind: "action",
2665
+ version: "0.0.0",
2666
+ description: "Declares the current node as superseded by another (writes `supersededBy` to the sidecar). Paired with the `core/superseded` analyzer.",
2667
+ stability: "experimental",
2668
+ mode: "deterministic",
2669
+ reportSchemaRef: "https://skill-map.dev/spec/v0/report-base-deterministic.schema.json",
2670
+ invoke(_input, _ctx) {
2671
+ const report = { ok: true, noop: true };
2672
+ return { report };
2673
+ }
2674
+ };
2675
+
2647
2676
  // cli/util/update-check-banner.ts
2648
2677
  import { existsSync as existsSync7 } from "fs";
2649
2678
 
@@ -3406,10 +3435,10 @@ function scanCheckedLiteral(sql4, start, closer, label) {
3406
3435
  }
3407
3436
  function findCommentMarker(ch, next, label) {
3408
3437
  if (ch === "-" && next === "-") {
3409
- return `${label} contains '--' (line comment marker). Reject \u2014 validator and engine would disagree on statement boundaries.`;
3438
+ return `${label} contains '--' (line comment marker). Reject: validator and engine would disagree on statement boundaries.`;
3410
3439
  }
3411
3440
  if (ch === "/" && next === "*") {
3412
- return `${label} contains '/*' (block comment marker). Reject \u2014 validator and engine would disagree on statement boundaries.`;
3441
+ return `${label} contains '/*' (block comment marker). Reject: validator and engine would disagree on statement boundaries.`;
3413
3442
  }
3414
3443
  return null;
3415
3444
  }
@@ -3975,7 +4004,7 @@ function rowToNode(row) {
3975
4004
  linksInCount: row.linksInCount,
3976
4005
  externalRefsCount: row.externalRefsCount,
3977
4006
  frontmatter: parseJsonObject(row.frontmatterJson),
3978
- // Step 9.6.2 reconstitute the sidecar overlay from the
4007
+ // Step 9.6.2, reconstitute the sidecar overlay from the
3979
4008
  // denormalised columns. Status is trusted as-stored (the kernel
3980
4009
  // wrote it from `computeDriftStatus`); annotations re-parse from
3981
4010
  // the JSON column.
@@ -3983,7 +4012,7 @@ function rowToNode(row) {
3983
4012
  present: row.sidecarPresent === 1,
3984
4013
  status: row.sidecarStatus,
3985
4014
  annotations: row.annotationsJson === null ? null : parseJsonObject(row.annotationsJson),
3986
- // R15 closure (2026-05-07) rehydrate the full parsed root from
4015
+ // R15 closure (2026-05-07), rehydrate the full parsed root from
3987
4016
  // the sibling JSON column. NULL when no sidecar is present, or
3988
4017
  // when the sidecar failed to parse on the scanning side.
3989
4018
  root: row.sidecarRootJson === null ? null : parseJsonObject(row.sidecarRootJson)
@@ -4076,7 +4105,13 @@ async function loadNodeEnrichments(db, nodePath) {
4076
4105
  nodePath: row.nodePath,
4077
4106
  extractorId: row.extractorId,
4078
4107
  bodyHashAtEnrichment: row.bodyHashAtEnrichment,
4079
- value: parseJsonObject(row.valueJson),
4108
+ // Audit M3: deep-strip `__proto__` / `constructor` / `prototype`
4109
+ // keys at every depth before the value flows into the read-time
4110
+ // merge in `mergeNodeWithEnrichments`. AJV at emit time does not
4111
+ // forbid these names; without the strip a hostile (or buggy)
4112
+ // extractor could persist a nested forbidden key that survived
4113
+ // the JSON round-trip and exploited a future deep merge.
4114
+ value: stripPrototypePollution(parseJsonObject(row.valueJson)),
4080
4115
  stale: row.stale === 1,
4081
4116
  enrichedAt: row.enrichedAt,
4082
4117
  isProbabilistic: row.isProbabilistic === 1
@@ -4214,7 +4249,7 @@ async function purgeContributionsByPlugin(db, pluginId, extensionId) {
4214
4249
  function rowToContribution(row) {
4215
4250
  let payload;
4216
4251
  try {
4217
- payload = JSON.parse(row.payloadJson);
4252
+ payload = stripPrototypePollution(JSON.parse(row.payloadJson));
4218
4253
  } catch {
4219
4254
  payload = {};
4220
4255
  }
@@ -4664,7 +4699,7 @@ var SqliteStorageAdapter = class {
4664
4699
  /**
4665
4700
  * Access the underlying Kysely instance.
4666
4701
  *
4667
- * Test-only escape hatch (per AGENTS.md § Kernel boundaries tests
4702
+ * Test-only escape hatch (per AGENTS.md § Kernel boundaries, tests
4668
4703
  * are the documented exception). CLI commands MUST consume the
4669
4704
  * adapter through the namespaced port surfaces (`port.<namespace>.*`
4670
4705
  * or `port.transaction(...)`); reaching for this getter from a
@@ -4701,6 +4736,7 @@ var SqliteStorageAdapter = class {
4701
4736
  };
4702
4737
  this.issues = {
4703
4738
  listAll: () => listAllIssues(this.db),
4739
+ list: (filter) => listIssues(this.db, filter),
4704
4740
  findActive: (predicate) => findActiveIssues(this.db, predicate)
4705
4741
  };
4706
4742
  this.history = {
@@ -4855,6 +4891,50 @@ async function listAllIssues(db) {
4855
4891
  const rows = await db.selectFrom("scan_issues").selectAll().execute();
4856
4892
  return rows.map(rowToIssue);
4857
4893
  }
4894
+ async function listIssues(db, filter) {
4895
+ const baseQuery = applyIssueFilters(
4896
+ db.selectFrom("scan_issues"),
4897
+ filter
4898
+ );
4899
+ const countRow = await baseQuery.select(({ fn }) => fn.countAll().as("c")).executeTakeFirst();
4900
+ const total = Number(countRow?.c ?? 0);
4901
+ const rows = await applyIssueFilters(
4902
+ db.selectFrom("scan_issues"),
4903
+ filter
4904
+ ).selectAll().orderBy("id", "asc").offset(filter.offset).limit(filter.limit).execute();
4905
+ return { items: rows.map(rowToIssue), total };
4906
+ }
4907
+ function applyIssueFilters(query, filter) {
4908
+ let q = query;
4909
+ if (filter.severities && filter.severities.length > 0) {
4910
+ q = q.where("severity", "in", [...filter.severities]);
4911
+ }
4912
+ if (filter.analyzerIds && filter.analyzerIds.length > 0) {
4913
+ const tokens = filter.analyzerIds;
4914
+ q = q.where(
4915
+ ({ eb, or }) => or(
4916
+ tokens.flatMap((token) => [
4917
+ eb("analyzerId", "=", token),
4918
+ // `'%/' || ?` keeps the LIKE pattern's `%` literal in the
4919
+ // template and binds `token` separately, no interpolation of
4920
+ // user input into the SQL string.
4921
+ eb("analyzerId", "like", `%/${token}`)
4922
+ ])
4923
+ )
4924
+ );
4925
+ }
4926
+ if (filter.nodePath !== void 0 && filter.nodePath !== null) {
4927
+ const target = filter.nodePath;
4928
+ q = q.where(
4929
+ ({ exists, selectFrom }) => exists(
4930
+ selectFrom(
4931
+ sql3`json_each(scan_issues.node_ids_json)`.as("je")
4932
+ ).select(sql3`1`.as("one")).where(sql3.ref("je.value"), "=", target)
4933
+ )
4934
+ );
4935
+ }
4936
+ return q;
4937
+ }
4858
4938
  async function findActiveIssues(db, predicate) {
4859
4939
  const rows = await db.selectFrom("scan_issues").selectAll().execute();
4860
4940
  const out = [];
@@ -5126,14 +5206,15 @@ function loadConfig(opts) {
5126
5206
  const partial = readJsonSafe(path, layer, warnings, strict);
5127
5207
  if (partial === null) continue;
5128
5208
  const cleaned = validateAndStrip(validators, partial, layer, warnings, strict);
5129
- if (layer === "project") {
5130
- stripProjectLocalOnlyKeys(cleaned, warnings, strict);
5209
+ if (layer !== "project-local") {
5210
+ stripProjectLocalOnlyKeys(cleaned, layer, warnings, strict);
5131
5211
  }
5132
5212
  effective = deepMerge(effective, cleaned);
5133
5213
  recordSources("", cleaned, sources, layer);
5134
5214
  }
5135
5215
  if (opts.overrides && Object.keys(opts.overrides).length > 0) {
5136
5216
  const cleaned = validateAndStrip(validators, opts.overrides, "override", warnings, strict);
5217
+ stripProjectLocalOnlyKeys(cleaned, "override", warnings, strict);
5137
5218
  effective = deepMerge(effective, cleaned);
5138
5219
  recordSources("", cleaned, sources, "override");
5139
5220
  }
@@ -5208,7 +5289,6 @@ function describeJsonType(v) {
5208
5289
  if (Array.isArray(v)) return "array";
5209
5290
  return typeof v;
5210
5291
  }
5211
- var FORBIDDEN_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
5212
5292
  function deleteAtPath(root, parentPath, key) {
5213
5293
  if (containsForbidden(parentPath, key)) return;
5214
5294
  const segments = parentPath.split("/").filter(Boolean);
@@ -5219,7 +5299,7 @@ function deleteAtPath(root, parentPath, key) {
5219
5299
  }
5220
5300
  if (isPlainObject2(cur)) delete cur[key];
5221
5301
  }
5222
- function stripProjectLocalOnlyKeys(cloned, warnings, strict) {
5302
+ function stripProjectLocalOnlyKeys(cloned, layer, warnings, strict) {
5223
5303
  for (const dotKey of PROJECT_LOCAL_ONLY_KEYS) {
5224
5304
  const segments = dotKey.split(".").filter(Boolean);
5225
5305
  if (segments.length === 0) continue;
@@ -5228,7 +5308,7 @@ function stripProjectLocalOnlyKeys(cloned, warnings, strict) {
5228
5308
  const parentPath = "/" + segments.join("/");
5229
5309
  deleteAtPath(cloned, parentPath, leaf);
5230
5310
  const msg = tx(CONFIG_LOADER_TEXTS.projectLocalOnlyStripped, {
5231
- layer: "project",
5311
+ layer,
5232
5312
  key: dotKey
5233
5313
  });
5234
5314
  if (strict) throw new Error(msg);
@@ -5387,13 +5467,17 @@ function enumerateConfigPaths(obj, prefix = "") {
5387
5467
 
5388
5468
  // core/config/atomic-write.ts
5389
5469
  import {
5470
+ closeSync,
5471
+ constants as fsConstants,
5390
5472
  existsSync as existsSync6,
5391
5473
  mkdirSync as mkdirSync3,
5474
+ openSync,
5392
5475
  readFileSync as readFileSync7,
5393
5476
  renameSync,
5394
5477
  unlinkSync,
5395
- writeFileSync
5478
+ writeSync
5396
5479
  } from "fs";
5480
+ import { randomBytes } from "crypto";
5397
5481
  import { dirname as dirname6 } from "path";
5398
5482
  function readJsonObjectOrEmpty(path) {
5399
5483
  if (!existsSync6(path)) return {};
@@ -5406,13 +5490,26 @@ function readJsonObjectOrEmpty(path) {
5406
5490
  }
5407
5491
  return {};
5408
5492
  }
5409
- function writeJsonAtomic(path, content) {
5410
- mkdirSync3(dirname6(path), { recursive: true });
5411
- const tmp = `${path}.tmp.${process.pid}`;
5493
+ function writeFileAtomicExclusive(path, content) {
5494
+ const tmp = `${path}.tmp.${process.pid}.${randomBytes(8).toString("hex")}`;
5495
+ let fd = null;
5412
5496
  try {
5413
- writeFileSync(tmp, JSON.stringify(content, null, 2) + "\n", "utf8");
5497
+ fd = openSync(
5498
+ tmp,
5499
+ fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_NOFOLLOW,
5500
+ 384
5501
+ );
5502
+ writeSync(fd, content);
5503
+ closeSync(fd);
5504
+ fd = null;
5414
5505
  renameSync(tmp, path);
5415
5506
  } catch (err) {
5507
+ if (fd !== null) {
5508
+ try {
5509
+ closeSync(fd);
5510
+ } catch {
5511
+ }
5512
+ }
5416
5513
  try {
5417
5514
  unlinkSync(tmp);
5418
5515
  } catch {
@@ -5420,6 +5517,10 @@ function writeJsonAtomic(path, content) {
5420
5517
  throw err;
5421
5518
  }
5422
5519
  }
5520
+ function writeJsonAtomic(path, content) {
5521
+ mkdirSync3(dirname6(path), { recursive: true });
5522
+ writeFileAtomicExclusive(path, JSON.stringify(content, null, 2) + "\n");
5523
+ }
5423
5524
 
5424
5525
  // core/config/helper.ts
5425
5526
  var USER_ONLY_KEYS = /* @__PURE__ */ new Set([
@@ -5549,6 +5650,7 @@ function isUnderProject(absPath, cwd) {
5549
5650
  }
5550
5651
 
5551
5652
  // core/update-check/index.ts
5653
+ var SEMVER_SHAPE_RE = /^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
5552
5654
  async function fetchLatestVersion(pkg, opts) {
5553
5655
  const controller = new AbortController();
5554
5656
  const timer = setTimeout(() => controller.abort(), opts.timeoutMs);
@@ -5565,6 +5667,9 @@ async function fetchLatestVersion(pkg, opts) {
5565
5667
  if (typeof payload.version !== "string" || payload.version.length === 0) {
5566
5668
  throw new Error("registry payload missing string `version`");
5567
5669
  }
5670
+ if (!SEMVER_SHAPE_RE.test(payload.version)) {
5671
+ throw new Error("registry payload `version` is not a semver-shaped string");
5672
+ }
5568
5673
  return payload.version;
5569
5674
  } finally {
5570
5675
  clearTimeout(timer);
@@ -5633,14 +5738,16 @@ function comparePrerelease(a, b) {
5633
5738
 
5634
5739
  // cli/i18n/update-check.texts.ts
5635
5740
  var UPDATE_CHECK_TEXTS = {
5636
- available: "{{glyph}} Update available: {{current}} \u2192 {{latest}}\n {{hint}}\n",
5741
+ /** Label rendered inside the top border, between corner and fill. */
5742
+ availableHeader: "Update available",
5743
+ /** Actionable hint shown on the second body line, in dim ANSI. */
5637
5744
  availableHint: "Run `npm i -g @skill-map/cli@latest` to update."
5638
5745
  };
5639
5746
 
5640
5747
  // package.json
5641
5748
  var package_default = {
5642
5749
  name: "@skill-map/cli",
5643
- version: "0.22.0",
5750
+ version: "0.23.0",
5644
5751
  description: "skill-map reference implementation \u2014 kernel + CLI + adapters.",
5645
5752
  license: "MIT",
5646
5753
  type: "module",
@@ -5707,16 +5814,16 @@ var package_default = {
5707
5814
  },
5708
5815
  dependencies: {
5709
5816
  "@hono/node-server": "2.0.1",
5710
- "@skill-map/spec": "0.22.0",
5817
+ "@skill-map/spec": "0.23.0",
5711
5818
  ajv: "8.18.0",
5712
5819
  "ajv-formats": "3.0.1",
5713
5820
  chokidar: "5.0.0",
5714
5821
  clipanion: "4.0.0-rc.4",
5715
- hono: "4.12.16",
5822
+ hono: "4.12.18",
5716
5823
  ignore: "7.0.5",
5717
5824
  "js-tiktoken": "1.0.21",
5718
5825
  "js-yaml": "4.1.1",
5719
- kysely: "0.28.16",
5826
+ kysely: "0.28.17",
5720
5827
  semver: "7.7.4",
5721
5828
  typanion: "3.14.0",
5722
5829
  ws: "8.20.0"
@@ -5873,18 +5980,23 @@ async function runWithAdapter(adapter, opts) {
5873
5980
  } catch {
5874
5981
  }
5875
5982
  }
5983
+ var BANNER_WIDTH = 60;
5876
5984
  function writeBanner(opts, latestVersion) {
5877
5985
  const ansi = ansiFor({
5878
5986
  isTTY: opts.stderr.isTTY === true,
5879
5987
  noColorFlag: opts.noColorFlag
5880
5988
  });
5881
- const block = tx(UPDATE_CHECK_TEXTS.available, {
5882
- glyph: ansi.cyan("\u2139"),
5883
- current: VERSION,
5884
- latest: latestVersion,
5885
- hint: ansi.dim(UPDATE_CHECK_TEXTS.availableHint)
5886
- });
5887
- opts.stderr.write(block);
5989
+ const labelRaw = ` \u2B06 ${UPDATE_CHECK_TEXTS.availableHeader} `;
5990
+ const fillCount = Math.max(0, BANNER_WIDTH - 2 - labelRaw.length);
5991
+ const header = ansi.cyan("\u250C\u2500") + ansi.bold(ansi.cyan(labelRaw)) + ansi.cyan("\u2500".repeat(fillCount));
5992
+ const versionLine = `${ansi.cyan("\u2502")} ${VERSION} \u2192 ${latestVersion}`;
5993
+ const hintLine = `${ansi.cyan("\u2502")} ${ansi.dim(UPDATE_CHECK_TEXTS.availableHint)}`;
5994
+ const footer = ansi.cyan("\u2514" + "\u2500".repeat(BANNER_WIDTH - 1));
5995
+ opts.stderr.write(`${header}
5996
+ ${versionLine}
5997
+ ${hintLine}
5998
+ ${footer}
5999
+ `);
5888
6000
  }
5889
6001
 
5890
6002
  // built-in-plugins/hooks/update-check/index.ts
@@ -5893,7 +6005,7 @@ var updateCheckHook = {
5893
6005
  pluginId: "core",
5894
6006
  kind: "hook",
5895
6007
  version: "1.0.0",
5896
- description: "Checks once a day for a newer version of skill-map on npm and shows the `update available` banner when one exists.",
6008
+ description: "Checks daily for a newer skill-map version on npm. Shows an `update available` banner when one is found.",
5897
6009
  stability: "stable",
5898
6010
  mode: "deterministic",
5899
6011
  triggers: ["boot"],
@@ -5933,7 +6045,7 @@ var builtInBundles = [
5933
6045
  {
5934
6046
  id: "agent-skills",
5935
6047
  granularity: "bundle",
5936
- description: "Open-standard agent skills. Classifies files under `.agents/skills/<name>/SKILL.md` (Anthropic / OpenAI / Google convention).",
6048
+ description: "Agent Skills open standard. Vendor-neutral path `.agents/skills/<name>/SKILL.md` (Anthropic, OpenAI, Google). See agentskills.io.",
5937
6049
  extensions: [
5938
6050
  agentSkillsProvider
5939
6051
  ]
@@ -5941,7 +6053,7 @@ var builtInBundles = [
5941
6053
  {
5942
6054
  id: "core",
5943
6055
  granularity: "extension",
5944
- description: "Core extensions shared across providers \u2014 extractors, analyzers, formatters, the bump action, and the universal `.md` fallback Provider.",
6056
+ description: "Core extensions shared across providers: extractors, analyzers, formatters, the bump action, and the universal `.md` fallback Provider.",
5945
6057
  extensions: [
5946
6058
  // Provider FIRST within the core bundle so the kindRegistry
5947
6059
  // composer picks it up alongside other providers; orchestration
@@ -5949,7 +6061,7 @@ var builtInBundles = [
5949
6061
  // enforced by the bundle list above (claude / gemini /
5950
6062
  // agent-skills precede core). Within the core bundle, the
5951
6063
  // provider's slot among extractors / analyzers / formatter is
5952
- // irrelevant the orchestrator buckets by kind before
6064
+ // irrelevant, the orchestrator buckets by kind before
5953
6065
  // iterating, so this list defines registration order, not
5954
6066
  // execution order.
5955
6067
  coreMarkdownProvider,
@@ -5968,12 +6080,13 @@ var builtInBundles = [
5968
6080
  jobOrphanFileAnalyzer,
5969
6081
  stabilityAnalyzer,
5970
6082
  unknownFieldAnalyzer,
5971
- unknownSlotAnalyzer,
5972
6083
  contributionOrphanAnalyzer,
5973
6084
  asciiFormatter,
6085
+ jsonFormatter,
5974
6086
  validateAllAnalyzer,
5975
6087
  linkCountsAnalyzer,
5976
6088
  bumpAction,
6089
+ markSupersededAction,
5977
6090
  updateCheckHook
5978
6091
  ]
5979
6092
  }
@@ -6183,9 +6296,9 @@ var UTIL_TEXTS = {
6183
6296
  // Every verb's body is expected to end on a content line (with or
6184
6297
  // without its own trailing \n); the blank line here is universal.
6185
6298
  doneIn: "\ndone in {{elapsed}}\n",
6186
- // confirm.ts (default-no prompt suffix destructive verbs)
6299
+ // confirm.ts (default-no prompt suffix, destructive verbs)
6187
6300
  confirmPromptSuffix: " [y/N] ",
6188
- // confirm.ts (default-yes prompt suffix consent-style verbs where the
6301
+ // confirm.ts (default-yes prompt suffix, consent-style verbs where the
6189
6302
  // user already triggered the action and is just acknowledging it,
6190
6303
  // e.g. the .sm write consent gate).
6191
6304
  confirmPromptSuffixDefaultYes: " [Y/n] ",
@@ -6441,7 +6554,7 @@ function ensureSidecarWritesAllowed(opts) {
6441
6554
  }
6442
6555
 
6443
6556
  // kernel/sidecar/store.ts
6444
- import { existsSync as existsSync9, readFileSync as readFileSync8, renameSync as renameSync2, writeFileSync as writeFileSync2, unlinkSync as unlinkSync2 } from "fs";
6557
+ import { existsSync as existsSync9, readFileSync as readFileSync8 } from "fs";
6445
6558
  import { dirname as dirname7, resolve as resolve10 } from "path";
6446
6559
  import { createRequire as createRequire4 } from "module";
6447
6560
  import { Ajv2020 as Ajv20204 } from "ajv/dist/2020.js";
@@ -6501,6 +6614,7 @@ var FilesystemSidecarStore = class {
6501
6614
  function deepMerge2(base, patch) {
6502
6615
  const out = { ...base };
6503
6616
  for (const key of Object.keys(patch)) {
6617
+ if (FORBIDDEN_KEYS.has(key)) continue;
6504
6618
  const a = out[key];
6505
6619
  const b = patch[key];
6506
6620
  if (b === null) {
@@ -6529,20 +6643,10 @@ function readSidecarObject(sidecarAbsPath) {
6529
6643
  `sidecar at ${sidecarAbsPath} is not a YAML mapping; refusing to patch`
6530
6644
  );
6531
6645
  }
6532
- return parsed;
6646
+ return stripPrototypePollution(parsed);
6533
6647
  }
6534
6648
  function atomicWriteFile(targetPath, content) {
6535
- const tmpPath = `${targetPath}.tmp`;
6536
- try {
6537
- writeFileSync2(tmpPath, content, { encoding: "utf8" });
6538
- renameSync2(tmpPath, targetPath);
6539
- } catch (err) {
6540
- try {
6541
- if (existsSync9(tmpPath)) unlinkSync2(tmpPath);
6542
- } catch {
6543
- }
6544
- throw err;
6545
- }
6649
+ writeFileAtomicExclusive(targetPath, content);
6546
6650
  }
6547
6651
  var cachedValidator = null;
6548
6652
  function getSidecarValidator2() {
@@ -6566,7 +6670,7 @@ function resolveSpecRoot3() {
6566
6670
  const indexPath = require2.resolve("@skill-map/spec/index.json");
6567
6671
  return dirname7(indexPath);
6568
6672
  } catch {
6569
- throw new Error("@skill-map/spec not resolvable \u2014 sidecar store cannot load schemas.");
6673
+ throw new Error("@skill-map/spec not resolvable: sidecar store cannot load schemas.");
6570
6674
  }
6571
6675
  }
6572
6676
 
@@ -6588,7 +6692,7 @@ var BUMP_TEXTS = {
6588
6692
  pendingNone: "sm bump --pending: no stale sidecars in the persisted scan. Nothing to do.\n",
6589
6693
  pendingSummary: "sm bump --pending: bumped {{bumped}}, refused {{refused}}, skipped {{skipped}}, errors {{errors}}.\n",
6590
6694
  bumpedItem: " bumped {{nodePath}} -> v{{version}}{{createdSuffix}}\n",
6591
- refusedItem: " refused {{nodePath}} (fresh \u2014 would need --force)\n",
6695
+ refusedItem: " refused {{nodePath}} (fresh, would need --force)\n",
6592
6696
  skippedItem: " skipped {{nodePath}} ({{reason}})\n",
6593
6697
  errorItem: " error {{nodePath}}: {{message}}\n",
6594
6698
  // --- staged-mode (--staged) ---------------------------------------------
@@ -6604,12 +6708,12 @@ var BUMP_TEXTS = {
6604
6708
  /**
6605
6709
  * Pre-prompt context shown before the interactive `confirm()` so the
6606
6710
  * operator sees what they are about to opt into. `.skill-map/settings.local.json`
6607
- * is gitignored the choice is saved per-checkout, never travels via the repo.
6711
+ * is gitignored, the choice is saved per-checkout, never travels via the repo.
6608
6712
  */
6609
6713
  consentPrompt: "skill-map needs your consent to create .sm sidecar files next to your\nsource files in this project. The choice is saved to\n.skill-map/settings.local.json (gitignored, per-checkout) so this prompt\nnever appears again. Decline to abort without persisting the rejection.\n\nAllow .sm sidecar writes in this project?",
6610
6714
  consentAborted: "{{glyph}} sm bump: aborted by user. No .sm sidecar files were written.\n",
6611
6715
  consentRequiredNonTty: "{{glyph}} sm bump: consent required to write .sm sidecar files in this project.\n {{hint}}\n",
6612
- consentRequiredNonTtyHint: "Pass --yes to grant (writes to .skill-map/settings.local.json \u2014 gitignored)."
6716
+ consentRequiredNonTtyHint: "Pass --yes to grant (writes to .skill-map/settings.local.json, gitignored)."
6613
6717
  };
6614
6718
 
6615
6719
  // cli/util/confirm.ts
@@ -6743,7 +6847,7 @@ var SmCommand = class extends Command {
6743
6847
  db = Option.String("--db", { required: false, description: "Override the database file location (escape hatch)." });
6744
6848
  /**
6745
6849
  * Subclasses set this to `false` to opt out of the trailing
6746
- * `done in <…>` line appropriate for interactive verbs (`db shell`),
6850
+ * `done in <…>` line, appropriate for interactive verbs (`db shell`),
6747
6851
  * watcher loops (`watch`), and meta verbs that report a fixed
6748
6852
  * version (`version`, `help`).
6749
6853
  */
@@ -6886,7 +6990,7 @@ var BumpCommand = class extends SmCommand {
6886
6990
  static paths = [["bump"]];
6887
6991
  static usage = Command2.Usage({
6888
6992
  category: "Actions",
6889
- description: "Bump a node's sidecar (`<basename>.sm`) \u2014 increment annotations.version, refresh hashes, stamp audit.",
6993
+ description: "Bump a node's sidecar (`<basename>.sm`): increment annotations.version, refresh hashes, stamp audit.",
6890
6994
  details: `
6891
6995
  Wraps the built-in deterministic \`core/bump\` Action. Single-node
6892
6996
  mode bumps one path; \`--pending\` walks every node whose sidecar
@@ -6901,7 +7005,7 @@ var BumpCommand = class extends SmCommand {
6901
7005
  \`--staged\` (only valid with \`--pending\`) runs \`git add\` on
6902
7006
  each successfully-bumped \`.sm\` file so the new content lands in
6903
7007
  the same commit. Requires a git binary on PATH and a parent
6904
- \`.git/\` \u2014 missing repo exits 5, missing binary exits 2.
7008
+ \`.git/\`: missing repo exits 5, missing binary exits 2.
6905
7009
  `,
6906
7010
  examples: [
6907
7011
  ["Bump a single node", "$0 bump .claude/agents/architect.md"],
@@ -7036,7 +7140,7 @@ var BumpCommand = class extends SmCommand {
7036
7140
  * Handle the three non-`bumped` outcomes for single-node mode
7037
7141
  * (`error`, `refused`, `skipped`). Returns the verb's exit code.
7038
7142
  * The caller pre-narrows on `item.status !== 'bumped'` so this
7039
- * method's union is exhaustive the `skipped` branch is the only
7143
+ * method's union is exhaustive, the `skipped` branch is the only
7040
7144
  * one that exits with `Ok` (silent no-op for fresh + --force).
7041
7145
  */
7042
7146
  #renderTerminalSingle(item, node, ansi) {
@@ -7336,8 +7440,8 @@ function matchesAnalyzerFilter(analyzerId, filter) {
7336
7440
  // cli/i18n/check.texts.ts
7337
7441
  var CHECK_TEXTS = {
7338
7442
  noIssues: "{{glyph}} No issues.\n",
7339
- /** Header summary line: `sm check 10 warnings · 0 errors`. */
7340
- summaryHeader: "sm check \u2014 {{summary}}\n\n",
7443
+ /** Header summary line: `sm check: 10 warnings · 0 errors`. */
7444
+ summaryHeader: "sm check: {{summary}}\n\n",
7341
7445
  /** Section heading: one per file with at least one issue. */
7342
7446
  fileSection: " {{file}}\n",
7343
7447
  /**
@@ -7349,8 +7453,8 @@ var CHECK_TEXTS = {
7349
7453
  /** Footer hint, separated from the body by a blank line. */
7350
7454
  tipLine: "\nTip: `sm refresh <node>` to revalidate a file after fixes.\n",
7351
7455
  // --- prob stub advisory ---------------------------------------------------
7352
- probStubAdvisory: "sm check --include-prob: probabilistic Analyzer dispatch requires the job subsystem (Step 10). Stub: skipped {{count}} probabilistic analyzer(s) \u2014 {{analyzerIds}}. Deterministic analyzers ran as usual; full dispatch lands when the job subsystem ships.\n",
7353
- probStubAdvisoryAsync: "sm check --include-prob --async: probabilistic Analyzer dispatch requires the job subsystem (Step 10). Stub: skipped {{count}} probabilistic analyzer(s) \u2014 {{analyzerIds}}. The --async flag is reserved for future encoding (returns job ids without waiting once jobs land); today it is a no-op. Deterministic analyzers ran as usual.\n"
7456
+ probStubAdvisory: "sm check --include-prob: probabilistic Analyzer dispatch requires the job subsystem (Step 10). Stub: skipped {{count}} probabilistic analyzer(s): {{analyzerIds}}. Deterministic analyzers ran as usual; full dispatch lands when the job subsystem ships.\n",
7457
+ probStubAdvisoryAsync: "sm check --include-prob --async: probabilistic Analyzer dispatch requires the job subsystem (Step 10). Stub: skipped {{count}} probabilistic analyzer(s): {{analyzerIds}}. The --async flag is reserved for future encoding (returns job ids without waiting once jobs land); today it is a no-op. Deterministic analyzers ran as usual.\n"
7354
7458
  };
7355
7459
 
7356
7460
  // cli/util/conformance-env.ts
@@ -7372,25 +7476,25 @@ var PLUGIN_LOADER_TEXTS = {
7372
7476
  invalidSpecCompat: 'specCompat "{{specCompat}}" is not a valid semver range. Use a range like "^1.0.0".',
7373
7477
  incompatibleSpec: `@skill-map/spec {{installedSpecVersion}} does not satisfy specCompat "{{specCompat}}". Either update the plugin's specCompat (and re-test) or pin sm to a compatible spec version.`,
7374
7478
  loadErrorFileNotFound: "extension file not found: {{relEntry}} (resolved to {{abs}}). Check plugin.json#/extensions paths.",
7375
- loadErrorImportFailed: "{{relEntry}}: import failed \u2014 {{errDescription}}",
7479
+ loadErrorImportFailed: "{{relEntry}}: import failed: {{errDescription}}",
7376
7480
  loadErrorMissingKind: "{{relEntry}}: default export missing a string `kind` field. Expected one of: {{knownKindsList}}.",
7377
7481
  loadErrorUnknownKind: '{{relEntry}}: unknown extension kind "{{kindReceived}}". Expected one of: {{knownKindsList}}.',
7378
- invalidManifestExtensionShape: "{{relEntry}}: {{kind}} manifest invalid \u2014 {{errors}}. See spec/schemas/extensions/{{kind}}.schema.json.",
7379
- importExceededTimeout: "import exceeded {{timeoutMs}}ms \u2014 likely a top-level side effect (network call, infinite loop, large blocking work). Move side effects into the runtime methods (`detect` / `evaluate` / `render` / etc.).",
7482
+ invalidManifestExtensionShape: "{{relEntry}}: {{kind}} manifest invalid: {{errors}}. See spec/schemas/extensions/{{kind}}.schema.json.",
7483
+ importExceededTimeout: "import exceeded {{timeoutMs}}ms; likely a top-level side effect (network call, infinite loop, large blocking work). Move side effects into the runtime methods (`detect` / `evaluate` / `render` / etc.).",
7380
7484
  disabledByConfig: "disabled by config_plugins or settings.json",
7381
7485
  invalidManifestDirMismatch: "directory name '{{dirName}}' does not match manifest id '{{manifestId}}'. Rename the directory to match the id, or update the manifest id to match the directory.",
7382
7486
  idCollision: "Plugin '{{id}}' at {{pathA}} collides with the plugin at {{pathB}}. Rename one and rerun.",
7383
- loadErrorPluginIdMismatch: "{{relEntry}}: extension declares pluginId '{{declared}}' but its plugin.json declares id '{{manifestId}}'. Remove the explicit pluginId from the extension \u2014 the loader injects it from plugin.json#/id.",
7384
- loadErrorStorageSchemaRead: "plugin '{{pluginId}}' failed to load schema for table '{{table}}': {{schemaPath}} \u2014 {{errDescription}}",
7385
- loadErrorStorageSchemaCompile: "plugin '{{pluginId}}' failed to compile schema for table '{{table}}': {{schemaPath}} \u2014 {{errDescription}}",
7386
- loadErrorStorageKvSchemaRead: "plugin '{{pluginId}}' failed to load KV schema: {{schemaPath}} \u2014 {{errDescription}}",
7387
- loadErrorStorageKvSchemaCompile: "plugin '{{pluginId}}' failed to compile KV schema: {{schemaPath}} \u2014 {{errDescription}}",
7487
+ loadErrorPluginIdMismatch: "{{relEntry}}: extension declares pluginId '{{declared}}' but its plugin.json declares id '{{manifestId}}'. Remove the explicit pluginId from the extension; the loader injects it from plugin.json#/id.",
7488
+ loadErrorStorageSchemaRead: "plugin '{{pluginId}}' failed to load schema for table '{{table}}': {{schemaPath}}: {{errDescription}}",
7489
+ loadErrorStorageSchemaCompile: "plugin '{{pluginId}}' failed to compile schema for table '{{table}}': {{schemaPath}}: {{errDescription}}",
7490
+ loadErrorStorageKvSchemaRead: "plugin '{{pluginId}}' failed to load KV schema: {{schemaPath}}: {{errDescription}}",
7491
+ loadErrorStorageKvSchemaCompile: "plugin '{{pluginId}}' failed to compile KV schema: {{schemaPath}}: {{errDescription}}",
7388
7492
  invalidManifestHookUnknownTrigger: "Hook '{{hookId}}' declares unknown trigger '{{trigger}}'. Hookable triggers: {{hookableList}}.",
7389
7493
  invalidManifestHookEmptyTriggers: "Hook '{{hookId}}' declares no triggers. At least one entry from the curated set is required.",
7390
7494
  loadErrorPathEscapesPlugin: "extension entry '{{relEntry}}' resolves outside the plugin directory ({{pluginPath}}). Plugin entries must be relative paths inside the plugin tree.",
7391
7495
  loadErrorSchemaPathEscapesPlugin: "schema path '{{relPath}}' resolves outside the plugin directory ({{pluginPath}}). Plugin schemas must be relative paths inside the plugin tree.",
7392
- invalidManifestRootSharedAnnotation: "{{relEntry}}: annotationContributions['{{key}}'] declares location: 'root' with ownership: '{{ownership}}' \u2014 root keys MUST be 'exclusive' (a top-level reserved key cannot be silently shared between plugins).",
7393
- invalidManifestAnnotationSchemaCompile: "{{relEntry}}: annotationContributions['{{key}}'].schema is not a valid JSON Schema \u2014 {{errDescription}}",
7496
+ invalidManifestRootSharedAnnotation: "{{relEntry}}: annotationContributions['{{key}}'] declares location: 'root' with ownership: '{{ownership}}'; root keys MUST be 'exclusive' (a top-level reserved key cannot be silently shared between plugins).",
7497
+ invalidManifestAnnotationSchemaCompile: "{{relEntry}}: annotationContributions['{{key}}'].schema is not a valid JSON Schema: {{errDescription}}",
7394
7498
  fatalAnnotationRootCollision: "Annotation root-key collision: '{{key}}' is claimed with ownership: 'exclusive' by multiple plugins ({{plugins}}). The kernel cannot boot with this configuration. Rename or merge the contributions and rerun."
7395
7499
  };
7396
7500
 
@@ -7756,10 +7860,10 @@ var PluginLoader = class {
7756
7860
  return out;
7757
7861
  }
7758
7862
  /**
7759
- * Full pass discover every plugin, attempt to load each, then apply
7863
+ * Full pass, discover every plugin, attempt to load each, then apply
7760
7864
  * the cross-root id-collision pass over the results. Two plugins that
7761
7865
  * survived their individual load with the same `manifest.id` both get
7762
- * downgraded to status `id-collision` (no precedence the spec is
7866
+ * downgraded to status `id-collision` (no precedence, the spec is
7763
7867
  * explicit that "no extension is privileged"). Plugins that already
7764
7868
  * failed their individual load (`invalid-manifest` /
7765
7869
  * `incompatible-spec` / `load-error`) keep their original status:
@@ -7776,7 +7880,7 @@ var PluginLoader = class {
7776
7880
  return applyIdCollisions(out);
7777
7881
  }
7778
7882
  /**
7779
- * Load a single plugin from its directory. Never throws a failure is
7883
+ * Load a single plugin from its directory. Never throws, a failure is
7780
7884
  * reported via the returned status.
7781
7885
  */
7782
7886
  // eslint-disable-next-line complexity
@@ -7818,7 +7922,7 @@ var PluginLoader = class {
7818
7922
  };
7819
7923
  }
7820
7924
  /**
7821
- * Phase 1 of `loadOne` read `plugin.json`, AJV-validate the manifest,
7925
+ * Phase 1 of `loadOne`, read `plugin.json`, AJV-validate the manifest,
7822
7926
  * enforce the directory-name == manifest.id structural rule, and check
7823
7927
  * specCompat (range syntax + satisfies the installed spec version).
7824
7928
  * Returns either the validated manifest or an `IDiscoveredPlugin` with
@@ -7895,7 +7999,7 @@ var PluginLoader = class {
7895
7999
  return { ok: true, manifest };
7896
8000
  }
7897
8001
  /**
7898
- * Phase 3 of `loadOne` load and validate one extension entry. Six
8002
+ * Phase 3 of `loadOne`, load and validate one extension entry. Six
7899
8003
  * sub-checks (file exists, dynamic import, has kind, kind known,
7900
8004
  * pluginId match, kind-specific manifest validation including hook
7901
8005
  * trigger pre-check). On success returns the `ILoadedExtension` with
@@ -8046,20 +8150,37 @@ var LOCKED_PLUGIN_IDS = /* @__PURE__ */ new Set([
8046
8150
  // `core/markdown` is the universal `.md` fallback Provider (see
8047
8151
  // spec/architecture.md §"core/markdown is the universal fallback for
8048
8152
  // unclaimed `.md` files"). Disabling it makes every orphan markdown
8049
- // silently invisible a foot-gun the host product does not want to
8153
+ // silently invisible, a foot-gun the host product does not want to
8050
8154
  // expose. Lock it in the enabled state.
8051
8155
  "core/markdown",
8052
8156
  // `core/annotations` turns the `supersedes` / `supersededBy` /
8053
8157
  // `requires` / `related` / `conflictsWith` entries of the sidecar
8054
8158
  // `annotations:` block into the arrows the graph draws between nodes.
8055
8159
  // It does NOT own the rest of the block (`version`, `stability`,
8056
- // `tags`, `description` those live on the node bundle directly and
8160
+ // `tags`, `description`, those live on the node bundle directly and
8057
8161
  // keep rendering with the plugin off). Disabling it produces a
8058
8162
  // confusing "edges disappear but the sidecar metadata stays" split
8059
8163
  // that no operator actually wants; the lock makes the asymmetry
8060
8164
  // unreachable from CLI / BFF / UI. Re-evaluate if a third-party ever
8061
8165
  // ships a competing supersession extractor.
8062
- "core/annotations"
8166
+ "core/annotations",
8167
+ // `core/validate-all` validates every scanned Node against
8168
+ // `node.schema.json` and every Link against `link.schema.json` (the
8169
+ // authoritative @skill-map/spec). Disabling it makes the system
8170
+ // persist non-conformant content silently, breaking the spec
8171
+ // invariant "what reaches the DB conforms to the spec". The check is
8172
+ // foundational, not advisory; lock it on so the guarantee holds
8173
+ // regardless of user / DB / settings hand-edits.
8174
+ "core/validate-all",
8175
+ // `core/ascii` is the only built-in Formatter today and the default
8176
+ // for `sm graph` (`--format ascii`). Disabling it breaks the verb
8177
+ // entirely (`composeFormatters` returns the empty list, the CLI
8178
+ // prints "no formatter registered for 'ascii'" and exits with an
8179
+ // error) with no useful fallback. Lock it on until additional
8180
+ // formatters land (mermaid / dot / json, deferred in ROADMAP § Built-in
8181
+ // graph formatters); revisit the lock once `sm graph` has a real
8182
+ // catalog to choose from.
8183
+ "core/ascii"
8063
8184
  ]);
8064
8185
  function isPluginLocked(idOrQualified) {
8065
8186
  return LOCKED_PLUGIN_IDS.has(idOrQualified);
@@ -8117,7 +8238,7 @@ async function buildEnabledResolver(scope, ctx) {
8117
8238
  }
8118
8239
 
8119
8240
  // kernel/scan/walk-content.ts
8120
- import { readFile, readdir, stat } from "fs/promises";
8241
+ import { readFile, readdir, lstat } from "fs/promises";
8121
8242
  import { join as join6, relative as relative2, sep as sep2 } from "path";
8122
8243
 
8123
8244
  // kernel/scan/ignore.ts
@@ -8200,7 +8321,6 @@ function readDefaultsFromDisk() {
8200
8321
  // built-in-plugins/parsers/frontmatter-yaml/index.ts
8201
8322
  import yaml3 from "js-yaml";
8202
8323
  var FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
8203
- var FORBIDDEN_FRONTMATTER_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
8204
8324
  var frontmatterYamlParser = {
8205
8325
  id: "frontmatter-yaml",
8206
8326
  parse(raw, _path) {
@@ -8208,20 +8328,30 @@ var frontmatterYamlParser = {
8208
8328
  if (!match) return { frontmatterRaw: "", frontmatter: {}, body: raw };
8209
8329
  const frontmatterRaw = match[1];
8210
8330
  const body = match[2];
8211
- const parsed = {};
8331
+ let parsed = {};
8332
+ const issues = [];
8212
8333
  try {
8213
8334
  const doc = yaml3.load(frontmatterRaw, { schema: yaml3.JSON_SCHEMA });
8214
8335
  if (doc && typeof doc === "object" && !Array.isArray(doc)) {
8215
- for (const [k, v] of Object.entries(doc)) {
8216
- if (FORBIDDEN_FRONTMATTER_KEYS.has(k)) continue;
8217
- parsed[k] = v;
8218
- }
8336
+ parsed = stripPrototypePollution(doc);
8219
8337
  }
8220
- } catch {
8338
+ } catch (err) {
8339
+ issues.push({
8340
+ code: "frontmatter-parse-error",
8341
+ message: sanitiseParseErrorMessage(err)
8342
+ });
8343
+ }
8344
+ const out = { frontmatterRaw, frontmatter: parsed, body };
8345
+ if (issues.length > 0) {
8346
+ return { ...out, issues };
8221
8347
  }
8222
- return { frontmatterRaw, frontmatter: parsed, body };
8348
+ return out;
8223
8349
  }
8224
8350
  };
8351
+ function sanitiseParseErrorMessage(err) {
8352
+ const raw = err instanceof Error ? err.message : String(err);
8353
+ return raw.replace(/[-]+/g, " ").replace(/\s+/g, " ").trim();
8354
+ }
8225
8355
 
8226
8356
  // built-in-plugins/parsers/plain/index.ts
8227
8357
  var plainParser = {
@@ -8267,7 +8397,12 @@ async function* walkContent(roots, options) {
8267
8397
  path: relPath,
8268
8398
  body: parsed.body,
8269
8399
  frontmatterRaw: parsed.frontmatterRaw,
8270
- frontmatter: parsed.frontmatter
8400
+ frontmatter: parsed.frontmatter,
8401
+ // Audit L1: forward parser diagnostics (e.g. malformed YAML)
8402
+ // through the IRawNode surface so the orchestrator can
8403
+ // convert them into warn-level kernel `Issue` rows. Omitted
8404
+ // when the parser reported no issues (happy path).
8405
+ ...parsed.issues && parsed.issues.length > 0 ? { parseIssues: parsed.issues } : {}
8271
8406
  };
8272
8407
  }
8273
8408
  }
@@ -8289,7 +8424,7 @@ async function* walkRoot(root, current, filter, extensions) {
8289
8424
  yield* walkRoot(root, full, filter, extensions);
8290
8425
  } else if (entry.isFile() && hasMatchingExtension(name, extensions)) {
8291
8426
  try {
8292
- const s = await stat(full);
8427
+ const s = await lstat(full);
8293
8428
  if (s.isFile()) yield full;
8294
8429
  } catch {
8295
8430
  }
@@ -8364,7 +8499,7 @@ function bucketLoaded(loaded, bundle) {
8364
8499
  analyzer: bundle.extensions.analyzers,
8365
8500
  formatter: bundle.extensions.formatters,
8366
8501
  hook: bundle.extensions.hooks
8367
- // `action` intentionally absent see docstring.
8502
+ // `action` intentionally absent, see docstring.
8368
8503
  });
8369
8504
  bundle.manifests.push({
8370
8505
  id: ext.id,
@@ -8420,7 +8555,7 @@ var PLUGIN_RUNTIME_TEXTS = {
8420
8555
  * status word and the reason scannable so a user can grep
8421
8556
  * `incompatible-spec` / `invalid-manifest` / `load-error`.
8422
8557
  */
8423
- warningRow: "plugin {{id}}: {{status}} \u2014 {{reason}}",
8558
+ warningRow: "plugin {{id}}: {{status}}, {{reason}}",
8424
8559
  /** Placeholder when a non-loaded plugin record carries no `reason`. */
8425
8560
  warningReasonMissing: "(no reason recorded)"
8426
8561
  };
@@ -8670,7 +8805,7 @@ function registerEnabledExtensions(kernel, pluginRuntime, options = {}) {
8670
8805
  // catalog row carries `pluginId`, not `extensionId`), so the
8671
8806
  // bundle-level toggle gates the entire row. Extension
8672
8807
  // granularity falls through to the manifest-level filter above
8673
- // this surface is bundle-scoped by design.
8808
+ // this surface is bundle-scoped by design.
8674
8809
  resolveEnabled(entry.pluginId)
8675
8810
  )
8676
8811
  );
@@ -8710,7 +8845,7 @@ var CheckCommand = class extends SmCommand {
8710
8845
  Run \`sm scan\` first to populate the DB.
8711
8846
 
8712
8847
  \`--include-prob\` is an opt-in flag for probabilistic Analyzer
8713
- dispatch (spec \xA7 A.7). Default is deterministic-only \u2014 same
8848
+ dispatch (spec \xA7 A.7). Default is deterministic-only: same
8714
8849
  CI-safe behaviour as before. With the flag, registered prob
8715
8850
  rules are detected and named in a stderr advisory; full
8716
8851
  dispatch lands when the job subsystem ships at Step 10.
@@ -8928,7 +9063,7 @@ var CONFIG_TEXTS = {
8928
9063
  /**
8929
9064
  * Surfaced when a PROJECT_LOCAL_ONLY key (`allowEditSmFiles` /
8930
9065
  * `scan.extraFolders` / `scan.referencePaths`) reaches the writer
8931
- * with `target: 'project'` defensive only, the CLI auto-routes to
9066
+ * with `target: 'project'`, defensive only, the CLI auto-routes to
8932
9067
  * `project-local`, but the helper enforces the rule for any other
8933
9068
  * caller too.
8934
9069
  */
@@ -8959,8 +9094,8 @@ var CONFIG_TEXTS = {
8959
9094
  * indented under the section heading.
8960
9095
  */
8961
9096
  listRow: " {{key}} {{value}}\n",
8962
- /** Placeholder for null / empty array / empty object printed dim. */
8963
- listEmptyValue: "\u2014",
9097
+ /** Placeholder for null / empty array / empty object, printed dim. */
9098
+ listEmptyValue: "-",
8964
9099
  /** Section titles. */
8965
9100
  listSectionGeneral: "General",
8966
9101
  listSectionScan: "Scan",
@@ -9435,7 +9570,7 @@ var ConfigResetCommand = class extends SmCommand {
9435
9570
  description: "Remove a config key from the target file (project default; -g for user).",
9436
9571
  details: `
9437
9572
  Strips the key from the target settings.json (lower layers still apply).
9438
- Idempotent \u2014 running twice is safe; absent key prints an info note and exits 0.
9573
+ Idempotent: running twice is safe; absent key prints an info note and exits 0.
9439
9574
  `
9440
9575
  });
9441
9576
  key = Option4.String({ required: true });
@@ -9901,7 +10036,7 @@ function resolveSpecRoot4() {
9901
10036
  return dirname10(indexPath);
9902
10037
  } catch {
9903
10038
  throw new Error(
9904
- "@skill-map/spec not resolvable \u2014 ensure the workspace is linked or the package is installed."
10039
+ "@skill-map/spec not resolvable: ensure the workspace is linked or the package is installed."
9905
10040
  );
9906
10041
  }
9907
10042
  }
@@ -9918,7 +10053,7 @@ function resolveCliWorkspaceRoot() {
9918
10053
  cursor = parent;
9919
10054
  }
9920
10055
  throw new Error(
9921
- `sm conformance: built-in Provider conformance assets not found (expected a 'built-in-plugins/providers/' directory above ${here}). The bundled CLI may not yet copy the assets \u2014 run from the source workspace, or rebuild after enabling the asset-copy step.`
10056
+ `sm conformance: built-in Provider conformance assets not found (expected a 'built-in-plugins/providers/' directory above ${here}). The bundled CLI may not yet copy the assets; run from the source workspace, or rebuild after enabling the asset-copy step.`
9922
10057
  );
9923
10058
  }
9924
10059
  function collectProviderScopes(specRoot) {
@@ -10007,7 +10142,7 @@ var ConformanceRunCommand = class extends SmCommand {
10007
10142
  static paths = [["conformance", "run"]];
10008
10143
  static usage = Command5.Usage({
10009
10144
  category: "Introspection",
10010
- description: "Run the conformance suite \u2014 spec-owned cases plus every built-in Provider.",
10145
+ description: "Run the conformance suite: spec-owned cases plus every built-in Provider.",
10011
10146
  details: `
10012
10147
  Drives the conformance runner shipped at
10013
10148
  \`@skill-map/cli/conformance\` against the cases bundled with
@@ -10055,11 +10190,22 @@ var ConformanceRunCommand = class extends SmCommand {
10055
10190
  scopes = selectConformanceScopes(this.scope);
10056
10191
  } catch (err) {
10057
10192
  const message = formatErrorMessage(err);
10193
+ if (this.json) {
10194
+ this.#emitJsonError("bad-query", message);
10195
+ return ExitCode.Error;
10196
+ }
10058
10197
  this.printer.error(tx(CONFORMANCE_TEXTS.unknownScope, { glyph: errGlyph, message }));
10059
10198
  return ExitCode.Error;
10060
10199
  }
10061
10200
  const binary = resolveBinary();
10062
10201
  if (!existsSync16(binary)) {
10202
+ if (this.json) {
10203
+ this.#emitJsonError(
10204
+ "internal",
10205
+ `cannot locate the sm binary at ${binary}`
10206
+ );
10207
+ return ExitCode.Error;
10208
+ }
10063
10209
  this.printer.error(
10064
10210
  tx(CONFORMANCE_TEXTS.noBinary, {
10065
10211
  glyph: errGlyph,
@@ -10072,21 +10218,28 @@ var ConformanceRunCommand = class extends SmCommand {
10072
10218
  let totalPass = 0;
10073
10219
  let totalCases = 0;
10074
10220
  let anyFailure = false;
10221
+ const scopeReports = [];
10075
10222
  for (const scope of scopes) {
10076
10223
  const cases = listCaseFiles(scope);
10077
10224
  if (cases.length === 0) {
10225
+ if (!this.json) {
10226
+ this.printer.data(
10227
+ tx(CONFORMANCE_TEXTS.scopeEmpty, { label: scope.label })
10228
+ );
10229
+ }
10230
+ scopeReports.push({ label: scope.label, passCount: 0, caseCount: 0, cases: [] });
10231
+ continue;
10232
+ }
10233
+ if (!this.json) {
10078
10234
  this.printer.data(
10079
- tx(CONFORMANCE_TEXTS.scopeEmpty, { label: scope.label })
10235
+ tx(CONFORMANCE_TEXTS.scopeHeader, {
10236
+ label: scope.label,
10237
+ caseCount: cases.length
10238
+ })
10080
10239
  );
10081
- continue;
10082
10240
  }
10083
- this.printer.data(
10084
- tx(CONFORMANCE_TEXTS.scopeHeader, {
10085
- label: scope.label,
10086
- caseCount: cases.length
10087
- })
10088
- );
10089
10241
  let scopePass = 0;
10242
+ const caseReports = [];
10090
10243
  for (const casePath of cases) {
10091
10244
  const caseId = readCaseId(casePath);
10092
10245
  try {
@@ -10097,51 +10250,92 @@ var ConformanceRunCommand = class extends SmCommand {
10097
10250
  fixturesRoot: scope.fixturesDir
10098
10251
  });
10099
10252
  if (result.passed) {
10100
- this.printer.data(
10101
- tx(CONFORMANCE_TEXTS.caseOk, { caseId: result.caseId })
10102
- );
10253
+ if (!this.json) {
10254
+ this.printer.data(
10255
+ tx(CONFORMANCE_TEXTS.caseOk, { caseId: result.caseId })
10256
+ );
10257
+ }
10258
+ caseReports.push({ id: result.caseId, status: "pass", failures: [] });
10103
10259
  scopePass += 1;
10104
10260
  } else {
10105
10261
  anyFailure = true;
10106
- this.printer.data(
10107
- tx(CONFORMANCE_TEXTS.caseFail, { caseId: result.caseId })
10108
- );
10109
- for (const a of result.assertions) {
10110
- if (a.ok) continue;
10111
- this.printer.info(
10112
- formatAssertionFailureDetail(a.type, a.reason)
10262
+ const failures = projectAssertionFailures(result.assertions);
10263
+ if (!this.json) {
10264
+ this.printer.data(
10265
+ tx(CONFORMANCE_TEXTS.caseFail, { caseId: result.caseId })
10266
+ );
10267
+ for (const a of result.assertions) {
10268
+ if (a.ok) continue;
10269
+ this.printer.info(
10270
+ formatAssertionFailureDetail(a.type, a.reason)
10271
+ );
10272
+ }
10273
+ writeStreamSnippet(
10274
+ this.context.stderr,
10275
+ CONFORMANCE_TEXTS.caseFailureStdoutHeader,
10276
+ result.stdout
10277
+ );
10278
+ writeStreamSnippet(
10279
+ this.context.stderr,
10280
+ CONFORMANCE_TEXTS.caseFailureStderrHeader,
10281
+ result.stderr
10113
10282
  );
10114
10283
  }
10115
- writeStreamSnippet(
10116
- this.context.stderr,
10117
- CONFORMANCE_TEXTS.caseFailureStdoutHeader,
10118
- result.stdout
10119
- );
10120
- writeStreamSnippet(
10121
- this.context.stderr,
10122
- CONFORMANCE_TEXTS.caseFailureStderrHeader,
10123
- result.stderr
10124
- );
10284
+ caseReports.push({ id: result.caseId, status: "fail", failures });
10125
10285
  }
10126
10286
  } catch (err) {
10127
10287
  anyFailure = true;
10128
10288
  const message = formatErrorMessage(err);
10129
- this.printer.error(
10130
- tx(CONFORMANCE_TEXTS.runtimeError, { glyph: errGlyph, message })
10131
- );
10132
- this.printer.data(tx(CONFORMANCE_TEXTS.caseFail, { caseId }));
10289
+ if (!this.json) {
10290
+ this.printer.error(
10291
+ tx(CONFORMANCE_TEXTS.runtimeError, { glyph: errGlyph, message })
10292
+ );
10293
+ this.printer.data(tx(CONFORMANCE_TEXTS.caseFail, { caseId }));
10294
+ }
10295
+ caseReports.push({
10296
+ id: caseId,
10297
+ status: "fail",
10298
+ failures: [{
10299
+ type: "runtime-error",
10300
+ reason: sanitizeForTerminal(truncateHead(message, ASSERTION_REASON_DISPLAY_CAP))
10301
+ }]
10302
+ });
10133
10303
  }
10134
10304
  }
10135
- this.printer.data(
10136
- tx(CONFORMANCE_TEXTS.scopeSummary, {
10137
- label: scope.label,
10138
- passCount: scopePass,
10139
- caseCount: cases.length
10140
- })
10141
- );
10305
+ if (!this.json) {
10306
+ this.printer.data(
10307
+ tx(CONFORMANCE_TEXTS.scopeSummary, {
10308
+ label: scope.label,
10309
+ passCount: scopePass,
10310
+ caseCount: cases.length
10311
+ })
10312
+ );
10313
+ }
10314
+ scopeReports.push({
10315
+ label: scope.label,
10316
+ passCount: scopePass,
10317
+ caseCount: cases.length,
10318
+ cases: caseReports
10319
+ });
10142
10320
  totalPass += scopePass;
10143
10321
  totalCases += cases.length;
10144
10322
  }
10323
+ if (this.json) {
10324
+ const envelope = {
10325
+ ok: true,
10326
+ kind: "conformance.result",
10327
+ totals: {
10328
+ scopes: scopes.length,
10329
+ cases: totalCases,
10330
+ passCount: totalPass,
10331
+ failCount: totalCases - totalPass
10332
+ },
10333
+ scopes: scopeReports,
10334
+ elapsedMs: this.elapsed.ms()
10335
+ };
10336
+ this.printer.data(JSON.stringify(envelope) + "\n");
10337
+ return anyFailure ? ExitCode.Issues : ExitCode.Ok;
10338
+ }
10145
10339
  this.printer.data(
10146
10340
  tx(CONFORMANCE_TEXTS.totalSummary, {
10147
10341
  passCount: totalPass,
@@ -10152,7 +10346,30 @@ var ConformanceRunCommand = class extends SmCommand {
10152
10346
  if (anyFailure) return ExitCode.Issues;
10153
10347
  return ExitCode.Ok;
10154
10348
  }
10349
+ /**
10350
+ * Emit the canonical `--json` error envelope on stdout. Mirrors the
10351
+ * shape from `cli-contract.md` §Error envelope. Suppresses the
10352
+ * human-facing glyph + hint output that the non-JSON branches still
10353
+ * render.
10354
+ */
10355
+ #emitJsonError(code, message) {
10356
+ const payload = { ok: false, error: { code, message } };
10357
+ this.printer.data(JSON.stringify(payload) + "\n");
10358
+ }
10155
10359
  };
10360
+ function projectAssertionFailures(assertions) {
10361
+ const out = [];
10362
+ for (const a of assertions) {
10363
+ if (a.ok) continue;
10364
+ out.push({
10365
+ type: a.type,
10366
+ reason: sanitizeForTerminal(
10367
+ truncateHead(a.reason ?? "", ASSERTION_REASON_DISPLAY_CAP)
10368
+ )
10369
+ });
10370
+ }
10371
+ return out;
10372
+ }
10156
10373
  function readCaseId(casePath) {
10157
10374
  try {
10158
10375
  const raw = readFileSync13(casePath, "utf8");
@@ -10226,13 +10443,13 @@ var DB_TEXTS = {
10226
10443
  pluginMigrateApplied: "{{glyph}} plugin {{pluginId}} \xB7 Applied {{count}} migration(s)\n",
10227
10444
  pluginMigrateIntrusion: "plugin {{pluginId}} \xB7 catalog intrusion detected: {{intrusions}}\n",
10228
10445
  // --- dry-run previews ------------------------------------------------
10229
- dryRunHeader: "(dry-run \u2014 no DB writes, no file unlinks)\n",
10230
- dryRunResetWouldClearNone: "would clear 0 table(s): (none \u2014 DB schema is empty)\n",
10446
+ dryRunHeader: "(dry-run, no DB writes, no file unlinks)\n",
10447
+ dryRunResetWouldClearNone: "would clear 0 table(s): (none, DB schema is empty)\n",
10231
10448
  // The `lines` arg is a pre-built multi-line block, one " - name: N row(s)"
10232
10449
  // per table, joined with `\n`.
10233
10450
  dryRunResetWouldClearWithRowCounts: "would clear {{tableCount}} table(s) ({{totalRows}} total row(s)):\n{{lines}}\n",
10234
10451
  dryRunResetHardWouldDelete: "would delete {{path}} ({{sizeBytes}} bytes)\n",
10235
- dryRunResetHardWouldDeleteMissing: "would delete {{path}} (file does not exist \u2014 no-op)\n",
10452
+ dryRunResetHardWouldDeleteMissing: "would delete {{path}} (file does not exist, no-op)\n",
10236
10453
  // The `targetClause` arg is one of two pre-built strings:
10237
10454
  // "(exists, would be overwritten)" / "(does not exist, would be created)".
10238
10455
  dryRunRestoreWouldOverwrite: "would copy {{sourcePath}} ({{sourceBytes}} bytes) \u2192 {{target}} {{targetClause}}\nwould delete {{target}}-wal and {{target}}-shm sidecars if present\n",
@@ -10249,7 +10466,7 @@ var DbBackupCommand = class extends SmCommand {
10249
10466
  details: `
10250
10467
  Default output: <db-dir>/backups/<timestamp>.db. Use --out to override.
10251
10468
  scan_* is regenerated on demand and is NOT excluded from the raw file
10252
- copy, but restoring a backup over a live DB is the expected use \u2014
10469
+ copy, but restoring a backup over a live DB is the expected use;
10253
10470
  running sm scan afterwards refreshes scan_*.
10254
10471
  `
10255
10472
  });
@@ -10281,10 +10498,10 @@ import { dirname as dirname13, resolve as resolve23 } from "path";
10281
10498
  import { Command as Command7, Option as Option7 } from "clipanion";
10282
10499
 
10283
10500
  // cli/util/fs.ts
10284
- import { stat as stat2 } from "fs/promises";
10501
+ import { stat } from "fs/promises";
10285
10502
  async function pathExists(path) {
10286
10503
  try {
10287
- await stat2(path);
10504
+ await stat(path);
10288
10505
  return true;
10289
10506
  } catch (err) {
10290
10507
  if (err.code === "ENOENT") return false;
@@ -10293,7 +10510,7 @@ async function pathExists(path) {
10293
10510
  }
10294
10511
  async function statOrNull(path) {
10295
10512
  try {
10296
- return await stat2(path);
10513
+ return await stat(path);
10297
10514
  } catch (err) {
10298
10515
  if (err.code === "ENOENT") return null;
10299
10516
  throw err;
@@ -10401,10 +10618,10 @@ var DbResetCommand = class extends SmCommand {
10401
10618
  category: "Database",
10402
10619
  description: "Drop scan_* (default), optionally state_*, or delete the DB entirely.",
10403
10620
  details: `
10404
- Without flags: drops scan_* tables only. Non-destructive \u2014 no prompt.
10405
- With --state: also drops state_* tables. Destructive \u2014 requires
10621
+ Without flags: drops scan_* tables only. Non-destructive, no prompt.
10622
+ With --state: also drops state_* tables. Destructive, requires
10406
10623
  confirmation unless --yes / --force.
10407
- With --hard: deletes the DB file entirely. Destructive \u2014 requires
10624
+ With --hard: deletes the DB file entirely. Destructive, requires
10408
10625
  confirmation unless --yes / --force.
10409
10626
  With --dry-run: previews what would be cleared / deleted without
10410
10627
  touching the DB. Bypasses the confirmation prompt entirely (the
@@ -10544,7 +10761,7 @@ var DbShellCommand = class extends SmCommand {
10544
10761
  `
10545
10762
  });
10546
10763
  // Interactive shell: the spawned `sqlite3` owns the terminal. No
10547
- // `done in <…>` line the user expects to see the shell's own
10764
+ // `done in <…>` line, the user expects to see the shell's own
10548
10765
  // prompt + farewell, not a follow-up trailer once they exit.
10549
10766
  emitElapsed = false;
10550
10767
  async run() {
@@ -10595,7 +10812,7 @@ var DbBrowserCommand = class extends SmCommand {
10595
10812
  ]
10596
10813
  });
10597
10814
  // GUI launch: the spawned process is detached and unref'd; we exit
10598
- // immediately. No `done in <…>` line the user expects to see the
10815
+ // immediately. No `done in <…>` line, the user expects to see the
10599
10816
  // GUI window, not a follow-up trailer in the terminal.
10600
10817
  emitElapsed = false;
10601
10818
  rw = Option9.Boolean("--rw", false, {
@@ -10637,7 +10854,7 @@ var DbDumpCommand = class extends SmCommand {
10637
10854
  static usage = Command11.Usage({
10638
10855
  category: "Database",
10639
10856
  description: "SQL dump to stdout.",
10640
- details: "Read-only. Pure node:sqlite \u2014 no external `sqlite3` binary required. Use --tables <names...> to limit the dump to specific tables."
10857
+ details: "Read-only. Pure node:sqlite; no external `sqlite3` binary required. Use --tables <names...> to limit the dump to specific tables."
10641
10858
  });
10642
10859
  tables = Option10.Array("--tables", { required: false });
10643
10860
  async run() {
@@ -11128,17 +11345,17 @@ var EXPORT_TEXTS = {
11128
11345
  /** Echo of the user's query string (or the empty placeholder). */
11129
11346
  mdQueryLine: "Query: `{{query}}`",
11130
11347
  /** Placeholder used when the user's query is empty. */
11131
- mdQueryEmpty: "(empty \u2014 all nodes)",
11348
+ mdQueryEmpty: "(empty, all nodes)",
11132
11349
  /** Counts summary line under the query. */
11133
11350
  mdCounts: "Counts: {{nodes}} nodes, {{links}} links, {{issues}} issues.",
11134
11351
  /** Section header for a single node-kind group. */
11135
11352
  mdKindSectionHeader: "## {{kind}} ({{count}})",
11136
11353
  /** Bullet template for a node row. `{{title}}` and `{{issues}}` are pre-rendered (empty when absent). */
11137
11354
  mdNodeBullet: "- `{{path}}`{{title}}{{issues}}",
11138
- /** ` "<title>"` segment when the node has a title. */
11139
- mdNodeTitleSuffix: ' \u2014 "{{title}}"',
11140
- /** ` N issue(s)` segment when the node has any associated issues. */
11141
- mdNodeIssueSuffix: " \u2014 {{count}} {{label}}",
11355
+ /** `: "<title>"` segment when the node has a title. */
11356
+ mdNodeTitleSuffix: ': "{{title}}"',
11357
+ /** ` (N issue(s))` segment when the node has any associated issues. */
11358
+ mdNodeIssueSuffix: " ({{count}} {{label}})",
11142
11359
  mdNodeIssueLabelSingular: "issue",
11143
11360
  mdNodeIssueLabelPlural: "issues",
11144
11361
  /** Section header for the links block. */
@@ -11169,10 +11386,10 @@ var ExportCommand = class extends SmCommand {
11169
11386
  Query syntax (v0.5.0): whitespace-separated key=value tokens; AND
11170
11387
  across keys, OR within comma-separated values. Keys: \`kind\`
11171
11388
  (skill / agent / command / note), \`has\` (issues), \`path\`
11172
- (POSIX glob \u2014 \`*\` matches a single segment, \`**\` matches across
11389
+ (POSIX glob: \`*\` matches a single segment, \`**\` matches across
11173
11390
  segments).
11174
11391
 
11175
- Pass an empty query (\`""\`) \u2014 or omit the argument entirely \u2014 to
11392
+ Pass an empty query (\`""\`), or omit the argument entirely, to
11176
11393
  export every node.
11177
11394
 
11178
11395
  Run \`sm scan\` first to populate the DB.
@@ -11431,7 +11648,12 @@ var GraphCommand = class extends SmCommand {
11431
11648
  const text = formatter.format({
11432
11649
  nodes: scan.nodes,
11433
11650
  links: scan.links,
11434
- issues: scan.issues
11651
+ issues: scan.issues,
11652
+ // Pass the full persisted scan so format-specific renderers
11653
+ // that mirror a `ScanResult` envelope (today: built-in `json`)
11654
+ // can emit it verbatim without re-deriving fields like
11655
+ // `schemaVersion` or `stats` from the three primary arrays.
11656
+ scanResult: scan
11435
11657
  });
11436
11658
  this.printer.data(text.endsWith("\n") ? text : text + "\n");
11437
11659
  return ExitCode.Ok;
@@ -11457,7 +11679,7 @@ var HELP_TEXTS = {
11457
11679
  mdSpecVersionLine: "- Spec version: `{{version}}`",
11458
11680
  // --- global flags section ------------------------------------------------
11459
11681
  mdHeaderGlobalFlags: "## Global flags",
11460
- mdGlobalFlagBullet: "- `{{name}}` \u2014 {{description}}",
11682
+ mdGlobalFlagBullet: "- `{{name}}`: {{description}}",
11461
11683
  /** Description copy for the `--help` global flag in the JSON / md output. */
11462
11684
  globalFlagHelpDescription: "Print usage and exit.",
11463
11685
  // --- per-category / per-verb (md) ----------------------------------------
@@ -11468,12 +11690,12 @@ var HELP_TEXTS = {
11468
11690
  mdFlagBullet: "- {{names}} `{{type}}`{{required}}{{description}}",
11469
11691
  /** Trailing fragment for `mdFlagBullet`'s `{{required}}` slot. */
11470
11692
  mdFlagBulletRequiredFragment: " (required)",
11471
- /** Trailing fragment for `mdFlagBullet`'s `{{description}}` slot (with leading em-dash). */
11472
- mdFlagBulletDescriptionFragment: " \u2014 {{description}}",
11693
+ /** Trailing fragment for `mdFlagBullet`'s `{{description}}` slot (with leading colon). */
11694
+ mdFlagBulletDescriptionFragment: ": {{description}}",
11473
11695
  mdExampleBullet: "- {{title}}",
11474
11696
  // --- human single-verb renderer ------------------------------------------
11475
11697
  /** Header line for `sm help <verb>` and `sm <verb> --help`. */
11476
- humanVerbHeader: "sm {{name}} \u2014 {{description}}",
11698
+ humanVerbHeader: "sm {{name}}: {{description}}",
11477
11699
  humanDescriptionHeading: "DESCRIPTION",
11478
11700
  humanUsageHeading: "USAGE",
11479
11701
  /**
@@ -11491,9 +11713,9 @@ var HELP_TEXTS = {
11491
11713
  // --- human compact overview (sm / sm --help / sm help, no verb) ---------
11492
11714
  /**
11493
11715
  * Compact-overview header. Replaces the Clipanion default ANSI banner.
11494
- * Tagline mirrors README.md "In a sentence" keep them in sync.
11716
+ * Tagline mirrors README.md "In a sentence", keep them in sync.
11495
11717
  */
11496
- compactHeader: "{{binary}} {{version}} \u2014 the missing map for Markdown-based generative-AI ecosystems",
11718
+ compactHeader: "{{binary}} {{version}}: the missing map for Markdown-based generative-AI ecosystems",
11497
11719
  compactUsageHeading: "USAGE",
11498
11720
  compactUsageLine: " sm <command> [options]",
11499
11721
  compactExamplesHeading: "EXAMPLES",
@@ -11503,7 +11725,7 @@ var HELP_TEXTS = {
11503
11725
  /**
11504
11726
  * Marker prepended to the description column for not-yet-implemented
11505
11727
  * verbs (those whose registered description carries `(planned)`).
11506
- * Trailing space is intentional the marker is concatenated before
11728
+ * Trailing space is intentional, the marker is concatenated before
11507
11729
  * the rest of the description.
11508
11730
  */
11509
11731
  compactStubMarker: "[stub] ",
@@ -11530,10 +11752,10 @@ var HelpCommand = class extends Command15 {
11530
11752
  With a verb: the detail view for that single command.
11531
11753
 
11532
11754
  Formats:
11533
- human (default) \u2014 pretty terminal output.
11534
- md \u2014 canonical markdown. context/cli-reference.md is
11535
- regenerated from this and CI fails on drift.
11536
- json \u2014 structured surface dump per spec/cli-contract.md.
11755
+ human (default): pretty terminal output.
11756
+ md : canonical markdown. context/cli-reference.md is
11757
+ regenerated from this and CI fails on drift.
11758
+ json : structured surface dump per spec/cli-contract.md.
11537
11759
  `
11538
11760
  });
11539
11761
  verbParts = Option14.Rest({ required: 0 });
@@ -11917,7 +12139,7 @@ import {
11917
12139
  mkdirSync as mkdirSync4,
11918
12140
  readFileSync as readFileSync15,
11919
12141
  statSync as statSync3,
11920
- writeFileSync as writeFileSync3
12142
+ writeFileSync
11921
12143
  } from "fs";
11922
12144
  import { dirname as dirname15, resolve as resolve26 } from "path";
11923
12145
  import { Command as Command16, Option as Option15 } from "clipanion";
@@ -11971,7 +12193,7 @@ var HooksInstallCommand = class extends SmCommand {
11971
12193
  replacing it.
11972
12194
 
11973
12195
  Requires a parent \`.git/\` (exit 5 otherwise). Writes nothing
11974
- under \`--dry-run\` \u2014 instead prints the planned content with
12196
+ under \`--dry-run\`; instead prints the planned content with
11975
12197
  \`--- target: <path> ---\` markers.
11976
12198
  `,
11977
12199
  examples: [
@@ -11981,7 +12203,7 @@ var HooksInstallCommand = class extends SmCommand {
11981
12203
  });
11982
12204
  flavour = Option15.String({ required: true });
11983
12205
  dryRun = Option15.Boolean("-n,--dry-run", false);
11984
- // The remaining cyclomatic count is from CLI ergonomics flavour
12206
+ // The remaining cyclomatic count is from CLI ergonomics, flavour
11985
12207
  // guard, repo lookup, marker detection, dry-run / json / chained /
11986
12208
  // fresh branches each contributing a guard. Inner work already lives
11987
12209
  // in `computePlannedHookContent` and `findGitRepoRoot`.
@@ -12040,7 +12262,7 @@ var HooksInstallCommand = class extends SmCommand {
12040
12262
  }
12041
12263
  try {
12042
12264
  if (!existsSync17(hooksDir)) mkdirSync4(hooksDir, { recursive: true });
12043
- writeFileSync3(hookPath, planned2.content, { encoding: "utf8" });
12265
+ writeFileSync(hookPath, planned2.content, { encoding: "utf8" });
12044
12266
  ensureExecutableBit(hookPath);
12045
12267
  } catch (err) {
12046
12268
  this.printer.error(
@@ -12103,13 +12325,14 @@ import cl100k_base from "js-tiktoken/ranks/cl100k_base";
12103
12325
  // kernel/i18n/orchestrator.texts.ts
12104
12326
  var ORCHESTRATOR_TEXTS = {
12105
12327
  frontmatterInvalid: "Frontmatter for {{path}} ({{kind}}) failed schema validation: {{errors}}",
12106
- frontmatterMalformedPasteWithIndent: "Frontmatter fence in {{path}} appears indented; YAML frontmatter MUST start with `---` at column 0. The file was scanned as body-only \u2014 the metadata block was silently lost. Move the `---` lines to the start of the line.",
12328
+ frontmatterMalformedPasteWithIndent: "Frontmatter fence in {{path}} appears indented; YAML frontmatter MUST start with `---` at column 0. The file was scanned as body-only; the metadata block was silently lost. Move the `---` lines to the start of the line.",
12107
12329
  frontmatterMalformedByteOrderMark: "Frontmatter fence in {{path}} is preceded by a UTF-8 byte-order mark (BOM); the file was scanned as body-only. Re-save the file as UTF-8 without BOM. The metadata block was silently lost.",
12108
- frontmatterMalformedMissingClose: "Frontmatter in {{path}} opens with `---` but never closes \u2014 no matching `---` line at column 0 was found. The file was scanned as body-only and every metadata field was silently lost. Add a closing `---` line below the metadata block.",
12330
+ frontmatterMalformedMissingClose: "Frontmatter in {{path}} opens with `---` but never closes (no matching `---` line at column 0 was found). The file was scanned as body-only and every metadata field was silently lost. Add a closing `---` line below the metadata block.",
12109
12331
  extensionErrorLinkKindNotDeclared: 'Extractor "{{extractorId}}" emitted a link of kind "{{linkKind}}" outside its declared `emitsLinkKinds` set [{{declaredKinds}}]. Link dropped.',
12110
12332
  extensionErrorIssueInvalidSeverity: `Rule "{{analyzerId}}" emitted an issue with invalid severity {{severity}} (allowed: 'error' | 'warn' | 'info'). Issue dropped.`,
12111
12333
  extensionErrorContributionUnknownId: 'Extractor "{{extractorId}}" emitted contribution "{{contributionId}}" on {{nodePath}} but did not declare it in its `viewContributions` map. Contribution dropped.',
12112
12334
  extensionErrorContributionPayloadInvalid: 'Extractor "{{extractorId}}" emitted contribution "{{contributionId}}" on {{nodePath}}; payload failed the "{{slot}}" schema: {{errors}}. Contribution dropped.',
12335
+ extensionErrorRecommendedActionMissing: 'Analyzer "{{analyzerId}}" declares recommendedAction "{{actionId}}" but no Action is registered under that qualified id. The analyzer stays registered; the recommendation will not surface in the inspector.',
12113
12336
  runScanRootEmptyArray: "runScan: roots must contain at least one path (spec requires minItems: 1)",
12114
12337
  runScanRootMissing: "runScan: root path '{{root}}' does not exist or is not a directory"
12115
12338
  };
@@ -12298,10 +12521,11 @@ function isExternalUrlLink(link2) {
12298
12521
  }
12299
12522
 
12300
12523
  // kernel/orchestrator/analyzers.ts
12301
- async function runAnalyzers(analyzers, nodes, internalLinks, orphanSidecars, sidecarRoots, annotationContributions, viewContributions, orphanJobFiles, referenceablePaths, cwd, emitter, hookDispatcher) {
12524
+ async function runAnalyzers(analyzers, nodes, internalLinks, orphanSidecars, sidecarRoots, annotationContributions, viewContributions, orphanJobFiles, referenceablePaths, cwd, registeredActionIds, emitter, hookDispatcher) {
12302
12525
  const issues = [];
12303
12526
  const contributions = [];
12304
12527
  const validators = loadSchemaValidators();
12528
+ validateRecommendedActions(analyzers, registeredActionIds, emitter);
12305
12529
  const analyzerOrphans = orphanSidecars.map((o) => ({
12306
12530
  relativePath: o.relativePath,
12307
12531
  expectedMdPath: o.expectedMdPath
@@ -12373,6 +12597,27 @@ async function runAnalyzers(analyzers, nodes, internalLinks, orphanSidecars, sid
12373
12597
  }
12374
12598
  return { issues, contributions };
12375
12599
  }
12600
+ function validateRecommendedActions(analyzers, registeredActionIds, emitter) {
12601
+ for (const analyzer of analyzers) {
12602
+ const refs = analyzer.recommendedActions;
12603
+ if (refs === void 0 || refs.length === 0) continue;
12604
+ const analyzerId = qualifiedExtensionId(analyzer.pluginId, analyzer.id);
12605
+ for (const actionId of refs) {
12606
+ if (registeredActionIds.has(actionId)) continue;
12607
+ emitter.emit(
12608
+ makeEvent("extension.error", {
12609
+ kind: "recommended-action-missing",
12610
+ extensionId: analyzerId,
12611
+ actionId,
12612
+ message: tx(ORCHESTRATOR_TEXTS.extensionErrorRecommendedActionMissing, {
12613
+ analyzerId,
12614
+ actionId
12615
+ })
12616
+ })
12617
+ );
12618
+ }
12619
+ }
12620
+ }
12376
12621
  function validateIssue(analyzer, issue, emitter) {
12377
12622
  const severity = issue.severity;
12378
12623
  if (severity !== "error" && severity !== "warn" && severity !== "info") {
@@ -12422,9 +12667,20 @@ function indexPriorLinks(links, priorNodePaths, byOriginating) {
12422
12667
  else byOriginating.set(key, [link2]);
12423
12668
  }
12424
12669
  }
12670
+ var FRONTMATTER_ISSUE_ANALYZERS = /* @__PURE__ */ new Set([
12671
+ "frontmatter-invalid",
12672
+ "frontmatter-malformed",
12673
+ // Audit L1: parser parse-error is emitted by
12674
+ // `buildFreshNodeAndValidateFrontmatter` from `raw.parseIssues`. The
12675
+ // raw.parseIssues only flows through the non-cache path; a cached
12676
+ // node skips the rebuild, so the prior issue MUST survive the
12677
+ // incremental scan or the warning silently disappears on a clean
12678
+ // re-scan of an unchanged file.
12679
+ "frontmatter-parse-error"
12680
+ ]);
12425
12681
  function indexPriorFrontmatterIssues(issues, byNode) {
12426
12682
  for (const issue of issues) {
12427
- if (issue.analyzerId !== "frontmatter-invalid" && issue.analyzerId !== "frontmatter-malformed") continue;
12683
+ if (!FRONTMATTER_ISSUE_ANALYZERS.has(issue.analyzerId)) continue;
12428
12684
  if (issue.nodeIds.length !== 1) continue;
12429
12685
  const path = issue.nodeIds[0];
12430
12686
  const list = byNode.get(path);
@@ -12623,7 +12879,7 @@ function flagAmbiguousRenames(opts) {
12623
12879
  analyzerId: "auto-rename-ambiguous",
12624
12880
  severity: "warn",
12625
12881
  nodeIds: [toPath],
12626
- message: `Auto-rename ambiguous: ${toPath} matches ${remaining.length} prior frontmatters \u2014 pick one with \`sm orphans undo-rename ${toPath} --from <old.path>\`.`,
12882
+ message: `Auto-rename ambiguous: ${toPath} matches ${remaining.length} prior frontmatters; pick one with \`sm orphans undo-rename ${toPath} --from <old.path>\`.`,
12627
12883
  data: { to: toPath, candidates: remaining }
12628
12884
  });
12629
12885
  }
@@ -12878,11 +13134,11 @@ function resolveSidecarOverlay(relativePath2, nodePathForIssue, roots, liveBodyH
12878
13134
  liveFrontmatterHash
12879
13135
  });
12880
13136
  return {
12881
- // R15 closure (2026-05-07) surface the full parsed root on the
13137
+ // R15 closure (2026-05-07), surface the full parsed root on the
12882
13138
  // overlay so BFF consumers (UI inspector audit / plugin-contributions
12883
13139
  // / debug panels) can read `for.*`, `audit.*`, `settings.*`, and
12884
13140
  // plugin-namespaced sub-keys without re-reading the file. The
12885
- // `annotations` field above stays it duplicates `root.annotations`
13141
+ // `annotations` field above stays, it duplicates `root.annotations`
12886
13142
  // by design so existing consumers keep working unchanged.
12887
13143
  overlay: {
12888
13144
  present: true,
@@ -12926,6 +13182,16 @@ function buildFreshNodeAndValidateFrontmatter(opts) {
12926
13182
  encoder: opts.encoder
12927
13183
  });
12928
13184
  const frontmatterIssues = [];
13185
+ if (opts.raw.parseIssues && opts.raw.parseIssues.length > 0) {
13186
+ for (const pi of opts.raw.parseIssues) {
13187
+ frontmatterIssues.push({
13188
+ analyzerId: pi.code,
13189
+ severity: opts.strict ? "error" : "warn",
13190
+ nodeIds: [opts.raw.path],
13191
+ message: pi.message
13192
+ });
13193
+ }
13194
+ }
12929
13195
  if (opts.raw.frontmatterRaw.length > 0) {
12930
13196
  const fmIssue = validateFrontmatter(
12931
13197
  opts.providerFrontmatter,
@@ -13217,6 +13483,9 @@ async function runScanInternal(_kernel, options) {
13217
13483
  recomputeLinkCounts(walked.nodes, walked.internalLinks);
13218
13484
  recomputeExternalRefsCount(walked.nodes, walked.externalLinks, walked.cachedPaths);
13219
13485
  await dispatchExtractorCompleted(exts.extractors, emitter, hookDispatcher);
13486
+ const registeredActionIds = new Set(
13487
+ _kernel.registry.all("action").map((a) => qualifiedExtensionId(a.pluginId, a.id))
13488
+ );
13220
13489
  const analyzerResult = await runAnalyzers(
13221
13490
  exts.analyzers,
13222
13491
  walked.nodes,
@@ -13228,6 +13497,7 @@ async function runScanInternal(_kernel, options) {
13228
13497
  options.orphanJobFiles ?? [],
13229
13498
  options.referenceablePaths,
13230
13499
  options.cwd,
13500
+ registeredActionIds,
13231
13501
  emitter,
13232
13502
  hookDispatcher
13233
13503
  );
@@ -13349,7 +13619,8 @@ function createChokidarWatcher(opts) {
13349
13619
  const watcher = chokidar.watch(absRoots, {
13350
13620
  ignoreInitial: true,
13351
13621
  persistent: true,
13352
- ...ignored ? { ignored } : {}
13622
+ ...ignored ? { ignored } : {},
13623
+ ...opts.depth !== void 0 ? { depth: opts.depth } : {}
13353
13624
  });
13354
13625
  let pending = [];
13355
13626
  let timer = null;
@@ -13558,16 +13829,19 @@ import { join as join10, resolve as resolve28 } from "path";
13558
13829
  function findOrphanJobFiles(jobsDir, referencedPaths) {
13559
13830
  let entries;
13560
13831
  try {
13561
- const stat3 = statSync6(jobsDir);
13562
- if (!stat3.isDirectory()) {
13832
+ const stat2 = statSync6(jobsDir);
13833
+ if (!stat2.isDirectory()) {
13563
13834
  return { orphanFilePaths: [], referencedCount: referencedPaths.size };
13564
13835
  }
13565
- entries = readdirSync7(jobsDir);
13836
+ entries = readdirSync7(jobsDir, { withFileTypes: true });
13566
13837
  } catch {
13567
13838
  return { orphanFilePaths: [], referencedCount: referencedPaths.size };
13568
13839
  }
13569
13840
  const orphans = [];
13570
- for (const name of entries) {
13841
+ for (const entry of entries) {
13842
+ if (entry.isSymbolicLink()) continue;
13843
+ if (!entry.isFile()) continue;
13844
+ const name = entry.name;
13571
13845
  if (!name.endsWith(".md")) continue;
13572
13846
  const abs = resolve28(join10(jobsDir, name));
13573
13847
  if (!referencedPaths.has(abs)) orphans.push(abs);
@@ -13649,7 +13923,7 @@ var REFERENCE_WALK_MAX_FILES = 5e4;
13649
13923
  var SKIPPED_DIR_NAMES = /* @__PURE__ */ new Set([
13650
13924
  "node_modules",
13651
13925
  ".git",
13652
- ".skill-map"
13926
+ SKILL_MAP_DIR
13653
13927
  ]);
13654
13928
  function resolveScanPath(raw, cwd, homedir4) {
13655
13929
  if (raw.startsWith("~/")) return resolve29(join11(homedir4, raw.slice(2)));
@@ -13664,8 +13938,8 @@ function walkReferencePaths(rawRoots, cwd, homedir4) {
13664
13938
  for (const raw of rawRoots) {
13665
13939
  if (truncated) break;
13666
13940
  const root = resolveScanPath(raw, cwd, homedir4);
13667
- const stat3 = safeStat(root);
13668
- if (!stat3 || !stat3.isDirectory()) {
13941
+ const stat2 = safeStat(root);
13942
+ if (!stat2 || !stat2.isDirectory()) {
13669
13943
  missingRoots.push(root);
13670
13944
  continue;
13671
13945
  }
@@ -13865,7 +14139,7 @@ function buildRunScanOptions(args2) {
13865
14139
  emitter: opts.emitterFactory ? opts.emitterFactory() : createStderrProgressEmitter(opts.stderr, {
13866
14140
  colorEnabled: opts.colorEnabled === true
13867
14141
  }),
13868
- // Orphan job-file detection empty list means "no orphans
14142
+ // Orphan job-file detection, empty list means "no orphans
13869
14143
  // visible from this caller" (legacy behaviour). The orchestrator
13870
14144
  // defaults to `[]` when the field is absent; we always pass the
13871
14145
  // array (possibly empty) to keep the wiring uniform.
@@ -13958,7 +14232,7 @@ var INIT_TEXTS = {
13958
14232
  scanFailed: "{{glyph}} sm init: scan failed: {{message}}\n",
13959
14233
  firstScanSummary: "{{glyph}} First scan: {{nodes}} node{{nodesPlural}}, {{links}} link{{linksPlural}}, {{issues}} issue{{issuesPlural}}.\n",
13960
14234
  // --- dry-run previews --------------------------------------------------
13961
- dryRunHeader: "(dry-run \u2014 no files written, no DB provisioned)\n",
14235
+ dryRunHeader: "(dry-run, no files written, no DB provisioned)\n",
13962
14236
  dryRunWouldCreateDir: "would create {{path}}/\n",
13963
14237
  dryRunWouldWriteFile: "would write {{path}}\n",
13964
14238
  dryRunWouldOverwriteFile: "would overwrite {{path}}\n",
@@ -14007,7 +14281,7 @@ var InitCommand = class extends SmCommand {
14007
14281
  description: "Strict mode: fail on any layered-loader warning AND promote frontmatter warnings to errors during the first scan. Same flag as sm scan / sm config."
14008
14282
  });
14009
14283
  dryRun = Option16.Boolean("-n,--dry-run", false, {
14010
- description: "Preview the scope provisioning without touching the filesystem or the DB. Honours --force for the would-overwrite preview. Skips the first scan unconditionally \u2014 dry-run never persists."
14284
+ description: "Preview the scope provisioning without touching the filesystem or the DB. Honours --force for the would-overwrite preview. Skips the first scan unconditionally; dry-run never persists."
14011
14285
  });
14012
14286
  // CLI orchestrator: paths setup + dry-run branch (delegated to
14013
14287
  // `writeDryRunPlan`) + real provision (mkdir + 4 file writes +
@@ -14137,7 +14411,7 @@ async function runFirstScan(scopeRoot, homedir4, strict, printer, stderr, ansi)
14137
14411
  dryRun: false,
14138
14412
  changed: false,
14139
14413
  // Init's first scan always persists, even when the scope is
14140
- // empty the historic behaviour was to seed the DB regardless of
14414
+ // empty, the historic behaviour was to seed the DB regardless of
14141
14415
  // node count. `runScanForCommand`'s guard refuses to wipe a
14142
14416
  // populated DB with a zero-result scan; init's DB is freshly
14143
14417
  // provisioned (zero rows), so the guard is dormant. Pass
@@ -14224,11 +14498,11 @@ var HISTORY_TEXTS = {
14224
14498
  statusInvalidHint: "Allowed: {{allowed}}.",
14225
14499
  periodInvalid: '{{glyph}} --period: invalid value "{{value}}".\n {{hint}}\n',
14226
14500
  periodInvalidHint: "Allowed: {{allowed}}.",
14227
- schemaValidationFailed: "{{glyph}} internal: history-stats output failed schema validation \u2014 {{errors}}\n",
14501
+ schemaValidationFailed: "{{glyph}} internal: history-stats output failed schema validation: {{errors}}\n",
14228
14502
  // --- renderStats: sectioned layout (matches `sm plugins doctor`) -----
14229
14503
  statsAllTimeWindow: "(all time)",
14230
- /** One-line dense header: `sm history stats N executions · M.M% error rate`. */
14231
- statsHeader: "sm history stats \u2014 {{summary}}\n\n",
14504
+ /** One-line dense header: `sm history stats: N executions · M.M% error rate`. */
14505
+ statsHeader: "sm history stats: {{summary}}\n\n",
14232
14506
  /** Section heading rendered before each indented block. */
14233
14507
  statsSectionHeader: " {{title}}\n",
14234
14508
  /** Two-column field row inside a section, label padded by the renderer. */
@@ -14243,7 +14517,7 @@ var HISTORY_TEXTS = {
14243
14517
  statsLabelExecutions: "Executions",
14244
14518
  statsLabelTokens: "Tokens",
14245
14519
  statsLabelDuration: "Duration",
14246
- /** `N (X ok · Y failed · Z cancelled)` only the populated buckets render. */
14520
+ /** `N (X ok · Y failed · Z cancelled)`, only the populated buckets render. */
14247
14521
  statsExecutionsCount: "{{count}}{{breakdown}}",
14248
14522
  statsTokensSplit: "{{in}} in / {{out}} out",
14249
14523
  /** Per-action row: `<id>@<version> N runs · T_in/T_out`. */
@@ -14273,7 +14547,7 @@ var HISTORY_TEXTS = {
14273
14547
  tableFooterCount: "{{count}} {{noun}}\n",
14274
14548
  tableFooterNounSingular: "execution",
14275
14549
  tableFooterNounPlural: "executions",
14276
- /** Footer tip printed dim under the count. */
14550
+ /** Footer tip, printed dim under the count. */
14277
14551
  tableFooterTip: "Tip: `sm history stats` for aggregated counts and top actions.\n"
14278
14552
  };
14279
14553
 
@@ -14519,7 +14793,7 @@ function toHistoryRow(r) {
14519
14793
  const status = reason.length > 0 ? tx(HISTORY_TEXTS.statusWithReason, { status: r.status, reason }) : r.status;
14520
14794
  return {
14521
14795
  id: truncateHead(sanitizeForTerminal(r.id), COL_ID_MAX),
14522
- // ISO timestamp with the `T` swapped for a space keeps the column
14796
+ // ISO timestamp with the `T` swapped for a space, keeps the column
14523
14797
  // narrow and human-readable without losing the `Z` UTC marker.
14524
14798
  started: new Date(r.startedAt).toISOString().slice(0, 19).replace("T", " ") + "Z",
14525
14799
  action: truncateHead(sanitizeForTerminal(r.extensionId), COL_ACTION_MAX),
@@ -14720,6 +14994,7 @@ function trimMs(iso) {
14720
14994
 
14721
14995
  // cli/commands/jobs.ts
14722
14996
  import { unlink } from "fs/promises";
14997
+ import { relative as relative6 } from "path";
14723
14998
  import { Command as Command19, Option as Option18 } from "clipanion";
14724
14999
 
14725
15000
  // cli/i18n/jobs.texts.ts
@@ -14811,18 +15086,30 @@ var JobPruneCommand = class extends SmCommand {
14811
15086
  const cutoff = now - completedPolicy * 1e3;
14812
15087
  const result = await this.pruneOrPreview("completed", cutoff, adapter, this.dryRun);
14813
15088
  out.retention.completed.deleted = result.deletedCount;
14814
- out.retention.completed.files = await this.unlinkFiles(result.filePaths, this.dryRun);
15089
+ out.retention.completed.files = await this.unlinkFiles(
15090
+ result.filePaths,
15091
+ jobsDir,
15092
+ this.dryRun
15093
+ );
14815
15094
  }
14816
15095
  if (failedPolicy !== null) {
14817
15096
  const cutoff = now - failedPolicy * 1e3;
14818
15097
  const result = await this.pruneOrPreview("failed", cutoff, adapter, this.dryRun);
14819
15098
  out.retention.failed.deleted = result.deletedCount;
14820
- out.retention.failed.files = await this.unlinkFiles(result.filePaths, this.dryRun);
15099
+ out.retention.failed.files = await this.unlinkFiles(
15100
+ result.filePaths,
15101
+ jobsDir,
15102
+ this.dryRun
15103
+ );
14821
15104
  }
14822
15105
  if (this.orphanFiles && out.orphanFiles.scanned) {
14823
15106
  const referenced = await adapter.jobs.listReferencedFilePaths();
14824
15107
  const orphans = findOrphanJobFiles(jobsDir, referenced);
14825
- const removed = await this.unlinkFiles(orphans.orphanFilePaths, this.dryRun);
15108
+ const removed = await this.unlinkFiles(
15109
+ orphans.orphanFilePaths,
15110
+ jobsDir,
15111
+ this.dryRun
15112
+ );
14826
15113
  out.orphanFiles = { scanned: true, deleted: removed };
14827
15114
  }
14828
15115
  });
@@ -14841,10 +15128,15 @@ var JobPruneCommand = class extends SmCommand {
14841
15128
  async pruneOrPreview(status, cutoffMs, adapter, dryRun) {
14842
15129
  return dryRun ? adapter.jobs.listTerminalCandidates(status, cutoffMs) : adapter.jobs.pruneTerminal(status, cutoffMs);
14843
15130
  }
14844
- async unlinkFiles(paths, dryRun) {
15131
+ async unlinkFiles(paths, jobsDir, dryRun) {
14845
15132
  if (dryRun) return paths.length;
14846
15133
  let removed = 0;
14847
15134
  for (const p of paths) {
15135
+ try {
15136
+ assertContained(jobsDir, relative6(jobsDir, p));
15137
+ } catch {
15138
+ continue;
15139
+ }
14848
15140
  try {
14849
15141
  await unlink(p);
14850
15142
  removed += 1;
@@ -14917,7 +15209,7 @@ var LIST_TEXTS = {
14917
15209
  tableFooterCount: "{{count}} {{noun}}\n",
14918
15210
  tableFooterNounSingular: "node",
14919
15211
  tableFooterNounPlural: "nodes",
14920
- /** Footer tip printed dim under the count. */
15212
+ /** Footer tip, printed dim under the count. */
14921
15213
  tableFooterTip: "Tip: `sm show <path>` for details, `sm check` for issues.\n"
14922
15214
  };
14923
15215
 
@@ -15183,7 +15475,7 @@ var ORPHANS_TEXTS = {
15183
15475
  reconcileDryRunHead: "{{glyph}} Would reconcile {{from}} \u2192 {{to}}{{dryTag}}\n",
15184
15476
  /** Breakdown line composed at the call site from non-zero counts only. */
15185
15477
  reconcileBreakdown: "{{rows}} rows \xB7 jobs {{jobs}} \xB7 execs {{execs}} \xB7 summaries {{summaries}} \xB7 enrichments {{enrichments}} \xB7 kv {{kv}} \xB7 favorites {{favorites}}",
15186
- reconcileCollisionsNote: "{{glyph}} {{count}} composite-PK collision{{plural}} \u2014 destination rows preserved.\n",
15478
+ reconcileCollisionsNote: "{{glyph}} {{count}} composite-PK collision{{plural}}; destination rows preserved.\n",
15187
15479
  reconcileCollisionsNoteDryRun: "{{glyph}} {{count}} composite-PK collision{{plural}} would be skipped; destination rows preserved.\n",
15188
15480
  // --- undo-rename -------------------------------------------------------
15189
15481
  undoNoActiveIssue: '{{glyph}} sm orphans undo-rename: no active auto-rename issue targets "{{path}}".\n {{hint}}\n',
@@ -15208,7 +15500,7 @@ var ORPHANS_TEXTS = {
15208
15500
  * emitted after `sm orphans undo-rename`. The string lands in DB rows
15209
15501
  * and travels through `--json`, `sm check`, and downstream consumers,
15210
15502
  * so localising it requires a kernel-side template (not just a CLI
15211
- * catalog) kept here for now so the wording lives in one greppable
15503
+ * catalog), kept here for now so the wording lives in one greppable
15212
15504
  * place even if the layering is imperfect.
15213
15505
  */
15214
15506
  undoRenameOrphanMessage: "Orphan history: {{toPath}} (was reverted from auto-rename to {{newPath}}).",
@@ -15217,7 +15509,7 @@ var ORPHANS_TEXTS = {
15217
15509
  invalidKindHint: "Allowed: orphan, medium, ambiguous.",
15218
15510
  // --- renderOrphans (pretty listing) ------------------------------------
15219
15511
  /** Header line for the active orphan / auto-rename issues block. */
15220
- listHeader: "sm orphans \u2014 {{count}} {{noun}}\n\n",
15512
+ listHeader: "sm orphans: {{count}} {{noun}}\n\n",
15221
15513
  listNounSingular: "issue",
15222
15514
  listNounPlural: "issues",
15223
15515
  /**
@@ -15437,7 +15729,7 @@ var OrphansUndoRenameCommand = class extends SmCommand {
15437
15729
  turned out to be unrelated.
15438
15730
 
15439
15731
  For an active auto-rename-medium issue on <new.path>, the prior
15440
- path is read from issue.data.from \u2014 omit --from. For an active
15732
+ path is read from issue.data.from; omit --from. For an active
15441
15733
  auto-rename-ambiguous issue, --from <old.path> is REQUIRED to
15442
15734
  pick a candidate from data.candidates.
15443
15735
 
@@ -15684,7 +15976,7 @@ import { Command as Command22, Option as Option21 } from "clipanion";
15684
15976
  // cli/i18n/plugins.texts.ts
15685
15977
  var PLUGINS_TEXTS = {
15686
15978
  // --- enable / disable error guidance --------------------------------
15687
- // Spec § A.7 granularity validation. The CLI rejects mismatched ids
15979
+ // Spec § A.7, granularity validation. The CLI rejects mismatched ids
15688
15980
  // up front (instead of silently writing a config_plugins row that the
15689
15981
  // runtime would later ignore) so the user learns the model immediately.
15690
15982
  /**
@@ -15707,7 +15999,7 @@ var PLUGINS_TEXTS = {
15707
15999
  qualifiedIdNotFoundHint: "Run `sm plugins list` to see what each bundle ships.",
15708
16000
  qualifiedIdUnknownBundle: "{{glyph}} Qualified extension id references unknown bundle: {{bundleId}}\n {{hint}}\n",
15709
16001
  qualifiedIdUnknownBundleHint: "Run `sm plugins list` for known bundle ids.",
15710
- // Spec § A.10 `applicableKinds` filter on Extractors. When an extractor
16002
+ // Spec § A.10, `applicableKinds` filter on Extractors. When an extractor
15711
16003
  // declares a kind that no installed Provider emits, the load succeeds
15712
16004
  // (the Provider may arrive later) but `sm plugins doctor` surfaces a
15713
16005
  // non-blocking warning so the author sees the typo / missing dependency.
@@ -15715,11 +16007,22 @@ var PLUGINS_TEXTS = {
15715
16007
  // The id is rendered as the entry header (`⚠ <id>`); the body skips
15716
16008
  // re-stating it so the message reads cleanly under the entry.
15717
16009
  doctorApplicableKindUnknown: "Declares applicableKinds including '{{unknownKind}}', but no installed Provider declares that kind. The extractor is loaded but will never fire on that kind.",
16010
+ // Phase 7 / View contribution system, defence-in-depth slot drift
16011
+ // check. AJV at manifest load already rejects unknown slots as
16012
+ // `invalid-manifest`, but a plugin authored against an older catalog
16013
+ // whose `catalogCompat` satisfies the current major syntactically can
16014
+ // still ship a slot id that was renamed / removed. The doctor pass
16015
+ // surfaces those so the user runs `sm plugins upgrade` to migrate.
16016
+ // Exit code is NOT promoted by this warning.
16017
+ // The id is rendered as the entry header
16018
+ // (`⚠ <pluginId>/<extensionId>/<contributionId>`); the body skips
16019
+ // re-stating it so the message reads cleanly under the entry.
16020
+ doctorUnknownSlot: "Contribution '{{contributionId}}' targets unknown slot '{{slot}}'. Run `sm plugins upgrade {{pluginId}}` or update the plugin to a slot in the current catalog (`sm plugins slots list`).",
15718
16021
  // --- list verb -------------------------------------------------------
15719
16022
  listEmpty: "No plugins discovered.\n",
15720
16023
  // --- doctor verb -----------------------------------------------------
15721
16024
  /** One-line summary that opens the human doctor output. */
15722
- doctorSummary: "plugins doctor \u2014 {{enabled}} enabled \xB7 {{issues}} issue{{issuesPlural}} \xB7 {{warnings}} warning{{warningsPlural}}\n\n",
16025
+ doctorSummary: "plugins doctor: {{enabled}} enabled \xB7 {{issues}} issue{{issuesPlural}} \xB7 {{warnings}} warning{{warningsPlural}}\n\n",
15723
16026
  /** Source breakdown row (built-in vs user). Indented 4 to match the status rows. */
15724
16027
  doctorSourceRow: " {{label}} {{count}}\n",
15725
16028
  /** Status breakdown table heading. */
@@ -15769,12 +16072,12 @@ var PLUGINS_TEXTS = {
15769
16072
  */
15770
16073
  bundleSubIndent: " ",
15771
16074
  listTipShow: "\nTip: `sm plugins show <id>` for kinds, versions, and per-extension status.\n",
15772
- /** Show command built-in header (no version row, no path). */
16075
+ /** Show command, built-in header (no version row, no path). */
15773
16076
  detailHeaderBuiltIn: " {{glyph}} {{id}} {{source}} {{count}} extension{{plural}}\n",
15774
16077
  /**
15775
- * Show command user-plugin header. Version always present (defaults
16078
+ * Show command, user-plugin header. Version always present (defaults
15776
16079
  * to `?` when the manifest omits it). Source labelled `user`; disabled
15777
- * / failed states surface via the glyph (✕) only the source label
16080
+ * / failed states surface via the glyph (✕) only, the source label
15778
16081
  * stays the same so users learn that the plugin _is_ a user one
15779
16082
  * regardless of its load state.
15780
16083
  */
@@ -15801,7 +16104,26 @@ var PLUGINS_TEXTS = {
15801
16104
  */
15802
16105
  detailExtensionRowBare: " {{kind}} {{name}} v{{version}}\n",
15803
16106
  detailVersionUnknown: "?",
15804
- detailCompatUnknown: "?"
16107
+ detailCompatUnknown: "?",
16108
+ // --- create verb -----------------------------------------------------
16109
+ /** Rejected when `<plugin-id>` fails the kebab-case lowercase regex. */
16110
+ createInvalidId: "{{glyph}} Plugin id must be kebab-case lowercase (got: {{id}})\n",
16111
+ /** Target directory exists and `--force` was not passed. */
16112
+ createRefuseOverwrite: "{{glyph}} Refusing to overwrite {{targetDir}}. Pass --force to overwrite.\n",
16113
+ /**
16114
+ * Success block printed after scaffolding. Follows the no-em-dash rule
16115
+ * across every line.
16116
+ */
16117
+ createSuccess: "Created {{targetDir}}\nNext:\n - Edit {{pluginId}}/extensions/extractor.js (the extract() body)\n - Run sm scan to see the contribution surface\n - sm plugins slots list: browse other slots\n",
16118
+ // --- slots list verb -------------------------------------------------
16119
+ /** Section header for the view-slots catalogue. */
16120
+ slotsListHeaderViewSlots: " View slots ({{count}})\n",
16121
+ /** Section header for the input-types catalogue (leading blank line). */
16122
+ slotsListHeaderInputTypes: "\n Input types ({{count}})\n",
16123
+ /** Trailing tip; the `{{tip}}` is the dim-wrapped tip text. */
16124
+ slotsListTipFooter: "\n{{tip}}\n",
16125
+ /** Tip body, dim-wrapped by the caller. */
16126
+ slotsListTipText: "Tip: full spec at spec/view-slots.md and spec/input-types.md."
15805
16127
  };
15806
16128
 
15807
16129
  // cli/commands/plugins/shared.ts
@@ -16232,15 +16554,28 @@ var PluginsDoctorCommand = class extends SmCommand {
16232
16554
  const counts = countByStatus(builtIns2, plugins);
16233
16555
  const knownKinds = collectKnownKinds(plugins);
16234
16556
  const applicableKindWarnings = collectApplicableKindWarnings(plugins, knownKinds);
16557
+ const unknownSlotWarnings = collectUnknownSlotWarnings(plugins, KNOWN_SLOT_NAMES);
16235
16558
  const bad = plugins.filter((p) => p.status !== "enabled" && p.status !== "disabled");
16236
- const totalWarnings = applicableKindWarnings.length;
16559
+ const totalWarnings = applicableKindWarnings.length + unknownSlotWarnings.length;
16560
+ if (this.json) {
16561
+ const envelope = buildDoctorJsonEnvelope({
16562
+ counts,
16563
+ bad,
16564
+ applicableKindWarnings,
16565
+ unknownSlotWarnings,
16566
+ totalWarnings,
16567
+ elapsedMs: this.elapsed.ms()
16568
+ });
16569
+ this.printer.data(JSON.stringify(envelope) + "\n");
16570
+ return bad.length > 0 ? ExitCode.Issues : ExitCode.Ok;
16571
+ }
16237
16572
  const stdout = this.context.stdout;
16238
16573
  const ansi = ansiFor({ isTTY: stdout.isTTY === true, noColorFlag: this.noColor });
16239
16574
  this.#renderSummaryHeader(counts.enabled, bad.length, totalWarnings);
16240
16575
  this.#renderSourceBreakdown(builtIns2.length, plugins.length);
16241
16576
  this.#renderStatusBreakdown(counts, ansi);
16242
16577
  if (totalWarnings > 0) {
16243
- this.#renderWarnings(applicableKindWarnings, totalWarnings, ansi);
16578
+ this.#renderWarnings(applicableKindWarnings, unknownSlotWarnings, totalWarnings, ansi);
16244
16579
  }
16245
16580
  if (bad.length > 0) {
16246
16581
  this.#renderIssues(bad, ansi);
@@ -16294,7 +16629,7 @@ var PluginsDoctorCommand = class extends SmCommand {
16294
16629
  );
16295
16630
  }
16296
16631
  }
16297
- #renderWarnings(applicableKindWarnings, totalWarnings, ansi) {
16632
+ #renderWarnings(applicableKindWarnings, unknownSlotWarnings, totalWarnings, ansi) {
16298
16633
  this.printer.data(tx(PLUGINS_TEXTS.doctorWarningsHeader, { count: totalWarnings }));
16299
16634
  const warnGlyph = ansi.yellow("\u26A0");
16300
16635
  for (const w of applicableKindWarnings) {
@@ -16307,6 +16642,20 @@ var PluginsDoctorCommand = class extends SmCommand {
16307
16642
  ansi
16308
16643
  );
16309
16644
  }
16645
+ for (const w of unknownSlotWarnings) {
16646
+ const slash = w.extensionQualifiedId.indexOf("/");
16647
+ const pluginId = slash >= 0 ? w.extensionQualifiedId.slice(0, slash) : w.extensionQualifiedId;
16648
+ this.#emitWarningEntry(
16649
+ warnGlyph,
16650
+ sanitizeForTerminal(`${w.extensionQualifiedId}/${w.contributionId}`),
16651
+ tx(PLUGINS_TEXTS.doctorUnknownSlot, {
16652
+ contributionId: sanitizeForTerminal(w.contributionId),
16653
+ slot: sanitizeForTerminal(w.slot),
16654
+ pluginId: sanitizeForTerminal(pluginId)
16655
+ }),
16656
+ ansi
16657
+ );
16658
+ }
16310
16659
  }
16311
16660
  #emitWarningEntry(glyph, id, message, ansi) {
16312
16661
  this.printer.data(tx(PLUGINS_TEXTS.doctorWarningEntry, { glyph, id }));
@@ -16446,29 +16795,116 @@ function appendUnknownKindWarnings(out, extractorQualifiedId, applicableKinds, k
16446
16795
  if (!knownKinds.has(k)) out.push({ extractorQualifiedId, unknownKind: k });
16447
16796
  }
16448
16797
  }
16449
-
16450
- // cli/commands/plugins/toggle.ts
16451
- import { Command as Command25, Option as Option24 } from "clipanion";
16452
- var TogglePluginsBase = class extends SmCommand {
16453
- all = Option24.Boolean("--all", false);
16454
- id = Option24.String({ required: false });
16455
- async toggle(enabled) {
16456
- const verb = enabled ? "enable" : "disable";
16457
- const stderr = this.context.stderr;
16458
- const stderrAnsi = ansiFor({ isTTY: stderr.isTTY === true, noColorFlag: this.noColor });
16459
- const argError = this.#validateArgs(stderrAnsi);
16460
- if (argError !== null) return argError;
16461
- const plugins = await loadAll({ global: this.global, pluginDir: void 0 });
16462
- const catalogue = bundleCatalogue(plugins);
16463
- const targetsResult = this.#pickTargets(catalogue, verb, stderrAnsi);
16464
- if (typeof targetsResult === "number") return targetsResult;
16465
- let targets = targetsResult;
16466
- const lockError = this.#applyLockGate(targets, stderrAnsi);
16467
- if (typeof lockError === "number") return lockError;
16468
- targets = lockError;
16469
- await this.#persistTargets(targets, enabled);
16470
- this.#renderSuccess(targets, enabled);
16471
- return ExitCode.Ok;
16798
+ function collectUnknownSlotWarnings(plugins, knownSlots) {
16799
+ const out = [];
16800
+ collectBuiltInUnknownSlotWarnings(out, knownSlots);
16801
+ collectUserUnknownSlotWarnings(out, plugins, knownSlots);
16802
+ return out;
16803
+ }
16804
+ function collectBuiltInUnknownSlotWarnings(out, knownSlots) {
16805
+ for (const bundle of builtInBundles) {
16806
+ for (const ext of bundle.extensions) {
16807
+ const vc = ext.viewContributions;
16808
+ if (!vc) continue;
16809
+ appendUnknownSlotWarnings(out, qualifiedExtensionId(bundle.id, ext.id), vc, knownSlots);
16810
+ }
16811
+ }
16812
+ }
16813
+ function collectUserUnknownSlotWarnings(out, plugins, knownSlots) {
16814
+ for (const p of plugins) {
16815
+ if (p.status !== "enabled" || !p.extensions) continue;
16816
+ for (const ext of p.extensions) {
16817
+ const inst = extensionInstance(ext);
16818
+ if (!inst) continue;
16819
+ const vc = inst["viewContributions"];
16820
+ if (vc === null || typeof vc !== "object") continue;
16821
+ appendUnknownSlotWarnings(
16822
+ out,
16823
+ qualifiedExtensionId(ext.pluginId, ext.id),
16824
+ vc,
16825
+ knownSlots
16826
+ );
16827
+ }
16828
+ }
16829
+ }
16830
+ function appendUnknownSlotWarnings(out, extensionQualifiedId, viewContributions, knownSlots) {
16831
+ for (const [contributionId, raw] of Object.entries(viewContributions)) {
16832
+ if (raw === null || typeof raw !== "object") continue;
16833
+ const slot = raw.slot;
16834
+ if (typeof slot !== "string") continue;
16835
+ if (knownSlots.has(slot)) continue;
16836
+ out.push({ extensionQualifiedId, contributionId, slot });
16837
+ }
16838
+ }
16839
+ function buildDoctorJsonEnvelope(args2) {
16840
+ const issues = args2.bad.map((p) => ({
16841
+ id: sanitizeForTerminal(p.id),
16842
+ status: p.status,
16843
+ reason: sanitizeForTerminal(p.reason ?? "")
16844
+ }));
16845
+ const warnings = [];
16846
+ for (const w of args2.applicableKindWarnings) {
16847
+ warnings.push({
16848
+ id: sanitizeForTerminal(w.extractorQualifiedId),
16849
+ kind: "applicable-kind-unknown",
16850
+ message: tx(PLUGINS_TEXTS.doctorApplicableKindUnknown, {
16851
+ unknownKind: sanitizeForTerminal(w.unknownKind)
16852
+ })
16853
+ });
16854
+ }
16855
+ for (const w of args2.unknownSlotWarnings) {
16856
+ const slash = w.extensionQualifiedId.indexOf("/");
16857
+ const pluginId = slash >= 0 ? w.extensionQualifiedId.slice(0, slash) : w.extensionQualifiedId;
16858
+ warnings.push({
16859
+ id: sanitizeForTerminal(`${w.extensionQualifiedId}/${w.contributionId}`),
16860
+ kind: "unknown-slot",
16861
+ message: tx(PLUGINS_TEXTS.doctorUnknownSlot, {
16862
+ contributionId: sanitizeForTerminal(w.contributionId),
16863
+ slot: sanitizeForTerminal(w.slot),
16864
+ pluginId: sanitizeForTerminal(pluginId)
16865
+ })
16866
+ });
16867
+ }
16868
+ return {
16869
+ ok: true,
16870
+ kind: "plugins.doctor",
16871
+ counts: {
16872
+ enabled: args2.counts.enabled,
16873
+ disabled: args2.counts.disabled,
16874
+ loaded: args2.counts.enabled,
16875
+ incompatible: args2.counts["incompatible-spec"] + args2.counts["incompatible-catalog"],
16876
+ invalid: args2.counts["invalid-manifest"],
16877
+ loadError: args2.counts["load-error"] + args2.counts["id-collision"],
16878
+ warnings: args2.totalWarnings
16879
+ },
16880
+ issues,
16881
+ warnings,
16882
+ elapsedMs: args2.elapsedMs
16883
+ };
16884
+ }
16885
+
16886
+ // cli/commands/plugins/toggle.ts
16887
+ import { Command as Command25, Option as Option24 } from "clipanion";
16888
+ var TogglePluginsBase = class extends SmCommand {
16889
+ all = Option24.Boolean("--all", false);
16890
+ id = Option24.String({ required: false });
16891
+ async toggle(enabled) {
16892
+ const verb = enabled ? "enable" : "disable";
16893
+ const stderr = this.context.stderr;
16894
+ const stderrAnsi = ansiFor({ isTTY: stderr.isTTY === true, noColorFlag: this.noColor });
16895
+ const argError = this.#validateArgs(stderrAnsi);
16896
+ if (argError !== null) return argError;
16897
+ const plugins = await loadAll({ global: this.global, pluginDir: void 0 });
16898
+ const catalogue = bundleCatalogue(plugins);
16899
+ const targetsResult = this.#pickTargets(catalogue, verb, stderrAnsi);
16900
+ if (typeof targetsResult === "number") return targetsResult;
16901
+ let targets = targetsResult;
16902
+ const lockError = this.#applyLockGate(targets, stderrAnsi);
16903
+ if (typeof lockError === "number") return lockError;
16904
+ targets = lockError;
16905
+ await this.#persistTargets(targets, enabled);
16906
+ this.#renderSuccess(targets, enabled);
16907
+ return ExitCode.Ok;
16472
16908
  }
16473
16909
  /**
16474
16910
  * `--all` vs `<id>` mutex check. The two are mutually exclusive and
@@ -16493,7 +16929,7 @@ var TogglePluginsBase = class extends SmCommand {
16493
16929
  *
16494
16930
  * `--all` is a macro on bundle ids: every plugin / bundle the user
16495
16931
  * can see. We deliberately do NOT expand to qualified
16496
- * <bundle>/<ext> keys that would silently flip a granularity
16932
+ * <bundle>/<ext> keys, that would silently flip a granularity
16497
16933
  * policy. For granularity=extension bundles the user already hits
16498
16934
  * the directed error message when they try the bundle id directly,
16499
16935
  * so `--all` skips them here too and the real "disable every core
@@ -16511,7 +16947,7 @@ var TogglePluginsBase = class extends SmCommand {
16511
16947
  return [resolved.key];
16512
16948
  }
16513
16949
  /**
16514
- * Host lock see `src/kernel/config/locked-plugins.ts`. `--all`
16950
+ * Host lock, see `src/kernel/config/locked-plugins.ts`. `--all`
16515
16951
  * silently skips locked targets so the user can still toggle the
16516
16952
  * rest. Single-id mode surfaces a directed exit-5 message.
16517
16953
  */
@@ -16531,7 +16967,7 @@ var TogglePluginsBase = class extends SmCommand {
16531
16967
  /**
16532
16968
  * Persist the toggle in `config_plugins`. On disable, also purge
16533
16969
  * the plugin's `scan_contributions` rows immediately (matches the
16534
- * BFF route see `server/routes/plugins.ts:applyChangeToAdapter`).
16970
+ * BFF route, see `server/routes/plugins.ts:applyChangeToAdapter`).
16535
16971
  * `targets` carries either a bare bundle id (e.g. `claude`) or a
16536
16972
  * qualified `<bundle>/<ext>` (e.g. `core/slash`); the split mirrors
16537
16973
  * how the catalog sweep groups rows.
@@ -16597,8 +17033,8 @@ var PluginsDisableCommand = class extends TogglePluginsBase {
16597
17033
  description: "Disable a plugin (or --all). Persists in config_plugins; does not delete files.",
16598
17034
  details: `
16599
17035
  Writes a row to config_plugins with enabled=0. Discovery still
16600
- surfaces the plugin in sm plugins list, but with status=disabled
16601
- \u2014 its extensions are not imported and the kernel will not run
17036
+ surfaces the plugin in sm plugins list, but with status=disabled;
17037
+ its extensions are not imported and the kernel will not run
16602
17038
  them.
16603
17039
 
16604
17040
  Granularity: a bundle-granularity plugin (default for user plugins,
@@ -16705,7 +17141,7 @@ function resolveBareToggle(id, catalogue, verb, ansi) {
16705
17141
  }
16706
17142
 
16707
17143
  // cli/commands/plugins/create.ts
16708
- import { existsSync as existsSync21, mkdirSync as mkdirSync5, writeFileSync as writeFileSync4 } from "fs";
17144
+ import { existsSync as existsSync21, mkdirSync as mkdirSync5, writeFileSync as writeFileSync2 } from "fs";
16709
17145
  import { join as join13, resolve as resolve31 } from "path";
16710
17146
  import { Command as Command26, Option as Option25 } from "clipanion";
16711
17147
  var PluginsCreateCommand = class extends SmCommand {
@@ -16719,18 +17155,27 @@ var PluginsCreateCommand = class extends SmCommand {
16719
17155
  at = Option25.String("--at", { required: false });
16720
17156
  force = Option25.Boolean("--force", false);
16721
17157
  async run() {
17158
+ const stderr = this.context.stderr;
17159
+ const ansi = ansiFor({ isTTY: stderr.isTTY === true, noColorFlag: this.noColor });
17160
+ const errGlyph = ansi.red("\u2715");
16722
17161
  if (!/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/.test(this.pluginId)) {
16723
17162
  this.printer.error(
16724
- `Plugin id must be kebab-case lowercase (got: ${sanitizeForTerminal(this.pluginId)})
16725
- `
17163
+ tx(PLUGINS_TEXTS.createInvalidId, {
17164
+ glyph: errGlyph,
17165
+ id: sanitizeForTerminal(this.pluginId)
17166
+ })
16726
17167
  );
16727
17168
  return ExitCode.Error;
16728
17169
  }
16729
- const targetDir = this.at ? resolve31(this.at) : resolve31(process.cwd(), ".skill-map", "plugins", this.pluginId);
17170
+ const ctx = defaultRuntimeContext();
17171
+ const baseDir = this.global ? defaultUserPluginsDir(ctx) : defaultProjectPluginsDir(ctx);
17172
+ const targetDir = this.at ? resolve31(this.at) : join13(baseDir, this.pluginId);
16730
17173
  if (existsSync21(targetDir) && !this.force) {
16731
17174
  this.printer.error(
16732
- `Refusing to overwrite ${sanitizeForTerminal(targetDir)}. Pass --force to overwrite.
16733
- `
17175
+ tx(PLUGINS_TEXTS.createRefuseOverwrite, {
17176
+ glyph: errGlyph,
17177
+ targetDir: sanitizeForTerminal(targetDir)
17178
+ })
16734
17179
  );
16735
17180
  return ExitCode.Error;
16736
17181
  }
@@ -16753,22 +17198,20 @@ var PluginsCreateCommand = class extends SmCommand {
16753
17198
  }
16754
17199
  }
16755
17200
  };
16756
- writeFileSync4(
17201
+ writeFileSync2(
16757
17202
  join13(targetDir, "plugin.json"),
16758
17203
  JSON.stringify(manifest, null, 2) + "\n"
16759
17204
  );
16760
- writeFileSync4(
17205
+ writeFileSync2(
16761
17206
  join13(targetDir, "extensions", "extractor.js"),
16762
17207
  scaffolderExtractorStub(this.pluginId)
16763
17208
  );
16764
- writeFileSync4(join13(targetDir, "README.md"), scaffolderReadme(this.pluginId));
17209
+ writeFileSync2(join13(targetDir, "README.md"), scaffolderReadme(this.pluginId));
16765
17210
  this.printer.data(
16766
- `Created ${sanitizeForTerminal(targetDir)}
16767
- Next:
16768
- - Edit ${this.pluginId}/extensions/extractor.js (the extract() body)
16769
- - Run sm scan to see the contribution surface
16770
- - sm plugins slots list \u2014 browse other slots
16771
- `
17211
+ tx(PLUGINS_TEXTS.createSuccess, {
17212
+ targetDir: sanitizeForTerminal(targetDir),
17213
+ pluginId: this.pluginId
17214
+ })
16772
17215
  );
16773
17216
  return ExitCode.Ok;
16774
17217
  }
@@ -16834,16 +17277,16 @@ Generated by \`sm plugins create\`. Edit \`extensions/extractor.js\` to taste.
16834
17277
 
16835
17278
  ## Verbs
16836
17279
 
16837
- - \`sm plugins show ${pluginId}\` \u2014 manifest + load status
16838
- - \`sm plugins doctor\` \u2014 full plugin diagnostic
16839
- - \`sm scan\` \u2014 re-emit contributions
17280
+ - \`sm plugins show ${pluginId}\`: manifest + load status
17281
+ - \`sm plugins doctor\`: full plugin diagnostic
17282
+ - \`sm scan\`: re-emit contributions
16840
17283
 
16841
17284
  ## Resources
16842
17285
 
16843
17286
  - \`spec/plugin-author-guide.md\` \xA7View contributions
16844
- - \`spec/view-slots.md\` \u2014 the closed catalog of slots
16845
- - \`spec/input-types.md\` \u2014 the closed catalog of input-types for settings
16846
- - \`sm plugins slots list\` \u2014 browse the catalog from the CLI
17287
+ - \`spec/view-slots.md\`: the closed catalog of slots
17288
+ - \`spec/input-types.md\`: the closed catalog of input-types for settings
17289
+ - \`sm plugins slots list\`: browse the catalog from the CLI
16847
17290
  `;
16848
17291
  }
16849
17292
 
@@ -16852,11 +17295,11 @@ import { Command as Command27 } from "clipanion";
16852
17295
 
16853
17296
  // cli/commands/plugins/slots-catalog.ts
16854
17297
  var VIEW_SLOTS_CATALOG = [
16855
- { id: "card.title.right", summary: "Small icon marker next to the card title \u2014 language flag, platform glyph." },
17298
+ { id: "card.title.right", summary: "Small icon marker next to the card title (language flag, platform glyph)." },
16856
17299
  { id: "card.subtitle.left", summary: "Single non-negative integer in the card subtitle row." },
16857
17300
  { id: "card.footer.left", summary: "Counter chip in the left footer of the card." },
16858
17301
  { id: "card.footer.right", summary: "Counter chip in the right footer of the card." },
16859
- { id: "graph.node.alert", summary: "Corner badge decoration on the graph node \u2014 alert / status." },
17302
+ { id: "graph.node.alert", summary: "Corner badge decoration on the graph node (alert / status)." },
16860
17303
  { id: "inspector.header.badge.counter", summary: "Counter chip in the inspector header badge cluster." },
16861
17304
  { id: "inspector.header.badge.tag", summary: "Qualitative tag chip in the inspector header badge cluster." },
16862
17305
  { id: "inspector.body.panel.breakdown", summary: "Top-N labeled values rendered as a bar chart in the inspector body." },
@@ -16905,17 +17348,18 @@ var PluginsSlotsListCommand = class extends SmCommand {
16905
17348
  ...VIEW_SLOTS_CATALOG.map((c) => c.id.length),
16906
17349
  ...INPUT_TYPES_CATALOG.map((t) => t.id.length)
16907
17350
  );
16908
- this.printer.data(` View slots (${VIEW_SLOTS_CATALOG.length})
16909
- `);
17351
+ this.printer.data(
17352
+ tx(PLUGINS_TEXTS.slotsListHeaderViewSlots, { count: VIEW_SLOTS_CATALOG.length })
17353
+ );
16910
17354
  for (const c of VIEW_SLOTS_CATALOG) {
16911
17355
  this.printer.data(
16912
17356
  ` ${c.id.padEnd(idWidth)} ${ansi.dim(c.summary)}
16913
17357
  `
16914
17358
  );
16915
17359
  }
16916
- this.printer.data(`
16917
- Input types (${INPUT_TYPES_CATALOG.length})
16918
- `);
17360
+ this.printer.data(
17361
+ tx(PLUGINS_TEXTS.slotsListHeaderInputTypes, { count: INPUT_TYPES_CATALOG.length })
17362
+ );
16919
17363
  for (const t of INPUT_TYPES_CATALOG) {
16920
17364
  this.printer.data(
16921
17365
  ` ${t.id.padEnd(idWidth)} ${ansi.dim(t.summary)}
@@ -16923,9 +17367,9 @@ var PluginsSlotsListCommand = class extends SmCommand {
16923
17367
  );
16924
17368
  }
16925
17369
  this.printer.data(
16926
- `
16927
- ${ansi.dim("Tip: full spec at spec/view-slots.md and spec/input-types.md.")}
16928
- `
17370
+ tx(PLUGINS_TEXTS.slotsListTipFooter, {
17371
+ tip: ansi.dim(PLUGINS_TEXTS.slotsListTipText)
17372
+ })
16929
17373
  );
16930
17374
  return ExitCode.Ok;
16931
17375
  }
@@ -16938,12 +17382,12 @@ var PluginsUpgradeCommand = class extends SmCommand {
16938
17382
  static usage = Command28.Usage({
16939
17383
  category: "Plugins",
16940
17384
  description: "Apply catalog migrations to plugin manifests.",
16941
- details: "No migrations registered against catalog v1.0.0 yet \u2014 this verb is a no-op today. The structure exists so future slot renames / deprecations land without spec churn."
17385
+ details: "No migrations registered against catalog v1.0.0 yet; this verb is a no-op today. The structure exists so future slot renames / deprecations land without spec churn."
16942
17386
  });
16943
17387
  pluginId = Option26.String({ required: false, name: "plugin-id" });
16944
17388
  async run() {
16945
17389
  this.printer.data(
16946
- "sm plugins upgrade \u2014 no migrations registered for catalog v1.0.0.\n All loaded plugins are catalog-current.\n Run `sm plugins doctor` to surface any incompatible-catalog status.\n"
17390
+ "sm plugins upgrade: no migrations registered for catalog v1.0.0.\n All loaded plugins are catalog-current.\n Run `sm plugins doctor` to surface any incompatible-catalog status.\n"
16947
17391
  );
16948
17392
  return ExitCode.Ok;
16949
17393
  }
@@ -16991,6 +17435,13 @@ var REFRESH_TEXTS = {
16991
17435
  refreshNodeNounPlural: "nodes",
16992
17436
  // --- failures -------------------------------------------------------------
16993
17437
  refreshFailed: "{{glyph}} sm refresh: {{message}}\n",
17438
+ /**
17439
+ * Error-envelope `message` body for `--json` failures. Used as the
17440
+ * `error.message` value when the verb cannot locate the project DB
17441
+ * (the `--json` consumer cannot rely on the human glyph + hint).
17442
+ */
17443
+ jsonErrorDbMissing: "Project database not found. Run `sm init` before `sm refresh`.",
17444
+ jsonErrorNodeNotFound: "Node not found: {{nodePath}}",
16994
17445
  /**
16995
17446
  * Sub-detail composed inside `refreshFailed` when the failure is a
16996
17447
  * filesystem read on a specific node body. Catalogued separately so the
@@ -17009,7 +17460,7 @@ var RefreshCommand = class extends SmCommand {
17009
17460
  details: `
17010
17461
  Re-runs Extractors against the node(s) and upserts their outputs into
17011
17462
  the universal enrichment layer (\`node_enrichments\`). Extractors are
17012
- deterministic-only \u2014 they always run for real and persist.
17463
+ deterministic-only: they always run for real and persist.
17013
17464
 
17014
17465
  Layer separation: enrichments live separately from the author's
17015
17466
  frontmatter, which is immutable from any Extractor.
@@ -17071,6 +17522,10 @@ var RefreshCommand = class extends SmCommand {
17071
17522
  }
17072
17523
  );
17073
17524
  if (!persisted) {
17525
+ if (this.json) {
17526
+ this.#emitJsonError("db-missing", tx(REFRESH_TEXTS.jsonErrorDbMissing));
17527
+ return ExitCode.NotFound;
17528
+ }
17074
17529
  this.printer.info(
17075
17530
  tx(REFRESH_TEXTS.nodeNotFound, {
17076
17531
  glyph: ansi.red("\u2715"),
@@ -17083,14 +17538,19 @@ var RefreshCommand = class extends SmCommand {
17083
17538
  const targetResult = this.#resolveTargetNodes(persisted, ansi);
17084
17539
  if (!targetResult.ok) return targetResult.exitCode;
17085
17540
  const targetNodes = targetResult.nodes;
17086
- let freshEnrichments;
17541
+ let freshEnrichmentsByNode;
17087
17542
  try {
17088
- freshEnrichments = await this.#runExtractorsAcrossNodes(targetNodes, allExtractors, ctx.cwd);
17543
+ freshEnrichmentsByNode = await this.#runExtractorsAcrossNodes(targetNodes, allExtractors, ctx.cwd);
17089
17544
  } catch (err) {
17090
17545
  const message = formatErrorMessage(err);
17546
+ if (this.json) {
17547
+ this.#emitJsonError("internal", message);
17548
+ return ExitCode.Error;
17549
+ }
17091
17550
  this.printer.info(tx(REFRESH_TEXTS.refreshFailed, { glyph: errGlyph, message }));
17092
17551
  return ExitCode.Error;
17093
17552
  }
17553
+ const freshEnrichments = freshEnrichmentsByNode.flatMap((n) => n.enrichments);
17094
17554
  if (freshEnrichments.length > 0) {
17095
17555
  try {
17096
17556
  await withSqlite({ databasePath: dbPath, autoBackup: false }, async (adapter) => {
@@ -17100,10 +17560,25 @@ var RefreshCommand = class extends SmCommand {
17100
17560
  });
17101
17561
  } catch (err) {
17102
17562
  const message = formatErrorMessage(err);
17563
+ if (this.json) {
17564
+ this.#emitJsonError("internal", message);
17565
+ return ExitCode.Error;
17566
+ }
17103
17567
  this.printer.info(tx(REFRESH_TEXTS.refreshFailed, { message }));
17104
17568
  return ExitCode.Error;
17105
17569
  }
17106
17570
  }
17571
+ if (this.json) {
17572
+ const envelope = {
17573
+ ok: true,
17574
+ kind: "refresh.report",
17575
+ refreshed: freshEnrichments.length,
17576
+ nodes: freshEnrichmentsByNode.map((n) => ({ path: n.path, enrichments: n.enrichments.length })),
17577
+ elapsedMs: this.elapsed.ms()
17578
+ };
17579
+ this.printer.data(JSON.stringify(envelope) + "\n");
17580
+ return ExitCode.Ok;
17581
+ }
17107
17582
  const glyph = ansi.green("\u2713");
17108
17583
  const count = freshEnrichments.length;
17109
17584
  const noun = count === 1 ? REFRESH_TEXTS.refreshNounSingular : REFRESH_TEXTS.refreshNounPlural;
@@ -17131,18 +17606,45 @@ var RefreshCommand = class extends SmCommand {
17131
17606
  }
17132
17607
  return ExitCode.Ok;
17133
17608
  }
17609
+ /**
17610
+ * Emit the canonical `--json` error envelope on stdout. Mirrors the
17611
+ * shape from `cli-contract.md` §Error envelope. Suppresses the
17612
+ * human-facing glyph + hint output that the non-JSON branches still
17613
+ * render.
17614
+ */
17615
+ #emitJsonError(code, message) {
17616
+ const payload = { ok: false, error: { code, message } };
17617
+ this.printer.data(JSON.stringify(payload) + "\n");
17618
+ }
17134
17619
  /**
17135
17620
  * Decide which nodes the verb should refresh based on `--stale` /
17136
17621
  * `<nodePath>`. Writes the per-target advisory to stdout (or the
17137
17622
  * not-found / nothing-to-do message). Returns either the target list
17138
17623
  * or the exit code the caller should use.
17624
+ *
17625
+ * Complexity is from the two-axis branch (stale-vs-single x
17626
+ * json-vs-human) plus the two terminal branches inside each axis.
17627
+ * Further extraction would split the method per axis but lose the
17628
+ * tight `nodesByPath.get(...)` reuse that drives both paths.
17139
17629
  */
17630
+ // eslint-disable-next-line complexity
17140
17631
  #resolveTargetNodes(persisted, ansi) {
17141
17632
  const nodesByPath = /* @__PURE__ */ new Map();
17142
17633
  for (const node2 of persisted.result.nodes) nodesByPath.set(node2.path, node2);
17143
17634
  if (this.stale) {
17144
17635
  const staleEnrichments = persisted.enrichments.filter((e) => e.stale);
17145
17636
  if (staleEnrichments.length === 0) {
17637
+ if (this.json) {
17638
+ const envelope = {
17639
+ ok: true,
17640
+ kind: "refresh.report",
17641
+ refreshed: 0,
17642
+ nodes: [],
17643
+ elapsedMs: this.elapsed.ms()
17644
+ };
17645
+ this.printer.data(JSON.stringify(envelope) + "\n");
17646
+ return { ok: false, exitCode: ExitCode.Ok };
17647
+ }
17146
17648
  this.printer.data(
17147
17649
  tx(REFRESH_TEXTS.refreshSuccessNoStale, { glyph: ansi.green("\u2713") })
17148
17650
  );
@@ -17158,6 +17660,13 @@ var RefreshCommand = class extends SmCommand {
17158
17660
  }
17159
17661
  const node = nodesByPath.get(this.nodePath);
17160
17662
  if (!node) {
17663
+ if (this.json) {
17664
+ this.#emitJsonError(
17665
+ "not-found",
17666
+ tx(REFRESH_TEXTS.jsonErrorNodeNotFound, { nodePath: this.nodePath })
17667
+ );
17668
+ return { ok: false, exitCode: ExitCode.NotFound };
17669
+ }
17161
17670
  this.printer.info(
17162
17671
  tx(REFRESH_TEXTS.nodeNotFound, {
17163
17672
  glyph: ansi.red("\u2715"),
@@ -17172,28 +17681,35 @@ var RefreshCommand = class extends SmCommand {
17172
17681
  /**
17173
17682
  * For each target node: read its body off disk, run every applicable
17174
17683
  * Extractor (deterministic-only by spec), and collect the enrichment
17175
- * records they produce.
17684
+ * records they produce. Returns one entry per node (in iteration
17685
+ * order) so the verb's `--json` envelope can report a per-node
17686
+ * breakdown; consumers that only care about the flat list flatten
17687
+ * the result.
17176
17688
  */
17177
17689
  async #runExtractorsAcrossNodes(targetNodes, allExtractors, cwd) {
17178
- const freshEnrichments = [];
17690
+ const perNode = [];
17179
17691
  for (const node of targetNodes) {
17692
+ const nodeEnrichments = [];
17180
17693
  let body;
17181
17694
  try {
17182
17695
  assertContained(cwd, node.path);
17183
17696
  const raw = await readFile3(resolve32(cwd, node.path), "utf8");
17184
17697
  body = stripFrontmatterFence(raw);
17185
17698
  } catch (err) {
17186
- const stderr = this.context.stderr;
17187
- const ansi = ansiFor({ isTTY: stderr.isTTY === true, noColorFlag: this.noColor });
17188
- this.printer.info(
17189
- tx(REFRESH_TEXTS.refreshFailed, {
17190
- glyph: ansi.red("\u2715"),
17191
- message: tx(REFRESH_TEXTS.readFailedDetail, {
17192
- path: node.path,
17193
- message: formatErrorMessage(err)
17699
+ if (!this.json) {
17700
+ const stderr = this.context.stderr;
17701
+ const ansi = ansiFor({ isTTY: stderr.isTTY === true, noColorFlag: this.noColor });
17702
+ this.printer.info(
17703
+ tx(REFRESH_TEXTS.refreshFailed, {
17704
+ glyph: ansi.red("\u2715"),
17705
+ message: tx(REFRESH_TEXTS.readFailedDetail, {
17706
+ path: node.path,
17707
+ message: formatErrorMessage(err)
17708
+ })
17194
17709
  })
17195
- })
17196
- );
17710
+ );
17711
+ }
17712
+ perNode.push({ path: node.path, enrichments: nodeEnrichments });
17197
17713
  continue;
17198
17714
  }
17199
17715
  const fm = node.frontmatter ?? {};
@@ -17202,10 +17718,11 @@ var RefreshCommand = class extends SmCommand {
17202
17718
  );
17203
17719
  for (const extractor of applicable) {
17204
17720
  const records = await runExtractorForEnrichment(extractor, node, body, fm);
17205
- for (const record of records) freshEnrichments.push(record);
17721
+ for (const record of records) nodeEnrichments.push(record);
17206
17722
  }
17723
+ perNode.push({ path: node.path, enrichments: nodeEnrichments });
17207
17724
  }
17208
- return freshEnrichments;
17725
+ return perNode;
17209
17726
  }
17210
17727
  };
17211
17728
  async function runExtractorForEnrichment(extractor, node, body, frontmatter) {
@@ -17244,7 +17761,7 @@ var SCAN_TEXTS = {
17244
17761
  scanFailure: "{{glyph}} sm scan: {{message}}\n",
17245
17762
  guardWipeRefused: "{{glyph}} Refusing to wipe a populated DB ({{existing}} rows in scan_*) with a zero-result scan.\n {{hint}}\n",
17246
17763
  guardWipeRefusedHint: "Pass --allow-empty to override. If this is unexpected, double-check the root paths.",
17247
- jsonSelfValidationFailed: "{{glyph}} sm scan: internal \u2014 scan-result failed self-validation: {{errors}}\n",
17764
+ jsonSelfValidationFailed: "{{glyph}} sm scan: internal: scan-result failed self-validation: {{errors}}\n",
17248
17765
  /**
17249
17766
  * Header summary line. `glyph` is ✓ (green) on success or ✕ (red)
17250
17767
  * when error-severity issues fired; `counts` is the comma-separated
@@ -17254,9 +17771,9 @@ var SCAN_TEXTS = {
17254
17771
  * ` (<N> roots)` for multi-root scans.
17255
17772
  */
17256
17773
  scannedSummary: " {{glyph}} {{counts}} {{duration}}{{rootsSuffix}}\n",
17257
- /** Body line directly under the header final DB path (dim). */
17774
+ /** Body line directly under the header, final DB path (dim). */
17258
17775
  persistedTo: " {{dbPath}}\n",
17259
- /** Body line for dry-run mode same indent, marker tail. */
17776
+ /** Body line for dry-run mode, same indent, marker tail. */
17260
17777
  wouldPersist: " would persist to {{dbPath}} (dry-run)\n",
17261
17778
  // --- scan compare-with sub-verb --------------------------------------
17262
17779
  compareErrorPrefix: "sm scan compare-with: {{message}}\n",
@@ -17272,7 +17789,7 @@ var SCAN_TEXTS = {
17272
17789
  */
17273
17790
  compareDeltaSummary: "{{glyph}} Delta {{comparedTag}}\n {{nodesLine}}\n {{linksLine}}\n {{issuesLine}}",
17274
17791
  compareDeltaComparedTag: "vs {{comparedWith}}",
17275
- /** Per-row breakdown templates composed at the call site with mid-dot separators. */
17792
+ /** Per-row breakdown templates, composed at the call site with mid-dot separators. */
17276
17793
  compareDeltaNodesLine: "nodes: {{added}} added \xB7 {{removed}} removed \xB7 {{changed}} changed",
17277
17794
  compareDeltaLinksLine: "links: {{added}} added \xB7 {{removed}} removed",
17278
17795
  compareDeltaIssuesLine: "issues: {{added}} added \xB7 {{removed}} removed",
@@ -17280,25 +17797,28 @@ var SCAN_TEXTS = {
17280
17797
  compareDeltaNodesHeader: "## nodes",
17281
17798
  compareDeltaLinksHeader: "## links",
17282
17799
  compareDeltaIssuesHeader: "## issues",
17283
- /** `+ <path> (<kind>)` added node row. */
17800
+ /** `+ <path> (<kind>)`, added node row. */
17284
17801
  compareDeltaNodeAdded: "+ {{path}} ({{kind}})",
17285
- /** `- <path> (<kind>)` removed node row. */
17802
+ /** `- <path> (<kind>)`, removed node row. */
17286
17803
  compareDeltaNodeRemoved: "- {{path}} ({{kind}})",
17287
- /** `~ <path> (<reason> changed)` changed node row. */
17804
+ /** `~ <path> (<reason> changed)`, changed node row. */
17288
17805
  compareDeltaNodeChanged: "~ {{path}} ({{reason}} changed)",
17289
- /** `+ <source> --<kind>--> <target>` added link row. */
17806
+ /** `+ <source> --<kind>--> <target>`, added link row. */
17290
17807
  compareDeltaLinkAdded: "+ {{source}} --{{kind}}--> {{target}}",
17291
- /** `- <source> --<kind>--> <target>` removed link row. */
17808
+ /** `- <source> --<kind>--> <target>`, removed link row. */
17292
17809
  compareDeltaLinkRemoved: "- {{source}} --{{kind}}--> {{target}}",
17293
- /** `+ [<severity>] <analyzerId>: <message>` added issue row. */
17810
+ /** `+ [<severity>] <analyzerId>: <message>`, added issue row. */
17294
17811
  compareDeltaIssueAdded: "+ [{{severity}}] {{analyzerId}}: {{message}}",
17295
- /** `- [<severity>] <analyzerId>: <message>` removed issue row. */
17812
+ /** `- [<severity>] <analyzerId>: <message>`, removed issue row. */
17296
17813
  compareDeltaIssueRemoved: "- [{{severity}}] {{analyzerId}}: {{message}}"
17297
17814
  };
17298
17815
 
17299
17816
  // cli/commands/watch.ts
17300
17817
  import { Command as Command30, Option as Option28 } from "clipanion";
17301
17818
 
17819
+ // core/watcher/runtime.ts
17820
+ import { dirname as dirname16 } from "path";
17821
+
17302
17822
  // core/runtime/fresh-resolver.ts
17303
17823
  async function buildFreshResolver(deps) {
17304
17824
  const overrides = await tryWithSqlite(
@@ -17504,16 +18024,37 @@ function createWatcherRuntime(opts) {
17504
18024
  });
17505
18025
  };
17506
18026
  const subscribeMeta = () => {
18027
+ const ignorePath = defaultIgnoreFilePath(cwd);
18028
+ const settingsPath = defaultSettingsPath(cwd);
18029
+ const metaTargets = /* @__PURE__ */ new Set([ignorePath, settingsPath]);
17507
18030
  metaHandle = createChokidarWatcher({
18031
+ // Watch the PARENT directories with `depth: 0`, not the
18032
+ // individual files. Why: chokidar single-file watching on
18033
+ // macOS + FSEvents loses the watch when an editor performs an
18034
+ // atomic save (write to a tempfile, rename over the target).
18035
+ // The original inode the watcher attached to is gone and the
18036
+ // newly-renamed file is unobserved, so a `.skillmapignore`
18037
+ // edit silently fails to reach this hook and stale nodes
18038
+ // remain in the graph until the user touches some `.md` file
18039
+ // to force a per-file re-evaluation. Watching the parent
18040
+ // directory tracks the target by name (chokidar maps
18041
+ // directory-level events to filename), so atomic saves
18042
+ // surface as a normal `change` event regardless of inode
18043
+ // churn. The `metaTargets` filter above strips events for any
18044
+ // other file the parent directories happen to contain.
17508
18045
  roots: [
17509
- defaultIgnoreFilePath(cwd),
17510
- defaultSettingsPath(cwd)
18046
+ cwd,
18047
+ // parent of `.skillmapignore`
18048
+ dirname16(settingsPath)
18049
+ // parent of `.skill-map/settings.json`
17511
18050
  ],
17512
18051
  cwd,
17513
18052
  debounceMs,
17514
- // No ignore filter — these specific paths must always be
18053
+ depth: 0,
18054
+ // No ignore filter, these specific paths must always be
17515
18055
  // observed regardless of any user pattern.
17516
- onBatch: async () => {
18056
+ onBatch: async ({ paths }) => {
18057
+ if (!paths.some((p) => metaTargets.has(p))) return;
17517
18058
  if (stopped) return;
17518
18059
  try {
17519
18060
  cfg = loadEffectiveConfig();
@@ -17583,10 +18124,10 @@ function createWatcherRuntime(opts) {
17583
18124
  // cli/i18n/watch.texts.ts
17584
18125
  var WATCH_TEXTS = {
17585
18126
  configLoadFailure: "{{glyph}} sm watch: {{message}}\n",
17586
- initialScanFailed: "{{glyph}} sm watch: initial scan failed \u2014 {{message}}\n",
17587
- batchFailed: "{{glyph}} sm watch: batch failed \u2014 {{message}}\n",
17588
- scanFailed: "{{glyph}} sm watch: scan failed \u2014 {{message}}\n",
17589
- watcherError: "{{glyph}} sm watch: watcher error \u2014 {{message}}\n",
18127
+ initialScanFailed: "{{glyph}} sm watch: initial scan failed: {{message}}\n",
18128
+ batchFailed: "{{glyph}} sm watch: batch failed: {{message}}\n",
18129
+ scanFailed: "{{glyph}} sm watch: scan failed: {{message}}\n",
18130
+ watcherError: "{{glyph}} sm watch: watcher error: {{message}}\n",
17590
18131
  starting: "sm watch: starting on {{rootsCount}} root(s), debounce {{debounceMs}}ms\n",
17591
18132
  ready: "sm watch: ready. Press Ctrl+C to stop.\n",
17592
18133
  stopped: "sm watch: stopped after {{batchCount}} batch(es).\n",
@@ -17599,7 +18140,7 @@ var WATCH_TEXTS = {
17599
18140
  scannedNounIssuePlural: "issues",
17600
18141
  scannedDurationTag: "in {{ms}}ms",
17601
18142
  priorSchemaValidationFailed: "prior scan-result loaded from DB failed schema validation: {{errors}}",
17602
- breakerTripped: "{{glyph}} sm watch: {{count}} consecutive batch failures \u2014 shutting down.\n {{hint}}\n",
18143
+ breakerTripped: "{{glyph}} sm watch: {{count}} consecutive batch failures, shutting down.\n {{hint}}\n",
17603
18144
  breakerTrippedHint: "Last error: {{message}}",
17604
18145
  maxConsecutiveFailuresInvalid: "{{glyph}} sm watch: --max-consecutive-failures must be a non-negative integer (got {{raw}}).\n"
17605
18146
  };
@@ -17655,7 +18196,7 @@ async function runWatchLoop(opts) {
17655
18196
  emitterFactory: () => createStderrProgressEmitter(context.stderr),
17656
18197
  runInitialBatch: true,
17657
18198
  // CLI ordering: initial scan first, then subscribe. Matches the
17658
- // historic `runWatchLoop` shape events arriving during the
18199
+ // historic `runWatchLoop` shape, events arriving during the
17659
18200
  // initial scan are intentionally lost (the next user save covers
17660
18201
  // any race).
17661
18202
  subscribeBeforeInitial: false,
@@ -17778,7 +18319,7 @@ var WatchCommand = class extends SmCommand {
17778
18319
  required: false,
17779
18320
  description: "Shut down with exit 2 after N consecutive batch failures (default 5; 0 disables the breaker)."
17780
18321
  });
17781
- // Long-running verb the watcher prints its own "stopped" line on
18322
+ // Long-running verb, the watcher prints its own "stopped" line on
17782
18323
  // SIGINT / SIGTERM. Adding `done in <…>` after that would be noise.
17783
18324
  emitElapsed = false;
17784
18325
  async run() {
@@ -17835,10 +18376,10 @@ var ScanCommand = class extends SmCommand {
17835
18376
  the prior snapshot from the DB, reuse unchanged nodes, and only
17836
18377
  reprocess new / modified files.
17837
18378
 
17838
- Scans honour scan.extraFolders (append extra dirs verbatim \u2014
17839
- the only way to extend the scan beyond cwd) and
17840
- scan.referencePaths (walk the configured dirs for
17841
- link-validation only \u2014 files there are not indexed). Both are
18379
+ Scans honour scan.extraFolders (append extra dirs verbatim, the
18380
+ only way to extend the scan beyond cwd) and scan.referencePaths
18381
+ (walk the configured dirs for link-validation only; files there
18382
+ are not indexed). Both are
17842
18383
  privacy-sensitive; see "sm config set --help" for the --yes
17843
18384
  gate.
17844
18385
  `,
@@ -17921,7 +18462,7 @@ var ScanCommand = class extends SmCommand {
17921
18462
  }
17922
18463
  /**
17923
18464
  * `--watch` is a thin alias for the `sm watch` verb. Combining
17924
- * `--watch` with one-shot-only flags is incoherent the watcher
18465
+ * `--watch` with one-shot-only flags is incoherent, the watcher
17925
18466
  * always persists incrementally over the prior snapshot.
17926
18467
  */
17927
18468
  async runWatchAlias() {
@@ -18055,7 +18596,7 @@ var ScanCompareCommand = class extends SmCommand {
18055
18596
  (default: current directory) using the same pipeline as 'sm scan'
18056
18597
  (built-ins + plugin runtime + layered config + ignore filter),
18057
18598
  and emits the delta between the dump and the fresh scan. The DB
18058
- is NEVER touched \u2014 this verb is read-only.
18599
+ is NEVER touched; this verb is read-only.
18059
18600
 
18060
18601
  Exit 0 on empty delta (state matches the dump), exit 1 on any
18061
18602
  drift (added / removed / changed nodes, links, or issues), exit
@@ -18289,13 +18830,26 @@ import { spawn as spawn2 } from "child_process";
18289
18830
  import { existsSync as existsSync26 } from "fs";
18290
18831
  import { Command as Command33, Option as Option31 } from "clipanion";
18291
18832
 
18833
+ // cli/util/browser-launch.ts
18834
+ function validateBrowserUrl(url) {
18835
+ if (typeof url !== "string" || url.length === 0) return false;
18836
+ const FORBIDDEN_META = /["&|^<>%]/;
18837
+ if (FORBIDDEN_META.test(url)) return false;
18838
+ for (let i = 0; i < url.length; i++) {
18839
+ const code = url.charCodeAt(i);
18840
+ if (code <= 31 || code === 127) return false;
18841
+ }
18842
+ return true;
18843
+ }
18844
+
18292
18845
  // server/index.ts
18293
18846
  import { serve } from "@hono/node-server";
18294
18847
  import { WebSocketServer } from "ws";
18295
18848
 
18296
18849
  // server/app.ts
18297
18850
  import { Hono } from "hono";
18298
- import { HTTPException as HTTPException12 } from "hono/http-exception";
18851
+ import { bodyLimit } from "hono/body-limit";
18852
+ import { HTTPException as HTTPException13 } from "hono/http-exception";
18299
18853
 
18300
18854
  // core/config/service.ts
18301
18855
  var ConfigService = class {
@@ -18306,7 +18860,7 @@ var ConfigService = class {
18306
18860
  }
18307
18861
  /**
18308
18862
  * Return the cached `ILoadedConfig` (loading on first call).
18309
- * Subsequent calls return the same object reference callers
18863
+ * Subsequent calls return the same object reference, callers
18310
18864
  * MUST treat it as read-only.
18311
18865
  */
18312
18866
  get() {
@@ -18321,7 +18875,7 @@ var ConfigService = class {
18321
18875
  return this.#cache;
18322
18876
  }
18323
18877
  /**
18324
- * Sugar for `this.get().effective` the most common consumer pattern
18878
+ * Sugar for `this.get().effective`, the most common consumer pattern
18325
18879
  * (the `sources` / `warnings` slots are only relevant to the
18326
18880
  * `GET /api/config` and `sm config show` paths).
18327
18881
  */
@@ -18341,19 +18895,19 @@ var ConfigService = class {
18341
18895
 
18342
18896
  // server/i18n/server.texts.ts
18343
18897
  var SERVER_TEXTS = {
18344
- // Boot banner printed by the server itself when it begins to listen.
18898
+ // Boot banner, printed by the server itself when it begins to listen.
18345
18899
  // The CLI verb `sm serve` formats its own boot banner separately
18346
18900
  // (SERVE_TEXTS.boot) so the two surfaces can diverge if needed.
18347
18901
  listening: "skill-map server listening on http://{{host}}:{{port}}\n",
18348
- // UI bundle missing non-fatal when the path was auto-resolved (the
18902
+ // UI bundle missing, non-fatal when the path was auto-resolved (the
18349
18903
  // server keeps running with an inline placeholder at `/`). Becomes
18350
18904
  // ExitCode.Error when `--ui-dist <path>` was explicit.
18351
- uiBundleMissing: 'skill-map server: UI bundle not found at {{path}} \u2014 serving inline placeholder at "/" (run "npm run build --workspace=ui" to populate).\n',
18352
- // Loopback-only deprecation hint Decision #119. Logged once at boot
18905
+ uiBundleMissing: 'skill-map server: UI bundle not found at {{path}} (serving inline placeholder at "/", run "npm run build --workspace=ui" to populate).\n',
18906
+ // Loopback-only deprecation hint, Decision #119. Logged once at boot
18353
18907
  // when `--host` resolves to a non-loopback address. Multi-host serve
18354
18908
  // re-opens post-v0.6.0.
18355
- hostNonLoopbackHint: "skill-map server: --host {{host}} is non-loopback \u2014 through v0.6.0 the BFF assumes loopback-only (no auth). See Decision #119 in ROADMAP.\n",
18356
- // Shutdown trace printed by the close path so test runs that bring
18909
+ hostNonLoopbackHint: "skill-map server: --host {{host}} is non-loopback (through v0.6.0 the BFF assumes loopback-only, no auth). See Decision #119 in ROADMAP.\n",
18910
+ // Shutdown trace, printed by the close path so test runs that bring
18357
18911
  // the server up and down have a clear marker.
18358
18912
  closed: "skill-map server: closed.\n",
18359
18913
  // ---- error envelope messages (Step 14.2) ---------------------------------
@@ -18361,51 +18915,97 @@ var SERVER_TEXTS = {
18361
18915
  // Hint nudges the user toward `sm scan` so the SPA can call it via the
18362
18916
  // CLI side-by-side with the server.
18363
18917
  dbMissingHint: "No persisted scan available at {{path}}. Run `sm scan` to populate the DB.",
18918
+ // First-stage loopback gate (DNS rebinding + cross-origin defence). The
18919
+ // messages are pre-baked, terse, and shared across every probe so the
18920
+ // response stays opaque (no per-request state leaks). The discriminator
18921
+ // travels on `error.code`; the message is informational only.
18922
+ hostNotAllowed: "Request rejected: Host header is not loopback.",
18923
+ originNotAllowed: "Request rejected: Origin header is not loopback.",
18364
18924
  // `?fresh=1` was requested but the server was booted with --no-built-ins
18365
18925
  // or --no-plugins. A fresh scan with neither pipeline yields an empty /
18366
18926
  // partial result that would surprise the SPA. Reject up front.
18367
18927
  freshScanRequiresPipeline: "?fresh=1 cannot run while the server was started with --no-built-ins or --no-plugins (would yield empty / partial results).",
18368
- // Unknown formatter on /api/graph the user asked for a `format` value
18928
+ // Unknown formatter on /api/graph, the user asked for a `format` value
18369
18929
  // that no registered formatter advertises. Mirrors `sm graph`'s message.
18370
18930
  graphUnknownFormat: 'Unknown graph format "{{format}}". Available: {{available}}.',
18371
18931
  // Pagination caps on /api/nodes.
18372
18932
  paginationLimitTooLarge: "limit={{value}} exceeds the maximum of {{max}}.",
18373
18933
  paginationInvalidInteger: "{{name}}={{value}} is not a non-negative integer.",
18934
+ // Required-query-param miss (used by `parseRequiredString`). The
18935
+ // route names the offending parameter so the operator gets a useful
18936
+ // 400 instead of a generic "missing input".
18937
+ queryRequiredString: "Required query parameter: {{name}}.",
18938
+ // Malformed URL-path segment on a route whose params follow the
18939
+ // qualified-id alphabet (`[A-Za-z0-9._-]`). Surfaces on the
18940
+ // contributions lookup route (`/api/contributions/:pluginId/:extensionId/:contributionId`)
18941
+ // so a request with a slash, space, or control char in any segment
18942
+ // returns 400 before the kernel lookup.
18943
+ qualifiedIdMalformed: '{{name}}="{{value}}" is not a valid qualified-id segment ([A-Za-z0-9._-]+).',
18944
+ // 404 envelope for `/api/contributions/:pluginId/:extensionId/:contributionId`
18945
+ // when the catalog has no matching entry. Interpolates the full
18946
+ // triple so the SPA / operator can see which qualified id missed.
18947
+ contributionUnknown: "No registered contribution: {{pluginId}}/{{extensionId}}/{{contributionId}}.",
18948
+ // 400 envelope on /api/graph when `?format=` arrives with an invalid
18949
+ // shape (too long, or characters outside the formatter-id alphabet).
18950
+ // Caught BEFORE the registry lookup so a hostile value never reaches
18951
+ // the formatter table.
18952
+ graphFormatMalformed: 'format="{{value}}" is not a valid formatter id (lowercase a-z, 0-9, hyphen, max 32 chars).',
18953
+ // POST /api/scan + GET /api/scan?fresh=1, the runner returned a
18954
+ // `guard-trip` outcome (an idempotency / safety latch in the kernel).
18955
+ // Surfaced as a 500 with the offending row-count.
18956
+ scanGuardTrip: "scan refused (existing rows: {{existing}})",
18957
+ freshScanGuardTrip: "fresh scan refused (existing rows: {{existing}})",
18374
18958
  // Node lookup miss on /api/nodes/:pathB64. Both the missing-node and
18375
- // the malformed-pathB64 cases funnel here the client experience is
18959
+ // the malformed-pathB64 cases funnel here, the client experience is
18376
18960
  // the same (the resource isn't there).
18377
18961
  nodeNotFound: 'No node with path "{{path}}".',
18378
- pathB64Malformed: "Malformed pathB64 \u2014 not a valid base64url-encoded node.path.",
18962
+ pathB64Malformed: "Malformed pathB64, not a valid base64url-encoded node.path.",
18379
18963
  // ---- WS broadcaster + watcher (Step 14.4.a) ------------------------------
18380
18964
  // Logged once on watcher boot after chokidar's initial walk completes.
18381
18965
  // Marks the broadcaster as armed and the live event stream as flowing.
18382
18966
  watcherReady: 'skill-map server: watcher ready (roots="{{roots}}", debounceMs={{debounceMs}}).\n',
18383
- // Watcher boot failure inside `createServer`. Non-fatal the REST
18967
+ // Watcher boot failure inside `createServer`. Non-fatal, the REST
18384
18968
  // surface stays alive so the operator can fix the underlying issue
18385
18969
  // (config, plugin, FS permission) and restart.
18386
- watcherBootFailed: "skill-map server: watcher boot failed \u2014 {{message}}. /api/* still serving; pass --no-watcher to silence this on the next boot.\n",
18970
+ watcherBootFailed: "skill-map server: watcher boot failed ({{message}}). /api/* still serving; pass --no-watcher to silence this on the next boot.\n",
18387
18971
  // Per-batch failure inside the watcher's scan+persist pipeline. The
18388
- // watcher loop continues a transient FS error must not kill the
18972
+ // watcher loop continues, a transient FS error must not kill the
18389
18973
  // broadcaster.
18390
- watcherBatchFailed: "skill-map server: watcher batch failed \u2014 {{message}}.\n",
18974
+ watcherBatchFailed: "skill-map server: watcher batch failed ({{message}}).\n",
18391
18975
  // chokidar surfaced an error. The watcher stays open per IFsWatcher's
18392
18976
  // contract; the BFF also broadcasts a `watcher.error` advisory so the
18393
18977
  // SPA can surface it in the live event log.
18394
- watcherError: "skill-map server: watcher error \u2014 {{message}}.\n",
18978
+ watcherError: "skill-map server: watcher error ({{message}}).\n",
18395
18979
  // chokidar.close() rejected during graceful shutdown. Logged but not
18396
- // surfaced close() is best-effort and idempotent.
18397
- watcherCloseFailed: "skill-map server: watcher close failed \u2014 {{message}}.\n",
18980
+ // surfaced, close() is best-effort and idempotent.
18981
+ watcherCloseFailed: "skill-map server: watcher close failed ({{message}}).\n",
18982
+ // ---- body-limit middleware (app.ts, audit M4) ---------------------------
18983
+ // 413 envelope when a request body exceeds the global `BODY_LIMIT_BYTES`
18984
+ // cap. The discriminator travels on `error.code` (`payload-too-large`);
18985
+ // the message is informational only and names the byte cap so the
18986
+ // operator / SPA log can correlate without re-reading the source.
18987
+ bodyTooLarge: "Request body exceeds the {{maxBytes}}-byte limit.",
18988
+ // ---- onError fall-through (app.ts, audit L3) ----------------------------
18989
+ // 500 envelope for any throw that doesn't match a known mapped subclass
18990
+ // (DbMissingError, BulkValidationError, LoopbackGateError, HTTPException,
18991
+ // ExportQueryError, EConsentRequiredError). The raw err.message often
18992
+ // carries kernel detail (absolute paths, registry-probe hostnames),
18993
+ // so we redact the human-readable text to a generic constant and route
18994
+ // the real detail to log.warn instead. The envelope `code` stays
18995
+ // `internal`; `details` stays `null`. Operators see the full message
18996
+ // on stderr / log file via the BFF's logger.
18997
+ internalError: "internal error",
18398
18998
  // ---- catch-all 404 envelopes (app.ts) ------------------------------------
18399
- // `/api/*` catch-all request hit the API namespace but no route
18999
+ // `/api/*` catch-all, request hit the API namespace but no route
18400
19000
  // matched. The path is interpolated so the operator (and the SPA)
18401
19001
  // can see exactly which endpoint was queried.
18402
19002
  unknownApiEndpoint: "Unknown API endpoint: {{path}}.",
18403
- // Hono's `app.notFound` fallback every other unmatched path funnels
19003
+ // Hono's `app.notFound` fallback, every other unmatched path funnels
18404
19004
  // here (after static + SPA fallback have had their turn).
18405
19005
  unknownPath: "Not found: {{path}}.",
18406
19006
  // ---- sidecar bump route (routes/sidecar.ts) ------------------------------
18407
19007
  // 409 refusal when a fresh node is bumped without `force`. The
18408
- // `sidecar-fresh:` prefix is load-bearing the UI pattern-matches
19008
+ // `sidecar-fresh:` prefix is load-bearing, the UI pattern-matches
18409
19009
  // it (the global `app.onError` already maps HTTP 409 to the
18410
19010
  // `sidecar-fresh` envelope `code`, so the prefix is for log-grep
18411
19011
  // affinity with the CLI's bump verb).
@@ -18425,41 +19025,41 @@ var SERVER_TEXTS = {
18425
19025
  * `ConfirmationService` dialog explaining `.sm` writes; on accept
18426
19026
  * it retries with `confirm: true` in the body.
18427
19027
  */
18428
- sidecarConsentRequired: "consent required to write .sm sidecar files in this project. Retry with `confirm: true` to grant (writes to .skill-map/settings.local.json \u2014 gitignored).",
19028
+ sidecarConsentRequired: "consent required to write .sm sidecar files in this project. Retry with `confirm: true` to grant (writes to .skill-map/settings.local.json, gitignored).",
18429
19029
  // 500 envelope when the built-in bump action ships without an
18430
- // `invoke()` should be impossible in production but the route
19030
+ // `invoke()`, should be impossible in production but the route
18431
19031
  // throws a typed envelope rather than a bare `Error` so the global
18432
19032
  // `app.onError` can format it.
18433
19033
  sidecarBumpInvokeMissing: "built-in bump action is missing its invoke().",
18434
19034
  // ---- POST /api/scan (manual refresh) ------------------------------------
18435
- // 400 runtime cannot persist a meaningful scan because the boot
19035
+ // 400, runtime cannot persist a meaningful scan because the boot
18436
19036
  // dropped half the pipeline. Same gate the `?fresh=1` GET applies.
18437
19037
  scanPostRequiresFullPipeline: "POST /api/scan cannot run while the server was started with --no-built-ins or --no-plugins (would persist a partial DB).",
18438
- // 409 another scan (watcher batch or another POST) is in flight.
19038
+ // 409, another scan (watcher batch or another POST) is in flight.
18439
19039
  // The `scan-busy:` prefix is load-bearing: HTTP 409 maps to
18440
19040
  // `scan-busy` in `app.onError`'s `codeForStatus`, but the prefix
18441
19041
  // keeps log-grep affinity with the CLI's `sm scan` verb.
18442
19042
  scanPostBusy: "scan-busy: Another scan is already in flight; retry once it finishes.",
18443
- // 500 DB missing on a write path. Read paths degrade to empty
19043
+ // 500, DB missing on a write path. Read paths degrade to empty
18444
19044
  // shapes; mutations cannot persist without a DB so they fail fast.
18445
19045
  scanPostDbMissing: "Cannot persist scan: project DB not found. Run `sm scan` once or pass --db <path>.",
18446
19046
  // ---- plugins toggle route (routes/plugins.ts) ---------------------------
18447
- // 400 envelopes from `parsePluginPatchBody` every branch keeps its
19047
+ // 400 envelopes from `parsePluginPatchBody`, every branch keeps its
18448
19048
  // own key so the UI can disambiguate without regex on the message.
18449
19049
  pluginsBodyNotJson: "Request body must be valid JSON.",
18450
19050
  pluginsBodyNotObject: "Request body must be a JSON object.",
18451
19051
  pluginsEnabledRequired: "`enabled` is required and must be a boolean.",
18452
- // 400 granularity mismatch. Two flavours so the message is useful
19052
+ // 400, granularity mismatch. Two flavours so the message is useful
18453
19053
  // when the operator hits the wrong route by hand.
18454
19054
  pluginsGranularityExtensionExpected: 'Plugin "{{id}}" has granularity:"extension"; toggle individual extensions via PATCH /api/plugins/{{id}}/extensions/<extensionId>.',
18455
19055
  pluginsGranularityBundleExpected: 'Plugin "{{id}}" has granularity:"bundle"; toggle the whole bundle via PATCH /api/plugins/{{id}}.',
18456
- // 404 unknown plugin / extension.
19056
+ // 404, unknown plugin / extension.
18457
19057
  pluginsUnknown: 'No plugin with id "{{id}}".',
18458
19058
  pluginsExtensionUnknown: 'Plugin "{{bundleId}}" has no extension named "{{extensionId}}".',
18459
- // 500 DB missing on a write path. Read paths degrade to empty
19059
+ // 500, DB missing on a write path. Read paths degrade to empty
18460
19060
  // shapes, but mutations cannot persist without a DB so they fail fast.
18461
19061
  pluginsDbMissing: "Cannot persist plugin override: project DB not found at {{path}}. Run `sm scan` first or pass --db <path>.",
18462
- // 403 host-enforced lock from `src/server/locked-plugins.ts`. The
19062
+ // 403, host-enforced lock from `src/server/locked-plugins.ts`. The
18463
19063
  // bundle (or qualified extension) is in the hardcoded lock-list and
18464
19064
  // its enabled state is fixed; the UI mirrors the same rule by
18465
19065
  // disabling the toggle.
@@ -18475,7 +19075,7 @@ var SERVER_TEXTS = {
18475
19075
  //
18476
19076
  // GET / PATCH /api/preferences. The PATCH body is shaped
18477
19077
  // `{ updateCheck?: { enabled?: boolean } }`
18478
- // additive: future user-only preferences (locale, theme) extend the
19078
+ // additive: future user-only preferences (locale, theme) extend the
18479
19079
  // shape under their own sub-key. Each error keeps its own message
18480
19080
  // key so the UI can disambiguate without regex on the body.
18481
19081
  preferencesBodyNotJson: "Request body must be valid JSON.",
@@ -18506,15 +19106,69 @@ var SERVER_TEXTS = {
18506
19106
  wsBackpressureEvicted: "skill-map server: ws client evicted (bufferedAmount={{buffered}} > threshold={{threshold}}).\n",
18507
19107
  // `WebSocket.send()` threw on a registered client. The client is
18508
19108
  // unregistered; the broadcast continues with the remaining clients.
18509
- wsClientSendFailed: "skill-map server: ws send failed \u2014 {{message}}.\n",
19109
+ wsClientSendFailed: "skill-map server: ws send failed ({{message}}).\n",
18510
19110
  // `JSON.stringify(envelope)` threw inside `broadcast()`. The event is
18511
19111
  // dropped. Per spec/job-events.md §Error handling, the right shape
18512
19112
  // is a synthetic `emitter.error` event; v14.4.a does not yet route
18513
19113
  // it through the broadcaster (would re-enter the same stringify
18514
19114
  // path), so we degrade to a logged warning.
18515
- wsBroadcastSerializeFailed: "skill-map server: ws broadcast dropped \u2014 failed to serialize event: {{message}}.\n"
19115
+ wsBroadcastSerializeFailed: "skill-map server: ws broadcast dropped, failed to serialize event: {{message}}.\n"
18516
19116
  };
18517
19117
 
19118
+ // server/loopback-gate.ts
19119
+ var LOOPBACK_HOSTNAMES = /* @__PURE__ */ new Set([
19120
+ "127.0.0.1",
19121
+ "::1",
19122
+ "localhost"
19123
+ ]);
19124
+ function createLoopbackGate(_opts) {
19125
+ return async function loopbackGate(c, next) {
19126
+ if (!hostAllowed(c.req.header("host"))) {
19127
+ throw new LoopbackGateError({
19128
+ code: "host-not-allowed",
19129
+ message: SERVER_TEXTS.hostNotAllowed
19130
+ });
19131
+ }
19132
+ if (originGuarded(c.req.path) && !originAllowed(c.req.header("origin"))) {
19133
+ throw new LoopbackGateError({
19134
+ code: "origin-not-allowed",
19135
+ message: SERVER_TEXTS.originNotAllowed
19136
+ });
19137
+ }
19138
+ await next();
19139
+ };
19140
+ }
19141
+ function hostAllowed(host) {
19142
+ if (host === void 0 || host === "") return true;
19143
+ return LOOPBACK_HOSTNAMES.has(hostnameOf(host));
19144
+ }
19145
+ function originAllowed(origin) {
19146
+ if (origin === void 0 || origin === "") return true;
19147
+ if (origin.toLowerCase() === "null") return true;
19148
+ try {
19149
+ const url = new URL(origin);
19150
+ if (url.protocol !== "http:" && url.protocol !== "https:") return false;
19151
+ return LOOPBACK_HOSTNAMES.has(url.hostname.toLowerCase());
19152
+ } catch {
19153
+ return false;
19154
+ }
19155
+ }
19156
+ function hostnameOf(host) {
19157
+ const lower = host.toLowerCase();
19158
+ if (lower.startsWith("[")) {
19159
+ const close = lower.indexOf("]");
19160
+ if (close < 0) return lower;
19161
+ return lower.slice(1, close);
19162
+ }
19163
+ const colon = lower.indexOf(":");
19164
+ return colon < 0 ? lower : lower.slice(0, colon);
19165
+ }
19166
+ function originGuarded(path) {
19167
+ if (path === "/ws") return true;
19168
+ if (path.startsWith("/api/")) return true;
19169
+ return false;
19170
+ }
19171
+
18518
19172
  // server/routes/annotations.ts
18519
19173
  var ENVELOPE_KIND = "annotations.registered";
18520
19174
  function registerAnnotationsRoute(app, deps) {
@@ -18531,6 +19185,63 @@ function registerAnnotationsRoute(app, deps) {
18531
19185
  }
18532
19186
 
18533
19187
  // server/routes/contributions.ts
19188
+ import { HTTPException as HTTPException2 } from "hono/http-exception";
19189
+
19190
+ // server/util/parse-query.ts
19191
+ import { HTTPException } from "hono/http-exception";
19192
+ function parseCsv(value) {
19193
+ if (value === void 0) return [];
19194
+ return value.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
19195
+ }
19196
+ function parsePagination(query, defaults) {
19197
+ const offset = parseNonNegativeInt(query.offset, "offset", 0);
19198
+ const limit = parseNonNegativeInt(query.limit, "limit", defaults.limit);
19199
+ if (limit > defaults.max) {
19200
+ throw new HTTPException(400, {
19201
+ message: tx(SERVER_TEXTS.paginationLimitTooLarge, {
19202
+ value: limit,
19203
+ max: defaults.max
19204
+ })
19205
+ });
19206
+ }
19207
+ return { offset, limit };
19208
+ }
19209
+ function parseBooleanFlag(value) {
19210
+ return value === "1" || value === "true";
19211
+ }
19212
+ function parseRequiredString(value, name) {
19213
+ if (typeof value !== "string" || value.length === 0) {
19214
+ throw new HTTPException(400, {
19215
+ message: tx(SERVER_TEXTS.queryRequiredString, { name })
19216
+ });
19217
+ }
19218
+ return value;
19219
+ }
19220
+ function parseNonNegativeInt(raw, name, fallback) {
19221
+ if (raw === void 0 || raw.length === 0) return fallback;
19222
+ const trimmed = raw.trim();
19223
+ const parsed = Number.parseInt(trimmed, 10);
19224
+ if (!Number.isInteger(parsed) || parsed < 0 || String(parsed) !== trimmed) {
19225
+ throw new HTTPException(400, {
19226
+ message: tx(SERVER_TEXTS.paginationInvalidInteger, { name, value: raw })
19227
+ });
19228
+ }
19229
+ return parsed;
19230
+ }
19231
+
19232
+ // server/routes/contributions.ts
19233
+ var QUALIFIED_ID_SEGMENT = /^[A-Za-z0-9._-]+$/;
19234
+ function parseQualifiedIdSegment(value, name) {
19235
+ if (!QUALIFIED_ID_SEGMENT.test(value)) {
19236
+ throw new HTTPException2(400, {
19237
+ message: tx(SERVER_TEXTS.qualifiedIdMalformed, {
19238
+ name,
19239
+ value: sanitizeForTerminal(value)
19240
+ })
19241
+ });
19242
+ }
19243
+ return value;
19244
+ }
18534
19245
  var REGISTERED_ENVELOPE_KIND = "contributions.registered";
18535
19246
  function registerContributionsRoutes(app, deps) {
18536
19247
  app.get("/api/contributions/registered", (c) => {
@@ -18544,27 +19255,21 @@ function registerContributionsRoutes(app, deps) {
18544
19255
  return c.json(envelope);
18545
19256
  });
18546
19257
  app.get("/api/contributions/:pluginId/:extensionId/:contributionId", async (c) => {
18547
- const pluginId = c.req.param("pluginId");
18548
- const extensionId = c.req.param("extensionId");
18549
- const contributionId = c.req.param("contributionId");
18550
- const nodePath = c.req.query("path");
18551
- if (typeof nodePath !== "string" || nodePath.length === 0) {
18552
- return c.json(
18553
- { error: "missing-path", message: "Required query parameter: path" },
18554
- 400
18555
- );
18556
- }
19258
+ const pluginId = parseQualifiedIdSegment(c.req.param("pluginId"), "pluginId");
19259
+ const extensionId = parseQualifiedIdSegment(c.req.param("extensionId"), "extensionId");
19260
+ const contributionId = parseQualifiedIdSegment(c.req.param("contributionId"), "contributionId");
19261
+ const nodePath = parseRequiredString(c.req.query("path"), "path");
18557
19262
  const catalogEntry = deps.kernel.getRegisteredViewContributions().find(
18558
19263
  (e) => e.pluginId === pluginId && e.extensionId === extensionId && e.contributionId === contributionId
18559
19264
  );
18560
19265
  if (!catalogEntry) {
18561
- return c.json(
18562
- {
18563
- error: "unknown-contribution",
18564
- message: `No registered contribution: ${pluginId}/${extensionId}/${contributionId}`
18565
- },
18566
- 404
18567
- );
19266
+ throw new HTTPException2(404, {
19267
+ message: tx(SERVER_TEXTS.contributionUnknown, {
19268
+ pluginId: sanitizeForTerminal(pluginId),
19269
+ extensionId: sanitizeForTerminal(extensionId),
19270
+ contributionId: sanitizeForTerminal(contributionId)
19271
+ })
19272
+ });
18568
19273
  }
18569
19274
  const rows = await tryWithSqlite(
18570
19275
  { databasePath: deps.options.dbPath, autoBackup: false },
@@ -18589,7 +19294,7 @@ function registerContributionsRoutes(app, deps) {
18589
19294
  }
18590
19295
 
18591
19296
  // server/routes/config.ts
18592
- import { HTTPException } from "hono/http-exception";
19297
+ import { HTTPException as HTTPException3 } from "hono/http-exception";
18593
19298
 
18594
19299
  // server/envelope.ts
18595
19300
  var REST_ENVELOPE_SCHEMA_VERSION = "1";
@@ -18626,7 +19331,7 @@ function registerConfigRoute(app, deps) {
18626
19331
  try {
18627
19332
  loaded = deps.configService.get();
18628
19333
  } catch (err) {
18629
- throw new HTTPException(500, { message: formatErrorMessage(err) });
19334
+ throw new HTTPException3(500, { message: formatErrorMessage(err) });
18630
19335
  }
18631
19336
  for (const warn of loaded.warnings) {
18632
19337
  log.warn(sanitizeForTerminal(warn));
@@ -18636,7 +19341,7 @@ function registerConfigRoute(app, deps) {
18636
19341
  }
18637
19342
 
18638
19343
  // server/routes/favorites.ts
18639
- import { HTTPException as HTTPException2 } from "hono/http-exception";
19344
+ import { HTTPException as HTTPException4 } from "hono/http-exception";
18640
19345
 
18641
19346
  // server/path-codec.ts
18642
19347
  var PathCodecError = class extends Error {
@@ -18676,7 +19381,7 @@ function registerFavoritesRoutes(app, deps) {
18676
19381
  }
18677
19382
  );
18678
19383
  if (!result || !result.found) {
18679
- throw new HTTPException2(404, {
19384
+ throw new HTTPException4(404, {
18680
19385
  message: tx(SERVER_TEXTS.nodeNotFound, { path: nodePath })
18681
19386
  });
18682
19387
  }
@@ -18696,18 +19401,30 @@ function decodePath(pathB64) {
18696
19401
  return decodeNodePath(pathB64);
18697
19402
  } catch (err) {
18698
19403
  if (err instanceof PathCodecError) {
18699
- throw new HTTPException2(404, { message: SERVER_TEXTS.pathB64Malformed });
19404
+ throw new HTTPException4(404, { message: SERVER_TEXTS.pathB64Malformed });
18700
19405
  }
18701
19406
  throw err;
18702
19407
  }
18703
19408
  }
18704
19409
 
18705
19410
  // server/routes/graph.ts
18706
- import { HTTPException as HTTPException3 } from "hono/http-exception";
19411
+ import { HTTPException as HTTPException5 } from "hono/http-exception";
18707
19412
  var DEFAULT_FORMAT2 = "ascii";
19413
+ var FORMAT_ID_PATTERN = /^[a-z0-9-]+$/;
19414
+ var FORMAT_ID_MAX = 32;
18708
19415
  function registerGraphRoute(app, deps) {
18709
19416
  app.get("/api/graph", async (c) => {
18710
19417
  const format = c.req.query("format") ?? DEFAULT_FORMAT2;
19418
+ if (format.length > FORMAT_ID_MAX || !FORMAT_ID_PATTERN.test(format)) {
19419
+ throw new HTTPException5(400, {
19420
+ // Sanitize defensively, the regex above already rejects ANSI
19421
+ // and control bytes, but the message interpolates user input
19422
+ // and the BFF mirrors error envelopes into the server log.
19423
+ message: tx(SERVER_TEXTS.graphFormatMalformed, {
19424
+ value: sanitizeForTerminal(format)
19425
+ })
19426
+ });
19427
+ }
18711
19428
  const formatters = composeFormatters({
18712
19429
  noBuiltIns: deps.options.noBuiltIns,
18713
19430
  pluginRuntime: deps.pluginRuntime
@@ -18715,7 +19432,7 @@ function registerGraphRoute(app, deps) {
18715
19432
  const formatter = formatters.find((f) => f.formatId === format);
18716
19433
  if (!formatter) {
18717
19434
  const available = formatters.map((f) => f.formatId).sort().join(", ");
18718
- throw new HTTPException3(400, {
19435
+ throw new HTTPException5(400, {
18719
19436
  message: tx(SERVER_TEXTS.graphUnknownFormat, {
18720
19437
  format,
18721
19438
  available: available || "(none)"
@@ -18726,16 +19443,23 @@ function registerGraphRoute(app, deps) {
18726
19443
  { databasePath: deps.options.dbPath, autoBackup: false },
18727
19444
  (adapter) => adapter.scans.load()
18728
19445
  );
18729
- const scan = loaded ?? { nodes: [], links: [], issues: [] };
18730
- const text = formatter.format({
18731
- nodes: scan.nodes,
18732
- links: scan.links,
18733
- issues: scan.issues
18734
- });
19446
+ const text = renderGraphPayload(formatter, loaded);
18735
19447
  const body = text.endsWith("\n") ? text : text + "\n";
18736
19448
  return c.body(body, 200, { "content-type": contentTypeFor(format) });
18737
19449
  });
18738
19450
  }
19451
+ function renderGraphPayload(formatter, loaded) {
19452
+ const scan = loaded ?? { nodes: [], links: [], issues: [] };
19453
+ if (loaded === null) {
19454
+ return formatter.format({ nodes: scan.nodes, links: scan.links, issues: scan.issues });
19455
+ }
19456
+ return formatter.format({
19457
+ nodes: scan.nodes,
19458
+ links: scan.links,
19459
+ issues: scan.issues,
19460
+ scanResult: loaded
19461
+ });
19462
+ }
18739
19463
  function contentTypeFor(format) {
18740
19464
  if (format === "json") return "application/json; charset=utf-8";
18741
19465
  if (format === "md" || format === "markdown" || format === "mermaid") {
@@ -18801,67 +19525,41 @@ function registerHealthRoute(app, deps) {
18801
19525
  });
18802
19526
  }
18803
19527
 
18804
- // server/util/parse-query.ts
18805
- import { HTTPException as HTTPException4 } from "hono/http-exception";
18806
- function parseCsv(value) {
18807
- if (value === void 0) return [];
18808
- return value.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
18809
- }
18810
- function parsePagination(query, defaults) {
18811
- const offset = parseNonNegativeInt(query.offset, "offset", 0);
18812
- const limit = parseNonNegativeInt(query.limit, "limit", defaults.limit);
18813
- if (limit > defaults.max) {
18814
- throw new HTTPException4(400, {
18815
- message: tx(SERVER_TEXTS.paginationLimitTooLarge, {
18816
- value: limit,
18817
- max: defaults.max
18818
- })
18819
- });
18820
- }
18821
- return { offset, limit };
18822
- }
18823
- function parseBooleanFlag(value) {
18824
- return value === "1" || value === "true";
18825
- }
18826
- function parseNonNegativeInt(raw, name, fallback) {
18827
- if (raw === void 0 || raw.length === 0) return fallback;
18828
- const trimmed = raw.trim();
18829
- const parsed = Number.parseInt(trimmed, 10);
18830
- if (!Number.isInteger(parsed) || parsed < 0 || String(parsed) !== trimmed) {
18831
- throw new HTTPException4(400, {
18832
- message: tx(SERVER_TEXTS.paginationInvalidInteger, { name, value: raw })
18833
- });
18834
- }
18835
- return parsed;
18836
- }
18837
-
18838
19528
  // server/routes/issues.ts
19529
+ var DEFAULT_LIMIT = 100;
19530
+ var MAX_LIMIT = 1e3;
18839
19531
  function registerIssuesRoute(app, deps) {
18840
19532
  app.get("/api/issues", async (c) => {
18841
19533
  const severityFilter = parseCsv(c.req.query("severity"));
18842
19534
  const analyzerFilter = parseCsv(c.req.query("analyzerId"));
18843
19535
  const nodePath = c.req.query("node") ?? null;
18844
- const loaded = await tryWithSqlite(
19536
+ const { offset, limit } = parsePagination(c.req.query(), {
19537
+ limit: DEFAULT_LIMIT,
19538
+ max: MAX_LIMIT
19539
+ });
19540
+ const result = await tryWithSqlite(
18845
19541
  { databasePath: deps.options.dbPath, autoBackup: false },
18846
- (adapter) => adapter.issues.listAll()
19542
+ (adapter) => adapter.issues.list({
19543
+ severities: severityFilter,
19544
+ analyzerIds: analyzerFilter,
19545
+ nodePath,
19546
+ offset,
19547
+ limit
19548
+ })
18847
19549
  );
18848
- const allIssues = loaded ?? [];
18849
- const filtered = allIssues.filter((issue) => {
18850
- if (severityFilter.length > 0 && !severityFilter.includes(issue.severity)) return false;
18851
- if (analyzerFilter.length > 0 && !matchesAnalyzerFilter(issue.analyzerId, analyzerFilter)) return false;
18852
- if (nodePath !== null && !issue.nodeIds.includes(nodePath)) return false;
18853
- return true;
18854
- });
19550
+ const items = result?.items ?? [];
19551
+ const total = result?.total ?? 0;
18855
19552
  return c.json(
18856
19553
  buildListEnvelope({
18857
19554
  kind: "issues",
18858
- items: filtered,
19555
+ items,
18859
19556
  filters: {
18860
19557
  severity: severityFilter.length > 0 ? severityFilter : null,
18861
19558
  analyzerId: analyzerFilter.length > 0 ? analyzerFilter : null,
18862
19559
  node: nodePath
18863
19560
  },
18864
- total: filtered.length,
19561
+ total,
19562
+ page: { offset, limit },
18865
19563
  kindRegistry: deps.kindRegistry,
18866
19564
  contributionsRegistry: deps.contributionsRegistry
18867
19565
  })
@@ -18904,7 +19602,7 @@ function registerLinksRoute(app, deps) {
18904
19602
  }
18905
19603
 
18906
19604
  // server/routes/nodes.ts
18907
- import { HTTPException as HTTPException5 } from "hono/http-exception";
19605
+ import { HTTPException as HTTPException6 } from "hono/http-exception";
18908
19606
 
18909
19607
  // server/node-body.ts
18910
19608
  import { readFile as readFile4 } from "fs/promises";
@@ -18990,8 +19688,8 @@ function splitCsv(raw) {
18990
19688
  }
18991
19689
 
18992
19690
  // server/routes/nodes.ts
18993
- var DEFAULT_LIMIT = 100;
18994
- var MAX_LIMIT = 1e3;
19691
+ var DEFAULT_LIMIT2 = 100;
19692
+ var MAX_LIMIT2 = 1e3;
18995
19693
  var BFF_MAX_BULK_CONTRIBUTIONS = 200;
18996
19694
  function registerNodesRoutes(app, deps) {
18997
19695
  app.get("/api/nodes/:pathB64", async (c) => {
@@ -19001,7 +19699,7 @@ function registerNodesRoutes(app, deps) {
19001
19699
  nodePath = decodeNodePath(pathB64);
19002
19700
  } catch (err) {
19003
19701
  if (err instanceof PathCodecError) {
19004
- throw new HTTPException5(404, { message: SERVER_TEXTS.pathB64Malformed });
19702
+ throw new HTTPException6(404, { message: SERVER_TEXTS.pathB64Malformed });
19005
19703
  }
19006
19704
  throw err;
19007
19705
  }
@@ -19033,7 +19731,7 @@ function registerNodesRoutes(app, deps) {
19033
19731
  const contributions = result?.contributions ?? [];
19034
19732
  const tags = result?.tags ?? { byAuthor: [], byUser: [] };
19035
19733
  if (!bundle) {
19036
- throw new HTTPException5(404, {
19734
+ throw new HTTPException6(404, {
19037
19735
  message: tx(SERVER_TEXTS.nodeNotFound, { path: nodePath })
19038
19736
  });
19039
19737
  }
@@ -19054,8 +19752,8 @@ function registerNodesRoutes(app, deps) {
19054
19752
  const params = new URL(c.req.url).searchParams;
19055
19753
  const { query, filters } = urlParamsToExportQuery(params);
19056
19754
  const { offset, limit } = parsePagination(c.req.query(), {
19057
- limit: DEFAULT_LIMIT,
19058
- max: MAX_LIMIT
19755
+ limit: DEFAULT_LIMIT2,
19756
+ max: MAX_LIMIT2
19059
19757
  });
19060
19758
  const opened = await tryWithSqlite(
19061
19759
  { databasePath: deps.options.dbPath, autoBackup: false },
@@ -19150,11 +19848,11 @@ async function groupContributionsByPath(rows) {
19150
19848
  }
19151
19849
 
19152
19850
  // server/routes/plugins.ts
19153
- import { HTTPException as HTTPException7 } from "hono/http-exception";
19851
+ import { HTTPException as HTTPException8 } from "hono/http-exception";
19154
19852
 
19155
19853
  // server/util/parse-body.ts
19156
19854
  import { Ajv2020 as Ajv20207 } from "ajv/dist/2020.js";
19157
- import { HTTPException as HTTPException6 } from "hono/http-exception";
19855
+ import { HTTPException as HTTPException7 } from "hono/http-exception";
19158
19856
  function makeBodyValidator(schema, messages) {
19159
19857
  const ajv = new Ajv20207({ strict: false, allErrors: false });
19160
19858
  const validate = ajv.compile(schema);
@@ -19163,16 +19861,16 @@ function makeBodyValidator(schema, messages) {
19163
19861
  try {
19164
19862
  raw = await req.json();
19165
19863
  } catch {
19166
- throw new HTTPException6(400, { message: messages.notJson });
19864
+ throw new HTTPException7(400, { message: messages.notJson });
19167
19865
  }
19168
19866
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
19169
- throw new HTTPException6(400, { message: messages.notObject });
19867
+ throw new HTTPException7(400, { message: messages.notObject });
19170
19868
  }
19171
19869
  if (validate(raw)) {
19172
19870
  return raw;
19173
19871
  }
19174
19872
  const message = resolveErrorMessage(validate.errors, messages);
19175
- throw new HTTPException6(400, { message });
19873
+ throw new HTTPException7(400, { message });
19176
19874
  };
19177
19875
  }
19178
19876
  function resolveErrorMessage(errors, messages) {
@@ -19272,23 +19970,23 @@ function registerPluginsRoute(app, deps) {
19272
19970
  app.patch("/api/plugins/:id", async (c) => {
19273
19971
  const id = c.req.param("id");
19274
19972
  if (id.includes("/")) {
19275
- throw new HTTPException7(400, {
19973
+ throw new HTTPException8(400, {
19276
19974
  message: tx(SERVER_TEXTS.pluginsGranularityExtensionExpected, { id })
19277
19975
  });
19278
19976
  }
19279
19977
  const handle = findHandle(id, deps);
19280
19978
  if (!handle) {
19281
- throw new HTTPException7(404, {
19979
+ throw new HTTPException8(404, {
19282
19980
  message: tx(SERVER_TEXTS.pluginsUnknown, { id })
19283
19981
  });
19284
19982
  }
19285
19983
  if (granularityOf(handle) !== "bundle") {
19286
- throw new HTTPException7(400, {
19984
+ throw new HTTPException8(400, {
19287
19985
  message: tx(SERVER_TEXTS.pluginsGranularityExtensionExpected, { id })
19288
19986
  });
19289
19987
  }
19290
19988
  if (isPluginLocked(id)) {
19291
- throw new HTTPException7(403, {
19989
+ throw new HTTPException8(403, {
19292
19990
  message: tx(SERVER_TEXTS.pluginsLocked, { id })
19293
19991
  });
19294
19992
  }
@@ -19300,23 +19998,23 @@ function registerPluginsRoute(app, deps) {
19300
19998
  const extensionId = c.req.param("extensionId");
19301
19999
  const handle = findHandle(bundleId, deps);
19302
20000
  if (!handle) {
19303
- throw new HTTPException7(404, {
20001
+ throw new HTTPException8(404, {
19304
20002
  message: tx(SERVER_TEXTS.pluginsUnknown, { id: bundleId })
19305
20003
  });
19306
20004
  }
19307
20005
  if (granularityOf(handle) !== "extension") {
19308
- throw new HTTPException7(400, {
20006
+ throw new HTTPException8(400, {
19309
20007
  message: tx(SERVER_TEXTS.pluginsGranularityBundleExpected, { id: bundleId })
19310
20008
  });
19311
20009
  }
19312
20010
  if (!hasExtension(handle, extensionId)) {
19313
- throw new HTTPException7(404, {
20011
+ throw new HTTPException8(404, {
19314
20012
  message: tx(SERVER_TEXTS.pluginsExtensionUnknown, { bundleId, extensionId })
19315
20013
  });
19316
20014
  }
19317
20015
  const qualified = qualifiedExtensionId(bundleId, extensionId);
19318
20016
  if (isPluginLocked(qualified) || isPluginLocked(bundleId)) {
19319
- throw new HTTPException7(403, {
20017
+ throw new HTTPException8(403, {
19320
20018
  message: tx(SERVER_TEXTS.pluginsExtensionLocked, { bundleId, extensionId })
19321
20019
  });
19322
20020
  }
@@ -19328,17 +20026,12 @@ function registerPluginsRoute(app, deps) {
19328
20026
  for (const change of changes) {
19329
20027
  const failure = validateBulkChange(change, deps);
19330
20028
  if (failure !== null) {
19331
- return c.json(
19332
- {
19333
- ok: false,
19334
- error: {
19335
- code: failure.code,
19336
- message: failure.message,
19337
- details: { id: change.id }
19338
- }
19339
- },
19340
- failure.status
19341
- );
20029
+ throw new BulkValidationError({
20030
+ status: failure.status,
20031
+ code: failure.code,
20032
+ message: failure.message,
20033
+ id: change.id
20034
+ });
19342
20035
  }
19343
20036
  }
19344
20037
  return await persistBulkAndProject(c, deps, changes);
@@ -19473,16 +20166,8 @@ async function applyChangeToAdapter(adapter, configKey, enabled) {
19473
20166
  }
19474
20167
  function projectListResponse(c, deps, overrides) {
19475
20168
  if (overrides === null) {
19476
- return c.json(
19477
- {
19478
- ok: false,
19479
- error: {
19480
- code: "db-missing",
19481
- message: tx(SERVER_TEXTS.pluginsDbMissing, { path: deps.options.dbPath }),
19482
- details: null
19483
- }
19484
- },
19485
- 500
20169
+ throw new DbMissingError(
20170
+ tx(SERVER_TEXTS.pluginsDbMissing, { path: deps.options.dbPath })
19486
20171
  );
19487
20172
  }
19488
20173
  const freshResolver = composeResolver2(deps, overrides);
@@ -19598,7 +20283,7 @@ function hasExtension(handle, extensionId) {
19598
20283
  }
19599
20284
 
19600
20285
  // server/routes/preferences.ts
19601
- import { HTTPException as HTTPException8 } from "hono/http-exception";
20286
+ import { HTTPException as HTTPException9 } from "hono/http-exception";
19602
20287
  function registerPreferencesRoute(app, deps) {
19603
20288
  app.get("/api/preferences", (c) => {
19604
20289
  return c.json(buildEnvelope(deps));
@@ -19631,7 +20316,7 @@ function applyPatch(deps, body) {
19631
20316
  });
19632
20317
  wrote = true;
19633
20318
  } catch (err) {
19634
- throw new HTTPException8(400, {
20319
+ throw new HTTPException9(400, {
19635
20320
  message: tx(SERVER_TEXTS.preferencesPersistFailed, {
19636
20321
  message: formatErrorMessage(err)
19637
20322
  })
@@ -19666,7 +20351,7 @@ var parsePatchBody2 = makeBodyValidator(PATCH_BODY_SCHEMA, {
19666
20351
  });
19667
20352
 
19668
20353
  // server/routes/project-preferences.ts
19669
- import { HTTPException as HTTPException9 } from "hono/http-exception";
20354
+ import { HTTPException as HTTPException10 } from "hono/http-exception";
19670
20355
  function registerProjectPreferencesRoute(app, deps) {
19671
20356
  app.get("/api/project-preferences", (c) => {
19672
20357
  return c.json(buildEnvelope2(deps));
@@ -19705,7 +20390,7 @@ function applyPatch2(deps, body) {
19705
20390
  const exposures = writes.map((w) => projectPathExposure({ key: w.key, value: w.value, cwd, homedir: homedir4 })).filter((e) => e.expandsSurface);
19706
20391
  if (exposures.length > 0 && body.confirm !== true) {
19707
20392
  const exposed = exposures.flatMap((e) => e.exposedPaths);
19708
- throw new HTTPException9(412, {
20393
+ throw new HTTPException10(412, {
19709
20394
  message: tx(SERVER_TEXTS.projectPrefsConfirmRequired, {
19710
20395
  paths: exposed.join(", ")
19711
20396
  })
@@ -19716,7 +20401,7 @@ function applyPatch2(deps, body) {
19716
20401
  writeConfigValue(w.key, w.value, { target: "project-local", cwd, homedir: homedir4 });
19717
20402
  } catch (err) {
19718
20403
  const status = err instanceof ConfigValidationError ? 400 : 400;
19719
- throw new HTTPException9(status, {
20404
+ throw new HTTPException10(status, {
19720
20405
  message: tx(SERVER_TEXTS.projectPrefsPersistFailed, {
19721
20406
  key: w.key,
19722
20407
  message: formatErrorMessage(err)
@@ -19771,7 +20456,7 @@ var parsePatchBody3 = makeBodyValidator(PATCH_BODY_SCHEMA2, {
19771
20456
  });
19772
20457
 
19773
20458
  // server/routes/scan.ts
19774
- import { HTTPException as HTTPException10 } from "hono/http-exception";
20459
+ import { HTTPException as HTTPException11 } from "hono/http-exception";
19775
20460
 
19776
20461
  // server/scan-mutex.ts
19777
20462
  var inFlight = null;
@@ -19898,24 +20583,14 @@ function registerScanRoute(app, deps) {
19898
20583
  }
19899
20584
  async function runPersistedScan(c, deps) {
19900
20585
  if (deps.options.noBuiltIns || deps.options.noPlugins) {
19901
- throw new HTTPException10(400, { message: SERVER_TEXTS.scanPostRequiresFullPipeline });
20586
+ throw new HTTPException11(400, { message: SERVER_TEXTS.scanPostRequiresFullPipeline });
19902
20587
  }
19903
20588
  const dbExists = await tryWithSqlite(
19904
20589
  { databasePath: deps.options.dbPath, autoBackup: false },
19905
20590
  async () => true
19906
20591
  );
19907
20592
  if (dbExists !== true) {
19908
- return c.json(
19909
- {
19910
- ok: false,
19911
- error: {
19912
- code: "db-missing",
19913
- message: SERVER_TEXTS.scanPostDbMissing,
19914
- details: null
19915
- }
19916
- },
19917
- 500
19918
- );
20593
+ throw new DbMissingError(SERVER_TEXTS.scanPostDbMissing);
19919
20594
  }
19920
20595
  try {
19921
20596
  return await withScanMutex(async () => {
@@ -19937,15 +20612,15 @@ async function runPersistedScan(c, deps) {
19937
20612
  emitterFactory: () => buildBroadcasterEmitter(deps.broadcaster)
19938
20613
  });
19939
20614
  if (outcome.kind !== "ok") {
19940
- throw new HTTPException10(500, {
19941
- message: outcome.kind === "guard-trip" ? `scan refused (existing rows: ${outcome.existing})` : outcome.message
20615
+ throw new HTTPException11(500, {
20616
+ message: outcome.kind === "guard-trip" ? tx(SERVER_TEXTS.scanGuardTrip, { existing: outcome.existing }) : outcome.message
19942
20617
  });
19943
20618
  }
19944
20619
  return c.json(outcome.result);
19945
20620
  });
19946
20621
  } catch (err) {
19947
20622
  if (err instanceof ScanBusyError) {
19948
- throw new HTTPException10(409, { message: SERVER_TEXTS.scanPostBusy });
20623
+ throw new HTTPException11(409, { message: SERVER_TEXTS.scanPostBusy });
19949
20624
  }
19950
20625
  throw err;
19951
20626
  }
@@ -20009,7 +20684,7 @@ function groupTagsBySource2(rows) {
20009
20684
  }
20010
20685
  async function runFreshScan(deps) {
20011
20686
  if (deps.options.noBuiltIns || deps.options.noPlugins) {
20012
- throw new HTTPException10(400, { message: SERVER_TEXTS.freshScanRequiresPipeline });
20687
+ throw new HTTPException11(400, { message: SERVER_TEXTS.freshScanRequiresPipeline });
20013
20688
  }
20014
20689
  const resolveEnabledOverride = await buildBffResolverOverride(deps);
20015
20690
  const outcome = await runScanForCommand({
@@ -20026,7 +20701,7 @@ async function runFreshScan(deps) {
20026
20701
  // M3: reuse the boot-cached pluginRuntime so a fresh scan over
20027
20702
  // the BFF doesn't re-walk `.skill-map/plugins/` per request. A
20028
20703
  // freshly-installed plugin needs an `sm serve` restart (the rest
20029
- // of the BFF already classified against the boot snapshot
20704
+ // of the BFF already classified against the boot snapshot,
20030
20705
  // discovering new plugins here would surface them in scan output
20031
20706
  // but not in `/api/plugins` or the kindRegistry).
20032
20707
  pluginRuntime: deps.pluginRuntime,
@@ -20038,8 +20713,8 @@ async function runFreshScan(deps) {
20038
20713
  printer: bffScanRunnerPrinter
20039
20714
  });
20040
20715
  if (outcome.kind !== "ok") {
20041
- throw new HTTPException10(500, {
20042
- message: outcome.kind === "guard-trip" ? `fresh scan refused (existing rows: ${outcome.existing})` : outcome.message
20716
+ throw new HTTPException11(500, {
20717
+ message: outcome.kind === "guard-trip" ? tx(SERVER_TEXTS.freshScanGuardTrip, { existing: outcome.existing }) : outcome.message
20043
20718
  });
20044
20719
  }
20045
20720
  return outcome.result;
@@ -20073,7 +20748,7 @@ function emptyScanResult() {
20073
20748
  }
20074
20749
 
20075
20750
  // server/routes/sidecar.ts
20076
- import { HTTPException as HTTPException11 } from "hono/http-exception";
20751
+ import { HTTPException as HTTPException12 } from "hono/http-exception";
20077
20752
  import { resolve as resolve33 } from "path";
20078
20753
  var STATUS_FRESH = "fresh";
20079
20754
  var ENVELOPE_KIND2 = "sidecar.bumped";
@@ -20110,11 +20785,11 @@ function registerSidecarRoutes(app, deps) {
20110
20785
  assertContained(deps.runtimeContext.cwd, node.path);
20111
20786
  absPath = resolve33(deps.runtimeContext.cwd, node.path);
20112
20787
  } catch (err) {
20113
- throw new HTTPException11(500, { message: formatErrorMessage(err) });
20788
+ throw new HTTPException12(500, { message: formatErrorMessage(err) });
20114
20789
  }
20115
20790
  const result = invokeBump2(node, absPath, body);
20116
20791
  if (result.report.ok === false && result.report.reason === "fresh") {
20117
- throw new HTTPException11(409, { message: SERVER_TEXTS.sidecarFreshRefusal });
20792
+ throw new HTTPException12(409, { message: SERVER_TEXTS.sidecarFreshRefusal });
20118
20793
  }
20119
20794
  if (result.report.ok === true && result.report.noop === true) {
20120
20795
  const envelope2 = {
@@ -20142,7 +20817,7 @@ function registerSidecarRoutes(app, deps) {
20142
20817
  }
20143
20818
  } catch (err) {
20144
20819
  if (err instanceof EConsentRequiredError) throw err;
20145
- throw new HTTPException11(500, { message: formatErrorMessage(err) });
20820
+ throw new HTTPException12(500, { message: formatErrorMessage(err) });
20146
20821
  }
20147
20822
  if (body.confirm === true) {
20148
20823
  deps.configService.reload();
@@ -20179,7 +20854,7 @@ async function loadNode(deps, nodePath) {
20179
20854
  );
20180
20855
  const node = persisted?.nodes.find((n) => n.path === nodePath);
20181
20856
  if (!node) {
20182
- throw new HTTPException11(404, {
20857
+ throw new HTTPException12(404, {
20183
20858
  message: tx(SERVER_TEXTS.nodeNotFound, { path: nodePath })
20184
20859
  });
20185
20860
  }
@@ -20187,7 +20862,7 @@ async function loadNode(deps, nodePath) {
20187
20862
  }
20188
20863
  function invokeBump2(node, absPath, body) {
20189
20864
  if (!bumpAction.invoke) {
20190
- throw new HTTPException11(500, { message: SERVER_TEXTS.sidecarBumpInvokeMissing });
20865
+ throw new HTTPException12(500, { message: SERVER_TEXTS.sidecarBumpInvokeMissing });
20191
20866
  }
20192
20867
  const input = {};
20193
20868
  if (body.force === true) input.force = true;
@@ -20252,7 +20927,7 @@ var PLACEHOLDER_HTML = `<!doctype html>
20252
20927
  </head>
20253
20928
  <body>
20254
20929
  <h1>skill-map server is running</h1>
20255
- <p>The UI bundle was not found. If you installed <code>@skill-map/cli</code> from npm, this is a packaging bug \u2014 please report it. If you're developing in the monorepo, run <code>npm run build --workspace=ui</code> from the repo root and restart <code>sm serve</code> (or pass <code>--ui-dist &lt;path&gt;</code> to point at a custom build).</p>
20930
+ <p>The UI bundle was not found. If you installed <code>@skill-map/cli</code> from npm, this is a packaging bug; please report it. If you're developing in the monorepo, run <code>npm run build --workspace=ui</code> from the repo root and restart <code>sm serve</code> (or pass <code>--ui-dist &lt;path&gt;</code> to point at a custom build).</p>
20256
20931
  <p>The REST API is available at <code>/api/health</code>.</p>
20257
20932
  </body>
20258
20933
  </html>
@@ -20271,7 +20946,7 @@ var DEV_PLACEHOLDER_HTML = `<!doctype html>
20271
20946
  </style>
20272
20947
  </head>
20273
20948
  <body>
20274
- <h1>skill-map BFF in dev mode \u2014 UI disabled</h1>
20949
+ <h1>skill-map BFF in dev mode (UI disabled)</h1>
20275
20950
  <p>Run <code>npm run ui:dev</code> in another terminal and visit <a href="http://localhost:4200/">http://localhost:4200/</a> for the Angular SPA.</p>
20276
20951
  <p>The REST API on this port is reachable at <code>/api/health</code>.</p>
20277
20952
  </body>
@@ -20372,6 +21047,31 @@ function attachBroadcasterRoute(app, broadcaster) {
20372
21047
  }
20373
21048
 
20374
21049
  // server/app.ts
21050
+ var BODY_LIMIT_BYTES = 1024 * 1024;
21051
+ var DbMissingError = class extends HTTPException13 {
21052
+ constructor(message) {
21053
+ super(500, { message });
21054
+ this.name = "DbMissingError";
21055
+ }
21056
+ };
21057
+ var BulkValidationError = class extends HTTPException13 {
21058
+ id;
21059
+ code;
21060
+ constructor(init) {
21061
+ super(init.status, { message: init.message });
21062
+ this.name = "BulkValidationError";
21063
+ this.id = init.id;
21064
+ this.code = init.code;
21065
+ }
21066
+ };
21067
+ var LoopbackGateError = class extends HTTPException13 {
21068
+ code;
21069
+ constructor(init) {
21070
+ super(403, { message: init.message });
21071
+ this.name = "LoopbackGateError";
21072
+ this.code = init.code;
21073
+ }
21074
+ };
20375
21075
  function createApp(deps) {
20376
21076
  const app = new Hono();
20377
21077
  const configService = new ConfigService({
@@ -20379,6 +21079,16 @@ function createApp(deps) {
20379
21079
  cwd: deps.runtimeContext.cwd,
20380
21080
  homedir: deps.runtimeContext.homedir
20381
21081
  });
21082
+ app.use("*", createLoopbackGate({ port: deps.options.port }));
21083
+ app.use(
21084
+ "/api/*",
21085
+ bodyLimit({
21086
+ maxSize: BODY_LIMIT_BYTES,
21087
+ onError: () => {
21088
+ throw new HTTPException13(413, { message: tx(SERVER_TEXTS.bodyTooLarge, { maxBytes: String(BODY_LIMIT_BYTES) }) });
21089
+ }
21090
+ })
21091
+ );
20382
21092
  if (deps.options.devCors) {
20383
21093
  app.use("*", async (c, next) => {
20384
21094
  await next();
@@ -20416,16 +21126,16 @@ function createApp(deps) {
20416
21126
  registerPreferencesRoute(app, routeDeps);
20417
21127
  registerProjectPreferencesRoute(app, routeDeps);
20418
21128
  app.all("/api/*", (c) => {
20419
- throw new HTTPException12(404, {
20420
- message: tx(SERVER_TEXTS.unknownApiEndpoint, { path: c.req.path })
21129
+ throw new HTTPException13(404, {
21130
+ message: tx(SERVER_TEXTS.unknownApiEndpoint, { path: sanitizeForTerminal(c.req.path) })
20421
21131
  });
20422
21132
  });
20423
21133
  attachBroadcasterRoute(app, deps.broadcaster);
20424
21134
  app.use("*", createStaticHandler({ uiDist: deps.options.uiDist, noUi: deps.options.noUi }));
20425
21135
  app.get("*", createSpaFallback({ uiDist: deps.options.uiDist, noUi: deps.options.noUi }));
20426
21136
  app.notFound((c) => {
20427
- throw new HTTPException12(404, {
20428
- message: tx(SERVER_TEXTS.unknownPath, { path: c.req.path })
21137
+ throw new HTTPException13(404, {
21138
+ message: tx(SERVER_TEXTS.unknownPath, { path: sanitizeForTerminal(c.req.path) })
20429
21139
  });
20430
21140
  });
20431
21141
  app.onError((err, c) => {
@@ -20438,6 +21148,7 @@ function codeForStatus(status, message) {
20438
21148
  if (status === 400) return "bad-query";
20439
21149
  if (status === 403) return "locked";
20440
21150
  if (status === 412) return "confirm-required";
21151
+ if (status === 413) return "payload-too-large";
20441
21152
  if (status === 409) {
20442
21153
  if (message.startsWith("scan-busy:")) return "scan-busy";
20443
21154
  return "sidecar-fresh";
@@ -20445,9 +21156,42 @@ function codeForStatus(status, message) {
20445
21156
  return "internal";
20446
21157
  }
20447
21158
  function formatError2(err, c) {
20448
- if (err instanceof HTTPException12) {
21159
+ if (err instanceof DbMissingError) {
21160
+ const envelope = {
21161
+ ok: false,
21162
+ error: {
21163
+ code: "db-missing",
21164
+ message: err.message,
21165
+ details: null
21166
+ }
21167
+ };
21168
+ return c.json(envelope, 500);
21169
+ }
21170
+ if (err instanceof BulkValidationError) {
21171
+ const envelope = {
21172
+ ok: false,
21173
+ error: {
21174
+ code: err.code,
21175
+ message: err.message,
21176
+ details: { id: err.id }
21177
+ }
21178
+ };
21179
+ return c.json(envelope, err.status);
21180
+ }
21181
+ if (err instanceof LoopbackGateError) {
21182
+ const envelope = {
21183
+ ok: false,
21184
+ error: {
21185
+ code: err.code,
21186
+ message: err.message,
21187
+ details: null
21188
+ }
21189
+ };
21190
+ return c.json(envelope, 403);
21191
+ }
21192
+ if (err instanceof HTTPException13) {
20449
21193
  const status = err.status;
20450
- const envelope2 = {
21194
+ const envelope = {
20451
21195
  ok: false,
20452
21196
  error: {
20453
21197
  code: codeForStatus(status, err.message),
@@ -20455,10 +21199,10 @@ function formatError2(err, c) {
20455
21199
  details: null
20456
21200
  }
20457
21201
  };
20458
- return c.json(envelope2, status);
21202
+ return c.json(envelope, status);
20459
21203
  }
20460
21204
  if (err instanceof ExportQueryError) {
20461
- const envelope2 = {
21205
+ const envelope = {
20462
21206
  ok: false,
20463
21207
  error: {
20464
21208
  code: "bad-query",
@@ -20466,10 +21210,10 @@ function formatError2(err, c) {
20466
21210
  details: null
20467
21211
  }
20468
21212
  };
20469
- return c.json(envelope2, 400);
21213
+ return c.json(envelope, 400);
20470
21214
  }
20471
21215
  if (err instanceof EConsentRequiredError) {
20472
- const envelope2 = {
21216
+ const envelope = {
20473
21217
  ok: false,
20474
21218
  error: {
20475
21219
  code: "confirm-required",
@@ -20477,13 +21221,20 @@ function formatError2(err, c) {
20477
21221
  details: { key: err.key }
20478
21222
  }
20479
21223
  };
20480
- return c.json(envelope2, 412);
21224
+ return c.json(envelope, 412);
20481
21225
  }
21226
+ return formatInternalErrorFallThrough(err, c);
21227
+ }
21228
+ function formatInternalErrorFallThrough(err, c) {
21229
+ const detail = formatErrorMessage(err);
21230
+ const stack = err instanceof Error && typeof err.stack === "string" ? err.stack : void 0;
21231
+ const context = stack !== void 0 ? { stack } : void 0;
21232
+ log.warn(`onError fall-through: ${detail}`, context);
20482
21233
  const envelope = {
20483
21234
  ok: false,
20484
21235
  error: {
20485
21236
  code: "internal",
20486
- message: formatErrorMessage(err),
21237
+ message: SERVER_TEXTS.internalError,
20487
21238
  details: null
20488
21239
  }
20489
21240
  };
@@ -20498,7 +21249,7 @@ var READY_STATE_OPEN = 1;
20498
21249
  var WsBroadcaster = class {
20499
21250
  #clients = /* @__PURE__ */ new Set();
20500
21251
  #shutDown = false;
20501
- /** Number of currently-registered clients. Read-only for tests / `/api/health`. */
21252
+ /** Number of currently-registered clients. Read-only, for tests / `/api/health`. */
20502
21253
  get clientCount() {
20503
21254
  return this.#clients.size;
20504
21255
  }
@@ -20520,7 +21271,7 @@ var WsBroadcaster = class {
20520
21271
  }
20521
21272
  /**
20522
21273
  * Unregister a client. Called from the `/ws` `onClose` / `onError`
20523
- * handlers and from the backpressure path. Idempotent calling on a
21274
+ * handlers and from the backpressure path. Idempotent, calling on a
20524
21275
  * client that was never registered (or was already removed) is a no-op.
20525
21276
  */
20526
21277
  unregister(ws) {
@@ -20563,7 +21314,7 @@ var WsBroadcaster = class {
20563
21314
  }
20564
21315
  /**
20565
21316
  * Drain every connected socket with code 1001 ('going away') + reason
20566
- * `'server shutdown'`. Idempotent a second call after the first
21317
+ * `'server shutdown'`. Idempotent, a second call after the first
20567
21318
  * `shutdown()` is a no-op. After shutdown, `register()` immediately
20568
21319
  * closes any new client offered.
20569
21320
  */
@@ -20793,7 +21544,7 @@ function validateNoUi(noUi, uiDist) {
20793
21544
 
20794
21545
  // server/paths.ts
20795
21546
  import { existsSync as existsSync25, statSync as statSync8 } from "fs";
20796
- import { dirname as dirname16, isAbsolute as isAbsolute9, join as join16, resolve as resolve34 } from "path";
21547
+ import { dirname as dirname17, isAbsolute as isAbsolute9, join as join16, resolve as resolve34 } from "path";
20797
21548
  import { fileURLToPath as fileURLToPath5 } from "url";
20798
21549
  var DEFAULT_UI_REL = join16("ui", "dist", "ui", "browser");
20799
21550
  var PACKAGE_UI_REL = "ui";
@@ -20818,7 +21569,7 @@ function isUiBundleDir(path) {
20818
21569
  function resolvePackageBundledUi() {
20819
21570
  let here;
20820
21571
  try {
20821
- here = dirname16(fileURLToPath5(import.meta.url));
21572
+ here = dirname17(fileURLToPath5(import.meta.url));
20822
21573
  } catch {
20823
21574
  return null;
20824
21575
  }
@@ -20831,7 +21582,7 @@ function resolvePackageBundledUiFrom(here) {
20831
21582
  if (isUiBundleDir(candidate)) return candidate;
20832
21583
  const distHere = join16(current, "dist", PACKAGE_UI_REL);
20833
21584
  if (isUiBundleDir(distHere)) return distHere;
20834
- const parent = dirname16(current);
21585
+ const parent = dirname17(current);
20835
21586
  if (parent === current) return null;
20836
21587
  current = parent;
20837
21588
  }
@@ -20842,7 +21593,7 @@ function walkUpForUi(startDir) {
20842
21593
  for (let i = 0; i < 64; i++) {
20843
21594
  const candidate = join16(current, DEFAULT_UI_REL);
20844
21595
  if (isUiBundleDir(candidate)) return candidate;
20845
- const parent = dirname16(current);
21596
+ const parent = dirname17(current);
20846
21597
  if (parent === current) return null;
20847
21598
  current = parent;
20848
21599
  }
@@ -21004,44 +21755,44 @@ function normalizeAddress(addr, fallbackHost, fallbackPort) {
21004
21755
  // cli/i18n/serve.texts.ts
21005
21756
  var SERVE_TEXTS = {
21006
21757
  // The boot banner (TTY box / flat-line fallback) is rendered by
21007
- // `cli/util/serve-banner.ts` rather than templated through `tx` —
21758
+ // `cli/util/serve-banner.ts` rather than templated through `tx`,
21008
21759
  // ANSI escapes + box-drawing aren't a good fit for the flat
21009
21760
  // `{{name}}` interpolation surface. The flat-mode strings live in
21010
21761
  // that helper and stay byte-equivalent to the pre-banner format so
21011
21762
  // existing pipes / redirects ('listening on <url>' scrapers) don't
21012
21763
  // break.
21013
- // Browser-open failure. Non-fatal the URL is already printed; the
21764
+ // Browser-open failure. Non-fatal, the URL is already printed; the
21014
21765
  // user can open it manually.
21015
21766
  openFailed: "sm serve: could not auto-open browser ({{message}}). Visit {{url}} manually.\n",
21016
21767
  // Bind failure (port in use, EACCES, etc.) → ExitCode.Error.
21017
- bindFailed: "sm serve: failed to bind {{host}}:{{port}} \u2014 {{message}}\n",
21018
- // Flag-validation failures ExitCode.Error.
21768
+ bindFailed: "sm serve: failed to bind {{host}}:{{port}}: {{message}}\n",
21769
+ // Flag-validation failures, ExitCode.Error.
21019
21770
  hostDevCorsRejected: "sm serve: --dev-cors requires a loopback --host (got {{host}}). Refusing per Decision #119.\n",
21020
21771
  portOutOfRange: "sm serve: --port must be an integer in [0, 65535] (got {{value}}).\n",
21021
21772
  portInvalid: "sm serve: --port must be a non-negative integer (got {{value}}).\n",
21022
21773
  scopeInvalid: 'sm serve: --scope must be "project" or "global" (got {{value}}).\n',
21023
- // Watcher option failures ExitCode.Error.
21774
+ // Watcher option failures, ExitCode.Error.
21024
21775
  watcherRequiresPipeline: "sm serve: --no-built-ins is incompatible with the watcher (would persist empty scans on every batch). Pass --no-watcher to opt out, or drop --no-built-ins.\n",
21025
21776
  watcherDebounceInvalid: "sm serve: --watcher-debounce-ms must be a non-negative integer (got {{value}}).\n",
21026
- // --no-ui flag-validation failures ExitCode.Error.
21777
+ // --no-ui flag-validation failures, ExitCode.Error.
21027
21778
  noUiConflictsUiDist: "sm serve: --no-ui and --ui-dist {{path}} are mutually exclusive (drop one).\n",
21028
- // --no-ui + --open is harmless but worth flagging non-fatal stderr note.
21779
+ // --no-ui + --open is harmless but worth flagging, non-fatal stderr note.
21029
21780
  noUiOpenWarning: "sm serve: warning: --open with --no-ui will open the placeholder, not the live UI; pass --no-open if running alongside `ui:dev`.\n",
21030
- // Generic operational error surfaced when the server itself throws
21781
+ // Generic operational error, surfaced when the server itself throws
21031
21782
  // before the listener binds (e.g. UI bundle missing under explicit
21032
21783
  // --ui-dist).
21033
- startupFailed: "sm serve: startup failed \u2014 {{message}}\n",
21784
+ startupFailed: "sm serve: startup failed: {{message}}\n",
21034
21785
  // DB-not-found (--db <path> doesn't exist) → ExitCode.NotFound.
21035
21786
  dbNotFound: "sm serve: --db {{path}} does not exist.\n",
21036
21787
  // --ui-dist override points at a missing / non-bundle directory.
21037
21788
  uiDistInvalid: "--ui-dist {{path}} does not exist or is not a directory containing index.html",
21038
- // Shutdown trace printed once the listener has closed.
21789
+ // Shutdown trace, printed once the listener has closed.
21039
21790
  shutdown: "sm serve: shutdown complete.\n"
21040
21791
  };
21041
21792
 
21042
21793
  // cli/util/serve-banner.ts
21043
21794
  import { homedir as homedir2 } from "os";
21044
- import { relative as relative6, isAbsolute as isAbsolute10 } from "path";
21795
+ import { relative as relative7, isAbsolute as isAbsolute10 } from "path";
21045
21796
  var ESC2 = {
21046
21797
  reset: "\x1B[0m",
21047
21798
  bold: "\x1B[1m",
@@ -21166,7 +21917,7 @@ function resolveAnsi(colorEnabled) {
21166
21917
  function formatDbPath(dbPath, cwd) {
21167
21918
  const safe = sanitizeForTerminal(dbPath);
21168
21919
  if (!isAbsolute10(safe)) return safe;
21169
- const rel = relative6(cwd, safe);
21920
+ const rel = relative7(cwd, safe);
21170
21921
  if (rel === "" || rel.startsWith("..") || isAbsolute10(rel)) {
21171
21922
  return safe;
21172
21923
  }
@@ -21190,10 +21941,10 @@ var ServeCommand = class extends SmCommand {
21190
21941
  details: `
21191
21942
  Boots the skill-map Web UI's backing server. One Node process
21192
21943
  serves the Angular SPA, the REST API under /api/*, and the
21193
- WebSocket at /ws \u2014 single-port mandate, no proxy.
21944
+ WebSocket at /ws (single-port mandate, no proxy).
21194
21945
 
21195
21946
  Default port is 4242, default host is 127.0.0.1. The server boots
21196
- even when the project DB is missing \u2014 /api/health reports
21947
+ even when the project DB is missing; /api/health reports
21197
21948
  'db: missing' so the SPA renders an empty-state CTA instead of
21198
21949
  failing the connection.
21199
21950
 
@@ -21227,7 +21978,7 @@ var ServeCommand = class extends SmCommand {
21227
21978
  noPlugins = Option31.Boolean("--no-plugins", false, {
21228
21979
  description: "Skip drop-in plugin discovery."
21229
21980
  });
21230
- // `Option.Boolean('--open', true)` Clipanion's parser auto-derives
21981
+ // `Option.Boolean('--open', true)`, Clipanion's parser auto-derives
21231
21982
  // the `--no-open` inverse for every boolean flag (search for
21232
21983
  // `--no-${name.slice(2)}` in clipanion's core), so the explicit
21233
21984
  // `--no-open` descriptor must NOT be declared here or the parser sees
@@ -21253,12 +22004,12 @@ var ServeCommand = class extends SmCommand {
21253
22004
  });
21254
22005
  // `--watcher-debounce-ms` is undocumented sugar for advanced users
21255
22006
  // who want to tighten / relax the watcher's batching window without
21256
- // editing settings.json. Hidden flag the Usage block omits it.
22007
+ // editing settings.json. Hidden flag, the Usage block omits it.
21257
22008
  watcherDebounceMs = Option31.String("--watcher-debounce-ms", { required: false, hidden: true });
21258
- // Long-running daemon `done in <…>` after a graceful shutdown is
22009
+ // Long-running daemon, `done in <…>` after a graceful shutdown is
21259
22010
  // noise. Mirrors `sm watch`'s opt-out.
21260
22011
  emitElapsed = false;
21261
- // CLI orchestrator with multi-flag handling each `if (this.flag)`
22012
+ // CLI orchestrator with multi-flag handling, each `if (this.flag)`
21262
22013
  // branch is one cyclomatic point. Splitting per branch scatters the
21263
22014
  // validation away from the flag it gates. Per AGENTS.md §Linting
21264
22015
  // category 1 ("CLI orchestrators with multi-flag handling").
@@ -21447,6 +22198,15 @@ function waitForShutdown() {
21447
22198
  }
21448
22199
  function tryOpenBrowser(url, stderr) {
21449
22200
  try {
22201
+ if (!validateBrowserUrl(url)) {
22202
+ stderr.write(
22203
+ tx(SERVE_TEXTS.openFailed, {
22204
+ message: sanitizeForTerminal("refused to launch browser: unsafe URL"),
22205
+ url: sanitizeForTerminal(url)
22206
+ })
22207
+ );
22208
+ return;
22209
+ }
21450
22210
  const platform = process.platform;
21451
22211
  let command;
21452
22212
  let args2;
@@ -21455,7 +22215,7 @@ function tryOpenBrowser(url, stderr) {
21455
22215
  args2 = [url];
21456
22216
  } else if (platform === "win32") {
21457
22217
  command = "cmd";
21458
- args2 = ["/c", "start", '""', url];
22218
+ args2 = ["/c", "start", "", url];
21459
22219
  } else {
21460
22220
  command = "xdg-open";
21461
22221
  args2 = [url];
@@ -21792,7 +22552,7 @@ function rankConfidenceForGrouping(c) {
21792
22552
  }
21793
22553
 
21794
22554
  // cli/commands/sidecar.ts
21795
- import { existsSync as existsSync27, unlinkSync as unlinkSync3 } from "fs";
22555
+ import { existsSync as existsSync27, unlinkSync as unlinkSync2 } from "fs";
21796
22556
  import { resolve as resolve35 } from "path";
21797
22557
  import { Command as Command35, Option as Option33 } from "clipanion";
21798
22558
 
@@ -21827,12 +22587,12 @@ var SIDECAR_TEXTS = {
21827
22587
  /**
21828
22588
  * Pre-prompt context shown before the interactive `confirm()` so the
21829
22589
  * operator sees what they are about to opt into. `.skill-map/settings.local.json`
21830
- * is gitignored the choice is saved per-checkout, never travels via the repo.
22590
+ * is gitignored, the choice is saved per-checkout, never travels via the repo.
21831
22591
  */
21832
22592
  consentPrompt: "skill-map needs your consent to create .sm sidecar files next to your\nsource files in this project. The choice is saved to\n.skill-map/settings.local.json (gitignored, per-checkout) so this prompt\nnever appears again. Decline to abort without persisting the rejection.\n\nAllow .sm sidecar writes in this project?",
21833
22593
  consentAborted: "{{glyph}} sm sidecar: aborted by user. No .sm sidecar files were written.\n",
21834
22594
  consentRequiredNonTty: "{{glyph}} sm sidecar: consent required to write .sm sidecar files in this project.\n {{hint}}\n",
21835
- consentRequiredNonTtyHint: "Pass --yes to grant (writes to .skill-map/settings.local.json \u2014 gitignored)."
22595
+ consentRequiredNonTtyHint: "Pass --yes to grant (writes to .skill-map/settings.local.json, gitignored)."
21836
22596
  };
21837
22597
 
21838
22598
  // cli/commands/sidecar.ts
@@ -21876,11 +22636,11 @@ var SidecarRefreshCommand = class extends SmCommand {
21876
22636
  Useful when the user knows a body change is editorial-only and
21877
22637
  doesn't want to spend a \`annotations.version\` increment.
21878
22638
  Distinct from \`sm refresh\` (the enrichment-layer verb at Step
21879
- A.8) \u2014 different storage, different concept.
22639
+ A.8); different storage, different concept.
21880
22640
 
21881
22641
  Refuses if the node has no sidecar (run \`sm sidecar annotate\`
21882
22642
  first, or \`sm bump\` to create one through the Action). No-ops
21883
- on a fresh node \u2014 there's nothing to refresh.
22643
+ on a fresh node, there's nothing to refresh.
21884
22644
  `,
21885
22645
  examples: [
21886
22646
  ["Refresh a node's sidecar hashes", "$0 sidecar refresh .claude/agents/architect.md"]
@@ -21912,7 +22672,7 @@ var SidecarRefreshCommand = class extends SmCommand {
21912
22672
  () => this.#runOnce(ctx, dbPath, okGlyph, errGlyph, ansi)
21913
22673
  );
21914
22674
  }
21915
- // Inner dispatch single attempt. The outer `run()` wraps every
22675
+ // Inner dispatch, single attempt. The outer `run()` wraps every
21916
22676
  // call in `runWithSidecarConsent` so an `EConsentRequiredError`
21917
22677
  // surfaces as an interactive prompt (TTY) or a directed exit
21918
22678
  // (non-TTY).
@@ -22020,7 +22780,7 @@ var SidecarPruneCommand = class extends SmCommand {
22020
22780
  convention). \`--yes\` (alias \`--force\`) bypasses the prompt
22021
22781
  for non-interactive use (CI, scripts, the pre-commit hook).
22022
22782
 
22023
- Different domain from \`sm orphans\` \u2014 that verb operates on the
22783
+ Different domain from \`sm orphans\`: that verb operates on the
22024
22784
  node graph (rename heuristic). This one operates on the
22025
22785
  filesystem layer.
22026
22786
  `,
@@ -22034,7 +22794,7 @@ var SidecarPruneCommand = class extends SmCommand {
22034
22794
  yes = Option33.Boolean("--yes,--force", false, {
22035
22795
  description: "Skip the interactive confirmation prompt. Required for non-interactive callers (CI, pre-commit hooks)."
22036
22796
  });
22037
- // Complexity is from per-orphan handling empty-set / dry-run /
22797
+ // Complexity is from per-orphan handling, empty-set / dry-run /
22038
22798
  // delete / error capture / json-vs-pretty branches each contributing
22039
22799
  // a guard. The unlink loop itself is linear.
22040
22800
  // eslint-disable-next-line complexity
@@ -22080,7 +22840,7 @@ var SidecarPruneCommand = class extends SmCommand {
22080
22840
  continue;
22081
22841
  }
22082
22842
  try {
22083
- unlinkSync3(orphan.sidecarPath);
22843
+ unlinkSync2(orphan.sidecarPath);
22084
22844
  items.push({
22085
22845
  sidecarPath: orphan.sidecarPath,
22086
22846
  expectedMd: orphan.expectedMdPath,
@@ -22160,7 +22920,7 @@ var SidecarAnnotateCommand = class extends SmCommand {
22160
22920
  block. After editing, run \`sm bump <node>\` to commit the
22161
22921
  version through the Action.
22162
22922
 
22163
- Refuses if the file already exists \u2014 pass \`--force\` to
22923
+ Refuses if the file already exists; pass \`--force\` to
22164
22924
  overwrite. Per Decision A4 the \`--from-frontmatter\` migration
22165
22925
  helper is deferred (no released consumer demands it).
22166
22926
  `,
@@ -22195,6 +22955,10 @@ var SidecarAnnotateCommand = class extends SmCommand {
22195
22955
  () => this.#runOnce(ctx, dbPath, errGlyph, ansi)
22196
22956
  );
22197
22957
  }
22958
+ // CLI orchestrator: argument-validation guards + dry-run branch +
22959
+ // interactive confirm + collect/delete loop. Each branch is one
22960
+ // cyclomatic point; splitting would scatter the validations away
22961
+ // from the flag they gate. Per `context/lint.md` category 1.
22198
22962
  // eslint-disable-next-line complexity
22199
22963
  async #runOnce(ctx, dbPath, errGlyph, ansi) {
22200
22964
  const persisted = await tryWithSqlite(
@@ -22245,7 +23009,7 @@ var SidecarAnnotateCommand = class extends SmCommand {
22245
23009
  }
22246
23010
  if (existsSync27(sidecarAbsPath) && this.force === true) {
22247
23011
  try {
22248
- unlinkSync3(sidecarAbsPath);
23012
+ unlinkSync2(sidecarAbsPath);
22249
23013
  } catch (err) {
22250
23014
  this.printer.error(
22251
23015
  tx(SIDECAR_TEXTS.annotateFailed, { glyph: errGlyph, message: formatErrorMessage(err) })
@@ -22476,13 +23240,13 @@ var STUB_COMMANDS = [
22476
23240
  // cli/commands/tutorial.ts
22477
23241
  import { existsSync as existsSync28, readFileSync as readFileSync17 } from "fs";
22478
23242
  import { writeFile as writeFile2 } from "fs/promises";
22479
- import { dirname as dirname17, join as join17, resolve as resolve36 } from "path";
23243
+ import { dirname as dirname18, join as join17, resolve as resolve36 } from "path";
22480
23244
  import { fileURLToPath as fileURLToPath6 } from "url";
22481
23245
  import { Command as Command37, Option as Option35 } from "clipanion";
22482
23246
 
22483
23247
  // cli/i18n/tutorial.texts.ts
22484
23248
  var TUTORIAL_TEXTS = {
22485
- // Success written to stdout after `<cwd>/sm-tutorial.md` is created.
23249
+ // Success, written to stdout after `<cwd>/sm-tutorial.md` is created.
22486
23250
  // Multi-line layout: the two trigger phrases (English / Spanish) are
22487
23251
  // indented and labelled so they're the most visible part of the
22488
23252
  // output. The reminder above them surfaces the SKILL's language
@@ -22491,13 +23255,13 @@ var TUTORIAL_TEXTS = {
22491
23255
  /**
22492
23256
  * Success body. `glyph` is wrapped green at the call site; `cwd`
22493
23257
  * renders relative to the user's cwd when it sits underneath. The
22494
- * `English` / `Español` labels print dim the eye lands on the
23258
+ * `English` / `Español` labels print dim, the eye lands on the
22495
23259
  * trigger phrases the user is going to copy / paste.
22496
23260
  */
22497
23261
  written: " {{glyph}} sm-tutorial.md created at {{cwd}}\n\n Open Claude Code in this directory. Your first message sets\n the tutorial language for the rest of the session:\n\n {{enLabel}} run @sm-tutorial.md\n {{esLabel}} ejecut\xE1 @sm-tutorial.md\n",
22498
23262
  writtenLabelEn: "English",
22499
23263
  writtenLabelEs: "Espa\xF1ol",
22500
- // Refusal `sm-tutorial.md` already exists and `--force` was not set.
23264
+ // Refusal, `sm-tutorial.md` already exists and `--force` was not set.
22501
23265
  // Goes to stderr, exit code 2 (operational error per spec § Exit codes).
22502
23266
  // Mirrors the success body shape: glyph + headline, then a dim hint
22503
23267
  // line spelling the fix.
@@ -22519,7 +23283,7 @@ var TutorialCommand = class extends SmCommand {
22519
23283
  details: `
22520
23284
  Drops the canonical SKILL.md content as ./sm-tutorial.md so a tester
22521
23285
  can open Claude Code in the cwd and load the file as a skill by
22522
- typing "ejecut\xE1 @sm-tutorial.md". Top-level only \u2014 no subdirectory
23286
+ typing "ejecut\xE1 @sm-tutorial.md". Top-level only; no subdirectory
22523
23287
  is created.
22524
23288
 
22525
23289
  Does NOT require an initialized .skill-map/ project. Refuses to
@@ -22603,7 +23367,7 @@ function loadBundledTutorialText() {
22603
23367
  return cachedTutorial;
22604
23368
  }
22605
23369
  function readTutorialFromDisk() {
22606
- const here = dirname17(fileURLToPath6(import.meta.url));
23370
+ const here = dirname18(fileURLToPath6(import.meta.url));
22607
23371
  const candidates = [
22608
23372
  // dev: src/cli/commands/ → repo-root .claude/skills/sm-tutorial/SKILL.md
22609
23373
  resolve36(here, "../../../.claude/skills/sm-tutorial/SKILL.md"),
@@ -22640,7 +23404,7 @@ var VersionCommand = class extends SmCommand {
22640
23404
  category: "Introspection",
22641
23405
  description: "Print the CLI / kernel / spec / runtime / db-schema version matrix."
22642
23406
  });
22643
- // Informational verb no `done in <…>` line; the version matrix is
23407
+ // Informational verb, no `done in <…>` line; the version matrix is
22644
23408
  // the entire output.
22645
23409
  emitElapsed = false;
22646
23410
  async run() {
@@ -22692,10 +23456,10 @@ async function resolveDbSchemaVersion() {
22692
23456
  { databasePath: dbPath, autoBackup: false },
22693
23457
  async (port) => port.migrations.currentSchemaVersion()
22694
23458
  );
22695
- if (v === null || v === void 0) return "\u2014";
23459
+ if (v === null || v === void 0) return "-";
22696
23460
  return String(v);
22697
23461
  } catch {
22698
- return "\u2014";
23462
+ return "-";
22699
23463
  }
22700
23464
  }
22701
23465