@skill-map/cli 0.9.0 → 0.10.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 }));
@@ -1367,8 +1386,15 @@ import { readFileSync as readFileSync2 } from "fs";
1367
1386
  import { dirname as dirname2, resolve as resolve3 } from "path";
1368
1387
  import { createRequire } from "module";
1369
1388
  import { Ajv2020 } from "ajv/dist/2020.js";
1389
+
1390
+ // kernel/util/ajv-interop.ts
1370
1391
  import addFormatsModule from "ajv-formats";
1371
1392
  var addFormats = addFormatsModule.default ?? addFormatsModule;
1393
+ function applyAjvFormats(ajv) {
1394
+ addFormats(ajv);
1395
+ }
1396
+
1397
+ // kernel/adapters/schema-validators.ts
1372
1398
  var SCHEMA_FILES = {
1373
1399
  node: "schemas/node.schema.json",
1374
1400
  link: "schemas/link.schema.json",
@@ -1407,7 +1433,7 @@ function buildSchemaValidators() {
1407
1433
  allErrors: true,
1408
1434
  allowUnionTypes: true
1409
1435
  });
1410
- addFormats(ajv);
1436
+ applyAjvFormats(ajv);
1411
1437
  for (const rel of SUPPORTING_SCHEMAS) {
1412
1438
  const file = resolve3(specRoot, rel);
1413
1439
  if (!existsSyncSafe(file)) continue;
@@ -1462,7 +1488,7 @@ function buildProviderFrontmatterValidator(providers) {
1462
1488
  allErrors: true,
1463
1489
  allowUnionTypes: true
1464
1490
  });
1465
- addFormats(ajv);
1491
+ applyAjvFormats(ajv);
1466
1492
  const baseFile = resolve3(specRoot, "schemas/frontmatter/base.schema.json");
1467
1493
  const baseSchema = JSON.parse(readFileSync2(baseFile, "utf8"));
1468
1494
  ajv.addSchema(baseSchema);
@@ -1683,7 +1709,6 @@ import { existsSync as existsSync3, readFileSync as readFileSync3, readdirSync }
1683
1709
  import { isAbsolute, join as join3, relative as relative2, resolve as resolve4 } from "path";
1684
1710
  import { pathToFileURL } from "url";
1685
1711
  import { Ajv2020 as Ajv20202 } from "ajv/dist/2020.js";
1686
- import addFormatsModule2 from "ajv-formats";
1687
1712
  import semver from "semver";
1688
1713
 
1689
1714
  // kernel/i18n/plugin-loader.texts.ts
@@ -1728,7 +1753,6 @@ var HOOK_TRIGGERS = Object.freeze([
1728
1753
  ]);
1729
1754
 
1730
1755
  // kernel/adapters/plugin-loader.ts
1731
- var addFormats2 = addFormatsModule2.default ?? addFormatsModule2;
1732
1756
  var DEFAULT_PLUGIN_IMPORT_TIMEOUT_MS = 5e3;
