@skill-map/cli 0.9.0 → 0.11.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
@@ -180,7 +180,7 @@ function extractLogLevelFlag(argv) {
180
180
  var LOGGER_ENV_VAR = ENV_VAR;
181
181
 
182
182
  // cli/commands/check.ts
183
- import { Command, Option } from "clipanion";
183
+ import { Command as Command2, Option as Option2 } from "clipanion";
184
184
 
185
185
  // kernel/i18n/registry.texts.ts
186
186
  var REGISTRY_TEXTS = {
@@ -297,7 +297,14 @@ var SKILL_MAP_DIR = ".skill-map";
297
297
  var DB_FILENAME = "skill-map.db";
298
298
  var JOBS_DIRNAME = "jobs";
299
299
  var PLUGINS_DIRNAME = "plugins";
300
+ var SETTINGS_FILENAME = "settings.json";
301
+ var LOCAL_SETTINGS_FILENAME = "settings.local.json";
302
+ var IGNORE_FILENAME = ".skill-mapignore";
300
303
  var DEFAULT_DB_REL = `${SKILL_MAP_DIR}/${DB_FILENAME}`;
304
+ var GITIGNORE_ENTRIES = [
305
+ `${SKILL_MAP_DIR}/${LOCAL_SETTINGS_FILENAME}`,
306
+ `${SKILL_MAP_DIR}/${DB_FILENAME}`
307
+ ];
301
308
  function resolveDbPath(options) {
302
309
  if (options.db) return resolve(options.db);
303
310
  if (options.global) return join(options.homedir, DEFAULT_DB_REL);
@@ -315,6 +322,18 @@ function defaultProjectPluginsDir(ctx) {
315
322
  function defaultUserPluginsDir(ctx) {
316
323
  return join(ctx.homedir, SKILL_MAP_DIR, PLUGINS_DIRNAME);
317
324
  }
325
+ function defaultDbPath(scopeRoot) {
326
+ return join(scopeRoot, SKILL_MAP_DIR, DB_FILENAME);
327
+ }
328
+ function defaultSettingsPath(scopeRoot) {
329
+ return join(scopeRoot, SKILL_MAP_DIR, SETTINGS_FILENAME);
330
+ }
331
+ function defaultLocalSettingsPath(scopeRoot) {
332
+ return join(scopeRoot, SKILL_MAP_DIR, LOCAL_SETTINGS_FILENAME);
333
+ }
334
+ function defaultIgnoreFilePath(scopeRoot) {
335
+ return join(scopeRoot, IGNORE_FILENAME);
336
+ }
318
337
  function assertDbExists(path, stderr) {
319
338
  if (path === ":memory:" || existsSync(path)) return true;
320
339
  stderr.write(tx(UTIL_TEXTS.dbNotFound, { path }));
@@ -390,11 +409,11 @@ function buildIgnoreFilter(opts = {}) {
390
409
  ig.add(opts.ignoreFileText);
391
410
  }
392
411
  return {
393
- ignores(relativePath) {
394
- if (relativePath === "" || relativePath === "." || relativePath === "./") {
412
+ ignores(relativePath2) {
413
+ if (relativePath2 === "" || relativePath2 === "." || relativePath2 === "./") {
395
414
  return false;
396
415
  }
397
- const normalised = relativePath.replace(/^\.\//, "").replace(/\\/g, "/").replace(/^\//, "");
416
+ const normalised = relativePath2.replace(/^\.\//, "").replace(/\\/g, "/").replace(/^\//, "");
398
417
  if (normalised === "") return false;
399
418
  return ig.ignores(normalised);
400
419
  }
@@ -605,31 +624,79 @@ var claudeProvider = {
605
624
  // pairs the relative manifest-style schema path (mirrors what the
606
625
  // spec's provider.schema.json validates) with the loaded JSON Schema
607
626
  // (`schemaJson`) the kernel registers with AJV at scan boot.
627
+ // Step 14.5.d: each kind declares its UI presentation (label, color,
628
+ // dark variant, icon). The UI consumes this registry via the
629
+ // `kindRegistry` field embedded in REST envelopes; it derives bg/fg
630
+ // tints from `color` per theme via a deterministic helper, so the
631
+ // Provider only declares intent (one base color per theme) instead of
632
+ // four hex values. Colors and SVG paths transplanted verbatim from
633
+ // the previous static UI catalog (`ui/src/styles.css` for hex,
634
+ // `ui/src/app/components/kind-icon/kind-icon.html` for SVG path data,
635
+ // `ui/src/i18n/kinds.texts.ts` for labels).
608
636
  kinds: {
609
637
  agent: {
610
638
  schema: "./schemas/agent.schema.json",
611
639
  schemaJson: agent_schema_default,
612
- defaultRefreshAction: "claude/summarize-agent"
640
+ defaultRefreshAction: "claude/summarize-agent",
641
+ ui: {
642
+ label: "Agents",
643
+ color: "#3b82f6",
644
+ colorDark: "#60a5fa",
645
+ icon: { kind: "pi", id: "pi-user" }
646
+ }
613
647
  },
614
648
  command: {
615
649
  schema: "./schemas/command.schema.json",
616
650
  schemaJson: command_schema_default,
617
- defaultRefreshAction: "claude/summarize-command"
651
+ defaultRefreshAction: "claude/summarize-command",
652
+ ui: {
653
+ label: "Commands",
654
+ color: "#f59e0b",
655
+ colorDark: "#fbbf24",
656
+ icon: {
657
+ kind: "svg",
658
+ path: "M4 17 L10 11 L4 5 M12 19 L20 19"
659
+ }
660
+ }
618
661
  },
619
662
  hook: {
620
663
  schema: "./schemas/hook.schema.json",
621
664
  schemaJson: hook_schema_default,
622
- defaultRefreshAction: "claude/summarize-hook"
665
+ defaultRefreshAction: "claude/summarize-hook",
666
+ ui: {
667
+ label: "Hooks",
668
+ color: "#8b5cf6",
669
+ colorDark: "#a78bfa",
670
+ icon: {
671
+ kind: "svg",
672
+ path: "M12 2 a3 3 0 1 0 0 6 a3 3 0 1 0 0 -6 M12 8 L12 22 M5 12 H2 a10 10 0 0 0 20 0 H19"
673
+ }
674
+ }
623
675
  },
624
676
  skill: {
625
677
  schema: "./schemas/skill.schema.json",
626
678
  schemaJson: skill_schema_default,
627
- defaultRefreshAction: "claude/summarize-skill"
679
+ defaultRefreshAction: "claude/summarize-skill",
680
+ ui: {
681
+ label: "Skills",
682
+ color: "#10b981",
683
+ colorDark: "#34d399",
684
+ icon: { kind: "pi", id: "pi-bolt" }
685
+ }
628
686
  },
629
687
  note: {
630
688
  schema: "./schemas/note.schema.json",
631
689
  schemaJson: note_schema_default,
632
- defaultRefreshAction: "claude/summarize-note"
690
+ defaultRefreshAction: "claude/summarize-note",
691
+ ui: {
692
+ label: "Notes",
693
+ color: "#5b908c",
694
+ colorDark: "#9bbcb8",
695
+ icon: {
696
+ kind: "svg",
697
+ path: "M14 2 H6 a2 2 0 0 0 -2 2 V20 a2 2 0 0 0 2 2 H18 a2 2 0 0 0 2 -2 V8 L14 2 M14 2 V8 H20 M16 13 H8 M16 17 H8 M10 9 H8"
698
+ }
699
+ }
633
700
  }
634
701
  },
635
702
  async *walk(roots, options = {}) {
@@ -1367,8 +1434,15 @@ import { readFileSync as readFileSync2 } from "fs";
1367
1434
  import { dirname as dirname2, resolve as resolve3 } from "path";
1368
1435
  import { createRequire } from "module";
1369
1436
  import { Ajv2020 } from "ajv/dist/2020.js";
1437
+
1438
+ // kernel/util/ajv-interop.ts
1370
1439
  import addFormatsModule from "ajv-formats";
1371
1440
  var addFormats = addFormatsModule.default ?? addFormatsModule;
1441
+ function applyAjvFormats(ajv) {
1442
+ addFormats(ajv);
1443
+ }
1444
+
1445
+ // kernel/adapters/schema-validators.ts
1372
1446
  var SCHEMA_FILES = {
1373
1447
  node: "schemas/node.schema.json",
1374
1448
  link: "schemas/link.schema.json",
@@ -1407,7 +1481,7 @@ function buildSchemaValidators() {
1407
1481
  allErrors: true,
1408
1482
  allowUnionTypes: true
1409
1483
  });
1410
- addFormats(ajv);
1484
+ applyAjvFormats(ajv);
1411
1485
  for (const rel of SUPPORTING_SCHEMAS) {
1412
1486
  const file = resolve3(specRoot, rel);
1413
1487
  if (!existsSyncSafe(file)) continue;
@@ -1462,7 +1536,7 @@ function buildProviderFrontmatterValidator(providers) {
1462
1536
  allErrors: true,
1463
1537
  allowUnionTypes: true
1464
1538
  });
1465
- addFormats(ajv);
1539
+ applyAjvFormats(ajv);
1466
1540
  const baseFile = resolve3(specRoot, "schemas/frontmatter/base.schema.json");
1467
1541
  const baseSchema = JSON.parse(readFileSync2(baseFile, "utf8"));
1468
1542
  ajv.addSchema(baseSchema);
@@ -1683,7 +1757,6 @@ import { existsSync as existsSync3, readFileSync as readFileSync3, readdirSync }
1683
1757
  import { isAbsolute, join as join3, relative as relative2, resolve as resolve4 } from "path";
1684
1758
  import { pathToFileURL } from "url";
1685
1759
  import { Ajv2020 as Ajv20202 } from "ajv/dist/2020.js";
1686
- import addFormatsModule2 from "ajv-formats";
1687
1760
  import semver from "semver";
1688
1761
 
1689
1762
  // kernel/i18n/plugin-loader.texts.ts
@@ -1728,7 +1801,6 @@ var HOOK_TRIGGERS = Object.freeze([
1728
1801
  ]);
1729
1802
 
1730
1803
  // kernel/adapters/plugin-loader.ts
1731
- var addFormats2 = addFormatsModule2.default ?? addFormatsModule2;
1732
1804
  var DEFAULT_PLUGIN_IMPORT_TIMEOUT_MS = 5e3;
1733
1805
  function createPluginLoader(options) {
1734
1806
  return new PluginLoader(options);
@@ -2243,7 +2315,7 @@ function compilePluginSchema(pluginPath, relPath) {
2243
2315
  }
2244
2316
  try {
2245
2317
  const ajv = new Ajv20202({ strict: false, allErrors: true, allowUnionTypes: true });
2246
- addFormats2(ajv);
2318
+ applyAjvFormats(ajv);
2247
2319
  const compiled = ajv.compile(raw);
2248
2320
  return { ok: true, validate: compiled };
2249
2321
  } catch (err) {
@@ -2628,7 +2700,7 @@ var AsyncMutex = class {
2628
2700
  this.#locked = true;
2629
2701
  return;
2630
2702
  }
2631
- await new Promise((resolve19) => this.#waiters.push(resolve19));
2703
+ await new Promise((resolve20) => this.#waiters.push(resolve20));
2632
2704
  this.#locked = true;
2633
2705
  }
2634
2706
  unlock() {
@@ -4627,11 +4699,111 @@ function formatWarning(plugin) {
4627
4699
  });
4628
4700
  }
4629
4701
 
4702
+ // cli/util/sm-command.ts
4703
+ import { Command, Option } from "clipanion";
4704
+
4705
+ // cli/util/elapsed.ts
4706
+ function startElapsed() {
4707
+ const startNs = process.hrtime.bigint();
4708
+ return {
4709
+ ms() {
4710
+ const elapsedNs = Number(process.hrtime.bigint() - startNs);
4711
+ return Math.round(elapsedNs / 1e6);
4712
+ },
4713
+ formatted() {
4714
+ return formatElapsed(this.ms());
4715
+ }
4716
+ };
4717
+ }
4718
+ function formatElapsed(ms) {
4719
+ if (ms < 1e3) return `${ms}ms`;
4720
+ if (ms < 6e4) return `${(ms / 1e3).toFixed(1)}s`;
4721
+ const minutes = Math.floor(ms / 6e4);
4722
+ const seconds = Math.round(ms % 6e4 / 1e3);
4723
+ return `${minutes}m ${seconds}s`;
4724
+ }
4725
+ function emitDoneStderr(stderr, elapsed, quiet = false) {
4726
+ if (quiet) return;
4727
+ stderr.write(tx(UTIL_TEXTS.doneIn, { elapsed: elapsed.formatted() }));
4728
+ }
4729
+
4730
+ // cli/util/sm-command.ts
4731
+ function isEnvSet(value) {
4732
+ return value !== void 0 && value !== "";
4733
+ }
4734
+ var SmCommand = class extends Command {
4735
+ global = Option.Boolean("-g,--global", false, {
4736
+ description: "Operate on ~/.skill-map/ instead of ./.skill-map/."
4737
+ });
4738
+ json = Option.Boolean("--json", false, {
4739
+ description: "Emit machine-readable output on stdout. Suppresses pretty printing."
4740
+ });
4741
+ quiet = Option.Boolean("-q,--quiet", false, {
4742
+ description: 'Suppress non-error stderr output (including "done in <\u2026>").'
4743
+ });
4744
+ noColor = Option.Boolean("--no-color", false, {
4745
+ description: "Disable ANSI color codes."
4746
+ });
4747
+ verbose = Option.Counter("-v,--verbose", 0, {
4748
+ description: "Increase log level (-v=info, -vv=debug, -vvv=trace)."
4749
+ });
4750
+ db = Option.String("--db", { required: false, description: "Override the database file location (escape hatch)." });
4751
+ /**
4752
+ * Subclasses set this to `false` to opt out of the trailing
4753
+ * `done in <…>` line — appropriate for interactive verbs (`db shell`),
4754
+ * watcher loops (`watch`), and meta verbs that report a fixed
4755
+ * version (`version`, `help`).
4756
+ */
4757
+ emitElapsed = true;
4758
+ /**
4759
+ * Wall-clock timer started just before `run()`. Subclasses that need
4760
+ * to embed `elapsedMs` in their `--json` output read `this.elapsed.ms()`.
4761
+ * `null` only between `Command` construction and the first
4762
+ * `execute()` call.
4763
+ */
4764
+ elapsed = null;
4765
+ async execute() {
4766
+ this.applyEnvOverrides();
4767
+ this.applyVerboseLogger();
4768
+ this.elapsed = startElapsed();
4769
+ try {
4770
+ return await this.run();
4771
+ } finally {
4772
+ if (this.emitElapsed) emitDoneStderr(this.context.stderr, this.elapsed, this.quiet);
4773
+ }
4774
+ }
4775
+ /**
4776
+ * Promote spec env vars into flag values when the flag was left at
4777
+ * default. CLI flag wins over env var (spec § Global flags
4778
+ * precedence: "CLI flag wins over env var. Env var wins over config
4779
+ * file.").
4780
+ */
4781
+ applyEnvOverrides() {
4782
+ const env = process.env;
4783
+ this.noColor = this.noColor || isEnvSet(env["NO_COLOR"]);
4784
+ this.global = this.global || env["SKILL_MAP_SCOPE"] === "global";
4785
+ this.json = this.json || isEnvSet(env["SKILL_MAP_JSON"]);
4786
+ if (this.db === void 0 && isEnvSet(env["SKILL_MAP_DB"])) {
4787
+ this.db = env["SKILL_MAP_DB"];
4788
+ }
4789
+ }
4790
+ /**
4791
+ * `-v` / `-vv` / `-vvv` reconfigures the kernel logger. Skipped
4792
+ * when `verbose === 0` so the level configured at `entry.ts` boot
4793
+ * (from `--log-level` / `SKILL_MAP_LOG_LEVEL`) stays in effect.
4794
+ */
4795
+ applyVerboseLogger() {
4796
+ if (this.verbose <= 0) return;
4797
+ const level = this.verbose >= 3 ? "trace" : this.verbose === 2 ? "debug" : "info";
4798
+ configureLogger(new Logger({ level, stream: process.stderr }));
4799
+ }
4800
+ };
4801
+
4630
4802
  // cli/commands/check.ts
4631
4803
  var SEVERITY_ORDER = ["error", "warn", "info"];
4632
- var CheckCommand = class extends Command {
4804
+ var CheckCommand = class extends SmCommand {
4633
4805
  static paths = [["check"]];
4634
- static usage = Command.Usage({
4806
+ static usage = Command2.Usage({
4635
4807
  category: "Browse",
4636
4808
  description: "Print all current issues (reads from DB, faster than sm scan --json | jq).",
4637
4809
  details: `
@@ -4656,27 +4828,24 @@ var CheckCommand = class extends Command {
4656
4828
  ["Use a non-default DB file", "$0 check --db /path/to/skill-map.db"]
4657
4829
  ]
4658
4830
  });
4659
- global = Option.Boolean("-g,--global", false);
4660
- db = Option.String("--db", { required: false });
4661
- json = Option.Boolean("--json", false);
4662
- node = Option.String("-n,--node", {
4831
+ node = Option2.String("-n,--node", {
4663
4832
  required: false,
4664
4833
  description: "Restrict to issues whose nodeIds include the given path. Combines with --rules and --include-prob."
4665
4834
  });
4666
- rules = Option.String("--rules", {
4835
+ rules = Option2.String("--rules", {
4667
4836
  required: false,
4668
4837
  description: "Comma-separated rule ids (qualified or short). Restrict the issue read; with --include-prob, also filters which prob rules surface in the advisory."
4669
4838
  });
4670
- includeProb = Option.Boolean("--include-prob", false, {
4839
+ includeProb = Option2.Boolean("--include-prob", false, {
4671
4840
  description: "Detect probabilistic Rules and emit a stub advisory naming them (full dispatch lands at Step 10). Default off \u2192 deterministic-only, CI-safe."
4672
4841
  });
4673
- async = Option.Boolean("--async", false, {
4842
+ async = Option2.Boolean("--async", false, {
4674
4843
  description: "Reserved companion to --include-prob: once jobs ship, returns job ids without waiting. No effect today."
4675
4844
  });
4676
- noPlugins = Option.Boolean("--no-plugins", false, {
4845
+ noPlugins = Option2.Boolean("--no-plugins", false, {
4677
4846
  description: "Skip drop-in plugin discovery; only kernel built-ins participate in the prob detection. Same flag shape as `sm scan`."
4678
4847
  });
4679
- async execute() {
4848
+ async run() {
4680
4849
  const dbPath = resolveDbPath({ global: this.global, db: this.db, ...defaultRuntimeContext() });
4681
4850
  if (!assertDbExists(dbPath, this.context.stderr)) return ExitCode.NotFound;
4682
4851
  const ruleFilter = parseRulesFlag(this.rules);
@@ -4785,32 +4954,7 @@ import {
4785
4954
  writeFileSync
4786
4955
  } from "fs";
4787
4956
  import { dirname as dirname5, join as join7 } from "path";
4788
- import { Command as Command2, Option as Option2 } from "clipanion";
4789
-
4790
- // cli/util/elapsed.ts
4791
- function startElapsed() {
4792
- const startNs = process.hrtime.bigint();
4793
- return {
4794
- ms() {
4795
- const elapsedNs = Number(process.hrtime.bigint() - startNs);
4796
- return Math.round(elapsedNs / 1e6);
4797
- },
4798
- formatted() {
4799
- return formatElapsed(this.ms());
4800
- }
4801
- };
4802
- }
4803
- function formatElapsed(ms) {
4804
- if (ms < 1e3) return `${ms}ms`;
4805
- if (ms < 6e4) return `${(ms / 1e3).toFixed(1)}s`;
4806
- const minutes = Math.floor(ms / 6e4);
4807
- const seconds = Math.round(ms % 6e4 / 1e3);
4808
- return `${minutes}m ${seconds}s`;
4809
- }
4810
- function emitDoneStderr(stderr, elapsed, quiet = false) {
4811
- if (quiet) return;
4812
- stderr.write(tx(UTIL_TEXTS.doneIn, { elapsed: elapsed.formatted() }));
4813
- }
4957
+ import { Command as Command3, Option as Option3 } from "clipanion";
4814
4958
 
4815
4959
  // cli/util/error-reporter.ts
4816
4960
  function formatErrorMessage(err) {
@@ -4970,9 +5114,9 @@ function formatValueHuman(v) {
4970
5114
  if (Array.isArray(v) || typeof v === "object" && v !== null) return JSON.stringify(v);
4971
5115
  return String(v);
4972
5116
  }
4973
- var ConfigListCommand = class extends Command2 {
5117
+ var ConfigListCommand = class extends SmCommand {
4974
5118
  static paths = [["config", "list"]];
4975
- static usage = Command2.Usage({
5119
+ static usage = Command3.Usage({
4976
5120
  category: "Config",
4977
5121
  description: "Print the effective config after layered merge.",
4978
5122
  details: `
@@ -4981,10 +5125,11 @@ var ConfigListCommand = class extends Command2 {
4981
5125
  Exempt from "done in <\u2026>" per spec/cli-contract.md \xA7Elapsed time.
4982
5126
  `
4983
5127
  });
4984
- json = Option2.Boolean("--json", false);
4985
- global = Option2.Boolean("-g,--global", false);
4986
- strict = Option2.Boolean("--strict", false);
4987
- async execute() {
5128
+ strict = Option3.Boolean("--strict", false);
5129
+ // Read-only config inspection: spec § Elapsed time exempts the
5130
+ // config family from the trailing "done in" line.
5131
+ emitElapsed = false;
5132
+ async run() {
4988
5133
  const result = tryLoadConfig(
4989
5134
  { scope: this.global ? "global" : "project", strict: this.strict, ...defaultRuntimeContext() },
4990
5135
  this.context.stderr
@@ -5005,9 +5150,9 @@ var ConfigListCommand = class extends Command2 {
5005
5150
  return ExitCode.Ok;
5006
5151
  }
5007
5152
  };
5008
- var ConfigGetCommand = class extends Command2 {
5153
+ var ConfigGetCommand = class extends SmCommand {
5009
5154
  static paths = [["config", "get"]];
5010
- static usage = Command2.Usage({
5155
+ static usage = Command3.Usage({
5011
5156
  category: "Config",
5012
5157
  description: "Read a single config value by dot-path key.",
5013
5158
  details: `
@@ -5015,11 +5160,10 @@ var ConfigGetCommand = class extends Command2 {
5015
5160
  Exempt from "done in <\u2026>".
5016
5161
  `
5017
5162
  });
5018
- key = Option2.String({ required: true });
5019
- json = Option2.Boolean("--json", false);
5020
- global = Option2.Boolean("-g,--global", false);
5021
- strict = Option2.Boolean("--strict", false);
5022
- async execute() {
5163
+ key = Option3.String({ required: true });
5164
+ strict = Option3.Boolean("--strict", false);
5165
+ emitElapsed = false;
5166
+ async run() {
5023
5167
  const result = tryLoadConfig(
5024
5168
  { scope: this.global ? "global" : "project", strict: this.strict, ...defaultRuntimeContext() },
5025
5169
  this.context.stderr
@@ -5049,9 +5193,9 @@ var ConfigGetCommand = class extends Command2 {
5049
5193
  return ExitCode.Ok;
5050
5194
  }
5051
5195
  };
5052
- var ConfigShowCommand = class extends Command2 {
5196
+ var ConfigShowCommand = class extends SmCommand {
5053
5197
  static paths = [["config", "show"]];
5054
- static usage = Command2.Usage({
5198
+ static usage = Command3.Usage({
5055
5199
  category: "Config",
5056
5200
  description: "Show a config value with the layer that set it (--source).",
5057
5201
  details: `
@@ -5061,17 +5205,16 @@ var ConfigShowCommand = class extends Command2 {
5061
5205
  Exempt from "done in <\u2026>".
5062
5206
  `
5063
5207
  });
5064
- key = Option2.String({ required: true });
5065
- source = Option2.Boolean("--source", false);
5066
- json = Option2.Boolean("--json", false);
5067
- global = Option2.Boolean("-g,--global", false);
5068
- strict = Option2.Boolean("--strict", false);
5208
+ key = Option3.String({ required: true });
5209
+ source = Option3.Boolean("--source", false);
5210
+ strict = Option3.Boolean("--strict", false);
5211
+ emitElapsed = false;
5069
5212
  // CLI orchestrator: each branch (load failure, forbidden segment,
5070
5213
  // unknown key, --json + --source 2x2 dispatch) is one validation gate
5071
5214
  // or output-format pick. Splitting per branch scatters the gate from
5072
5215
  // the value it gates.
5073
5216
  // eslint-disable-next-line complexity
5074
- async execute() {
5217
+ async run() {
5075
5218
  const result = tryLoadConfig(
5076
5219
  { scope: this.global ? "global" : "project", strict: this.strict, ...defaultRuntimeContext() },
5077
5220
  this.context.stderr
@@ -5134,9 +5277,9 @@ var LAYER_RANK = {
5134
5277
  "project-local": 4,
5135
5278
  override: 5
5136
5279
  };
5137
- var ConfigSetCommand = class extends Command2 {
5280
+ var ConfigSetCommand = class extends SmCommand {
5138
5281
  static paths = [["config", "set"]];
5139
- static usage = Command2.Usage({
5282
+ static usage = Command3.Usage({
5140
5283
  category: "Config",
5141
5284
  description: "Write a config key. Project file by default; -g writes to user.",
5142
5285
  details: `
@@ -5147,11 +5290,9 @@ var ConfigSetCommand = class extends Command2 {
5147
5290
  Schema violation \u2192 exit 2, no write performed.
5148
5291
  `
5149
5292
  });
5150
- key = Option2.String({ required: true });
5151
- value = Option2.String({ required: true });
5152
- global = Option2.Boolean("-g,--global", false);
5153
- async execute() {
5154
- const elapsed = startElapsed();
5293
+ key = Option3.String({ required: true });
5294
+ value = Option3.String({ required: true });
5295
+ async run() {
5155
5296
  const ctx = defaultRuntimeContext();
5156
5297
  const target = this.global ? "user" : "project";
5157
5298
  const path = targetSettingsPath(target, ctx.cwd, ctx.homedir);
@@ -5162,7 +5303,6 @@ var ConfigSetCommand = class extends Command2 {
5162
5303
  } catch (err) {
5163
5304
  if (err instanceof ForbiddenSegmentError) {
5164
5305
  this.context.stderr.write(tx(CONFIG_TEXTS.forbiddenKeySegment, { segment: err.segment, key: err.key }));
5165
- emitDoneStderr(this.context.stderr, elapsed);
5166
5306
  return ExitCode.Error;
5167
5307
  }
5168
5308
  throw err;
@@ -5171,18 +5311,16 @@ var ConfigSetCommand = class extends Command2 {
5171
5311
  const result = validators.validate("project-config", current);
5172
5312
  if (!result.ok) {
5173
5313
  this.context.stderr.write(tx(CONFIG_TEXTS.invalidAfterSet, { errors: result.errors }));
5174
- emitDoneStderr(this.context.stderr, elapsed);
5175
5314
  return ExitCode.Error;
5176
5315
  }
5177
5316
  writeJsonAtomic(path, current);
5178
5317
  this.context.stdout.write(tx(CONFIG_TEXTS.setWritten, { key: this.key, value: formatValueHuman(value), path }));
5179
- emitDoneStderr(this.context.stderr, elapsed);
5180
5318
  return ExitCode.Ok;
5181
5319
  }
5182
5320
  };
5183
- var ConfigResetCommand = class extends Command2 {
5321
+ var ConfigResetCommand = class extends SmCommand {
5184
5322
  static paths = [["config", "reset"]];
5185
- static usage = Command2.Usage({
5323
+ static usage = Command3.Usage({
5186
5324
  category: "Config",
5187
5325
  description: "Remove a config key from the target file (project default; -g for user).",
5188
5326
  details: `
@@ -5190,16 +5328,13 @@ var ConfigResetCommand = class extends Command2 {
5190
5328
  Idempotent \u2014 running twice is safe; absent key prints an info note and exits 0.
5191
5329
  `
5192
5330
  });
5193
- key = Option2.String({ required: true });
5194
- global = Option2.Boolean("-g,--global", false);
5195
- async execute() {
5196
- const elapsed = startElapsed();
5331
+ key = Option3.String({ required: true });
5332
+ async run() {
5197
5333
  const ctx = defaultRuntimeContext();
5198
5334
  const target = this.global ? "user" : "project";
5199
5335
  const path = targetSettingsPath(target, ctx.cwd, ctx.homedir);
5200
5336
  if (!existsSync8(path)) {
5201
5337
  this.context.stdout.write(tx(CONFIG_TEXTS.unsetNoOverride, { path, key: this.key }));
5202
- emitDoneStderr(this.context.stderr, elapsed);
5203
5338
  return ExitCode.Ok;
5204
5339
  }
5205
5340
  const current = readJsonObjectOrEmpty(path);
@@ -5209,19 +5344,16 @@ var ConfigResetCommand = class extends Command2 {
5209
5344
  } catch (err) {
5210
5345
  if (err instanceof ForbiddenSegmentError) {
5211
5346
  this.context.stderr.write(tx(CONFIG_TEXTS.forbiddenKeySegment, { segment: err.segment, key: err.key }));
5212
- emitDoneStderr(this.context.stderr, elapsed);
5213
5347
  return ExitCode.Error;
5214
5348
  }
5215
5349
  throw err;
5216
5350
  }
5217
5351
  if (!removed) {
5218
5352
  this.context.stdout.write(tx(CONFIG_TEXTS.unsetNoOverride, { path, key: this.key }));
5219
- emitDoneStderr(this.context.stderr, elapsed);
5220
5353
  return ExitCode.Ok;
5221
5354
  }
5222
5355
  writeJsonAtomic(path, current);
5223
5356
  this.context.stdout.write(tx(CONFIG_TEXTS.unsetRemoved, { key: this.key, path }));
5224
- emitDoneStderr(this.context.stderr, elapsed);
5225
5357
  return ExitCode.Ok;
5226
5358
  }
5227
5359
  };
@@ -5237,7 +5369,7 @@ var CONFIG_COMMANDS = [
5237
5369
  import { existsSync as existsSync11, readFileSync as readFileSync9 } from "fs";
5238
5370
  import { dirname as dirname7, resolve as resolve11 } from "path";
5239
5371
  import { fileURLToPath as fileURLToPath4 } from "url";
5240
- import { Command as Command3, Option as Option3 } from "clipanion";
5372
+ import { Command as Command4, Option as Option4 } from "clipanion";
5241
5373
 
5242
5374
  // conformance/index.ts
5243
5375
  import { spawnSync } from "child_process";
@@ -5700,9 +5832,9 @@ function resolveBinary() {
5700
5832
  }
5701
5833
  return resolve11(here, "..", "..", "bin", "sm.js");
5702
5834
  }
5703
- var ConformanceRunCommand = class extends Command3 {
5835
+ var ConformanceRunCommand = class extends SmCommand {
5704
5836
  static paths = [["conformance", "run"]];
5705
- static usage = Command3.Usage({
5837
+ static usage = Command4.Usage({
5706
5838
  category: "Introspection",
5707
5839
  description: "Run the conformance suite \u2014 spec-owned cases plus every built-in Provider.",
5708
5840
  details: `
@@ -5736,14 +5868,14 @@ var ConformanceRunCommand = class extends Command3 {
5736
5868
  ]
5737
5869
  ]
5738
5870
  });
5739
- scope = Option3.String("--scope", {
5871
+ scope = Option4.String("--scope", {
5740
5872
  required: false,
5741
5873
  description: "Suite selector: 'all' (default), 'spec', or 'provider:<id>'."
5742
5874
  });
5743
5875
  // CLI orchestrator: scope resolution + per-case run loop +
5744
5876
  // per-result render branches + global pass/fail decision.
5745
5877
  // eslint-disable-next-line complexity
5746
- async execute() {
5878
+ async run() {
5747
5879
  let scopes;
5748
5880
  try {
5749
5881
  scopes = selectConformanceScopes(this.scope);
@@ -5864,7 +5996,7 @@ var CONFORMANCE_COMMANDS = [ConformanceRunCommand];
5864
5996
 
5865
5997
  // cli/commands/db.ts
5866
5998
  import { spawnSync as spawnSync2 } from "child_process";
5867
- import { chmod, copyFile, mkdir, rm, stat as stat2 } from "fs/promises";
5999
+ import { chmod, copyFile, mkdir, rm } from "fs/promises";
5868
6000
  import { dirname as dirname8, join as join9, resolve as resolve12 } from "path";
5869
6001
  import { DatabaseSync as DatabaseSync4 } from "node:sqlite";
5870
6002
 
@@ -5940,13 +6072,10 @@ var DB_TEXTS = {
5940
6072
  };
5941
6073
 
5942
6074
  // cli/commands/db.ts
5943
- import { Command as Command4, Option as Option4 } from "clipanion";
5944
- var SAFE_SQL_IDENTIFIER_RE = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
5945
- function assertSafeIdentifier(name) {
5946
- if (!SAFE_SQL_IDENTIFIER_RE.test(name)) {
5947
- throw new Error(`refusing to operate on non-identifier table name: ${JSON.stringify(name)}`);
5948
- }
5949
- }
6075
+ import { Command as Command5, Option as Option5 } from "clipanion";
6076
+
6077
+ // cli/util/fs.ts
6078
+ import { stat as stat2 } from "fs/promises";
5950
6079
  async function pathExists(path) {
5951
6080
  try {
5952
6081
  await stat2(path);
@@ -5964,15 +6093,23 @@ async function statOrNull(path) {
5964
6093
  throw err;
5965
6094
  }
5966
6095
  }
6096
+
6097
+ // cli/commands/db.ts
6098
+ var SAFE_SQL_IDENTIFIER_RE = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
6099
+ function assertSafeIdentifier(name) {
6100
+ if (!SAFE_SQL_IDENTIFIER_RE.test(name)) {
6101
+ throw new Error(`refusing to operate on non-identifier table name: ${JSON.stringify(name)}`);
6102
+ }
6103
+ }
5967
6104
  async function chmodOwnerOnlyBestEffort(target) {
5968
6105
  try {
5969
6106
  await chmod(target, 384);
5970
6107
  } catch {
5971
6108
  }
5972
6109
  }
5973
- var DbBackupCommand = class extends Command4 {
6110
+ var DbBackupCommand = class extends SmCommand {
5974
6111
  static paths = [["db", "backup"]];
5975
- static usage = Command4.Usage({
6112
+ static usage = Command5.Usage({
5976
6113
  category: "Database",
5977
6114
  description: "WAL checkpoint + copy the DB file to a backup.",
5978
6115
  details: `
@@ -5982,10 +6119,8 @@ var DbBackupCommand = class extends Command4 {
5982
6119
  running sm scan afterwards refreshes scan_*.
5983
6120
  `
5984
6121
  });
5985
- global = Option4.Boolean("-g,--global", false);
5986
- db = Option4.String("--db", { required: false });
5987
- out = Option4.String("--out", { required: false });
5988
- async execute() {
6122
+ out = Option5.String("--out", { required: false });
6123
+ async run() {
5989
6124
  const path = resolveDbPath({ global: this.global, db: this.db, ...defaultRuntimeContext() });
5990
6125
  if (!assertDbExists(path, this.context.stderr)) return ExitCode.NotFound;
5991
6126
  const ts = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
@@ -5997,9 +6132,9 @@ var DbBackupCommand = class extends Command4 {
5997
6132
  return ExitCode.Ok;
5998
6133
  }
5999
6134
  };
6000
- var DbRestoreCommand = class extends Command4 {
6135
+ var DbRestoreCommand = class extends SmCommand {
6001
6136
  static paths = [["db", "restore"]];
6002
- static usage = Command4.Usage({
6137
+ static usage = Command5.Usage({
6003
6138
  category: "Database",
6004
6139
  description: "Replace the active DB file with a backup.",
6005
6140
  details: `
@@ -6010,14 +6145,12 @@ var DbRestoreCommand = class extends Command4 {
6010
6145
  Dry-run bypasses the confirmation prompt.
6011
6146
  `
6012
6147
  });
6013
- source = Option4.String({ required: true });
6014
- global = Option4.Boolean("-g,--global", false);
6015
- db = Option4.String("--db", { required: false });
6016
- yes = Option4.Boolean("--yes,--force", false);
6017
- dryRun = Option4.Boolean("-n,--dry-run", false, {
6148
+ source = Option5.String({ required: true });
6149
+ yes = Option5.Boolean("--yes,--force", false);
6150
+ dryRun = Option5.Boolean("-n,--dry-run", false, {
6018
6151
  description: "Preview the restore without overwriting the live DB."
6019
6152
  });
6020
- async execute() {
6153
+ async run() {
6021
6154
  const target = resolveDbPath({ global: this.global, db: this.db, ...defaultRuntimeContext() });
6022
6155
  const sourcePath = resolve12(this.source);
6023
6156
  const sourceStat = await statOrNull(sourcePath);
@@ -6059,9 +6192,9 @@ var DbRestoreCommand = class extends Command4 {
6059
6192
  return ExitCode.Ok;
6060
6193
  }
6061
6194
  };
6062
- var DbResetCommand = class extends Command4 {
6195
+ var DbResetCommand = class extends SmCommand {
6063
6196
  static paths = [["db", "reset"]];
6064
- static usage = Command4.Usage({
6197
+ static usage = Command5.Usage({
6065
6198
  category: "Database",
6066
6199
  description: "Drop scan_* (default), optionally state_*, or delete the DB entirely.",
6067
6200
  details: `
@@ -6075,12 +6208,10 @@ var DbResetCommand = class extends Command4 {
6075
6208
  preview itself is non-destructive).
6076
6209
  `
6077
6210
  });
6078
- global = Option4.Boolean("-g,--global", false);
6079
- db = Option4.String("--db", { required: false });
6080
- state = Option4.Boolean("--state", false);
6081
- hard = Option4.Boolean("--hard", false);
6082
- yes = Option4.Boolean("--yes,--force", false);
6083
- dryRun = Option4.Boolean("-n,--dry-run", false, {
6211
+ state = Option5.Boolean("--state", false);
6212
+ hard = Option5.Boolean("--hard", false);
6213
+ yes = Option5.Boolean("--yes,--force", false);
6214
+ dryRun = Option5.Boolean("-n,--dry-run", false, {
6084
6215
  description: "Preview the reset without dropping any tables or unlinking any files."
6085
6216
  });
6086
6217
  // CLI orchestrator: --state vs --hard flag combo + --dry-run + --yes
@@ -6088,7 +6219,7 @@ var DbResetCommand = class extends Command4 {
6088
6219
  // expression of the flag semantics; splitting per branch would
6089
6220
  // distance the validations from their guards.
6090
6221
  // eslint-disable-next-line complexity
6091
- async execute() {
6222
+ async run() {
6092
6223
  if (this.state && this.hard) {
6093
6224
  this.context.stderr.write(DB_TEXTS.resetStateAndHardMutex);
6094
6225
  return ExitCode.Error;
@@ -6176,9 +6307,9 @@ var DbResetCommand = class extends Command4 {
6176
6307
  return ExitCode.Ok;
6177
6308
  }
6178
6309
  };
6179
- var DbShellCommand = class extends Command4 {
6310
+ var DbShellCommand = class extends SmCommand {
6180
6311
  static paths = [["db", "shell"]];
6181
- static usage = Command4.Usage({
6312
+ static usage = Command5.Usage({
6182
6313
  category: "Database",
6183
6314
  description: "Open an interactive sqlite3 shell on the DB file.",
6184
6315
  details: `
@@ -6187,9 +6318,11 @@ var DbShellCommand = class extends Command4 {
6187
6318
  sm db dump for a read-only inspection.
6188
6319
  `
6189
6320
  });
6190
- global = Option4.Boolean("-g,--global", false);
6191
- db = Option4.String("--db", { required: false });
6192
- async execute() {
6321
+ // Interactive shell: the spawned `sqlite3` owns the terminal. No
6322
+ // `done in <…>` line the user expects to see the shell's own
6323
+ // prompt + farewell, not a follow-up trailer once they exit.
6324
+ emitElapsed = false;
6325
+ async run() {
6193
6326
  const path = resolveDbPath({ global: this.global, db: this.db, ...defaultRuntimeContext() });
6194
6327
  if (!assertDbExists(path, this.context.stderr)) return ExitCode.NotFound;
6195
6328
  const result = spawnSync2("sqlite3", [path], { stdio: "inherit" });
@@ -6200,22 +6333,20 @@ var DbShellCommand = class extends Command4 {
6200
6333
  return result.status ?? 0;
6201
6334
  }
6202
6335
  };
6203
- var DbDumpCommand = class extends Command4 {
6336
+ var DbDumpCommand = class extends SmCommand {
6204
6337
  static paths = [["db", "dump"]];
6205
- static usage = Command4.Usage({
6338
+ static usage = Command5.Usage({
6206
6339
  category: "Database",
6207
6340
  description: "SQL dump to stdout.",
6208
6341
  details: "Read-only. Use --tables <names...> to limit the dump to specific tables."
6209
6342
  });
6210
- global = Option4.Boolean("-g,--global", false);
6211
- db = Option4.String("--db", { required: false });
6212
- tables = Option4.Array("--tables", { required: false });
6343
+ tables = Option5.Array("--tables", { required: false });
6213
6344
  // CLI orchestrator: each branch (db existence, per-table identifier
6214
6345
  // gate, sqlite3-not-found fallback, exit-status passthrough) is a
6215
6346
  // single dispatcher decision. Splitting per branch scatters the gate
6216
6347
  // away from the value it gates.
6217
6348
  // eslint-disable-next-line complexity
6218
- async execute() {
6349
+ async run() {
6219
6350
  const path = resolveDbPath({ global: this.global, db: this.db, ...defaultRuntimeContext() });
6220
6351
  if (!assertDbExists(path, this.context.stderr)) return ExitCode.NotFound;
6221
6352
  const args2 = ["-readonly", path, ".dump"];
@@ -6236,9 +6367,9 @@ var DbDumpCommand = class extends Command4 {
6236
6367
  return result.status ?? 0;
6237
6368
  }
6238
6369
  };
6239
- var DbMigrateCommand = class extends Command4 {
6370
+ var DbMigrateCommand = class extends SmCommand {
6240
6371
  static paths = [["db", "migrate"]];
6241
- static usage = Command4.Usage({
6372
+ static usage = Command5.Usage({
6242
6373
  category: "Database",
6243
6374
  description: "Apply pending kernel + plugin migrations (default) or inspect plan.",
6244
6375
  details: `
@@ -6259,21 +6390,19 @@ var DbMigrateCommand = class extends Command4 {
6259
6390
  object outside the prefix.
6260
6391
  `
6261
6392
  });
6262
- global = Option4.Boolean("-g,--global", false);
6263
- db = Option4.String("--db", { required: false });
6264
- dryRun = Option4.Boolean("--dry-run", false);
6265
- status = Option4.Boolean("--status", false);
6266
- to = Option4.String("--to", { required: false });
6267
- noBackup = Option4.Boolean("--no-backup", false);
6268
- kernelOnly = Option4.Boolean("--kernel-only", false);
6269
- pluginId = Option4.String("--plugin", { required: false });
6393
+ dryRun = Option5.Boolean("-n,--dry-run", false);
6394
+ status = Option5.Boolean("--status", false);
6395
+ to = Option5.String("--to", { required: false });
6396
+ noBackup = Option5.Boolean("--no-backup", false);
6397
+ kernelOnly = Option5.Boolean("--kernel-only", false);
6398
+ pluginId = Option5.String("--plugin", { required: false });
6270
6399
  // Multi-flag CLI orchestrator: validates flag combos, optionally
6271
6400
  // discovers plugins, fans out into status / apply branches against
6272
6401
  // both the kernel ledger and per-plugin ledgers. Splitting per branch
6273
6402
  // would scatter the close-to-call-site flag handling without making
6274
6403
  // the verb easier to follow.
6275
6404
  // eslint-disable-next-line complexity
6276
- async execute() {
6405
+ async run() {
6277
6406
  if (this.kernelOnly && this.pluginId !== void 0) {
6278
6407
  this.context.stderr.write(DB_TEXTS.migrateKernelOnlyAndPluginMutex);
6279
6408
  return ExitCode.Error;
@@ -6455,7 +6584,7 @@ var DB_COMMANDS = [
6455
6584
  ];
6456
6585
 
6457
6586
  // cli/commands/export.ts
6458
- import { Command as Command5, Option as Option5 } from "clipanion";
6587
+ import { Command as Command6, Option as Option6 } from "clipanion";
6459
6588
 
6460
6589
  // kernel/scan/query.ts
6461
6590
  var HAS_VALUES = /* @__PURE__ */ new Set(["issues"]);
@@ -6612,9 +6741,9 @@ var SUPPORTED_FORMATS = ["json", "md"];
6612
6741
  var DEFERRED_FORMATS = {
6613
6742
  mermaid: EXPORT_TEXTS.formatDeferredReasonMermaid
6614
6743
  };
6615
- var ExportCommand = class extends Command5 {
6744
+ var ExportCommand = class extends SmCommand {
6616
6745
  static paths = [["export"]];
6617
- static usage = Command5.Usage({
6746
+ static usage = Command6.Usage({
6618
6747
  category: "Browse",
6619
6748
  description: "Filtered export. Query syntax is implementation-defined pre-1.0.",
6620
6749
  details: `
@@ -6638,11 +6767,9 @@ var ExportCommand = class extends Command5 {
6638
6767
  ["Whole graph as Markdown", '$0 export "" --format md']
6639
6768
  ]
6640
6769
  });
6641
- query = Option5.String({ required: true });
6642
- format = Option5.String("--format", { required: false });
6643
- global = Option5.Boolean("-g,--global", false);
6644
- db = Option5.String("--db", { required: false });
6645
- async execute() {
6770
+ query = Option6.String({ required: true });
6771
+ format = Option6.String("--format", { required: false });
6772
+ async run() {
6646
6773
  const format = (this.format ?? "json").toLowerCase();
6647
6774
  if (DEFERRED_FORMATS[format]) {
6648
6775
  this.context.stderr.write(
@@ -6826,7 +6953,7 @@ function pickTitle2(node) {
6826
6953
  }
6827
6954
 
6828
6955
  // cli/commands/graph.ts
6829
- import { Command as Command6, Option as Option6 } from "clipanion";
6956
+ import { Command as Command7, Option as Option7 } from "clipanion";
6830
6957
 
6831
6958
  // cli/i18n/graph.texts.ts
6832
6959
  var GRAPH_TEXTS = {
@@ -6836,9 +6963,9 @@ var GRAPH_TEXTS = {
6836
6963
 
6837
6964
  // cli/commands/graph.ts
6838
6965
  var DEFAULT_FORMAT = "ascii";
6839
- var GraphCommand = class extends Command6 {
6966
+ var GraphCommand = class extends SmCommand {
6840
6967
  static paths = [["graph"]];
6841
- static usage = Command6.Usage({
6968
+ static usage = Command7.Usage({
6842
6969
  category: "Browse",
6843
6970
  description: "Render the full graph via the named formatter.",
6844
6971
  details: `
@@ -6855,15 +6982,13 @@ var GraphCommand = class extends Command6 {
6855
6982
  ["Use a non-default DB file", "$0 graph --db /path/to/skill-map.db"]
6856
6983
  ]
6857
6984
  });
6858
- format = Option6.String("--format", DEFAULT_FORMAT, {
6985
+ format = Option7.String("--format", DEFAULT_FORMAT, {
6859
6986
  description: `Formatter format. Must match the \`formatId\` field of a registered formatter. Default: ${DEFAULT_FORMAT}.`
6860
6987
  });
6861
- global = Option6.Boolean("-g,--global", false);
6862
- db = Option6.String("--db", { required: false });
6863
- noPlugins = Option6.Boolean("--no-plugins", false, {
6988
+ noPlugins = Option7.Boolean("--no-plugins", false, {
6864
6989
  description: "Skip drop-in plugin discovery. Only built-in formatters participate."
6865
6990
  });
6866
- async execute() {
6991
+ async run() {
6867
6992
  const dbPath = resolveDbPath({ global: this.global, db: this.db, ...defaultRuntimeContext() });
6868
6993
  if (!assertDbExists(dbPath, this.context.stderr)) return ExitCode.NotFound;
6869
6994
  const pluginRuntime = this.noPlugins ? emptyPluginRuntime() : await loadPluginRuntime({ scope: this.global ? "global" : "project" });
@@ -6898,12 +7023,12 @@ var GraphCommand = class extends Command6 {
6898
7023
  import { readFileSync as readFileSync10 } from "fs";
6899
7024
  import { createRequire as createRequire4 } from "module";
6900
7025
  import { resolve as resolve13 } from "path";
6901
- import { Command as Command7, Option as Option7 } from "clipanion";
7026
+ import { Command as Command8, Option as Option8 } from "clipanion";
6902
7027
 
6903
7028
  // package.json
6904
7029
  var package_default = {
6905
7030
  name: "@skill-map/cli",
6906
- version: "0.9.0",
7031
+ version: "0.11.0",
6907
7032
  description: "skill-map reference implementation \u2014 kernel + CLI + adapters.",
6908
7033
  license: "MIT",
6909
7034
  type: "module",
@@ -6950,6 +7075,7 @@ var package_default = {
6950
7075
  scripts: {
6951
7076
  build: "tsup",
6952
7077
  dev: "tsup --watch",
7078
+ "dev:serve": "node ../scripts/dev-serve.js",
6953
7079
  typecheck: "tsc --noEmit",
6954
7080
  lint: "eslint .",
6955
7081
  "lint:fix": "eslint . --fix",
@@ -6960,17 +7086,20 @@ var package_default = {
6960
7086
  clean: "rm -rf dist coverage"
6961
7087
  },
6962
7088
  dependencies: {
7089
+ "@hono/node-server": "2.0.1",
6963
7090
  "@skill-map/spec": "*",
6964
7091
  ajv: "8.18.0",
6965
7092
  "ajv-formats": "3.0.1",
6966
7093
  chokidar: "5.0.0",
6967
7094
  clipanion: "4.0.0-rc.4",
7095
+ hono: "4.12.16",
6968
7096
  ignore: "7.0.5",
6969
7097
  "js-tiktoken": "1.0.21",
6970
7098
  "js-yaml": "4.1.1",
6971
7099
  kysely: "0.28.16",
6972
7100
  semver: "7.7.4",
6973
- typanion: "3.14.0"
7101
+ typanion: "3.14.0",
7102
+ ws: "8.20.0"
6974
7103
  },
6975
7104
  devDependencies: {
6976
7105
  "@eslint/js": "10.0.1",
@@ -6978,6 +7107,7 @@ var package_default = {
6978
7107
  "@types/js-yaml": "4.0.9",
6979
7108
  "@types/node": "24.12.2",
6980
7109
  "@types/semver": "7.7.1",
7110
+ "@types/ws": "8.18.1",
6981
7111
  c8: "11.0.0",
6982
7112
  eslint: "10.2.1",
6983
7113
  "eslint-plugin-import-x": "4.16.2",
@@ -7074,9 +7204,9 @@ var HELP_TEXTS = {
7074
7204
  };
7075
7205
 
7076
7206
  // cli/commands/help.ts
7077
- var HelpCommand = class extends Command7 {
7207
+ var HelpCommand = class extends Command8 {
7078
7208
  static paths = [["help"]];
7079
- static usage = Command7.Usage({
7209
+ static usage = Command8.Usage({
7080
7210
  category: "Introspection",
7081
7211
  description: "Self-describing introspection. --format human|md|json.",
7082
7212
  details: `
@@ -7090,8 +7220,8 @@ var HelpCommand = class extends Command7 {
7090
7220
  json \u2014 structured surface dump per spec/cli-contract.md.
7091
7221
  `
7092
7222
  });
7093
- verbParts = Option7.Rest({ required: 0 });
7094
- format = Option7.String("--format", "human");
7223
+ verbParts = Option8.Rest({ required: 0 });
7224
+ format = Option8.String("--format", "human");
7095
7225
  async execute() {
7096
7226
  const format = normalizeFormat(this.format);
7097
7227
  if (!format) {
@@ -7409,8 +7539,8 @@ function renderCompactOverview(verbs) {
7409
7539
  lines.push(HELP_TEXTS.compactFooter);
7410
7540
  return lines.join("\n") + "\n";
7411
7541
  }
7412
- var RootHelpCommand = class extends Command7 {
7413
- static paths = [["-h"], ["--help"], Command7.Default];
7542
+ var RootHelpCommand = class extends Command8 {
7543
+ static paths = [["-h"], ["--help"], Command8.Default];
7414
7544
  async execute() {
7415
7545
  const rawDefs = this.cli.definitions();
7416
7546
  const verbs = rawDefs.filter((d) => !isBuiltin(d)).map(normalizeDefinition).sort(byPath);
@@ -7465,9 +7595,9 @@ function registeredVerbPaths(cli2) {
7465
7595
  }
7466
7596
 
7467
7597
  // cli/commands/init.ts
7468
- import { mkdir as mkdir2, readFile as readFile2, stat as stat3, writeFile } from "fs/promises";
7598
+ import { mkdir as mkdir2, readFile as readFile2, writeFile } from "fs/promises";
7469
7599
  import { join as join10 } from "path";
7470
- import { Command as Command8, Option as Option8 } from "clipanion";
7600
+ import { Command as Command9, Option as Option9 } from "clipanion";
7471
7601
 
7472
7602
  // kernel/orchestrator.ts
7473
7603
  import { createHash } from "crypto";
@@ -8629,22 +8759,9 @@ function createCliProgressEmitter(stderr) {
8629
8759
  }
8630
8760
 
8631
8761
  // cli/commands/init.ts
8632
- var GITIGNORE_ENTRIES = [
8633
- ".skill-map/settings.local.json",
8634
- ".skill-map/skill-map.db"
8635
- ];
8636
- async function pathExists2(path) {
8637
- try {
8638
- await stat3(path);
8639
- return true;
8640
- } catch (err) {
8641
- if (err.code === "ENOENT") return false;
8642
- throw err;
8643
- }
8644
- }
8645
- var InitCommand = class extends Command8 {
8762
+ var InitCommand = class extends SmCommand {
8646
8763
  static paths = [["init"]];
8647
- static usage = Command8.Usage({
8764
+ static usage = Command9.Usage({
8648
8765
  category: "Setup",
8649
8766
  description: "Bootstrap the current scope: scaffold .skill-map/, provision DB, run first scan.",
8650
8767
  details: `
@@ -8668,19 +8785,16 @@ var InitCommand = class extends Command8 {
8668
8785
  ["Preview what would be created", "$0 init --dry-run"]
8669
8786
  ]
8670
8787
  });
8671
- global = Option8.Boolean("-g,--global", false, {
8672
- description: "Initialise ~/.skill-map/ instead of ./.skill-map/."
8673
- });
8674
- noScan = Option8.Boolean("--no-scan", false, {
8788
+ noScan = Option9.Boolean("--no-scan", false, {
8675
8789
  description: "Skip the first scan after scaffolding."
8676
8790
  });
8677
- force = Option8.Boolean("--force", false, {
8791
+ force = Option9.Boolean("--force", false, {
8678
8792
  description: "Overwrite an existing settings.json / settings.local.json / .skill-mapignore."
8679
8793
  });
8680
- strict = Option8.Boolean("--strict", false, {
8794
+ strict = Option9.Boolean("--strict", false, {
8681
8795
  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."
8682
8796
  });
8683
- dryRun = Option8.Boolean("-n,--dry-run", false, {
8797
+ dryRun = Option9.Boolean("-n,--dry-run", false, {
8684
8798
  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."
8685
8799
  });
8686
8800
  // CLI orchestrator: paths setup + dry-run branch (delegated to
@@ -8688,18 +8802,16 @@ var InitCommand = class extends Command8 {
8688
8802
  // gitignore management + DB provision + first scan delegation).
8689
8803
  // The first-scan branch already lives in `runFirstScan`.
8690
8804
  // eslint-disable-next-line complexity
8691
- async execute() {
8692
- const elapsed = startElapsed();
8805
+ async run() {
8693
8806
  const ctx = defaultRuntimeContext();
8694
8807
  const scopeRoot = this.global ? ctx.homedir : ctx.cwd;
8695
8808
  const skillMapDir = join10(scopeRoot, SKILL_MAP_DIR);
8696
- const settingsPath = join10(skillMapDir, "settings.json");
8697
- const localPath = join10(skillMapDir, "settings.local.json");
8698
- const ignorePath = join10(scopeRoot, ".skill-mapignore");
8699
- const dbPath = join10(skillMapDir, "skill-map.db");
8700
- if (await pathExists2(settingsPath) && !this.force) {
8809
+ const settingsPath = defaultSettingsPath(scopeRoot);
8810
+ const localPath = defaultLocalSettingsPath(scopeRoot);
8811
+ const ignorePath = defaultIgnoreFilePath(scopeRoot);
8812
+ const dbPath = defaultDbPath(scopeRoot);
8813
+ if (await pathExists(settingsPath) && !this.force) {
8701
8814
  this.context.stderr.write(tx(INIT_TEXTS.alreadyInitialised, { settingsPath }));
8702
- emitDoneStderr(this.context.stderr, elapsed);
8703
8815
  return ExitCode.Error;
8704
8816
  }
8705
8817
  if (this.dryRun) {
@@ -8714,15 +8826,14 @@ var InitCommand = class extends Command8 {
8714
8826
  global: this.global,
8715
8827
  noScan: this.noScan
8716
8828
  });
8717
- emitDoneStderr(this.context.stderr, elapsed);
8718
8829
  return ExitCode.Ok;
8719
8830
  }
8720
8831
  await mkdir2(skillMapDir, { recursive: true });
8721
8832
  await writeFile(settingsPath, JSON.stringify({ schemaVersion: 1 }, null, 2) + "\n");
8722
- if (!await pathExists2(localPath) || this.force) {
8833
+ if (!await pathExists(localPath) || this.force) {
8723
8834
  await writeFile(localPath, "{}\n");
8724
8835
  }
8725
- if (!await pathExists2(ignorePath) || this.force) {
8836
+ if (!await pathExists(ignorePath) || this.force) {
8726
8837
  await writeFile(ignorePath, loadBundledIgnoreText());
8727
8838
  }
8728
8839
  if (!this.global) {
@@ -8740,25 +8851,20 @@ var InitCommand = class extends Command8 {
8740
8851
  await withSqlite({ databasePath: dbPath, autoBackup: false }, async () => {
8741
8852
  });
8742
8853
  this.context.stdout.write(tx(INIT_TEXTS.initialised, { skillMapDir }));
8743
- if (this.noScan) {
8744
- emitDoneStderr(this.context.stderr, elapsed);
8745
- return ExitCode.Ok;
8746
- }
8747
- const scanCode = await runFirstScan(scopeRoot, ctx.homedir, dbPath, this.strict, this.context.stdout, this.context.stderr);
8748
- emitDoneStderr(this.context.stderr, elapsed);
8749
- return scanCode;
8854
+ if (this.noScan) return ExitCode.Ok;
8855
+ return runFirstScan(scopeRoot, ctx.homedir, dbPath, this.strict, this.context.stdout, this.context.stderr);
8750
8856
  }
8751
8857
  };
8752
8858
  async function writeDryRunPlan(stdout, opts) {
8753
8859
  stdout.write(INIT_TEXTS.dryRunHeader);
8754
- if (!await pathExists2(opts.skillMapDir)) {
8860
+ if (!await pathExists(opts.skillMapDir)) {
8755
8861
  stdout.write(tx(INIT_TEXTS.dryRunWouldCreateDir, { path: opts.skillMapDir }));
8756
8862
  }
8757
8863
  stdout.write(await dryRunFileMessage(opts.settingsPath));
8758
- if (!await pathExists2(opts.localPath) || opts.force) {
8864
+ if (!await pathExists(opts.localPath) || opts.force) {
8759
8865
  stdout.write(await dryRunFileMessage(opts.localPath));
8760
8866
  }
8761
- if (!await pathExists2(opts.ignorePath) || opts.force) {
8867
+ if (!await pathExists(opts.ignorePath) || opts.force) {
8762
8868
  stdout.write(await dryRunFileMessage(opts.ignorePath));
8763
8869
  }
8764
8870
  if (!opts.global) await writeDryRunGitignorePlan(stdout, opts.scopeRoot);
@@ -8768,7 +8874,7 @@ async function writeDryRunPlan(stdout, opts) {
8768
8874
  );
8769
8875
  }
8770
8876
  async function dryRunFileMessage(path) {
8771
- return await pathExists2(path) ? tx(INIT_TEXTS.dryRunWouldOverwriteFile, { path }) : tx(INIT_TEXTS.dryRunWouldWriteFile, { path });
8877
+ return await pathExists(path) ? tx(INIT_TEXTS.dryRunWouldOverwriteFile, { path }) : tx(INIT_TEXTS.dryRunWouldWriteFile, { path });
8772
8878
  }
8773
8879
  async function writeDryRunGitignorePlan(stdout, scopeRoot) {
8774
8880
  const wouldAdd = await previewGitignoreEntries(scopeRoot, GITIGNORE_ENTRIES);
@@ -8848,7 +8954,7 @@ async function runFirstScan(scopeRoot, homedir2, dbPath, strict, stdout, stderr)
8848
8954
  }
8849
8955
  async function previewGitignoreEntries(scopeRoot, entries) {
8850
8956
  const path = join10(scopeRoot, ".gitignore");
8851
- const body = await pathExists2(path) ? await readFile2(path, "utf8") : "";
8957
+ const body = await pathExists(path) ? await readFile2(path, "utf8") : "";
8852
8958
  const present = new Set(
8853
8959
  body.split("\n").map((line) => line.trim()).filter((line) => line.length > 0 && !line.startsWith("#"))
8854
8960
  );
@@ -8857,7 +8963,7 @@ async function previewGitignoreEntries(scopeRoot, entries) {
8857
8963
  async function ensureGitignoreEntries(scopeRoot, entries) {
8858
8964
  const path = join10(scopeRoot, ".gitignore");
8859
8965
  let body = "";
8860
- if (await pathExists2(path)) {
8966
+ if (await pathExists(path)) {
8861
8967
  body = await readFile2(path, "utf8");
8862
8968
  }
8863
8969
  const present = new Set(
@@ -8877,7 +8983,7 @@ async function ensureGitignoreEntries(scopeRoot, entries) {
8877
8983
  }
8878
8984
 
8879
8985
  // cli/commands/history.ts
8880
- import { Command as Command9, Option as Option9 } from "clipanion";
8986
+ import { Command as Command10, Option as Option10 } from "clipanion";
8881
8987
 
8882
8988
  // cli/i18n/option-validators.texts.ts
8883
8989
  var OPTION_VALIDATORS_TEXTS = {
@@ -8964,9 +9070,9 @@ function parseStatuses(input, stderr) {
8964
9070
  }
8965
9071
  return parts;
8966
9072
  }
8967
- var HistoryCommand = class extends Command9 {
9073
+ var HistoryCommand = class extends SmCommand {
8968
9074
  static paths = [["history"]];
8969
- static usage = Command9.Usage({
9075
+ static usage = Command10.Usage({
8970
9076
  category: "History",
8971
9077
  description: "Filter execution records. --json emits an array conforming to execution-record.schema.json.",
8972
9078
  details: `
@@ -8986,24 +9092,19 @@ var HistoryCommand = class extends Command9 {
8986
9092
  ["Machine-readable, scoped to one node", "$0 history -n skills/foo.md --json"]
8987
9093
  ]
8988
9094
  });
8989
- global = Option9.Boolean("-g,--global", false);
8990
- db = Option9.String("--db", { required: false });
8991
- node = Option9.String("-n", { required: false });
8992
- action = Option9.String("--action", { required: false });
8993
- status = Option9.String("--status", { required: false });
8994
- since = Option9.String("--since", { required: false });
8995
- until = Option9.String("--until", { required: false });
8996
- limit = Option9.String("--limit", { required: false });
8997
- json = Option9.Boolean("--json", false);
8998
- quiet = Option9.Boolean("--quiet", false);
9095
+ node = Option10.String("-n", { required: false });
9096
+ action = Option10.String("--action", { required: false });
9097
+ status = Option10.String("--status", { required: false });
9098
+ since = Option10.String("--since", { required: false });
9099
+ until = Option10.String("--until", { required: false });
9100
+ limit = Option10.String("--limit", { required: false });
8999
9101
  // CLI list verb: many optional filter flags (`--node`, `--action`,
9000
9102
  // `--status`, `--since`, `--until`, `--limit`, `--json`, `--quiet`)
9001
9103
  // each adding a guarded mutation to the filter or render path. Each
9002
9104
  // branch is single-purpose; splitting per flag would distance the
9003
9105
  // validations from the filter they shape.
9004
9106
  // eslint-disable-next-line complexity
9005
- async execute() {
9006
- const elapsed = startElapsed();
9107
+ async run() {
9007
9108
  const filter = {};
9008
9109
  if (this.node !== void 0) filter.nodePath = this.node;
9009
9110
  if (this.action !== void 0) filter.actionId = this.action;
@@ -9038,14 +9139,13 @@ var HistoryCommand = class extends Command9 {
9038
9139
  } else {
9039
9140
  this.context.stdout.write(renderTable(rows));
9040
9141
  }
9041
- emitDoneStderr(this.context.stderr, elapsed, this.quiet);
9042
9142
  return ExitCode.Ok;
9043
9143
  });
9044
9144
  }
9045
9145
  };
9046
- var HistoryStatsCommand = class extends Command9 {
9146
+ var HistoryStatsCommand = class extends SmCommand {
9047
9147
  static paths = [["history", "stats"]];
9048
- static usage = Command9.Usage({
9148
+ static usage = Command10.Usage({
9049
9149
  category: "History",
9050
9150
  description: "Aggregate counts, tokens, periods, top nodes, and error rates over state_executions. --json conforms to history-stats.schema.json.",
9051
9151
  details: `
@@ -9063,20 +9163,16 @@ var HistoryStatsCommand = class extends Command9 {
9063
9163
  ["Top 5 nodes, JSON", "$0 history stats --top 5 --json"]
9064
9164
  ]
9065
9165
  });
9066
- global = Option9.Boolean("-g,--global", false);
9067
- db = Option9.String("--db", { required: false });
9068
- since = Option9.String("--since", { required: false });
9069
- until = Option9.String("--until", { required: false });
9070
- period = Option9.String("--period", { required: false });
9071
- top = Option9.String("--top", { required: false });
9072
- json = Option9.Boolean("--json", false);
9073
- quiet = Option9.Boolean("--quiet", false);
9166
+ since = Option10.String("--since", { required: false });
9167
+ until = Option10.String("--until", { required: false });
9168
+ period = Option10.String("--period", { required: false });
9169
+ top = Option10.String("--top", { required: false });
9074
9170
  // CLI stats verb: range parsing + window flags + period flag + JSON
9075
9171
  // branch + per-period iteration. Each branch is a single-purpose
9076
9172
  // gate; the data work lives in `aggregateHistoryStats`.
9077
9173
  // eslint-disable-next-line complexity
9078
- async execute() {
9079
- const elapsed = startElapsed();
9174
+ async run() {
9175
+ const elapsed = this.elapsed;
9080
9176
  let sinceMs = null;
9081
9177
  let untilMs = Date.now();
9082
9178
  if (this.since !== void 0) {
@@ -9140,7 +9236,6 @@ var HistoryStatsCommand = class extends Command9 {
9140
9236
  } else {
9141
9237
  this.context.stdout.write(renderStats(stats));
9142
9238
  }
9143
- emitDoneStderr(this.context.stderr, elapsed, this.quiet);
9144
9239
  return ExitCode.Ok;
9145
9240
  });
9146
9241
  }
@@ -9161,8 +9256,8 @@ function renderTable(rows) {
9161
9256
  HISTORY_TEXTS.tableHeaderTokens,
9162
9257
  HISTORY_TEXTS.tableHeaderNodes
9163
9258
  );
9164
- const sep4 = "-".repeat(header.length);
9165
- const lines = [header, sep4];
9259
+ const sep5 = "-".repeat(header.length);
9260
+ const lines = [header, sep5];
9166
9261
  for (const r of rows) {
9167
9262
  const tokens = `${r.tokensIn ?? 0}/${r.tokensOut ?? 0}`;
9168
9263
  const duration = r.durationMs === null || r.durationMs === void 0 ? "-" : formatElapsed(r.durationMs);
@@ -9252,7 +9347,7 @@ function formatRow(...cols) {
9252
9347
 
9253
9348
  // cli/commands/jobs.ts
9254
9349
  import { unlink } from "fs/promises";
9255
- import { Command as Command10, Option as Option10 } from "clipanion";
9350
+ import { Command as Command11, Option as Option11 } from "clipanion";
9256
9351
 
9257
9352
  // kernel/jobs/orphan-files.ts
9258
9353
  import { readdirSync as readdirSync6, statSync as statSync4 } from "fs";
@@ -9260,8 +9355,8 @@ import { join as join11, resolve as resolve15 } from "path";
9260
9355
  function findOrphanJobFiles(jobsDir, referencedPaths) {
9261
9356
  let entries;
9262
9357
  try {
9263
- const stat4 = statSync4(jobsDir);
9264
- if (!stat4.isDirectory()) {
9358
+ const stat3 = statSync4(jobsDir);
9359
+ if (!stat3.isDirectory()) {
9265
9360
  return { orphanFilePaths: [], referencedCount: referencedPaths.size };
9266
9361
  }
9267
9362
  entries = readdirSync6(jobsDir);
@@ -9298,9 +9393,9 @@ var JOBS_TEXTS = {
9298
9393
  };
9299
9394
 
9300
9395
  // cli/commands/jobs.ts
9301
- var JobPruneCommand = class extends Command10 {
9396
+ var JobPruneCommand = class extends SmCommand {
9302
9397
  static paths = [["job", "prune"]];
9303
- static usage = Command10.Usage({
9398
+ static usage = Command11.Usage({
9304
9399
  category: "Jobs",
9305
9400
  description: "Retention GC for completed / failed jobs (per config policy). --orphan-files removes MD files with no DB row.",
9306
9401
  details: `
@@ -9327,16 +9422,13 @@ var JobPruneCommand = class extends Command10 {
9327
9422
  ["Preview without touching the DB", "$0 job prune --dry-run --json"]
9328
9423
  ]
9329
9424
  });
9330
- orphanFiles = Option10.Boolean("--orphan-files", false, {
9425
+ orphanFiles = Option11.Boolean("--orphan-files", false, {
9331
9426
  description: "Also remove MD files in .skill-map/jobs/ that have no matching state_jobs row."
9332
9427
  });
9333
- dryRun = Option10.Boolean("-n,--dry-run", false, {
9428
+ dryRun = Option11.Boolean("-n,--dry-run", false, {
9334
9429
  description: "Report what would be pruned without touching the DB or filesystem."
9335
9430
  });
9336
- json = Option10.Boolean("--json", false, {
9337
- description: "Emit a structured prune-result document on stdout."
9338
- });
9339
- async execute() {
9431
+ async run() {
9340
9432
  const ctx = defaultRuntimeContext();
9341
9433
  const dbPath = defaultProjectDbPath(ctx);
9342
9434
  const jobsDir = defaultProjectJobsDir(ctx);
@@ -9450,7 +9542,7 @@ function formatPolicy(seconds) {
9450
9542
  }
9451
9543
 
9452
9544
  // cli/commands/list.ts
9453
- import { Command as Command11, Option as Option11 } from "clipanion";
9545
+ import { Command as Command12, Option as Option12 } from "clipanion";
9454
9546
 
9455
9547
  // cli/i18n/list.texts.ts
9456
9548
  var LIST_TEXTS = {
@@ -9476,9 +9568,9 @@ var SORT_BY = {
9476
9568
  external_refs_count: { column: "externalRefsCount", direction: "desc" }
9477
9569
  };
9478
9570
  var PATH_COL_WIDTH = 50;
9479
- var ListCommand = class extends Command11 {
9571
+ var ListCommand = class extends SmCommand {
9480
9572
  static paths = [["list"]];
9481
- static usage = Command11.Usage({
9573
+ static usage = Command12.Usage({
9482
9574
  category: "Browse",
9483
9575
  description: "Tabular listing of nodes. --json emits an array conforming to node.schema.json.",
9484
9576
  details: `
@@ -9499,14 +9591,11 @@ var ListCommand = class extends Command11 {
9499
9591
  ["Only nodes with issues, machine-readable", "$0 list --issue --json"]
9500
9592
  ]
9501
9593
  });
9502
- global = Option11.Boolean("-g,--global", false);
9503
- db = Option11.String("--db", { required: false });
9504
- kind = Option11.String("--kind", { required: false });
9505
- issue = Option11.Boolean("--issue", false);
9506
- sortBy = Option11.String("--sort-by", { required: false });
9507
- limit = Option11.String("--limit", { required: false });
9508
- json = Option11.Boolean("--json", false);
9509
- async execute() {
9594
+ kind = Option12.String("--kind", { required: false });
9595
+ issue = Option12.Boolean("--issue", false);
9596
+ sortBy = Option12.String("--sort-by", { required: false });
9597
+ limit = Option12.String("--limit", { required: false });
9598
+ async run() {
9510
9599
  let sortColumn = "path";
9511
9600
  let sortDirection = "asc";
9512
9601
  if (this.sortBy !== void 0) {
@@ -9577,8 +9666,8 @@ function renderTable2(nodes, issuesByNode) {
9577
9666
  LIST_TEXTS.tableHeaderIssues,
9578
9667
  LIST_TEXTS.tableHeaderBytes
9579
9668
  );
9580
- const sep4 = "-".repeat(header.length);
9581
- const lines = [header, sep4];
9669
+ const sep5 = "-".repeat(header.length);
9670
+ const lines = [header, sep5];
9582
9671
  for (const node of nodes) {
9583
9672
  lines.push(
9584
9673
  formatRow2(
@@ -9607,7 +9696,7 @@ function formatRow2(path, kind, out, inCount, ext, issues, bytes) {
9607
9696
  }
9608
9697
 
9609
9698
  // cli/commands/orphans.ts
9610
- import { Command as Command12, Option as Option12 } from "clipanion";
9699
+ import { Command as Command13, Option as Option13 } from "clipanion";
9611
9700
 
9612
9701
  // cli/i18n/orphans.texts.ts
9613
9702
  var ORPHANS_TEXTS = {
@@ -9660,9 +9749,9 @@ async function findActiveOrphanIssues(adapter, predicate) {
9660
9749
  function isStringArray(v) {
9661
9750
  return Array.isArray(v) && v.every((s) => typeof s === "string");
9662
9751
  }
9663
- var OrphansCommand = class extends Command12 {
9752
+ var OrphansCommand = class extends SmCommand {
9664
9753
  static paths = [["orphans"]];
9665
- static usage = Command12.Usage({
9754
+ static usage = Command13.Usage({
9666
9755
  category: "Browse",
9667
9756
  description: "List orphan / auto-rename issues from the last scan. --json emits an array conforming to issue.schema.json.",
9668
9757
  details: `
@@ -9677,13 +9766,8 @@ var OrphansCommand = class extends Command12 {
9677
9766
  ["Just the ambiguous ones, JSON", "$0 orphans --kind ambiguous --json"]
9678
9767
  ]
9679
9768
  });
9680
- global = Option12.Boolean("-g,--global", false);
9681
- db = Option12.String("--db", { required: false });
9682
- kind = Option12.String("--kind", { required: false });
9683
- json = Option12.Boolean("--json", false);
9684
- quiet = Option12.Boolean("--quiet", false);
9685
- async execute() {
9686
- const elapsed = startElapsed();
9769
+ kind = Option13.String("--kind", { required: false });
9770
+ async run() {
9687
9771
  let ruleFilter = null;
9688
9772
  if (this.kind !== void 0) {
9689
9773
  const map = {
@@ -9714,14 +9798,13 @@ var OrphansCommand = class extends Command12 {
9714
9798
  } else {
9715
9799
  this.context.stdout.write(renderOrphans(found.map((f) => f.issue)));
9716
9800
  }
9717
- emitDoneStderr(this.context.stderr, elapsed, this.quiet);
9718
9801
  return ExitCode.Ok;
9719
9802
  });
9720
9803
  }
9721
9804
  };
9722
- var OrphansReconcileCommand = class extends Command12 {
9805
+ var OrphansReconcileCommand = class extends SmCommand {
9723
9806
  static paths = [["orphans", "reconcile"]];
9724
- static usage = Command12.Usage({
9807
+ static usage = Command13.Usage({
9725
9808
  category: "Browse",
9726
9809
  description: "Migrate state_* FKs from an orphan path to a live node, resolving the orphan issue.",
9727
9810
  details: `
@@ -9737,14 +9820,10 @@ var OrphansReconcileCommand = class extends Command12 {
9737
9820
  ["Reattach orphan history", "$0 orphans reconcile skills/old.md --to skills/new.md"]
9738
9821
  ]
9739
9822
  });
9740
- global = Option12.Boolean("-g,--global", false);
9741
- db = Option12.String("--db", { required: false });
9742
- orphanPath = Option12.String({ required: true });
9743
- to = Option12.String("--to", { required: true });
9744
- dryRun = Option12.Boolean("-n,--dry-run", false);
9745
- quiet = Option12.Boolean("--quiet", false);
9746
- async execute() {
9747
- const elapsed = startElapsed();
9823
+ orphanPath = Option13.String({ required: true });
9824
+ to = Option13.String("--to", { required: true });
9825
+ dryRun = Option13.Boolean("-n,--dry-run", false);
9826
+ async run() {
9748
9827
  const dbPath = resolveDbPath({ global: this.global, db: this.db, ...defaultRuntimeContext() });
9749
9828
  if (!assertDbExists(dbPath, this.context.stderr)) return ExitCode.NotFound;
9750
9829
  return withSqlite({ databasePath: dbPath, autoBackup: false }, async (adapter) => {
@@ -9807,14 +9886,13 @@ var OrphansReconcileCommand = class extends Command12 {
9807
9886
  )
9808
9887
  );
9809
9888
  }
9810
- emitDoneStderr(this.context.stderr, elapsed, this.quiet);
9811
9889
  return ExitCode.Ok;
9812
9890
  });
9813
9891
  }
9814
9892
  };
9815
- var OrphansUndoRenameCommand = class extends Command12 {
9893
+ var OrphansUndoRenameCommand = class extends SmCommand {
9816
9894
  static paths = [["orphans", "undo-rename"]];
9817
- static usage = Command12.Usage({
9895
+ static usage = Command13.Usage({
9818
9896
  category: "Browse",
9819
9897
  description: "Reverse a medium- or ambiguous-confidence auto-rename. Migrates state_* FKs back, emits a new orphan on the prior path.",
9820
9898
  details: `
@@ -9834,15 +9912,11 @@ var OrphansUndoRenameCommand = class extends Command12 {
9834
9912
  ["Undo an ambiguous, picking a candidate", "$0 orphans undo-rename skills/new.md --from skills/old-a.md"]
9835
9913
  ]
9836
9914
  });
9837
- global = Option12.Boolean("-g,--global", false);
9838
- db = Option12.String("--db", { required: false });
9839
- newPath = Option12.String({ required: true });
9840
- from = Option12.String("--from", { required: false });
9841
- force = Option12.Boolean("--force", false);
9842
- dryRun = Option12.Boolean("-n,--dry-run", false);
9843
- quiet = Option12.Boolean("--quiet", false);
9844
- async execute() {
9845
- const elapsed = startElapsed();
9915
+ newPath = Option13.String({ required: true });
9916
+ from = Option13.String("--from", { required: false });
9917
+ force = Option13.Boolean("--force", false);
9918
+ dryRun = Option13.Boolean("-n,--dry-run", false);
9919
+ async run() {
9846
9920
  const dbPath = resolveDbPath({ global: this.global, db: this.db, ...defaultRuntimeContext() });
9847
9921
  if (!assertDbExists(dbPath, this.context.stderr)) return ExitCode.NotFound;
9848
9922
  return withSqlite({ databasePath: dbPath, autoBackup: false }, async (adapter) => {
@@ -9914,7 +9988,6 @@ var OrphansUndoRenameCommand = class extends Command12 {
9914
9988
  rows: summaryTotal(summary)
9915
9989
  })
9916
9990
  );
9917
- emitDoneStderr(this.context.stderr, elapsed, this.quiet);
9918
9991
  return ExitCode.Ok;
9919
9992
  });
9920
9993
  }
@@ -10008,7 +10081,7 @@ var ORPHANS_COMMANDS = [
10008
10081
  // cli/commands/plugins.ts
10009
10082
  import { existsSync as existsSync13 } from "fs";
10010
10083
  import { join as join12, resolve as resolve16 } from "path";
10011
- import { Command as Command13, Option as Option13 } from "clipanion";
10084
+ import { Command as Command14, Option as Option14 } from "clipanion";
10012
10085
 
10013
10086
  // cli/i18n/plugins.texts.ts
10014
10087
  var PLUGINS_TEXTS = {
@@ -10151,17 +10224,15 @@ function builtInRows(resolveEnabled) {
10151
10224
  };
10152
10225
  });
10153
10226
  }
10154
- var PluginsListCommand = class extends Command13 {
10227
+ var PluginsListCommand = class extends SmCommand {
10155
10228
  static paths = [["plugins", "list"]];
10156
- static usage = Command13.Usage({
10229
+ static usage = Command14.Usage({
10157
10230
  category: "Plugins",
10158
10231
  description: "List discovered plugins and their load status.",
10159
10232
  details: "Scans <scope>/.skill-map/plugins and ~/.skill-map/plugins (or --plugin-dir <path>). Built-in bundles (claude, core) are listed alongside user plugins."
10160
10233
  });
10161
- global = Option13.Boolean("-g,--global", false);
10162
- pluginDir = Option13.String("--plugin-dir", { required: false });
10163
- json = Option13.Boolean("--json", false);
10164
- async execute() {
10234
+ pluginDir = Option14.String("--plugin-dir", { required: false });
10235
+ async run() {
10165
10236
  const plugins = await loadAll({ global: this.global, pluginDir: this.pluginDir });
10166
10237
  const resolveEnabled = await buildResolver(this.global);
10167
10238
  const builtIns2 = builtInRows(resolveEnabled);
@@ -10220,24 +10291,24 @@ function renderPluginRow(p) {
10220
10291
  tail
10221
10292
  }) + "\n";
10222
10293
  }
10223
- var PluginsShowCommand = class extends Command13 {
10294
+ var PluginsShowCommand = class extends SmCommand {
10224
10295
  static paths = [["plugins", "show"]];
10225
- static usage = Command13.Usage({
10296
+ static usage = Command14.Usage({
10226
10297
  category: "Plugins",
10227
10298
  description: "Show a single plugin's manifest + loaded extensions."
10228
10299
  });
10229
- id = Option13.String({ required: true });
10230
- global = Option13.Boolean("-g,--global", false);
10231
- pluginDir = Option13.String("--plugin-dir", { required: false });
10232
- json = Option13.Boolean("--json", false);
10233
- async execute() {
10300
+ id = Option14.String({ required: true });
10301
+ pluginDir = Option14.String("--plugin-dir", { required: false });
10302
+ async run() {
10234
10303
  const plugins = await loadAll({ global: this.global, pluginDir: this.pluginDir });
10235
10304
  const resolveEnabled = await buildResolver(this.global);
10236
10305
  const builtIns2 = builtInRows(resolveEnabled);
10237
10306
  const builtIn = builtIns2.find((b) => b.id === this.id);
10238
10307
  const match = plugins.find((p) => p.id === this.id);
10239
10308
  if (!builtIn && !match) {
10240
- this.context.stderr.write(tx(PLUGINS_TEXTS.pluginNotFound, { id: this.id }) + "\n");
10309
+ this.context.stderr.write(
10310
+ tx(PLUGINS_TEXTS.pluginNotFound, { id: sanitizeForTerminal(this.id) }) + "\n"
10311
+ );
10241
10312
  return ExitCode.NotFound;
10242
10313
  }
10243
10314
  if (this.json) {
@@ -10423,15 +10494,14 @@ function collectExplorationDirWarnings(plugins, homedir2) {
10423
10494
  });
10424
10495
  return out;
10425
10496
  }
10426
- var PluginsDoctorCommand = class extends Command13 {
10497
+ var PluginsDoctorCommand = class extends SmCommand {
10427
10498
  static paths = [["plugins", "doctor"]];
10428
- static usage = Command13.Usage({
10499
+ static usage = Command14.Usage({
10429
10500
  category: "Plugins",
10430
10501
  description: "Run the full load pass and summarise by failure mode.",
10431
10502
  details: "Exit code 0 when every plugin loads or is intentionally disabled; 1 when any plugin is in an error / incompat state."
10432
10503
  });
10433
- global = Option13.Boolean("-g,--global", false);
10434
- pluginDir = Option13.String("--plugin-dir", { required: false });
10504
+ pluginDir = Option14.String("--plugin-dir", { required: false });
10435
10505
  // Doctor verb: counts by status + applicableKinds warnings +
10436
10506
  // explorationDir warnings + bad-plugins issues, each with its own
10437
10507
  // gated render. Branching is intrinsic to the multi-section diagnostic
@@ -10439,7 +10509,7 @@ var PluginsDoctorCommand = class extends Command13 {
10439
10509
  // `collectApplicableKindWarnings`, `collectExplorationDirWarnings`)
10440
10510
  // already encapsulate the data gathering.
10441
10511
  // eslint-disable-next-line complexity
10442
- async execute() {
10512
+ async run() {
10443
10513
  const plugins = await loadAll({ global: this.global, pluginDir: this.pluginDir });
10444
10514
  const resolveEnabled = await buildResolver(this.global);
10445
10515
  const builtIns2 = builtInRows(resolveEnabled);
@@ -10548,17 +10618,25 @@ function resolveToggleTarget(id, catalogue, verb) {
10548
10618
  if (id.includes("/")) {
10549
10619
  const [bundleId, extId, ...rest] = id.split("/");
10550
10620
  if (!bundleId || !extId || rest.length > 0) {
10551
- return { error: tx(PLUGINS_TEXTS.qualifiedIdUnknownBundle, { bundleId: id }) };
10621
+ return {
10622
+ error: tx(PLUGINS_TEXTS.qualifiedIdUnknownBundle, {
10623
+ bundleId: sanitizeForTerminal(id)
10624
+ })
10625
+ };
10552
10626
  }
10553
10627
  const bundle2 = catalogue.find((b) => b.id === bundleId);
10554
10628
  if (!bundle2) {
10555
- return { error: tx(PLUGINS_TEXTS.qualifiedIdUnknownBundle, { bundleId }) };
10629
+ return {
10630
+ error: tx(PLUGINS_TEXTS.qualifiedIdUnknownBundle, {
10631
+ bundleId: sanitizeForTerminal(bundleId)
10632
+ })
10633
+ };
10556
10634
  }
10557
10635
  if (bundle2.granularity === "bundle") {
10558
10636
  return {
10559
10637
  error: tx(PLUGINS_TEXTS.granularityBundleRejectsQualified, {
10560
- bundleId,
10561
- extId,
10638
+ bundleId: sanitizeForTerminal(bundleId),
10639
+ extId: sanitizeForTerminal(extId),
10562
10640
  verb
10563
10641
  })
10564
10642
  };
@@ -10566,9 +10644,9 @@ function resolveToggleTarget(id, catalogue, verb) {
10566
10644
  if (!bundle2.extensionIds.includes(extId)) {
10567
10645
  return {
10568
10646
  error: tx(PLUGINS_TEXTS.qualifiedIdNotFound, {
10569
- id,
10570
- bundleId,
10571
- extId
10647
+ id: sanitizeForTerminal(id),
10648
+ bundleId: sanitizeForTerminal(bundleId),
10649
+ extId: sanitizeForTerminal(extId)
10572
10650
  })
10573
10651
  };
10574
10652
  }
@@ -10576,34 +10654,30 @@ function resolveToggleTarget(id, catalogue, verb) {
10576
10654
  }
10577
10655
  const bundle = catalogue.find((b) => b.id === id);
10578
10656
  if (!bundle) {
10579
- return { error: tx(PLUGINS_TEXTS.pluginNotFound, { id }) };
10657
+ return { error: tx(PLUGINS_TEXTS.pluginNotFound, { id: sanitizeForTerminal(id) }) };
10580
10658
  }
10581
10659
  if (bundle.granularity === "extension") {
10582
10660
  return {
10583
10661
  error: tx(PLUGINS_TEXTS.granularityExtensionRejectsBundleId, {
10584
- bundleId: id,
10662
+ bundleId: sanitizeForTerminal(id),
10585
10663
  verb
10586
10664
  })
10587
10665
  };
10588
10666
  }
10589
10667
  return { key: bundle.id };
10590
10668
  }
10591
- var TogglePluginsBase = class extends Command13 {
10592
- global = Option13.Boolean("-g,--global", false);
10593
- all = Option13.Boolean("--all", false);
10594
- id = Option13.String({ required: false });
10669
+ var TogglePluginsBase = class extends SmCommand {
10670
+ all = Option14.Boolean("--all", false);
10671
+ id = Option14.String({ required: false });
10595
10672
  // eslint-disable-next-line complexity
10596
10673
  async toggle(enabled) {
10597
- const elapsed = startElapsed();
10598
10674
  const verb = enabled ? "enable" : "disable";
10599
10675
  if (this.all && this.id) {
10600
10676
  this.context.stderr.write(PLUGINS_TEXTS.toggleBothIdAndAll);
10601
- emitDoneStderr(this.context.stderr, elapsed);
10602
10677
  return ExitCode.Error;
10603
10678
  }
10604
10679
  if (!this.all && !this.id) {
10605
10680
  this.context.stderr.write(PLUGINS_TEXTS.toggleNeitherIdNorAll);
10606
- emitDoneStderr(this.context.stderr, elapsed);
10607
10681
  return ExitCode.Error;
10608
10682
  }
10609
10683
  const plugins = await loadAll({
@@ -10618,7 +10692,6 @@ var TogglePluginsBase = class extends Command13 {
10618
10692
  const resolved = resolveToggleTarget(this.id, catalogue, verb);
10619
10693
  if ("error" in resolved) {
10620
10694
  this.context.stderr.write(tx(PLUGINS_TEXTS.toggleResolveError, { error: resolved.error }));
10621
- emitDoneStderr(this.context.stderr, elapsed);
10622
10695
  return ExitCode.NotFound;
10623
10696
  }
10624
10697
  targets = [resolved.key];
@@ -10641,13 +10714,12 @@ var TogglePluginsBase = class extends Command13 {
10641
10714
  this.context.stdout.write(tx(PLUGINS_TEXTS.toggleAppliedManyRow, { id }));
10642
10715
  }
10643
10716
  }
10644
- emitDoneStderr(this.context.stderr, elapsed);
10645
10717
  return ExitCode.Ok;
10646
10718
  }
10647
10719
  };
10648
10720
  var PluginsEnableCommand = class extends TogglePluginsBase {
10649
10721
  static paths = [["plugins", "enable"]];
10650
- static usage = Command13.Usage({
10722
+ static usage = Command14.Usage({
10651
10723
  category: "Plugins",
10652
10724
  description: "Enable a plugin (or --all). Persists in config_plugins.",
10653
10725
  details: `
@@ -10663,13 +10735,13 @@ var PluginsEnableCommand = class extends TogglePluginsBase {
10663
10735
  with directed guidance.
10664
10736
  `
10665
10737
  });
10666
- async execute() {
10738
+ async run() {
10667
10739
  return this.toggle(true);
10668
10740
  }
10669
10741
  };
10670
10742
  var PluginsDisableCommand = class extends TogglePluginsBase {
10671
10743
  static paths = [["plugins", "disable"]];
10672
- static usage = Command13.Usage({
10744
+ static usage = Command14.Usage({
10673
10745
  category: "Plugins",
10674
10746
  description: "Disable a plugin (or --all). Persists in config_plugins; does not delete files.",
10675
10747
  details: `
@@ -10685,7 +10757,7 @@ var PluginsDisableCommand = class extends TogglePluginsBase {
10685
10757
  with directed guidance.
10686
10758
  `
10687
10759
  });
10688
- async execute() {
10760
+ async run() {
10689
10761
  return this.toggle(false);
10690
10762
  }
10691
10763
  };
@@ -10706,7 +10778,7 @@ var PLUGIN_COMMANDS = [
10706
10778
  // cli/commands/refresh.ts
10707
10779
  import { readFile as readFile3 } from "fs/promises";
10708
10780
  import { resolve as resolve18 } from "path";
10709
- import { Command as Command14, Option as Option14 } from "clipanion";
10781
+ import { Command as Command15, Option as Option15 } from "clipanion";
10710
10782
 
10711
10783
  // cli/i18n/refresh.texts.ts
10712
10784
  var REFRESH_TEXTS = {
@@ -10747,9 +10819,9 @@ function assertContained2(cwd, rel) {
10747
10819
  }
10748
10820
 
10749
10821
  // cli/commands/refresh.ts
10750
- var RefreshCommand = class extends Command14 {
10822
+ var RefreshCommand = class extends SmCommand {
10751
10823
  static paths = [["refresh"]];
10752
- static usage = Command14.Usage({
10824
+ static usage = Command15.Usage({
10753
10825
  category: "Scan",
10754
10826
  description: "Refresh enrichment rows: granular (single node) or batch (every stale row).",
10755
10827
  details: `
@@ -10774,11 +10846,11 @@ var RefreshCommand = class extends Command14 {
10774
10846
  ["Refresh every node with stale enrichments", "$0 refresh --stale"]
10775
10847
  ]
10776
10848
  });
10777
- nodePath = Option14.String({ name: "node", required: false });
10778
- stale = Option14.Boolean("--stale", false, {
10849
+ nodePath = Option15.String({ name: "node", required: false });
10850
+ stale = Option15.Boolean("--stale", false, {
10779
10851
  description: "Refresh every node whose probabilistic enrichment row is flagged stale=1."
10780
10852
  });
10781
- noPlugins = Option14.Boolean("--no-plugins", false, {
10853
+ noPlugins = Option15.Boolean("--no-plugins", false, {
10782
10854
  description: "Skip drop-in plugin discovery; use only the built-in extractor set."
10783
10855
  });
10784
10856
  // The remaining cyclomatic count comes from CLI ergonomics that don't
@@ -10787,7 +10859,7 @@ var RefreshCommand = class extends Command14 {
10787
10859
  // catch, plus the `if (probSkipCount > 0)` advisory. The inner work
10788
10860
  // already lives in `#resolveTargetNodes` and `#runDetExtractorsAcrossNodes`.
10789
10861
  // eslint-disable-next-line complexity
10790
- async execute() {
10862
+ async run() {
10791
10863
  if (this.stale && this.nodePath !== void 0) {
10792
10864
  this.context.stderr.write(REFRESH_TEXTS.nodeAndStaleMutex);
10793
10865
  return ExitCode.Error;
@@ -10963,7 +11035,7 @@ function stripFrontmatterFence(text) {
10963
11035
  var REFRESH_COMMANDS = [RefreshCommand];
10964
11036
 
10965
11037
  // cli/commands/scan.ts
10966
- import { Command as Command16, Option as Option16 } from "clipanion";
11038
+ import { Command as Command17, Option as Option17 } from "clipanion";
10967
11039
 
10968
11040
  // cli/i18n/scan.texts.ts
10969
11041
  var SCAN_TEXTS = {
@@ -11006,8 +11078,156 @@ var SCAN_TEXTS = {
11006
11078
  compareDeltaIssueRemoved: "- [{{severity}}] {{ruleId}}: {{message}}"
11007
11079
  };
11008
11080
 
11009
- // cli/commands/watch.ts
11010
- import { Command as Command15, Option as Option15 } from "clipanion";
11081
+ // cli/util/scan-runner.ts
11082
+ async function runScanForCommand(opts) {
11083
+ const ctx = opts.ctx ?? defaultRuntimeContext();
11084
+ const dbPath = defaultProjectDbPath(ctx);
11085
+ const kernel = createKernel();
11086
+ const pluginRuntime = await preparePluginRuntime(opts);
11087
+ const extensions = registerExtensions(kernel, pluginRuntime, opts);
11088
+ let cfg;
11089
+ try {
11090
+ cfg = loadConfig({ scope: "project", strict: opts.strict, ...ctx }).effective;
11091
+ } catch (err) {
11092
+ return { kind: "config-error", message: formatErrorMessage(err) };
11093
+ }
11094
+ const ignoreFilter = buildScanIgnoreFilter(cfg, ctx.cwd);
11095
+ const strict = opts.strict || cfg.scan.strict === true;
11096
+ const loadPrior = makePriorLoader(opts.noBuiltIns, strict);
11097
+ const runScanWith = makeScanRunner(kernel, opts, ignoreFilter, strict, extensions);
11098
+ const willPersist = !opts.noBuiltIns && !opts.dryRun;
11099
+ return willPersist ? runPersistPath(opts, dbPath, strict, loadPrior, runScanWith) : runEphemeralPath(opts, dbPath, strict, loadPrior, runScanWith);
11100
+ }
11101
+ async function preparePluginRuntime(opts) {
11102
+ const pluginRuntime = opts.noPlugins ? emptyPluginRuntime() : await loadPluginRuntime({ scope: "project" });
11103
+ for (const warn of pluginRuntime.warnings) opts.stderr.write(`${warn}
11104
+ `);
11105
+ return pluginRuntime;
11106
+ }
11107
+ function registerExtensions(kernel, pluginRuntime, opts) {
11108
+ const extensions = composeScanExtensions({
11109
+ noBuiltIns: opts.noBuiltIns,
11110
+ pluginRuntime
11111
+ });
11112
+ if (!opts.noBuiltIns) {
11113
+ const enabledBuiltIns = filterBuiltInManifests(listBuiltIns(), pluginRuntime.resolveEnabled);
11114
+ for (const manifest of enabledBuiltIns) kernel.registry.register(manifest);
11115
+ }
11116
+ for (const manifest of pluginRuntime.manifests) kernel.registry.register(manifest);
11117
+ return extensions;
11118
+ }
11119
+ function buildScanIgnoreFilter(cfg, cwd) {
11120
+ const ignoreFileText = readIgnoreFileText(cwd);
11121
+ const ignoreFilterOpts = {};
11122
+ if (cfg.ignore.length > 0) ignoreFilterOpts.configIgnore = cfg.ignore;
11123
+ if (ignoreFileText !== void 0) ignoreFilterOpts.ignoreFileText = ignoreFileText;
11124
+ return buildIgnoreFilter(ignoreFilterOpts);
11125
+ }
11126
+ function makePriorLoader(noBuiltIns, strict) {
11127
+ return async (adapter) => {
11128
+ if (noBuiltIns) return null;
11129
+ const loaded = await adapter.scans.load();
11130
+ if (loaded.nodes.length === 0) return null;
11131
+ if (strict) {
11132
+ const validators = loadSchemaValidators();
11133
+ const result = validators.validate("scan-result", loaded);
11134
+ if (!result.ok) {
11135
+ throw new Error(tx(SCAN_TEXTS.priorSchemaValidationFailed, { errors: result.errors }));
11136
+ }
11137
+ }
11138
+ return loaded;
11139
+ };
11140
+ }
11141
+ function makeScanRunner(kernel, opts, ignoreFilter, strict, extensions) {
11142
+ return async (prior, priorExtractorRuns) => {
11143
+ if (opts.changed && prior === null) {
11144
+ opts.stderr.write(SCAN_TEXTS.changedNoPriorWarning);
11145
+ }
11146
+ const runOptions = {
11147
+ roots: opts.roots,
11148
+ // Hardcoded `'project'`: spec § Global flags lists `-g/--global`
11149
+ // as universal, but the per-verb § Scan table does not list it
11150
+ // and the semantics of "scan global" are undefined. The
11151
+ // ScanCommand surface accepts `-g` (inherited from SmCommand)
11152
+ // but ignores it here. Wire to `opts.scope` once spec defines
11153
+ // the contract.
11154
+ scope: "project",
11155
+ tokenize: !opts.noTokens,
11156
+ ignoreFilter,
11157
+ strict,
11158
+ emitter: createCliProgressEmitter(opts.stderr)
11159
+ };
11160
+ if (extensions) runOptions.extensions = extensions;
11161
+ if (prior) {
11162
+ runOptions.priorSnapshot = prior;
11163
+ runOptions.enableCache = opts.changed;
11164
+ }
11165
+ if (priorExtractorRuns) runOptions.priorExtractorRuns = priorExtractorRuns;
11166
+ return runScanWithRenames(kernel, runOptions);
11167
+ };
11168
+ }
11169
+ async function runPersistPath(opts, dbPath, strict, loadPrior, runScanWith) {
11170
+ let outcome;
11171
+ try {
11172
+ outcome = await withSqlite({ databasePath: dbPath }, async (adapter) => {
11173
+ const prior = await loadPrior(adapter);
11174
+ const priorExtractorRuns = opts.changed && prior ? await adapter.scans.loadExtractorRuns() : void 0;
11175
+ let scanned;
11176
+ try {
11177
+ scanned = await runScanWith(prior, priorExtractorRuns);
11178
+ } catch (err) {
11179
+ return { kind: "scan-error", message: formatErrorMessage(err) };
11180
+ }
11181
+ if (scanned.result.stats.nodesCount === 0 && !opts.allowEmpty) {
11182
+ const counts = await adapter.scans.countRows();
11183
+ const existing = counts.nodes + counts.links + counts.issues;
11184
+ if (existing > 0) return { kind: "guard", existing };
11185
+ }
11186
+ await adapter.scans.persist(scanned.result, {
11187
+ renameOps: scanned.renameOps,
11188
+ extractorRuns: scanned.extractorRuns,
11189
+ enrichments: scanned.enrichments
11190
+ });
11191
+ return { kind: "ok", ...scanned };
11192
+ });
11193
+ } catch (err) {
11194
+ return { kind: "scan-error", message: formatErrorMessage(err) };
11195
+ }
11196
+ if (outcome.kind === "scan-error") return outcome;
11197
+ if (outcome.kind === "guard") return { kind: "guard-trip", existing: outcome.existing };
11198
+ return {
11199
+ kind: "ok",
11200
+ result: outcome.result,
11201
+ renameOps: outcome.renameOps,
11202
+ persistedTo: dbPath,
11203
+ dbPath,
11204
+ strict
11205
+ };
11206
+ }
11207
+ async function runEphemeralPath(opts, dbPath, strict, loadPrior, runScanWith) {
11208
+ let prior;
11209
+ try {
11210
+ prior = opts.noBuiltIns ? null : await tryWithSqlite({ databasePath: dbPath, autoBackup: false }, loadPrior);
11211
+ } catch (err) {
11212
+ return { kind: "scan-error", message: formatErrorMessage(err) };
11213
+ }
11214
+ try {
11215
+ const scanned = await runScanWith(prior);
11216
+ return {
11217
+ kind: "ok",
11218
+ result: scanned.result,
11219
+ renameOps: scanned.renameOps,
11220
+ persistedTo: null,
11221
+ dbPath,
11222
+ strict
11223
+ };
11224
+ } catch (err) {
11225
+ return { kind: "scan-error", message: formatErrorMessage(err) };
11226
+ }
11227
+ }
11228
+
11229
+ // cli/commands/watch.ts
11230
+ import { Command as Command16, Option as Option16 } from "clipanion";
11011
11231
 
11012
11232
  // cli/i18n/watch.texts.ts
11013
11233
  var WATCH_TEXTS = {
@@ -11020,10 +11240,12 @@ var WATCH_TEXTS = {
11020
11240
  ready: "sm watch: ready. Press Ctrl+C to stop.\n",
11021
11241
  stopped: "sm watch: stopped after {{batchCount}} batch(es).\n",
11022
11242
  scannedSummary: "scanned {{nodes}} nodes / {{links}} links / {{issues}} issues in {{durationMs}}ms\n",
11023
- priorSchemaValidationFailed: "prior scan-result loaded from DB failed schema validation: {{errors}}"
11243
+ priorSchemaValidationFailed: "prior scan-result loaded from DB failed schema validation: {{errors}}",
11244
+ breakerTripped: "sm watch: {{count}} consecutive batch failures \u2014 shutting down. Last error: {{message}}\n"
11024
11245
  };
11025
11246
 
11026
11247
  // cli/commands/watch.ts
11248
+ var DEFAULT_MAX_CONSECUTIVE_FAILURES = 5;
11027
11249
  async function runWatchLoop(opts) {
11028
11250
  const { context } = opts;
11029
11251
  const runtimeCtx = defaultRuntimeContext();
@@ -11087,21 +11309,8 @@ async function runWatchLoop(opts) {
11087
11309
  runOptions.enableCache = true;
11088
11310
  }
11089
11311
  if (priorExtractorRuns) runOptions.priorExtractorRuns = priorExtractorRuns;
11090
- let result;
11091
- let renameOps;
11092
- let extractorRuns;
11093
- let enrichments;
11094
- try {
11095
- const ran = await runScanWithRenames(kernel, runOptions);
11096
- result = ran.result;
11097
- renameOps = ran.renameOps;
11098
- extractorRuns = ran.extractorRuns;
11099
- enrichments = ran.enrichments;
11100
- } catch (err) {
11101
- const message = formatErrorMessage(err);
11102
- context.stderr.write(tx(WATCH_TEXTS.scanFailed, { message }));
11103
- return;
11104
- }
11312
+ const ran = await runScanWithRenames(kernel, runOptions);
11313
+ const { result, renameOps, extractorRuns, enrichments } = ran;
11105
11314
  await withSqlite(
11106
11315
  { databasePath: dbPath },
11107
11316
  (writer) => writer.scans.persist(result, { renameOps, extractorRuns, enrichments })
@@ -11135,21 +11344,38 @@ async function runWatchLoop(opts) {
11135
11344
  const stopped = new Promise((r) => {
11136
11345
  stopResolve = r;
11137
11346
  });
11347
+ const breakerLimit = opts.maxConsecutiveFailures ?? DEFAULT_MAX_CONSECUTIVE_FAILURES;
11348
+ let consecutiveFailures = 0;
11349
+ let exitCode2 = ExitCode.Ok;
11350
+ const handleBatch = async () => {
11351
+ if (stopRequested) return "stop";
11352
+ batchCount++;
11353
+ try {
11354
+ await runOnePass();
11355
+ consecutiveFailures = 0;
11356
+ } catch (err) {
11357
+ const message = formatErrorMessage(err);
11358
+ context.stderr.write(tx(WATCH_TEXTS.batchFailed, { message }));
11359
+ consecutiveFailures += 1;
11360
+ if (breakerLimit > 0 && consecutiveFailures >= breakerLimit) {
11361
+ context.stderr.write(
11362
+ tx(WATCH_TEXTS.breakerTripped, { count: consecutiveFailures, message })
11363
+ );
11364
+ exitCode2 = ExitCode.Error;
11365
+ return "stop";
11366
+ }
11367
+ }
11368
+ if (opts.maxBatches !== void 0 && batchCount >= opts.maxBatches) return "stop";
11369
+ return "continue";
11370
+ };
11138
11371
  const watcher = createChokidarWatcher({
11139
11372
  roots: opts.roots,
11140
11373
  cwd,
11141
11374
  debounceMs,
11142
11375
  ignoreFilter,
11143
11376
  onBatch: async () => {
11144
- if (stopRequested) return;
11145
- batchCount++;
11146
- try {
11147
- await runOnePass();
11148
- } catch (err) {
11149
- const message = formatErrorMessage(err);
11150
- context.stderr.write(tx(WATCH_TEXTS.batchFailed, { message }));
11151
- }
11152
- if (opts.maxBatches !== void 0 && batchCount >= opts.maxBatches) {
11377
+ const next = await handleBatch();
11378
+ if (next === "stop") {
11153
11379
  stopRequested = true;
11154
11380
  stopResolve?.();
11155
11381
  }
@@ -11176,11 +11402,11 @@ async function runWatchLoop(opts) {
11176
11402
  if (!opts.json) {
11177
11403
  context.stderr.write(tx(WATCH_TEXTS.stopped, { batchCount }));
11178
11404
  }
11179
- return ExitCode.Ok;
11405
+ return exitCode2;
11180
11406
  }
11181
- var WatchCommand = class extends Command15 {
11407
+ var WatchCommand = class extends SmCommand {
11182
11408
  static paths = [["watch"]];
11183
- static usage = Command15.Usage({
11409
+ static usage = Command16.Usage({
11184
11410
  category: "Scan",
11185
11411
  description: "Watch roots and run an incremental scan after each debounced batch of filesystem events.",
11186
11412
  details: `
@@ -11204,36 +11430,55 @@ var WatchCommand = class extends Command15 {
11204
11430
  ["Stream ScanResult per batch as ndjson", "$0 watch --json"]
11205
11431
  ]
11206
11432
  });
11207
- roots = Option15.Rest({ name: "roots" });
11208
- json = Option15.Boolean("--json", false, {
11209
- description: "Emit one ScanResult document per batch as ndjson on stdout."
11210
- });
11211
- noTokens = Option15.Boolean("--no-tokens", false, {
11433
+ roots = Option16.Rest({ name: "roots" });
11434
+ noTokens = Option16.Boolean("--no-tokens", false, {
11212
11435
  description: "Skip per-node token counts (cl100k_base BPE)."
11213
11436
  });
11214
- strict = Option15.Boolean("--strict", false, {
11437
+ strict = Option16.Boolean("--strict", false, {
11215
11438
  description: "Promote frontmatter-validation findings from warn to error inside each batch. Does not change the watcher exit code."
11216
11439
  });
11217
- noPlugins = Option15.Boolean("--no-plugins", false, {
11440
+ noPlugins = Option16.Boolean("--no-plugins", false, {
11218
11441
  description: "Skip drop-in plugin discovery for the watcher session."
11219
11442
  });
11220
- async execute() {
11443
+ maxConsecutiveFailures = Option16.String("--max-consecutive-failures", {
11444
+ required: false,
11445
+ description: "Shut down with exit 2 after N consecutive batch failures (default 5; 0 disables the breaker)."
11446
+ });
11447
+ // Long-running verb — the watcher prints its own "stopped" line on
11448
+ // SIGINT / SIGTERM. Adding `done in <…>` after that would be noise.
11449
+ emitElapsed = false;
11450
+ async run() {
11221
11451
  const roots = this.roots.length > 0 ? this.roots : ["."];
11222
- return runWatchLoop({
11452
+ const breaker = parseBreakerLimit(this.maxConsecutiveFailures, this.context.stderr);
11453
+ if (breaker === null) return ExitCode.Error;
11454
+ const watchOpts = {
11223
11455
  roots,
11224
11456
  json: this.json,
11225
11457
  noTokens: this.noTokens,
11226
11458
  strict: this.strict,
11227
11459
  noPlugins: this.noPlugins,
11228
11460
  context: this.context
11229
- });
11461
+ };
11462
+ if (breaker !== void 0) watchOpts.maxConsecutiveFailures = breaker;
11463
+ return runWatchLoop(watchOpts);
11230
11464
  }
11231
11465
  };
11466
+ function parseBreakerLimit(raw, stderr) {
11467
+ if (raw === void 0) return void 0;
11468
+ const trimmed = raw.trim();
11469
+ const parsed = Number.parseInt(trimmed, 10);
11470
+ if (!Number.isInteger(parsed) || parsed < 0 || String(parsed) !== trimmed) {
11471
+ stderr.write(`sm watch: --max-consecutive-failures must be a non-negative integer (got ${raw})
11472
+ `);
11473
+ return null;
11474
+ }
11475
+ return parsed;
11476
+ }
11232
11477
 
11233
11478
  // cli/commands/scan.ts
11234
- var ScanCommand = class extends Command16 {
11479
+ var ScanCommand = class extends SmCommand {
11235
11480
  static paths = [["scan"]];
11236
- static usage = Command16.Usage({
11481
+ static usage = Command17.Usage({
11237
11482
  category: "Scan",
11238
11483
  description: "Scan roots for markdown nodes, run extractors and rules.",
11239
11484
  details: `
@@ -11262,198 +11507,87 @@ var ScanCommand = class extends Command16 {
11262
11507
  ["What would the next incremental scan persist?", "$0 scan --changed -n --json"]
11263
11508
  ]
11264
11509
  });
11265
- roots = Option16.Rest({ name: "roots" });
11266
- json = Option16.Boolean("--json", false, {
11267
- description: "Emit a machine-readable ScanResult document on stdout."
11268
- });
11269
- noBuiltIns = Option16.Boolean("--no-built-ins", false, {
11510
+ roots = Option17.Rest({ name: "roots" });
11511
+ noBuiltIns = Option17.Boolean("--no-built-ins", false, {
11270
11512
  description: "Skip the built-in extension set. Yields a zero-filled ScanResult (kernel-empty-boot parity); skips DB persistence."
11271
11513
  });
11272
- noPlugins = Option16.Boolean("--no-plugins", false, {
11514
+ noPlugins = Option17.Boolean("--no-plugins", false, {
11273
11515
  description: "Skip drop-in plugin discovery. Only the built-in set runs. Combine with --no-built-ins for a fully empty pipeline."
11274
11516
  });
11275
- noTokens = Option16.Boolean("--no-tokens", false, {
11517
+ noTokens = Option17.Boolean("--no-tokens", false, {
11276
11518
  description: "Skip per-node token counts (cl100k_base BPE). Leaves node.tokens undefined; spec-valid since the field is optional."
11277
11519
  });
11278
- dryRun = Option16.Boolean("-n,--dry-run", false, {
11520
+ dryRun = Option17.Boolean("-n,--dry-run", false, {
11279
11521
  description: "Run the scan in memory and skip every DB write. Combined with --changed, still opens the DB read-side to load the prior snapshot."
11280
11522
  });
11281
- changed = Option16.Boolean("--changed", false, {
11523
+ changed = Option17.Boolean("--changed", false, {
11282
11524
  description: "Incremental scan: reuse unchanged nodes from the persisted prior snapshot. Degrades to a full scan if no prior snapshot exists."
11283
11525
  });
11284
- allowEmpty = Option16.Boolean("--allow-empty", false, {
11526
+ allowEmpty = Option17.Boolean("--allow-empty", false, {
11285
11527
  description: "Allow a zero-result scan to wipe an already-populated DB (replace-all replace by zero rows). Off by default to avoid the typo-trap where an invalid root silently clears your data."
11286
11528
  });
11287
- strict = Option16.Boolean("--strict", false, {
11529
+ strict = Option17.Boolean("--strict", false, {
11288
11530
  description: "Promote frontmatter-validation findings from warn to error (exit code 1 on any violation). Overrides scan.strict from config when both are set."
11289
11531
  });
11290
- watch = Option16.Boolean("--watch", false, {
11532
+ watch = Option17.Boolean("--watch", false, {
11291
11533
  description: "Long-running mode: watch the roots and trigger an incremental scan after each debounced batch of filesystem events. Alias of `sm watch`."
11292
11534
  });
11293
- // The biggest CLI orchestrator — watch alias dispatch + flag combo
11294
- // checks + plugin runtime + config + ignore filter + prior-snapshot
11295
- // load + persist branch (with stranded-orphan guard) + dry-run branch
11296
- // + JSON / human render with strict self-validation. The core scan
11297
- // pipeline lives in `runScan` / `runScanWithRenames`; the
11298
- // `loadPrior` and `runScanWith` closures below encapsulate the DB +
11299
- // option wiring. Splitting per branch would scatter Clipanion's
11300
- // `this.<flag>` reads from the validations they shape.
11301
- // eslint-disable-next-line complexity
11302
- async execute() {
11303
- if (this.watch) {
11304
- if (this.noBuiltIns || this.dryRun || this.changed || this.allowEmpty) {
11305
- this.context.stderr.write(SCAN_TEXTS.watchCannotCombine);
11306
- return ExitCode.Error;
11307
- }
11308
- const roots2 = this.roots.length > 0 ? this.roots : ["."];
11309
- return runWatchLoop({
11310
- roots: roots2,
11311
- json: this.json,
11312
- noTokens: this.noTokens,
11313
- strict: this.strict,
11314
- noPlugins: this.noPlugins,
11315
- context: this.context
11316
- });
11317
- }
11535
+ async run() {
11536
+ if (this.watch) return this.runWatchAlias();
11318
11537
  if (this.changed && this.noBuiltIns) {
11319
11538
  this.context.stderr.write(SCAN_TEXTS.changedWithoutBuiltIns);
11320
11539
  return ExitCode.Error;
11321
11540
  }
11322
- const kernel = createKernel();
11323
11541
  const roots = this.roots.length > 0 ? this.roots : ["."];
11324
- const pluginRuntime = this.noPlugins ? emptyPluginRuntime() : await loadPluginRuntime({ scope: "project" });
11325
- for (const warn of pluginRuntime.warnings) {
11326
- this.context.stderr.write(`${warn}
11327
- `);
11328
- }
11329
- const extensions = composeScanExtensions({
11542
+ const outcome = await runScanForCommand({
11543
+ roots,
11330
11544
  noBuiltIns: this.noBuiltIns,
11331
- pluginRuntime
11545
+ noPlugins: this.noPlugins,
11546
+ noTokens: this.noTokens,
11547
+ dryRun: this.dryRun,
11548
+ changed: this.changed,
11549
+ allowEmpty: this.allowEmpty,
11550
+ strict: this.strict,
11551
+ stderr: this.context.stderr
11332
11552
  });
11333
- if (!this.noBuiltIns) {
11334
- const enabledBuiltIns = filterBuiltInManifests(listBuiltIns(), pluginRuntime.resolveEnabled);
11335
- for (const manifest of enabledBuiltIns) kernel.registry.register(manifest);
11336
- }
11337
- for (const manifest of pluginRuntime.manifests) kernel.registry.register(manifest);
11338
- const ctx = defaultRuntimeContext();
11339
- const dbPath = defaultProjectDbPath(ctx);
11340
- let cfg;
11341
- try {
11342
- cfg = loadConfig({ scope: "project", strict: this.strict, ...ctx }).effective;
11343
- } catch (err) {
11344
- const message = formatErrorMessage(err);
11345
- this.context.stderr.write(tx(SCAN_TEXTS.scanFailure, { message }));
11553
+ return outcome.kind === "ok" ? this.renderOutcome(outcome.result, outcome.persistedTo, outcome.dbPath, outcome.strict) : this.renderFailure(outcome);
11554
+ }
11555
+ /**
11556
+ * `--watch` is a thin alias for the `sm watch` verb. Combining
11557
+ * `--watch` with one-shot-only flags is incoherent — the watcher
11558
+ * always persists incrementally over the prior snapshot.
11559
+ */
11560
+ async runWatchAlias() {
11561
+ if (this.noBuiltIns || this.dryRun || this.changed || this.allowEmpty) {
11562
+ this.context.stderr.write(SCAN_TEXTS.watchCannotCombine);
11346
11563
  return ExitCode.Error;
11347
11564
  }
11348
- const ignoreFileText = readIgnoreFileText(ctx.cwd);
11349
- const ignoreFilterOpts = {};
11350
- if (cfg.ignore.length > 0) ignoreFilterOpts.configIgnore = cfg.ignore;
11351
- if (ignoreFileText !== void 0) ignoreFilterOpts.ignoreFileText = ignoreFileText;
11352
- const ignoreFilter = buildIgnoreFilter(ignoreFilterOpts);
11353
- const strict = this.strict || cfg.scan.strict === true;
11354
- const loadPrior = async (adapter) => {
11355
- if (this.noBuiltIns) return null;
11356
- const loaded = await adapter.scans.load();
11357
- if (loaded.nodes.length === 0) return null;
11358
- if (strict) {
11359
- const validators = loadSchemaValidators();
11360
- const result2 = validators.validate("scan-result", loaded);
11361
- if (!result2.ok) {
11362
- throw new Error(tx(SCAN_TEXTS.priorSchemaValidationFailed, { errors: result2.errors }));
11363
- }
11364
- }
11365
- return loaded;
11366
- };
11367
- const runScanWith = async (prior, priorExtractorRuns) => {
11368
- if (this.changed && prior === null) {
11369
- this.context.stderr.write(SCAN_TEXTS.changedNoPriorWarning);
11370
- }
11371
- const runOptions = {
11372
- roots,
11373
- // `--global` for `sm scan` lands in Step 6 (config + onboarding).
11374
- // The orchestrator already accepts the scope override; the CLI
11375
- // surface defaults to `'project'` until the flag is wired.
11376
- scope: "project",
11377
- tokenize: !this.noTokens,
11378
- ignoreFilter,
11379
- strict,
11380
- emitter: createCliProgressEmitter(this.context.stderr)
11381
- };
11382
- if (extensions) runOptions.extensions = extensions;
11383
- if (prior) {
11384
- runOptions.priorSnapshot = prior;
11385
- runOptions.enableCache = this.changed;
11386
- }
11387
- if (priorExtractorRuns) runOptions.priorExtractorRuns = priorExtractorRuns;
11388
- return await runScanWithRenames(kernel, runOptions);
11389
- };
11390
- const willPersist = !this.noBuiltIns && !this.dryRun;
11391
- let result;
11392
- let renameOps;
11393
- let persistedTo = null;
11394
- let outcome;
11395
- if (willPersist) {
11396
- try {
11397
- outcome = await withSqlite({ databasePath: dbPath }, async (adapter) => {
11398
- const prior = await loadPrior(adapter);
11399
- const priorExtractorRuns = this.changed && prior ? await adapter.scans.loadExtractorRuns() : void 0;
11400
- let scanned;
11401
- try {
11402
- scanned = await runScanWith(prior, priorExtractorRuns);
11403
- } catch (err) {
11404
- const message = formatErrorMessage(err);
11405
- return { kind: "scan-error", message };
11406
- }
11407
- if (scanned.result.stats.nodesCount === 0 && !this.allowEmpty) {
11408
- const counts = await adapter.scans.countRows();
11409
- const existing = counts.nodes + counts.links + counts.issues;
11410
- if (existing > 0) return { kind: "guard", existing };
11411
- }
11412
- await adapter.scans.persist(scanned.result, {
11413
- renameOps: scanned.renameOps,
11414
- extractorRuns: scanned.extractorRuns,
11415
- enrichments: scanned.enrichments
11416
- });
11417
- return { kind: "ok", ...scanned };
11418
- });
11419
- } catch (err) {
11420
- const message = formatErrorMessage(err);
11421
- this.context.stderr.write(tx(SCAN_TEXTS.scanFailure, { message }));
11422
- return ExitCode.Error;
11423
- }
11424
- if (outcome.kind === "scan-error") {
11425
- this.context.stderr.write(tx(SCAN_TEXTS.scanFailure, { message: outcome.message }));
11426
- return ExitCode.Error;
11427
- }
11428
- if (outcome.kind === "guard") {
11429
- this.context.stderr.write(tx(SCAN_TEXTS.guardWipeRefused, { existing: outcome.existing }));
11430
- return ExitCode.Error;
11431
- }
11432
- result = outcome.result;
11433
- renameOps = outcome.renameOps;
11434
- persistedTo = dbPath;
11435
- } else {
11436
- let prior;
11437
- try {
11438
- prior = this.noBuiltIns ? null : await tryWithSqlite(
11439
- { databasePath: dbPath, autoBackup: false },
11440
- loadPrior
11441
- );
11442
- } catch (err) {
11443
- const message = formatErrorMessage(err);
11444
- this.context.stderr.write(tx(SCAN_TEXTS.scanFailure, { message }));
11445
- return ExitCode.Error;
11446
- }
11447
- try {
11448
- const scanned = await runScanWith(prior);
11449
- result = scanned.result;
11450
- renameOps = scanned.renameOps;
11451
- } catch (err) {
11452
- const message = formatErrorMessage(err);
11453
- this.context.stderr.write(tx(SCAN_TEXTS.scanFailure, { message }));
11454
- return ExitCode.Error;
11455
- }
11565
+ this.emitElapsed = false;
11566
+ const roots = this.roots.length > 0 ? this.roots : ["."];
11567
+ return runWatchLoop({
11568
+ roots,
11569
+ json: this.json,
11570
+ noTokens: this.noTokens,
11571
+ strict: this.strict,
11572
+ noPlugins: this.noPlugins,
11573
+ context: this.context
11574
+ });
11575
+ }
11576
+ /** Render the failure branch of `IScanRunResult` to stderr. */
11577
+ renderFailure(outcome) {
11578
+ if (outcome.kind === "guard-trip") {
11579
+ this.context.stderr.write(tx(SCAN_TEXTS.guardWipeRefused, { existing: outcome.existing }));
11580
+ return ExitCode.Error;
11456
11581
  }
11582
+ this.context.stderr.write(tx(SCAN_TEXTS.scanFailure, { message: outcome.message }));
11583
+ return ExitCode.Error;
11584
+ }
11585
+ /**
11586
+ * Render the successful outcome to stdout (JSON or human) and compute
11587
+ * the exit code. Exit 1 only when at least one issue is at `error`
11588
+ * severity (mirrors `sm check`, per spec § Exit codes).
11589
+ */
11590
+ renderOutcome(result, persistedTo, dbPath, strict) {
11457
11591
  const exitCode2 = result.issues.some((i) => i.severity === "error") ? ExitCode.Issues : ExitCode.Ok;
11458
11592
  if (this.json) {
11459
11593
  if (strict) {
@@ -11494,10 +11628,10 @@ var ScanCommand = class extends Command16 {
11494
11628
 
11495
11629
  // cli/commands/scan-compare.ts
11496
11630
  import { existsSync as existsSync14, readFileSync as readFileSync11 } from "fs";
11497
- import { Command as Command17, Option as Option17 } from "clipanion";
11498
- var ScanCompareCommand = class extends Command17 {
11631
+ import { Command as Command18, Option as Option18 } from "clipanion";
11632
+ var ScanCompareCommand = class extends SmCommand {
11499
11633
  static paths = [["scan", "compare-with"]];
11500
- static usage = Command17.Usage({
11634
+ static usage = Command18.Usage({
11501
11635
  category: "Scan",
11502
11636
  description: "Run a fresh scan in memory and emit a delta against the saved ScanResult dump at <dump>. Read-only.",
11503
11637
  details: `
@@ -11525,18 +11659,15 @@ var ScanCompareCommand = class extends Command17 {
11525
11659
  ["JSON output for tooling", "$0 scan compare-with baseline.json --json"]
11526
11660
  ]
11527
11661
  });
11528
- dump = Option17.String({ required: true });
11529
- roots = Option17.Rest({ name: "roots" });
11530
- json = Option17.Boolean("--json", false, {
11531
- description: "Emit the IScanDelta document as JSON on stdout."
11532
- });
11533
- noTokens = Option17.Boolean("--no-tokens", false, {
11662
+ dump = Option18.String({ required: true });
11663
+ roots = Option18.Rest({ name: "roots" });
11664
+ noTokens = Option18.Boolean("--no-tokens", false, {
11534
11665
  description: "Skip per-node token counts during the fresh scan."
11535
11666
  });
11536
- strict = Option17.Boolean("--strict", false, {
11667
+ strict = Option18.Boolean("--strict", false, {
11537
11668
  description: "Promote layered-config warnings and frontmatter-validation findings from warn to error."
11538
11669
  });
11539
- noPlugins = Option17.Boolean("--no-plugins", false, {
11670
+ noPlugins = Option18.Boolean("--no-plugins", false, {
11540
11671
  description: "Skip drop-in plugin discovery."
11541
11672
  });
11542
11673
  // Cyclomatic count comes from CLI ergonomics: 3 distinct try/catch
@@ -11544,7 +11675,7 @@ var ScanCompareCommand = class extends Command17 {
11544
11675
  // for the JSON branch. The pure pieces already live in
11545
11676
  // `loadAndValidateDump` and `computeScanDelta`.
11546
11677
  // eslint-disable-next-line complexity
11547
- async execute() {
11678
+ async run() {
11548
11679
  const ctx = defaultRuntimeContext();
11549
11680
  const roots = this.roots.length > 0 ? this.roots : ["."];
11550
11681
  let prior;
@@ -11719,55 +11850,1768 @@ function renderDeltaIssues(issues) {
11719
11850
  return lines;
11720
11851
  }
11721
11852
 
11722
- // cli/commands/show.ts
11723
- import { Command as Command18, Option as Option18 } from "clipanion";
11853
+ // cli/commands/serve.ts
11854
+ import { spawn } from "child_process";
11855
+ import { existsSync as existsSync18 } from "fs";
11856
+ import { Command as Command19, Option as Option19 } from "clipanion";
11724
11857
 
11725
- // cli/i18n/show.texts.ts
11726
- var SHOW_TEXTS = {
11727
- nodeNotFound: "Node not found: {{nodePath}}\n",
11728
- // --- renderHuman labels ------------------------------------------------
11729
- sectionFrontmatter: "Frontmatter:",
11730
- sectionLinksOut: "Links out",
11731
- sectionLinksIn: "Links in",
11732
- sectionIssues: "Issues",
11733
- placeholderNone: " (none)",
11734
- sectionHeader: "{{label}} ({{count}}, {{unique}} unique):",
11735
- issuesHeader: "Issues ({{count}}):",
11736
- issueRow: " - [{{severity}}] {{ruleId}}: {{message}}",
11737
- // --- formatGroupedLink ------------------------------------------------
11738
- /**
11739
- * Bullet line for one grouped link in the in/out lists. `{{kind}}` and
11740
- * `{{endpoint}}` are pre-sanitized by the caller; `{{dup}}` is the
11741
- * `(×N)` count when the row collapses multiple identical edges, empty
11742
- * otherwise; `{{sources}}` is the trailing ` sources: a, b` segment
11743
- * (empty when the link has no sources).
11744
- */
11745
- groupedLinkHead: " - [{{kind}}/{{confidence}}] {{arrow}} {{endpoint}}{{dup}}{{sources}}",
11746
- groupedLinkDup: " (\xD7{{count}})",
11747
- groupedLinkSources: " sources: {{values}}",
11748
- // --- renderNodeHeader labels ------------------------------------------
11749
- nodeIdentity: "{{path}} [{{kind}}] (provider: {{provider}})",
11750
- nodeFieldTitle: "title: {{value}}",
11751
- nodeFieldDescription: "description: {{value}}",
11752
- nodeFieldStability: "stability: {{value}}",
11753
- nodeFieldVersion: "version: {{value}}",
11754
- nodeFieldAuthor: "author: {{value}}",
11755
- nodeWeight: "Weight: bytes {{total}} total / {{frontmatter}} frontmatter / {{body}} body",
11756
- nodeTokens: " tokens {{total}} total / {{frontmatter}} frontmatter / {{body}} body",
11757
- nodeExternalRefs: "External refs: {{count}}"
11758
- };
11858
+ // server/index.ts
11859
+ import { serve } from "@hono/node-server";
11860
+ import { WebSocketServer } from "ws";
11759
11861
 
11760
- // cli/commands/show.ts
11761
- var ShowCommand = class extends Command18 {
11762
- static paths = [["show"]];
11763
- static usage = Command18.Usage({
11764
- category: "Browse",
11765
- description: "Node detail: weight, frontmatter, links, issues, findings, summary.",
11766
- details: `
11862
+ // server/app.ts
11863
+ import { Hono } from "hono";
11864
+ import { HTTPException as HTTPException5 } from "hono/http-exception";
11865
+
11866
+ // server/routes/config.ts
11867
+ import { HTTPException } from "hono/http-exception";
11868
+
11869
+ // server/envelope.ts
11870
+ var REST_ENVELOPE_SCHEMA_VERSION = "1";
11871
+ function buildListEnvelope(opts) {
11872
+ const counts = {
11873
+ total: opts.total,
11874
+ returned: opts.items.length
11875
+ };
11876
+ if (opts.page) counts.page = opts.page;
11877
+ return {
11878
+ schemaVersion: REST_ENVELOPE_SCHEMA_VERSION,
11879
+ kind: opts.kind,
11880
+ items: opts.items,
11881
+ filters: opts.filters,
11882
+ counts,
11883
+ kindRegistry: opts.kindRegistry
11884
+ };
11885
+ }
11886
+ function buildValueEnvelope(kind, value, kindRegistry) {
11887
+ return {
11888
+ schemaVersion: REST_ENVELOPE_SCHEMA_VERSION,
11889
+ kind,
11890
+ value,
11891
+ kindRegistry
11892
+ };
11893
+ }
11894
+
11895
+ // server/routes/config.ts
11896
+ function registerConfigRoute(app, deps) {
11897
+ app.get("/api/config", (c) => {
11898
+ let loaded;
11899
+ try {
11900
+ loaded = loadConfig({
11901
+ scope: deps.options.scope,
11902
+ cwd: deps.runtimeContext.cwd,
11903
+ homedir: deps.runtimeContext.homedir
11904
+ });
11905
+ } catch (err) {
11906
+ throw new HTTPException(500, { message: formatErrorMessage(err) });
11907
+ }
11908
+ for (const warn of loaded.warnings) {
11909
+ process.stderr.write(`${warn}
11910
+ `);
11911
+ }
11912
+ return c.json(buildValueEnvelope("config", loaded.effective, deps.kindRegistry));
11913
+ });
11914
+ }
11915
+
11916
+ // server/routes/graph.ts
11917
+ import { HTTPException as HTTPException2 } from "hono/http-exception";
11918
+
11919
+ // server/i18n/server.texts.ts
11920
+ var SERVER_TEXTS = {
11921
+ // Boot banner — printed by the server itself when it begins to listen.
11922
+ // The CLI verb `sm serve` formats its own boot banner separately
11923
+ // (SERVE_TEXTS.boot) so the two surfaces can diverge if needed.
11924
+ listening: "skill-map server listening on http://{{host}}:{{port}}\n",
11925
+ // UI bundle missing — non-fatal when the path was auto-resolved (the
11926
+ // server keeps running with an inline placeholder at `/`). Becomes
11927
+ // ExitCode.Error when `--ui-dist <path>` was explicit.
11928
+ uiBundleMissing: 'skill-map server: UI bundle not found at {{path}} \u2014 serving inline placeholder at "/" (run "npm run build --workspace=ui" to populate).\n',
11929
+ // Loopback-only deprecation hint — Decision #119. Logged once at boot
11930
+ // when `--host` resolves to a non-loopback address. Multi-host serve
11931
+ // re-opens post-v0.6.0.
11932
+ 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",
11933
+ // Shutdown trace — printed by the close path so test runs that bring
11934
+ // the server up and down have a clear marker.
11935
+ closed: "skill-map server: closed.\n",
11936
+ // ---- error envelope messages (Step 14.2) ---------------------------------
11937
+ // Persisted scan absent and the route can't degrade to an empty result.
11938
+ // Hint nudges the user toward `sm scan` so the SPA can call it via the
11939
+ // CLI side-by-side with the server.
11940
+ dbMissingHint: "No persisted scan available at {{path}}. Run `sm scan` to populate the DB.",
11941
+ // `?fresh=1` was requested but the server was booted with --no-built-ins
11942
+ // or --no-plugins. A fresh scan with neither pipeline yields an empty /
11943
+ // partial result that would surprise the SPA. Reject up front.
11944
+ freshScanRequiresPipeline: "?fresh=1 cannot run while the server was started with --no-built-ins or --no-plugins (would yield empty / partial results).",
11945
+ // Unknown formatter on /api/graph — the user asked for a `format` value
11946
+ // that no registered formatter advertises. Mirrors `sm graph`'s message.
11947
+ graphUnknownFormat: 'Unknown graph format "{{format}}". Available: {{available}}.',
11948
+ // Pagination caps on /api/nodes.
11949
+ paginationLimitTooLarge: "limit={{value}} exceeds the maximum of {{max}}.",
11950
+ paginationInvalidInteger: "{{name}}={{value}} is not a non-negative integer.",
11951
+ // Node lookup miss on /api/nodes/:pathB64. Both the missing-node and
11952
+ // the malformed-pathB64 cases funnel here — the client experience is
11953
+ // the same (the resource isn't there).
11954
+ nodeNotFound: 'No node with path "{{path}}".',
11955
+ pathB64Malformed: "Malformed pathB64 \u2014 not a valid base64url-encoded node.path.",
11956
+ // ---- WS broadcaster + watcher (Step 14.4.a) ------------------------------
11957
+ // Logged once on watcher boot after chokidar's initial walk completes.
11958
+ // Marks the broadcaster as armed and the live event stream as flowing.
11959
+ watcherReady: 'skill-map server: watcher ready (roots="{{roots}}", debounceMs={{debounceMs}}).\n',
11960
+ // Watcher boot failure inside `createServer`. Non-fatal — the REST
11961
+ // surface stays alive so the operator can fix the underlying issue
11962
+ // (config, plugin, FS permission) and restart.
11963
+ watcherBootFailed: "skill-map server: watcher boot failed \u2014 {{message}}. /api/* still serving; pass --no-watcher to silence this on the next boot.\n",
11964
+ // Per-batch failure inside the watcher's scan+persist pipeline. The
11965
+ // watcher loop continues — a transient FS error must not kill the
11966
+ // broadcaster.
11967
+ watcherBatchFailed: "skill-map server: watcher batch failed \u2014 {{message}}.\n",
11968
+ // chokidar surfaced an error. The watcher stays open per IFsWatcher's
11969
+ // contract; the BFF also broadcasts a `watcher.error` advisory so the
11970
+ // SPA can surface it in the live event log.
11971
+ watcherError: "skill-map server: watcher error \u2014 {{message}}.\n",
11972
+ // chokidar.close() rejected during graceful shutdown. Logged but not
11973
+ // surfaced — close() is best-effort and idempotent.
11974
+ watcherCloseFailed: "skill-map server: watcher close failed \u2014 {{message}}.\n",
11975
+ // A connected client's outbound buffer exceeded the backpressure
11976
+ // threshold. The broadcaster closes the client with code 1009 and
11977
+ // unregisters it. Logged so operators can spot a wedged consumer.
11978
+ wsBackpressureEvicted: "skill-map server: ws client evicted (bufferedAmount={{buffered}} > threshold={{threshold}}).\n",
11979
+ // `WebSocket.send()` threw on a registered client. The client is
11980
+ // unregistered; the broadcast continues with the remaining clients.
11981
+ wsClientSendFailed: "skill-map server: ws send failed \u2014 {{message}}.\n",
11982
+ // `JSON.stringify(envelope)` threw inside `broadcast()`. The event is
11983
+ // dropped. Per spec/job-events.md §Error handling, the right shape
11984
+ // is a synthetic `emitter.error` event; v14.4.a does not yet route
11985
+ // it through the broadcaster (would re-enter the same stringify
11986
+ // path), so we degrade to a logged warning.
11987
+ wsBroadcastSerializeFailed: "skill-map server: ws broadcast dropped \u2014 failed to serialize event: {{message}}.\n"
11988
+ };
11989
+
11990
+ // server/routes/graph.ts
11991
+ var DEFAULT_FORMAT2 = "ascii";
11992
+ function registerGraphRoute(app, deps) {
11993
+ app.get("/api/graph", async (c) => {
11994
+ const format = c.req.query("format") ?? DEFAULT_FORMAT2;
11995
+ const pluginRuntime = deps.options.noPlugins ? emptyPluginRuntime() : await loadPluginRuntime({ scope: deps.options.scope });
11996
+ for (const warn of pluginRuntime.warnings) {
11997
+ process.stderr.write(`${warn}
11998
+ `);
11999
+ }
12000
+ const formatters = composeFormatters({
12001
+ noBuiltIns: deps.options.noBuiltIns,
12002
+ pluginRuntime
12003
+ });
12004
+ const formatter = formatters.find((f) => f.formatId === format);
12005
+ if (!formatter) {
12006
+ const available = formatters.map((f) => f.formatId).sort().join(", ");
12007
+ throw new HTTPException2(400, {
12008
+ message: tx(SERVER_TEXTS.graphUnknownFormat, {
12009
+ format,
12010
+ available: available || "(none)"
12011
+ })
12012
+ });
12013
+ }
12014
+ const loaded = await tryWithSqlite(
12015
+ { databasePath: deps.options.dbPath, autoBackup: false },
12016
+ (adapter) => adapter.scans.load()
12017
+ );
12018
+ const scan = loaded ?? { nodes: [], links: [], issues: [] };
12019
+ const text = formatter.format({
12020
+ nodes: scan.nodes,
12021
+ links: scan.links,
12022
+ issues: scan.issues
12023
+ });
12024
+ const body = text.endsWith("\n") ? text : text + "\n";
12025
+ return c.body(body, 200, { "content-type": contentTypeFor(format) });
12026
+ });
12027
+ }
12028
+ function contentTypeFor(format) {
12029
+ if (format === "json") return "application/json; charset=utf-8";
12030
+ if (format === "md" || format === "markdown" || format === "mermaid") {
12031
+ return "text/markdown; charset=utf-8";
12032
+ }
12033
+ return "text/plain; charset=utf-8";
12034
+ }
12035
+
12036
+ // server/health.ts
12037
+ import { existsSync as existsSync15 } from "fs";
12038
+ var FALLBACK_SCHEMA_VERSION = "1";
12039
+ function buildHealth(deps) {
12040
+ return {
12041
+ ok: true,
12042
+ schemaVersion: FALLBACK_SCHEMA_VERSION,
12043
+ specVersion: deps.specVersion,
12044
+ implVersion: VERSION,
12045
+ scope: deps.scope,
12046
+ db: existsSync15(deps.dbPath) ? "present" : "missing"
12047
+ };
12048
+ }
12049
+ async function resolveSpecVersion2() {
12050
+ try {
12051
+ const mod = await import("@skill-map/spec", { with: { type: "json" } });
12052
+ const version = mod.default?.specPackageVersion;
12053
+ return version ?? "unknown";
12054
+ } catch {
12055
+ return "unknown";
12056
+ }
12057
+ }
12058
+
12059
+ // server/routes/health.ts
12060
+ function registerHealthRoute(app, deps) {
12061
+ app.get("/api/health", (c) => {
12062
+ const payload = buildHealth({
12063
+ dbPath: deps.options.dbPath,
12064
+ scope: deps.options.scope,
12065
+ specVersion: deps.specVersion
12066
+ });
12067
+ return c.json(payload);
12068
+ });
12069
+ }
12070
+
12071
+ // server/routes/issues.ts
12072
+ function registerIssuesRoute(app, deps) {
12073
+ app.get("/api/issues", async (c) => {
12074
+ const params = new URL(c.req.url).searchParams;
12075
+ const severityFilter = parseCsv(params.get("severity"));
12076
+ const ruleFilter = parseRulesFilter(params.get("ruleId"));
12077
+ const nodePath = params.get("node");
12078
+ const loaded = await tryWithSqlite(
12079
+ { databasePath: deps.options.dbPath, autoBackup: false },
12080
+ (adapter) => adapter.issues.listAll()
12081
+ );
12082
+ const allIssues = loaded ?? [];
12083
+ const filtered = allIssues.filter((issue) => {
12084
+ if (severityFilter && !severityFilter.includes(issue.severity)) return false;
12085
+ if (ruleFilter && !matchesRuleFilter2(issue.ruleId, ruleFilter)) return false;
12086
+ if (nodePath !== null && !issue.nodeIds.includes(nodePath)) return false;
12087
+ return true;
12088
+ });
12089
+ return c.json(
12090
+ buildListEnvelope({
12091
+ kind: "issues",
12092
+ items: filtered,
12093
+ filters: {
12094
+ severity: severityFilter ?? null,
12095
+ ruleId: ruleFilter ? [...ruleFilter] : null,
12096
+ node: nodePath ?? null
12097
+ },
12098
+ total: filtered.length,
12099
+ kindRegistry: deps.kindRegistry
12100
+ })
12101
+ );
12102
+ });
12103
+ }
12104
+ function parseCsv(raw) {
12105
+ if (raw === null) return null;
12106
+ const list = raw.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
12107
+ return list.length > 0 ? list : null;
12108
+ }
12109
+ function parseRulesFilter(raw) {
12110
+ const list = parseCsv(raw);
12111
+ return list ? new Set(list) : null;
12112
+ }
12113
+ function matchesRuleFilter2(ruleId, filter) {
12114
+ if (filter.has(ruleId)) return true;
12115
+ const slashIdx = ruleId.indexOf("/");
12116
+ if (slashIdx >= 0) {
12117
+ const short = ruleId.slice(slashIdx + 1);
12118
+ if (filter.has(short)) return true;
12119
+ }
12120
+ return false;
12121
+ }
12122
+
12123
+ // server/routes/links.ts
12124
+ function registerLinksRoute(app, deps) {
12125
+ app.get("/api/links", async (c) => {
12126
+ const params = new URL(c.req.url).searchParams;
12127
+ const kindFilter = parseCsv2(params.get("kind"));
12128
+ const from = params.get("from");
12129
+ const to = params.get("to");
12130
+ const loaded = await tryWithSqlite(
12131
+ { databasePath: deps.options.dbPath, autoBackup: false },
12132
+ (adapter) => adapter.scans.load()
12133
+ );
12134
+ const allLinks = loaded?.links ?? [];
12135
+ const filtered = allLinks.filter((link2) => {
12136
+ if (kindFilter && !kindFilter.includes(link2.kind)) return false;
12137
+ if (from !== null && link2.source !== from) return false;
12138
+ if (to !== null && link2.target !== to) return false;
12139
+ return true;
12140
+ });
12141
+ return c.json(
12142
+ buildListEnvelope({
12143
+ kind: "links",
12144
+ items: filtered,
12145
+ filters: {
12146
+ kind: kindFilter ?? null,
12147
+ from: from ?? null,
12148
+ to: to ?? null
12149
+ },
12150
+ total: filtered.length,
12151
+ kindRegistry: deps.kindRegistry
12152
+ })
12153
+ );
12154
+ });
12155
+ }
12156
+ function parseCsv2(raw) {
12157
+ if (raw === null) return null;
12158
+ const list = raw.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
12159
+ return list.length > 0 ? list : null;
12160
+ }
12161
+
12162
+ // server/routes/nodes.ts
12163
+ import { HTTPException as HTTPException3 } from "hono/http-exception";
12164
+
12165
+ // server/node-body.ts
12166
+ import { readFile as readFile4 } from "fs/promises";
12167
+ import { isAbsolute as isAbsolute4, resolve as resolvePath, relative as relativePath, sep as sep4 } from "path";
12168
+ async function readNodeBody(cwd, relPath) {
12169
+ if (isAbsolute4(relPath)) return null;
12170
+ const absRoot = resolvePath(cwd);
12171
+ const absFile = resolvePath(absRoot, relPath);
12172
+ const rel = relativePath(absRoot, absFile);
12173
+ if (rel.startsWith("..") || rel.startsWith(sep4) || rel.length === 0) {
12174
+ return null;
12175
+ }
12176
+ let raw;
12177
+ try {
12178
+ raw = await readFile4(absFile, "utf-8");
12179
+ } catch (err) {
12180
+ if (isExpectedFsError(err)) return null;
12181
+ throw err;
12182
+ }
12183
+ return stripFrontmatter(raw);
12184
+ }
12185
+ function stripFrontmatter(raw) {
12186
+ if (!raw.startsWith("---")) return raw;
12187
+ const match = raw.match(/^---\r?\n[\s\S]*?^---\r?\n?/m);
12188
+ if (!match) return raw;
12189
+ return raw.slice(match[0].length);
12190
+ }
12191
+ var EXPECTED_FS_ERROR_CODES = /* @__PURE__ */ new Set(["ENOENT", "EACCES", "EISDIR", "ENOTDIR"]);
12192
+ function isExpectedFsError(err) {
12193
+ if (err === null || typeof err !== "object") return false;
12194
+ const code = err.code;
12195
+ return typeof code === "string" && EXPECTED_FS_ERROR_CODES.has(code);
12196
+ }
12197
+
12198
+ // server/path-codec.ts
12199
+ var PathCodecError = class extends Error {
12200
+ constructor(message) {
12201
+ super(message);
12202
+ this.name = "PathCodecError";
12203
+ }
12204
+ };
12205
+ function encodeNodePath(path) {
12206
+ return Buffer.from(path, "utf8").toString("base64url");
12207
+ }
12208
+ function decodeNodePath(encoded) {
12209
+ if (encoded.length === 0) {
12210
+ throw new PathCodecError("empty pathB64");
12211
+ }
12212
+ if (!/^[A-Za-z0-9_-]+$/.test(encoded)) {
12213
+ throw new PathCodecError("pathB64 contains characters outside the base64url alphabet");
12214
+ }
12215
+ const decoded = Buffer.from(encoded, "base64url").toString("utf8");
12216
+ if (encodeNodePath(decoded) !== encoded) {
12217
+ throw new PathCodecError("pathB64 did not round-trip cleanly through base64url");
12218
+ }
12219
+ return decoded;
12220
+ }
12221
+
12222
+ // server/query-adapter.ts
12223
+ function urlParamsToExportQuery(params) {
12224
+ const filters = {};
12225
+ const tokens = [];
12226
+ const kindRaw = params.get("kind");
12227
+ if (kindRaw !== null) {
12228
+ const kinds = splitCsv(kindRaw);
12229
+ if (kinds.length === 0) {
12230
+ throw new ExportQueryError("kind: empty value list");
12231
+ }
12232
+ filters.kinds = kinds;
12233
+ tokens.push(`kind=${kinds.join(",")}`);
12234
+ }
12235
+ const hasIssuesRaw = params.get("hasIssues");
12236
+ if (hasIssuesRaw !== null) {
12237
+ const lower = hasIssuesRaw.toLowerCase();
12238
+ if (lower === "true") {
12239
+ filters.hasIssues = true;
12240
+ tokens.push("has=issues");
12241
+ } else if (lower === "false") {
12242
+ filters.hasIssues = false;
12243
+ } else {
12244
+ throw new ExportQueryError(`hasIssues: expected "true" or "false", got "${hasIssuesRaw}"`);
12245
+ }
12246
+ }
12247
+ const pathRaw = params.get("path");
12248
+ if (pathRaw !== null) {
12249
+ const globs = splitCsv(pathRaw);
12250
+ if (globs.length === 0) {
12251
+ throw new ExportQueryError("path: empty value list");
12252
+ }
12253
+ filters.pathGlobs = globs;
12254
+ tokens.push(`path=${globs.join(",")}`);
12255
+ }
12256
+ const raw = tokens.join(" ");
12257
+ const query = parseExportQuery(raw);
12258
+ return { query, filters };
12259
+ }
12260
+ function filterNodesWithoutIssues(nodes, issues) {
12261
+ if (issues.length === 0) return nodes;
12262
+ const nodesWithIssues = /* @__PURE__ */ new Set();
12263
+ for (const issue of issues) {
12264
+ for (const id of issue.nodeIds) nodesWithIssues.add(id);
12265
+ }
12266
+ return nodes.filter((n) => !nodesWithIssues.has(n.path));
12267
+ }
12268
+ function splitCsv(raw) {
12269
+ return raw.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
12270
+ }
12271
+
12272
+ // server/routes/nodes.ts
12273
+ var DEFAULT_LIMIT = 100;
12274
+ var MAX_LIMIT = 1e3;
12275
+ function registerNodesRoutes(app, deps) {
12276
+ app.get("/api/nodes/:pathB64", async (c) => {
12277
+ const pathB64 = c.req.param("pathB64");
12278
+ let nodePath;
12279
+ try {
12280
+ nodePath = decodeNodePath(pathB64);
12281
+ } catch (err) {
12282
+ if (err instanceof PathCodecError) {
12283
+ throw new HTTPException3(404, { message: SERVER_TEXTS.pathB64Malformed });
12284
+ }
12285
+ throw err;
12286
+ }
12287
+ const bundle = await tryWithSqlite(
12288
+ { databasePath: deps.options.dbPath, autoBackup: false },
12289
+ (adapter) => adapter.scans.findNode(nodePath)
12290
+ );
12291
+ if (!bundle) {
12292
+ throw new HTTPException3(404, {
12293
+ message: tx(SERVER_TEXTS.nodeNotFound, { path: nodePath })
12294
+ });
12295
+ }
12296
+ const includes = parseIncludes(new URL(c.req.url).searchParams.get("include"));
12297
+ const item = includes.has("body") ? { ...bundle.node, body: await readNodeBody(deps.runtimeContext.cwd, nodePath) } : bundle.node;
12298
+ return c.json({
12299
+ schemaVersion: REST_ENVELOPE_SCHEMA_VERSION,
12300
+ kind: "node",
12301
+ item,
12302
+ links: { incoming: bundle.linksIn, outgoing: bundle.linksOut },
12303
+ issues: bundle.issues,
12304
+ kindRegistry: deps.kindRegistry
12305
+ });
12306
+ });
12307
+ app.get("/api/nodes", async (c) => {
12308
+ const params = new URL(c.req.url).searchParams;
12309
+ const { query, filters } = urlParamsToExportQuery(params);
12310
+ const { offset, limit } = parsePagination(params);
12311
+ const loaded = await tryWithSqlite(
12312
+ { databasePath: deps.options.dbPath, autoBackup: false },
12313
+ (adapter) => adapter.scans.load()
12314
+ );
12315
+ const scan = loaded ?? { nodes: [], links: [], issues: [] };
12316
+ const subset = applyExportQuery(scan, query);
12317
+ let nodes = subset.nodes;
12318
+ if (filters.hasIssues === false) {
12319
+ nodes = filterNodesWithoutIssues(nodes, scan.issues);
12320
+ }
12321
+ const total = nodes.length;
12322
+ const items = nodes.slice(offset, offset + limit);
12323
+ return c.json(
12324
+ buildListEnvelope({
12325
+ kind: "nodes",
12326
+ items,
12327
+ filters: {
12328
+ kind: filters.kinds ?? null,
12329
+ hasIssues: filters.hasIssues ?? null,
12330
+ path: filters.pathGlobs ?? null
12331
+ },
12332
+ total,
12333
+ page: { offset, limit },
12334
+ kindRegistry: deps.kindRegistry
12335
+ })
12336
+ );
12337
+ });
12338
+ }
12339
+ function parsePagination(params) {
12340
+ const offset = parseNonNegativeInt(params.get("offset"), "offset", 0);
12341
+ const limit = parseNonNegativeInt(params.get("limit"), "limit", DEFAULT_LIMIT);
12342
+ if (limit > MAX_LIMIT) {
12343
+ throw new HTTPException3(400, {
12344
+ message: tx(SERVER_TEXTS.paginationLimitTooLarge, { value: limit, max: MAX_LIMIT })
12345
+ });
12346
+ }
12347
+ return { offset, limit };
12348
+ }
12349
+ function parseNonNegativeInt(raw, name, fallback) {
12350
+ if (raw === null || raw.length === 0) return fallback;
12351
+ const trimmed = raw.trim();
12352
+ const parsed = Number.parseInt(trimmed, 10);
12353
+ if (!Number.isInteger(parsed) || parsed < 0 || String(parsed) !== trimmed) {
12354
+ throw new HTTPException3(400, {
12355
+ message: tx(SERVER_TEXTS.paginationInvalidInteger, { name, value: raw })
12356
+ });
12357
+ }
12358
+ return parsed;
12359
+ }
12360
+ function parseIncludes(raw) {
12361
+ if (raw === null || raw.length === 0) return /* @__PURE__ */ new Set();
12362
+ return new Set(raw.split(",").map((s) => s.trim()).filter((s) => s.length > 0));
12363
+ }
12364
+
12365
+ // server/routes/plugins.ts
12366
+ function registerPluginsRoute(app, deps) {
12367
+ app.get("/api/plugins", async (c) => {
12368
+ const pluginRuntime = deps.options.noPlugins ? emptyPluginRuntime() : await loadPluginRuntime({ scope: deps.options.scope });
12369
+ for (const warn of pluginRuntime.warnings) {
12370
+ process.stderr.write(`${warn}
12371
+ `);
12372
+ }
12373
+ const items = [
12374
+ ...deps.options.noBuiltIns ? [] : buildBuiltInItems(pluginRuntime.resolveEnabled),
12375
+ ...buildDiscoveredItems(pluginRuntime.discovered, deps)
12376
+ ];
12377
+ return c.json(
12378
+ buildListEnvelope({
12379
+ kind: "plugins",
12380
+ items,
12381
+ filters: {},
12382
+ total: items.length,
12383
+ kindRegistry: deps.kindRegistry
12384
+ })
12385
+ );
12386
+ });
12387
+ }
12388
+ function buildBuiltInItems(resolveEnabled) {
12389
+ return builtInBundles.map((bundle) => ({
12390
+ id: bundle.id,
12391
+ version: firstVersion(bundle.extensions),
12392
+ kinds: uniqueKinds(bundle.extensions.map((e) => e.kind)),
12393
+ status: resolveEnabled(bundle.id) ? "enabled" : "disabled",
12394
+ reason: null,
12395
+ source: "built-in"
12396
+ }));
12397
+ }
12398
+ function buildDiscoveredItems(discovered, deps) {
12399
+ return discovered.map((plugin) => ({
12400
+ id: plugin.id,
12401
+ version: plugin.manifest?.version ?? null,
12402
+ kinds: uniqueKinds(plugin.extensions?.map((e) => e.kind) ?? []),
12403
+ status: plugin.status,
12404
+ reason: plugin.reason ?? null,
12405
+ source: classifyPluginSource(plugin.path, deps)
12406
+ }));
12407
+ }
12408
+ function uniqueKinds(kinds) {
12409
+ return [...new Set(kinds)].sort();
12410
+ }
12411
+ function firstVersion(extensions) {
12412
+ for (const ext of extensions) {
12413
+ if (ext.version) return ext.version;
12414
+ }
12415
+ return null;
12416
+ }
12417
+ function classifyPluginSource(pluginPath, deps) {
12418
+ const projectDir = defaultProjectPluginsDir(deps.runtimeContext);
12419
+ return pluginPath.startsWith(projectDir) ? "project" : "global";
12420
+ }
12421
+
12422
+ // server/routes/scan.ts
12423
+ import { HTTPException as HTTPException4 } from "hono/http-exception";
12424
+ function registerScanRoute(app, deps) {
12425
+ app.get("/api/scan", async (c) => {
12426
+ const fresh = c.req.query("fresh");
12427
+ if (fresh === "1" || fresh === "true") {
12428
+ return c.json(await runFreshScan(deps));
12429
+ }
12430
+ return c.json(await loadPersistedScan(deps));
12431
+ });
12432
+ }
12433
+ async function loadPersistedScan(deps) {
12434
+ const loaded = await tryWithSqlite(
12435
+ { databasePath: deps.options.dbPath, autoBackup: false },
12436
+ (adapter) => adapter.scans.load()
12437
+ );
12438
+ if (loaded !== null) return loaded;
12439
+ return emptyScanResult();
12440
+ }
12441
+ async function runFreshScan(deps) {
12442
+ if (deps.options.noBuiltIns || deps.options.noPlugins) {
12443
+ throw new HTTPException4(400, { message: SERVER_TEXTS.freshScanRequiresPipeline });
12444
+ }
12445
+ const outcome = await runScanForCommand({
12446
+ roots: [deps.runtimeContext.cwd],
12447
+ noBuiltIns: false,
12448
+ noPlugins: false,
12449
+ noTokens: false,
12450
+ dryRun: true,
12451
+ changed: false,
12452
+ allowEmpty: true,
12453
+ strict: false,
12454
+ stderr: process.stderr,
12455
+ ctx: deps.runtimeContext
12456
+ });
12457
+ if (outcome.kind !== "ok") {
12458
+ throw new HTTPException4(500, {
12459
+ message: outcome.kind === "guard-trip" ? `fresh scan refused (existing rows: ${outcome.existing})` : outcome.message
12460
+ });
12461
+ }
12462
+ return outcome.result;
12463
+ }
12464
+ function emptyScanResult() {
12465
+ return {
12466
+ schemaVersion: 1,
12467
+ scannedAt: Date.now(),
12468
+ scope: "project",
12469
+ roots: ["."],
12470
+ providers: [],
12471
+ nodes: [],
12472
+ links: [],
12473
+ issues: [],
12474
+ stats: {
12475
+ filesWalked: 0,
12476
+ filesSkipped: 0,
12477
+ nodesCount: 0,
12478
+ linksCount: 0,
12479
+ issuesCount: 0,
12480
+ durationMs: 0
12481
+ }
12482
+ };
12483
+ }
12484
+
12485
+ // server/static.ts
12486
+ import { existsSync as existsSync16 } from "fs";
12487
+ import { readFile as readFile5 } from "fs/promises";
12488
+ import { extname, join as join13 } from "path";
12489
+ import { serveStatic } from "@hono/node-server/serve-static";
12490
+ var INDEX_HTML = "index.html";
12491
+ var PLACEHOLDER_HTML = `<!doctype html>
12492
+ <html lang="en">
12493
+ <head>
12494
+ <meta charset="utf-8" />
12495
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
12496
+ <meta name="skill-map-mode" content="live" />
12497
+ <title>skill-map server</title>
12498
+ <style>
12499
+ body { font-family: system-ui, sans-serif; margin: 2rem; max-width: 36rem; line-height: 1.5; }
12500
+ code { background: #f4f4f4; padding: 0.1rem 0.3rem; border-radius: 3px; }
12501
+ h1 { font-size: 1.4rem; }
12502
+ </style>
12503
+ </head>
12504
+ <body>
12505
+ <h1>skill-map server is running</h1>
12506
+ <p>The UI bundle was not found. Run <code>npm run build --workspace=ui</code> from the repo root, or pass <code>--ui-dist &lt;path&gt;</code>, then restart <code>sm serve</code>.</p>
12507
+ <p>The REST API is available at <code>/api/health</code>.</p>
12508
+ </body>
12509
+ </html>
12510
+ `;
12511
+ function createStaticHandler(uiDist) {
12512
+ if (uiDist === null) return placeholderRootMiddleware();
12513
+ return serveStatic({ root: uiDist });
12514
+ }
12515
+ function createSpaFallback(uiDist) {
12516
+ return async (c, _next) => {
12517
+ if (c.req.method !== "GET" && c.req.method !== "HEAD") return c.notFound();
12518
+ if (uiDist === null) return htmlResponse(c, PLACEHOLDER_HTML);
12519
+ const indexPath = join13(uiDist, INDEX_HTML);
12520
+ if (!existsSync16(indexPath)) return htmlResponse(c, PLACEHOLDER_HTML);
12521
+ return fileResponse(c, indexPath);
12522
+ };
12523
+ }
12524
+ function placeholderRootMiddleware() {
12525
+ return async (c, next) => {
12526
+ if (c.req.method !== "GET" && c.req.method !== "HEAD") return next();
12527
+ if (c.req.path === "/" || c.req.path === "/index.html") {
12528
+ return htmlResponse(c, PLACEHOLDER_HTML);
12529
+ }
12530
+ return next();
12531
+ };
12532
+ }
12533
+ var MIME_TYPES = {
12534
+ ".html": "text/html; charset=UTF-8",
12535
+ ".js": "application/javascript; charset=UTF-8",
12536
+ ".mjs": "application/javascript; charset=UTF-8",
12537
+ ".css": "text/css; charset=UTF-8",
12538
+ ".json": "application/json; charset=UTF-8",
12539
+ ".svg": "image/svg+xml",
12540
+ ".png": "image/png",
12541
+ ".jpg": "image/jpeg",
12542
+ ".jpeg": "image/jpeg",
12543
+ ".gif": "image/gif",
12544
+ ".webp": "image/webp",
12545
+ ".ico": "image/x-icon",
12546
+ ".woff": "font/woff",
12547
+ ".woff2": "font/woff2",
12548
+ ".ttf": "font/ttf",
12549
+ ".otf": "font/otf",
12550
+ ".txt": "text/plain; charset=UTF-8",
12551
+ ".map": "application/json; charset=UTF-8"
12552
+ };
12553
+ function mimeFor(filePath) {
12554
+ const ext = extname(filePath).toLowerCase();
12555
+ return MIME_TYPES[ext] ?? "application/octet-stream";
12556
+ }
12557
+ function htmlResponse(c, html) {
12558
+ return c.body(html, 200, { "content-type": "text/html; charset=UTF-8" });
12559
+ }
12560
+ async function fileResponse(c, absPath) {
12561
+ const buf = await readFile5(absPath);
12562
+ return c.body(buf, 200, { "content-type": mimeFor(absPath) });
12563
+ }
12564
+
12565
+ // server/ws.ts
12566
+ import { upgradeWebSocket } from "@hono/node-server";
12567
+ var WS_PATH = "/ws";
12568
+ function attachBroadcasterRoute(app, broadcaster) {
12569
+ app.get(
12570
+ WS_PATH,
12571
+ upgradeWebSocket(() => ({
12572
+ onOpen(_event, ws) {
12573
+ const raw = ws.raw;
12574
+ if (!raw) {
12575
+ broadcaster.register({
12576
+ send: (data) => ws.send(data),
12577
+ close: (code, reason) => ws.close(code, reason),
12578
+ bufferedAmount: 0,
12579
+ readyState: ws.readyState
12580
+ });
12581
+ return;
12582
+ }
12583
+ broadcaster.register(raw);
12584
+ },
12585
+ onClose(_event, ws) {
12586
+ const raw = ws.raw;
12587
+ if (raw) broadcaster.unregister(raw);
12588
+ },
12589
+ onError(event, ws) {
12590
+ const raw = ws.raw;
12591
+ if (raw) broadcaster.unregister(raw);
12592
+ const message = event?.message ?? "unknown";
12593
+ log.warn(
12594
+ tx(SERVER_TEXTS.wsClientSendFailed, {
12595
+ message: sanitizeForTerminal(message)
12596
+ })
12597
+ );
12598
+ }
12599
+ }))
12600
+ );
12601
+ }
12602
+
12603
+ // server/app.ts
12604
+ function createApp(deps) {
12605
+ const app = new Hono();
12606
+ if (deps.options.devCors) {
12607
+ app.use("*", async (c, next) => {
12608
+ await next();
12609
+ c.res.headers.set("access-control-allow-origin", "*");
12610
+ c.res.headers.set("access-control-allow-methods", "GET,POST,PUT,DELETE,OPTIONS");
12611
+ c.res.headers.set("access-control-allow-headers", "content-type, authorization");
12612
+ });
12613
+ app.options("*", (c) => c.body(null, 204));
12614
+ }
12615
+ registerHealthRoute(app, { options: deps.options, specVersion: deps.specVersion });
12616
+ const routeDeps = {
12617
+ options: deps.options,
12618
+ runtimeContext: deps.runtimeContext,
12619
+ kindRegistry: deps.kindRegistry
12620
+ };
12621
+ registerScanRoute(app, routeDeps);
12622
+ registerNodesRoutes(app, routeDeps);
12623
+ registerLinksRoute(app, routeDeps);
12624
+ registerIssuesRoute(app, routeDeps);
12625
+ registerGraphRoute(app, routeDeps);
12626
+ registerConfigRoute(app, routeDeps);
12627
+ registerPluginsRoute(app, routeDeps);
12628
+ app.all("/api/*", (c) => {
12629
+ throw new HTTPException5(404, { message: `Unknown API endpoint: ${c.req.path}` });
12630
+ });
12631
+ attachBroadcasterRoute(app, deps.broadcaster);
12632
+ app.use("*", createStaticHandler(deps.options.uiDist));
12633
+ app.get("*", createSpaFallback(deps.options.uiDist));
12634
+ app.notFound((c) => {
12635
+ throw new HTTPException5(404, { message: `Not found: ${c.req.path}` });
12636
+ });
12637
+ app.onError((err, c) => {
12638
+ return formatError2(err, c);
12639
+ });
12640
+ return app;
12641
+ }
12642
+ function codeForStatus(status) {
12643
+ if (status === 404) return "not-found";
12644
+ if (status === 400) return "bad-query";
12645
+ return "internal";
12646
+ }
12647
+ function formatError2(err, c) {
12648
+ if (err instanceof HTTPException5) {
12649
+ const status = err.status;
12650
+ const envelope2 = {
12651
+ ok: false,
12652
+ error: {
12653
+ code: codeForStatus(status),
12654
+ message: err.message,
12655
+ details: null
12656
+ }
12657
+ };
12658
+ return c.json(envelope2, status);
12659
+ }
12660
+ if (err instanceof ExportQueryError) {
12661
+ const envelope2 = {
12662
+ ok: false,
12663
+ error: {
12664
+ code: "bad-query",
12665
+ message: err.message,
12666
+ details: null
12667
+ }
12668
+ };
12669
+ return c.json(envelope2, 400);
12670
+ }
12671
+ const envelope = {
12672
+ ok: false,
12673
+ error: {
12674
+ code: "internal",
12675
+ message: formatErrorMessage(err),
12676
+ details: null
12677
+ }
12678
+ };
12679
+ return c.json(envelope, 500);
12680
+ }
12681
+
12682
+ // server/broadcaster.ts
12683
+ var MAX_BUFFERED_BYTES = 4 * 1024 * 1024;
12684
+ var CLOSE_CODE_GOING_AWAY = 1001;
12685
+ var CLOSE_CODE_MESSAGE_TOO_BIG = 1009;
12686
+ var READY_STATE_OPEN = 1;
12687
+ var WsBroadcaster = class {
12688
+ #clients = /* @__PURE__ */ new Set();
12689
+ #shutDown = false;
12690
+ /** Number of currently-registered clients. Read-only — for tests / `/api/health`. */
12691
+ get clientCount() {
12692
+ return this.#clients.size;
12693
+ }
12694
+ /**
12695
+ * Register a client. Called from the `/ws` `onOpen` handler with the
12696
+ * raw `WebSocket` instance. After shutdown the broadcaster refuses
12697
+ * new registrations and immediately closes the offered socket so a
12698
+ * late upgrade doesn't leak.
12699
+ */
12700
+ register(ws) {
12701
+ if (this.#shutDown) {
12702
+ try {
12703
+ ws.close(CLOSE_CODE_GOING_AWAY, "server shutdown");
12704
+ } catch {
12705
+ }
12706
+ return;
12707
+ }
12708
+ this.#clients.add(ws);
12709
+ }
12710
+ /**
12711
+ * Unregister a client. Called from the `/ws` `onClose` / `onError`
12712
+ * handlers and from the backpressure path. Idempotent — calling on a
12713
+ * client that was never registered (or was already removed) is a no-op.
12714
+ */
12715
+ unregister(ws) {
12716
+ this.#clients.delete(ws);
12717
+ }
12718
+ /**
12719
+ * Serialize the envelope once and fan out to every open client.
12720
+ * Closed / closing clients are silently skipped (the `onClose` handler
12721
+ * has already removed them, but we double-check `readyState` because a
12722
+ * close in the middle of the loop is observable as a transient
12723
+ * `OPEN → CLOSING` flip).
12724
+ *
12725
+ * Per-client `send()` failures are caught: one rogue socket cannot
12726
+ * stop the rest from receiving the event. A failing socket is closed
12727
+ * + unregistered so the next broadcast doesn't waste cycles on it.
12728
+ *
12729
+ * Backpressure check (per AGENTS.md §Watcher integration): if a
12730
+ * client's `bufferedAmount` exceeds `MAX_BUFFERED_BYTES`, it's evicted
12731
+ * with close code 1009. The check runs BEFORE `send` so the threshold
12732
+ * acts as an admission gate, not a post-mortem.
12733
+ */
12734
+ broadcast(envelope) {
12735
+ if (this.#shutDown) return;
12736
+ let payload;
12737
+ try {
12738
+ payload = JSON.stringify(envelope);
12739
+ } catch (err) {
12740
+ const message = err instanceof Error ? err.message : String(err);
12741
+ log.warn(
12742
+ tx(SERVER_TEXTS.wsBroadcastSerializeFailed, {
12743
+ message: sanitizeForTerminal(message)
12744
+ })
12745
+ );
12746
+ return;
12747
+ }
12748
+ const snapshot = Array.from(this.#clients);
12749
+ for (const client of snapshot) {
12750
+ this.#deliver(client, payload);
12751
+ }
12752
+ }
12753
+ /**
12754
+ * Drain every connected socket with code 1001 ('going away') + reason
12755
+ * `'server shutdown'`. Idempotent — a second call after the first
12756
+ * `shutdown()` is a no-op. After shutdown, `register()` immediately
12757
+ * closes any new client offered.
12758
+ */
12759
+ shutdown() {
12760
+ if (this.#shutDown) return;
12761
+ this.#shutDown = true;
12762
+ const snapshot = Array.from(this.#clients);
12763
+ this.#clients.clear();
12764
+ for (const client of snapshot) {
12765
+ try {
12766
+ client.close(CLOSE_CODE_GOING_AWAY, "server shutdown");
12767
+ } catch {
12768
+ }
12769
+ }
12770
+ }
12771
+ /**
12772
+ * Per-client delivery: backpressure check, then `send()`. Eviction +
12773
+ * unregistration on either failure mode.
12774
+ */
12775
+ #deliver(client, payload) {
12776
+ if (client.readyState !== READY_STATE_OPEN) {
12777
+ this.#clients.delete(client);
12778
+ return;
12779
+ }
12780
+ if (client.bufferedAmount > MAX_BUFFERED_BYTES) {
12781
+ this.#clients.delete(client);
12782
+ try {
12783
+ client.close(CLOSE_CODE_MESSAGE_TOO_BIG, "backpressure exceeded");
12784
+ } catch {
12785
+ }
12786
+ log.warn(
12787
+ tx(SERVER_TEXTS.wsBackpressureEvicted, {
12788
+ buffered: String(client.bufferedAmount),
12789
+ threshold: String(MAX_BUFFERED_BYTES)
12790
+ })
12791
+ );
12792
+ return;
12793
+ }
12794
+ try {
12795
+ client.send(payload);
12796
+ } catch (err) {
12797
+ this.#clients.delete(client);
12798
+ try {
12799
+ client.close();
12800
+ } catch {
12801
+ }
12802
+ const message = err instanceof Error ? err.message : String(err);
12803
+ log.warn(
12804
+ tx(SERVER_TEXTS.wsClientSendFailed, {
12805
+ message: sanitizeForTerminal(message)
12806
+ })
12807
+ );
12808
+ }
12809
+ }
12810
+ };
12811
+
12812
+ // server/kind-registry.ts
12813
+ function buildKindRegistry(providers) {
12814
+ const registry = {};
12815
+ for (const provider of providers) {
12816
+ for (const [kindName, kindEntry] of Object.entries(provider.kinds)) {
12817
+ if (registry[kindName]) continue;
12818
+ const ui = kindEntry.ui;
12819
+ const entry = {
12820
+ providerId: provider.id,
12821
+ label: ui.label,
12822
+ color: ui.color
12823
+ };
12824
+ if (ui.colorDark !== void 0) entry.colorDark = ui.colorDark;
12825
+ if (ui.emoji !== void 0) entry.emoji = ui.emoji;
12826
+ if (ui.icon !== void 0) entry.icon = ui.icon;
12827
+ registry[kindName] = entry;
12828
+ }
12829
+ }
12830
+ return registry;
12831
+ }
12832
+
12833
+ // server/events.ts
12834
+ function buildWatcherStartedEvent(data) {
12835
+ return {
12836
+ type: "watcher.started",
12837
+ timestamp: Date.now(),
12838
+ jobId: null,
12839
+ data
12840
+ };
12841
+ }
12842
+ function buildWatcherErrorEvent(data) {
12843
+ return {
12844
+ type: "watcher.error",
12845
+ timestamp: Date.now(),
12846
+ jobId: null,
12847
+ data
12848
+ };
12849
+ }
12850
+
12851
+ // server/watcher.ts
12852
+ var WATCH_ROOT = ".";
12853
+ function createWatcherService(opts) {
12854
+ let chokidarHandle = null;
12855
+ let stopped = false;
12856
+ const start = async () => {
12857
+ const cfg = loadConfig({
12858
+ scope: opts.options.scope,
12859
+ cwd: opts.runtimeContext.cwd,
12860
+ homedir: opts.runtimeContext.homedir
12861
+ }).effective;
12862
+ const ignoreFileText = readIgnoreFileText(opts.runtimeContext.cwd);
12863
+ const ignoreFilterOpts = {};
12864
+ if (cfg.ignore.length > 0) ignoreFilterOpts.configIgnore = cfg.ignore;
12865
+ if (ignoreFileText !== void 0) ignoreFilterOpts.ignoreFileText = ignoreFileText;
12866
+ const ignoreFilter = buildIgnoreFilter(ignoreFilterOpts);
12867
+ const debounceMs = opts.debounceMsOverride ?? cfg.scan.watch.debounceMs;
12868
+ const pluginRuntime = opts.options.noPlugins ? emptyPluginRuntime() : await loadPluginRuntime({ scope: opts.options.scope });
12869
+ for (const warn of pluginRuntime.warnings) {
12870
+ log.warn(sanitizeForTerminal(warn));
12871
+ }
12872
+ const runOneBatch = async () => {
12873
+ const kernel = createKernel();
12874
+ registerKernelExtensions(kernel, pluginRuntime, opts.options.noBuiltIns);
12875
+ const emitter = buildBroadcasterEmitter(opts.broadcaster);
12876
+ const priorState = await loadPriorState(opts.options.dbPath);
12877
+ const composed = composeScanExtensions({
12878
+ noBuiltIns: opts.options.noBuiltIns,
12879
+ pluginRuntime
12880
+ });
12881
+ const runOptions = assembleRunOptions({
12882
+ scope: opts.options.scope,
12883
+ tokenize: cfg.scan.tokenize !== false,
12884
+ strict: cfg.scan.strict === true,
12885
+ ignoreFilter,
12886
+ emitter,
12887
+ composed,
12888
+ priorState
12889
+ });
12890
+ const ran = await runScanWithRenames(kernel, runOptions);
12891
+ await persistOutcome(opts.options.dbPath, ran);
12892
+ };
12893
+ chokidarHandle = createChokidarWatcher({
12894
+ roots: [WATCH_ROOT],
12895
+ cwd: opts.runtimeContext.cwd,
12896
+ debounceMs,
12897
+ ignoreFilter,
12898
+ onBatch: async () => {
12899
+ if (stopped) return;
12900
+ try {
12901
+ await runOneBatch();
12902
+ } catch (err) {
12903
+ const message = formatErrorMessage(err);
12904
+ log.warn(
12905
+ tx(SERVER_TEXTS.watcherBatchFailed, {
12906
+ message: sanitizeForTerminal(message)
12907
+ })
12908
+ );
12909
+ }
12910
+ },
12911
+ onError: (err) => {
12912
+ const message = err.message;
12913
+ log.warn(
12914
+ tx(SERVER_TEXTS.watcherError, {
12915
+ message: sanitizeForTerminal(message)
12916
+ })
12917
+ );
12918
+ opts.broadcaster.broadcast(buildWatcherErrorEvent({ message }));
12919
+ }
12920
+ });
12921
+ if ("ready" in chokidarHandle && chokidarHandle.ready instanceof Promise) {
12922
+ await chokidarHandle.ready;
12923
+ }
12924
+ opts.broadcaster.broadcast(
12925
+ buildWatcherStartedEvent({ roots: [WATCH_ROOT], debounceMs })
12926
+ );
12927
+ log.info(
12928
+ tx(SERVER_TEXTS.watcherReady, {
12929
+ roots: WATCH_ROOT,
12930
+ debounceMs: String(debounceMs)
12931
+ })
12932
+ );
12933
+ };
12934
+ const stop = async () => {
12935
+ if (stopped) return;
12936
+ stopped = true;
12937
+ if (chokidarHandle) {
12938
+ try {
12939
+ await chokidarHandle.close();
12940
+ } catch (err) {
12941
+ const message = err instanceof Error ? err.message : String(err);
12942
+ log.warn(
12943
+ tx(SERVER_TEXTS.watcherCloseFailed, {
12944
+ message: sanitizeForTerminal(message)
12945
+ })
12946
+ );
12947
+ }
12948
+ chokidarHandle = null;
12949
+ }
12950
+ };
12951
+ return { start, stop };
12952
+ }
12953
+ function registerKernelExtensions(kernel, pluginRuntime, noBuiltIns) {
12954
+ if (!noBuiltIns) {
12955
+ const enabledBuiltIns = filterBuiltInManifests(
12956
+ listBuiltIns(),
12957
+ pluginRuntime.resolveEnabled
12958
+ );
12959
+ for (const manifest of enabledBuiltIns) kernel.registry.register(manifest);
12960
+ }
12961
+ for (const manifest of pluginRuntime.manifests) kernel.registry.register(manifest);
12962
+ }
12963
+ function buildBroadcasterEmitter(broadcaster) {
12964
+ return {
12965
+ emit(event) {
12966
+ broadcaster.broadcast(event);
12967
+ },
12968
+ subscribe() {
12969
+ return () => {
12970
+ };
12971
+ }
12972
+ };
12973
+ }
12974
+ async function loadPriorState(dbPath) {
12975
+ return tryWithSqlite({ databasePath: dbPath, autoBackup: false }, async (reader) => {
12976
+ const loaded = await reader.scans.load();
12977
+ if (loaded.nodes.length === 0) return null;
12978
+ const extractorRuns = await reader.scans.loadExtractorRuns();
12979
+ return { snapshot: loaded, extractorRuns };
12980
+ });
12981
+ }
12982
+ function assembleRunOptions(args2) {
12983
+ const runOptions = {
12984
+ roots: [WATCH_ROOT],
12985
+ scope: args2.scope,
12986
+ tokenize: args2.tokenize,
12987
+ ignoreFilter: args2.ignoreFilter,
12988
+ strict: args2.strict,
12989
+ emitter: args2.emitter
12990
+ };
12991
+ if (args2.composed) runOptions.extensions = args2.composed;
12992
+ if (args2.priorState) {
12993
+ runOptions.priorSnapshot = args2.priorState.snapshot;
12994
+ runOptions.enableCache = true;
12995
+ runOptions.priorExtractorRuns = args2.priorState.extractorRuns;
12996
+ }
12997
+ return runOptions;
12998
+ }
12999
+ async function persistOutcome(dbPath, ran) {
13000
+ const { result, renameOps, extractorRuns, enrichments } = ran;
13001
+ await withSqlite(
13002
+ { databasePath: dbPath },
13003
+ (writer) => writer.scans.persist(result, { renameOps, extractorRuns, enrichments })
13004
+ );
13005
+ }
13006
+
13007
+ // server/options.ts
13008
+ var DEFAULT_PORT = 4242;
13009
+ var DEFAULT_HOST = "127.0.0.1";
13010
+ var DEFAULT_SCOPE = "project";
13011
+ var LOOPBACK_HOSTS = /* @__PURE__ */ new Set(["127.0.0.1", "::1", "localhost", "0:0:0:0:0:0:0:1"]);
13012
+ function isLoopbackHost(host) {
13013
+ return LOOPBACK_HOSTS.has(host.toLowerCase());
13014
+ }
13015
+ function validateServerOptions(input) {
13016
+ const filled = applyDefaults(input);
13017
+ const portError = validatePort(filled.port);
13018
+ if (portError !== null) return { ok: false, error: portError };
13019
+ const scopeError = validateScope(filled.scope);
13020
+ if (scopeError !== null) return { ok: false, error: scopeError };
13021
+ const hostError = validateHost(filled.host, filled.devCors);
13022
+ if (hostError !== null) return { ok: false, error: hostError };
13023
+ const watcherError = validateWatcher(filled.noWatcher, filled.noBuiltIns, filled.noPlugins);
13024
+ if (watcherError !== null) return { ok: false, error: watcherError };
13025
+ const debounceError = validateWatcherDebounce(input.watcherDebounceMs);
13026
+ if (debounceError !== null) return { ok: false, error: debounceError };
13027
+ const options = {
13028
+ port: filled.port,
13029
+ host: filled.host,
13030
+ scope: filled.scope,
13031
+ dbPath: input.dbPath,
13032
+ uiDist: filled.uiDist,
13033
+ noBuiltIns: filled.noBuiltIns,
13034
+ noPlugins: filled.noPlugins,
13035
+ open: filled.open,
13036
+ devCors: filled.devCors,
13037
+ noWatcher: filled.noWatcher
13038
+ };
13039
+ if (input.watcherDebounceMs !== void 0) {
13040
+ options.watcherDebounceMs = input.watcherDebounceMs;
13041
+ }
13042
+ return { ok: true, options };
13043
+ }
13044
+ function applyDefaults(input) {
13045
+ return {
13046
+ port: input.port ?? DEFAULT_PORT,
13047
+ host: input.host ?? DEFAULT_HOST,
13048
+ scope: input.scope ?? DEFAULT_SCOPE,
13049
+ uiDist: input.uiDist ?? null,
13050
+ noBuiltIns: input.noBuiltIns ?? false,
13051
+ noPlugins: input.noPlugins ?? false,
13052
+ open: input.open ?? true,
13053
+ devCors: input.devCors ?? false,
13054
+ noWatcher: input.noWatcher ?? false
13055
+ };
13056
+ }
13057
+ function validatePort(port) {
13058
+ if (!Number.isInteger(port)) {
13059
+ return { code: "port-invalid", message: `port must be an integer (got ${port})`, value: String(port) };
13060
+ }
13061
+ if (port < 0 || port > 65535) {
13062
+ return {
13063
+ code: "port-out-of-range",
13064
+ message: `port must be in [0, 65535] (got ${port})`,
13065
+ value: String(port)
13066
+ };
13067
+ }
13068
+ return null;
13069
+ }
13070
+ function validateScope(scope) {
13071
+ if (scope !== "project" && scope !== "global") {
13072
+ return { code: "scope-invalid", message: `scope must be "project" or "global"`, value: String(scope) };
13073
+ }
13074
+ return null;
13075
+ }
13076
+ function validateHost(host, devCors) {
13077
+ if (devCors && !isLoopbackHost(host)) {
13078
+ return {
13079
+ code: "host-dev-cors-rejected",
13080
+ message: `--dev-cors requires a loopback --host (got ${host})`,
13081
+ value: host
13082
+ };
13083
+ }
13084
+ return null;
13085
+ }
13086
+ function validateWatcher(noWatcher, noBuiltIns, _noPlugins) {
13087
+ if (noWatcher) return null;
13088
+ if (noBuiltIns) {
13089
+ return {
13090
+ code: "watcher-requires-pipeline",
13091
+ message: "the watcher cannot run with --no-built-ins (would persist empty scans on every batch). Pass --no-watcher to opt out, or drop --no-built-ins.",
13092
+ value: "no-built-ins"
13093
+ };
13094
+ }
13095
+ return null;
13096
+ }
13097
+ function validateWatcherDebounce(value) {
13098
+ if (value === void 0) return null;
13099
+ if (!Number.isInteger(value) || value < 0) {
13100
+ return {
13101
+ code: "watcher-debounce-invalid",
13102
+ message: `--watcher-debounce-ms must be a non-negative integer (got ${value})`,
13103
+ value: String(value)
13104
+ };
13105
+ }
13106
+ return null;
13107
+ }
13108
+
13109
+ // server/paths.ts
13110
+ import { existsSync as existsSync17, statSync as statSync5 } from "fs";
13111
+ import { dirname as dirname9, isAbsolute as isAbsolute5, join as join14, resolve as resolve19 } from "path";
13112
+ var DEFAULT_UI_REL = join14("ui", "dist", "browser");
13113
+ var INDEX_HTML2 = "index.html";
13114
+ function resolveDefaultUiDist(ctx) {
13115
+ let current = resolve19(ctx.cwd);
13116
+ for (let i = 0; i < 64; i++) {
13117
+ const candidate = join14(current, DEFAULT_UI_REL);
13118
+ if (isUiBundleDir(candidate)) return candidate;
13119
+ const parent = dirname9(current);
13120
+ if (parent === current) return null;
13121
+ current = parent;
13122
+ }
13123
+ return null;
13124
+ }
13125
+ function resolveExplicitUiDist(ctx, raw) {
13126
+ return isAbsolute5(raw) ? raw : resolve19(ctx.cwd, raw);
13127
+ }
13128
+ function isUiBundleDir(path) {
13129
+ if (!existsSync17(path)) return false;
13130
+ try {
13131
+ if (!statSync5(path).isDirectory()) return false;
13132
+ return existsSync17(join14(path, INDEX_HTML2));
13133
+ } catch {
13134
+ return false;
13135
+ }
13136
+ }
13137
+
13138
+ // server/index.ts
13139
+ async function createServer(options, extra = {}) {
13140
+ const specVersion = await resolveSpecVersion2();
13141
+ const runtimeContext = extra.runtimeContext ?? defaultRuntimeContext();
13142
+ const broadcaster = new WsBroadcaster();
13143
+ const kindRegistry = await assembleKindRegistry(options);
13144
+ const app = createApp({
13145
+ options,
13146
+ specVersion,
13147
+ broadcaster,
13148
+ runtimeContext,
13149
+ kindRegistry
13150
+ });
13151
+ const wss = new WebSocketServer({ noServer: true });
13152
+ const server = await listenAsync(app.fetch, wss, options.host, options.port);
13153
+ const addr = server.address();
13154
+ const address = normalizeAddress(addr, options.host, options.port);
13155
+ let watcherService = null;
13156
+ if (!options.noWatcher) {
13157
+ const debounce = options.watcherDebounceMs;
13158
+ const svcOpts = {
13159
+ options,
13160
+ runtimeContext,
13161
+ broadcaster
13162
+ };
13163
+ if (debounce !== void 0) svcOpts.debounceMsOverride = debounce;
13164
+ const candidate = createWatcherService(svcOpts);
13165
+ try {
13166
+ await candidate.start();
13167
+ watcherService = candidate;
13168
+ } catch (err) {
13169
+ const message = err instanceof Error ? err.message : String(err);
13170
+ log.warn(
13171
+ tx(SERVER_TEXTS.watcherBootFailed, {
13172
+ message: sanitizeForTerminal(message)
13173
+ })
13174
+ );
13175
+ try {
13176
+ await candidate.stop();
13177
+ } catch {
13178
+ }
13179
+ }
13180
+ }
13181
+ let closed = false;
13182
+ const close = async () => {
13183
+ if (closed) return;
13184
+ closed = true;
13185
+ if (watcherService) {
13186
+ try {
13187
+ await watcherService.stop();
13188
+ } catch {
13189
+ }
13190
+ }
13191
+ broadcaster.shutdown();
13192
+ await closeServer(server);
13193
+ wss.close();
13194
+ };
13195
+ return { address, close, broadcaster };
13196
+ }
13197
+ async function assembleKindRegistry(options) {
13198
+ const pluginRuntime = options.noPlugins ? emptyPluginRuntime() : await loadPluginRuntime({ scope: options.scope });
13199
+ for (const warn of pluginRuntime.warnings) {
13200
+ log.warn(sanitizeForTerminal(warn));
13201
+ }
13202
+ const composed = composeScanExtensions({
13203
+ noBuiltIns: options.noBuiltIns,
13204
+ pluginRuntime
13205
+ });
13206
+ return buildKindRegistry(composed?.providers ?? []);
13207
+ }
13208
+ function listenAsync(fetchCallback, wss, host, port) {
13209
+ return new Promise((resolveListen, rejectListen) => {
13210
+ let settled = false;
13211
+ const server = serve(
13212
+ {
13213
+ fetch: fetchCallback,
13214
+ hostname: host,
13215
+ port,
13216
+ websocket: { server: wss }
13217
+ },
13218
+ () => {
13219
+ if (settled) return;
13220
+ settled = true;
13221
+ server.removeListener("error", onBindError);
13222
+ resolveListen(server);
13223
+ }
13224
+ );
13225
+ const onBindError = (err) => {
13226
+ if (settled) return;
13227
+ settled = true;
13228
+ rejectListen(err);
13229
+ };
13230
+ server.once("error", onBindError);
13231
+ });
13232
+ }
13233
+ function closeServer(server) {
13234
+ return new Promise((resolveClose, rejectClose) => {
13235
+ server.close((err) => {
13236
+ if (err) {
13237
+ rejectClose(err);
13238
+ } else {
13239
+ resolveClose();
13240
+ }
13241
+ });
13242
+ server.closeAllConnections?.();
13243
+ });
13244
+ }
13245
+ function normalizeAddress(addr, fallbackHost, fallbackPort) {
13246
+ if (addr === null || typeof addr === "string") {
13247
+ return { host: fallbackHost, port: fallbackPort, family: "IPv4" };
13248
+ }
13249
+ return { host: addr.address, port: addr.port, family: addr.family };
13250
+ }
13251
+
13252
+ // cli/i18n/serve.texts.ts
13253
+ var SERVE_TEXTS = {
13254
+ // Banner emitted to stderr after the listener binds. Mirrors `sm watch`
13255
+ // by writing operational status to stderr (stdout is reserved for
13256
+ // future `--json` boot payloads).
13257
+ boot: "sm serve: listening on http://{{host}}:{{port}} (scope={{scope}}, db={{db}})\n",
13258
+ // Hint shown after the boot line. Branches on --open: when auto-open
13259
+ // is on (default), the message states intent ("opening …"); when
13260
+ // --no-open, it instructs the user to visit the URL manually.
13261
+ // Both end with the Ctrl+C reminder so the operational tail is
13262
+ // identical regardless of branch.
13263
+ bootOpening: "sm serve: opening http://{{host}}:{{port}}/ in your browser. Press Ctrl+C to stop.\n",
13264
+ bootVisitHint: "sm serve: visit http://{{host}}:{{port}}/ in your browser. Press Ctrl+C to stop.\n",
13265
+ // Browser-open failure. Non-fatal — the URL is already printed; the
13266
+ // user can open it manually.
13267
+ openFailed: "sm serve: could not auto-open browser ({{message}}). Visit {{url}} manually.\n",
13268
+ // Bind failure (port in use, EACCES, etc.) → ExitCode.Error.
13269
+ bindFailed: "sm serve: failed to bind {{host}}:{{port}} \u2014 {{message}}\n",
13270
+ // Flag-validation failures — ExitCode.Error.
13271
+ hostDevCorsRejected: "sm serve: --dev-cors requires a loopback --host (got {{host}}). Refusing per Decision #119.\n",
13272
+ portOutOfRange: "sm serve: --port must be an integer in [0, 65535] (got {{value}}).\n",
13273
+ portInvalid: "sm serve: --port must be a non-negative integer (got {{value}}).\n",
13274
+ scopeInvalid: 'sm serve: --scope must be "project" or "global" (got {{value}}).\n',
13275
+ // Watcher option failures — ExitCode.Error.
13276
+ 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",
13277
+ watcherDebounceInvalid: "sm serve: --watcher-debounce-ms must be a non-negative integer (got {{value}}).\n",
13278
+ // Generic operational error — surfaced when the server itself throws
13279
+ // before the listener binds (e.g. UI bundle missing under explicit
13280
+ // --ui-dist).
13281
+ startupFailed: "sm serve: startup failed \u2014 {{message}}\n",
13282
+ // DB-not-found (--db <path> doesn't exist) → ExitCode.NotFound.
13283
+ dbNotFound: "sm serve: --db {{path}} does not exist.\n",
13284
+ // Shutdown trace — printed once the listener has closed.
13285
+ shutdown: "sm serve: shutdown complete.\n"
13286
+ };
13287
+
13288
+ // cli/commands/serve.ts
13289
+ var ServeCommand = class extends SmCommand {
13290
+ static paths = [["serve"]];
13291
+ static usage = Command19.Usage({
13292
+ category: "Setup",
13293
+ description: "Start the Hono BFF (single-port: REST + WebSocket + SPA bundle).",
13294
+ details: `
13295
+ Boots the skill-map Web UI's backing server. One Node process
13296
+ serves the Angular SPA, the REST API under /api/*, and the
13297
+ WebSocket at /ws \u2014 single-port mandate, no proxy.
13298
+
13299
+ Default port is 4242, default host is 127.0.0.1. The server boots
13300
+ even when the project DB is missing \u2014 /api/health reports
13301
+ 'db: missing' so the SPA renders an empty-state CTA instead of
13302
+ failing the connection.
13303
+
13304
+ Loopback-only assumption through v0.6.0 (no per-connection auth on
13305
+ /ws). Combining --dev-cors with a non-loopback --host is rejected.
13306
+
13307
+ SIGINT / SIGTERM trigger a graceful shutdown.
13308
+ `,
13309
+ examples: [
13310
+ ["Start on the default port and open the browser", "$0 serve"],
13311
+ ["Custom port, no browser auto-open", "$0 serve --port 5000 --no-open"],
13312
+ ["Use the global scope DB", "$0 serve --scope global"],
13313
+ ["Point at a pre-built UI bundle", "$0 serve --ui-dist ./ui/dist/browser"]
13314
+ ]
13315
+ });
13316
+ port = Option19.String("--port", {
13317
+ required: false,
13318
+ description: "Listening port (default 4242). 0 = OS-assigned."
13319
+ });
13320
+ host = Option19.String("--host", {
13321
+ required: false,
13322
+ description: "Listening host (default 127.0.0.1). Loopback-only enforced when --dev-cors is set."
13323
+ });
13324
+ scope = Option19.String("--scope", {
13325
+ required: false,
13326
+ description: "project | global. Alias for -g/--global. Default: project."
13327
+ });
13328
+ noBuiltIns = Option19.Boolean("--no-built-ins", false, {
13329
+ description: "Skip built-in plugin registration (parity with sm scan --no-built-ins)."
13330
+ });
13331
+ noPlugins = Option19.Boolean("--no-plugins", false, {
13332
+ description: "Skip drop-in plugin discovery."
13333
+ });
13334
+ // `Option.Boolean('--open', true)` — Clipanion's parser auto-derives
13335
+ // the `--no-open` inverse for every boolean flag (search for
13336
+ // `--no-${name.slice(2)}` in clipanion's core), so the explicit
13337
+ // `--no-open` descriptor must NOT be declared here or the parser sees
13338
+ // two registrations for the same flag and rejects the invocation
13339
+ // with "Ambiguous Syntax Error". Same convention shipped by every
13340
+ // other `--no-...` flag in the CLI tree.
13341
+ open = Option19.Boolean("--open", true, {
13342
+ description: "Auto-open the SPA in the user's default browser after listen. --no-open opts out."
13343
+ });
13344
+ devCors = Option19.Boolean("--dev-cors", false, {
13345
+ description: "Enable permissive CORS for the Angular dev-server proxy workflow."
13346
+ });
13347
+ // `--ui-dist` is intentionally undocumented in the Usage block above
13348
+ // (the demo build pipeline + tests rely on it; everyday users never
13349
+ // need it). Clipanion still exposes it on the parser; the Usage
13350
+ // omission is the "hidden" contract per the 14.1 brief.
13351
+ uiDist = Option19.String("--ui-dist", { required: false, hidden: true });
13352
+ noWatcher = Option19.Boolean("--no-watcher", false, {
13353
+ description: "Disable the chokidar-fed scan-and-broadcast loop. Use only for CI / read-only deployments."
13354
+ });
13355
+ // `--watcher-debounce-ms` is undocumented sugar for advanced users
13356
+ // who want to tighten / relax the watcher's batching window without
13357
+ // editing settings.json. Hidden flag — the Usage block omits it.
13358
+ watcherDebounceMs = Option19.String("--watcher-debounce-ms", { required: false, hidden: true });
13359
+ // Long-running daemon — `done in <…>` after a graceful shutdown is
13360
+ // noise. Mirrors `sm watch`'s opt-out.
13361
+ emitElapsed = false;
13362
+ // CLI orchestrator with multi-flag handling — each `if (this.flag)`
13363
+ // branch is one cyclomatic point. Splitting per branch scatters the
13364
+ // validation away from the flag it gates. Per AGENTS.md §Linting
13365
+ // category 1 ("CLI orchestrators with multi-flag handling").
13366
+ // eslint-disable-next-line complexity
13367
+ async run() {
13368
+ const runtimeCtx = defaultRuntimeContext();
13369
+ const scopeResult = resolveScope(this.scope, this.global);
13370
+ if (!scopeResult.ok) {
13371
+ this.context.stderr.write(
13372
+ tx(SERVE_TEXTS.scopeInvalid, { value: sanitizeForTerminal(scopeResult.value) })
13373
+ );
13374
+ return ExitCode.Error;
13375
+ }
13376
+ const scope = scopeResult.scope;
13377
+ if (scope === "global") this.global = true;
13378
+ const portResult = parsePort(this.port);
13379
+ if (!portResult.ok) {
13380
+ this.context.stderr.write(
13381
+ tx(SERVE_TEXTS.portInvalid, { value: sanitizeForTerminal(portResult.value) })
13382
+ );
13383
+ return ExitCode.Error;
13384
+ }
13385
+ const dbPath = resolveDbPath({ global: this.global, db: this.db, ...runtimeCtx });
13386
+ if (this.db !== void 0 && !existsSync18(dbPath)) {
13387
+ this.context.stderr.write(
13388
+ tx(SERVE_TEXTS.dbNotFound, { path: sanitizeForTerminal(dbPath) })
13389
+ );
13390
+ return ExitCode.NotFound;
13391
+ }
13392
+ const uiDistResult = resolveUiDist(runtimeCtx, this.uiDist);
13393
+ if (!uiDistResult.ok) {
13394
+ this.context.stderr.write(
13395
+ tx(SERVE_TEXTS.startupFailed, { message: sanitizeForTerminal(uiDistResult.message) })
13396
+ );
13397
+ return ExitCode.Error;
13398
+ }
13399
+ const debounceResult = parseDebounce(this.watcherDebounceMs);
13400
+ if (!debounceResult.ok) {
13401
+ this.context.stderr.write(
13402
+ tx(SERVE_TEXTS.watcherDebounceInvalid, {
13403
+ value: sanitizeForTerminal(debounceResult.value)
13404
+ })
13405
+ );
13406
+ return ExitCode.Error;
13407
+ }
13408
+ const input = {
13409
+ dbPath,
13410
+ scope,
13411
+ uiDist: uiDistResult.uiDist,
13412
+ noBuiltIns: this.noBuiltIns,
13413
+ noPlugins: this.noPlugins,
13414
+ open: this.open,
13415
+ devCors: this.devCors,
13416
+ noWatcher: this.noWatcher
13417
+ };
13418
+ if (portResult.port !== void 0) input.port = portResult.port;
13419
+ if (this.host !== void 0) input.host = this.host;
13420
+ if (debounceResult.value !== void 0) input.watcherDebounceMs = debounceResult.value;
13421
+ const validation = validateServerOptions(input);
13422
+ if (!validation.ok) {
13423
+ this.context.stderr.write(formatValidationError(validation.error));
13424
+ return ExitCode.Error;
13425
+ }
13426
+ let handle;
13427
+ try {
13428
+ handle = await createServer(validation.options);
13429
+ } catch (err) {
13430
+ const message = formatErrorMessage(err);
13431
+ this.context.stderr.write(
13432
+ tx(SERVE_TEXTS.bindFailed, {
13433
+ host: sanitizeForTerminal(validation.options.host),
13434
+ port: validation.options.port,
13435
+ message: sanitizeForTerminal(message)
13436
+ })
13437
+ );
13438
+ return ExitCode.Error;
13439
+ }
13440
+ this.context.stderr.write(
13441
+ tx(SERVE_TEXTS.boot, {
13442
+ host: sanitizeForTerminal(handle.address.host),
13443
+ port: handle.address.port,
13444
+ scope,
13445
+ db: sanitizeForTerminal(dbPath)
13446
+ })
13447
+ );
13448
+ this.context.stderr.write(
13449
+ tx(validation.options.open ? SERVE_TEXTS.bootOpening : SERVE_TEXTS.bootVisitHint, {
13450
+ host: sanitizeForTerminal(handle.address.host),
13451
+ port: handle.address.port
13452
+ })
13453
+ );
13454
+ if (validation.options.open) {
13455
+ const url = `http://${handle.address.host}:${handle.address.port}/`;
13456
+ tryOpenBrowser(url, this.context.stderr);
13457
+ }
13458
+ await waitForShutdown();
13459
+ await handle.close();
13460
+ this.context.stderr.write(SERVE_TEXTS.shutdown);
13461
+ return ExitCode.Ok;
13462
+ }
13463
+ };
13464
+ function parsePort(raw) {
13465
+ if (raw === void 0) return { ok: true, port: void 0 };
13466
+ const trimmed = raw.trim();
13467
+ const parsed = Number.parseInt(trimmed, 10);
13468
+ if (!Number.isInteger(parsed) || parsed < 0 || String(parsed) !== trimmed) {
13469
+ return { ok: false, value: raw };
13470
+ }
13471
+ return { ok: true, port: parsed };
13472
+ }
13473
+ function parseDebounce(raw) {
13474
+ if (raw === void 0) return { ok: true, value: void 0 };
13475
+ const trimmed = raw.trim();
13476
+ const parsed = Number.parseInt(trimmed, 10);
13477
+ if (!Number.isInteger(parsed) || parsed < 0 || String(parsed) !== trimmed) {
13478
+ return { ok: false, value: raw };
13479
+ }
13480
+ return { ok: true, value: parsed };
13481
+ }
13482
+ function resolveScope(rawScope, global) {
13483
+ if (rawScope === void 0) return { ok: true, scope: global ? "global" : "project" };
13484
+ if (rawScope === "project" || rawScope === "global") {
13485
+ return { ok: true, scope: rawScope };
13486
+ }
13487
+ return { ok: false, value: rawScope };
13488
+ }
13489
+ function resolveUiDist(ctx, raw) {
13490
+ if (raw === void 0) {
13491
+ return { ok: true, uiDist: resolveDefaultUiDist(ctx) };
13492
+ }
13493
+ const abs = resolveExplicitUiDist(ctx, raw);
13494
+ if (!isUiBundleDir(abs)) {
13495
+ return {
13496
+ ok: false,
13497
+ message: `--ui-dist ${abs} does not exist or is not a directory containing index.html`
13498
+ };
13499
+ }
13500
+ return { ok: true, uiDist: abs };
13501
+ }
13502
+ function formatValidationError(err) {
13503
+ switch (err.code) {
13504
+ case "host-dev-cors-rejected":
13505
+ return tx(SERVE_TEXTS.hostDevCorsRejected, { host: sanitizeForTerminal(err.value) });
13506
+ case "port-out-of-range":
13507
+ return tx(SERVE_TEXTS.portOutOfRange, { value: sanitizeForTerminal(err.value) });
13508
+ case "port-invalid":
13509
+ return tx(SERVE_TEXTS.portInvalid, { value: sanitizeForTerminal(err.value) });
13510
+ case "scope-invalid":
13511
+ return tx(SERVE_TEXTS.scopeInvalid, { value: sanitizeForTerminal(err.value) });
13512
+ case "watcher-requires-pipeline":
13513
+ return tx(SERVE_TEXTS.watcherRequiresPipeline, { value: sanitizeForTerminal(err.value) });
13514
+ case "watcher-debounce-invalid":
13515
+ return tx(SERVE_TEXTS.watcherDebounceInvalid, { value: sanitizeForTerminal(err.value) });
13516
+ default:
13517
+ return tx(SERVE_TEXTS.startupFailed, { message: sanitizeForTerminal(err.message) });
13518
+ }
13519
+ }
13520
+ function waitForShutdown() {
13521
+ return new Promise((resolveShutdown) => {
13522
+ const onSignal = () => {
13523
+ process.removeListener("SIGINT", onSignal);
13524
+ process.removeListener("SIGTERM", onSignal);
13525
+ resolveShutdown();
13526
+ };
13527
+ process.once("SIGINT", onSignal);
13528
+ process.once("SIGTERM", onSignal);
13529
+ });
13530
+ }
13531
+ function tryOpenBrowser(url, stderr) {
13532
+ try {
13533
+ const platform = process.platform;
13534
+ let command;
13535
+ let args2;
13536
+ if (platform === "darwin") {
13537
+ command = "open";
13538
+ args2 = [url];
13539
+ } else if (platform === "win32") {
13540
+ command = "cmd";
13541
+ args2 = ["/c", "start", '""', url];
13542
+ } else {
13543
+ command = "xdg-open";
13544
+ args2 = [url];
13545
+ }
13546
+ const child = spawn(command, args2, { detached: true, stdio: "ignore" });
13547
+ child.on("error", (err) => {
13548
+ stderr.write(
13549
+ tx(SERVE_TEXTS.openFailed, {
13550
+ message: sanitizeForTerminal(formatErrorMessage(err)),
13551
+ url: sanitizeForTerminal(url)
13552
+ })
13553
+ );
13554
+ });
13555
+ child.unref();
13556
+ } catch (err) {
13557
+ stderr.write(
13558
+ tx(SERVE_TEXTS.openFailed, {
13559
+ message: sanitizeForTerminal(formatErrorMessage(err)),
13560
+ url: sanitizeForTerminal(url)
13561
+ })
13562
+ );
13563
+ }
13564
+ }
13565
+
13566
+ // cli/commands/show.ts
13567
+ import { Command as Command20, Option as Option20 } from "clipanion";
13568
+
13569
+ // cli/i18n/show.texts.ts
13570
+ var SHOW_TEXTS = {
13571
+ nodeNotFound: "Node not found: {{nodePath}}\n",
13572
+ // --- renderHuman labels ------------------------------------------------
13573
+ sectionFrontmatter: "Frontmatter:",
13574
+ sectionLinksOut: "Links out",
13575
+ sectionLinksIn: "Links in",
13576
+ sectionIssues: "Issues",
13577
+ placeholderNone: " (none)",
13578
+ sectionHeader: "{{label}} ({{count}}, {{unique}} unique):",
13579
+ issuesHeader: "Issues ({{count}}):",
13580
+ issueRow: " - [{{severity}}] {{ruleId}}: {{message}}",
13581
+ // --- formatGroupedLink ------------------------------------------------
13582
+ /**
13583
+ * Bullet line for one grouped link in the in/out lists. `{{kind}}` and
13584
+ * `{{endpoint}}` are pre-sanitized by the caller; `{{dup}}` is the
13585
+ * `(×N)` count when the row collapses multiple identical edges, empty
13586
+ * otherwise; `{{sources}}` is the trailing ` sources: a, b` segment
13587
+ * (empty when the link has no sources).
13588
+ */
13589
+ groupedLinkHead: " - [{{kind}}/{{confidence}}] {{arrow}} {{endpoint}}{{dup}}{{sources}}",
13590
+ groupedLinkDup: " (\xD7{{count}})",
13591
+ groupedLinkSources: " sources: {{values}}",
13592
+ // --- renderNodeHeader labels ------------------------------------------
13593
+ nodeIdentity: "{{path}} [{{kind}}] (provider: {{provider}})",
13594
+ nodeFieldTitle: "title: {{value}}",
13595
+ nodeFieldDescription: "description: {{value}}",
13596
+ nodeFieldStability: "stability: {{value}}",
13597
+ nodeFieldVersion: "version: {{value}}",
13598
+ nodeFieldAuthor: "author: {{value}}",
13599
+ nodeWeight: "Weight: bytes {{total}} total / {{frontmatter}} frontmatter / {{body}} body",
13600
+ nodeTokens: " tokens {{total}} total / {{frontmatter}} frontmatter / {{body}} body",
13601
+ nodeExternalRefs: "External refs: {{count}}"
13602
+ };
13603
+
13604
+ // cli/commands/show.ts
13605
+ var ShowCommand = class extends SmCommand {
13606
+ static paths = [["show"]];
13607
+ static usage = Command20.Usage({
13608
+ category: "Browse",
13609
+ description: "Node detail: weight, frontmatter, links, issues.",
13610
+ details: `
11767
13611
  Loads a single node from the persisted snapshot, plus every link
11768
- (in and out) and every current issue touching it. Findings and
11769
- summaries are reserved slots and remain empty / null until the
11770
- Step 10 / Step 11 features land.
13612
+ (in and out) and every current issue touching it. Step 10
13613
+ (findings) and Step 11 (summary) will add fields when their
13614
+ backing tables ship.
11771
13615
 
11772
13616
  Run \`sm scan\` first to populate the DB.
11773
13617
  `,
@@ -11776,11 +13620,8 @@ var ShowCommand = class extends Command18 {
11776
13620
  ["Machine-readable detail", "$0 show .claude/agents/architect.md --json"]
11777
13621
  ]
11778
13622
  });
11779
- nodePath = Option18.String({ required: true });
11780
- global = Option18.Boolean("-g,--global", false);
11781
- db = Option18.String("--db", { required: false });
11782
- json = Option18.Boolean("--json", false);
11783
- async execute() {
13623
+ nodePath = Option20.String({ required: true });
13624
+ async run() {
11784
13625
  const dbPath = resolveDbPath({ global: this.global, db: this.db, ...defaultRuntimeContext() });
11785
13626
  if (!assertDbExists(dbPath, this.context.stderr)) return ExitCode.NotFound;
11786
13627
  return withSqlite({ databasePath: dbPath, autoBackup: false }, async (adapter) => {
@@ -11793,9 +13634,7 @@ var ShowCommand = class extends Command18 {
11793
13634
  node: bundle.node,
11794
13635
  linksOut: bundle.linksOut,
11795
13636
  linksIn: bundle.linksIn,
11796
- issues: bundle.issues,
11797
- findings: [],
11798
- summary: null
13637
+ issues: bundle.issues
11799
13638
  };
11800
13639
  if (this.json) {
11801
13640
  this.context.stdout.write(JSON.stringify(doc) + "\n");
@@ -11930,7 +13769,7 @@ function rankConfidenceForGrouping(c) {
11930
13769
  }
11931
13770
 
11932
13771
  // cli/commands/stubs.ts
11933
- import { Command as Command19, Option as Option19 } from "clipanion";
13772
+ import { Command as Command21, Option as Option21 } from "clipanion";
11934
13773
 
11935
13774
  // cli/i18n/stubs.texts.ts
11936
13775
  var STUBS_TEXTS = {
@@ -11945,9 +13784,9 @@ function notImplemented(cmd, verb) {
11945
13784
  cmd.context.stderr.write(tx(STUBS_TEXTS.notImplemented, { verb }));
11946
13785
  return ExitCode.Error;
11947
13786
  }
11948
- var DoctorCommand = class extends Command19 {
13787
+ var DoctorCommand = class extends Command21 {
11949
13788
  static paths = [["doctor"]];
11950
- static usage = Command19.Usage({
13789
+ static usage = Command21.Usage({
11951
13790
  category: "Setup",
11952
13791
  description: planned("Diagnostic report: DB integrity, pending migrations, orphan rows, plugin status, runner availability.")
11953
13792
  });
@@ -11955,23 +13794,23 @@ var DoctorCommand = class extends Command19 {
11955
13794
  return notImplemented(this, "doctor");
11956
13795
  }
11957
13796
  };
11958
- var FindingsCommand = class extends Command19 {
13797
+ var FindingsCommand = class extends Command21 {
11959
13798
  static paths = [["findings"]];
11960
- static usage = Command19.Usage({
13799
+ static usage = Command21.Usage({
11961
13800
  category: "Browse",
11962
13801
  description: planned("Probabilistic findings: injection, stale summaries, low confidence.")
11963
13802
  });
11964
- kind = Option19.String("--kind", { required: false });
11965
- since = Option19.String("--since", { required: false });
11966
- threshold = Option19.String("--threshold", { required: false });
11967
- json = Option19.Boolean("--json", false);
13803
+ kind = Option21.String("--kind", { required: false });
13804
+ since = Option21.String("--since", { required: false });
13805
+ threshold = Option21.String("--threshold", { required: false });
13806
+ json = Option21.Boolean("--json", false);
11968
13807
  async execute() {
11969
13808
  return notImplemented(this, "findings");
11970
13809
  }
11971
13810
  };
11972
- var ActionsListCommand = class extends Command19 {
13811
+ var ActionsListCommand = class extends Command21 {
11973
13812
  static paths = [["actions", "list"]];
11974
- static usage = Command19.Usage({
13813
+ static usage = Command21.Usage({
11975
13814
  category: "Jobs",
11976
13815
  description: planned("Registered action types (manifest view).")
11977
13816
  });
@@ -11979,138 +13818,125 @@ var ActionsListCommand = class extends Command19 {
11979
13818
  return notImplemented(this, "actions list");
11980
13819
  }
11981
13820
  };
11982
- var ActionsShowCommand = class extends Command19 {
13821
+ var ActionsShowCommand = class extends Command21 {
11983
13822
  static paths = [["actions", "show"]];
11984
- static usage = Command19.Usage({
13823
+ static usage = Command21.Usage({
11985
13824
  category: "Jobs",
11986
13825
  description: planned("Full action manifest, including preconditions and expected duration.")
11987
13826
  });
11988
- id = Option19.String({ required: true });
13827
+ id = Option21.String({ required: true });
11989
13828
  async execute() {
11990
13829
  return notImplemented(this, "actions show");
11991
13830
  }
11992
13831
  };
11993
- var JobSubmitCommand = class extends Command19 {
13832
+ var JobSubmitCommand = class extends Command21 {
11994
13833
  static paths = [["job", "submit"]];
11995
- static usage = Command19.Usage({
13834
+ static usage = Command21.Usage({
11996
13835
  category: "Jobs",
11997
13836
  description: planned("Enqueue a single job or fan out to every matching node (--all).")
11998
13837
  });
11999
- action = Option19.String({ required: true });
12000
- node = Option19.String("-n", { required: false });
12001
- all = Option19.Boolean("--all", false);
12002
- run = Option19.Boolean("--run", false);
12003
- force = Option19.Boolean("--force", false);
12004
- ttl = Option19.String("--ttl", { required: false });
12005
- priority = Option19.String("--priority", { required: false });
13838
+ action = Option21.String({ required: true });
13839
+ node = Option21.String("-n", { required: false });
13840
+ all = Option21.Boolean("--all", false);
13841
+ run = Option21.Boolean("--run", false);
13842
+ force = Option21.Boolean("--force", false);
13843
+ ttl = Option21.String("--ttl", { required: false });
13844
+ priority = Option21.String("--priority", { required: false });
12006
13845
  async execute() {
12007
13846
  return notImplemented(this, "job submit");
12008
13847
  }
12009
13848
  };
12010
- var JobListCommand = class extends Command19 {
13849
+ var JobListCommand = class extends Command21 {
12011
13850
  static paths = [["job", "list"]];
12012
- static usage = Command19.Usage({ category: "Jobs", description: planned("List jobs.") });
12013
- status = Option19.String("--status", { required: false });
12014
- action = Option19.String("--action", { required: false });
12015
- node = Option19.String("--node", { required: false });
13851
+ static usage = Command21.Usage({ category: "Jobs", description: planned("List jobs.") });
13852
+ status = Option21.String("--status", { required: false });
13853
+ action = Option21.String("--action", { required: false });
13854
+ node = Option21.String("--node", { required: false });
12016
13855
  async execute() {
12017
13856
  return notImplemented(this, "job list");
12018
13857
  }
12019
13858
  };
12020
- var JobShowCommand = class extends Command19 {
13859
+ var JobShowCommand = class extends Command21 {
12021
13860
  static paths = [["job", "show"]];
12022
- static usage = Command19.Usage({ category: "Jobs", description: planned("Job detail: state, claim time, TTL, runner, content hash.") });
12023
- id = Option19.String({ required: true });
13861
+ static usage = Command21.Usage({ category: "Jobs", description: planned("Job detail: state, claim time, TTL, runner, content hash.") });
13862
+ id = Option21.String({ required: true });
12024
13863
  async execute() {
12025
13864
  return notImplemented(this, "job show");
12026
13865
  }
12027
13866
  };
12028
- var JobPreviewCommand = class extends Command19 {
13867
+ var JobPreviewCommand = class extends Command21 {
12029
13868
  static paths = [["job", "preview"]];
12030
- static usage = Command19.Usage({ category: "Jobs", description: planned("Render the job MD file without executing.") });
12031
- id = Option19.String({ required: true });
13869
+ static usage = Command21.Usage({ category: "Jobs", description: planned("Render the job MD file without executing.") });
13870
+ id = Option21.String({ required: true });
12032
13871
  async execute() {
12033
13872
  return notImplemented(this, "job preview");
12034
13873
  }
12035
13874
  };
12036
- var JobClaimCommand = class extends Command19 {
13875
+ var JobClaimCommand = class extends Command21 {
12037
13876
  static paths = [["job", "claim"]];
12038
- static usage = Command19.Usage({
13877
+ static usage = Command21.Usage({
12039
13878
  category: "Jobs",
12040
13879
  description: planned("Atomic primitive: return next queued job id, mark it running.")
12041
13880
  });
12042
- filter = Option19.String("--filter", { required: false });
13881
+ filter = Option21.String("--filter", { required: false });
12043
13882
  async execute() {
12044
13883
  return notImplemented(this, "job claim");
12045
13884
  }
12046
13885
  };
12047
- var JobRunCommand = class extends Command19 {
13886
+ var JobRunCommand = class extends Command21 {
12048
13887
  static paths = [["job", "run"]];
12049
- static usage = Command19.Usage({
13888
+ static usage = Command21.Usage({
12050
13889
  category: "Jobs",
12051
13890
  description: planned("Full CLI-runner loop: claim + spawn + record.")
12052
13891
  });
12053
- all = Option19.Boolean("--all", false);
12054
- max = Option19.String("--max", { required: false });
13892
+ all = Option21.Boolean("--all", false);
13893
+ max = Option21.String("--max", { required: false });
12055
13894
  async execute() {
12056
13895
  return notImplemented(this, "job run");
12057
13896
  }
12058
13897
  };
12059
- var JobStatusCommand = class extends Command19 {
13898
+ var JobStatusCommand = class extends Command21 {
12060
13899
  static paths = [["job", "status"]];
12061
- static usage = Command19.Usage({
13900
+ static usage = Command21.Usage({
12062
13901
  category: "Jobs",
12063
13902
  description: planned("Counts (per status) or single-job status.")
12064
13903
  });
12065
- id = Option19.String({ required: false });
13904
+ id = Option21.String({ required: false });
12066
13905
  async execute() {
12067
13906
  return notImplemented(this, "job status");
12068
13907
  }
12069
13908
  };
12070
- var JobCancelCommand = class extends Command19 {
13909
+ var JobCancelCommand = class extends Command21 {
12071
13910
  static paths = [["job", "cancel"]];
12072
- static usage = Command19.Usage({
13911
+ static usage = Command21.Usage({
12073
13912
  category: "Jobs",
12074
13913
  description: planned("Force a running job to failed with reason user-cancelled.")
12075
13914
  });
12076
- id = Option19.String({ required: false });
12077
- all = Option19.Boolean("--all", false);
13915
+ id = Option21.String({ required: false });
13916
+ all = Option21.Boolean("--all", false);
12078
13917
  async execute() {
12079
13918
  return notImplemented(this, "job cancel");
12080
13919
  }
12081
13920
  };
12082
- var RecordCommand = class extends Command19 {
13921
+ var RecordCommand = class extends Command21 {
12083
13922
  static paths = [["record"]];
12084
- static usage = Command19.Usage({
13923
+ static usage = Command21.Usage({
12085
13924
  category: "Jobs",
12086
13925
  description: planned("Close a running job with success or failure. Nonce is the sole credential.")
12087
13926
  });
12088
- id = Option19.String("--id", { required: true });
12089
- nonce = Option19.String("--nonce", { required: true });
12090
- status = Option19.String("--status", { required: true });
12091
- report = Option19.String("--report", { required: false });
12092
- tokensIn = Option19.String("--tokens-in", { required: false });
12093
- tokensOut = Option19.String("--tokens-out", { required: false });
12094
- durationMs = Option19.String("--duration-ms", { required: false });
12095
- model = Option19.String("--model", { required: false });
12096
- error = Option19.String("--error", { required: false });
13927
+ id = Option21.String("--id", { required: true });
13928
+ nonce = Option21.String("--nonce", { required: true });
13929
+ status = Option21.String("--status", { required: true });
13930
+ report = Option21.String("--report", { required: false });
13931
+ tokensIn = Option21.String("--tokens-in", { required: false });
13932
+ tokensOut = Option21.String("--tokens-out", { required: false });
13933
+ durationMs = Option21.String("--duration-ms", { required: false });
13934
+ model = Option21.String("--model", { required: false });
13935
+ error = Option21.String("--error", { required: false });
12097
13936
  async execute() {
12098
13937
  return notImplemented(this, "record");
12099
13938
  }
12100
13939
  };
12101
- var ServeCommand = class extends Command19 {
12102
- static paths = [["serve"]];
12103
- static usage = Command19.Usage({
12104
- category: "Setup",
12105
- description: planned("Start Hono + WebSocket for the Web UI. Single-port mandate: SPA + REST + WS under one listener.")
12106
- });
12107
- port = Option19.String("--port", { required: false });
12108
- host = Option19.String("--host", { required: false });
12109
- noOpen = Option19.Boolean("--no-open", false);
12110
- async execute() {
12111
- return notImplemented(this, "serve");
12112
- }
12113
- };
12114
13940
  var STUB_COMMANDS = [
12115
13941
  DoctorCommand,
12116
13942
  FindingsCommand,
@@ -12124,12 +13950,11 @@ var STUB_COMMANDS = [
12124
13950
  JobRunCommand,
12125
13951
  JobStatusCommand,
12126
13952
  JobCancelCommand,
12127
- RecordCommand,
12128
- ServeCommand
13953
+ RecordCommand
12129
13954
  ];
12130
13955
 
12131
13956
  // cli/commands/version.ts
12132
- import { Command as Command20, Option as Option20 } from "clipanion";
13957
+ import { Command as Command22 } from "clipanion";
12133
13958
 
12134
13959
  // cli/i18n/version.texts.ts
12135
13960
  var VERSION_TEXTS = {
@@ -12139,17 +13964,19 @@ var VERSION_TEXTS = {
12139
13964
  };
12140
13965
 
12141
13966
  // cli/commands/version.ts
12142
- var VersionCommand = class extends Command20 {
13967
+ var VersionCommand = class extends SmCommand {
12143
13968
  static paths = [["version"]];
12144
- static usage = Command20.Usage({
13969
+ static usage = Command22.Usage({
12145
13970
  category: "Introspection",
12146
13971
  description: "Print the CLI / kernel / spec / runtime / db-schema version matrix."
12147
13972
  });
12148
- json = Option20.Boolean("--json", false);
12149
- async execute() {
13973
+ // Informational verb — no `done in <…>` line; the version matrix is
13974
+ // the entire output.
13975
+ emitElapsed = false;
13976
+ async run() {
12150
13977
  const runtime = `Node ${process.version}`;
12151
13978
  const kernelVersion = VERSION;
12152
- const specVersion = await resolveSpecVersion2();
13979
+ const specVersion = await resolveSpecVersion3();
12153
13980
  const dbSchema = await resolveDbSchemaVersion();
12154
13981
  if (this.json) {
12155
13982
  const payload = {
@@ -12175,7 +14002,7 @@ var VersionCommand = class extends Command20 {
12175
14002
  return ExitCode.Ok;
12176
14003
  }
12177
14004
  };
12178
- async function resolveSpecVersion2() {
14005
+ async function resolveSpecVersion3() {
12179
14006
  try {
12180
14007
  const mod = await import("@skill-map/spec", { with: { type: "json" } });
12181
14008
  const version = mod.default?.specPackageVersion;
@@ -12211,6 +14038,7 @@ cli.register(HelpCommand);
12211
14038
  cli.register(InitCommand);
12212
14039
  cli.register(ScanCommand);
12213
14040
  cli.register(ScanCompareCommand);
14041
+ cli.register(ServeCommand);
12214
14042
  cli.register(WatchCommand);
12215
14043
  cli.register(VersionCommand);
12216
14044
  cli.register(ListCommand);