1733
1757
  function createPluginLoader(options) {
1734
1758
  return new PluginLoader(options);
@@ -2243,7 +2267,7 @@ function compilePluginSchema(pluginPath, relPath) {
2243
2267
  }
2244
2268
  try {
2245
2269
  const ajv = new Ajv20202({ strict: false, allErrors: true, allowUnionTypes: true });
2246
- addFormats2(ajv);
2270
+ applyAjvFormats(ajv);
2247
2271
  const compiled = ajv.compile(raw);
2248
2272
  return { ok: true, validate: compiled };
2249
2273
  } catch (err) {
@@ -2628,7 +2652,7 @@ var AsyncMutex = class {
2628
2652
  this.#locked = true;
2629
2653
  return;
2630
2654
  }
2631
- await new Promise((resolve19) => this.#waiters.push(resolve19));
2655
+ await new Promise((resolve20) => this.#waiters.push(resolve20));
2632
2656
  this.#locked = true;
2633
2657
  }
2634
2658
  unlock() {
@@ -4627,11 +4651,111 @@ function formatWarning(plugin) {
4627
4651
  });
4628
4652
  }
4629
4653
 
4654
+ // cli/util/sm-command.ts
4655
+ import { Command, Option } from "clipanion";
4656
+
4657
+ // cli/util/elapsed.ts
4658
+ function startElapsed() {
4659
+ const startNs = process.hrtime.bigint();
4660
+ return {
4661
+ ms() {
4662
+ const elapsedNs = Number(process.hrtime.bigint() - startNs);
4663
+ return Math.round(elapsedNs / 1e6);
4664
+ },
4665
+ formatted() {
4666
+ return formatElapsed(this.ms());
4667
+ }
4668
+ };
4669
+ }
4670
+ function formatElapsed(ms) {
4671
+ if (ms < 1e3) return `${ms}ms`;
4672
+ if (ms < 6e4) return `${(ms / 1e3).toFixed(1)}s`;
4673
+ const minutes = Math.floor(ms / 6e4);
4674
+ const seconds = Math.round(ms % 6e4 / 1e3);
4675
+ return `${minutes}m ${seconds}s`;
4676
+ }
4677
+ function emitDoneStderr(stderr, elapsed, quiet = false) {
4678
+ if (quiet) return;
4679
+ stderr.write(tx(UTIL_TEXTS.doneIn, { elapsed: elapsed.formatted() }));
4680
+ }
4681
+
4682
+ // cli/util/sm-command.ts
4683
+ function isEnvSet(value) {
4684
+ return value !== void 0 && value !== "";
4685
+ }
4686
+ var SmCommand = class extends Command {
4687
+ global = Option.Boolean("-g,--global", false, {
4688
+ description: "Operate on ~/.skill-map/ instead of ./.skill-map/."
4689
+ });
4690
+ json = Option.Boolean("--json", false, {
4691
+ description: "Emit machine-readable output on stdout. Suppresses pretty printing."
4692
+ });
4693
+ quiet = Option.Boolean("-q,--quiet", false, {
4694
+ description: 'Suppress non-error stderr output (including "done in <\u2026>").'
4695
+ });
4696
+ noColor = Option.Boolean("--no-color", false, {
4697
+ description: "Disable ANSI color codes."
4698
+ });
4699
+ verbose = Option.Counter("-v,--verbose", 0, {
4700
+ description: "Increase log level (-v=info, -vv=debug, -vvv=trace)."
4701
+ });
4702
+ db = Option.String("--db", { required: false, description: "Override the database file location (escape hatch)." });
4703
+ /**
4704
+ * Subclasses set this to `false` to opt out of the trailing
4705
+ * `done in <…>` line — appropriate for interactive verbs (`db shell`),
4706
+ * watcher loops (`watch`), and meta verbs that report a fixed
4707
+ * version (`version`, `help`).
4708
+ */
4709
+ emitElapsed = true;
4710
+ /**
4711
+ * Wall-clock timer started just before `run()`. Subclasses that need
4712
+ * to embed `elapsedMs` in their `--json` output read `this.elapsed.ms()`.
4713
+ * `null` only between `Command` construction and the first
4714
+ * `execute()` call.
4715
+ */
4716
+ elapsed = null;
4717
+ async execute() {
4718
+ this.applyEnvOverrides();
4719
+ this.applyVerboseLogger();
4720
+ this.elapsed = startElapsed();
4721
+ try {
4722
+ return await this.run();
4723
+ } finally {
4724
+ if (this.emitElapsed) emitDoneStderr(this.context.stderr, this.elapsed, this.quiet);
4725
+ }
4726
+ }
4727
+ /**
4728
+ * Promote spec env vars into flag values when the flag was left at
4729
+ * default. CLI flag wins over env var (spec § Global flags
4730
+ * precedence: "CLI flag wins over env var. Env var wins over config
4731
+ * file.").
4732
+ */
4733
+ applyEnvOverrides() {
4734
+ const env = process.env;
4735
+ this.noColor = this.noColor || isEnvSet(env["NO_COLOR"]);
4736
+ this.global = this.global || env["SKILL_MAP_SCOPE"] === "global";
4737
+ this.json = this.json || isEnvSet(env["SKILL_MAP_JSON"]);
4738
+ if (this.db === void 0 && isEnvSet(env["SKILL_MAP_DB"])) {
4739
+ this.db = env["SKILL_MAP_DB"];
4740
+ }
4741
+ }
4742
+ /**
4743
+ * `-v` / `-vv` / `-vvv` reconfigures the kernel logger. Skipped
4744
+ * when `verbose === 0` so the level configured at `entry.ts` boot
4745
+ * (from `--log-level` / `SKILL_MAP_LOG_LEVEL`) stays in effect.
4746
+ */
4747
+ applyVerboseLogger() {
4748
+ if (this.verbose <= 0) return;
4749
+ const level = this.verbose >= 3 ? "trace" : this.verbose === 2 ? "debug" : "info";
4750
+ configureLogger(new Logger({ level, stream: process.stderr }));
4751
+ }
4752
+ };
4753
+
4630
4754
  // cli/commands/check.ts
4631
4755
  var SEVERITY_ORDER = ["error", "warn", "info"];
4632
- var CheckCommand = class extends Command {
4756
+ var CheckCommand = class extends SmCommand {
4633
4757
  static paths = [["check"]];
4634
- static usage = Command.Usage({
4758
+ static usage = Command2.Usage({
4635
4759
  category: "Browse",
4636
4760
  description: "Print all current issues (reads from DB, faster than sm scan --json | jq).",
4637
4761
  details: `
@@ -4656,27 +4780,24 @@ var CheckCommand = class extends Command {
4656
4780
  ["Use a non-default DB file", "$0 check --db /path/to/skill-map.db"]
4657
4781
  ]
4658
4782
  });
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", {
4783
+ node = Option2.String("-n,--node", {
4663
4784
  required: false,
4664
4785
  description: "Restrict to issues whose nodeIds include the given path. Combines with --rules and --include-prob."
4665
4786
  });
4666
- rules = Option.String("--rules", {
4787
+ rules = Option2.String("--rules", {
4667
4788
  required: false,
4668
4789
  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
4790
  });
4670
- includeProb = Option.Boolean("--include-prob", false, {
4791
+ includeProb = Option2.Boolean("--include-prob", false, {
4671
4792
  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
4793
  });
4673
- async = Option.Boolean("--async", false, {
4794
+ async = Option2.Boolean("--async", false, {
4674
4795
  description: "Reserved companion to --include-prob: once jobs ship, returns job ids without waiting. No effect today."
4675
4796
  });
4676
- noPlugins = Option.Boolean("--no-plugins", false, {
4797
+ noPlugins = Option2.Boolean("--no-plugins", false, {
4677
4798
  description: "Skip drop-in plugin discovery; only kernel built-ins participate in the prob detection. Same flag shape as `sm scan`."
4678
4799
  });
4679
- async execute() {
4800
+ async run() {
4680
4801
  const dbPath = resolveDbPath({ global: this.global, db: this.db, ...defaultRuntimeContext() });
4681
4802
  if (!assertDbExists(dbPath, this.context.stderr)) return ExitCode.NotFound;
4682
4803
  const ruleFilter = parseRulesFlag(this.rules);
@@ -4785,32 +4906,7 @@ import {
4785
4906
  writeFileSync
4786
4907
  } from "fs";
4787
4908
  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
- }
4909
+ import { Command as Command3, Option as Option3 } from "clipanion";
4814
4910
 
4815
4911
  // cli/util/error-reporter.ts
4816
4912
  function formatErrorMessage(err) {
@@ -4970,9 +5066,9 @@ function formatValueHuman(v) {
4970
5066
  if (Array.isArray(v) || typeof v === "object" && v !== null) return JSON.stringify(v);
4971
5067
  return String(v);
4972
5068
  }
4973
- var ConfigListCommand = class extends Command2 {
5069
+ var ConfigListCommand = class extends SmCommand {
4974
5070
  static paths = [["config", "list"]];
4975
- static usage = Command2.Usage({
5071
+ static usage = Command3.Usage({
4976
5072
  category: "Config",
4977
5073
  description: "Print the effective config after layered merge.",
4978
5074
  details: `
@@ -4981,10 +5077,11 @@ var ConfigListCommand = class extends Command2 {
4981
5077
  Exempt from "done in <\u2026>" per spec/cli-contract.md \xA7Elapsed time.
4982
5078
  `
4983
5079
  });
4984
- json = Option2.Boolean("--json", false);
4985
- global = Option2.Boolean("-g,--global", false);
4986
- strict = Option2.Boolean("--strict", false);
4987
- async execute() {
5080
+ strict = Option3.Boolean("--strict", false);
5081
+ // Read-only config inspection: spec § Elapsed time exempts the
5082
+ // config family from the trailing "done in" line.
5083
+ emitElapsed = false;
5084
+ async run() {
4988
5085
  const result = tryLoadConfig(
4989
5086
  { scope: this.global ? "global" : "project", strict: this.strict, ...defaultRuntimeContext() },
4990
5087
  this.context.stderr
@@ -5005,9 +5102,9 @@ var ConfigListCommand = class extends Command2 {
5005
5102
  return ExitCode.Ok;
5006
5103
  }
5007
5104
  };
5008
- var ConfigGetCommand = class extends Command2 {
5105
+ var ConfigGetCommand = class extends SmCommand {
5009
5106
  static paths = [["config", "get"]];
5010
- static usage = Command2.Usage({
5107
+ static usage = Command3.Usage({
5011
5108
  category: "Config",
5012
5109
  description: "Read a single config value by dot-path key.",
5013
5110
  details: `
@@ -5015,11 +5112,10 @@ var ConfigGetCommand = class extends Command2 {
5015
5112
  Exempt from "done in <\u2026>".
5016
5113
  `
5017
5114
  });
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() {
5115
+ key = Option3.String({ required: true });
5116
+ strict = Option3.Boolean("--strict", false);
5117
+ emitElapsed = false;
5118
+ async run() {
5023
5119
  const result = tryLoadConfig(
5024
5120
  { scope: this.global ? "global" : "project", strict: this.strict, ...defaultRuntimeContext() },
5025
5121
  this.context.stderr
@@ -5049,9 +5145,9 @@ var ConfigGetCommand = class extends Command2 {
5049
5145
  return ExitCode.Ok;
5050
5146
  }
5051
5147
  };
5052
- var ConfigShowCommand = class extends Command2 {
5148
+ var ConfigShowCommand = class extends SmCommand {
5053
5149
  static paths = [["config", "show"]];
5054
- static usage = Command2.Usage({
5150
+ static usage = Command3.Usage({
5055
5151
  category: "Config",
5056
5152
  description: "Show a config value with the layer that set it (--source).",
5057
5153
  details: `
@@ -5061,17 +5157,16 @@ var ConfigShowCommand = class extends Command2 {
5061
5157
  Exempt from "done in <\u2026>".
5062
5158
  `
5063
5159
  });
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);
5160
+ key = Option3.String({ required: true });
5161
+ source = Option3.Boolean("--source", false);
5162
+ strict = Option3.Boolean("--strict", false);
5163
+ emitElapsed = false;
5069
5164
  // CLI orchestrator: each branch (load failure, forbidden segment,
5070
5165
  // unknown key, --json + --source 2x2 dispatch) is one validation gate
5071
5166
  // or output-format pick. Splitting per branch scatters the gate from
5072
5167
  // the value it gates.
5073
5168
  // eslint-disable-next-line complexity
5074
- async execute() {
5169
+ async run() {
5075
5170
  const result = tryLoadConfig(
5076
5171
  { scope: this.global ? "global" : "project", strict: this.strict, ...defaultRuntimeContext() },
5077
5172
  this.context.stderr
@@ -5134,9 +5229,9 @@ var LAYER_RANK = {
5134
5229
  "project-local": 4,
5135
5230
  override: 5
5136
5231
  };
5137
- var ConfigSetCommand = class extends Command2 {
5232
+ var ConfigSetCommand = class extends SmCommand {
5138
5233
  static paths = [["config", "set"]];
5139
- static usage = Command2.Usage({
5234
+ static usage = Command3.Usage({
5140
5235
  category: "Config",
5141
5236
  description: "Write a config key. Project file by default; -g writes to user.",
5142
5237
  details: `
@@ -5147,11 +5242,9 @@ var ConfigSetCommand = class extends Command2 {
5147
5242
  Schema violation \u2192 exit 2, no write performed.
5148
5243
  `
5149
5244
  });
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();
5245
+ key = Option3.String({ required: true });
5246
+ value = Option3.String({ required: true });
5247
+ async run() {
5155
5248
  const ctx = defaultRuntimeContext();
5156
5249
  const target = this.global ? "user" : "project";
5157
5250
  const path = targetSettingsPath(target, ctx.cwd, ctx.homedir);
@@ -5162,7 +5255,6 @@ var ConfigSetCommand = class extends Command2 {
5162
5255
  } catch (err) {
5163
5256
  if (err instanceof ForbiddenSegmentError) {
5164
5257
  this.context.stderr.write(tx(CONFIG_TEXTS.forbiddenKeySegment, { segment: err.segment, key: err.key }));
5165
- emitDoneStderr(this.context.stderr, elapsed);
5166
5258
  return ExitCode.Error;
5167
5259
  }
5168
5260
  throw err;
@@ -5171,18 +5263,16 @@ var ConfigSetCommand = class extends Command2 {
5171
5263
  const result = validators.validate("project-config", current);
5172
5264
  if (!result.ok) {
5173
5265
  this.context.stderr.write(tx(CONFIG_TEXTS.invalidAfterSet, { errors: result.errors }));
5174
- emitDoneStderr(this.context.stderr, elapsed);
5175
5266
  return ExitCode.Error;
5176
5267
  }
5177
5268
  writeJsonAtomic(path, current);
5178
5269
  this.context.stdout.write(tx(CONFIG_TEXTS.setWritten, { key: this.key, value: formatValueHuman(value), path }));
5179
- emitDoneStderr(this.context.stderr, elapsed);
5180
5270
  return ExitCode.Ok;
5181
5271
  }
5182
5272
  };
5183
- var ConfigResetCommand = class extends Command2 {
5273
+ var ConfigResetCommand = class extends SmCommand {
5184
5274
  static paths = [["config", "reset"]];
5185
- static usage = Command2.Usage({
5275
+ static usage = Command3.Usage({
5186
5276
  category: "Config",
5187
5277
  description: "Remove a config key from the target file (project default; -g for user).",
5188
5278
  details: `
@@ -5190,16 +5280,13 @@ var ConfigResetCommand = class extends Command2 {
5190
5280
  Idempotent \u2014 running twice is safe; absent key prints an info note and exits 0.
5191
5281
  `
5192
5282
  });
5193
- key = Option2.String({ required: true });
5194
- global = Option2.Boolean("-g,--global", false);
5195
- async execute() {
5196
- const elapsed = startElapsed();
5283
+ key = Option3.String({ required: true });
5284
+ async run() {
5197
5285
  const ctx = defaultRuntimeContext();
5198
5286
  const target = this.global ? "user" : "project";
5199
5287
  const path = targetSettingsPath(target, ctx.cwd, ctx.homedir);
5200
5288
  if (!existsSync8(path)) {
5201
5289
  this.context.stdout.write(tx(CONFIG_TEXTS.unsetNoOverride, { path, key: this.key }));
5202
- emitDoneStderr(this.context.stderr, elapsed);
5203
5290
  return ExitCode.Ok;
5204
5291
  }
5205
5292
  const current = readJsonObjectOrEmpty(path);
@@ -5209,19 +5296,16 @@ var ConfigResetCommand = class extends Command2 {
5209
5296
  } catch (err) {
5210
5297
  if (err instanceof ForbiddenSegmentError) {
5211
5298
  this.context.stderr.write(tx(CONFIG_TEXTS.forbiddenKeySegment, { segment: err.segment, key: err.key }));
5212
- emitDoneStderr(this.context.stderr, elapsed);
5213
5299
  return ExitCode.Error;
5214
5300
  }
5215
5301
  throw err;
5216
5302
  }
5217
5303
  if (!removed) {
5218
5304
  this.context.stdout.write(tx(CONFIG_TEXTS.unsetNoOverride, { path, key: this.key }));
5219
- emitDoneStderr(this.context.stderr, elapsed);
5220
5305
  return ExitCode.Ok;
5221
5306
  }
5222
5307
  writeJsonAtomic(path, current);
5223
5308
  this.context.stdout.write(tx(CONFIG_TEXTS.unsetRemoved, { key: this.key, path }));
5224
- emitDoneStderr(this.context.stderr, elapsed);
5225
5309
  return ExitCode.Ok;
5226
5310
  }
5227
5311
  };
@@ -5237,7 +5321,7 @@ var CONFIG_COMMANDS = [
5237
5321
  import { existsSync as existsSync11, readFileSync as readFileSync9 } from "fs";
5238
5322
  import { dirname as dirname7, resolve as resolve11 } from "path";
5239
5323
  import { fileURLToPath as fileURLToPath4 } from "url";
5240
- import { Command as Command3, Option as Option3 } from "clipanion";
5324
+ import { Command as Command4, Option as Option4 } from "clipanion";
5241
5325
 
5242
5326
  // conformance/index.ts
5243
5327
  import { spawnSync } from "child_process";
@@ -5700,9 +5784,9 @@ function resolveBinary() {
5700
5784
  }
5701
5785
  return resolve11(here, "..", "..", "bin", "sm.js");
5702
5786
  }
5703
- var ConformanceRunCommand = class extends Command3 {
5787
+ var ConformanceRunCommand = class extends SmCommand {
5704
5788
  static paths = [["conformance", "run"]];
5705
- static usage = Command3.Usage({
5789
+ static usage = Command4.Usage({
5706
5790
  category: "Introspection",
5707
5791
  description: "Run the conformance suite \u2014 spec-owned cases plus every built-in Provider.",
5708
5792
  details: `
@@ -5736,14 +5820,14 @@ var ConformanceRunCommand = class extends Command3 {
5736
5820
  ]
5737
5821
  ]
5738
5822
  });
5739
- scope = Option3.String("--scope", {
5823
+ scope = Option4.String("--scope", {
5740
5824
  required: false,
5741
5825
  description: "Suite selector: 'all' (default), 'spec', or 'provider:<id>'."
5742
5826
  });
5743
5827
  // CLI orchestrator: scope resolution + per-case run loop +
5744
5828
  // per-result render branches + global pass/fail decision.
5745
5829
  // eslint-disable-next-line complexity
5746
- async execute() {
5830
+ async run() {
5747
5831
  let scopes;
5748
5832
  try {
5749
5833
  scopes = selectConformanceScopes(this.scope);
@@ -5864,7 +5948,7 @@ var CONFORMANCE_COMMANDS = [ConformanceRunCommand];
5864
5948
 
5865
5949
  // cli/commands/db.ts
5866
5950
  import { spawnSync as spawnSync2 } from "child_process";
5867
- import { chmod, copyFile, mkdir, rm, stat as stat2 } from "fs/promises";
5951
+ import { chmod, copyFile, mkdir, rm } from "fs/promises";
5868
5952
  import { dirname as dirname8, join as join9, resolve as resolve12 } from "path";
5869
5953
  import { DatabaseSync as DatabaseSync4 } from "node:sqlite";
5870
5954
 
@@ -5940,13 +6024,10 @@ var DB_TEXTS = {
5940
6024
  };
5941
6025
 
5942
6026
  // 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
- }
6027
+ import { Command as Command5, Option as Option5 } from "clipanion";
6028
+
6029
+ // cli/util/fs.ts
6030
+ import { stat as stat2 } from "fs/promises";
5950
6031
  async function pathExists(path) {
5951
6032
  try {
5952
6033
  await stat2(path);
@@ -5964,15 +6045,23 @@ async function statOrNull(path) {
5964
6045
  throw err;
5965
6046
  }
5966
6047
  }
6048
+
6049
+ // cli/commands/db.ts
6050
+ var SAFE_SQL_IDENTIFIER_RE = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
6051
+ function assertSafeIdentifier(name) {
6052
+ if (!SAFE_SQL_IDENTIFIER_RE.test(name)) {
6053
+ throw new Error(`refusing to operate on non-identifier table name: ${JSON.stringify(name)}`);
6054
+ }
6055
+ }
5967
6056
  async function chmodOwnerOnlyBestEffort(target) {
5968
6057
  try {
5969
6058
  await chmod(target, 384);
5970
6059
  } catch {
5971
6060
  }
5972
6061
  }
5973
- var DbBackupCommand = class extends Command4 {
6062
+ var DbBackupCommand = class extends SmCommand {
5974
6063
  static paths = [["db", "backup"]];
5975
- static usage = Command4.Usage({
6064
+ static usage = Command5.Usage({
5976
6065
  category: "Database",
5977
6066
  description: "WAL checkpoint + copy the DB file to a backup.",
5978
6067
  details: `
@@ -5982,10 +6071,8 @@ var DbBackupCommand = class extends Command4 {
5982
6071
  running sm scan afterwards refreshes scan_*.
5983
6072
  `
5984
6073
  });
5985
- global = Option4.Boolean("-g,--global", false);
5986
- db = Option4.String("--db", { required: false });
5987
- out = Option4.String("--out", { required: false });
5988
- async execute() {
6074
+ out = Option5.String("--out", { required: false });
6075
+ async run() {
5989
6076
  const path = resolveDbPath({ global: this.global, db: this.db, ...defaultRuntimeContext() });
5990
6077
  if (!assertDbExists(path, this.context.stderr)) return ExitCode.NotFound;
5991
6078
  const ts = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
@@ -5997,9 +6084,9 @@ var DbBackupCommand = class extends Command4 {
5997
6084
  return ExitCode.Ok;
5998
6085
  }
5999
6086
  };
6000
- var DbRestoreCommand = class extends Command4 {
6087
+ var DbRestoreCommand = class extends SmCommand {
6001
6088
  static paths = [["db", "restore"]];
6002
- static usage = Command4.Usage({
6089
+ static usage = Command5.Usage({
6003
6090
  category: "Database",
6004
6091
  description: "Replace the active DB file with a backup.",
6005
6092
  details: `
@@ -6010,14 +6097,12 @@ var DbRestoreCommand = class extends Command4 {
6010
6097
  Dry-run bypasses the confirmation prompt.
6011
6098
  `
6012
6099
  });
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, {
6100
+ source = Option5.String({ required: true });
6101
+ yes = Option5.Boolean("--yes,--force", false);
6102
+ dryRun = Option5.Boolean("-n,--dry-run", false, {
6018
6103
  description: "Preview the restore without overwriting the live DB."
6019
6104
  });
6020
- async execute() {
6105
+ async run() {
6021
6106
  const target = resolveDbPath({ global: this.global, db: this.db, ...defaultRuntimeContext() });
6022
6107
  const sourcePath = resolve12(this.source);
6023
6108
  const sourceStat = await statOrNull(sourcePath);
@@ -6059,9 +6144,9 @@ var DbRestoreCommand = class extends Command4 {
6059
6144
  return ExitCode.Ok;
6060
6145
  }
6061
6146
  };
6062
- var DbResetCommand = class extends Command4 {
6147
+ var DbResetCommand = class extends SmCommand {
6063
6148
  static paths = [["db", "reset"]];
6064
- static usage = Command4.Usage({
6149
+ static usage = Command5.Usage({
6065
6150
  category: "Database",
6066
6151
  description: "Drop scan_* (default), optionally state_*, or delete the DB entirely.",
6067
6152
  details: `
@@ -6075,12 +6160,10 @@ var DbResetCommand = class extends Command4 {
6075
6160
  preview itself is non-destructive).
6076
6161
  `
6077
6162
  });
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, {
6163
+ state = Option5.Boolean("--state", false);
6164
+ hard = Option5.Boolean("--hard", false);
6165
+ yes = Option5.Boolean("--yes,--force", false);
6166
+ dryRun = Option5.Boolean("-n,--dry-run", false, {
6084
6167
  description: "Preview the reset without dropping any tables or unlinking any files."
6085
6168
  });
6086
6169
  // CLI orchestrator: --state vs --hard flag combo + --dry-run + --yes
@@ -6088,7 +6171,7 @@ var DbResetCommand = class extends Command4 {
6088
6171
  // expression of the flag semantics; splitting per branch would
6089
6172
  // distance the validations from their guards.
6090
6173
  // eslint-disable-next-line complexity
6091
- async execute() {
6174
+ async run() {
6092
6175
  if (this.state && this.hard) {
6093
6176
  this.context.stderr.write(DB_TEXTS.resetStateAndHardMutex);
6094
6177
  return ExitCode.Error;
@@ -6176,9 +6259,9 @@ var DbResetCommand = class extends Command4 {
6176
6259
  return ExitCode.Ok;
6177
6260
  }
6178
6261
  };
6179
- var DbShellCommand = class extends Command4 {
6262
+ var DbShellCommand = class extends SmCommand {
6180
6263
  static paths = [["db", "shell"]];
6181
- static usage = Command4.Usage({
6264
+ static usage = Command5.Usage({
6182
6265
  category: "Database",
6183
6266
  description: "Open an interactive sqlite3 shell on the DB file.",
6184
6267
  details: `
@@ -6187,9 +6270,11 @@ var DbShellCommand = class extends Command4 {
6187
6270
  sm db dump for a read-only inspection.
6188
6271
  `
6189
6272
  });
6190
- global = Option4.Boolean("-g,--global", false);
6191
- db = Option4.String("--db", { required: false });
6192
- async execute() {
6273
+ // Interactive shell: the spawned `sqlite3` owns the terminal. No
6274
+ // `done in <…>` line the user expects to see the shell's own
6275
+ // prompt + farewell, not a follow-up trailer once they exit.
6276
+ emitElapsed = false;
6277
+ async run() {
6193
6278
  const path = resolveDbPath({ global: this.global, db: this.db, ...defaultRuntimeContext() });
6194
6279
  if (!assertDbExists(path, this.context.stderr)) return ExitCode.NotFound;
6195
6280
  const result = spawnSync2("sqlite3", [path], { stdio: "inherit" });
@@ -6200,22 +6285,20 @@ var DbShellCommand = class extends Command4 {
6200
6285
  return result.status ?? 0;
6201
6286
  }
6202
6287
  };
6203
- var DbDumpCommand = class extends Command4 {
6288
+ var DbDumpCommand = class extends SmCommand {
6204
6289
  static paths = [["db", "dump"]];
6205
- static usage = Command4.Usage({
6290
+ static usage = Command5.Usage({
6206
6291
  category: "Database",
6207
6292
  description: "SQL dump to stdout.",
6208
6293
  details: "Read-only. Use --tables <names...> to limit the dump to specific tables."
6209
6294
  });
6210
- global = Option4.Boolean("-g,--global", false);
6211
- db = Option4.String("--db", { required: false });
6212
- tables = Option4.Array("--tables", { required: false });
6295
+ tables = Option5.Array("--tables", { required: false });
6213
6296
  // CLI orchestrator: each branch (db existence, per-table identifier
6214
6297
  // gate, sqlite3-not-found fallback, exit-status passthrough) is a
6215
6298
  // single dispatcher decision. Splitting per branch scatters the gate
6216
6299
  // away from the value it gates.
6217
6300
  // eslint-disable-next-line complexity
6218
- async execute() {
6301
+ async run() {
6219
6302
  const path = resolveDbPath({ global: this.global, db: this.db, ...defaultRuntimeContext() });
6220
6303
  if (!assertDbExists(path, this.context.stderr)) return ExitCode.NotFound;
6221
6304
  const args2 = ["-readonly", path, ".dump"];
@@ -6236,9 +6319,9 @@ var DbDumpCommand = class extends Command4 {
6236
6319
  return result.status ?? 0;
6237
6320
  }
6238
6321
  };
6239
- var DbMigrateCommand = class extends Command4 {
6322
+ var DbMigrateCommand = class extends SmCommand {
6240
6323
  static paths = [["db", "migrate"]];
6241
- static usage = Command4.Usage({
6324
+ static usage = Command5.Usage({
6242
6325
  category: "Database",
6243
6326
  description: "Apply pending kernel + plugin migrations (default) or inspect plan.",
6244
6327
  details: `
@@ -6259,21 +6342,19 @@ var DbMigrateCommand = class extends Command4 {
6259
6342
  object outside the prefix.
6260
6343
  `
6261
6344
  });
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 });
6345
+ dryRun = Option5.Boolean("-n,--dry-run", false);
6346
+ status = Option5.Boolean("--status", false);
6347
+ to = Option5.String("--to", { required: false });
6348
+ noBackup = Option5.Boolean("--no-backup", false);
6349
+ kernelOnly = Option5.Boolean("--kernel-only", false);
6350
+ pluginId = Option5.String("--plugin", { required: false });
6270
6351
  // Multi-flag CLI orchestrator: validates flag combos, optionally
6271
6352
  // discovers plugins, fans out into status / apply branches against
6272
6353
  // both the kernel ledger and per-plugin ledgers. Splitting per branch
6273
6354
  // would scatter the close-to-call-site flag handling without making
6274
6355
  // the verb easier to follow.
6275
6356
  // eslint-disable-next-line complexity
6276
- async execute() {
6357
+ async run() {
6277
6358
  if (this.kernelOnly && this.pluginId !== void 0) {
6278
6359
  this.context.stderr.write(DB_TEXTS.migrateKernelOnlyAndPluginMutex);
6279
6360
  return ExitCode.Error;
@@ -6455,7 +6536,7 @@ var DB_COMMANDS = [
6455
6536
  ];
6456
6537
 
6457
6538
  // cli/commands/export.ts
6458
- import { Command as Command5, Option as Option5 } from "clipanion";
6539
+ import { Command as Command6, Option as Option6 } from "clipanion";
6459
6540
 
6460
6541
  // kernel/scan/query.ts
6461
6542
  var HAS_VALUES = /* @__PURE__ */ new Set(["issues"]);
@@ -6612,9 +6693,9 @@ var SUPPORTED_FORMATS = ["json", "md"];
6612
6693
  var DEFERRED_FORMATS = {
6613
6694
  mermaid: EXPORT_TEXTS.formatDeferredReasonMermaid
6614
6695
  };
6615
- var ExportCommand = class extends Command5 {
6696
+ var ExportCommand = class extends SmCommand {
6616
6697
  static paths = [["export"]];
6617
- static usage = Command5.Usage({
6698
+ static usage = Command6.Usage({
6618
6699
  category: "Browse",
6619
6700
  description: "Filtered export. Query syntax is implementation-defined pre-1.0.",
6620
6701
  details: `
@@ -6638,11 +6719,9 @@ var ExportCommand = class extends Command5 {
6638
6719
  ["Whole graph as Markdown", '$0 export "" --format md']
6639
6720
  ]
6640
6721
  });
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() {
6722
+ query = Option6.String({ required: true });
6723
+ format = Option6.String("--format", { required: false });
6724
+ async run() {
6646
6725
  const format = (this.format ?? "json").toLowerCase();
6647
6726
  if (DEFERRED_FORMATS[format]) {
6648
6727
  this.context.stderr.write(
@@ -6826,7 +6905,7 @@ function pickTitle2(node) {
6826
6905
  }
6827
6906
 
6828
6907
  // cli/commands/graph.ts
6829
- import { Command as Command6, Option as Option6 } from "clipanion";
6908
+ import { Command as Command7, Option as Option7 } from "clipanion";
6830
6909
 
6831
6910
  // cli/i18n/graph.texts.ts
6832
6911
  var GRAPH_TEXTS = {
@@ -6836,9 +6915,9 @@ var GRAPH_TEXTS = {
6836
6915
 
6837
6916
  // cli/commands/graph.ts
6838
6917
  var DEFAULT_FORMAT = "ascii";
6839
- var GraphCommand = class extends Command6 {
6918
+ var GraphCommand = class extends SmCommand {
6840
6919
  static paths = [["graph"]];
6841
- static usage = Command6.Usage({
6920
+ static usage = Command7.Usage({
6842
6921
  category: "Browse",
6843
6922
  description: "Render the full graph via the named formatter.",
6844
6923
  details: `
@@ -6855,15 +6934,13 @@ var GraphCommand = class extends Command6 {
6855
6934
  ["Use a non-default DB file", "$0 graph --db /path/to/skill-map.db"]
6856
6935
  ]
6857
6936
  });
6858
- format = Option6.String("--format", DEFAULT_FORMAT, {
6937
+ format = Option7.String("--format", DEFAULT_FORMAT, {
6859
6938
  description: `Formatter format. Must match the \`formatId\` field of a registered formatter. Default: ${DEFAULT_FORMAT}.`
6860
6939
  });
6861
- global = Option6.Boolean("-g,--global", false);
6862
- db = Option6.String("--db", { required: false });
6863
- noPlugins = Option6.Boolean("--no-plugins", false, {
6940
+ noPlugins = Option7.Boolean("--no-plugins", false, {
6864
6941
  description: "Skip drop-in plugin discovery. Only built-in formatters participate."
6865
6942
  });
6866
- async execute() {
6943
+ async run() {
6867
6944
  const dbPath = resolveDbPath({ global: this.global, db: this.db, ...defaultRuntimeContext() });
6868
6945
  if (!assertDbExists(dbPath, this.context.stderr)) return ExitCode.NotFound;
6869
6946
  const pluginRuntime = this.noPlugins ? emptyPluginRuntime() : await loadPluginRuntime({ scope: this.global ? "global" : "project" });
@@ -6898,12 +6975,12 @@ var GraphCommand = class extends Command6 {
6898
6975
  import { readFileSync as readFileSync10 } from "fs";
6899
6976
  import { createRequire as createRequire4 } from "module";
6900
6977
  import { resolve as resolve13 } from "path";
6901
- import { Command as Command7, Option as Option7 } from "clipanion";
6978
+ import { Command as Command8, Option as Option8 } from "clipanion";
6902
6979
 
6903
6980
  // package.json
6904
6981
  var package_default = {
6905
6982
  name: "@skill-map/cli",
6906
- version: "0.9.0",
6983
+ version: "0.10.0",
6907
6984
  description: "skill-map reference implementation \u2014 kernel + CLI + adapters.",
6908
6985
  license: "MIT",
6909
6986
  type: "module",
@@ -6950,6 +7027,7 @@ var package_default = {
6950
7027
  scripts: {
6951
7028
  build: "tsup",
6952
7029
  dev: "tsup --watch",
7030
+ "dev:serve": "node ../scripts/dev-serve.js",
6953
7031
  typecheck: "tsc --noEmit",
6954
7032
  lint: "eslint .",
6955
7033
  "lint:fix": "eslint . --fix",
@@ -6960,17 +7038,20 @@ var package_default = {
6960
7038
  clean: "rm -rf dist coverage"
6961
7039
  },
6962
7040
  dependencies: {
7041
+ "@hono/node-server": "2.0.1",
6963
7042
  "@skill-map/spec": "*",
6964
7043
  ajv: "8.18.0",
6965
7044
  "ajv-formats": "3.0.1",
6966
7045
  chokidar: "5.0.0",
6967
7046
  clipanion: "4.0.0-rc.4",
7047
+ hono: "4.12.16",
6968
7048
  ignore: "7.0.5",
6969
7049
  "js-tiktoken": "1.0.21",
6970
7050
  "js-yaml": "4.1.1",
6971
7051
  kysely: "0.28.16",
6972
7052
  semver: "7.7.4",
6973
- typanion: "3.14.0"
7053
+ typanion: "3.14.0",
7054
+ ws: "8.20.0"
6974
7055
  },
6975
7056
  devDependencies: {
6976
7057
  "@eslint/js": "10.0.1",
@@ -6978,6 +7059,7 @@ var package_default = {
6978
7059
  "@types/js-yaml": "4.0.9",
6979
7060
  "@types/node": "24.12.2",
6980
7061
  "@types/semver": "7.7.1",
7062
+ "@types/ws": "8.18.1",
6981
7063
  c8: "11.0.0",
6982
7064
  eslint: "10.2.1",
6983
7065
  "eslint-plugin-import-x": "4.16.2",
@@ -7074,9 +7156,9 @@ var HELP_TEXTS = {
7074
7156
  };
7075
7157
 
7076
7158
  // cli/commands/help.ts
7077
- var HelpCommand = class extends Command7 {
7159
+ var HelpCommand = class extends Command8 {
7078
7160
  static paths = [["help"]];
7079
- static usage = Command7.Usage({
7161
+ static usage = Command8.Usage({
7080
7162
  category: "Introspection",
7081
7163
  description: "Self-describing introspection. --format human|md|json.",
7082
7164
  details: `
@@ -7090,8 +7172,8 @@ var HelpCommand = class extends Command7 {
7090
7172
  json \u2014 structured surface dump per spec/cli-contract.md.
7091
7173
  `
7092
7174
  });
7093
- verbParts = Option7.Rest({ required: 0 });
7094
- format = Option7.String("--format", "human");
7175
+ verbParts = Option8.Rest({ required: 0 });
7176
+ format = Option8.String("--format", "human");
7095
7177
  async execute() {
7096
7178
  const format = normalizeFormat(this.format);
7097
7179
  if (!format) {
@@ -7409,8 +7491,8 @@ function renderCompactOverview(verbs) {
7409
7491
  lines.push(HELP_TEXTS.compactFooter);
7410
7492
  return lines.join("\n") + "\n";
7411
7493
  }
7412
- var RootHelpCommand = class extends Command7 {
7413
- static paths = [["-h"], ["--help"], Command7.Default];
7494
+ var RootHelpCommand = class extends Command8 {
7495
+ static paths = [["-h"], ["--help"], Command8.Default];
7414
7496
  async execute() {
7415
7497
  const rawDefs = this.cli.definitions();
7416
7498
  const verbs = rawDefs.filter((d) => !isBuiltin(d)).map(normalizeDefinition).sort(byPath);
@@ -7465,9 +7547,9 @@ function registeredVerbPaths(cli2) {
7465
7547
  }
7466
7548
 
7467
7549
  // cli/commands/init.ts
7468
- import { mkdir as mkdir2, readFile as readFile2, stat as stat3, writeFile } from "fs/promises";
7550
+ import { mkdir as mkdir2, readFile as readFile2, writeFile } from "fs/promises";
7469
7551
  import { join as join10 } from "path";
7470
- import { Command as Command8, Option as Option8 } from "clipanion";
7552
+ import { Command as Command9, Option as Option9 } from "clipanion";
7471
7553
 
7472
7554
  // kernel/orchestrator.ts
7473
7555
  import { createHash } from "crypto";
@@ -8629,22 +8711,9 @@ function createCliProgressEmitter(stderr) {
8629
8711
  }
8630
8712
 
8631
8713
  // 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 {
8714
+ var InitCommand = class extends SmCommand {
8646
8715
  static paths = [["init"]];
8647
- static usage = Command8.Usage({
8716
+ static usage = Command9.Usage({
8648
8717
  category: "Setup",
8649
8718
  description: "Bootstrap the current scope: scaffold .skill-map/, provision DB, run first scan.",
8650
8719
  details: `
@@ -8668,19 +8737,16 @@ var InitCommand = class extends Command8 {
8668
8737
  ["Preview what would be created", "$0 init --dry-run"]
8669
8738
  ]
8670
8739
  });
8671
- global = Option8.Boolean("-g,--global", false, {
8672
- description: "Initialise ~/.skill-map/ instead of ./.skill-map/."
8673
- });
8674
- noScan = Option8.Boolean("--no-scan", false, {
8740
+ noScan = Option9.Boolean("--no-scan", false, {
8675
8741
  description: "Skip the first scan after scaffolding."
8676
8742
  });
8677
- force = Option8.Boolean("--force", false, {
8743
+ force = Option9.Boolean("--force", false, {
8678
8744
  description: "Overwrite an existing settings.json / settings.local.json / .skill-mapignore."
8679
8745
  });
8680
- strict = Option8.Boolean("--strict", false, {
8746
+ strict = Option9.Boolean("--strict", false, {
8681
8747
  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
8748
  });
8683
- dryRun = Option8.Boolean("-n,--dry-run", false, {
8749
+ dryRun = Option9.Boolean("-n,--dry-run", false, {
8684
8750
  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
8751
  });
8686
8752
  // CLI orchestrator: paths setup + dry-run branch (delegated to
@@ -8688,18 +8754,16 @@ var InitCommand = class extends Command8 {
8688
8754
  // gitignore management + DB provision + first scan delegation).
8689
8755
  // The first-scan branch already lives in `runFirstScan`.
8690
8756
  // eslint-disable-next-line complexity
8691
- async execute() {
8692
- const elapsed = startElapsed();
8757
+ async run() {
8693
8758
  const ctx = defaultRuntimeContext();
8694
8759
  const scopeRoot = this.global ? ctx.homedir : ctx.cwd;
8695
8760
  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) {
8761
+ const settingsPath = defaultSettingsPath(scopeRoot);
8762
+ const localPath = defaultLocalSettingsPath(scopeRoot);
8763
+ const ignorePath = defaultIgnoreFilePath(scopeRoot);
8764
+ const dbPath = defaultDbPath(scopeRoot);
8765
+ if (await pathExists(settingsPath) && !this.force) {
8701
8766
  this.context.stderr.write(tx(INIT_TEXTS.alreadyInitialised, { settingsPath }));
8702
- emitDoneStderr(this.context.stderr, elapsed);
8703
8767
  return ExitCode.Error;
8704
8768
  }
8705
8769
  if (this.dryRun) {
@@ -8714,15 +8778,14 @@ var InitCommand = class extends Command8 {
8714
8778
  global: this.global,
8715
8779
  noScan: this.noScan
8716
8780
  });
8717
- emitDoneStderr(this.context.stderr, elapsed);
8718
8781
  return ExitCode.Ok;
8719
8782
  }
8720
8783
  await mkdir2(skillMapDir, { recursive: true });
8721
8784
  await writeFile(settingsPath, JSON.stringify({ schemaVersion: 1 }, null, 2) + "\n");
8722
- if (!await pathExists2(localPath) || this.force) {
8785
+ if (!await pathExists(localPath) || this.force) {
8723
8786
  await writeFile(localPath, "{}\n");
8724
8787
  }
8725
- if (!await pathExists2(ignorePath) || this.force) {
8788
+ if (!await pathExists(ignorePath) || this.force) {
8726
8789
  await writeFile(ignorePath, loadBundledIgnoreText());
8727
8790
  }
8728
8791
  if (!this.global) {
@@ -8740,25 +8803,20 @@ var InitCommand = class extends Command8 {
8740
8803
  await withSqlite({ databasePath: dbPath, autoBackup: false }, async () => {
8741
8804
  });
8742
8805
  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;
8806
+ if (this.noScan) return ExitCode.Ok;
8807
+ return runFirstScan(scopeRoot, ctx.homedir, dbPath, this.strict, this.context.stdout, this.context.stderr);
8750
8808
  }
8751
8809
  };
8752
8810
  async function writeDryRunPlan(stdout, opts) {
8753
8811
  stdout.write(INIT_TEXTS.dryRunHeader);
8754
- if (!await pathExists2(opts.skillMapDir)) {
8812
+ if (!await pathExists(opts.skillMapDir)) {
8755
8813
  stdout.write(tx(INIT_TEXTS.dryRunWouldCreateDir, { path: opts.skillMapDir }));
8756
8814
  }
8757
8815
  stdout.write(await dryRunFileMessage(opts.settingsPath));
8758
- if (!await pathExists2(opts.localPath) || opts.force) {
8816
+ if (!await pathExists(opts.localPath) || opts.force) {
8759
8817
  stdout.write(await dryRunFileMessage(opts.localPath));
8760
8818
  }
8761
- if (!await pathExists2(opts.ignorePath) || opts.force) {
8819
+ if (!await pathExists(opts.ignorePath) || opts.force) {
8762
8820
  stdout.write(await dryRunFileMessage(opts.ignorePath));
8763
8821
  }
8764
8822
  if (!opts.global) await writeDryRunGitignorePlan(stdout, opts.scopeRoot);
@@ -8768,7 +8826,7 @@ async function writeDryRunPlan(stdout, opts) {
8768
8826
  );
8769
8827
  }
8770
8828
  async function dryRunFileMessage(path) {
8771
- return await pathExists2(path) ? tx(INIT_TEXTS.dryRunWouldOverwriteFile, { path }) : tx(INIT_TEXTS.dryRunWouldWriteFile, { path });
8829
+ return await pathExists(path) ? tx(INIT_TEXTS.dryRunWouldOverwriteFile, { path }) : tx(INIT_TEXTS.dryRunWouldWriteFile, { path });
8772
8830
  }
8773
8831
  async function writeDryRunGitignorePlan(stdout, scopeRoot) {
8774
8832
  const wouldAdd = await previewGitignoreEntries(scopeRoot, GITIGNORE_ENTRIES);
@@ -8848,7 +8906,7 @@ async function runFirstScan(scopeRoot, homedir2, dbPath, strict, stdout, stderr)
8848
8906
  }
8849
8907
  async function previewGitignoreEntries(scopeRoot, entries) {
8850
8908
  const path = join10(scopeRoot, ".gitignore");
8851
- const body = await pathExists2(path) ? await readFile2(path, "utf8") : "";
8909
+ const body = await pathExists(path) ? await readFile2(path, "utf8") : "";
8852
8910
  const present = new Set(
8853
8911
  body.split("\n").map((line) => line.trim()).filter((line) => line.length > 0 && !line.startsWith("#"))
8854
8912
  );
@@ -8857,7 +8915,7 @@ async function previewGitignoreEntries(scopeRoot, entries) {
8857
8915
  async function ensureGitignoreEntries(scopeRoot, entries) {
8858
8916
  const path = join10(scopeRoot, ".gitignore");
8859
8917
  let body = "";
8860
- if (await pathExists2(path)) {
8918
+ if (await pathExists(path)) {
8861
8919
  body = await readFile2(path, "utf8");
8862
8920
  }
8863
8921
  const present = new Set(
@@ -8877,7 +8935,7 @@ async function ensureGitignoreEntries(scopeRoot, entries) {
8877
8935
  }
8878
8936
 
8879
8937
  // cli/commands/history.ts
8880
- import { Command as Command9, Option as Option9 } from "clipanion";
8938
+ import { Command as Command10, Option as Option10 } from "clipanion";
8881
8939
 
8882
8940
  // cli/i18n/option-validators.texts.ts
8883
8941
  var OPTION_VALIDATORS_TEXTS = {
@@ -8964,9 +9022,9 @@ function parseStatuses(input, stderr) {
8964
9022
  }
8965
9023
  return parts;
8966
9024
  }
8967
- var HistoryCommand = class extends Command9 {
9025
+ var HistoryCommand = class extends SmCommand {
8968
9026
  static paths = [["history"]];
8969
- static usage = Command9.Usage({
9027
+ static usage = Command10.Usage({
8970
9028
  category: "History",
8971
9029
  description: "Filter execution records. --json emits an array conforming to execution-record.schema.json.",
8972
9030
  details: `
@@ -8986,24 +9044,19 @@ var HistoryCommand = class extends Command9 {
8986
9044
  ["Machine-readable, scoped to one node", "$0 history -n skills/foo.md --json"]
8987
9045
  ]
8988
9046
  });
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);
9047
+ node = Option10.String("-n", { required: false });
9048
+ action = Option10.String("--action", { required: false });
9049
+ status = Option10.String("--status", { required: false });
9050
+ since = Option10.String("--since", { required: false });
9051
+ until = Option10.String("--until", { required: false });
9052
+ limit = Option10.String("--limit", { required: false });
8999
9053
  // CLI list verb: many optional filter flags (`--node`, `--action`,
9000
9054
  // `--status`, `--since`, `--until`, `--limit`, `--json`, `--quiet`)
9001
9055
  // each adding a guarded mutation to the filter or render path. Each
9002
9056
  // branch is single-purpose; splitting per flag would distance the
9003
9057
  // validations from the filter they shape.
9004
9058
  // eslint-disable-next-line complexity
9005
- async execute() {
9006
- const elapsed = startElapsed();
9059
+ async run() {
9007
9060
  const filter = {};
9008
9061
  if (this.node !== void 0) filter.nodePath = this.node;
9009
9062
  if (this.action !== void 0) filter.actionId = this.action;
@@ -9038,14 +9091,13 @@ var HistoryCommand = class extends Command9 {
9038
9091
  } else {
9039
9092
  this.context.stdout.write(renderTable(rows));
9040
9093
  }
9041
- emitDoneStderr(this.context.stderr, elapsed, this.quiet);
9042
9094
  return ExitCode.Ok;
9043
9095
  });
9044
9096
  }
9045
9097
  };
9046
- var HistoryStatsCommand = class extends Command9 {
9098
+ var HistoryStatsCommand = class extends SmCommand {
9047
9099
  static paths = [["history", "stats"]];
9048
- static usage = Command9.Usage({
9100
+ static usage = Command10.Usage({
9049
9101
  category: "History",
9050
9102
  description: "Aggregate counts, tokens, periods, top nodes, and error rates over state_executions. --json conforms to history-stats.schema.json.",
9051
9103
  details: `
@@ -9063,20 +9115,16 @@ var HistoryStatsCommand = class extends Command9 {
9063
9115
  ["Top 5 nodes, JSON", "$0 history stats --top 5 --json"]
9064
9116
  ]
9065
9117
  });
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);
9118
+ since = Option10.String("--since", { required: false });
9119
+ until = Option10.String("--until", { required: false });
9120
+ period = Option10.String("--period", { required: false });
9121
+ top = Option10.String("--top", { required: false });
9074
9122
  // CLI stats verb: range parsing + window flags + period flag + JSON
9075
9123
  // branch + per-period iteration. Each branch is a single-purpose
9076
9124
  // gate; the data work lives in `aggregateHistoryStats`.
9077
9125
  // eslint-disable-next-line complexity
9078
- async execute() {
9079
- const elapsed = startElapsed();
9126
+ async run() {
9127
+ const elapsed = this.elapsed;
9080
9128
  let sinceMs = null;
9081
9129
  let untilMs = Date.now();
9082
9130
  if (this.since !== void 0) {
@@ -9140,7 +9188,6 @@ var HistoryStatsCommand = class extends Command9 {
9140
9188
  } else {
9141
9189
  this.context.stdout.write(renderStats(stats));
9142
9190
  }
9143
- emitDoneStderr(this.context.stderr, elapsed, this.quiet);
9144
9191
  return ExitCode.Ok;
9145
9192
  });
9146
9193
  }
@@ -9252,7 +9299,7 @@ function formatRow(...cols) {
9252
9299
 
9253
9300
  // cli/commands/jobs.ts
9254
9301
  import { unlink } from "fs/promises";
9255
- import { Command as Command10, Option as Option10 } from "clipanion";
9302
+ import { Command as Command11, Option as Option11 } from "clipanion";
9256
9303
 
9257
9304
  // kernel/jobs/orphan-files.ts
9258
9305
  import { readdirSync as readdirSync6, statSync as statSync4 } from "fs";
@@ -9260,8 +9307,8 @@ import { join as join11, resolve as resolve15 } from "path";
9260
9307
  function findOrphanJobFiles(jobsDir, referencedPaths) {
9261
9308
  let entries;
9262
9309
  try {
9263
- const stat4 = statSync4(jobsDir);
9264
- if (!stat4.isDirectory()) {
9310
+ const stat3 = statSync4(jobsDir);
9311
+ if (!stat3.isDirectory()) {
9265
9312
  return { orphanFilePaths: [], referencedCount: referencedPaths.size };
9266
9313
  }
9267
9314
  entries = readdirSync6(jobsDir);
@@ -9298,9 +9345,9 @@ var JOBS_TEXTS = {
9298
9345
  };
9299
9346
 
9300
9347
  // cli/commands/jobs.ts
9301
- var JobPruneCommand = class extends Command10 {
9348
+ var JobPruneCommand = class extends SmCommand {
9302
9349
  static paths = [["job", "prune"]];
9303
- static usage = Command10.Usage({
9350
+ static usage = Command11.Usage({
9304
9351
  category: "Jobs",
9305
9352
  description: "Retention GC for completed / failed jobs (per config policy). --orphan-files removes MD files with no DB row.",
9306
9353
  details: `
@@ -9327,16 +9374,13 @@ var JobPruneCommand = class extends Command10 {
9327
9374
  ["Preview without touching the DB", "$0 job prune --dry-run --json"]
9328
9375
  ]
9329
9376
  });
9330
- orphanFiles = Option10.Boolean("--orphan-files", false, {
9377
+ orphanFiles = Option11.Boolean("--orphan-files", false, {
9331
9378
  description: "Also remove MD files in .skill-map/jobs/ that have no matching state_jobs row."
9332
9379
  });
9333
- dryRun = Option10.Boolean("-n,--dry-run", false, {
9380
+ dryRun = Option11.Boolean("-n,--dry-run", false, {
9334
9381
  description: "Report what would be pruned without touching the DB or filesystem."
9335
9382
  });
9336
- json = Option10.Boolean("--json", false, {
9337
- description: "Emit a structured prune-result document on stdout."
9338
- });
9339
- async execute() {
9383
+ async run() {
9340
9384
  const ctx = defaultRuntimeContext();
9341
9385
  const dbPath = defaultProjectDbPath(ctx);
9342
9386
  const jobsDir = defaultProjectJobsDir(ctx);
@@ -9450,7 +9494,7 @@ function formatPolicy(seconds) {
9450
9494
  }
9451
9495
 
9452
9496
  // cli/commands/list.ts
9453
- import { Command as Command11, Option as Option11 } from "clipanion";
9497
+ import { Command as Command12, Option as Option12 } from "clipanion";
9454
9498
 
9455
9499
  // cli/i18n/list.texts.ts
9456
9500
  var LIST_TEXTS = {
@@ -9476,9 +9520,9 @@ var SORT_BY = {
9476
9520
  external_refs_count: { column: "externalRefsCount", direction: "desc" }
9477
9521
  };
9478
9522
  var PATH_COL_WIDTH = 50;
9479
- var ListCommand = class extends Command11 {
9523
+ var ListCommand = class extends SmCommand {
9480
9524
  static paths = [["list"]];
9481
- static usage = Command11.Usage({
9525
+ static usage = Command12.Usage({
9482
9526
  category: "Browse",
9483
9527
  description: "Tabular listing of nodes. --json emits an array conforming to node.schema.json.",
9484
9528
  details: `
@@ -9499,14 +9543,11 @@ var ListCommand = class extends Command11 {
9499
9543
  ["Only nodes with issues, machine-readable", "$0 list --issue --json"]
9500
9544
  ]
9501
9545
  });
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() {
9546
+ kind = Option12.String("--kind", { required: false });
9547
+ issue = Option12.Boolean("--issue", false);
9548
+ sortBy = Option12.String("--sort-by", { required: false });
9549
+ limit = Option12.String("--limit", { required: false });
9550
+ async run() {
9510
9551
  let sortColumn = "path";
9511
9552
  let sortDirection = "asc";
9512
9553
  if (this.sortBy !== void 0) {
@@ -9607,7 +9648,7 @@ function formatRow2(path, kind, out, inCount, ext, issues, bytes) {
9607
9648
  }
9608
9649
 
9609
9650
  // cli/commands/orphans.ts
9610
- import { Command as Command12, Option as Option12 } from "clipanion";
9651
+ import { Command as Command13, Option as Option13 } from "clipanion";
9611
9652
 
9612
9653
  // cli/i18n/orphans.texts.ts
9613
9654
  var ORPHANS_TEXTS = {
@@ -9660,9 +9701,9 @@ async function findActiveOrphanIssues(adapter, predicate) {
9660
9701
  function isStringArray(v) {
9661
9702
  return Array.isArray(v) && v.every((s) => typeof s === "string");
9662
9703
  }
9663
- var OrphansCommand = class extends Command12 {
9704
+ var OrphansCommand = class extends SmCommand {
9664
9705
  static paths = [["orphans"]];
9665
- static usage = Command12.Usage({
9706
+ static usage = Command13.Usage({
9666
9707
  category: "Browse",
9667
9708
  description: "List orphan / auto-rename issues from the last scan. --json emits an array conforming to issue.schema.json.",
9668
9709
  details: `
@@ -9677,13 +9718,8 @@ var OrphansCommand = class extends Command12 {
9677
9718
  ["Just the ambiguous ones, JSON", "$0 orphans --kind ambiguous --json"]
9678
9719
  ]
9679
9720
  });
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();
9721
+ kind = Option13.String("--kind", { required: false });
9722
+ async run() {
9687
9723
  let ruleFilter = null;
9688
9724
  if (this.kind !== void 0) {
9689
9725
  const map = {
@@ -9714,14 +9750,13 @@ var OrphansCommand = class extends Command12 {
9714
9750
  } else {
9715
9751
  this.context.stdout.write(renderOrphans(found.map((f) => f.issue)));
9716
9752
  }
9717
- emitDoneStderr(this.context.stderr, elapsed, this.quiet);
9718
9753
  return ExitCode.Ok;
9719
9754
  });
9720
9755
  }
9721
9756
  };
9722
- var OrphansReconcileCommand = class extends Command12 {
9757
+ var OrphansReconcileCommand = class extends SmCommand {
9723
9758
  static paths = [["orphans", "reconcile"]];
9724
- static usage = Command12.Usage({
9759
+ static usage = Command13.Usage({
9725
9760
  category: "Browse",
9726
9761
  description: "Migrate state_* FKs from an orphan path to a live node, resolving the orphan issue.",
9727
9762
  details: `
@@ -9737,14 +9772,10 @@ var OrphansReconcileCommand = class extends Command12 {
9737
9772
  ["Reattach orphan history", "$0 orphans reconcile skills/old.md --to skills/new.md"]
9738
9773
  ]
9739
9774
  });
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();
9775
+ orphanPath = Option13.String({ required: true });
9776
+ to = Option13.String("--to", { required: true });
9777
+ dryRun = Option13.Boolean("-n,--dry-run", false);
9778
+ async run() {
9748
9779
  const dbPath = resolveDbPath({ global: this.global, db: this.db, ...defaultRuntimeContext() });
9749
9780
  if (!assertDbExists(dbPath, this.context.stderr)) return ExitCode.NotFound;
9750
9781
  return withSqlite({ databasePath: dbPath, autoBackup: false }, async (adapter) => {
@@ -9807,14 +9838,13 @@ var OrphansReconcileCommand = class extends Command12 {
9807
9838
  )
9808
9839
  );
9809
9840
  }
9810
- emitDoneStderr(this.context.stderr, elapsed, this.quiet);
9811
9841
  return ExitCode.Ok;
9812
9842
  });
9813
9843
  }
9814
9844
  };
9815
- var OrphansUndoRenameCommand = class extends Command12 {
9845
+ var OrphansUndoRenameCommand = class extends SmCommand {
9816
9846
  static paths = [["orphans", "undo-rename"]];
9817
- static usage = Command12.Usage({
9847
+ static usage = Command13.Usage({
9818
9848
  category: "Browse",
9819
9849
  description: "Reverse a medium- or ambiguous-confidence auto-rename. Migrates state_* FKs back, emits a new orphan on the prior path.",
9820
9850
  details: `
@@ -9834,15 +9864,11 @@ var OrphansUndoRenameCommand = class extends Command12 {
9834
9864
  ["Undo an ambiguous, picking a candidate", "$0 orphans undo-rename skills/new.md --from skills/old-a.md"]
9835
9865
  ]
9836
9866
  });
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();
9867
+ newPath = Option13.String({ required: true });
9868
+ from = Option13.String("--from", { required: false });
9869
+ force = Option13.Boolean("--force", false);
9870
+ dryRun = Option13.Boolean("-n,--dry-run", false);
9871
+ async run() {
9846
9872
  const dbPath = resolveDbPath({ global: this.global, db: this.db, ...defaultRuntimeContext() });
9847
9873
  if (!assertDbExists(dbPath, this.context.stderr)) return ExitCode.NotFound;
9848
9874
  return withSqlite({ databasePath: dbPath, autoBackup: false }, async (adapter) => {
@@ -9914,7 +9940,6 @@ var OrphansUndoRenameCommand = class extends Command12 {
9914
9940
  rows: summaryTotal(summary)
9915
9941
  })
9916
9942
  );
9917
- emitDoneStderr(this.context.stderr, elapsed, this.quiet);
9918
9943
  return ExitCode.Ok;
9919
9944
  });
9920
9945
  }
@@ -10008,7 +10033,7 @@ var ORPHANS_COMMANDS = [
10008
10033
  // cli/commands/plugins.ts
10009
10034
  import { existsSync as existsSync13 } from "fs";
10010
10035
  import { join as join12, resolve as resolve16 } from "path";
10011
- import { Command as Command13, Option as Option13 } from "clipanion";
10036
+ import { Command as Command14, Option as Option14 } from "clipanion";
10012
10037
 
10013
10038
  // cli/i18n/plugins.texts.ts
10014
10039
  var PLUGINS_TEXTS = {
@@ -10151,17 +10176,15 @@ function builtInRows(resolveEnabled) {
10151
10176
  };
10152
10177
  });
10153
10178
  }
10154
- var PluginsListCommand = class extends Command13 {
10179
+ var PluginsListCommand = class extends SmCommand {
10155
10180
  static paths = [["plugins", "list"]];
10156
- static usage = Command13.Usage({
10181
+ static usage = Command14.Usage({
10157
10182
  category: "Plugins",
10158
10183
  description: "List discovered plugins and their load status.",
10159
10184
  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
10185
  });
10161
- global = Option13.Boolean("-g,--global", false);
10162
- pluginDir = Option13.String("--plugin-dir", { required: false });
10163
- json = Option13.Boolean("--json", false);
10164
- async execute() {
10186
+ pluginDir = Option14.String("--plugin-dir", { required: false });
10187
+ async run() {
10165
10188
  const plugins = await loadAll({ global: this.global, pluginDir: this.pluginDir });
10166
10189
  const resolveEnabled = await buildResolver(this.global);
10167
10190
  const builtIns2 = builtInRows(resolveEnabled);
@@ -10220,24 +10243,24 @@ function renderPluginRow(p) {
10220
10243
  tail
10221
10244
  }) + "\n";
10222
10245
  }
10223
- var PluginsShowCommand = class extends Command13 {
10246
+ var PluginsShowCommand = class extends SmCommand {
10224
10247
  static paths = [["plugins", "show"]];
10225
- static usage = Command13.Usage({
10248
+ static usage = Command14.Usage({
10226
10249
  category: "Plugins",
10227
10250
  description: "Show a single plugin's manifest + loaded extensions."
10228
10251
  });
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() {
10252
+ id = Option14.String({ required: true });
10253
+ pluginDir = Option14.String("--plugin-dir", { required: false });
10254
+ async run() {
10234
10255
  const plugins = await loadAll({ global: this.global, pluginDir: this.pluginDir });
10235
10256
  const resolveEnabled = await buildResolver(this.global);
10236
10257
  const builtIns2 = builtInRows(resolveEnabled);
10237
10258
  const builtIn = builtIns2.find((b) => b.id === this.id);
10238
10259
  const match = plugins.find((p) => p.id === this.id);
10239
10260
  if (!builtIn && !match) {
10240
- this.context.stderr.write(tx(PLUGINS_TEXTS.pluginNotFound, { id: this.id }) + "\n");
10261
+ this.context.stderr.write(
10262
+ tx(PLUGINS_TEXTS.pluginNotFound, { id: sanitizeForTerminal(this.id) }) + "\n"
10263
+ );
10241
10264
  return ExitCode.NotFound;
10242
10265
  }
10243
10266
  if (this.json) {
@@ -10423,15 +10446,14 @@ function collectExplorationDirWarnings(plugins, homedir2) {
10423
10446
  });
10424
10447
  return out;
10425
10448
  }
10426
- var PluginsDoctorCommand = class extends Command13 {
10449
+ var PluginsDoctorCommand = class extends SmCommand {
10427
10450
  static paths = [["plugins", "doctor"]];
10428
- static usage = Command13.Usage({
10451
+ static usage = Command14.Usage({
10429
10452
  category: "Plugins",
10430
10453
  description: "Run the full load pass and summarise by failure mode.",
10431
10454
  details: "Exit code 0 when every plugin loads or is intentionally disabled; 1 when any plugin is in an error / incompat state."
10432
10455
  });
10433
- global = Option13.Boolean("-g,--global", false);
10434
- pluginDir = Option13.String("--plugin-dir", { required: false });
10456
+ pluginDir = Option14.String("--plugin-dir", { required: false });
10435
10457
  // Doctor verb: counts by status + applicableKinds warnings +
10436
10458
  // explorationDir warnings + bad-plugins issues, each with its own
10437
10459
  // gated render. Branching is intrinsic to the multi-section diagnostic
@@ -10439,7 +10461,7 @@ var PluginsDoctorCommand = class extends Command13 {
10439
10461
  // `collectApplicableKindWarnings`, `collectExplorationDirWarnings`)
10440
10462
  // already encapsulate the data gathering.
10441
10463
  // eslint-disable-next-line complexity
10442
- async execute() {
10464
+ async run() {
10443
10465
  const plugins = await loadAll({ global: this.global, pluginDir: this.pluginDir });
10444
10466
  const resolveEnabled = await buildResolver(this.global);
10445
10467
  const builtIns2 = builtInRows(resolveEnabled);
@@ -10548,17 +10570,25 @@ function resolveToggleTarget(id, catalogue, verb) {
10548
10570
  if (id.includes("/")) {
10549
10571
  const [bundleId, extId, ...rest] = id.split("/");
10550
10572
  if (!bundleId || !extId || rest.length > 0) {
10551
- return { error: tx(PLUGINS_TEXTS.qualifiedIdUnknownBundle, { bundleId: id }) };
10573
+ return {
10574
+ error: tx(PLUGINS_TEXTS.qualifiedIdUnknownBundle, {
10575
+ bundleId: sanitizeForTerminal(id)
10576
+ })
10577
+ };
10552
10578
  }
10553
10579
  const bundle2 = catalogue.find((b) => b.id === bundleId);
10554
10580
  if (!bundle2) {
10555
- return { error: tx(PLUGINS_TEXTS.qualifiedIdUnknownBundle, { bundleId }) };
10581
+ return {
10582
+ error: tx(PLUGINS_TEXTS.qualifiedIdUnknownBundle, {
10583
+ bundleId: sanitizeForTerminal(bundleId)
10584
+ })
10585
+ };
10556
10586
  }
10557
10587
  if (bundle2.granularity === "bundle") {
10558
10588
  return {
10559
10589
  error: tx(PLUGINS_TEXTS.granularityBundleRejectsQualified, {
10560
- bundleId,
10561
- extId,
10590
+ bundleId: sanitizeForTerminal(bundleId),
10591
+ extId: sanitizeForTerminal(extId),
10562
10592
  verb
10563
10593
  })
10564
10594
  };
@@ -10566,9 +10596,9 @@ function resolveToggleTarget(id, catalogue, verb) {
10566
10596
  if (!bundle2.extensionIds.includes(extId)) {
10567
10597
  return {
10568
10598
  error: tx(PLUGINS_TEXTS.qualifiedIdNotFound, {
10569
- id,
10570
- bundleId,
10571
- extId
10599
+ id: sanitizeForTerminal(id),
10600
+ bundleId: sanitizeForTerminal(bundleId),
10601
+ extId: sanitizeForTerminal(extId)
10572
10602
  })
10573
10603
  };
10574
10604
  }
@@ -10576,34 +10606,30 @@ function resolveToggleTarget(id, catalogue, verb) {
10576
10606
  }
10577
10607
  const bundle = catalogue.find((b) => b.id === id);
10578
10608
  if (!bundle) {
10579
- return { error: tx(PLUGINS_TEXTS.pluginNotFound, { id }) };
10609
+ return { error: tx(PLUGINS_TEXTS.pluginNotFound, { id: sanitizeForTerminal(id) }) };
10580
10610
  }
10581
10611
  if (bundle.granularity === "extension") {
10582
10612
  return {
10583
10613
  error: tx(PLUGINS_TEXTS.granularityExtensionRejectsBundleId, {
10584
- bundleId: id,
10614
+ bundleId: sanitizeForTerminal(id),
10585
10615
  verb
10586
10616
  })
10587
10617
  };
10588
10618
  }
10589
10619
  return { key: bundle.id };
10590
10620
  }
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 });
10621
+ var TogglePluginsBase = class extends SmCommand {
10622
+ all = Option14.Boolean("--all", false);
10623
+ id = Option14.String({ required: false });
10595
10624
  // eslint-disable-next-line complexity
10596
10625
  async toggle(enabled) {
10597
- const elapsed = startElapsed();
10598
10626
  const verb = enabled ? "enable" : "disable";
10599
10627
  if (this.all && this.id) {
10600
10628
  this.context.stderr.write(PLUGINS_TEXTS.toggleBothIdAndAll);
10601
- emitDoneStderr(this.context.stderr, elapsed);
10602
10629
  return ExitCode.Error;
10603
10630
  }
10604
10631
  if (!this.all && !this.id) {
10605
10632
  this.context.stderr.write(PLUGINS_TEXTS.toggleNeitherIdNorAll);
10606
- emitDoneStderr(this.context.stderr, elapsed);
10607
10633
  return ExitCode.Error;
10608
10634
  }
10609
10635
  const plugins = await loadAll({
@@ -10618,7 +10644,6 @@ var TogglePluginsBase = class extends Command13 {
10618
10644
  const resolved = resolveToggleTarget(this.id, catalogue, verb);
10619
10645
  if ("error" in resolved) {
10620
10646
  this.context.stderr.write(tx(PLUGINS_TEXTS.toggleResolveError, { error: resolved.error }));
10621
- emitDoneStderr(this.context.stderr, elapsed);
10622
10647
  return ExitCode.NotFound;
10623
10648
  }
10624
10649
  targets = [resolved.key];
@@ -10641,13 +10666,12 @@ var TogglePluginsBase = class extends Command13 {
10641
10666
  this.context.stdout.write(tx(PLUGINS_TEXTS.toggleAppliedManyRow, { id }));
10642
10667
  }
10643
10668
  }
10644
- emitDoneStderr(this.context.stderr, elapsed);
10645
10669
  return ExitCode.Ok;
10646
10670
  }
10647
10671
  };
10648
10672
  var PluginsEnableCommand = class extends TogglePluginsBase {
10649
10673
  static paths = [["plugins", "enable"]];
10650
- static usage = Command13.Usage({
10674
+ static usage = Command14.Usage({
10651
10675
  category: "Plugins",
10652
10676
  description: "Enable a plugin (or --all). Persists in config_plugins.",
10653
10677
  details: `
@@ -10663,13 +10687,13 @@ var PluginsEnableCommand = class extends TogglePluginsBase {
10663
10687
  with directed guidance.
10664
10688
  `
10665
10689
  });
10666
- async execute() {
10690
+ async run() {
10667
10691
  return this.toggle(true);
10668
10692
  }
10669
10693
  };
10670
10694
  var PluginsDisableCommand = class extends TogglePluginsBase {
10671
10695
  static paths = [["plugins", "disable"]];
10672
- static usage = Command13.Usage({
10696
+ static usage = Command14.Usage({
10673
10697
  category: "Plugins",
10674
10698
  description: "Disable a plugin (or --all). Persists in config_plugins; does not delete files.",
10675
10699
  details: `
@@ -10685,7 +10709,7 @@ var PluginsDisableCommand = class extends TogglePluginsBase {
10685
10709
  with directed guidance.
10686
10710
  `
10687
10711
  });
10688
- async execute() {
10712
+ async run() {
10689
10713
  return this.toggle(false);
10690
10714
  }
10691
10715
  };
@@ -10706,7 +10730,7 @@ var PLUGIN_COMMANDS = [
10706
10730
  // cli/commands/refresh.ts
10707
10731
  import { readFile as readFile3 } from "fs/promises";
10708
10732
  import { resolve as resolve18 } from "path";
10709
- import { Command as Command14, Option as Option14 } from "clipanion";
10733
+ import { Command as Command15, Option as Option15 } from "clipanion";
10710
10734
 
10711
10735
  // cli/i18n/refresh.texts.ts
10712
10736
  var REFRESH_TEXTS = {
@@ -10747,9 +10771,9 @@ function assertContained2(cwd, rel) {
10747
10771
  }
10748
10772
 
10749
10773
  // cli/commands/refresh.ts
10750
- var RefreshCommand = class extends Command14 {
10774
+ var RefreshCommand = class extends SmCommand {
10751
10775
  static paths = [["refresh"]];
10752
- static usage = Command14.Usage({
10776
+ static usage = Command15.Usage({
10753
10777
  category: "Scan",
10754
10778
  description: "Refresh enrichment rows: granular (single node) or batch (every stale row).",
10755
10779
  details: `
@@ -10774,11 +10798,11 @@ var RefreshCommand = class extends Command14 {
10774
10798
  ["Refresh every node with stale enrichments", "$0 refresh --stale"]
10775
10799
  ]
10776
10800
  });
10777
- nodePath = Option14.String({ name: "node", required: false });
10778
- stale = Option14.Boolean("--stale", false, {
10801
+ nodePath = Option15.String({ name: "node", required: false });
10802
+ stale = Option15.Boolean("--stale", false, {
10779
10803
  description: "Refresh every node whose probabilistic enrichment row is flagged stale=1."
10780
10804
  });
10781
- noPlugins = Option14.Boolean("--no-plugins", false, {
10805
+ noPlugins = Option15.Boolean("--no-plugins", false, {
10782
10806
  description: "Skip drop-in plugin discovery; use only the built-in extractor set."
10783
10807
  });
10784
10808
  // The remaining cyclomatic count comes from CLI ergonomics that don't
@@ -10787,7 +10811,7 @@ var RefreshCommand = class extends Command14 {
10787
10811
  // catch, plus the `if (probSkipCount > 0)` advisory. The inner work
10788
10812
  // already lives in `#resolveTargetNodes` and `#runDetExtractorsAcrossNodes`.
10789
10813
  // eslint-disable-next-line complexity
10790
- async execute() {
10814
+ async run() {
10791
10815
  if (this.stale && this.nodePath !== void 0) {
10792
10816
  this.context.stderr.write(REFRESH_TEXTS.nodeAndStaleMutex);
10793
10817
  return ExitCode.Error;
@@ -10963,7 +10987,7 @@ function stripFrontmatterFence(text) {
10963
10987
  var REFRESH_COMMANDS = [RefreshCommand];
10964
10988
 
10965
10989
  // cli/commands/scan.ts
10966
- import { Command as Command16, Option as Option16 } from "clipanion";
10990
+ import { Command as Command17, Option as Option17 } from "clipanion";
10967
10991
 
10968
10992
  // cli/i18n/scan.texts.ts
10969
10993
  var SCAN_TEXTS = {
@@ -11006,24 +11030,174 @@ var SCAN_TEXTS = {
11006
11030
  compareDeltaIssueRemoved: "- [{{severity}}] {{ruleId}}: {{message}}"
11007
11031
  };
11008
11032
 
11009
- // cli/commands/watch.ts
11010
- import { Command as Command15, Option as Option15 } from "clipanion";
11011
-
11012
- // cli/i18n/watch.texts.ts
11013
- var WATCH_TEXTS = {
11014
- configLoadFailure: "sm watch: {{message}}\n",
11015
- initialScanFailed: "sm watch: initial scan failed \u2014 {{message}}\n",
11016
- batchFailed: "sm watch: batch failed \u2014 {{message}}\n",
11017
- scanFailed: "sm watch: scan failed \u2014 {{message}}\n",
11018
- watcherError: "sm watch: watcher error \u2014 {{message}}\n",
11033
+ // cli/util/scan-runner.ts
11034
+ async function runScanForCommand(opts) {
11035
+ const ctx = opts.ctx ?? defaultRuntimeContext();
11036
+ const dbPath = defaultProjectDbPath(ctx);
11037
+ const kernel = createKernel();
11038
+ const pluginRuntime = await preparePluginRuntime(opts);
11039
+ const extensions = registerExtensions(kernel, pluginRuntime, opts);
11040
+ let cfg;
11041
+ try {
11042
+ cfg = loadConfig({ scope: "project", strict: opts.strict, ...ctx }).effective;
11043
+ } catch (err) {
11044
+ return { kind: "config-error", message: formatErrorMessage(err) };
11045
+ }
11046
+ const ignoreFilter = buildScanIgnoreFilter(cfg, ctx.cwd);
11047
+ const strict = opts.strict || cfg.scan.strict === true;
11048
+ const loadPrior = makePriorLoader(opts.noBuiltIns, strict);
11049
+ const runScanWith = makeScanRunner(kernel, opts, ignoreFilter, strict, extensions);
11050
+ const willPersist = !opts.noBuiltIns && !opts.dryRun;
11051
+ return willPersist ? runPersistPath(opts, dbPath, strict, loadPrior, runScanWith) : runEphemeralPath(opts, dbPath, strict, loadPrior, runScanWith);
11052
+ }
11053
+ async function preparePluginRuntime(opts) {
11054
+ const pluginRuntime = opts.noPlugins ? emptyPluginRuntime() : await loadPluginRuntime({ scope: "project" });
11055
+ for (const warn of pluginRuntime.warnings) opts.stderr.write(`${warn}
11056
+ `);
11057
+ return pluginRuntime;
11058
+ }
11059
+ function registerExtensions(kernel, pluginRuntime, opts) {
11060
+ const extensions = composeScanExtensions({
11061
+ noBuiltIns: opts.noBuiltIns,
11062
+ pluginRuntime
11063
+ });
11064
+ if (!opts.noBuiltIns) {
11065
+ const enabledBuiltIns = filterBuiltInManifests(listBuiltIns(), pluginRuntime.resolveEnabled);
11066
+ for (const manifest of enabledBuiltIns) kernel.registry.register(manifest);
11067
+ }
11068
+ for (const manifest of pluginRuntime.manifests) kernel.registry.register(manifest);
11069
+ return extensions;
11070
+ }
11071
+ function buildScanIgnoreFilter(cfg, cwd) {
11072
+ const ignoreFileText = readIgnoreFileText(cwd);
11073
+ const ignoreFilterOpts = {};
11074
+ if (cfg.ignore.length > 0) ignoreFilterOpts.configIgnore = cfg.ignore;
11075
+ if (ignoreFileText !== void 0) ignoreFilterOpts.ignoreFileText = ignoreFileText;
11076
+ return buildIgnoreFilter(ignoreFilterOpts);
11077
+ }
11078
+ function makePriorLoader(noBuiltIns, strict) {
11079
+ return async (adapter) => {
11080
+ if (noBuiltIns) return null;
11081
+ const loaded = await adapter.scans.load();
11082
+ if (loaded.nodes.length === 0) return null;
11083
+ if (strict) {
11084
+ const validators = loadSchemaValidators();
11085
+ const result = validators.validate("scan-result", loaded);
11086
+ if (!result.ok) {
11087
+ throw new Error(tx(SCAN_TEXTS.priorSchemaValidationFailed, { errors: result.errors }));
11088
+ }
11089
+ }
11090
+ return loaded;
11091
+ };
11092
+ }
11093
+ function makeScanRunner(kernel, opts, ignoreFilter, strict, extensions) {
11094
+ return async (prior, priorExtractorRuns) => {
11095
+ if (opts.changed && prior === null) {
11096
+ opts.stderr.write(SCAN_TEXTS.changedNoPriorWarning);
11097
+ }
11098
+ const runOptions = {
11099
+ roots: opts.roots,
11100
+ // Hardcoded `'project'`: spec § Global flags lists `-g/--global`
11101
+ // as universal, but the per-verb § Scan table does not list it
11102
+ // and the semantics of "scan global" are undefined. The
11103
+ // ScanCommand surface accepts `-g` (inherited from SmCommand)
11104
+ // but ignores it here. Wire to `opts.scope` once spec defines
11105
+ // the contract.
11106
+ scope: "project",
11107
+ tokenize: !opts.noTokens,
11108
+ ignoreFilter,
11109
+ strict,
11110
+ emitter: createCliProgressEmitter(opts.stderr)
11111
+ };
11112
+ if (extensions) runOptions.extensions = extensions;
11113
+ if (prior) {
11114
+ runOptions.priorSnapshot = prior;
11115
+ runOptions.enableCache = opts.changed;
11116
+ }
11117
+ if (priorExtractorRuns) runOptions.priorExtractorRuns = priorExtractorRuns;
11118
+ return runScanWithRenames(kernel, runOptions);
11119
+ };
11120
+ }
11121
+ async function runPersistPath(opts, dbPath, strict, loadPrior, runScanWith) {
11122
+ let outcome;
11123
+ try {
11124
+ outcome = await withSqlite({ databasePath: dbPath }, async (adapter) => {
11125
+ const prior = await loadPrior(adapter);
11126
+ const priorExtractorRuns = opts.changed && prior ? await adapter.scans.loadExtractorRuns() : void 0;
11127
+ let scanned;
11128
+ try {
11129
+ scanned = await runScanWith(prior, priorExtractorRuns);
11130
+ } catch (err) {
11131
+ return { kind: "scan-error", message: formatErrorMessage(err) };
11132
+ }
11133
+ if (scanned.result.stats.nodesCount === 0 && !opts.allowEmpty) {
11134
+ const counts = await adapter.scans.countRows();
11135
+ const existing = counts.nodes + counts.links + counts.issues;
11136
+ if (existing > 0) return { kind: "guard", existing };
11137
+ }
11138
+ await adapter.scans.persist(scanned.result, {
11139
+ renameOps: scanned.renameOps,
11140
+ extractorRuns: scanned.extractorRuns,
11141
+ enrichments: scanned.enrichments
11142
+ });
11143
+ return { kind: "ok", ...scanned };
11144
+ });
11145
+ } catch (err) {
11146
+ return { kind: "scan-error", message: formatErrorMessage(err) };
11147
+ }
11148
+ if (outcome.kind === "scan-error") return outcome;
11149
+ if (outcome.kind === "guard") return { kind: "guard-trip", existing: outcome.existing };
11150
+ return {
11151
+ kind: "ok",
11152
+ result: outcome.result,
11153
+ renameOps: outcome.renameOps,
11154
+ persistedTo: dbPath,
11155
+ dbPath,
11156
+ strict
11157
+ };
11158
+ }
11159
+ async function runEphemeralPath(opts, dbPath, strict, loadPrior, runScanWith) {
11160
+ let prior;
11161
+ try {
11162
+ prior = opts.noBuiltIns ? null : await tryWithSqlite({ databasePath: dbPath, autoBackup: false }, loadPrior);
11163
+ } catch (err) {
11164
+ return { kind: "scan-error", message: formatErrorMessage(err) };
11165
+ }
11166
+ try {
11167
+ const scanned = await runScanWith(prior);
11168
+ return {
11169
+ kind: "ok",
11170
+ result: scanned.result,
11171
+ renameOps: scanned.renameOps,
11172
+ persistedTo: null,
11173
+ dbPath,
11174
+ strict
11175
+ };
11176
+ } catch (err) {
11177
+ return { kind: "scan-error", message: formatErrorMessage(err) };
11178
+ }
11179
+ }
11180
+
11181
+ // cli/commands/watch.ts
11182
+ import { Command as Command16, Option as Option16 } from "clipanion";
11183
+
11184
+ // cli/i18n/watch.texts.ts
11185
+ var WATCH_TEXTS = {
11186
+ configLoadFailure: "sm watch: {{message}}\n",
11187
+ initialScanFailed: "sm watch: initial scan failed \u2014 {{message}}\n",
11188
+ batchFailed: "sm watch: batch failed \u2014 {{message}}\n",
11189
+ scanFailed: "sm watch: scan failed \u2014 {{message}}\n",
11190
+ watcherError: "sm watch: watcher error \u2014 {{message}}\n",
11019
11191
  starting: "sm watch: starting on {{rootsCount}} root(s), debounce {{debounceMs}}ms\n",
11020
11192
  ready: "sm watch: ready. Press Ctrl+C to stop.\n",
11021
11193
  stopped: "sm watch: stopped after {{batchCount}} batch(es).\n",
11022
11194
  scannedSummary: "scanned {{nodes}} nodes / {{links}} links / {{issues}} issues in {{durationMs}}ms\n",
11023
- priorSchemaValidationFailed: "prior scan-result loaded from DB failed schema validation: {{errors}}"
11195
+ priorSchemaValidationFailed: "prior scan-result loaded from DB failed schema validation: {{errors}}",
11196
+ breakerTripped: "sm watch: {{count}} consecutive batch failures \u2014 shutting down. Last error: {{message}}\n"
11024
11197
  };
11025
11198
 
11026
11199
  // cli/commands/watch.ts
11200
+ var DEFAULT_MAX_CONSECUTIVE_FAILURES = 5;
11027
11201
  async function runWatchLoop(opts) {
11028
11202
  const { context } = opts;
11029
11203
  const runtimeCtx = defaultRuntimeContext();
@@ -11087,21 +11261,8 @@ async function runWatchLoop(opts) {
11087
11261
  runOptions.enableCache = true;
11088
11262
  }
11089
11263
  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
- }
11264
+ const ran = await runScanWithRenames(kernel, runOptions);
11265
+ const { result, renameOps, extractorRuns, enrichments } = ran;
11105
11266
  await withSqlite(
11106
11267
  { databasePath: dbPath },
11107
11268
  (writer) => writer.scans.persist(result, { renameOps, extractorRuns, enrichments })
@@ -11135,21 +11296,38 @@ async function runWatchLoop(opts) {
11135
11296
  const stopped = new Promise((r) => {
11136
11297
  stopResolve = r;
11137
11298
  });
11299
+ const breakerLimit = opts.maxConsecutiveFailures ?? DEFAULT_MAX_CONSECUTIVE_FAILURES;
11300
+ let consecutiveFailures = 0;
11301
+ let exitCode2 = ExitCode.Ok;
11302
+ const handleBatch = async () => {
11303
+ if (stopRequested) return "stop";
11304
+ batchCount++;
11305
+ try {
11306
+ await runOnePass();
11307
+ consecutiveFailures = 0;
11308
+ } catch (err) {
11309
+ const message = formatErrorMessage(err);
11310
+ context.stderr.write(tx(WATCH_TEXTS.batchFailed, { message }));
11311
+ consecutiveFailures += 1;
11312
+ if (breakerLimit > 0 && consecutiveFailures >= breakerLimit) {
11313
+ context.stderr.write(
11314
+ tx(WATCH_TEXTS.breakerTripped, { count: consecutiveFailures, message })
11315
+ );
11316
+ exitCode2 = ExitCode.Error;
11317
+ return "stop";
11318
+ }
11319
+ }
11320
+ if (opts.maxBatches !== void 0 && batchCount >= opts.maxBatches) return "stop";
11321
+ return "continue";
11322
+ };
11138
11323
  const watcher = createChokidarWatcher({
11139
11324
  roots: opts.roots,
11140
11325
  cwd,
11141
11326
  debounceMs,
11142
11327
  ignoreFilter,
11143
11328
  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) {
11329
+ const next = await handleBatch();
11330
+ if (next === "stop") {
11153
11331
  stopRequested = true;
11154
11332
  stopResolve?.();
11155
11333
  }
@@ -11176,11 +11354,11 @@ async function runWatchLoop(opts) {
11176
11354
  if (!opts.json) {
11177
11355
  context.stderr.write(tx(WATCH_TEXTS.stopped, { batchCount }));
11178
11356
  }
11179
- return ExitCode.Ok;
11357
+ return exitCode2;
11180
11358
  }
11181
- var WatchCommand = class extends Command15 {
11359
+ var WatchCommand = class extends SmCommand {
11182
11360
  static paths = [["watch"]];
11183
- static usage = Command15.Usage({
11361
+ static usage = Command16.Usage({
11184
11362
  category: "Scan",
11185
11363
  description: "Watch roots and run an incremental scan after each debounced batch of filesystem events.",
11186
11364
  details: `
@@ -11204,36 +11382,55 @@ var WatchCommand = class extends Command15 {
11204
11382
  ["Stream ScanResult per batch as ndjson", "$0 watch --json"]
11205
11383
  ]
11206
11384
  });
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, {
11385
+ roots = Option16.Rest({ name: "roots" });
11386
+ noTokens = Option16.Boolean("--no-tokens", false, {
11212
11387
  description: "Skip per-node token counts (cl100k_base BPE)."
11213
11388
  });
11214
- strict = Option15.Boolean("--strict", false, {
11389
+ strict = Option16.Boolean("--strict", false, {
11215
11390
  description: "Promote frontmatter-validation findings from warn to error inside each batch. Does not change the watcher exit code."
11216
11391
  });
11217
- noPlugins = Option15.Boolean("--no-plugins", false, {
11392
+ noPlugins = Option16.Boolean("--no-plugins", false, {
11218
11393
  description: "Skip drop-in plugin discovery for the watcher session."
11219
11394
  });
11220
- async execute() {
11395
+ maxConsecutiveFailures = Option16.String("--max-consecutive-failures", {
11396
+ required: false,
11397
+ description: "Shut down with exit 2 after N consecutive batch failures (default 5; 0 disables the breaker)."
11398
+ });
11399
+ // Long-running verb — the watcher prints its own "stopped" line on
11400
+ // SIGINT / SIGTERM. Adding `done in <…>` after that would be noise.
11401
+ emitElapsed = false;
11402
+ async run() {
11221
11403
  const roots = this.roots.length > 0 ? this.roots : ["."];
11222
- return runWatchLoop({
11404
+ const breaker = parseBreakerLimit(this.maxConsecutiveFailures, this.context.stderr);
11405
+ if (breaker === null) return ExitCode.Error;
11406
+ const watchOpts = {
11223
11407
  roots,
11224
11408
  json: this.json,
11225
11409
  noTokens: this.noTokens,
11226
11410
  strict: this.strict,
11227
11411
  noPlugins: this.noPlugins,
11228
11412
  context: this.context
11229
- });
11413
+ };
11414
+ if (breaker !== void 0) watchOpts.maxConsecutiveFailures = breaker;
11415
+ return runWatchLoop(watchOpts);
11230
11416
  }
11231
11417
  };
11418
+ function parseBreakerLimit(raw, stderr) {
11419
+ if (raw === void 0) return void 0;
11420
+ const trimmed = raw.trim();
11421
+ const parsed = Number.parseInt(trimmed, 10);
11422
+ if (!Number.isInteger(parsed) || parsed < 0 || String(parsed) !== trimmed) {
11423
+ stderr.write(`sm watch: --max-consecutive-failures must be a non-negative integer (got ${raw})
11424
+ `);
11425
+ return null;
11426
+ }
11427
+ return parsed;
11428
+ }
11232
11429
 
11233
11430
  // cli/commands/scan.ts
11234
- var ScanCommand = class extends Command16 {
11431
+ var ScanCommand = class extends SmCommand {
11235
11432
  static paths = [["scan"]];
11236
- static usage = Command16.Usage({
11433
+ static usage = Command17.Usage({
11237
11434
  category: "Scan",
11238
11435
  description: "Scan roots for markdown nodes, run extractors and rules.",
11239
11436
  details: `
@@ -11262,198 +11459,87 @@ var ScanCommand = class extends Command16 {
11262
11459
  ["What would the next incremental scan persist?", "$0 scan --changed -n --json"]
11263
11460
  ]
11264
11461
  });
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, {
11462
+ roots = Option17.Rest({ name: "roots" });
11463
+ noBuiltIns = Option17.Boolean("--no-built-ins", false, {
11270
11464
  description: "Skip the built-in extension set. Yields a zero-filled ScanResult (kernel-empty-boot parity); skips DB persistence."
11271
11465
  });
11272
- noPlugins = Option16.Boolean("--no-plugins", false, {
11466
+ noPlugins = Option17.Boolean("--no-plugins", false, {
11273
11467
  description: "Skip drop-in plugin discovery. Only the built-in set runs. Combine with --no-built-ins for a fully empty pipeline."
11274
11468
  });
11275
- noTokens = Option16.Boolean("--no-tokens", false, {
11469
+ noTokens = Option17.Boolean("--no-tokens", false, {
11276
11470
  description: "Skip per-node token counts (cl100k_base BPE). Leaves node.tokens undefined; spec-valid since the field is optional."
11277
11471
  });
11278
- dryRun = Option16.Boolean("-n,--dry-run", false, {
11472
+ dryRun = Option17.Boolean("-n,--dry-run", false, {
11279
11473
  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
11474
  });
11281
- changed = Option16.Boolean("--changed", false, {
11475
+ changed = Option17.Boolean("--changed", false, {
11282
11476
  description: "Incremental scan: reuse unchanged nodes from the persisted prior snapshot. Degrades to a full scan if no prior snapshot exists."
11283
11477
  });
11284
- allowEmpty = Option16.Boolean("--allow-empty", false, {
11478
+ allowEmpty = Option17.Boolean("--allow-empty", false, {
11285
11479
  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
11480
  });
11287
- strict = Option16.Boolean("--strict", false, {
11481
+ strict = Option17.Boolean("--strict", false, {
11288
11482
  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
11483
  });
11290
- watch = Option16.Boolean("--watch", false, {
11484
+ watch = Option17.Boolean("--watch", false, {
11291
11485
  description: "Long-running mode: watch the roots and trigger an incremental scan after each debounced batch of filesystem events. Alias of `sm watch`."
11292
11486
  });
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
- }
11487
+ async run() {
11488
+ if (this.watch) return this.runWatchAlias();
11318
11489
  if (this.changed && this.noBuiltIns) {
11319
11490
  this.context.stderr.write(SCAN_TEXTS.changedWithoutBuiltIns);
11320
11491
  return ExitCode.Error;
11321
11492
  }
11322
- const kernel = createKernel();
11323
11493
  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({
11494
+ const outcome = await runScanForCommand({
11495
+ roots,
11330
11496
  noBuiltIns: this.noBuiltIns,
11331
- pluginRuntime
11497
+ noPlugins: this.noPlugins,
11498
+ noTokens: this.noTokens,
11499
+ dryRun: this.dryRun,
11500
+ changed: this.changed,
11501
+ allowEmpty: this.allowEmpty,
11502
+ strict: this.strict,
11503
+ stderr: this.context.stderr
11332
11504
  });
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 }));
11505
+ return outcome.kind === "ok" ? this.renderOutcome(outcome.result, outcome.persistedTo, outcome.dbPath, outcome.strict) : this.renderFailure(outcome);
11506
+ }
11507
+ /**
11508
+ * `--watch` is a thin alias for the `sm watch` verb. Combining
11509
+ * `--watch` with one-shot-only flags is incoherent — the watcher
11510
+ * always persists incrementally over the prior snapshot.
11511
+ */
11512
+ async runWatchAlias() {
11513
+ if (this.noBuiltIns || this.dryRun || this.changed || this.allowEmpty) {
11514
+ this.context.stderr.write(SCAN_TEXTS.watchCannotCombine);
11346
11515
  return ExitCode.Error;
11347
11516
  }
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
- }
11517
+ this.emitElapsed = false;
11518
+ const roots = this.roots.length > 0 ? this.roots : ["."];
11519
+ return runWatchLoop({
11520
+ roots,
11521
+ json: this.json,
11522
+ noTokens: this.noTokens,
11523
+ strict: this.strict,
11524
+ noPlugins: this.noPlugins,
11525
+ context: this.context
11526
+ });
11527
+ }
11528
+ /** Render the failure branch of `IScanRunResult` to stderr. */
11529
+ renderFailure(outcome) {
11530
+ if (outcome.kind === "guard-trip") {
11531
+ this.context.stderr.write(tx(SCAN_TEXTS.guardWipeRefused, { existing: outcome.existing }));
11532
+ return ExitCode.Error;
11456
11533
  }
11534
+ this.context.stderr.write(tx(SCAN_TEXTS.scanFailure, { message: outcome.message }));
11535
+ return ExitCode.Error;
11536
+ }
11537
+ /**
11538
+ * Render the successful outcome to stdout (JSON or human) and compute
11539
+ * the exit code. Exit 1 only when at least one issue is at `error`
11540
+ * severity (mirrors `sm check`, per spec § Exit codes).
11541
+ */
11542
+ renderOutcome(result, persistedTo, dbPath, strict) {
11457
11543
  const exitCode2 = result.issues.some((i) => i.severity === "error") ? ExitCode.Issues : ExitCode.Ok;
11458
11544
  if (this.json) {
11459
11545
  if (strict) {
@@ -11494,10 +11580,10 @@ var ScanCommand = class extends Command16 {
11494
11580
 
11495
11581
  // cli/commands/scan-compare.ts
11496
11582
  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 {
11583
+ import { Command as Command18, Option as Option18 } from "clipanion";
11584
+ var ScanCompareCommand = class extends SmCommand {
11499
11585
  static paths = [["scan", "compare-with"]];
11500
- static usage = Command17.Usage({
11586
+ static usage = Command18.Usage({
11501
11587
  category: "Scan",
11502
11588
  description: "Run a fresh scan in memory and emit a delta against the saved ScanResult dump at <dump>. Read-only.",
11503
11589
  details: `
@@ -11525,18 +11611,15 @@ var ScanCompareCommand = class extends Command17 {
11525
11611
  ["JSON output for tooling", "$0 scan compare-with baseline.json --json"]
11526
11612
  ]
11527
11613
  });
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, {
11614
+ dump = Option18.String({ required: true });
11615
+ roots = Option18.Rest({ name: "roots" });
11616
+ noTokens = Option18.Boolean("--no-tokens", false, {
11534
11617
  description: "Skip per-node token counts during the fresh scan."
11535
11618
  });
11536
- strict = Option17.Boolean("--strict", false, {
11619
+ strict = Option18.Boolean("--strict", false, {
11537
11620
  description: "Promote layered-config warnings and frontmatter-validation findings from warn to error."
11538
11621
  });
11539
- noPlugins = Option17.Boolean("--no-plugins", false, {
11622
+ noPlugins = Option18.Boolean("--no-plugins", false, {
11540
11623
  description: "Skip drop-in plugin discovery."
11541
11624
  });
11542
11625
  // Cyclomatic count comes from CLI ergonomics: 3 distinct try/catch
@@ -11544,7 +11627,7 @@ var ScanCompareCommand = class extends Command17 {
11544
11627
  // for the JSON branch. The pure pieces already live in
11545
11628
  // `loadAndValidateDump` and `computeScanDelta`.
11546
11629
  // eslint-disable-next-line complexity
11547
- async execute() {
11630
+ async run() {
11548
11631
  const ctx = defaultRuntimeContext();
11549
11632
  const roots = this.roots.length > 0 ? this.roots : ["."];
11550
11633
  let prior;
@@ -11719,8 +11802,646 @@ function renderDeltaIssues(issues) {
11719
11802
  return lines;
11720
11803
  }
11721
11804
 
11805
+ // cli/commands/serve.ts
11806
+ import { spawn } from "child_process";
11807
+ import { existsSync as existsSync18 } from "fs";
11808
+ import { Command as Command19, Option as Option19 } from "clipanion";
11809
+
11810
+ // server/index.ts
11811
+ import { serve } from "@hono/node-server";
11812
+ import { WebSocketServer } from "ws";
11813
+
11814
+ // server/app.ts
11815
+ import { Hono } from "hono";
11816
+ import { HTTPException } from "hono/http-exception";
11817
+
11818
+ // server/health.ts
11819
+ import { existsSync as existsSync15 } from "fs";
11820
+ var FALLBACK_SCHEMA_VERSION = "1";
11821
+ function buildHealth(deps) {
11822
+ return {
11823
+ ok: true,
11824
+ schemaVersion: FALLBACK_SCHEMA_VERSION,
11825
+ specVersion: deps.specVersion,
11826
+ implVersion: VERSION,
11827
+ scope: deps.scope,
11828
+ db: existsSync15(deps.dbPath) ? "present" : "missing"
11829
+ };
11830
+ }
11831
+ async function resolveSpecVersion2() {
11832
+ try {
11833
+ const mod = await import("@skill-map/spec", { with: { type: "json" } });
11834
+ const version = mod.default?.specPackageVersion;
11835
+ return version ?? "unknown";
11836
+ } catch {
11837
+ return "unknown";
11838
+ }
11839
+ }
11840
+
11841
+ // server/static.ts
11842
+ import { existsSync as existsSync16 } from "fs";
11843
+ import { readFile as readFile4 } from "fs/promises";
11844
+ import { extname, join as join13 } from "path";
11845
+ import { serveStatic } from "@hono/node-server/serve-static";
11846
+ var INDEX_HTML = "index.html";
11847
+ var PLACEHOLDER_HTML = `<!doctype html>
11848
+ <html lang="en">
11849
+ <head>
11850
+ <meta charset="utf-8" />
11851
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
11852
+ <meta name="skill-map-mode" content="live" />
11853
+ <title>skill-map server</title>
11854
+ <style>
11855
+ body { font-family: system-ui, sans-serif; margin: 2rem; max-width: 36rem; line-height: 1.5; }
11856
+ code { background: #f4f4f4; padding: 0.1rem 0.3rem; border-radius: 3px; }
11857
+ h1 { font-size: 1.4rem; }
11858
+ </style>
11859
+ </head>
11860
+ <body>
11861
+ <h1>skill-map server is running</h1>
11862
+ <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>
11863
+ <p>The REST API is available at <code>/api/health</code>.</p>
11864
+ </body>
11865
+ </html>
11866
+ `;
11867
+ function createStaticHandler(uiDist) {
11868
+ if (uiDist === null) return placeholderRootMiddleware();
11869
+ return serveStatic({ root: uiDist });
11870
+ }
11871
+ function createSpaFallback(uiDist) {
11872
+ return async (c, _next) => {
11873
+ if (c.req.method !== "GET" && c.req.method !== "HEAD") return c.notFound();
11874
+ if (uiDist === null) return htmlResponse(c, PLACEHOLDER_HTML);
11875
+ const indexPath = join13(uiDist, INDEX_HTML);
11876
+ if (!existsSync16(indexPath)) return htmlResponse(c, PLACEHOLDER_HTML);
11877
+ return fileResponse(c, indexPath);
11878
+ };
11879
+ }
11880
+ function placeholderRootMiddleware() {
11881
+ return async (c, next) => {
11882
+ if (c.req.method !== "GET" && c.req.method !== "HEAD") return next();
11883
+ if (c.req.path === "/" || c.req.path === "/index.html") {
11884
+ return htmlResponse(c, PLACEHOLDER_HTML);
11885
+ }
11886
+ return next();
11887
+ };
11888
+ }
11889
+ var MIME_TYPES = {
11890
+ ".html": "text/html; charset=UTF-8",
11891
+ ".js": "application/javascript; charset=UTF-8",
11892
+ ".mjs": "application/javascript; charset=UTF-8",
11893
+ ".css": "text/css; charset=UTF-8",
11894
+ ".json": "application/json; charset=UTF-8",
11895
+ ".svg": "image/svg+xml",
11896
+ ".png": "image/png",
11897
+ ".jpg": "image/jpeg",
11898
+ ".jpeg": "image/jpeg",
11899
+ ".gif": "image/gif",
11900
+ ".webp": "image/webp",
11901
+ ".ico": "image/x-icon",
11902
+ ".woff": "font/woff",
11903
+ ".woff2": "font/woff2",
11904
+ ".ttf": "font/ttf",
11905
+ ".otf": "font/otf",
11906
+ ".txt": "text/plain; charset=UTF-8",
11907
+ ".map": "application/json; charset=UTF-8"
11908
+ };
11909
+ function mimeFor(filePath) {
11910
+ const ext = extname(filePath).toLowerCase();
11911
+ return MIME_TYPES[ext] ?? "application/octet-stream";
11912
+ }
11913
+ function htmlResponse(c, html) {
11914
+ return c.body(html, 200, { "content-type": "text/html; charset=UTF-8" });
11915
+ }
11916
+ async function fileResponse(c, absPath) {
11917
+ const buf = await readFile4(absPath);
11918
+ return c.body(buf, 200, { "content-type": mimeFor(absPath) });
11919
+ }
11920
+
11921
+ // server/app.ts
11922
+ function createApp(deps) {
11923
+ const app = new Hono();
11924
+ if (deps.options.devCors) {
11925
+ app.use("*", async (c, next) => {
11926
+ await next();
11927
+ c.res.headers.set("access-control-allow-origin", "*");
11928
+ c.res.headers.set("access-control-allow-methods", "GET,POST,PUT,DELETE,OPTIONS");
11929
+ c.res.headers.set("access-control-allow-headers", "content-type, authorization");
11930
+ });
11931
+ app.options("*", (c) => c.body(null, 204));
11932
+ }
11933
+ app.get("/api/health", (c) => {
11934
+ const payload = buildHealth({
11935
+ dbPath: deps.options.dbPath,
11936
+ scope: deps.options.scope,
11937
+ specVersion: deps.specVersion
11938
+ });
11939
+ return c.json(payload);
11940
+ });
11941
+ app.all("/api/*", (c) => {
11942
+ throw new HTTPException(404, { message: `Unknown API endpoint: ${c.req.path}` });
11943
+ });
11944
+ deps.attachWs(app);
11945
+ app.use("*", createStaticHandler(deps.options.uiDist));
11946
+ app.get("*", createSpaFallback(deps.options.uiDist));
11947
+ app.notFound((c) => {
11948
+ throw new HTTPException(404, { message: `Not found: ${c.req.path}` });
11949
+ });
11950
+ app.onError((err, c) => {
11951
+ return formatError2(err, c);
11952
+ });
11953
+ return app;
11954
+ }
11955
+ function codeForStatus(status) {
11956
+ if (status === 404) return "not-found";
11957
+ if (status === 400) return "bad-query";
11958
+ return "internal";
11959
+ }
11960
+ function formatError2(err, c) {
11961
+ if (err instanceof HTTPException) {
11962
+ const status = err.status;
11963
+ const envelope2 = {
11964
+ ok: false,
11965
+ error: {
11966
+ code: codeForStatus(status),
11967
+ message: err.message,
11968
+ details: null
11969
+ }
11970
+ };
11971
+ return c.json(envelope2, status);
11972
+ }
11973
+ const envelope = {
11974
+ ok: false,
11975
+ error: {
11976
+ code: "internal",
11977
+ message: formatErrorMessage(err),
11978
+ details: null
11979
+ }
11980
+ };
11981
+ return c.json(envelope, 500);
11982
+ }
11983
+
11984
+ // server/ws.ts
11985
+ import { upgradeWebSocket } from "@hono/node-server";
11986
+ var WS_PATH = "/ws";
11987
+ var NOOP_CLOSE_CODE = 1e3;
11988
+ var NOOP_CLOSE_REASON = "no broadcaster yet";
11989
+ function noopWebSocketRoute(app) {
11990
+ app.get(
11991
+ WS_PATH,
11992
+ upgradeWebSocket(() => ({
11993
+ onOpen(_event, ws) {
11994
+ ws.close(NOOP_CLOSE_CODE, NOOP_CLOSE_REASON);
11995
+ }
11996
+ }))
11997
+ );
11998
+ }
11999
+
12000
+ // server/options.ts
12001
+ var DEFAULT_PORT = 4242;
12002
+ var DEFAULT_HOST = "127.0.0.1";
12003
+ var DEFAULT_SCOPE = "project";
12004
+ var LOOPBACK_HOSTS = /* @__PURE__ */ new Set(["127.0.0.1", "::1", "localhost", "0:0:0:0:0:0:0:1"]);
12005
+ function isLoopbackHost(host) {
12006
+ return LOOPBACK_HOSTS.has(host.toLowerCase());
12007
+ }
12008
+ function validateServerOptions(input) {
12009
+ const filled = applyDefaults(input);
12010
+ const portError = validatePort(filled.port);
12011
+ if (portError !== null) return { ok: false, error: portError };
12012
+ const scopeError = validateScope(filled.scope);
12013
+ if (scopeError !== null) return { ok: false, error: scopeError };
12014
+ const hostError = validateHost(filled.host, filled.devCors);
12015
+ if (hostError !== null) return { ok: false, error: hostError };
12016
+ return {
12017
+ ok: true,
12018
+ options: {
12019
+ port: filled.port,
12020
+ host: filled.host,
12021
+ scope: filled.scope,
12022
+ dbPath: input.dbPath,
12023
+ uiDist: filled.uiDist,
12024
+ noBuiltIns: filled.noBuiltIns,
12025
+ noPlugins: filled.noPlugins,
12026
+ open: filled.open,
12027
+ devCors: filled.devCors
12028
+ }
12029
+ };
12030
+ }
12031
+ function applyDefaults(input) {
12032
+ return {
12033
+ port: input.port ?? DEFAULT_PORT,
12034
+ host: input.host ?? DEFAULT_HOST,
12035
+ scope: input.scope ?? DEFAULT_SCOPE,
12036
+ uiDist: input.uiDist ?? null,
12037
+ noBuiltIns: input.noBuiltIns ?? false,
12038
+ noPlugins: input.noPlugins ?? false,
12039
+ open: input.open ?? true,
12040
+ devCors: input.devCors ?? false
12041
+ };
12042
+ }
12043
+ function validatePort(port) {
12044
+ if (!Number.isInteger(port)) {
12045
+ return { code: "port-invalid", message: `port must be an integer (got ${port})`, value: String(port) };
12046
+ }
12047
+ if (port < 0 || port > 65535) {
12048
+ return {
12049
+ code: "port-out-of-range",
12050
+ message: `port must be in [0, 65535] (got ${port})`,
12051
+ value: String(port)
12052
+ };
12053
+ }
12054
+ return null;
12055
+ }
12056
+ function validateScope(scope) {
12057
+ if (scope !== "project" && scope !== "global") {
12058
+ return { code: "scope-invalid", message: `scope must be "project" or "global"`, value: String(scope) };
12059
+ }
12060
+ return null;
12061
+ }
12062
+ function validateHost(host, devCors) {
12063
+ if (devCors && !isLoopbackHost(host)) {
12064
+ return {
12065
+ code: "host-dev-cors-rejected",
12066
+ message: `--dev-cors requires a loopback --host (got ${host})`,
12067
+ value: host
12068
+ };
12069
+ }
12070
+ return null;
12071
+ }
12072
+
12073
+ // server/paths.ts
12074
+ import { existsSync as existsSync17, statSync as statSync5 } from "fs";
12075
+ import { dirname as dirname9, isAbsolute as isAbsolute4, join as join14, resolve as resolve19 } from "path";
12076
+ var DEFAULT_UI_REL = join14("ui", "dist", "browser");
12077
+ var INDEX_HTML2 = "index.html";
12078
+ function resolveDefaultUiDist(ctx) {
12079
+ let current = resolve19(ctx.cwd);
12080
+ for (let i = 0; i < 64; i++) {
12081
+ const candidate = join14(current, DEFAULT_UI_REL);
12082
+ if (isUiBundleDir(candidate)) return candidate;
12083
+ const parent = dirname9(current);
12084
+ if (parent === current) return null;
12085
+ current = parent;
12086
+ }
12087
+ return null;
12088
+ }
12089
+ function resolveExplicitUiDist(ctx, raw) {
12090
+ return isAbsolute4(raw) ? raw : resolve19(ctx.cwd, raw);
12091
+ }
12092
+ function isUiBundleDir(path) {
12093
+ if (!existsSync17(path)) return false;
12094
+ try {
12095
+ if (!statSync5(path).isDirectory()) return false;
12096
+ return existsSync17(join14(path, INDEX_HTML2));
12097
+ } catch {
12098
+ return false;
12099
+ }
12100
+ }
12101
+
12102
+ // server/index.ts
12103
+ async function createServer(options) {
12104
+ const specVersion = await resolveSpecVersion2();
12105
+ const app = createApp({ options, specVersion, attachWs: noopWebSocketRoute });
12106
+ const wss = new WebSocketServer({ noServer: true });
12107
+ const server = await listenAsync(app.fetch, wss, options.host, options.port);
12108
+ const addr = server.address();
12109
+ const address = normalizeAddress(addr, options.host, options.port);
12110
+ let closed = false;
12111
+ const close = async () => {
12112
+ if (closed) return;
12113
+ closed = true;
12114
+ await closeServer(server);
12115
+ wss.close();
12116
+ };
12117
+ return { address, close };
12118
+ }
12119
+ function listenAsync(fetchCallback, wss, host, port) {
12120
+ return new Promise((resolveListen, rejectListen) => {
12121
+ let settled = false;
12122
+ const server = serve(
12123
+ {
12124
+ fetch: fetchCallback,
12125
+ hostname: host,
12126
+ port,
12127
+ websocket: { server: wss }
12128
+ },
12129
+ () => {
12130
+ if (settled) return;
12131
+ settled = true;
12132
+ server.removeListener("error", onBindError);
12133
+ resolveListen(server);
12134
+ }
12135
+ );
12136
+ const onBindError = (err) => {
12137
+ if (settled) return;
12138
+ settled = true;
12139
+ rejectListen(err);
12140
+ };
12141
+ server.once("error", onBindError);
12142
+ });
12143
+ }
12144
+ function closeServer(server) {
12145
+ return new Promise((resolveClose, rejectClose) => {
12146
+ server.close((err) => {
12147
+ if (err) {
12148
+ rejectClose(err);
12149
+ } else {
12150
+ resolveClose();
12151
+ }
12152
+ });
12153
+ server.closeAllConnections?.();
12154
+ });
12155
+ }
12156
+ function normalizeAddress(addr, fallbackHost, fallbackPort) {
12157
+ if (addr === null || typeof addr === "string") {
12158
+ return { host: fallbackHost, port: fallbackPort, family: "IPv4" };
12159
+ }
12160
+ return { host: addr.address, port: addr.port, family: addr.family };
12161
+ }
12162
+
12163
+ // cli/i18n/serve.texts.ts
12164
+ var SERVE_TEXTS = {
12165
+ // Banner emitted to stderr after the listener binds. Mirrors `sm watch`
12166
+ // by writing operational status to stderr (stdout is reserved for
12167
+ // future `--json` boot payloads).
12168
+ boot: "sm serve: listening on http://{{host}}:{{port}} (scope={{scope}}, db={{db}})\n",
12169
+ // Hint shown after the boot line. Branches on --open: when auto-open
12170
+ // is on (default), the message states intent ("opening …"); when
12171
+ // --no-open, it instructs the user to visit the URL manually.
12172
+ // Both end with the Ctrl+C reminder so the operational tail is
12173
+ // identical regardless of branch.
12174
+ bootOpening: "sm serve: opening http://{{host}}:{{port}}/ in your browser. Press Ctrl+C to stop.\n",
12175
+ bootVisitHint: "sm serve: visit http://{{host}}:{{port}}/ in your browser. Press Ctrl+C to stop.\n",
12176
+ // Browser-open failure. Non-fatal — the URL is already printed; the
12177
+ // user can open it manually.
12178
+ openFailed: "sm serve: could not auto-open browser ({{message}}). Visit {{url}} manually.\n",
12179
+ // Bind failure (port in use, EACCES, etc.) → ExitCode.Error.
12180
+ bindFailed: "sm serve: failed to bind {{host}}:{{port}} \u2014 {{message}}\n",
12181
+ // Flag-validation failures — ExitCode.Error.
12182
+ hostDevCorsRejected: "sm serve: --dev-cors requires a loopback --host (got {{host}}). Refusing per Decision #119.\n",
12183
+ portOutOfRange: "sm serve: --port must be an integer in [0, 65535] (got {{value}}).\n",
12184
+ portInvalid: "sm serve: --port must be a non-negative integer (got {{value}}).\n",
12185
+ scopeInvalid: 'sm serve: --scope must be "project" or "global" (got {{value}}).\n',
12186
+ // Generic operational error — surfaced when the server itself throws
12187
+ // before the listener binds (e.g. UI bundle missing under explicit
12188
+ // --ui-dist).
12189
+ startupFailed: "sm serve: startup failed \u2014 {{message}}\n",
12190
+ // DB-not-found (--db <path> doesn't exist) → ExitCode.NotFound.
12191
+ dbNotFound: "sm serve: --db {{path}} does not exist.\n",
12192
+ // Shutdown trace — printed once the listener has closed.
12193
+ shutdown: "sm serve: shutdown complete.\n"
12194
+ };
12195
+
12196
+ // cli/commands/serve.ts
12197
+ var ServeCommand = class extends SmCommand {
12198
+ static paths = [["serve"]];
12199
+ static usage = Command19.Usage({
12200
+ category: "Setup",
12201
+ description: "Start the Hono BFF (single-port: REST + WebSocket + SPA bundle).",
12202
+ details: `
12203
+ Boots the skill-map Web UI's backing server. One Node process
12204
+ serves the Angular SPA, the REST API under /api/*, and the
12205
+ WebSocket at /ws \u2014 single-port mandate, no proxy.
12206
+
12207
+ Default port is 4242, default host is 127.0.0.1. The server boots
12208
+ even when the project DB is missing \u2014 /api/health reports
12209
+ 'db: missing' so the SPA renders an empty-state CTA instead of
12210
+ failing the connection.
12211
+
12212
+ Loopback-only assumption through v0.6.0 (no per-connection auth on
12213
+ /ws). Combining --dev-cors with a non-loopback --host is rejected.
12214
+
12215
+ SIGINT / SIGTERM trigger a graceful shutdown.
12216
+ `,
12217
+ examples: [
12218
+ ["Start on the default port and open the browser", "$0 serve"],
12219
+ ["Custom port, no browser auto-open", "$0 serve --port 5000 --no-open"],
12220
+ ["Use the global scope DB", "$0 serve --scope global"],
12221
+ ["Point at a pre-built UI bundle", "$0 serve --ui-dist ./ui/dist/browser"]
12222
+ ]
12223
+ });
12224
+ port = Option19.String("--port", {
12225
+ required: false,
12226
+ description: "Listening port (default 4242). 0 = OS-assigned."
12227
+ });
12228
+ host = Option19.String("--host", {
12229
+ required: false,
12230
+ description: "Listening host (default 127.0.0.1). Loopback-only enforced when --dev-cors is set."
12231
+ });
12232
+ scope = Option19.String("--scope", {
12233
+ required: false,
12234
+ description: "project | global. Alias for -g/--global. Default: project."
12235
+ });
12236
+ noBuiltIns = Option19.Boolean("--no-built-ins", false, {
12237
+ description: "Skip built-in plugin registration (parity with sm scan --no-built-ins)."
12238
+ });
12239
+ noPlugins = Option19.Boolean("--no-plugins", false, {
12240
+ description: "Skip drop-in plugin discovery."
12241
+ });
12242
+ // `Option.Boolean('--open', true)` — Clipanion's parser auto-derives
12243
+ // the `--no-open` inverse for every boolean flag (search for
12244
+ // `--no-${name.slice(2)}` in clipanion's core), so the explicit
12245
+ // `--no-open` descriptor must NOT be declared here or the parser sees
12246
+ // two registrations for the same flag and rejects the invocation
12247
+ // with "Ambiguous Syntax Error". Same convention shipped by every
12248
+ // other `--no-...` flag in the CLI tree.
12249
+ open = Option19.Boolean("--open", true, {
12250
+ description: "Auto-open the SPA in the user's default browser after listen. --no-open opts out."
12251
+ });
12252
+ devCors = Option19.Boolean("--dev-cors", false, {
12253
+ description: "Enable permissive CORS for the Angular dev-server proxy workflow."
12254
+ });
12255
+ // `--ui-dist` is intentionally undocumented in the Usage block above
12256
+ // (the demo build pipeline + tests rely on it; everyday users never
12257
+ // need it). Clipanion still exposes it on the parser; the Usage
12258
+ // omission is the "hidden" contract per the 14.1 brief.
12259
+ uiDist = Option19.String("--ui-dist", { required: false, hidden: true });
12260
+ // Long-running daemon — `done in <…>` after a graceful shutdown is
12261
+ // noise. Mirrors `sm watch`'s opt-out.
12262
+ emitElapsed = false;
12263
+ // CLI orchestrator with multi-flag handling — each `if (this.flag)`
12264
+ // branch is one cyclomatic point. Splitting per branch scatters the
12265
+ // validation away from the flag it gates. Per AGENTS.md §Linting
12266
+ // category 1 ("CLI orchestrators with multi-flag handling").
12267
+ // eslint-disable-next-line complexity
12268
+ async run() {
12269
+ const runtimeCtx = defaultRuntimeContext();
12270
+ const scopeResult = resolveScope(this.scope, this.global);
12271
+ if (!scopeResult.ok) {
12272
+ this.context.stderr.write(
12273
+ tx(SERVE_TEXTS.scopeInvalid, { value: sanitizeForTerminal(scopeResult.value) })
12274
+ );
12275
+ return ExitCode.Error;
12276
+ }
12277
+ const scope = scopeResult.scope;
12278
+ if (scope === "global") this.global = true;
12279
+ const portResult = parsePort(this.port);
12280
+ if (!portResult.ok) {
12281
+ this.context.stderr.write(
12282
+ tx(SERVE_TEXTS.portInvalid, { value: sanitizeForTerminal(portResult.value) })
12283
+ );
12284
+ return ExitCode.Error;
12285
+ }
12286
+ const dbPath = resolveDbPath({ global: this.global, db: this.db, ...runtimeCtx });
12287
+ if (this.db !== void 0 && !existsSync18(dbPath)) {
12288
+ this.context.stderr.write(
12289
+ tx(SERVE_TEXTS.dbNotFound, { path: sanitizeForTerminal(dbPath) })
12290
+ );
12291
+ return ExitCode.NotFound;
12292
+ }
12293
+ const uiDistResult = resolveUiDist(runtimeCtx, this.uiDist);
12294
+ if (!uiDistResult.ok) {
12295
+ this.context.stderr.write(
12296
+ tx(SERVE_TEXTS.startupFailed, { message: sanitizeForTerminal(uiDistResult.message) })
12297
+ );
12298
+ return ExitCode.Error;
12299
+ }
12300
+ const input = {
12301
+ dbPath,
12302
+ scope,
12303
+ uiDist: uiDistResult.uiDist,
12304
+ noBuiltIns: this.noBuiltIns,
12305
+ noPlugins: this.noPlugins,
12306
+ open: this.open,
12307
+ devCors: this.devCors
12308
+ };
12309
+ if (portResult.port !== void 0) input.port = portResult.port;
12310
+ if (this.host !== void 0) input.host = this.host;
12311
+ const validation = validateServerOptions(input);
12312
+ if (!validation.ok) {
12313
+ this.context.stderr.write(formatValidationError(validation.error));
12314
+ return ExitCode.Error;
12315
+ }
12316
+ let handle;
12317
+ try {
12318
+ handle = await createServer(validation.options);
12319
+ } catch (err) {
12320
+ const message = formatErrorMessage(err);
12321
+ this.context.stderr.write(
12322
+ tx(SERVE_TEXTS.bindFailed, {
12323
+ host: sanitizeForTerminal(validation.options.host),
12324
+ port: validation.options.port,
12325
+ message: sanitizeForTerminal(message)
12326
+ })
12327
+ );
12328
+ return ExitCode.Error;
12329
+ }
12330
+ this.context.stderr.write(
12331
+ tx(SERVE_TEXTS.boot, {
12332
+ host: sanitizeForTerminal(handle.address.host),
12333
+ port: handle.address.port,
12334
+ scope,
12335
+ db: sanitizeForTerminal(dbPath)
12336
+ })
12337
+ );
12338
+ this.context.stderr.write(
12339
+ tx(validation.options.open ? SERVE_TEXTS.bootOpening : SERVE_TEXTS.bootVisitHint, {
12340
+ host: sanitizeForTerminal(handle.address.host),
12341
+ port: handle.address.port
12342
+ })
12343
+ );
12344
+ if (validation.options.open) {
12345
+ const url = `http://${handle.address.host}:${handle.address.port}/`;
12346
+ tryOpenBrowser(url, this.context.stderr);
12347
+ }
12348
+ await waitForShutdown();
12349
+ await handle.close();
12350
+ this.context.stderr.write(SERVE_TEXTS.shutdown);
12351
+ return ExitCode.Ok;
12352
+ }
12353
+ };
12354
+ function parsePort(raw) {
12355
+ if (raw === void 0) return { ok: true, port: void 0 };
12356
+ const trimmed = raw.trim();
12357
+ const parsed = Number.parseInt(trimmed, 10);
12358
+ if (!Number.isInteger(parsed) || parsed < 0 || String(parsed) !== trimmed) {
12359
+ return { ok: false, value: raw };
12360
+ }
12361
+ return { ok: true, port: parsed };
12362
+ }
12363
+ function resolveScope(rawScope, global) {
12364
+ if (rawScope === void 0) return { ok: true, scope: global ? "global" : "project" };
12365
+ if (rawScope === "project" || rawScope === "global") {
12366
+ return { ok: true, scope: rawScope };
12367
+ }
12368
+ return { ok: false, value: rawScope };
12369
+ }
12370
+ function resolveUiDist(ctx, raw) {
12371
+ if (raw === void 0) {
12372
+ return { ok: true, uiDist: resolveDefaultUiDist(ctx) };
12373
+ }
12374
+ const abs = resolveExplicitUiDist(ctx, raw);
12375
+ if (!isUiBundleDir(abs)) {
12376
+ return {
12377
+ ok: false,
12378
+ message: `--ui-dist ${abs} does not exist or is not a directory containing index.html`
12379
+ };
12380
+ }
12381
+ return { ok: true, uiDist: abs };
12382
+ }
12383
+ function formatValidationError(err) {
12384
+ switch (err.code) {
12385
+ case "host-dev-cors-rejected":
12386
+ return tx(SERVE_TEXTS.hostDevCorsRejected, { host: sanitizeForTerminal(err.value) });
12387
+ case "port-out-of-range":
12388
+ return tx(SERVE_TEXTS.portOutOfRange, { value: sanitizeForTerminal(err.value) });
12389
+ case "port-invalid":
12390
+ return tx(SERVE_TEXTS.portInvalid, { value: sanitizeForTerminal(err.value) });
12391
+ case "scope-invalid":
12392
+ return tx(SERVE_TEXTS.scopeInvalid, { value: sanitizeForTerminal(err.value) });
12393
+ default:
12394
+ return tx(SERVE_TEXTS.startupFailed, { message: sanitizeForTerminal(err.message) });
12395
+ }
12396
+ }
12397
+ function waitForShutdown() {
12398
+ return new Promise((resolveShutdown) => {
12399
+ const onSignal = () => {
12400
+ process.removeListener("SIGINT", onSignal);
12401
+ process.removeListener("SIGTERM", onSignal);
12402
+ resolveShutdown();
12403
+ };
12404
+ process.once("SIGINT", onSignal);
12405
+ process.once("SIGTERM", onSignal);
12406
+ });
12407
+ }
12408
+ function tryOpenBrowser(url, stderr) {
12409
+ try {
12410
+ const platform = process.platform;
12411
+ let command;
12412
+ let args2;
12413
+ if (platform === "darwin") {
12414
+ command = "open";
12415
+ args2 = [url];
12416
+ } else if (platform === "win32") {
12417
+ command = "cmd";
12418
+ args2 = ["/c", "start", '""', url];
12419
+ } else {
12420
+ command = "xdg-open";
12421
+ args2 = [url];
12422
+ }
12423
+ const child = spawn(command, args2, { detached: true, stdio: "ignore" });
12424
+ child.on("error", (err) => {
12425
+ stderr.write(
12426
+ tx(SERVE_TEXTS.openFailed, {
12427
+ message: sanitizeForTerminal(formatErrorMessage(err)),
12428
+ url: sanitizeForTerminal(url)
12429
+ })
12430
+ );
12431
+ });
12432
+ child.unref();
12433
+ } catch (err) {
12434
+ stderr.write(
12435
+ tx(SERVE_TEXTS.openFailed, {
12436
+ message: sanitizeForTerminal(formatErrorMessage(err)),
12437
+ url: sanitizeForTerminal(url)
12438
+ })
12439
+ );
12440
+ }
12441
+ }
12442
+
11722
12443
  // cli/commands/show.ts
11723
- import { Command as Command18, Option as Option18 } from "clipanion";
12444
+ import { Command as Command20, Option as Option20 } from "clipanion";
11724
12445
 
11725
12446
  // cli/i18n/show.texts.ts
11726
12447
  var SHOW_TEXTS = {
@@ -11758,16 +12479,16 @@ var SHOW_TEXTS = {
11758
12479
  };
11759
12480
 
11760
12481
  // cli/commands/show.ts
11761
- var ShowCommand = class extends Command18 {
12482
+ var ShowCommand = class extends SmCommand {
11762
12483
  static paths = [["show"]];
11763
- static usage = Command18.Usage({
12484
+ static usage = Command20.Usage({
11764
12485
  category: "Browse",
11765
- description: "Node detail: weight, frontmatter, links, issues, findings, summary.",
12486
+ description: "Node detail: weight, frontmatter, links, issues.",
11766
12487
  details: `
11767
12488
  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.
12489
+ (in and out) and every current issue touching it. Step 10
12490
+ (findings) and Step 11 (summary) will add fields when their
12491
+ backing tables ship.
11771
12492
 
11772
12493
  Run \`sm scan\` first to populate the DB.
11773
12494
  `,
@@ -11776,11 +12497,8 @@ var ShowCommand = class extends Command18 {
11776
12497
  ["Machine-readable detail", "$0 show .claude/agents/architect.md --json"]
11777
12498
  ]
11778
12499
  });
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() {
12500
+ nodePath = Option20.String({ required: true });
12501
+ async run() {
11784
12502
  const dbPath = resolveDbPath({ global: this.global, db: this.db, ...defaultRuntimeContext() });
11785
12503
  if (!assertDbExists(dbPath, this.context.stderr)) return ExitCode.NotFound;
11786
12504
  return withSqlite({ databasePath: dbPath, autoBackup: false }, async (adapter) => {
@@ -11793,9 +12511,7 @@ var ShowCommand = class extends Command18 {
11793
12511
  node: bundle.node,
11794
12512
  linksOut: bundle.linksOut,
11795
12513
  linksIn: bundle.linksIn,
11796
- issues: bundle.issues,
11797
- findings: [],
11798
- summary: null
12514
+ issues: bundle.issues
11799
12515
  };
11800
12516
  if (this.json) {
11801
12517
  this.context.stdout.write(JSON.stringify(doc) + "\n");
@@ -11930,7 +12646,7 @@ function rankConfidenceForGrouping(c) {
11930
12646
  }
11931
12647
 
11932
12648
  // cli/commands/stubs.ts
11933
- import { Command as Command19, Option as Option19 } from "clipanion";
12649
+ import { Command as Command21, Option as Option21 } from "clipanion";
11934
12650
 
11935
12651
  // cli/i18n/stubs.texts.ts
11936
12652
  var STUBS_TEXTS = {
@@ -11945,9 +12661,9 @@ function notImplemented(cmd, verb) {
11945
12661
  cmd.context.stderr.write(tx(STUBS_TEXTS.notImplemented, { verb }));
11946
12662
  return ExitCode.Error;
11947
12663
  }
11948
- var DoctorCommand = class extends Command19 {
12664
+ var DoctorCommand = class extends Command21 {
11949
12665
  static paths = [["doctor"]];
11950
- static usage = Command19.Usage({
12666
+ static usage = Command21.Usage({
11951
12667
  category: "Setup",
11952
12668
  description: planned("Diagnostic report: DB integrity, pending migrations, orphan rows, plugin status, runner availability.")
11953
12669
  });
@@ -11955,23 +12671,23 @@ var DoctorCommand = class extends Command19 {
11955
12671
  return notImplemented(this, "doctor");
11956
12672
  }
11957
12673
  };
11958
- var FindingsCommand = class extends Command19 {
12674
+ var FindingsCommand = class extends Command21 {
11959
12675
  static paths = [["findings"]];
11960
- static usage = Command19.Usage({
12676
+ static usage = Command21.Usage({
11961
12677
  category: "Browse",
11962
12678
  description: planned("Probabilistic findings: injection, stale summaries, low confidence.")
11963
12679
  });
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);
12680
+ kind = Option21.String("--kind", { required: false });
12681
+ since = Option21.String("--since", { required: false });
12682
+ threshold = Option21.String("--threshold", { required: false });
12683
+ json = Option21.Boolean("--json", false);
11968
12684
  async execute() {
11969
12685
  return notImplemented(this, "findings");
11970
12686
  }
11971
12687
  };
11972
- var ActionsListCommand = class extends Command19 {
12688
+ var ActionsListCommand = class extends Command21 {
11973
12689
  static paths = [["actions", "list"]];
11974
- static usage = Command19.Usage({
12690
+ static usage = Command21.Usage({
11975
12691
  category: "Jobs",
11976
12692
  description: planned("Registered action types (manifest view).")
11977
12693
  });
@@ -11979,138 +12695,125 @@ var ActionsListCommand = class extends Command19 {
11979
12695
  return notImplemented(this, "actions list");
11980
12696
  }
11981
12697
  };
11982
- var ActionsShowCommand = class extends Command19 {
12698
+ var ActionsShowCommand = class extends Command21 {
11983
12699
  static paths = [["actions", "show"]];
11984
- static usage = Command19.Usage({
12700
+ static usage = Command21.Usage({
11985
12701
  category: "Jobs",
11986
12702
  description: planned("Full action manifest, including preconditions and expected duration.")
11987
12703
  });
11988
- id = Option19.String({ required: true });
12704
+ id = Option21.String({ required: true });
11989
12705
  async execute() {
11990
12706
  return notImplemented(this, "actions show");
11991
12707
  }
11992
12708
  };
11993
- var JobSubmitCommand = class extends Command19 {
12709
+ var JobSubmitCommand = class extends Command21 {
11994
12710
  static paths = [["job", "submit"]];
11995
- static usage = Command19.Usage({
12711
+ static usage = Command21.Usage({
11996
12712
  category: "Jobs",
11997
12713
  description: planned("Enqueue a single job or fan out to every matching node (--all).")
11998
12714
  });
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 });
12715
+ action = Option21.String({ required: true });
12716
+ node = Option21.String("-n", { required: false });
12717
+ all = Option21.Boolean("--all", false);
12718
+ run = Option21.Boolean("--run", false);
12719
+ force = Option21.Boolean("--force", false);
12720
+ ttl = Option21.String("--ttl", { required: false });
12721
+ priority = Option21.String("--priority", { required: false });
12006
12722
  async execute() {
12007
12723
  return notImplemented(this, "job submit");
12008
12724
  }
12009
12725
  };
12010
- var JobListCommand = class extends Command19 {
12726
+ var JobListCommand = class extends Command21 {
12011
12727
  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 });
12728
+ static usage = Command21.Usage({ category: "Jobs", description: planned("List jobs.") });
12729
+ status = Option21.String("--status", { required: false });
12730
+ action = Option21.String("--action", { required: false });
12731
+ node = Option21.String("--node", { required: false });
12016
12732
  async execute() {
12017
12733
  return notImplemented(this, "job list");
12018
12734
  }
12019
12735
  };
12020
- var JobShowCommand = class extends Command19 {
12736
+ var JobShowCommand = class extends Command21 {
12021
12737
  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 });
12738
+ static usage = Command21.Usage({ category: "Jobs", description: planned("Job detail: state, claim time, TTL, runner, content hash.") });
12739
+ id = Option21.String({ required: true });
12024
12740
  async execute() {
12025
12741
  return notImplemented(this, "job show");
12026
12742
  }
12027
12743
  };
12028
- var JobPreviewCommand = class extends Command19 {
12744
+ var JobPreviewCommand = class extends Command21 {
12029
12745
  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 });
12746
+ static usage = Command21.Usage({ category: "Jobs", description: planned("Render the job MD file without executing.") });
12747
+ id = Option21.String({ required: true });
12032
12748
  async execute() {
12033
12749
  return notImplemented(this, "job preview");
12034
12750
  }
12035
12751
  };
12036
- var JobClaimCommand = class extends Command19 {
12752
+ var JobClaimCommand = class extends Command21 {
12037
12753
  static paths = [["job", "claim"]];
12038
- static usage = Command19.Usage({
12754
+ static usage = Command21.Usage({
12039
12755
  category: "Jobs",
12040
12756
  description: planned("Atomic primitive: return next queued job id, mark it running.")
12041
12757
  });
12042
- filter = Option19.String("--filter", { required: false });
12758
+ filter = Option21.String("--filter", { required: false });
12043
12759
  async execute() {
12044
12760
  return notImplemented(this, "job claim");
12045
12761
  }
12046
12762
  };
12047
- var JobRunCommand = class extends Command19 {
12763
+ var JobRunCommand = class extends Command21 {
12048
12764
  static paths = [["job", "run"]];
12049
- static usage = Command19.Usage({
12765
+ static usage = Command21.Usage({
12050
12766
  category: "Jobs",
12051
12767
  description: planned("Full CLI-runner loop: claim + spawn + record.")
12052
12768
  });
12053
- all = Option19.Boolean("--all", false);
12054
- max = Option19.String("--max", { required: false });
12769
+ all = Option21.Boolean("--all", false);
12770
+ max = Option21.String("--max", { required: false });
12055
12771
  async execute() {
12056
12772
  return notImplemented(this, "job run");
12057
12773
  }
12058
12774
  };
12059
- var JobStatusCommand = class extends Command19 {
12775
+ var JobStatusCommand = class extends Command21 {
12060
12776
  static paths = [["job", "status"]];
12061
- static usage = Command19.Usage({
12777
+ static usage = Command21.Usage({
12062
12778
  category: "Jobs",
12063
12779
  description: planned("Counts (per status) or single-job status.")
12064
12780
  });
12065
- id = Option19.String({ required: false });
12781
+ id = Option21.String({ required: false });
12066
12782
  async execute() {
12067
12783
  return notImplemented(this, "job status");
12068
12784
  }
12069
12785
  };
12070
- var JobCancelCommand = class extends Command19 {
12786
+ var JobCancelCommand = class extends Command21 {
12071
12787
  static paths = [["job", "cancel"]];
12072
- static usage = Command19.Usage({
12788
+ static usage = Command21.Usage({
12073
12789
  category: "Jobs",
12074
12790
  description: planned("Force a running job to failed with reason user-cancelled.")
12075
12791
  });
12076
- id = Option19.String({ required: false });
12077
- all = Option19.Boolean("--all", false);
12792
+ id = Option21.String({ required: false });
12793
+ all = Option21.Boolean("--all", false);
12078
12794
  async execute() {
12079
12795
  return notImplemented(this, "job cancel");
12080
12796
  }
12081
12797
  };
12082
- var RecordCommand = class extends Command19 {
12798
+ var RecordCommand = class extends Command21 {
12083
12799
  static paths = [["record"]];
12084
- static usage = Command19.Usage({
12800
+ static usage = Command21.Usage({
12085
12801
  category: "Jobs",
12086
12802
  description: planned("Close a running job with success or failure. Nonce is the sole credential.")
12087
12803
  });
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 });
12804
+ id = Option21.String("--id", { required: true });
12805
+ nonce = Option21.String("--nonce", { required: true });
12806
+ status = Option21.String("--status", { required: true });
12807
+ report = Option21.String("--report", { required: false });
12808
+ tokensIn = Option21.String("--tokens-in", { required: false });
12809
+ tokensOut = Option21.String("--tokens-out", { required: false });
12810
+ durationMs = Option21.String("--duration-ms", { required: false });
12811
+ model = Option21.String("--model", { required: false });
12812
+ error = Option21.String("--error", { required: false });
12097
12813
  async execute() {
12098
12814
  return notImplemented(this, "record");
12099
12815
  }
12100
12816
  };
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
12817
  var STUB_COMMANDS = [
12115
12818
  DoctorCommand,
12116
12819
  FindingsCommand,
@@ -12124,12 +12827,11 @@ var STUB_COMMANDS = [
12124
12827
  JobRunCommand,
12125
12828
  JobStatusCommand,
12126
12829
  JobCancelCommand,
12127
- RecordCommand,
12128
- ServeCommand
12830
+ RecordCommand
12129
12831
  ];
12130
12832
 
12131
12833
  // cli/commands/version.ts
12132
- import { Command as Command20, Option as Option20 } from "clipanion";
12834
+ import { Command as Command22 } from "clipanion";
12133
12835
 
12134
12836
  // cli/i18n/version.texts.ts
12135
12837
  var VERSION_TEXTS = {
@@ -12139,17 +12841,19 @@ var VERSION_TEXTS = {
12139
12841
  };
12140
12842
 
12141
12843
  // cli/commands/version.ts
12142
- var VersionCommand = class extends Command20 {
12844
+ var VersionCommand = class extends SmCommand {
12143
12845
  static paths = [["version"]];
12144
- static usage = Command20.Usage({
12846
+ static usage = Command22.Usage({
12145
12847
  category: "Introspection",
12146
12848
  description: "Print the CLI / kernel / spec / runtime / db-schema version matrix."
12147
12849
  });
12148
- json = Option20.Boolean("--json", false);
12149
- async execute() {
12850
+ // Informational verb — no `done in <…>` line; the version matrix is
12851
+ // the entire output.
12852
+ emitElapsed = false;
12853
+ async run() {
12150
12854
  const runtime = `Node ${process.version}`;
12151
12855
  const kernelVersion = VERSION;
12152
- const specVersion = await resolveSpecVersion2();
12856
+ const specVersion = await resolveSpecVersion3();
12153
12857
  const dbSchema = await resolveDbSchemaVersion();
12154
12858
  if (this.json) {
12155
12859
  const payload = {
@@ -12175,7 +12879,7 @@ var VersionCommand = class extends Command20 {
12175
12879
  return ExitCode.Ok;
12176
12880
  }
12177
12881
  };
12178
- async function resolveSpecVersion2() {
12882
+ async function resolveSpecVersion3() {
12179
12883
  try {
12180
12884
  const mod = await import("@skill-map/spec", { with: { type: "json" } });
12181
12885
  const version = mod.default?.specPackageVersion;
@@ -12211,6 +12915,7 @@ cli.register(HelpCommand);
12211
12915
  cli.register(InitCommand);
12212
12916
  cli.register(ScanCommand);
12213
12917
  cli.register(ScanCompareCommand);
12918
+ cli.register(ServeCommand);
12214
12919
  cli.register(WatchCommand);
12215
12920
  cli.register(VersionCommand);
12216
12921
  cli.register(ListCommand);