@ctxo/cli 0.13.1 → 0.15.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.
@@ -6,24 +6,43 @@ import {
6
6
  CoChangeReader,
7
7
  CommunitySnapshotWriter,
8
8
  ContextsViewReader,
9
- DATA_EDGE_KINDS,
10
9
  DeadCodeDetector,
11
10
  DriftDetector,
12
11
  PageRankCalculator,
13
12
  RevertDetector,
14
13
  SessionRecorderAdapter,
15
- SimpleGitAdapter,
16
14
  SqliteStorageAdapter,
15
+ buildCoChangeMatrix,
16
+ detectWorkspace,
17
+ findCoChangedFilesForBlastRadius
18
+ } from "./chunk-A4URKKPP.js";
19
+ import {
20
+ FAST_ROUTE_COMMANDS,
21
+ isKnownModuleCommand,
22
+ loadModulesFor,
23
+ moduleHelpLines,
24
+ runFastRoute,
25
+ runModuleCommand
26
+ } from "./chunk-BBSJB4CG.js";
27
+ import {
28
+ DOMAIN_MODULE_PACKAGE,
29
+ loadCtxoModules
30
+ } from "./chunk-LAX6VVBU.js";
31
+ import {
32
+ discoverPlugins,
33
+ resolvePackageCandidates,
34
+ resolvePluginPackageJson
35
+ } from "./chunk-2Q76LH2H.js";
36
+ import {
37
+ DATA_EDGE_KINDS,
38
+ SimpleGitAdapter,
17
39
  SymbolGraph,
18
40
  SymbolTokenizer,
19
- buildCoChangeMatrix,
20
41
  communityDetectorOptions,
21
- compareCodepoint,
22
42
  createTestFileClassifier,
23
- detectWorkspace,
24
43
  factsRequested,
25
44
  filesWithTestAnnotations,
26
- findCoChangedFilesForBlastRadius,
45
+ hasCSharpAnnotations,
27
46
  indexIgnorePatterns,
28
47
  indexIgnoreProjectPatterns,
29
48
  indexTestPatterns,
@@ -34,17 +53,24 @@ import {
34
53
  resolveCoChangeConfig,
35
54
  resolveContextsViewConfig,
36
55
  selectWindow,
37
- sinceCutoff,
38
- watchSnapshotMinFileChanges
39
- } from "./chunk-WB7WZKX4.js";
56
+ watchSnapshotMinFileChanges,
57
+ windowCutoff
58
+ } from "./chunk-I7RPXT6L.js";
40
59
  import {
41
- atomicWrite
42
- } from "./chunk-RFEXMBTG.js";
60
+ compareCodepoint,
61
+ getCliPackageRoot,
62
+ getVersion,
63
+ locateGitRoot
64
+ } from "./chunk-72CEQEQD.js";
43
65
  import {
44
66
  COMMUNITY_HISTORY_DIR,
45
67
  INDEX_ARTIFACT_FILES,
46
- JsonIndexReader
47
- } from "./chunk-DT3ZH4AU.js";
68
+ JsonIndexReader,
69
+ isMarkedArtifact
70
+ } from "./chunk-EC6INGPV.js";
71
+ import {
72
+ atomicWrite
73
+ } from "./chunk-RFEXMBTG.js";
48
74
  import {
49
75
  createLogger
50
76
  } from "./chunk-5DSKZJNN.js";
@@ -52,15 +78,11 @@ import {
52
78
  StalenessDetector
53
79
  } from "./chunk-PSKDDLSF.js";
54
80
 
55
- // src/cli/cli-router.ts
56
- import { readFileSync as readFileSync19, existsSync as existsSync30 } from "fs";
57
- import { dirname as dirname10, join as join36 } from "path";
58
-
59
81
  // src/cli/index-command.ts
60
82
  import { execFileSync as execFileSync3 } from "child_process";
61
- import { readFileSync as readFileSync8, writeFileSync as writeFileSync2, existsSync as existsSync12, statSync, readdirSync as readdirSync2 } from "fs";
62
- import { join as join11, relative as relative2, extname as extname3, isAbsolute as isAbsolute2, resolve as resolve4 } from "path";
63
- import { createRequire as createRequire3 } from "module";
83
+ import { readFileSync as readFileSync7, writeFileSync as writeFileSync2, existsSync as existsSync11, statSync, readdirSync as readdirSync2 } from "fs";
84
+ import { join as join11, relative as relative2, extname as extname3, isAbsolute, resolve as resolve3 } from "path";
85
+ import { createRequire as createRequire2 } from "module";
64
86
 
65
87
  // src/core/staleness/content-hasher.ts
66
88
  import { createHash } from "crypto";
@@ -91,140 +113,12 @@ var LanguageAdapterRegistry = class {
91
113
  };
92
114
 
93
115
  // src/cli/plugin-loader.ts
94
- import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
95
- import { dirname as dirname2, join } from "path";
96
-
97
- // src/adapters/language/plugin-discovery.ts
98
116
  import { existsSync, readFileSync } from "fs";
99
- import { createRequire } from "module";
100
- import { dirname, isAbsolute, resolve } from "path";
101
- import { pathToFileURL } from "url";
102
- import { SUPPORTED_API_VERSIONS } from "@ctxo/plugin-api";
103
- var log = createLogger("ctxo:plugin-discovery");
104
- var OFFICIAL_PREFIX = "@ctxo/lang-";
105
- var COMMUNITY_PREFIX = "ctxo-lang-";
106
- function readManifestDeps(manifestPath) {
107
- try {
108
- const raw = readFileSync(manifestPath, "utf-8");
109
- const manifest = JSON.parse(raw);
110
- const all = {
111
- ...manifest.dependencies,
112
- ...manifest.devDependencies,
113
- ...manifest.peerDependencies
114
- };
115
- return Object.keys(all).filter(
116
- (name) => name.startsWith(OFFICIAL_PREFIX) || name.startsWith(COMMUNITY_PREFIX)
117
- );
118
- } catch (err) {
119
- log.warn(`Failed to read manifest ${manifestPath}: ${err.message}`);
120
- return [];
121
- }
122
- }
123
- function isLocalSpecifier(spec) {
124
- return spec.startsWith(".") || spec.startsWith("/") || isAbsolute(spec);
125
- }
126
- function pickEsmEntry(exportsField) {
127
- if (!exportsField) return void 0;
128
- if (typeof exportsField === "string") return exportsField;
129
- if (typeof exportsField !== "object") return void 0;
130
- const table = exportsField;
131
- const dot = "." in table ? table["."] : exportsField;
132
- if (typeof dot === "string") return dot;
133
- if (!dot || typeof dot !== "object") return void 0;
134
- for (const key of ["import", "node", "default"]) {
135
- const nested = dot[key];
136
- if (typeof nested === "string") return nested;
137
- if (nested && typeof nested === "object") {
138
- for (const inner of ["default", "import", "node"]) {
139
- const v = nested[inner];
140
- if (typeof v === "string") return v;
141
- }
142
- }
143
- }
144
- return void 0;
145
- }
146
- var cliFallbackRequire = createRequire(import.meta.url);
147
- function resolvePluginPackageJson(specifier, projectRequire) {
148
- try {
149
- return projectRequire.resolve(`${specifier}/package.json`);
150
- } catch (primary) {
151
- try {
152
- return cliFallbackRequire.resolve(`${specifier}/package.json`);
153
- } catch {
154
- throw primary;
155
- }
156
- }
157
- }
158
- function resolvePluginEntryUrl(specifier, manifestRequire) {
159
- const pkgJsonPath = resolvePluginPackageJson(specifier, manifestRequire);
160
- const pkgDir = dirname(pkgJsonPath);
161
- const pkgJson = JSON.parse(readFileSync(pkgJsonPath, "utf-8"));
162
- const entry = pickEsmEntry(pkgJson.exports) ?? pkgJson.module ?? pkgJson.main ?? "index.js";
163
- return pathToFileURL(resolve(pkgDir, entry)).href;
164
- }
165
- async function tryLoad(specifier, baseDir, manifestRequire) {
166
- try {
167
- const target = isLocalSpecifier(specifier) ? pathToFileURL(resolve(baseDir, specifier)).href : resolvePluginEntryUrl(specifier, manifestRequire);
168
- const mod = await import(target);
169
- const candidate = mod.default ?? mod.plugin ?? mod;
170
- if (!candidate || typeof candidate !== "object") {
171
- return { specifier, reason: "module export is not an object" };
172
- }
173
- if (!SUPPORTED_API_VERSIONS.includes(candidate.apiVersion)) {
174
- return {
175
- specifier,
176
- reason: `unsupported apiVersion ${String(candidate.apiVersion)} (supported: ${SUPPORTED_API_VERSIONS.join(", ")})`
177
- };
178
- }
179
- if (typeof candidate.createAdapter !== "function") {
180
- return { specifier, reason: "missing createAdapter()" };
181
- }
182
- if (typeof candidate.id !== "string" || candidate.id.length === 0) {
183
- return { specifier, reason: "missing or empty id" };
184
- }
185
- if (!Array.isArray(candidate.extensions)) {
186
- return { specifier, reason: "extensions must be an array" };
187
- }
188
- if (candidate.tier !== "syntax" && candidate.tier !== "full") {
189
- return { specifier, reason: `invalid tier ${String(candidate.tier)}` };
190
- }
191
- return candidate;
192
- } catch (err) {
193
- return { specifier, reason: err.message };
194
- }
195
- }
196
- async function discoverPlugins(options) {
197
- const { manifestPath } = options;
198
- if (!existsSync(manifestPath)) {
199
- log.info(`Manifest not found at ${manifestPath} \u2014 no plugins discovered`);
200
- return { plugins: [], failures: [] };
201
- }
202
- const baseDir = dirname(manifestPath);
203
- const specifiers = options.explicit ?? readManifestDeps(manifestPath);
204
- const manifestRequire = createRequire(pathToFileURL(manifestPath).href);
205
- const plugins = [];
206
- const failures = [];
207
- for (const spec of specifiers) {
208
- if (options.shouldSkipSpecifier?.(spec)) {
209
- log.info(`Skipping plugin ${spec} (matched ignoreProjects)`);
210
- continue;
211
- }
212
- const result = await tryLoad(spec, baseDir, manifestRequire);
213
- if ("apiVersion" in result) {
214
- plugins.push({ plugin: result, specifier: spec });
215
- } else {
216
- failures.push(result);
217
- log.warn(`Skipped plugin ${spec}: ${result.reason}`);
218
- }
219
- }
220
- return { plugins, failures };
221
- }
222
-
223
- // src/cli/plugin-loader.ts
224
- var log2 = createLogger("ctxo:plugin-loader");
117
+ import { dirname, join } from "path";
118
+ var log = createLogger("ctxo:plugin-loader");
225
119
  function manifestHasPlugins(path) {
226
120
  try {
227
- const raw = JSON.parse(readFileSync2(path, "utf-8"));
121
+ const raw = JSON.parse(readFileSync(path, "utf-8"));
228
122
  const all = { ...raw.dependencies, ...raw.devDependencies, ...raw.peerDependencies };
229
123
  return Object.keys(all).some((n) => n.startsWith("@ctxo/lang-") || n.startsWith("ctxo-lang-"));
230
124
  } catch {
@@ -236,18 +130,18 @@ function loadManifestPath(projectRoot) {
236
130
  }
237
131
  function resolveManifestPath(projectRoot) {
238
132
  const projectManifest = join(projectRoot, "package.json");
239
- if (existsSync2(projectManifest) && manifestHasPlugins(projectManifest)) {
133
+ if (existsSync(projectManifest) && manifestHasPlugins(projectManifest)) {
240
134
  return projectManifest;
241
135
  }
242
136
  let dir = import.meta.dirname;
243
137
  for (let i = 0; i < 12; i++) {
244
138
  const candidate = join(dir, "package.json");
245
- if (existsSync2(candidate) && manifestHasPlugins(candidate)) return candidate;
246
- const parent = dirname2(dir);
139
+ if (existsSync(candidate) && manifestHasPlugins(candidate)) return candidate;
140
+ const parent = dirname(dir);
247
141
  if (parent === dir) break;
248
142
  dir = parent;
249
143
  }
250
- return existsSync2(projectManifest) ? projectManifest : null;
144
+ return existsSync(projectManifest) ? projectManifest : null;
251
145
  }
252
146
  function buildContext(projectRoot, workspace, pluginId, capabilities) {
253
147
  const pluginLog = createLogger(`ctxo:plugin:${pluginId}`);
@@ -273,7 +167,7 @@ function hostCapabilities(plugins, options) {
273
167
  async function loadPlugins(projectRoot, options = {}) {
274
168
  const manifestPath = resolveManifestPath(projectRoot);
275
169
  if (!manifestPath) {
276
- log2.info("No package.json found for plugin discovery");
170
+ log.info("No package.json found for plugin discovery");
277
171
  return [];
278
172
  }
279
173
  const workspace = detectWorkspace(projectRoot);
@@ -291,20 +185,20 @@ async function loadPlugins(projectRoot, options = {}) {
291
185
  const adapter = plugin.createAdapter(ctx);
292
186
  loaded.push({ plugin, adapter, specifier });
293
187
  } catch (err) {
294
- log2.warn(`Plugin ${plugin.id} createAdapter failed: ${err.message}`);
188
+ log.warn(`Plugin ${plugin.id} createAdapter failed: ${err.message}`);
295
189
  }
296
190
  }
297
191
  return loaded;
298
192
  }
299
193
 
300
194
  // src/cli/install-command.ts
301
- import { existsSync as existsSync6 } from "fs";
302
- import { createRequire as createRequire2 } from "module";
195
+ import { existsSync as existsSync5 } from "fs";
196
+ import { createRequire } from "module";
303
197
  import { join as join5 } from "path";
304
198
 
305
199
  // src/core/detection/detect-languages.ts
306
200
  import { execFileSync } from "child_process";
307
- import { existsSync as existsSync3, readdirSync } from "fs";
201
+ import { existsSync as existsSync2, readdirSync } from "fs";
308
202
  import { extname as extname2, join as join2 } from "path";
309
203
  var KNOWN_LANGUAGES = [
310
204
  "typescript",
@@ -337,29 +231,29 @@ var EXTENSION_LANGUAGE = {
337
231
  var MANIFEST_PROBES = [
338
232
  {
339
233
  id: "typescript",
340
- check: (root) => existsSync3(join2(root, "tsconfig.json")) ? "tsconfig.json" : null
234
+ check: (root) => existsSync2(join2(root, "tsconfig.json")) ? "tsconfig.json" : null
341
235
  },
342
236
  {
343
237
  id: "python",
344
238
  check: (root) => {
345
- if (existsSync3(join2(root, "pyproject.toml"))) return "pyproject.toml";
346
- if (existsSync3(join2(root, "setup.py"))) return "setup.py";
347
- if (existsSync3(join2(root, "requirements.txt"))) return "requirements.txt";
239
+ if (existsSync2(join2(root, "pyproject.toml"))) return "pyproject.toml";
240
+ if (existsSync2(join2(root, "setup.py"))) return "setup.py";
241
+ if (existsSync2(join2(root, "requirements.txt"))) return "requirements.txt";
348
242
  return null;
349
243
  }
350
244
  },
351
245
  {
352
246
  id: "java",
353
247
  check: (root) => {
354
- if (existsSync3(join2(root, "pom.xml"))) return "pom.xml";
355
- if (existsSync3(join2(root, "build.gradle"))) return "build.gradle";
356
- if (existsSync3(join2(root, "build.gradle.kts"))) return "build.gradle.kts";
248
+ if (existsSync2(join2(root, "pom.xml"))) return "pom.xml";
249
+ if (existsSync2(join2(root, "build.gradle"))) return "build.gradle";
250
+ if (existsSync2(join2(root, "build.gradle.kts"))) return "build.gradle.kts";
357
251
  return null;
358
252
  }
359
253
  },
360
254
  {
361
255
  id: "go",
362
- check: (root) => existsSync3(join2(root, "go.mod")) ? "go.mod" : null
256
+ check: (root) => existsSync2(join2(root, "go.mod")) ? "go.mod" : null
363
257
  },
364
258
  {
365
259
  id: "csharp",
@@ -377,11 +271,11 @@ var MANIFEST_PROBES = [
377
271
  },
378
272
  {
379
273
  id: "rust",
380
- check: (root) => existsSync3(join2(root, "Cargo.toml")) ? "Cargo.toml" : null
274
+ check: (root) => existsSync2(join2(root, "Cargo.toml")) ? "Cargo.toml" : null
381
275
  },
382
276
  {
383
277
  id: "ruby",
384
- check: (root) => existsSync3(join2(root, "Gemfile")) ? "Gemfile" : null
278
+ check: (root) => existsSync2(join2(root, "Gemfile")) ? "Gemfile" : null
385
279
  }
386
280
  ];
387
281
  function listGitFiles(root) {
@@ -441,8 +335,8 @@ function officialPluginFor(language) {
441
335
  }
442
336
 
443
337
  // src/core/install/package-manager.ts
444
- import { existsSync as existsSync4, readFileSync as readFileSync3 } from "fs";
445
- import { dirname as dirname3, join as join3 } from "path";
338
+ import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
339
+ import { dirname as dirname2, join as join3 } from "path";
446
340
  var PACKAGE_MANAGERS = ["npm", "pnpm", "yarn", "bun"];
447
341
  function isPackageManager(value) {
448
342
  return PACKAGE_MANAGERS.includes(value);
@@ -478,9 +372,9 @@ function readPackageManagerField(startDir) {
478
372
  let dir = startDir;
479
373
  for (let i = 0; i < 12; i++) {
480
374
  const pkg = join3(dir, "package.json");
481
- if (existsSync4(pkg)) {
375
+ if (existsSync3(pkg)) {
482
376
  try {
483
- const parsed = JSON.parse(readFileSync3(pkg, "utf-8"));
377
+ const parsed = JSON.parse(readFileSync2(pkg, "utf-8"));
484
378
  const value = parsed.packageManager;
485
379
  if (typeof value === "string") {
486
380
  const [nameRaw] = value.split("@");
@@ -492,7 +386,7 @@ function readPackageManagerField(startDir) {
492
386
  } catch {
493
387
  }
494
388
  }
495
- const parent = dirname3(dir);
389
+ const parent = dirname2(dir);
496
390
  if (parent === dir) break;
497
391
  dir = parent;
498
392
  }
@@ -500,9 +394,9 @@ function readPackageManagerField(startDir) {
500
394
  }
501
395
  function readConfigPackageManager(projectRoot) {
502
396
  const path = join3(projectRoot, ".ctxo", "config.yaml");
503
- if (!existsSync4(path)) return null;
397
+ if (!existsSync3(path)) return null;
504
398
  try {
505
- const raw = readFileSync3(path, "utf-8");
399
+ const raw = readFileSync2(path, "utf-8");
506
400
  const match = raw.match(/^packageManager:\s*(\S+)\s*$/m);
507
401
  if (!match) return null;
508
402
  const value = match[1];
@@ -523,11 +417,11 @@ function detectFromLockfileWalkUp(startDir) {
523
417
  for (let i = 0; i < 12; i++) {
524
418
  for (const probe of probes) {
525
419
  const path = join3(dir, probe.file);
526
- if (existsSync4(path)) {
420
+ if (existsSync3(path)) {
527
421
  return { manager: probe.manager, detail: probe.file };
528
422
  }
529
423
  }
530
- const parent = dirname3(dir);
424
+ const parent = dirname2(dir);
531
425
  if (parent === dir) break;
532
426
  dir = parent;
533
427
  }
@@ -536,9 +430,9 @@ function detectFromLockfileWalkUp(startDir) {
536
430
  function readWorkspaceLinks(projectRoot) {
537
431
  const links = /* @__PURE__ */ new Set();
538
432
  const pkgPath = join3(projectRoot, "package.json");
539
- if (!existsSync4(pkgPath)) return links;
433
+ if (!existsSync3(pkgPath)) return links;
540
434
  try {
541
- const pkg = JSON.parse(readFileSync3(pkgPath, "utf-8"));
435
+ const pkg = JSON.parse(readFileSync2(pkgPath, "utf-8"));
542
436
  for (const group of [pkg.dependencies, pkg.devDependencies, pkg.peerDependencies, pkg.optionalDependencies]) {
543
437
  if (!group) continue;
544
438
  for (const [name, spec] of Object.entries(group)) {
@@ -553,11 +447,11 @@ function readWorkspaceLinks(projectRoot) {
553
447
  }
554
448
  }
555
449
  function isWorkspaceRoot(projectRoot) {
556
- if (existsSync4(join3(projectRoot, "pnpm-workspace.yaml"))) return true;
450
+ if (existsSync3(join3(projectRoot, "pnpm-workspace.yaml"))) return true;
557
451
  const pkgPath = join3(projectRoot, "package.json");
558
- if (!existsSync4(pkgPath)) return false;
452
+ if (!existsSync3(pkgPath)) return false;
559
453
  try {
560
- const pkg = JSON.parse(readFileSync3(pkgPath, "utf-8"));
454
+ const pkg = JSON.parse(readFileSync2(pkgPath, "utf-8"));
561
455
  const ws = pkg.workspaces;
562
456
  if (Array.isArray(ws) && ws.length > 0) return true;
563
457
  if (ws && typeof ws === "object" && Array.isArray(ws.packages)) return true;
@@ -600,16 +494,16 @@ function buildInstallCommand(manager, packages, options = {}) {
600
494
  // src/core/install/run-package-manager.ts
601
495
  import { spawn } from "child_process";
602
496
  function runPackageManager(command, args, cwd) {
603
- return new Promise((resolve6, reject) => {
497
+ return new Promise((resolve5, reject) => {
604
498
  const child = process.platform === "win32" ? spawn([command, ...args].join(" "), { cwd, stdio: "inherit", shell: true }) : spawn(command, [...args], { cwd, stdio: "inherit", shell: false });
605
- child.on("exit", (code) => resolve6(code ?? 0));
499
+ child.on("exit", (code) => resolve5(code ?? 0));
606
500
  child.on("error", (err) => reject(err));
607
501
  });
608
502
  }
609
503
 
610
504
  // src/core/detection/detect-java-runtime.ts
611
505
  import { spawnSync } from "child_process";
612
- import { existsSync as existsSync5 } from "fs";
506
+ import { existsSync as existsSync4 } from "fs";
613
507
  import { join as join4 } from "path";
614
508
  function parseJavaMajor(output) {
615
509
  const m = output.match(/version "([^"]+)"/);
@@ -625,7 +519,7 @@ function detectJavaMajor() {
625
519
  }
626
520
  const home = process.env["CTXO_JAVA_HOME"] || process.env["JAVA_HOME"];
627
521
  const bin = home ? join4(home, "bin", process.platform === "win32" ? "java.exe" : "java") : "java";
628
- if (home && !existsSync5(bin)) {
522
+ if (home && !existsSync4(bin)) {
629
523
  _javaMajorCache = null;
630
524
  return void 0;
631
525
  }
@@ -647,6 +541,23 @@ function javaRuntimeAvailable() {
647
541
  }
648
542
 
649
543
  // src/cli/install-command.ts
544
+ var INSTALL_EXTRAS = { domain: DOMAIN_MODULE_PACKAGE };
545
+ function resolveInstallTargets(raw) {
546
+ const languages = [];
547
+ const extras = [];
548
+ const unknown = [];
549
+ for (const name of raw) {
550
+ const extra = Object.hasOwn(INSTALL_EXTRAS, name.toLowerCase()) ? INSTALL_EXTRAS[name.toLowerCase()] : void 0;
551
+ if (extra !== void 0) {
552
+ if (!extras.includes(extra)) extras.push(extra);
553
+ continue;
554
+ }
555
+ const lang = normalizeLanguage(name);
556
+ if (lang === null) unknown.push(name);
557
+ else if (!languages.includes(lang)) languages.push(lang);
558
+ }
559
+ return { languages, extras, unknown };
560
+ }
650
561
  function resolveJavaPackages(opts) {
651
562
  if (opts.fullTier && opts.syntaxOnly) throw new Error("Cannot combine --full-tier with --syntax-only.");
652
563
  const base = ["@ctxo/lang-java"];
@@ -657,8 +568,8 @@ function resolveJavaPackages(opts) {
657
568
  function buildPluginSpecifiers(languages, opts) {
658
569
  const out = [];
659
570
  const seen = /* @__PURE__ */ new Set();
660
- const add = (pkg) => {
661
- const spec = opts.version ? `${pkg}@${opts.version}` : pkg;
571
+ const add = (pkg, version = opts.version) => {
572
+ const spec = version ? `${pkg}@${version}` : pkg;
662
573
  if (seen.has(spec)) return;
663
574
  seen.add(spec);
664
575
  out.push(spec);
@@ -670,6 +581,7 @@ function buildPluginSpecifiers(languages, opts) {
670
581
  add(officialPluginFor(lang));
671
582
  }
672
583
  }
584
+ for (const pkg of opts.extras ?? []) add(pkg, opts.version ?? opts.cliVersion);
673
585
  return out;
674
586
  }
675
587
  var InstallCommand = class {
@@ -678,8 +590,9 @@ var InstallCommand = class {
678
590
  }
679
591
  projectRoot;
680
592
  async run(options = {}) {
681
- const resolvedLangs = this.resolveLanguages(options);
682
- if (resolvedLangs.length === 0) {
593
+ const { languages: resolvedLangs, extras } = this.resolveLanguages(options);
594
+ if (resolvedLangs.length === 0 && extras.length === 0) {
595
+ if (process.exitCode === 1) return;
683
596
  console.error('[ctxo] Nothing to install. Specify a language (e.g. "ctxo install python") or add one to your project.');
684
597
  process.exitCode = 0;
685
598
  return;
@@ -695,10 +608,10 @@ var InstallCommand = class {
695
608
  process.exitCode = 1;
696
609
  return;
697
610
  }
698
- if (!options.global && !existsSync6(join5(this.projectRoot, "package.json"))) {
699
- console.error("[ctxo] No package.json in the current project. Re-run with --global or create a package.json first.");
700
- process.exitCode = 1;
701
- return;
611
+ const noManifest = !options.global && !existsSync5(join5(this.projectRoot, "package.json"));
612
+ const installGlobal = (options.global ?? false) || noManifest;
613
+ if (noManifest) {
614
+ console.error("[ctxo] No package.json here: installing globally, so the MCP server, hooks and CLI share one copy.");
702
615
  }
703
616
  if (!options.force && !options.global && isLockedCI(process.env)) {
704
617
  console.error("[ctxo] CI environment detected with a frozen lockfile. Refusing to mutate dependencies. Use --force or switch to --global.");
@@ -715,7 +628,9 @@ var InstallCommand = class {
715
628
  jreAvailable,
716
629
  fullTier: options.fullTier,
717
630
  syntaxOnly: options.syntaxOnly,
718
- version: options.version
631
+ version: options.version,
632
+ extras,
633
+ cliVersion: getVersion()
719
634
  });
720
635
  } catch (err) {
721
636
  console.error(`[ctxo] ${err.message}`);
@@ -723,15 +638,15 @@ var InstallCommand = class {
723
638
  return;
724
639
  }
725
640
  const invocation = buildInstallCommand(resolution.manager, specifiers, {
726
- global: options.global,
727
- workspaceRoot: !options.global && isWorkspaceRoot(this.projectRoot)
641
+ global: installGlobal,
642
+ workspaceRoot: !installGlobal && isWorkspaceRoot(this.projectRoot)
728
643
  });
729
644
  const plan = {
730
645
  languages: resolvedLangs,
731
646
  packages: specifiers,
732
647
  manager: resolution.manager,
733
648
  managerSource: resolution.source,
734
- global: options.global ?? false,
649
+ global: installGlobal,
735
650
  command: invocation.command,
736
651
  args: [...invocation.args]
737
652
  };
@@ -753,30 +668,26 @@ var InstallCommand = class {
753
668
  resolveLanguages(options) {
754
669
  const requested = options.languages ?? [];
755
670
  if (requested.length > 0) {
756
- const out = [];
757
- for (const raw of requested) {
758
- const lang = normalizeLanguage(raw);
759
- if (!lang) {
760
- console.error(
761
- `[ctxo] Unknown language "${raw}". Known: ${KNOWN_LANGUAGES.join(", ")}. Community plugins: see the ctxo-lang-template scaffolding guide.`
762
- );
763
- process.exitCode = 1;
764
- return [];
765
- }
766
- if (!out.includes(lang)) out.push(lang);
671
+ const { languages, extras, unknown } = resolveInstallTargets(requested);
672
+ if (unknown.length > 0) {
673
+ console.error(
674
+ `[ctxo] Unknown language "${unknown[0]}". Known: ${[...KNOWN_LANGUAGES, ...Object.keys(INSTALL_EXTRAS)].join(", ")}. Community plugins: see the ctxo-lang-template scaffolding guide.`
675
+ );
676
+ process.exitCode = 1;
677
+ return { languages: [], extras: [] };
767
678
  }
768
- return out;
679
+ return { languages, extras };
769
680
  }
770
681
  const detection = detectLanguages(this.projectRoot);
771
682
  const needed = decideNeededLanguages(detection);
772
- if (needed.length === 0) return [];
683
+ if (needed.length === 0) return { languages: [], extras: [] };
773
684
  const missing = needed.filter((lang) => !isPluginInstalled(lang));
774
685
  if (missing.length === 0) {
775
686
  console.error("[ctxo] All detected languages already have plugins installed.");
776
- return [];
687
+ return { languages: [], extras: [] };
777
688
  }
778
689
  console.error(`[ctxo] Detected missing plugins for: ${missing.join(", ")}`);
779
- return missing;
690
+ return { languages: missing, extras: [] };
780
691
  }
781
692
  };
782
693
  function normalizeLanguage(input) {
@@ -799,7 +710,7 @@ function normalizeLanguage(input) {
799
710
  };
800
711
  return aliases[lowered] ?? null;
801
712
  }
802
- var installRequire = createRequire2(import.meta.url);
713
+ var installRequire = createRequire(import.meta.url);
803
714
  function isPluginInstalled(lang) {
804
715
  try {
805
716
  installRequire.resolve(`${officialPluginFor(lang)}/package.json`);
@@ -814,9 +725,9 @@ function isLockedCI(env) {
814
725
  }
815
726
 
816
727
  // src/cli/read-source.ts
817
- import { readFileSync as readFileSync4 } from "fs";
728
+ import { readFileSync as readFileSync3 } from "fs";
818
729
  function readSourceText(absolutePath) {
819
- const bytes = readFileSync4(absolutePath);
730
+ const bytes = readFileSync3(absolutePath);
820
731
  if (bytes.length >= 2) {
821
732
  const first = bytes[0];
822
733
  const second = bytes[1];
@@ -835,7 +746,7 @@ function decodeUtf16(bytes, bigEndian) {
835
746
 
836
747
  // src/cli/index-cochange.ts
837
748
  import { execFileSync as execFileSync2 } from "child_process";
838
- import { existsSync as existsSync7 } from "fs";
749
+ import { existsSync as existsSync6 } from "fs";
839
750
  import { join as join6 } from "path";
840
751
  function changeLogSize(windowCommits, maxHistory) {
841
752
  return windowCommits === "all" ? "all" : Math.max(windowCommits, maxHistory * 50);
@@ -844,9 +755,7 @@ function writeCoChangeMatrix(options) {
844
755
  const { writer, projectRoot, commits, head, headCommittedAt, coChange, indexedFiles } = options;
845
756
  const isIncluded = coChangeFileFilter(projectRoot, indexedFiles, coChange);
846
757
  if (isIncluded === void 0) return;
847
- const since = coChange.since !== void 0 ? parseCoChangeSince(coChange.since) : void 0;
848
- const cutoff = since !== void 0 ? sinceCutoff(headCommittedAt, since) : void 0;
849
- const windowed = selectWindow(commits, coChange.maxCommits, cutoff);
758
+ const windowed = selectWindow(commits, coChange.maxCommits, windowCutoff(coChange, headCommittedAt));
850
759
  const matrix = buildCoChangeMatrix(windowed, {
851
760
  isIncluded,
852
761
  maxCommits: coChange.maxCommits,
@@ -876,7 +785,7 @@ function coChangeFileFilter(projectRoot, indexedFiles, coChange) {
876
785
  const isExcluded = makeGlobMatcher([...coChange.exclude]);
877
786
  const present = /* @__PURE__ */ new Set();
878
787
  for (const path of candidates) {
879
- if (!isExcluded(path) && existsSync7(join6(projectRoot, path))) present.add(path);
788
+ if (!isExcluded(path) && existsSync6(join6(projectRoot, path))) present.add(path);
880
789
  }
881
790
  return (path) => present.has(path);
882
791
  }
@@ -903,14 +812,14 @@ function describeCoChangeWindow(coChange, commitsScanned) {
903
812
  }
904
813
 
905
814
  // src/cli/index-contexts-view.ts
906
- import { existsSync as existsSync9 } from "fs";
815
+ import { existsSync as existsSync8 } from "fs";
907
816
  import { join as join8 } from "path";
908
817
 
909
818
  // src/adapters/storage/architecture-artifacts-writer.ts
910
- import { existsSync as existsSync8, mkdirSync, readFileSync as readFileSync5 } from "fs";
911
- import { join as join7, resolve as resolve2 } from "path";
819
+ import { existsSync as existsSync7, mkdirSync, readFileSync as readFileSync4 } from "fs";
820
+ import { join as join7, resolve } from "path";
912
821
  import { tmpdir } from "os";
913
- var log3 = createLogger("ctxo:architecture-artifacts");
822
+ var log2 = createLogger("ctxo:architecture-artifacts");
914
823
  var DRIFT_FILE_NAME = "drift-events.json";
915
824
  var VIOLATIONS_FILE_NAME = "boundary-violations.json";
916
825
  var CONTEXTS_VIEW_FILE_NAME = "contexts-view.json";
@@ -937,7 +846,7 @@ var ArchitectureArtifactsWriter = class {
937
846
  const path = join7(this.indexDir, CONTEXTS_VIEW_FILE_NAME);
938
847
  const content = JSON.stringify(view, null, 2);
939
848
  try {
940
- if (readFileSync5(path, "utf-8") === content) return false;
849
+ if (readFileSync4(path, "utf-8") === content) return false;
941
850
  } catch {
942
851
  }
943
852
  mkdirSync(this.indexDir, { recursive: true });
@@ -946,28 +855,28 @@ var ArchitectureArtifactsWriter = class {
946
855
  }
947
856
  readDriftEvents() {
948
857
  const path = join7(this.indexDir, DRIFT_FILE_NAME);
949
- if (!existsSync8(path)) return void 0;
858
+ if (!existsSync7(path)) return void 0;
950
859
  try {
951
- return JSON.parse(readFileSync5(path, "utf-8"));
860
+ return JSON.parse(readFileSync4(path, "utf-8"));
952
861
  } catch (err) {
953
- log3.warn(`failed to parse ${DRIFT_FILE_NAME}: ${err.message}`);
862
+ log2.warn(`failed to parse ${DRIFT_FILE_NAME}: ${err.message}`);
954
863
  return void 0;
955
864
  }
956
865
  }
957
866
  readBoundaryViolations() {
958
867
  const path = join7(this.indexDir, VIOLATIONS_FILE_NAME);
959
- if (!existsSync8(path)) return void 0;
868
+ if (!existsSync7(path)) return void 0;
960
869
  try {
961
- return JSON.parse(readFileSync5(path, "utf-8"));
870
+ return JSON.parse(readFileSync4(path, "utf-8"));
962
871
  } catch (err) {
963
- log3.warn(`failed to parse ${VIOLATIONS_FILE_NAME}: ${err.message}`);
872
+ log2.warn(`failed to parse ${VIOLATIONS_FILE_NAME}: ${err.message}`);
964
873
  return void 0;
965
874
  }
966
875
  }
967
876
  };
968
877
  function assertSafeWritePath(ctxoRoot, allowProductionPath) {
969
- const resolvedRoot = resolve2(ctxoRoot);
970
- const resolvedTmp = resolve2(tmpdir());
878
+ const resolvedRoot = resolve(ctxoRoot);
879
+ const resolvedTmp = resolve(tmpdir());
971
880
  if (resolvedRoot.startsWith(resolvedTmp)) return;
972
881
  if (allowProductionPath) return;
973
882
  throw new Error(
@@ -1851,14 +1760,9 @@ function fileOfSymbol(symbolId) {
1851
1760
  const cut = symbolId.indexOf("::");
1852
1761
  return cut === -1 ? symbolId : symbolId.slice(0, cut);
1853
1762
  }
1854
- function hasCSharpAnnotations(indices) {
1855
- return indices.some(
1856
- (index) => index.file.endsWith(".cs") && index.symbols.some((symbol) => symbol.annotations && symbol.annotations.length > 0)
1857
- );
1858
- }
1859
1763
  function computeAndWriteContextsView(options) {
1860
1764
  const { projectRoot, ctxoRoot, config, indices, snapshot, symbolPageRank } = options;
1861
- const exists = options.exists ?? existsSync9;
1765
+ const exists = options.exists ?? existsSync8;
1862
1766
  const settings = resolveContextsViewConfig(config);
1863
1767
  const isTest = createTestFileClassifier(indexTestPatterns(config), filesWithTestAnnotations(indices), {
1864
1768
  annotationAware: hasCSharpAnnotations(indices)
@@ -1913,8 +1817,8 @@ async function extractFileFacts(adapter, relativePath, source) {
1913
1817
  }
1914
1818
 
1915
1819
  // src/adapters/storage/json-index-writer.ts
1916
- import { mkdirSync as mkdirSync2, unlinkSync, existsSync as existsSync10, readFileSync as readFileSync6 } from "fs";
1917
- import { dirname as dirname4, join as join9, relative, resolve as resolve3, sep } from "path";
1820
+ import { mkdirSync as mkdirSync2, unlinkSync, existsSync as existsSync9, readFileSync as readFileSync5 } from "fs";
1821
+ import { dirname as dirname3, join as join9, relative, resolve as resolve2, sep } from "path";
1918
1822
  import { isDeepStrictEqual } from "util";
1919
1823
  var JsonIndexWriter = class {
1920
1824
  indexDir;
@@ -1926,7 +1830,7 @@ var JsonIndexWriter = class {
1926
1830
  throw new Error("FileIndex.file must not be empty");
1927
1831
  }
1928
1832
  const targetPath = this.resolveIndexPath(fileIndex.file);
1929
- mkdirSync2(dirname4(targetPath), { recursive: true });
1833
+ mkdirSync2(dirname3(targetPath), { recursive: true });
1930
1834
  const sorted = this.sortKeys(fileIndex);
1931
1835
  const json = JSON.stringify(sorted, null, 2);
1932
1836
  this.atomicWrite(targetPath, json);
@@ -1945,9 +1849,9 @@ var JsonIndexWriter = class {
1945
1849
  }
1946
1850
  /** True when the file at `targetPath` parses and has the same `window` and `entries` as `matrix`. */
1947
1851
  holdsSameCoChanges(targetPath, matrix) {
1948
- if (!existsSync10(targetPath)) return false;
1852
+ if (!existsSync9(targetPath)) return false;
1949
1853
  try {
1950
- const existing = JSON.parse(readFileSync6(targetPath, "utf-8"));
1854
+ const existing = JSON.parse(readFileSync5(targetPath, "utf-8"));
1951
1855
  if (existing === null || typeof existing !== "object") return false;
1952
1856
  const { window, entries } = existing;
1953
1857
  return isDeepStrictEqual(window, matrix.window) && isDeepStrictEqual(entries, matrix.entries);
@@ -1960,7 +1864,7 @@ var JsonIndexWriter = class {
1960
1864
  }
1961
1865
  delete(relativePath) {
1962
1866
  const targetPath = this.resolveIndexPath(relativePath);
1963
- if (existsSync10(targetPath)) {
1867
+ if (existsSync9(targetPath)) {
1964
1868
  unlinkSync(targetPath);
1965
1869
  }
1966
1870
  }
@@ -1970,11 +1874,12 @@ var JsonIndexWriter = class {
1970
1874
  * an artifact's included. Returns true when a file was deleted and false when there was none.
1971
1875
  *
1972
1876
  * Throws for a path outside the index folder, and for the artifacts that live there but are not entries
1973
- * (the co-change matrix, the community snapshot and its history, the architecture artifacts).
1877
+ * (the co-change matrix, the community snapshot and its history, the architecture artifacts, and any JSON
1878
+ * carrying the `ctxoArtifact` marker of a module artifact).
1974
1879
  */
1975
1880
  deleteEntryFile(jsonPath) {
1976
- const resolved = resolve3(jsonPath);
1977
- const indexDir = resolve3(this.indexDir);
1881
+ const resolved = resolve2(jsonPath);
1882
+ const indexDir = resolve2(this.indexDir);
1978
1883
  if (!resolved.startsWith(indexDir + sep)) {
1979
1884
  throw new Error(`Not inside the index folder: ${jsonPath}`);
1980
1885
  }
@@ -1982,13 +1887,33 @@ var JsonIndexWriter = class {
1982
1887
  if (INDEX_ARTIFACT_FILES.includes(inIndex) || inIndex.startsWith(`${COMMUNITY_HISTORY_DIR}/`)) {
1983
1888
  throw new Error(`Refusing to delete the index artifact ${inIndex}`);
1984
1889
  }
1985
- if (!existsSync10(resolved)) return false;
1986
- unlinkSync(resolved);
1890
+ let text;
1891
+ try {
1892
+ text = readFileSync5(resolved, "utf-8");
1893
+ } catch (err) {
1894
+ if (err.code === "ENOENT") return false;
1895
+ throw err;
1896
+ }
1897
+ let data;
1898
+ try {
1899
+ data = JSON.parse(text);
1900
+ } catch {
1901
+ data = void 0;
1902
+ }
1903
+ if (isMarkedArtifact(data)) {
1904
+ throw new Error(`Refusing to delete the index artifact ${inIndex}`);
1905
+ }
1906
+ try {
1907
+ unlinkSync(resolved);
1908
+ } catch (err) {
1909
+ if (err.code === "ENOENT") return false;
1910
+ throw err;
1911
+ }
1987
1912
  return true;
1988
1913
  }
1989
1914
  resolveIndexPath(relativePath) {
1990
- const resolved = resolve3(this.indexDir, `${relativePath}.json`);
1991
- const normalizedDir = resolve3(this.indexDir) + sep;
1915
+ const resolved = resolve2(this.indexDir, `${relativePath}.json`);
1916
+ const normalizedDir = resolve2(this.indexDir) + sep;
1992
1917
  if (!resolved.startsWith(normalizedDir)) {
1993
1918
  throw new Error(`Path traversal detected: ${relativePath}`);
1994
1919
  }
@@ -2010,8 +1935,8 @@ var JsonIndexWriter = class {
2010
1935
  };
2011
1936
 
2012
1937
  // src/adapters/storage/schema-manager.ts
2013
- import { readFileSync as readFileSync7, writeFileSync, existsSync as existsSync11, mkdirSync as mkdirSync3 } from "fs";
2014
- import { join as join10, dirname as dirname5 } from "path";
1938
+ import { readFileSync as readFileSync6, writeFileSync, existsSync as existsSync10, mkdirSync as mkdirSync3 } from "fs";
1939
+ import { join as join10, dirname as dirname4 } from "path";
2015
1940
  var CURRENT_SCHEMA_VERSION = "1.0.0";
2016
1941
  var SchemaManager = class {
2017
1942
  versionFilePath;
@@ -2022,13 +1947,13 @@ var SchemaManager = class {
2022
1947
  return CURRENT_SCHEMA_VERSION;
2023
1948
  }
2024
1949
  readStoredVersion() {
2025
- if (!existsSync11(this.versionFilePath)) {
1950
+ if (!existsSync10(this.versionFilePath)) {
2026
1951
  return void 0;
2027
1952
  }
2028
- return readFileSync7(this.versionFilePath, "utf-8").trim();
1953
+ return readFileSync6(this.versionFilePath, "utf-8").trim();
2029
1954
  }
2030
1955
  writeVersion() {
2031
- mkdirSync3(dirname5(this.versionFilePath), { recursive: true });
1956
+ mkdirSync3(dirname4(this.versionFilePath), { recursive: true });
2032
1957
  writeFileSync(this.versionFilePath, CURRENT_SCHEMA_VERSION, "utf-8");
2033
1958
  }
2034
1959
  isCompatible() {
@@ -2332,7 +2257,7 @@ var CommunityDetector = class {
2332
2257
  };
2333
2258
 
2334
2259
  // src/cli/index-command.ts
2335
- var requireCjs = createRequire3(import.meta.url);
2260
+ var requireCjs = createRequire2(import.meta.url);
2336
2261
  var IndexCommand = class {
2337
2262
  projectRoot;
2338
2263
  ctxoRoot;
@@ -2370,8 +2295,10 @@ var IndexCommand = class {
2370
2295
  const gitAdapter = new SimpleGitAdapter(this.projectRoot);
2371
2296
  const incremental = options.files !== void 0 || options.commit !== void 0 || Boolean(options.file);
2372
2297
  const commitChanges = options.commit !== void 0 ? await readCommitChanges(gitAdapter, options.commit) : [];
2298
+ const modules = options.modules ?? [];
2299
+ const requestFacts = factsRequested(ignore.config) || modules.some((module) => module.registration.indexCapabilities?.facts === true);
2373
2300
  const registry = new LanguageAdapterRegistry();
2374
- const plugins = await this.registerDiscoveredPlugins(registry, ignore.ignoreProjectPatterns, factsRequested(ignore.config));
2301
+ const plugins = await this.registerDiscoveredPlugins(registry, ignore.ignoreProjectPatterns, requestFacts);
2375
2302
  const { tsMorphLike, roslynAdapter, csharpComposite, goTier, javaTier } = plugins;
2376
2303
  const csharpTierAtLoad = plugins.csharpTier;
2377
2304
  let csharpTier = plugins.csharpTier;
@@ -2494,7 +2421,7 @@ var IndexCommand = class {
2494
2421
  console.error(`[ctxo] Edge extraction failed for ${entry.relativePath}: ${err.message}`);
2495
2422
  }
2496
2423
  }
2497
- if (factsRequested(ignore.config)) {
2424
+ if (requestFacts) {
2498
2425
  for (const entry of pendingIndices) {
2499
2426
  const adapter = registry.getAdapter(entry.relativePath);
2500
2427
  if (!adapter) continue;
@@ -2529,6 +2456,7 @@ var IndexCommand = class {
2529
2456
  registry.getSupportedExtensions().size === 0 ? "[ctxo] No language plugin is loaded, so no file was indexed. Run `ctxo install` (or `ctxo index --install-missing`). communities.json, its history and the architecture artifacts were left as they are" : "[ctxo] No source file was indexed. communities.json, its history and the architecture artifacts were left as they are"
2530
2457
  );
2531
2458
  }
2459
+ let phase2ChangeLog;
2532
2460
  if (!options.skipHistory && refreshCoChange) {
2533
2461
  try {
2534
2462
  const coChange = resolveCoChangeConfig(ignore.config, {
@@ -2537,6 +2465,7 @@ var IndexCommand = class {
2537
2465
  });
2538
2466
  const maxHistory = options.maxHistory ?? 20;
2539
2467
  const changeLog = await gitAdapter.getChangeLog(changeLogSize(coChange.maxCommits, maxHistory));
2468
+ if (changeLog !== void 0) phase2ChangeLog = { changeLog, coChange };
2540
2469
  if (changeLog === void 0) {
2541
2470
  console.error("[ctxo] Git history unavailable: intent[] left empty and co-changes.json not updated");
2542
2471
  } else {
@@ -2569,7 +2498,7 @@ var IndexCommand = class {
2569
2498
  let communitySnapshot;
2570
2499
  let symbolPageRank;
2571
2500
  if (options.skipCommunity && refreshProducts) {
2572
- if (existsSync12(join11(this.ctxoRoot, "index", "communities.json"))) {
2501
+ if (existsSync11(join11(this.ctxoRoot, "index", "communities.json"))) {
2573
2502
  console.error(
2574
2503
  `[ctxo] WARN stale communities.json present \u2014 --skip-community preserved existing snapshot. Run \`ctxo index\` (full) to refresh.`
2575
2504
  );
@@ -2646,6 +2575,9 @@ var IndexCommand = class {
2646
2575
  }
2647
2576
  }
2648
2577
  }
2578
+ if (refreshProducts) {
2579
+ await this.runModuleIndexPasses(modules, ignore.config, indexed, phase2ChangeLog);
2580
+ }
2649
2581
  if (!options.skipSideEffects) {
2650
2582
  this.ensureGitignore();
2651
2583
  }
@@ -2664,6 +2596,49 @@ var IndexCommand = class {
2664
2596
  if (sqlCount > 0) console.error(`[ctxo] SQL: ${sqlCount} files (syntax tier)`);
2665
2597
  if (roslynAdapter) await roslynAdapter.dispose();
2666
2598
  }
2599
+ /**
2600
+ * Phase 2e. Runs each module's `indexPass` on the merged index (the one the snapshot, the artifacts and
2601
+ * the contexts view were built from), with a graph built on first use, the host's test-file check and,
2602
+ * when Phase 2 read the change log, the raw commits of the co-change window (renames intact). Each pass
2603
+ * is awaited in turn; one that throws is reported and the next one still runs.
2604
+ */
2605
+ async runModuleIndexPasses(modules, config, indices, phase2) {
2606
+ const passes = modules.filter((module) => typeof module.registration.indexPass === "function");
2607
+ if (passes.length === 0) return;
2608
+ let ctx;
2609
+ try {
2610
+ const { createModuleHost } = await import("./module-host-KTGDODES.js");
2611
+ const { services } = createModuleHost({ projectRoot: this.projectRoot, mode: "cli" });
2612
+ let graph;
2613
+ ctx = {
2614
+ projectRoot: this.projectRoot,
2615
+ ctxoRoot: this.ctxoRoot,
2616
+ config,
2617
+ indices,
2618
+ graph: () => graph ??= services.buildGraph(indices),
2619
+ isTestFile: services.createTestFileCheck(config, indices),
2620
+ history: phase2 === void 0 ? void 0 : {
2621
+ head: phase2.changeLog.head,
2622
+ headCommittedAt: phase2.changeLog.headCommittedAt,
2623
+ commits: selectWindow(
2624
+ phase2.changeLog.commits,
2625
+ phase2.coChange.maxCommits,
2626
+ windowCutoff(phase2.coChange, phase2.changeLog.headCommittedAt)
2627
+ )
2628
+ }
2629
+ };
2630
+ } catch (err) {
2631
+ console.error(`[ctxo] Module index passes skipped: ${err.message}`);
2632
+ return;
2633
+ }
2634
+ for (const module of passes) {
2635
+ try {
2636
+ await module.registration.indexPass(ctx);
2637
+ } catch (err) {
2638
+ console.error(`[ctxo] ${module.name} index pass failed: ${err.message}`);
2639
+ }
2640
+ }
2641
+ }
2667
2642
  async autoInstallMissingPlugins() {
2668
2643
  const detection = detectLanguages(this.projectRoot);
2669
2644
  const needed = decideNeededLanguages(detection);
@@ -2743,7 +2718,7 @@ var IndexCommand = class {
2743
2718
  return output.split("\0").filter((line) => line.length > 0).filter((line) => this.isSupportedExtension(line)).map((line) => join11(root, line)).filter((abs) => {
2744
2719
  const rel = relative2(this.projectRoot, abs).replace(/\\/g, "/");
2745
2720
  return !ignoreFile(rel);
2746
- }).filter((abs) => existsSync12(abs));
2721
+ }).filter((abs) => existsSync11(abs));
2747
2722
  } catch {
2748
2723
  console.error(`[ctxo] git ls-files failed for ${root}`);
2749
2724
  return [];
@@ -2755,16 +2730,16 @@ var IndexCommand = class {
2755
2730
  */
2756
2731
  discoverWorkspaces(ignoreProject = () => false) {
2757
2732
  const pkgPath = join11(this.projectRoot, "package.json");
2758
- if (!existsSync12(pkgPath)) return { kept: [this.projectRoot], ignored: [] };
2733
+ if (!existsSync11(pkgPath)) return { kept: [this.projectRoot], ignored: [] };
2759
2734
  try {
2760
- const pkg = JSON.parse(readFileSync8(pkgPath, "utf-8"));
2735
+ const pkg = JSON.parse(readFileSync7(pkgPath, "utf-8"));
2761
2736
  const workspaces = Array.isArray(pkg.workspaces) ? pkg.workspaces : pkg.workspaces?.packages;
2762
2737
  if (!workspaces || workspaces.length === 0) return { kept: [this.projectRoot], ignored: [] };
2763
2738
  const resolved = [];
2764
2739
  for (const ws of workspaces) {
2765
2740
  if (ws.endsWith("/*") || ws.endsWith("\\*")) {
2766
2741
  const parentDir = join11(this.projectRoot, ws.slice(0, -2));
2767
- if (existsSync12(parentDir)) {
2742
+ if (existsSync11(parentDir)) {
2768
2743
  for (const entry of readdirSync2(parentDir, { withFileTypes: true })) {
2769
2744
  if (entry.isDirectory()) {
2770
2745
  resolved.push(join11(parentDir, entry.name));
@@ -2773,7 +2748,7 @@ var IndexCommand = class {
2773
2748
  }
2774
2749
  } else {
2775
2750
  const wsPath = join11(this.projectRoot, ws);
2776
- if (existsSync12(wsPath)) {
2751
+ if (existsSync11(wsPath)) {
2777
2752
  resolved.push(wsPath);
2778
2753
  }
2779
2754
  }
@@ -2808,7 +2783,7 @@ var IndexCommand = class {
2808
2783
  if (_roslyn) await _roslyn.dispose();
2809
2784
  const hasher = new ContentHasher();
2810
2785
  const files = this.discoverFilesIn(this.projectRoot);
2811
- const reader = new (await import("./json-index-reader-OXTRTGE6.js")).JsonIndexReader(this.ctxoRoot);
2786
+ const reader = new (await import("./json-index-reader-DKEIP2QR.js")).JsonIndexReader(this.ctxoRoot);
2812
2787
  const indices = reader.readAll();
2813
2788
  const indexedMap = new Map(indices.map((i) => [i.file, i]));
2814
2789
  let staleCount = 0;
@@ -2821,7 +2796,7 @@ var IndexCommand = class {
2821
2796
  staleCount++;
2822
2797
  continue;
2823
2798
  }
2824
- if (!existsSync12(filePath)) {
2799
+ if (!existsSync11(filePath)) {
2825
2800
  console.error(`[ctxo] DELETED: ${relativePath}`);
2826
2801
  staleCount++;
2827
2802
  continue;
@@ -2853,7 +2828,7 @@ var IndexCommand = class {
2853
2828
  # Ctxo local cache (never committed)
2854
2829
  ${cachePattern}
2855
2830
  `;
2856
- const existing = existsSync12(gitignorePath) ? readFileSync8(gitignorePath, "utf-8") : "";
2831
+ const existing = existsSync11(gitignorePath) ? readFileSync7(gitignorePath, "utf-8") : "";
2857
2832
  if (existing.includes(cachePattern)) return;
2858
2833
  writeFileSync2(gitignorePath, existing + suffix, "utf-8");
2859
2834
  console.error("[ctxo] Added .ctxo/.cache/ to .gitignore");
@@ -2900,7 +2875,7 @@ ${cachePattern}
2900
2875
  for (const path of [...candidates].sort(compareCodepoint)) {
2901
2876
  if (!isIndexable(path)) {
2902
2877
  if (listed.has(path)) skippedListed += 1;
2903
- } else if (existsSync12(join11(this.projectRoot, path))) {
2878
+ } else if (existsSync11(join11(this.projectRoot, path))) {
2904
2879
  changed.push(path);
2905
2880
  } else {
2906
2881
  missing.push(path);
@@ -2953,7 +2928,7 @@ ${cachePattern}
2953
2928
  * have been committed by a machine that has the plugin, and the file is still there.
2954
2929
  */
2955
2930
  findStaleEntries(committed, reindexed, isIgnored) {
2956
- return committed.map((idx) => idx.file).filter((file) => !reindexed.has(file) && (!existsSync12(join11(this.projectRoot, file)) || isIgnored(file))).sort(compareCodepoint);
2931
+ return committed.map((idx) => idx.file).filter((file) => !reindexed.has(file) && (!existsSync11(join11(this.projectRoot, file)) || isIgnored(file))).sort(compareCodepoint);
2957
2932
  }
2958
2933
  /**
2959
2934
  * Produce the FileIndex list used as input to community detection, the architecture artifacts and the
@@ -3127,13 +3102,13 @@ async function readCommitChanges(gitAdapter, rev) {
3127
3102
  function toRepoPath(projectRoot, input) {
3128
3103
  const trimmed = input.trim().replace(/\\/g, "/");
3129
3104
  if (trimmed === "") return void 0;
3130
- const relativePath = relative2(projectRoot, resolve4(projectRoot, trimmed));
3131
- if (relativePath === "" || isAbsolute2(relativePath) || relativePath.split(/[\\/]/)[0] === "..") return void 0;
3105
+ const relativePath = relative2(projectRoot, resolve3(projectRoot, trimmed));
3106
+ if (relativePath === "" || isAbsolute(relativePath) || relativePath.split(/[\\/]/)[0] === "..") return void 0;
3132
3107
  return relativePath.replace(/\\/g, "/");
3133
3108
  }
3134
3109
  function isInsideDirectory(directory, absolute) {
3135
3110
  const relativePath = relative2(directory, absolute);
3136
- return relativePath !== "" && !isAbsolute2(relativePath) && relativePath.split(/[\\/]/)[0] !== "..";
3111
+ return relativePath !== "" && !isAbsolute(relativePath) && relativePath.split(/[\\/]/)[0] !== "..";
3137
3112
  }
3138
3113
  function countCommunities(snapshot) {
3139
3114
  const ids = /* @__PURE__ */ new Set();
@@ -3162,7 +3137,7 @@ var SyncCommand = class {
3162
3137
 
3163
3138
  // src/cli/status-command.ts
3164
3139
  import { join as join13 } from "path";
3165
- import { existsSync as existsSync13 } from "fs";
3140
+ import { existsSync as existsSync12 } from "fs";
3166
3141
  import { execFileSync as execFileSync4 } from "child_process";
3167
3142
  var StatusCommand = class {
3168
3143
  projectRoot;
@@ -3173,7 +3148,7 @@ var StatusCommand = class {
3173
3148
  }
3174
3149
  run() {
3175
3150
  const indexDir = join13(this.ctxoRoot, "index");
3176
- if (!existsSync13(indexDir)) {
3151
+ if (!existsSync12(indexDir)) {
3177
3152
  console.error('[ctxo] No index found. Run "ctxo index" first.');
3178
3153
  return;
3179
3154
  }
@@ -3188,7 +3163,7 @@ var StatusCommand = class {
3188
3163
  console.error(` Indexed files: ${indices.length}`);
3189
3164
  console.error(` Total symbols: ${totalSymbols}`);
3190
3165
  console.error(` Total edges: ${totalEdges}`);
3191
- const cacheExists = existsSync13(join13(this.ctxoRoot, ".cache", "symbols.db"));
3166
+ const cacheExists = existsSync12(join13(this.ctxoRoot, ".cache", "symbols.db"));
3192
3167
  console.error(` SQLite cache: ${cacheExists ? "present" : "missing (run ctxo sync)"}`);
3193
3168
  if (indices.length > 0) {
3194
3169
  console.error("");
@@ -3264,13 +3239,13 @@ var VerifyCommand = class {
3264
3239
  };
3265
3240
 
3266
3241
  // src/cli/init-command.ts
3267
- import { isAbsolute as isAbsolute3, join as join17, relative as relative4 } from "path";
3268
- import { existsSync as existsSync15, readFileSync as readFileSync10, writeFileSync as writeFileSync4, mkdirSync as mkdirSync5, chmodSync } from "fs";
3269
- import { createRequire as createRequire4 } from "module";
3242
+ import { isAbsolute as isAbsolute2, join as join17, relative as relative4 } from "path";
3243
+ import { existsSync as existsSync14, readFileSync as readFileSync9, writeFileSync as writeFileSync4, mkdirSync as mkdirSync5, chmodSync } from "fs";
3244
+ import { createRequire as createRequire3 } from "module";
3270
3245
 
3271
3246
  // src/cli/ai-rules.ts
3272
- import { existsSync as existsSync14, readFileSync as readFileSync9, realpathSync, writeFileSync as writeFileSync3, mkdirSync as mkdirSync4, statSync as statSync2 } from "fs";
3273
- import { join as join15, dirname as dirname6, relative as relative3, sep as sep2 } from "path";
3247
+ import { existsSync as existsSync13, readFileSync as readFileSync8, realpathSync, writeFileSync as writeFileSync3, mkdirSync as mkdirSync4, statSync as statSync2 } from "fs";
3248
+ import { join as join15, dirname as dirname5, relative as relative3, sep as sep2 } from "path";
3274
3249
 
3275
3250
  // src/core/agent-rules/rules-block.ts
3276
3251
  var RULES_TEMPLATE_VERSION = "0.13";
@@ -3564,16 +3539,20 @@ function guardAndSkillsExpected(block) {
3564
3539
  // src/cli/ai-rules.ts
3565
3540
  var CTXO_HOOK_COMMAND = "ctxo gate-hook";
3566
3541
  var CLAUDE_SETTINGS_FILE = ".claude/settings.json";
3567
- var UNREADABLE_SETTINGS_WARNING = `${CLAUDE_SETTINGS_FILE} is not valid JSON or not a settings object; left unchanged. Fix it, then run "ctxo init --only rules,skills,guard".`;
3542
+ var GUARD_RERUN_PARTS = "rules,skills,guard";
3543
+ function unreadableSettingsWarning(rerun) {
3544
+ return `${CLAUDE_SETTINGS_FILE} is not valid JSON or not a settings object; left unchanged. Fix it, then run "ctxo init --only ${rerun}".`;
3545
+ }
3568
3546
  function isPlainObject(value) {
3569
3547
  return typeof value === "object" && value !== null && !Array.isArray(value);
3570
3548
  }
3571
- function readSettings(path) {
3572
- if (!existsSync14(path)) return { kind: "missing" };
3549
+ var GUARD_PROBE = { event: "PreToolUse", command: CTXO_HOOK_COMMAND };
3550
+ function readSettings(path, probe = GUARD_PROBE) {
3551
+ if (!existsSync13(path)) return { kind: "missing" };
3573
3552
  let json;
3574
3553
  let shape;
3575
3554
  try {
3576
- shape = textShape(readFileSync9(path, "utf-8"));
3555
+ shape = textShape(readFileSync8(path, "utf-8"));
3577
3556
  json = JSON.parse(shape.text);
3578
3557
  } catch {
3579
3558
  return { kind: "unreadable" };
@@ -3581,19 +3560,19 @@ function readSettings(path) {
3581
3560
  if (!isPlainObject(json)) return { kind: "unreadable" };
3582
3561
  if (json.hooks === void 0) return { kind: "ok", json, hasHook: false, shape };
3583
3562
  if (!isPlainObject(json.hooks)) return { kind: "unreadable" };
3584
- const pre = json.hooks.PreToolUse;
3585
- if (pre === void 0) return { kind: "ok", json, hasHook: false, shape };
3586
- if (!Array.isArray(pre)) return { kind: "unreadable" };
3587
- const wellFormed = pre.filter(
3563
+ const list = json.hooks[probe.event];
3564
+ if (list === void 0) return { kind: "ok", json, hasHook: false, shape };
3565
+ if (!Array.isArray(list)) return { kind: "unreadable" };
3566
+ const wellFormed = list.filter(
3588
3567
  (entry) => isPlainObject(entry) && Array.isArray(entry.hooks)
3589
3568
  );
3590
- const hasHook = wellFormed.some((entry) => entry.hooks.some((h) => isPlainObject(h) && h.command === CTXO_HOOK_COMMAND));
3591
- if (!hasHook && wellFormed.length !== pre.length) return { kind: "unreadable" };
3569
+ const hasHook = wellFormed.some((entry) => entry.hooks.some((h) => isPlainObject(h) && h.command === probe.command));
3570
+ if (!hasHook && wellFormed.length !== list.length) return { kind: "unreadable" };
3592
3571
  return { kind: "ok", json, hasHook, shape };
3593
3572
  }
3594
- function guardRegistration(projectRoot) {
3595
- const shared = readSettings(join15(projectRoot, ".claude", "settings.json"));
3596
- const local = readSettings(join15(projectRoot, ".claude", "settings.local.json"));
3573
+ function guardRegistration(projectRoot, probe = GUARD_PROBE) {
3574
+ const shared = readSettings(join15(projectRoot, ".claude", "settings.json"), probe);
3575
+ const local = readSettings(join15(projectRoot, ".claude", "settings.local.json"), probe);
3597
3576
  if (shared.kind === "ok" && shared.hasHook || local.kind === "ok" && local.hasHook) {
3598
3577
  return { shared, state: "present" };
3599
3578
  }
@@ -3606,7 +3585,7 @@ function platformRulesState(projectRoot, platform) {
3606
3585
  const path = join15(projectRoot, platform.file);
3607
3586
  let text;
3608
3587
  try {
3609
- text = readFileSync9(path, "utf-8");
3588
+ text = readFileSync8(path, "utf-8");
3610
3589
  } catch (err) {
3611
3590
  const code = err.code;
3612
3591
  if (code === "ENOENT" || code === "EISDIR") return void 0;
@@ -3639,11 +3618,11 @@ function sameFileOnDisk(a, b) {
3639
3618
  function linkedRulesPlatforms(projectRoot, platformId) {
3640
3619
  const platform = PLATFORMS.find((p) => p.id === platformId);
3641
3620
  const path = platform ? join15(projectRoot, platform.file) : void 0;
3642
- if (!path || !existsSync14(path)) return [];
3621
+ if (!path || !existsSync13(path)) return [];
3643
3622
  return PLATFORMS.filter((other) => {
3644
3623
  if (other.id === platformId) return false;
3645
3624
  const otherPath = join15(projectRoot, other.file);
3646
- return existsSync14(otherPath) && sameFileOnDisk(path, otherPath);
3625
+ return existsSync13(otherPath) && sameFileOnDisk(path, otherPath);
3647
3626
  }).map((other) => other.id);
3648
3627
  }
3649
3628
  function firstRulesBlock(projectRoot, platformId) {
@@ -3706,23 +3685,29 @@ function rulesFileHoldReasons(projectRoot, platformId, optionsFor = () => ({}))
3706
3685
  return void 0;
3707
3686
  }
3708
3687
  function ensureClaudeHook(projectRoot, options = {}) {
3688
+ return ensureClaudeHookEntry(projectRoot, { event: "PreToolUse", matcher: "Edit", command: CTXO_HOOK_COMMAND }, options);
3689
+ }
3690
+ function ensureClaudeHookEntry(projectRoot, entry, options = {}) {
3709
3691
  const filePath = join15(projectRoot, ".claude", "settings.json");
3710
- const ctxoEntry = { matcher: "Edit", hooks: [{ type: "command", command: CTXO_HOOK_COMMAND }] };
3711
- const { shared, state } = guardRegistration(projectRoot);
3692
+ const hookEntry = {
3693
+ ...entry.matcher !== void 0 ? { matcher: entry.matcher } : {},
3694
+ hooks: [{ type: "command", command: entry.command }]
3695
+ };
3696
+ const { shared, state } = guardRegistration(projectRoot, { event: entry.event, command: entry.command });
3712
3697
  if (state === "present") return { file: CLAUDE_SETTINGS_FILE, action: "skipped" };
3713
3698
  if (shared.kind === "unreadable") {
3714
- return { file: CLAUDE_SETTINGS_FILE, action: "skipped", warning: UNREADABLE_SETTINGS_WARNING };
3699
+ return { file: CLAUDE_SETTINGS_FILE, action: "skipped", warning: unreadableSettingsWarning(options.rerun ?? GUARD_RERUN_PARTS) };
3715
3700
  }
3716
3701
  if (shared.kind === "missing") {
3717
3702
  if (!options.dryRun) {
3718
- mkdirSync4(dirname6(filePath), { recursive: true });
3719
- writeFileSync3(filePath, JSON.stringify({ hooks: { PreToolUse: [ctxoEntry] } }, null, 2) + "\n", "utf-8");
3703
+ mkdirSync4(dirname5(filePath), { recursive: true });
3704
+ writeFileSync3(filePath, JSON.stringify({ hooks: { [entry.event]: [hookEntry] } }, null, 2) + "\n", "utf-8");
3720
3705
  }
3721
3706
  return { file: CLAUDE_SETTINGS_FILE, action: "created" };
3722
3707
  }
3723
3708
  const hooks = isPlainObject(shared.json.hooks) ? shared.json.hooks : {};
3724
- const pre = Array.isArray(hooks.PreToolUse) ? hooks.PreToolUse : [];
3725
- const updated = { ...shared.json, hooks: { ...hooks, PreToolUse: [...pre, ctxoEntry] } };
3709
+ const list = Array.isArray(hooks[entry.event]) ? hooks[entry.event] : [];
3710
+ const updated = { ...shared.json, hooks: { ...hooks, [entry.event]: [...list, hookEntry] } };
3726
3711
  const body = JSON.stringify(updated, null, 2) + "\n";
3727
3712
  if (!options.dryRun) writeFileSync3(filePath, shared.shape.restore(body), "utf-8");
3728
3713
  return { file: CLAUDE_SETTINGS_FILE, action: "updated" };
@@ -3752,7 +3737,7 @@ var PLATFORMS = [
3752
3737
  function detectPlatforms(projectRoot) {
3753
3738
  const detected = /* @__PURE__ */ new Set();
3754
3739
  for (const p of PLATFORMS) {
3755
- if (p.detectPaths.some((d) => existsSync14(join15(projectRoot, d)))) {
3740
+ if (p.detectPaths.some((d) => existsSync13(join15(projectRoot, d)))) {
3756
3741
  detected.add(p.id);
3757
3742
  }
3758
3743
  }
@@ -3785,7 +3770,7 @@ function installRules(projectRoot, platformId, options = {}) {
3785
3770
  const { result, content } = planRules(projectRoot, platformId, { tier: options.tier });
3786
3771
  if (!options.dryRun && result.action !== "skipped" && content !== void 0) {
3787
3772
  const filePath = join15(projectRoot, result.file);
3788
- if (result.action === "created") mkdirSync4(dirname6(filePath), { recursive: true });
3773
+ if (result.action === "created") mkdirSync4(dirname5(filePath), { recursive: true });
3789
3774
  writeFileSync3(filePath, content, "utf-8");
3790
3775
  }
3791
3776
  return result;
@@ -3798,9 +3783,9 @@ function planRules(projectRoot, platformId, options = {}) {
3798
3783
  const filePath = join15(projectRoot, file);
3799
3784
  let existing = options.existing;
3800
3785
  if (existing === void 0) {
3801
- if (!existsSync14(filePath)) return { result: { file, action: "created" }, content: generateRules(platformId, tier) };
3786
+ if (!existsSync13(filePath)) return { result: { file, action: "created" }, content: generateRules(platformId, tier) };
3802
3787
  try {
3803
- existing = readFileSync9(filePath, "utf-8");
3788
+ existing = readFileSync8(filePath, "utf-8");
3804
3789
  } catch (err) {
3805
3790
  return { result: { file, action: "skipped", warning: unreadableWarning(file, err) } };
3806
3791
  }
@@ -3844,7 +3829,7 @@ function skillsSourceDir() {
3844
3829
  let dir = import.meta.dirname;
3845
3830
  for (let i = 0; i < 10; i++) {
3846
3831
  const candidate = join15(dir, "skills");
3847
- if (existsSync14(join15(candidate, "ctxo-safe-edit", "SKILL.md"))) return candidate;
3832
+ if (existsSync13(join15(candidate, "ctxo-safe-edit", "SKILL.md"))) return candidate;
3848
3833
  const parent = join15(dir, "..");
3849
3834
  if (parent === dir) break;
3850
3835
  dir = parent;
@@ -3856,13 +3841,13 @@ function skillPlan(projectRoot, platformId) {
3856
3841
  if (!target) return [];
3857
3842
  const src = skillsSourceDir();
3858
3843
  return SKILLS.map((skill) => {
3859
- const bundled = readFileSync9(join15(src, skill, "SKILL.md"), "utf-8");
3844
+ const bundled = readFileSync8(join15(src, skill, "SKILL.md"), "utf-8");
3860
3845
  const to = target(projectRoot, skill);
3861
3846
  const file = relative3(projectRoot, to).split(sep2).join("/");
3862
- if (!existsSync14(to)) return { skill, file, state: "missing", bundled, to };
3847
+ if (!existsSync13(to)) return { skill, file, state: "missing", bundled, to };
3863
3848
  let installed;
3864
3849
  try {
3865
- installed = readFileSync9(to, "utf-8");
3850
+ installed = readFileSync8(to, "utf-8");
3866
3851
  } catch (err) {
3867
3852
  return { skill, file, state: "missing", bundled, to, blocked: unreadableWarning(file, err) };
3868
3853
  }
@@ -3878,18 +3863,37 @@ function skillStates(projectRoot, platformId) {
3878
3863
  return skillPlan(projectRoot, platformId).map(({ skill, file, state }) => ({ skill, file, state }));
3879
3864
  }
3880
3865
  function installSkills(projectRoot, platformId, options = {}) {
3881
- return skillPlan(projectRoot, platformId).map(({ file, state, bundled, to, version, blocked }) => {
3882
- if (blocked !== void 0) return { file, action: "skipped", warning: blocked };
3883
- if (state === "current") return { file, action: "skipped" };
3884
- if (state === "newer") {
3885
- return { file, action: "skipped", warning: `${file}: skill v${version ?? ""} comes from a newer ctxo; left unchanged` };
3866
+ if (!skillTarget(platformId)) return [];
3867
+ const src = skillsSourceDir();
3868
+ const bundled = SKILLS.map((skill) => ({ skill, content: readFileSync8(join15(src, skill, "SKILL.md"), "utf-8") }));
3869
+ return bundled.map(({ skill, content }) => installSkillContent(projectRoot, platformId, skill, content, options));
3870
+ }
3871
+ function installSkillContent(projectRoot, platformId, name, content, options = {}) {
3872
+ const target = skillTarget(platformId);
3873
+ if (!target) return void 0;
3874
+ const to = target(projectRoot, name);
3875
+ const file = relative3(projectRoot, to).split(sep2).join("/");
3876
+ let installed;
3877
+ try {
3878
+ installed = readFileSync8(to, "utf-8");
3879
+ } catch (err) {
3880
+ if (err.code !== "ENOENT") {
3881
+ return { file, action: "skipped", warning: unreadableWarning(file, err) };
3886
3882
  }
3887
- if (!options.dryRun) {
3888
- mkdirSync4(dirname6(to), { recursive: true });
3889
- writeFileSync3(to, bundled, "utf-8");
3883
+ }
3884
+ if (installed !== void 0) {
3885
+ const version = skillTemplateVersion(installed);
3886
+ const ours = skillTemplateVersion(content);
3887
+ if (version !== void 0 && ours !== void 0 && compareTemplateVersions(version, ours) > 0) {
3888
+ return { file, action: "skipped", warning: `${file}: skill v${version} comes from a newer ctxo; left unchanged` };
3890
3889
  }
3891
- return { file, action: state === "missing" ? "created" : "updated" };
3892
- });
3890
+ if (comparableMarkdown(installed) === comparableMarkdown(content)) return { file, action: "skipped" };
3891
+ }
3892
+ if (!options.dryRun) {
3893
+ mkdirSync4(dirname5(to), { recursive: true });
3894
+ writeFileSync3(to, content, "utf-8");
3895
+ }
3896
+ return { file, action: installed === void 0 ? "created" : "updated" };
3893
3897
  }
3894
3898
  function skillInstallWarnings(projectRoot, platformId, results) {
3895
3899
  const newer = new Set(skillStates(projectRoot, platformId).filter((s) => s.state === "newer").map((s) => s.file));
@@ -3905,7 +3909,7 @@ function skillInstallWarnings(projectRoot, platformId, results) {
3905
3909
  var GITIGNORE_ENTRY = ".ctxo/.cache/";
3906
3910
  function ensureIndexDir(projectRoot, options = {}) {
3907
3911
  const dir = join15(projectRoot, ".ctxo", "index");
3908
- if (existsSync14(dir)) return { file: ".ctxo/index/", action: "skipped" };
3912
+ if (existsSync13(dir)) return { file: ".ctxo/index/", action: "skipped" };
3909
3913
  if (!options.dryRun) mkdirSync4(dir, { recursive: true });
3910
3914
  return { file: ".ctxo/index/", action: "created" };
3911
3915
  }
@@ -3913,7 +3917,7 @@ function ensureGitignore(projectRoot, options = {}) {
3913
3917
  const filePath = join15(projectRoot, ".gitignore");
3914
3918
  let existing;
3915
3919
  try {
3916
- existing = readFileSync9(filePath, "utf-8");
3920
+ existing = readFileSync8(filePath, "utf-8");
3917
3921
  } catch (err) {
3918
3922
  if (err.code !== "ENOENT") throw err;
3919
3923
  if (!options.dryRun) {
@@ -3970,9 +3974,9 @@ function ensureConfig(projectRoot, options = {}) {
3970
3974
  const filePath = join15(projectRoot, ".ctxo", "config.yaml");
3971
3975
  const file = ".ctxo/config.yaml";
3972
3976
  if (options.dryRun) {
3973
- return { file, action: existsSync14(filePath) ? "skipped" : "created" };
3977
+ return { file, action: existsSync13(filePath) ? "skipped" : "created" };
3974
3978
  }
3975
- mkdirSync4(dirname6(filePath), { recursive: true });
3979
+ mkdirSync4(dirname5(filePath), { recursive: true });
3976
3980
  try {
3977
3981
  writeFileSync3(filePath, DEFAULT_CONFIG, { encoding: "utf-8", flag: "wx" });
3978
3982
  } catch (err) {
@@ -3981,7 +3985,15 @@ function ensureConfig(projectRoot, options = {}) {
3981
3985
  }
3982
3986
  return { file, action: "created" };
3983
3987
  }
3984
- var CTXO_MCP_ENTRY = { command: "npx", args: ["-y", "@ctxo/cli"] };
3988
+ var NPX_MCP_ENTRY = { command: "npx", args: ["-y", "@ctxo/cli"] };
3989
+ function ctxoMcpEntry(kind) {
3990
+ return kind === "global" ? { command: "ctxo", args: [] } : { command: NPX_MCP_ENTRY.command, args: [...NPX_MCP_ENTRY.args] };
3991
+ }
3992
+ function isNpxDefaultEntry(entry) {
3993
+ if (!isPlainObject(entry) || entry["command"] !== NPX_MCP_ENTRY.command) return false;
3994
+ const args = entry["args"];
3995
+ return Array.isArray(args) && args.length === NPX_MCP_ENTRY.args.length && args.every((a, i) => a === NPX_MCP_ENTRY.args[i]);
3996
+ }
3985
3997
  function getMcpConfigTargets(selectedPlatformIds) {
3986
3998
  const targets = /* @__PURE__ */ new Map();
3987
3999
  for (const id of selectedPlatformIds) {
@@ -4013,18 +4025,20 @@ function ensureMcpConfig(projectRoot, target, options = {}) {
4013
4025
  const filePath = join15(projectRoot, target.file);
4014
4026
  const write = (config) => {
4015
4027
  if (options.dryRun) return;
4016
- mkdirSync4(dirname6(filePath), { recursive: true });
4028
+ mkdirSync4(dirname5(filePath), { recursive: true });
4017
4029
  writeFileSync3(filePath, JSON.stringify(config, null, 2) + "\n", "utf-8");
4018
4030
  };
4019
- const entry = target.extraFields ? { ...target.extraFields, ...CTXO_MCP_ENTRY } : { ...CTXO_MCP_ENTRY };
4031
+ const kind = options.installKind ?? "project";
4032
+ const base = ctxoMcpEntry(kind);
4033
+ const entry = target.extraFields ? { ...target.extraFields, ...base } : { ...base };
4020
4034
  const fresh = () => ({ [target.serverKey]: { ctxo: entry } });
4021
- if (!existsSync14(filePath)) {
4035
+ if (!existsSync13(filePath)) {
4022
4036
  write(fresh());
4023
4037
  return { file: target.file, action: "created" };
4024
4038
  }
4025
4039
  let text;
4026
4040
  try {
4027
- text = readFileSync9(filePath, "utf-8");
4041
+ text = readFileSync8(filePath, "utf-8");
4028
4042
  } catch (err) {
4029
4043
  return { file: target.file, action: "skipped", warning: unreadableWarning(target.file, err) };
4030
4044
  }
@@ -4040,15 +4054,103 @@ function ensureMcpConfig(projectRoot, target, options = {}) {
4040
4054
  return { file: target.file, action: "updated" };
4041
4055
  }
4042
4056
  if (servers["ctxo"]) {
4057
+ if (kind === "global" && isNpxDefaultEntry(servers["ctxo"])) {
4058
+ write({ ...parsed, [target.serverKey]: { ...servers, ctxo: { ...servers["ctxo"], ...entry } } });
4059
+ return { file: target.file, action: "updated" };
4060
+ }
4043
4061
  return { file: target.file, action: "skipped" };
4044
4062
  }
4045
4063
  write({ ...parsed, [target.serverKey]: { ...servers, ctxo: entry } });
4046
4064
  return { file: target.file, action: "updated" };
4047
4065
  }
4048
4066
 
4067
+ // src/core/install/install-kind.ts
4068
+ function normalizePath(path, platform) {
4069
+ const slashed = path.replace(/\\/g, "/").replace(/\/+$/, "");
4070
+ return platform === "win32" ? slashed.toLowerCase() : slashed;
4071
+ }
4072
+ var RUNNER_CACHE = /\/(_npx|dlx)\/|\/bunx-[^/]*\/|\/\.bun\/install\/cache\//;
4073
+ function cliInstallKind(cliPackageRoot, projectRoot, platform = process.platform) {
4074
+ const cli = normalizePath(cliPackageRoot, platform);
4075
+ if (RUNNER_CACHE.test(`${cli}/`)) return "npx";
4076
+ const marker = "/node_modules/";
4077
+ const at = cli.indexOf(marker);
4078
+ if (at >= 0) {
4079
+ const owner = cli.slice(0, at);
4080
+ const project = normalizePath(projectRoot, platform);
4081
+ if (project === owner || project.startsWith(`${owner}/`)) return "project";
4082
+ }
4083
+ return "global";
4084
+ }
4085
+ function isRecord(value) {
4086
+ return typeof value === "object" && value !== null && !Array.isArray(value);
4087
+ }
4088
+ function commandName(command) {
4089
+ const base = command.replace(/\\/g, "/").split("/").pop() ?? command;
4090
+ return base.toLowerCase().replace(/\.(cmd|exe|bat)$/, "");
4091
+ }
4092
+ var CTXO_SPEC = /^@ctxo\/cli(@|$)/;
4093
+ function launcherOfCommand(command, args) {
4094
+ const name = commandName(command);
4095
+ if (name === "npx") return args.some((a) => CTXO_SPEC.test(a)) ? "npx" : "other";
4096
+ if (name === "ctxo") return "path-binary";
4097
+ return "other";
4098
+ }
4099
+ function innerCommand(entry) {
4100
+ if (!isRecord(entry) || typeof entry["command"] !== "string") return void 0;
4101
+ const args = Array.isArray(entry["args"]) ? entry["args"].filter((a) => typeof a === "string") : [];
4102
+ if (commandName(entry["command"]) === "cmd") {
4103
+ const run2 = args.findIndex((a) => a.toLowerCase() === "/c");
4104
+ const inner = run2 >= 0 ? args[run2 + 1] : void 0;
4105
+ return inner === void 0 ? { command: "cmd", args: [] } : { command: inner, args: args.slice(run2 + 2) };
4106
+ }
4107
+ return { command: entry["command"], args };
4108
+ }
4109
+ function mcpLauncherOf(entry) {
4110
+ const inner = innerCommand(entry);
4111
+ return inner === void 0 ? void 0 : launcherOfCommand(inner.command, inner.args);
4112
+ }
4113
+ function mcpNpxPin(entry) {
4114
+ const inner = innerCommand(entry);
4115
+ if (inner === void 0 || launcherOfCommand(inner.command, inner.args) !== "npx") return void 0;
4116
+ const spec = inner.args.find((a) => CTXO_SPEC.test(a));
4117
+ const version = spec?.startsWith("@ctxo/cli@") ? spec.slice("@ctxo/cli@".length) : "";
4118
+ return version !== "" && version !== "latest" ? version : void 0;
4119
+ }
4120
+ function assessInstallCopies(facts) {
4121
+ if (facts.hooksCallCtxo && !facts.ctxoOnPath) {
4122
+ return {
4123
+ status: "warn",
4124
+ message: 'hooks call "ctxo" but no ctxo is on PATH, so they never run',
4125
+ fix: 'Install @ctxo/cli globally ("npm i -g @ctxo/cli")'
4126
+ };
4127
+ }
4128
+ if (facts.hooksCallCtxo && facts.launchers.includes("npx")) {
4129
+ const global = facts.globalCliVersion;
4130
+ const differing = global === void 0 ? void 0 : (facts.npxPins ?? []).find((pin) => pin !== global);
4131
+ if (differing !== void 0) {
4132
+ const start = `MCP starts "npx -y @ctxo/cli@${differing}" while hooks run "ctxo" ${global} from PATH: two copies`;
4133
+ return facts.domainInstalled === true ? {
4134
+ status: "warn",
4135
+ message: `${start}, and @ctxo/domain installed in one is missing from the other`,
4136
+ fix: 'Install once globally ("npm i -g @ctxo/cli @ctxo/domain"), then run "ctxo init --only mcp" from that install'
4137
+ } : {
4138
+ status: "warn",
4139
+ message: `${start}, so a package installed next to one is not seen by the other`,
4140
+ fix: 'Install once globally ("npm i -g @ctxo/cli"), then run "ctxo init --only mcp" from that install'
4141
+ };
4142
+ }
4143
+ return { status: "pass", message: "one copy: MCP and hooks run ctxo from PATH" };
4144
+ }
4145
+ if (facts.hooksCallCtxo && facts.launchers.includes("path-binary")) {
4146
+ return { status: "pass", message: "one copy: MCP and hooks run ctxo from PATH" };
4147
+ }
4148
+ return { status: "pass", message: "nothing to compare (no ctxo hooks or no MCP entry)" };
4149
+ }
4150
+
4049
4151
  // src/cli/git-hooks.ts
4050
4152
  import { execFileSync as execFileSync5 } from "child_process";
4051
- import { join as join16, resolve as resolve5 } from "path";
4153
+ import { join as join16, resolve as resolve4 } from "path";
4052
4154
  var CTXO_START = "# ctxo-start";
4053
4155
  var CTXO_END = "# ctxo-end";
4054
4156
  var POST_COMMIT_HOOK_VERSION = 2;
@@ -4107,7 +4209,7 @@ function resolveHooksDir(projectRoot) {
4107
4209
  encoding: "utf-8",
4108
4210
  stdio: ["ignore", "pipe", "ignore"]
4109
4211
  }).trim();
4110
- return answer ? resolve5(projectRoot, answer) : fallback;
4212
+ return answer ? resolve4(projectRoot, answer) : fallback;
4111
4213
  } catch {
4112
4214
  return fallback;
4113
4215
  }
@@ -4147,7 +4249,8 @@ function sharedHooksWarning(hooksDir, scope) {
4147
4249
  }
4148
4250
 
4149
4251
  // src/cli/init-parts.ts
4150
- var INIT_PARTS = ["rules", "skills", "guard", "mcp", "git-hooks", "config"];
4252
+ var INIT_PARTS = ["rules", "skills", "guard", "mcp", "git-hooks", "config", "domain"];
4253
+ var OPT_IN_PARTS = /* @__PURE__ */ new Set(["domain"]);
4151
4254
  function isInitPart(value) {
4152
4255
  return INIT_PARTS.includes(value);
4153
4256
  }
@@ -4172,7 +4275,9 @@ function resolveInitParts(flags) {
4172
4275
  return new Set(
4173
4276
  INIT_PARTS.filter((part) => {
4174
4277
  if (flags.noGuard && part === "guard") return false;
4175
- return flags.only !== void 0 ? flags.only.includes(part) : !removed.has(part);
4278
+ if (part === "domain" && flags.domain && !removed.has(part)) return true;
4279
+ if (flags.only !== void 0) return flags.only.includes(part);
4280
+ return !OPT_IN_PARTS.has(part) && !removed.has(part);
4176
4281
  })
4177
4282
  );
4178
4283
  }
@@ -4181,7 +4286,19 @@ function pluginStepEnabled(flags) {
4181
4286
  }
4182
4287
 
4183
4288
  // src/cli/init-command.ts
4184
- var requireCjs2 = createRequire4(import.meta.url);
4289
+ var requireCjs2 = createRequire3(import.meta.url);
4290
+ function projectDeclaresCtxoCli(root) {
4291
+ try {
4292
+ const pkg = JSON.parse(readFileSync9(join17(root, "package.json"), "utf-8"));
4293
+ if (typeof pkg !== "object" || pkg === null) return false;
4294
+ return ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"].some((field) => {
4295
+ const deps = pkg[field];
4296
+ return typeof deps === "object" && deps !== null && "@ctxo/cli" in deps;
4297
+ });
4298
+ } catch {
4299
+ return false;
4300
+ }
4301
+ }
4185
4302
  function resolveInstalledPlugins(needed) {
4186
4303
  const installed = [];
4187
4304
  const missing = [];
@@ -4250,6 +4367,54 @@ function renderBanner(_version, pc) {
4250
4367
  var RULES_DEPRECATION_NOTICE = "[ctxo] --rules is deprecated and now means --skip git-hooks,guard. Use --only or --skip instead.";
4251
4368
  var NOTHING_TO_INSTALL_NOTICE = "[ctxo] Nothing to install: every part is excluded.";
4252
4369
  var SHARED_HOOKS_SKIPPED = "git hooks skipped - shared hooks directory (see warning above)";
4370
+ var DOMAIN_PACKAGE_ROW = "@ctxo/domain (package)";
4371
+ var DOMAIN_MISSING_WARNING = '@ctxo/domain is not installed; run "ctxo install domain", then "ctxo init --only domain"';
4372
+ var BOUNDARY_QUESTION = "Boundary violations between named contexts";
4373
+ var BOUNDARY_CHOICES = [
4374
+ { value: "by-rung", label: "by-rung: fail verify-domain for bounded and boring contexts, warn for the others" },
4375
+ { value: "report", label: "report: warnings only" }
4376
+ ];
4377
+ var INITIAL_ANSWERS = {
4378
+ confirm: async (_message, initial) => initial,
4379
+ select: async (_message, _options, initial) => initial
4380
+ };
4381
+ function nonInteractiveDomainRun(options, toolIds) {
4382
+ return {
4383
+ interactive: false,
4384
+ yes: options.yes === true,
4385
+ noInstall: options.noInstall === true,
4386
+ ...options.boundaries !== void 0 ? { boundaries: options.boundaries } : {},
4387
+ prompter: INITIAL_ANSWERS,
4388
+ toolIds
4389
+ };
4390
+ }
4391
+ function clackPrompter(clack, pc) {
4392
+ const answered = (value) => {
4393
+ if (clack.isCancel(value)) {
4394
+ clack.cancel("Setup cancelled.");
4395
+ process.exit(0);
4396
+ }
4397
+ return value;
4398
+ };
4399
+ return {
4400
+ async confirm(message, initial) {
4401
+ return answered(await clack.confirm({ message: pc.cyan(message), initialValue: initial })) === true;
4402
+ },
4403
+ async select(message, options, initial) {
4404
+ const choices = options.map((option) => ({ value: option.value, label: option.label }));
4405
+ const picked = await clack.select({ message: pc.cyan(message), options: choices, initialValue: initial });
4406
+ return answered(picked);
4407
+ }
4408
+ };
4409
+ }
4410
+ function configuredBoundaryMode(projectRoot) {
4411
+ const domain = loadConfig(join17(projectRoot, ".ctxo")).config["domain"];
4412
+ if (typeof domain !== "object" || domain === null) return void 0;
4413
+ const boundaries = domain["boundaries"];
4414
+ if (typeof boundaries !== "object" || boundaries === null) return void 0;
4415
+ const enforce = boundaries["enforce"];
4416
+ return enforce === "by-rung" || enforce === "report" ? enforce : void 0;
4417
+ }
4253
4418
  var TOOL_PARTS = ["rules", "skills", "guard", "mcp"];
4254
4419
  function selectedTools(options) {
4255
4420
  return options.tools ? [...new Set(options.tools)] : PLATFORMS.map((p) => p.id);
@@ -4295,12 +4460,16 @@ var GIT_HOOKS = [
4295
4460
  ];
4296
4461
  function displayPath(projectRoot, path) {
4297
4462
  const rel = forwardSlashes(relative4(projectRoot, path));
4298
- return rel === "" || rel.startsWith("..") || isAbsolute3(rel) ? forwardSlashes(path) : rel;
4463
+ return rel === "" || rel.startsWith("..") || isAbsolute2(rel) ? forwardSlashes(path) : rel;
4299
4464
  }
4300
4465
  var InitCommand = class {
4301
4466
  projectRoot;
4302
- constructor(projectRoot) {
4467
+ cliAnchor;
4468
+ /** Set when the domain part could not run (missing package, failed init); `run` then ends with exit code 1. */
4469
+ domainFailed = false;
4470
+ constructor(projectRoot, options = {}) {
4303
4471
  this.projectRoot = projectRoot;
4472
+ this.cliAnchor = options.cliAnchor;
4304
4473
  }
4305
4474
  /**
4306
4475
  * Installs (or upgrades in place) the post-commit and post-merge blocks in the hooks directory
@@ -4324,13 +4493,15 @@ var InitCommand = class {
4324
4493
  }
4325
4494
  /**
4326
4495
  * What init would do with these options, one row per file, writing nothing: the same pipeline as a
4327
- * real run over the parts the flags select. Without `tools` it previews every platform (D16).
4496
+ * real run over the parts the flags select. Without `tools` it previews every platform (D16), the
4497
+ * domain part included (PF11). Without @ctxo/domain the domain part is the row `@ctxo/domain (package)`.
4328
4498
  */
4329
- plan(options = {}) {
4330
- return this.applyParts(resolveInitParts(options), selectedTools(options), true, declinedBy(options));
4499
+ async plan(options = {}) {
4500
+ const toolIds = selectedTools(options);
4501
+ return this.applyParts(resolveInitParts(options), toolIds, true, declinedBy(options), nonInteractiveDomainRun(options, toolIds));
4331
4502
  }
4332
4503
  async run(options = {}) {
4333
- if (!existsSync15(join17(this.projectRoot, ".git"))) {
4504
+ if (!existsSync14(join17(this.projectRoot, ".git"))) {
4334
4505
  console.error('[ctxo] Not a git repository. Run "git init" first.');
4335
4506
  process.exit(1);
4336
4507
  }
@@ -4340,23 +4511,25 @@ var InitCommand = class {
4340
4511
  console.error(NOTHING_TO_INSTALL_NOTICE);
4341
4512
  return;
4342
4513
  }
4343
- if (options.dryRun) return this.dryRun(options);
4344
- if (options.yes || options.tools) return await this.runNonInteractive(options, parts);
4345
- return this.runInteractive(options, parts);
4514
+ if (options.dryRun) await this.dryRun(options);
4515
+ else if (options.yes || options.tools) await this.runNonInteractive(options, parts);
4516
+ else await this.runInteractive(options, parts);
4517
+ if (this.domainFailed) process.exitCode = 1;
4346
4518
  }
4347
4519
  /* ---------------------------------------------------------------- */
4348
4520
  /* The parts pipeline */
4349
4521
  /* ---------------------------------------------------------------- */
4350
4522
  /**
4351
- * Installs the selected parts in one fixed order: config, skills, guard, rules, MCP, git hooks.
4523
+ * Installs the selected parts in one fixed order: config, skills, guard, rules, MCP, git hooks, domain.
4352
4524
  * Skills and the guard go first so each platform's rules tier is resolved from what is on disk after
4353
4525
  * them, and so `rulesHold` can see whether a block that expects them must wait (for a file under
4354
4526
  * any of its linked names, `rulesFileHoldReasons`). A part not in the
4355
4527
  * set adds no row. Bundled skills that cannot be read become a warning row, and the other parts
4356
4528
  * still run. `dryRun` gives the same rows and writes nothing; the rules tier and the hold then count
4357
4529
  * the skills and guard it would install as present. `declined` is what the user declined in this run.
4530
+ * `domain` says how the domain part (step 7, `applyDomain`) installs @ctxo/domain and asks its questions.
4358
4531
  */
4359
- applyParts(parts, toolIds, dryRun, declined) {
4532
+ async applyParts(parts, toolIds, dryRun, declined, domain) {
4360
4533
  const root = this.projectRoot;
4361
4534
  const write = { dryRun };
4362
4535
  const results = [];
@@ -4408,7 +4581,9 @@ var InitCommand = class {
4408
4581
  }
4409
4582
  }
4410
4583
  if (parts.has("mcp")) {
4411
- for (const target of getMcpConfigTargets([...toolIds])) results.push(ensureMcpConfig(root, target, write));
4584
+ const detected = cliInstallKind(getCliPackageRoot(), root);
4585
+ const installKind = detected === "global" && projectDeclaresCtxoCli(root) ? "project" : detected;
4586
+ for (const target of getMcpConfigTargets([...toolIds])) results.push(ensureMcpConfig(root, target, { ...write, installKind }));
4412
4587
  }
4413
4588
  if (parts.has("git-hooks")) {
4414
4589
  const hooks = this.installHooks(write);
@@ -4417,8 +4592,81 @@ var InitCommand = class {
4417
4592
  results.push({ file: displayPath(root, hooks.hooksDir), action: "skipped", warning: SHARED_HOOKS_SKIPPED });
4418
4593
  }
4419
4594
  }
4595
+ if (parts.has("domain")) {
4596
+ domain.pause?.();
4597
+ try {
4598
+ results.push(...await this.applyDomain(dryRun, domain));
4599
+ } finally {
4600
+ domain.resume?.();
4601
+ }
4602
+ }
4420
4603
  return results;
4421
4604
  }
4605
+ /**
4606
+ * Step 7, the domain part. Without @ctxo/domain's init contribution: a dry run gives the row
4607
+ * `@ctxo/domain (package)`; `--no-install`, a non-interactive run without `--yes` or a declined install
4608
+ * question give the missing-package warning (all but the declined question fail the run); otherwise
4609
+ * @ctxo/domain is installed (`ctxo install domain`) and loaded again. Then the contribution's `apply` runs
4610
+ * with the domain tools, the run's prompter, `ensureConfig` and the two writers of `ai-rules.ts`.
4611
+ */
4612
+ async applyDomain(dryRun, run2) {
4613
+ const missing = { file: "@ctxo/domain", action: "skipped", warning: DOMAIN_MISSING_WARNING };
4614
+ let init = await this.loadDomainInit(false);
4615
+ if (init === void 0) {
4616
+ if (dryRun) return [{ file: DOMAIN_PACKAGE_ROW, action: "created" }];
4617
+ const declined = run2.interactive && run2.installApproved === false;
4618
+ const allowed = !run2.noInstall && (run2.interactive ? run2.installApproved === true : run2.yes);
4619
+ if (!allowed) {
4620
+ if (!declined) this.domainFailed = true;
4621
+ return [missing];
4622
+ }
4623
+ await new InstallCommand(this.projectRoot).run({ languages: ["domain"], yes: true });
4624
+ init = await this.loadDomainInit(true);
4625
+ if (init === void 0) {
4626
+ this.domainFailed = true;
4627
+ return [missing];
4628
+ }
4629
+ }
4630
+ const root = this.projectRoot;
4631
+ const write = { dryRun };
4632
+ const ctx = {
4633
+ projectRoot: root,
4634
+ toolIds: run2.toolIds,
4635
+ dryRun,
4636
+ yes: run2.yes,
4637
+ ...run2.boundaries !== void 0 ? { boundaries: run2.boundaries } : {},
4638
+ prompt: run2.prompter,
4639
+ ensureConfigFile: () => ensureConfig(root, write),
4640
+ ensureClaudeHookEntry: (entry) => ensureClaudeHookEntry(root, entry, { ...write, rerun: "domain" }),
4641
+ installSkill: (platformId, name, content) => installSkillContent(root, platformId, name, content, write)
4642
+ };
4643
+ try {
4644
+ return [...await init.apply(ctx)];
4645
+ } catch (err) {
4646
+ this.domainFailed = true;
4647
+ return [{ file: "@ctxo/domain", action: "skipped", warning: `@ctxo/domain init failed: ${err.message}` }];
4648
+ }
4649
+ }
4650
+ /**
4651
+ * @ctxo/domain's init contribution, or undefined when the package is not installed, does not load
4652
+ * (version mismatch, load failure: the loader warns) or has no `init`. `refresh` looks again after an install.
4653
+ */
4654
+ async loadDomainInit(refresh) {
4655
+ try {
4656
+ const [{ DOMAIN_MODULE_PACKAGE: DOMAIN_MODULE_PACKAGE2, loadCtxoModules: loadCtxoModules2 }, { resolvePackageCandidates: resolvePackageCandidates2 }, { createModuleHost }] = await Promise.all([
4657
+ import("./module-loader-B3LYF3W7.js"),
4658
+ import("./plugin-discovery-XRXI3IJF.js"),
4659
+ import("./module-host-KTGDODES.js")
4660
+ ]);
4661
+ if (resolvePackageCandidates2(DOMAIN_MODULE_PACKAGE2, this.projectRoot, this.cliAnchor).length === 0) return void 0;
4662
+ const host = createModuleHost({ projectRoot: this.projectRoot, mode: "cli" });
4663
+ const { modules } = await loadCtxoModules2(host, { refresh, cliAnchor: this.cliAnchor });
4664
+ const init = modules[0]?.registration.init;
4665
+ return init !== void 0 && typeof init.apply === "function" ? init : void 0;
4666
+ } catch {
4667
+ return void 0;
4668
+ }
4669
+ }
4422
4670
  /**
4423
4671
  * The rows for a rules file that `rulesHold` keeps back: the held row, then the warning a write
4424
4672
  * would have given (a damaged block or one from a newer ctxo, read by a dry run that writes nothing).
@@ -4454,7 +4702,8 @@ var InitCommand = class {
4454
4702
  const showTools = TOOL_PARTS.some((part) => parts.has(part));
4455
4703
  const showPlugins = missingPlugins.length > 0;
4456
4704
  const showHooks = parts.has("git-hooks");
4457
- const totalSteps = [showIndex, showTools, showPlugins, showHooks].filter(Boolean).length;
4705
+ const showDomain = parts.has("domain");
4706
+ const totalSteps = [showIndex, showTools, showPlugins, showHooks, showDomain].filter(Boolean).length;
4458
4707
  let stepNum = 0;
4459
4708
  console.error("");
4460
4709
  console.error(renderBanner(version, pc));
@@ -4548,6 +4797,31 @@ var InitCommand = class {
4548
4797
  }
4549
4798
  console.error("");
4550
4799
  }
4800
+ const prompter = clackPrompter(clack, pc);
4801
+ let installApproved;
4802
+ let boundaries = options.boundaries;
4803
+ if (showDomain) {
4804
+ stepNum++;
4805
+ console.error(stepHeader(
4806
+ stepNum,
4807
+ totalSteps,
4808
+ "Domain Knowledge",
4809
+ "Registry, fold guard and the ctxo-domain-fold skill (@ctxo/domain).",
4810
+ pc
4811
+ ));
4812
+ console.error("");
4813
+ const installed = await this.loadDomainInit(false) !== void 0;
4814
+ if (!installed && !options.noInstall) {
4815
+ installApproved = await prompter.confirm(
4816
+ `Install @ctxo/domain@${getVersion()}? It adds the registry, contexts-touched and the fold guard.`,
4817
+ true
4818
+ );
4819
+ }
4820
+ if ((installed || installApproved === true) && boundaries === void 0 && configuredBoundaryMode(this.projectRoot) === void 0) {
4821
+ boundaries = await prompter.select(BOUNDARY_QUESTION, BOUNDARY_CHOICES, "by-rung");
4822
+ }
4823
+ console.error("");
4824
+ }
4551
4825
  const declined = declinedBy(options);
4552
4826
  if (parts.has("guard") && toolIds.includes("claude-code") && !options.noGuard) {
4553
4827
  const guard = await clack.confirm({
@@ -4565,7 +4839,19 @@ var InitCommand = class {
4565
4839
  }
4566
4840
  const s = clack.spinner();
4567
4841
  s.start(pc.dim("Configuring..."));
4568
- const rows = this.applyParts(parts, toolIds, false, declined).map((result) => summaryRow(result, pc));
4842
+ const domainRun = {
4843
+ interactive: true,
4844
+ yes: false,
4845
+ noInstall: options.noInstall === true,
4846
+ ...boundaries !== void 0 ? { boundaries } : {},
4847
+ ...installApproved !== void 0 ? { installApproved } : {},
4848
+ prompter,
4849
+ toolIds: showTools ? toolIds : [...detectPlatforms(this.projectRoot)],
4850
+ // The domain step may install a package and ask the module's own questions: never under a live spinner.
4851
+ pause: () => s.stop(pc.dim("Setting up the domain layer...")),
4852
+ resume: () => s.start(pc.dim("Finalizing..."))
4853
+ };
4854
+ const rows = (await this.applyParts(parts, toolIds, false, declined, domainRun)).map((result) => summaryRow(result, pc));
4569
4855
  if (pluginsToInstall.length > 0) {
4570
4856
  s.stop(pc.dim("Installing language plugins..."));
4571
4857
  const installer = new InstallCommand(this.projectRoot);
@@ -4578,7 +4864,7 @@ var InitCommand = class {
4578
4864
  console.error("");
4579
4865
  console.error(box(rows, { title: pc.green("\u2713 Summary"), border: pc.green }, pc));
4580
4866
  }
4581
- const hasWork = toolIds.length > 0 || parts.has("git-hooks");
4867
+ const hasWork = toolIds.length > 0 || parts.has("git-hooks") || parts.has("domain");
4582
4868
  if (hasWork) {
4583
4869
  const steps = [
4584
4870
  `${pc.cyan("\u25B6")} ctxo index ${pc.dim("\u2500 build codebase index")}`,
@@ -4597,7 +4883,8 @@ var InitCommand = class {
4597
4883
  async runNonInteractive(options, parts) {
4598
4884
  const toolIds = selectedTools(options);
4599
4885
  exitOnUnknownTools(toolIds);
4600
- for (const result of this.applyParts(parts, toolIds, false, declinedBy(options))) console.error(formatResultLine(result));
4886
+ const rows = await this.applyParts(parts, toolIds, false, declinedBy(options), nonInteractiveDomainRun(options, toolIds));
4887
+ for (const result of rows) console.error(formatResultLine(result));
4601
4888
  if (pluginStepEnabled(options)) {
4602
4889
  const missing = this.missingPlugins();
4603
4890
  if (missing.length > 0) {
@@ -4620,10 +4907,11 @@ var InitCommand = class {
4620
4907
  * hooks directory prints installHooks' warning and no hook rows, as a real run does. The hook rows
4621
4908
  * come marked from the pipeline, so the hooks directory is looked up only when git-hooks is selected.
4622
4909
  */
4623
- dryRun(options) {
4910
+ async dryRun(options) {
4624
4911
  exitOnUnknownTools(selectedTools(options));
4625
4912
  console.error(DRY_RUN_HEADER);
4626
- for (const result of this.plan(options)) console.error(formatPlanLine(result));
4913
+ const rows = await this.plan(options);
4914
+ for (const result of rows) console.error(formatPlanLine(result));
4627
4915
  }
4628
4916
  /* ---------------------------------------------------------------- */
4629
4917
  /* Helpers */
@@ -4631,11 +4919,11 @@ var InitCommand = class {
4631
4919
  async getVersion() {
4632
4920
  try {
4633
4921
  const pkgPath = join17(this.projectRoot, "node_modules", "@ctxo", "cli", "package.json");
4634
- if (existsSync15(pkgPath)) {
4635
- return JSON.parse(readFileSync10(pkgPath, "utf-8")).version;
4922
+ if (existsSync14(pkgPath)) {
4923
+ return JSON.parse(readFileSync9(pkgPath, "utf-8")).version;
4636
4924
  }
4637
- const { createRequire: createRequire10 } = await import("module");
4638
- const require5 = createRequire10(import.meta.url);
4925
+ const { createRequire: createRequire9 } = await import("module");
4926
+ const require5 = createRequire9(import.meta.url);
4639
4927
  return require5("../../package.json").version;
4640
4928
  } catch {
4641
4929
  return void 0;
@@ -4656,7 +4944,7 @@ var InitCommand = class {
4656
4944
  let existing = "#!/bin/sh\n";
4657
4945
  let hookExists = true;
4658
4946
  try {
4659
- existing = readFileSync10(hookPath, "utf-8");
4947
+ existing = readFileSync9(hookPath, "utf-8");
4660
4948
  } catch (err) {
4661
4949
  if (err.code !== "ENOENT") throw err;
4662
4950
  hookExists = false;
@@ -4948,7 +5236,7 @@ function readHeadSha() {
4948
5236
 
4949
5237
  // src/cli/stats-command.ts
4950
5238
  import { join as join19 } from "path";
4951
- import { existsSync as existsSync16, readFileSync as readFileSync11 } from "fs";
5239
+ import { existsSync as existsSync15, readFileSync as readFileSync10 } from "fs";
4952
5240
  import initSqlJs from "sql.js";
4953
5241
  var StatsCommand = class {
4954
5242
  ctxoRoot;
@@ -4961,7 +5249,7 @@ var StatsCommand = class {
4961
5249
  return;
4962
5250
  }
4963
5251
  const dbPath = join19(this.ctxoRoot, ".cache", "symbols.db");
4964
- if (!existsSync16(dbPath)) {
5252
+ if (!existsSync15(dbPath)) {
4965
5253
  if (options.json) {
4966
5254
  process.stdout.write(JSON.stringify(this.emptyReport(options.days)) + "\n");
4967
5255
  } else {
@@ -4970,7 +5258,7 @@ var StatsCommand = class {
4970
5258
  return;
4971
5259
  }
4972
5260
  const SQL = await initSqlJs();
4973
- const buffer = readFileSync11(dbPath);
5261
+ const buffer = readFileSync10(dbPath);
4974
5262
  const db = new SQL.Database(buffer);
4975
5263
  const recorder = new SessionRecorderAdapter(db);
4976
5264
  try {
@@ -5009,9 +5297,9 @@ var StatsCommand = class {
5009
5297
  }
5010
5298
  isStatsEnabled() {
5011
5299
  const configPath = join19(this.ctxoRoot, "config.yaml");
5012
- if (!existsSync16(configPath)) return true;
5300
+ if (!existsSync15(configPath)) return true;
5013
5301
  try {
5014
- const raw = readFileSync11(configPath, "utf-8");
5302
+ const raw = readFileSync10(configPath, "utf-8");
5015
5303
  const match = raw.match(/stats:\s*\n\s*enabled:\s*(true|false)/);
5016
5304
  if (match) return match[1] !== "false";
5017
5305
  return true;
@@ -5100,11 +5388,11 @@ var StatsCommand = class {
5100
5388
  };
5101
5389
 
5102
5390
  // src/cli/doctor-command.ts
5103
- import { join as join28 } from "path";
5391
+ import { join as join29 } from "path";
5104
5392
  import { existsSync as existsSync25 } from "fs";
5105
5393
 
5106
5394
  // src/adapters/diagnostics/health-checker.ts
5107
- var log4 = createLogger("ctxo:doctor");
5395
+ var log3 = createLogger("ctxo:doctor");
5108
5396
  var HealthChecker = class {
5109
5397
  checks;
5110
5398
  quiet;
@@ -5121,12 +5409,12 @@ var HealthChecker = class {
5121
5409
  if (outcome.status === "fulfilled") {
5122
5410
  const r = outcome.value;
5123
5411
  if (!this.quiet) {
5124
- log4.info(`${r.id}: ${r.status.toUpperCase()} (${r.message})`);
5412
+ log3.info(`${r.id}: ${r.status.toUpperCase()} (${r.message})`);
5125
5413
  }
5126
5414
  return r;
5127
5415
  }
5128
5416
  const message = outcome.reason?.message ?? "Unknown error";
5129
- if (!this.quiet) log4.error(`${check.id}: FAIL (${message})`);
5417
+ if (!this.quiet) log3.error(`${check.id}: FAIL (${message})`);
5130
5418
  return {
5131
5419
  id: check.id,
5132
5420
  title: check.title,
@@ -5193,8 +5481,8 @@ var DoctorReporter = class {
5193
5481
  };
5194
5482
 
5195
5483
  // src/adapters/diagnostics/checks/runtime-check.ts
5196
- import { createRequire as createRequire5 } from "module";
5197
- var require2 = createRequire5(import.meta.url);
5484
+ import { createRequire as createRequire4 } from "module";
5485
+ var require2 = createRequire4(import.meta.url);
5198
5486
  function pass(id, title, message) {
5199
5487
  return { id, title, status: "pass", message };
5200
5488
  }
@@ -5265,21 +5553,22 @@ function tryResolve(packageName) {
5265
5553
  }
5266
5554
 
5267
5555
  // src/cli/version-command.ts
5268
- import { SUPPORTED_API_VERSIONS as SUPPORTED_API_VERSIONS2 } from "@ctxo/plugin-api";
5269
- var NEWEST_API_VERSION = SUPPORTED_API_VERSIONS2[SUPPORTED_API_VERSIONS2.length - 1];
5270
- function gatherVersionInfo(discovered) {
5556
+ import { SUPPORTED_API_VERSIONS } from "@ctxo/plugin-api";
5557
+ var NEWEST_API_VERSION = SUPPORTED_API_VERSIONS[SUPPORTED_API_VERSIONS.length - 1];
5558
+ function gatherVersionInfo(discovered, modules = []) {
5271
5559
  const plugins = discovered.map(({ plugin, specifier }) => ({
5272
5560
  name: specifier,
5273
5561
  id: plugin.id,
5274
5562
  version: plugin.version,
5275
5563
  apiVersion: plugin.apiVersion,
5276
- compatible: SUPPORTED_API_VERSIONS2.includes(plugin.apiVersion)
5564
+ compatible: SUPPORTED_API_VERSIONS.includes(plugin.apiVersion)
5277
5565
  }));
5278
5566
  return {
5279
5567
  ctxo: getVersion(),
5280
5568
  pluginApiVersion: NEWEST_API_VERSION,
5281
- pluginApiVersions: SUPPORTED_API_VERSIONS2,
5569
+ pluginApiVersions: SUPPORTED_API_VERSIONS,
5282
5570
  plugins,
5571
+ modules,
5283
5572
  runtime: {
5284
5573
  node: process.version,
5285
5574
  platform: process.platform,
@@ -5296,6 +5585,11 @@ function formatVerbose(info) {
5296
5585
  lines.push(`Plugin API: ${info.pluginApiVersions.join(", ")}`);
5297
5586
  lines.push(`Runtime: Node ${info.runtime.node} on ${info.runtime.platform}/${info.runtime.arch}`);
5298
5587
  lines.push("");
5588
+ for (const m of info.modules.filter((x) => x.status !== "not-installed")) {
5589
+ const label = m.version === void 0 ? m.name : `${m.name} ${m.version}`;
5590
+ lines.push(`Modules: ${label} (${m.status})`);
5591
+ }
5592
+ if (info.modules.some((x) => x.status !== "not-installed")) lines.push("");
5299
5593
  if (info.plugins.length === 0) {
5300
5594
  lines.push("Plugins: (none installed)");
5301
5595
  lines.push("");
@@ -5320,6 +5614,18 @@ function formatVerbose(info) {
5320
5614
  function formatJson(info) {
5321
5615
  return JSON.stringify(info, null, 2);
5322
5616
  }
5617
+ async function collectModuleReports(projectRoot, options = {}) {
5618
+ try {
5619
+ if (resolvePackageCandidates(DOMAIN_MODULE_PACKAGE, projectRoot, options.cliAnchor).length === 0) {
5620
+ return [{ name: DOMAIN_MODULE_PACKAGE, status: "not-installed" }];
5621
+ }
5622
+ const { createModuleHost } = await import("./module-host-KTGDODES.js");
5623
+ const { reports } = await loadCtxoModules(createModuleHost({ projectRoot, mode: "cli" }), { cliAnchor: options.cliAnchor });
5624
+ return reports;
5625
+ } catch (err) {
5626
+ return [{ name: DOMAIN_MODULE_PACKAGE, status: "load-failed", reason: err.message }];
5627
+ }
5628
+ }
5323
5629
  var VersionCommand = class {
5324
5630
  constructor(projectRoot) {
5325
5631
  this.projectRoot = projectRoot;
@@ -5338,12 +5644,13 @@ var VersionCommand = class {
5338
5644
  process.stdout.write(formatShort(info) + "\n");
5339
5645
  }
5340
5646
  async collect() {
5647
+ const modules = await collectModuleReports(this.projectRoot);
5341
5648
  const manifestPath = loadManifestPath(this.projectRoot);
5342
5649
  if (!manifestPath) {
5343
- return gatherVersionInfo([]);
5650
+ return gatherVersionInfo([], modules);
5344
5651
  }
5345
5652
  const { plugins } = await discoverPlugins({ manifestPath });
5346
- return gatherVersionInfo(plugins);
5653
+ return gatherVersionInfo(plugins, modules);
5347
5654
  }
5348
5655
  };
5349
5656
 
@@ -5375,10 +5682,11 @@ var VersionsCheck = class {
5375
5682
  }
5376
5683
  };
5377
5684
  async function collectInfo(ctx) {
5685
+ const modules = await collectModuleReports(ctx.projectRoot);
5378
5686
  const manifestPath = loadManifestPath(ctx.projectRoot);
5379
- if (!manifestPath) return gatherVersionInfo([]);
5687
+ if (!manifestPath) return gatherVersionInfo([], modules);
5380
5688
  const { plugins } = await discoverPlugins({ manifestPath });
5381
- return gatherVersionInfo(plugins);
5689
+ return gatherVersionInfo(plugins, modules);
5382
5690
  }
5383
5691
  function buildSummary(info) {
5384
5692
  const pluginCount = info.plugins.length;
@@ -5387,8 +5695,8 @@ function buildSummary(info) {
5387
5695
  }
5388
5696
 
5389
5697
  // src/adapters/diagnostics/checks/language-coverage-check.ts
5390
- import { createRequire as createRequire6 } from "module";
5391
- var require3 = createRequire6(import.meta.url);
5698
+ import { createRequire as createRequire5 } from "module";
5699
+ var require3 = createRequire5(import.meta.url);
5392
5700
  var LanguageCoverageCheck = class {
5393
5701
  id = "language_coverage";
5394
5702
  title = "Language coverage";
@@ -5439,10 +5747,10 @@ function pluginResolves(packageName) {
5439
5747
  }
5440
5748
 
5441
5749
  // src/adapters/diagnostics/checks/java-tier-check.ts
5442
- import { createRequire as createRequire7 } from "module";
5443
- import { existsSync as existsSync17, readdirSync as readdirSync3 } from "fs";
5444
- import { join as join20, dirname as dirname7 } from "path";
5445
- var require4 = createRequire7(import.meta.url);
5750
+ import { createRequire as createRequire6 } from "module";
5751
+ import { existsSync as existsSync16, readdirSync as readdirSync3 } from "fs";
5752
+ import { join as join20, dirname as dirname6 } from "path";
5753
+ var require4 = createRequire6(import.meta.url);
5446
5754
  function evaluateJavaTier(i) {
5447
5755
  const id = "java_tier";
5448
5756
  const title = "Java analysis tier";
@@ -5482,8 +5790,8 @@ function evaluateJavaTier(i) {
5482
5790
  function analyzerJarPresent() {
5483
5791
  try {
5484
5792
  const pkgJson = require4.resolve("@ctxo/lang-java-analyzer/package.json");
5485
- const jarDir = join20(dirname7(pkgJson), "jar");
5486
- return existsSync17(join20(jarDir, "ctxo-jdt-analyzer-11.jar")) || existsSync17(join20(jarDir, "ctxo-jdt-analyzer-17.jar"));
5793
+ const jarDir = join20(dirname6(pkgJson), "jar");
5794
+ return existsSync16(join20(jarDir, "ctxo-jdt-analyzer-11.jar")) || existsSync16(join20(jarDir, "ctxo-jdt-analyzer-17.jar"));
5487
5795
  } catch {
5488
5796
  return false;
5489
5797
  }
@@ -5528,7 +5836,7 @@ function findSourceFile(root, extension, maxDepth) {
5528
5836
  }
5529
5837
  function projectHasJava(root) {
5530
5838
  for (const f of ["pom.xml", "build.gradle", "build.gradle.kts"]) {
5531
- if (existsSync17(join20(root, f))) return true;
5839
+ if (existsSync16(join20(root, f))) return true;
5532
5840
  }
5533
5841
  return findJavaFile(root, 4);
5534
5842
  }
@@ -5545,10 +5853,10 @@ var JavaTierCheck = class {
5545
5853
  };
5546
5854
 
5547
5855
  // src/adapters/diagnostics/checks/csharp-tier-check.ts
5548
- import { createRequire as createRequire8 } from "module";
5856
+ import { createRequire as createRequire7 } from "module";
5549
5857
  import { spawnSync as spawnSync3 } from "child_process";
5550
- import { existsSync as existsSync18, readdirSync as readdirSync4 } from "fs";
5551
- import { join as join21, dirname as dirname8 } from "path";
5858
+ import { existsSync as existsSync17, readdirSync as readdirSync4 } from "fs";
5859
+ import { join as join21, dirname as dirname7 } from "path";
5552
5860
 
5553
5861
  // src/core/detection/detect-dotnet-sdk.ts
5554
5862
  import { spawnSync as spawnSync2 } from "child_process";
@@ -5635,10 +5943,10 @@ function restoreFailureMessage(r) {
5635
5943
  }
5636
5944
  function resolveHelperProject(projectRoot) {
5637
5945
  try {
5638
- const requireFromProject = createRequire8(join21(projectRoot, "package.json"));
5946
+ const requireFromProject = createRequire7(join21(projectRoot, "package.json"));
5639
5947
  const pkgJson = resolvePluginPackageJson("@ctxo/lang-csharp", requireFromProject);
5640
- const csproj = join21(dirname8(pkgJson), "tools", "ctxo-roslyn", "ctxo-roslyn.csproj");
5641
- return existsSync18(csproj) ? csproj : null;
5948
+ const csproj = join21(dirname7(pkgJson), "tools", "ctxo-roslyn", "ctxo-roslyn.csproj");
5949
+ return existsSync17(csproj) ? csproj : null;
5642
5950
  } catch {
5643
5951
  return null;
5644
5952
  }
@@ -5686,7 +5994,7 @@ var CSharpTierCheck = class {
5686
5994
 
5687
5995
  // src/adapters/diagnostics/checks/git-check.ts
5688
5996
  import { execFileSync as execFileSync7 } from "child_process";
5689
- import { existsSync as existsSync19, readFileSync as readFileSync12 } from "fs";
5997
+ import { existsSync as existsSync18, readFileSync as readFileSync11 } from "fs";
5690
5998
  import { join as join22 } from "path";
5691
5999
  var GitBinaryCheck = class {
5692
6000
  id = "git_binary";
@@ -5704,7 +6012,7 @@ var GitRepoCheck = class {
5704
6012
  id = "git_repo";
5705
6013
  title = "Git repository";
5706
6014
  async run(ctx) {
5707
- if (existsSync19(join22(ctx.projectRoot, ".git"))) {
6015
+ if (existsSync18(join22(ctx.projectRoot, ".git"))) {
5708
6016
  return { id: this.id, title: this.title, status: "pass", message: ctx.projectRoot, value: ctx.projectRoot };
5709
6017
  }
5710
6018
  return { id: this.id, title: this.title, status: "fail", message: "Not a git repository", fix: 'Run "git init" or clone a repository' };
@@ -5714,14 +6022,14 @@ var GitHooksCheck = class {
5714
6022
  id = "git_hooks";
5715
6023
  title = "Git hooks";
5716
6024
  async run(ctx) {
5717
- if (!existsSync19(join22(ctx.projectRoot, ".git"))) {
6025
+ if (!existsSync18(join22(ctx.projectRoot, ".git"))) {
5718
6026
  return { id: this.id, title: this.title, status: "pass", message: "Not a git repository (hooks not applicable)" };
5719
6027
  }
5720
6028
  const hooksDir = resolveHooksDir(ctx.projectRoot);
5721
6029
  const hookPath = join22(hooksDir, "post-commit");
5722
6030
  let versions;
5723
6031
  try {
5724
- versions = readCtxoBlockVersions(readFileSync12(hookPath, "utf-8"));
6032
+ versions = readCtxoBlockVersions(readFileSync11(hookPath, "utf-8"));
5725
6033
  } catch {
5726
6034
  versions = [];
5727
6035
  }
@@ -5924,10 +6232,119 @@ var ClaudeGuardHookCheck = class {
5924
6232
  }
5925
6233
  };
5926
6234
 
6235
+ // src/adapters/diagnostics/checks/install-copies-check.ts
6236
+ import { existsSync as existsSync19, readFileSync as readFileSync12, realpathSync as realpathSync2 } from "fs";
6237
+ import { delimiter, join as join23 } from "path";
6238
+ var MCP_FILES = [
6239
+ { file: ".mcp.json", keys: ["mcpServers"] },
6240
+ { file: ".vscode/mcp.json", keys: ["servers"] },
6241
+ { file: ".gemini/settings.json", keys: ["mcpServers"] },
6242
+ { file: ".amazonq/mcp.json", keys: ["mcpServers"] },
6243
+ { file: ".continue/mcpServers/ctxo.json", keys: ["mcpServers"] }
6244
+ ];
6245
+ var SETTINGS_FILES = [".claude/settings.json", ".claude/settings.local.json"];
6246
+ function isRecord2(value) {
6247
+ return typeof value === "object" && value !== null && !Array.isArray(value);
6248
+ }
6249
+ function readJson(path) {
6250
+ try {
6251
+ return JSON.parse(readFileSync12(path, "utf-8"));
6252
+ } catch {
6253
+ return void 0;
6254
+ }
6255
+ }
6256
+ function mcpLaunchers(projectRoot) {
6257
+ const out = [];
6258
+ const npxPins = [];
6259
+ for (const { file, keys } of MCP_FILES) {
6260
+ const json = readJson(join23(projectRoot, file));
6261
+ if (!isRecord2(json)) continue;
6262
+ for (const key of keys) {
6263
+ const servers = json[key];
6264
+ const launcher = isRecord2(servers) ? mcpLauncherOf(servers["ctxo"]) : void 0;
6265
+ if (launcher !== void 0) out.push(launcher);
6266
+ const pin = isRecord2(servers) ? mcpNpxPin(servers["ctxo"]) : void 0;
6267
+ if (pin !== void 0) npxPins.push(pin);
6268
+ }
6269
+ }
6270
+ return { launchers: out, npxPins };
6271
+ }
6272
+ function hookCallsCtxo(node, depth = 0) {
6273
+ if (depth > 8) return false;
6274
+ if (Array.isArray(node)) return node.some((child) => hookCallsCtxo(child, depth + 1));
6275
+ if (!isRecord2(node)) return false;
6276
+ const command = node["command"];
6277
+ if (typeof command === "string" && command.trimStart().startsWith("ctxo ")) return true;
6278
+ return Object.values(node).some((child) => hookCallsCtxo(child, depth + 1));
6279
+ }
6280
+ function anyHookCallsCtxo(projectRoot) {
6281
+ return SETTINGS_FILES.some((file) => {
6282
+ const json = readJson(join23(projectRoot, file));
6283
+ return isRecord2(json) && hookCallsCtxo(json["hooks"]);
6284
+ });
6285
+ }
6286
+ function binaryOnPath(dirs) {
6287
+ const names = process.platform === "win32" ? ["ctxo.cmd", "ctxo.exe"] : ["ctxo"];
6288
+ return dirs.some((dir) => dir !== "" && names.some((name) => existsSync19(join23(dir, name))));
6289
+ }
6290
+ function globalCliVersion(dirs) {
6291
+ const names = process.platform === "win32" ? ["ctxo.cmd", "ctxo.exe"] : ["ctxo"];
6292
+ for (const dir of dirs) {
6293
+ const found = dir === "" ? void 0 : names.find((name) => existsSync19(join23(dir, name)));
6294
+ if (found === void 0) continue;
6295
+ const candidates = [
6296
+ join23(dir, "node_modules", "@ctxo", "cli", "package.json"),
6297
+ // Windows npm prefix, a global bin dir with its own node_modules
6298
+ join23(dir, "..", "lib", "node_modules", "@ctxo", "cli", "package.json")
6299
+ // unix npm prefix/bin
6300
+ ];
6301
+ try {
6302
+ candidates.push(join23(realpathSync2(join23(dir, found)), "..", "..", "package.json"));
6303
+ } catch {
6304
+ }
6305
+ for (const file of candidates) {
6306
+ const json = readJson(file);
6307
+ if (isRecord2(json) && json["name"] === "@ctxo/cli" && typeof json["version"] === "string") return json["version"];
6308
+ }
6309
+ }
6310
+ return void 0;
6311
+ }
6312
+ var InstallCopiesCheck = class {
6313
+ constructor(options = {}) {
6314
+ this.options = options;
6315
+ }
6316
+ options;
6317
+ id = "install_copies";
6318
+ title = "Install copies";
6319
+ async run(ctx) {
6320
+ try {
6321
+ const pathDirs = this.options.pathDirs ?? (process.env["PATH"] ?? "").split(delimiter);
6322
+ const { launchers, npxPins } = mcpLaunchers(ctx.projectRoot);
6323
+ const globalVersion = globalCliVersion(pathDirs);
6324
+ const verdict = assessInstallCopies({
6325
+ launchers,
6326
+ npxPins,
6327
+ ...globalVersion !== void 0 ? { globalCliVersion: globalVersion } : {},
6328
+ hooksCallCtxo: anyHookCallsCtxo(ctx.projectRoot),
6329
+ ctxoOnPath: binaryOnPath(pathDirs),
6330
+ domainInstalled: resolvePackageCandidates(DOMAIN_MODULE_PACKAGE, ctx.projectRoot).length > 0
6331
+ });
6332
+ return { id: this.id, title: this.title, ...verdict };
6333
+ } catch (err) {
6334
+ return {
6335
+ id: this.id,
6336
+ title: this.title,
6337
+ status: "pass",
6338
+ message: `could not be checked: ${err instanceof Error ? err.message : String(err)}`
6339
+ };
6340
+ }
6341
+ }
6342
+ };
6343
+
5927
6344
  // src/adapters/diagnostics/checks/index-check.ts
5928
6345
  import { existsSync as existsSync20 } from "fs";
5929
6346
  import { execFileSync as execFileSync8 } from "child_process";
5930
- import { join as join23 } from "path";
6347
+ import { join as join24 } from "path";
5931
6348
  function pass2(id, title, message, value) {
5932
6349
  return { id, title, status: "pass", message, value };
5933
6350
  }
@@ -5944,7 +6361,7 @@ var IndexDirectoryCheck = class {
5944
6361
  id = "index_directory";
5945
6362
  title = "Index directory";
5946
6363
  async run(ctx) {
5947
- const indexDir = join23(ctx.ctxoRoot, "index");
6364
+ const indexDir = join24(ctx.ctxoRoot, "index");
5948
6365
  if (!existsSync20(indexDir)) {
5949
6366
  return fail(this.id, this.title, "No index directory", 'Run "ctxo index"');
5950
6367
  }
@@ -6039,7 +6456,7 @@ var CoChangesCacheCheck = class {
6039
6456
  id = "co_changes_cache";
6040
6457
  title = "Co-changes cache";
6041
6458
  async run(ctx) {
6042
- const coChangesPath = join23(ctx.ctxoRoot, "index", "co-changes.json");
6459
+ const coChangesPath = join24(ctx.ctxoRoot, "index", "co-changes.json");
6043
6460
  if (existsSync20(coChangesPath)) {
6044
6461
  return pass2(this.id, this.title, "present");
6045
6462
  }
@@ -6065,12 +6482,12 @@ var SchemaVersionCheck = class {
6065
6482
 
6066
6483
  // src/adapters/diagnostics/checks/storage-check.ts
6067
6484
  import { existsSync as existsSync21, readFileSync as readFileSync13 } from "fs";
6068
- import { join as join24 } from "path";
6485
+ import { join as join25 } from "path";
6069
6486
  var SqliteCacheCheck = class {
6070
6487
  id = "sqlite_cache";
6071
6488
  title = "SQLite cache";
6072
6489
  async run(ctx) {
6073
- const dbPath = join24(ctx.ctxoRoot, ".cache", "symbols.db");
6490
+ const dbPath = join25(ctx.ctxoRoot, ".cache", "symbols.db");
6074
6491
  if (!existsSync21(dbPath)) {
6075
6492
  return { id: this.id, title: this.title, status: "warn", message: "SQLite cache missing", fix: 'Run "ctxo sync" to rebuild' };
6076
6493
  }
@@ -6094,12 +6511,12 @@ var SqliteCacheCheck = class {
6094
6511
 
6095
6512
  // src/adapters/diagnostics/checks/config-check.ts
6096
6513
  import { existsSync as existsSync22, readFileSync as readFileSync14 } from "fs";
6097
- import { join as join25 } from "path";
6514
+ import { join as join26 } from "path";
6098
6515
  var ConfigFileCheck = class {
6099
6516
  id = "config_file";
6100
6517
  title = "Config file";
6101
6518
  async run(ctx) {
6102
- const configPath = join25(ctx.ctxoRoot, "config.yaml");
6519
+ const configPath = join26(ctx.ctxoRoot, "config.yaml");
6103
6520
  if (!existsSync22(configPath)) {
6104
6521
  return { id: this.id, title: this.title, status: "warn", message: "No config.yaml (using defaults)" };
6105
6522
  }
@@ -6139,7 +6556,7 @@ var ConfigFileCheck = class {
6139
6556
 
6140
6557
  // src/adapters/diagnostics/checks/disk-check.ts
6141
6558
  import { existsSync as existsSync23, readdirSync as readdirSync5, statSync as statSync3 } from "fs";
6142
- import { join as join26 } from "path";
6559
+ import { join as join27 } from "path";
6143
6560
  var DEFAULT_THRESHOLDS = { warnMb: 100, failMb: 500 };
6144
6561
  var DiskUsageCheck = class {
6145
6562
  id = "disk_usage";
@@ -6171,7 +6588,7 @@ var DiskUsageCheck = class {
6171
6588
  if (entry.isFile()) {
6172
6589
  try {
6173
6590
  const parent = entry.parentPath ?? entry.path ?? dirPath;
6174
- total += statSync3(join26(parent, entry.name)).size;
6591
+ total += statSync3(join27(parent, entry.name)).size;
6175
6592
  } catch {
6176
6593
  }
6177
6594
  }
@@ -6185,8 +6602,8 @@ var DiskUsageCheck = class {
6185
6602
  // src/cli/doctor-fix.ts
6186
6603
  import { execFileSync as execFileSync9 } from "child_process";
6187
6604
  import { appendFileSync, existsSync as existsSync24, mkdirSync as mkdirSync6, rmSync as rmSync2, unlinkSync as unlinkSync2 } from "fs";
6188
- import { createRequire as createRequire9 } from "module";
6189
- import { dirname as dirname9, join as join27 } from "path";
6605
+ import { createRequire as createRequire8 } from "module";
6606
+ import { dirname as dirname8, join as join28 } from "path";
6190
6607
  async function applyFixes(report, options) {
6191
6608
  if (isCI(process.env) && !options.yes && !options.dryRun) {
6192
6609
  return {
@@ -6421,7 +6838,7 @@ async function fixIndex(projectRoot, dryRun) {
6421
6838
  }
6422
6839
  async function pruneOrphans(projectRoot, ctxoRoot, dryRun) {
6423
6840
  if (dryRun) return;
6424
- const indexDir = join27(ctxoRoot, "index");
6841
+ const indexDir = join28(ctxoRoot, "index");
6425
6842
  if (!existsSync24(indexDir)) return;
6426
6843
  const sourceFiles = listGitFiles2(projectRoot);
6427
6844
  if (sourceFiles.size === 0) return;
@@ -6430,7 +6847,7 @@ async function pruneOrphans(projectRoot, ctxoRoot, dryRun) {
6430
6847
  let removed = 0;
6431
6848
  for (const idx of indices) {
6432
6849
  if (sourceFiles.has(idx.file)) continue;
6433
- const jsonPath = join27(indexDir, `${idx.file}.json`);
6850
+ const jsonPath = join28(indexDir, `${idx.file}.json`);
6434
6851
  try {
6435
6852
  if (existsSync24(jsonPath)) {
6436
6853
  unlinkSync2(jsonPath);
@@ -6443,14 +6860,14 @@ async function pruneOrphans(projectRoot, ctxoRoot, dryRun) {
6443
6860
  if (removed === 0) return;
6444
6861
  }
6445
6862
  function pruneEmptyParents(filePath, stopAt) {
6446
- let dir = dirname9(filePath);
6863
+ let dir = dirname8(filePath);
6447
6864
  while (dir !== stopAt && dir.startsWith(stopAt)) {
6448
6865
  try {
6449
6866
  rmSync2(dir, { recursive: false });
6450
6867
  } catch {
6451
6868
  return;
6452
6869
  }
6453
- dir = dirname9(dir);
6870
+ dir = dirname8(dir);
6454
6871
  }
6455
6872
  }
6456
6873
  function listGitFiles2(projectRoot) {
@@ -6468,12 +6885,12 @@ function listGitFiles2(projectRoot) {
6468
6885
  }
6469
6886
  }
6470
6887
  async function fixSqliteCache(projectRoot, ctxoRoot, dryRun) {
6471
- const cacheDir = join27(ctxoRoot, ".cache");
6888
+ const cacheDir = join28(ctxoRoot, ".cache");
6472
6889
  if (dryRun) return;
6473
6890
  if (existsSync24(cacheDir)) rmSync2(cacheDir, { recursive: true, force: true });
6474
6891
  await new SyncCommand(projectRoot).run();
6475
6892
  }
6476
- var doctorRequire = createRequire9(import.meta.url);
6893
+ var doctorRequire = createRequire8(import.meta.url);
6477
6894
  function canResolvePlugin(lang) {
6478
6895
  try {
6479
6896
  doctorRequire.resolve(`${officialPluginFor(lang)}/package.json`);
@@ -6488,7 +6905,7 @@ function isCI(env) {
6488
6905
  function writeLog(ctxoRoot, attempts, halted, reason) {
6489
6906
  try {
6490
6907
  if (!existsSync24(ctxoRoot)) mkdirSync6(ctxoRoot, { recursive: true });
6491
- const path = join27(ctxoRoot, "doctor-fix.log");
6908
+ const path = join28(ctxoRoot, "doctor-fix.log");
6492
6909
  const ts = (/* @__PURE__ */ new Date()).toISOString();
6493
6910
  const lines = [`# ${ts} doctor --fix${halted ? " (halted: " + reason + ")" : ""}`];
6494
6911
  for (const a of attempts) {
@@ -6523,11 +6940,10 @@ var DoctorCommand = class {
6523
6940
  ctxoRoot;
6524
6941
  constructor(projectRoot) {
6525
6942
  this.projectRoot = projectRoot;
6526
- this.ctxoRoot = join28(projectRoot, ".ctxo");
6943
+ this.ctxoRoot = join29(projectRoot, ".ctxo");
6527
6944
  }
6528
6945
  async run(options = {}) {
6529
- const { getVersion: getVersion2 } = await import("./cli-router-DDHH4WQH.js");
6530
- console.error(`ctxo v${getVersion2()} \u2014 health check
6946
+ console.error(`ctxo v${getVersion()} \u2014 health check
6531
6947
  `);
6532
6948
  const checks = [
6533
6949
  new VersionsCheck(),
@@ -6538,6 +6954,7 @@ var DoctorCommand = class {
6538
6954
  new AiRulesCheck(),
6539
6955
  new SkillsCheck(),
6540
6956
  new ClaudeGuardHookCheck(),
6957
+ new InstallCopiesCheck(),
6541
6958
  new IndexDirectoryCheck(),
6542
6959
  new IndexFreshnessCheck(),
6543
6960
  new SqliteCacheCheck(),
@@ -6552,9 +6969,11 @@ var DoctorCommand = class {
6552
6969
  new EdgeCountCheck(),
6553
6970
  new OrphanedFilesCheck(),
6554
6971
  new CoChangesCacheCheck(),
6555
- new SchemaVersionCheck()
6972
+ new SchemaVersionCheck(),
6973
+ // Module checks last, so the core report reads the same with or without a module.
6974
+ ...(options.modules ?? []).flatMap((module) => module.registration.doctorChecks ?? [])
6556
6975
  ];
6557
- const indexDir = join28(this.ctxoRoot, "index");
6976
+ const indexDir = join29(this.ctxoRoot, "index");
6558
6977
  const indices = existsSync25(indexDir) ? new JsonIndexReader(this.ctxoRoot).readAll() : [];
6559
6978
  const checker = new HealthChecker(checks, { quiet: Boolean(options.quiet) });
6560
6979
  const report = await checker.runAll({
@@ -6597,7 +7016,7 @@ var DoctorCommand = class {
6597
7016
 
6598
7017
  // src/cli/visualize-command.ts
6599
7018
  import { readFileSync as readFileSync15, writeFileSync as writeFileSync5, existsSync as existsSync26 } from "fs";
6600
- import { join as join29, basename } from "path";
7019
+ import { join as join30, basename } from "path";
6601
7020
  import { execFile } from "child_process";
6602
7021
 
6603
7022
  // src/cli/graph-loader.ts
@@ -6613,7 +7032,7 @@ function loadGraph(ctxoRoot) {
6613
7032
  var VisualizeCommand = class {
6614
7033
  constructor(projectRoot) {
6615
7034
  this.projectRoot = projectRoot;
6616
- this.ctxoRoot = join29(projectRoot, ".ctxo");
7035
+ this.ctxoRoot = join30(projectRoot, ".ctxo");
6617
7036
  }
6618
7037
  projectRoot;
6619
7038
  ctxoRoot;
@@ -6756,7 +7175,7 @@ var VisualizeCommand = class {
6756
7175
  const templatePath = this.getTemplatePath();
6757
7176
  const template = readFileSync15(templatePath, "utf-8");
6758
7177
  const html = template.replace("/*__CTXO_DATA__*/null", () => JSON.stringify(payload).replace(/</g, "\\u003c"));
6759
- const outputPath = options.output ?? join29(this.ctxoRoot, "visualize.html");
7178
+ const outputPath = options.output ?? join30(this.ctxoRoot, "visualize.html");
6760
7179
  writeFileSync5(outputPath, html, "utf-8");
6761
7180
  console.error(`[ctxo] Wrote visualization to ${outputPath}`);
6762
7181
  console.error(`[ctxo] ${nodes.length} symbols, ${edges.length} edges, ${deadResult.deadSymbols.length} dead`);
@@ -6767,9 +7186,9 @@ var VisualizeCommand = class {
6767
7186
  getTemplatePath() {
6768
7187
  let dir = import.meta.dirname;
6769
7188
  for (let i = 0; i < 10; i++) {
6770
- const candidate = join29(dir, "pages", "visualize-template.html");
7189
+ const candidate = join30(dir, "pages", "visualize-template.html");
6771
7190
  if (existsSync26(candidate)) return candidate;
6772
- const parent = join29(dir, "..");
7191
+ const parent = join30(dir, "..");
6773
7192
  if (parent === dir) break;
6774
7193
  dir = parent;
6775
7194
  }
@@ -6794,11 +7213,11 @@ var VisualizeCommand = class {
6794
7213
 
6795
7214
  // src/cli/update-command.ts
6796
7215
  import { existsSync as existsSync27, readFileSync as readFileSync16 } from "fs";
6797
- import { join as join30 } from "path";
7216
+ import { join as join31 } from "path";
6798
7217
 
6799
7218
  // src/core/update/registry-client.ts
6800
7219
  import { request } from "https";
6801
- var log5 = createLogger("ctxo:update:registry");
7220
+ var log4 = createLogger("ctxo:update:registry");
6802
7221
  var DEFAULT_TIMEOUT_MS = 5e3;
6803
7222
  async function fetchDistTags(packageName, options = {}) {
6804
7223
  const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
@@ -6840,14 +7259,14 @@ function classifyFetchError(name, err) {
6840
7259
  if (code === "ECONNREFUSED" || code === "ECONNRESET" || code === "ENOTFOUND" || code === "EAI_AGAIN") {
6841
7260
  return { name, reason: "network", message };
6842
7261
  }
6843
- log5.error(`unexpected fetch error for ${name}: ${message}`);
7262
+ log4.error(`unexpected fetch error for ${name}: ${message}`);
6844
7263
  return { name, reason: "network", message };
6845
7264
  }
6846
- var defaultHttpsFetcher = (url, timeoutMs) => new Promise((resolve6, reject) => {
7265
+ var defaultHttpsFetcher = (url, timeoutMs) => new Promise((resolve5, reject) => {
6847
7266
  const req = request(url, { method: "GET", headers: { "user-agent": "ctxo-update", accept: "application/json" } }, (res) => {
6848
7267
  const chunks = [];
6849
7268
  res.on("data", (chunk) => chunks.push(chunk));
6850
- res.on("end", () => resolve6({ status: res.statusCode ?? 0, body: Buffer.concat(chunks).toString("utf-8") }));
7269
+ res.on("end", () => resolve5({ status: res.statusCode ?? 0, body: Buffer.concat(chunks).toString("utf-8") }));
6851
7270
  res.on("error", reject);
6852
7271
  });
6853
7272
  req.setTimeout(timeoutMs, () => {
@@ -6948,7 +7367,7 @@ function isSafeVersionSpecifier(version) {
6948
7367
  }
6949
7368
 
6950
7369
  // src/cli/update-command.ts
6951
- var log6 = createLogger("ctxo:update");
7370
+ var log5 = createLogger("ctxo:update");
6952
7371
  var CTXO_CLI_PACKAGE = "@ctxo/cli";
6953
7372
  function formatText(report) {
6954
7373
  const lines = [];
@@ -7112,16 +7531,16 @@ var UpdateCommand = class {
7112
7531
  return this.projectHasCtxoDep() ? "execute" : "print";
7113
7532
  }
7114
7533
  projectHasCtxoDep() {
7115
- const pkg = join30(this.projectRoot, "package.json");
7534
+ const pkg = join31(this.projectRoot, "package.json");
7116
7535
  if (!existsSync27(pkg)) return false;
7117
7536
  try {
7118
7537
  const json = JSON.parse(readFileSync16(pkg, "utf-8"));
7119
7538
  const merged = { ...json.dependencies ?? {}, ...json.devDependencies ?? {} };
7120
7539
  return Object.keys(merged).some(
7121
- (name) => name === CTXO_CLI_PACKAGE || name.startsWith("@ctxo/lang-") || name.startsWith("ctxo-lang-")
7540
+ (name) => name === CTXO_CLI_PACKAGE || name === DOMAIN_MODULE_PACKAGE || name.startsWith("@ctxo/lang-") || name.startsWith("ctxo-lang-")
7122
7541
  );
7123
7542
  } catch (err) {
7124
- log6.error(`failed to read project package.json: ${err.message}`);
7543
+ log5.error(`failed to read project package.json: ${err.message}`);
7125
7544
  return false;
7126
7545
  }
7127
7546
  }
@@ -7140,9 +7559,12 @@ var UpdateCommand = class {
7140
7559
  async function defaultDiscoverInstalled(projectRoot) {
7141
7560
  const out = [{ name: CTXO_CLI_PACKAGE, version: getVersion() }];
7142
7561
  const manifestPath = loadManifestPath(projectRoot);
7143
- if (!manifestPath) return out;
7144
- const { plugins } = await discoverPlugins({ manifestPath });
7145
- for (const p of plugins) out.push({ name: p.specifier, version: p.plugin.version });
7562
+ if (manifestPath) {
7563
+ const { plugins } = await discoverPlugins({ manifestPath });
7564
+ for (const p of plugins) out.push({ name: p.specifier, version: p.plugin.version });
7565
+ }
7566
+ const domain = resolvePackageCandidates(DOMAIN_MODULE_PACKAGE, projectRoot)[0];
7567
+ if (domain !== void 0) out.push({ name: DOMAIN_MODULE_PACKAGE, version: domain.version });
7146
7568
  return out;
7147
7569
  }
7148
7570
  function isLockedCI2(env) {
@@ -7150,11 +7572,11 @@ function isLockedCI2(env) {
7150
7572
  }
7151
7573
 
7152
7574
  // src/cli/blast-radius-command.ts
7153
- import { join as join31 } from "path";
7575
+ import { join as join32 } from "path";
7154
7576
  var BlastRadiusCommand = class {
7155
7577
  ctxoRoot;
7156
7578
  constructor(projectRoot) {
7157
- this.ctxoRoot = join31(projectRoot, ".ctxo");
7579
+ this.ctxoRoot = join32(projectRoot, ".ctxo");
7158
7580
  }
7159
7581
  run(options) {
7160
7582
  const { graph } = loadGraph(this.ctxoRoot);
@@ -7187,12 +7609,12 @@ var BlastRadiusCommand = class {
7187
7609
  };
7188
7610
 
7189
7611
  // src/cli/contexts-command.ts
7190
- import { join as join32 } from "path";
7612
+ import { join as join33 } from "path";
7191
7613
  var NO_VIEW_HINT = "No contexts view. Run ctxo index (without --skip-community).";
7192
7614
  var ContextsCommand = class {
7193
7615
  ctxoRoot;
7194
7616
  constructor(projectRoot) {
7195
- this.ctxoRoot = join32(projectRoot, ".ctxo");
7617
+ this.ctxoRoot = join33(projectRoot, ".ctxo");
7196
7618
  }
7197
7619
  run(options) {
7198
7620
  const view = new ContextsViewReader(this.ctxoRoot).readContextsView();
@@ -7232,7 +7654,7 @@ var ContextsCommand = class {
7232
7654
  };
7233
7655
 
7234
7656
  // src/cli/gate-hook-command.ts
7235
- import { join as join34, relative as relative6, sep as sep3 } from "path";
7657
+ import { join as join35, relative as relative6, sep as sep3 } from "path";
7236
7658
  import { existsSync as existsSync29, readFileSync as readFileSync18 } from "fs";
7237
7659
 
7238
7660
  // src/core/gate/gate-config.ts
@@ -7301,8 +7723,8 @@ function transcriptHasBlastCheck(lines, symbolId) {
7301
7723
 
7302
7724
  // src/adapters/gate/gate-session-store.ts
7303
7725
  import { existsSync as existsSync28, mkdirSync as mkdirSync7, readFileSync as readFileSync17, writeFileSync as writeFileSync6 } from "fs";
7304
- import { join as join33 } from "path";
7305
- var log7 = createLogger("ctxo:gate-store");
7726
+ import { join as join34 } from "path";
7727
+ var log6 = createLogger("ctxo:gate-store");
7306
7728
  var GateSessionStore = class {
7307
7729
  constructor(cacheDir) {
7308
7730
  this.cacheDir = cacheDir;
@@ -7310,7 +7732,7 @@ var GateSessionStore = class {
7310
7732
  cacheDir;
7311
7733
  file(sessionId) {
7312
7734
  const safe = sessionId.replace(/[^a-zA-Z0-9_-]/g, "_");
7313
- return join33(this.cacheDir, "gate", `${safe}.json`);
7735
+ return join34(this.cacheDir, "gate", `${safe}.json`);
7314
7736
  }
7315
7737
  read(sessionId) {
7316
7738
  const f = this.file(sessionId);
@@ -7318,7 +7740,7 @@ var GateSessionStore = class {
7318
7740
  try {
7319
7741
  return new Set(JSON.parse(readFileSync17(f, "utf-8")));
7320
7742
  } catch (err) {
7321
- log7.error(`${err.message}`);
7743
+ log6.error(`${err.message}`);
7322
7744
  return /* @__PURE__ */ new Set();
7323
7745
  }
7324
7746
  }
@@ -7330,10 +7752,10 @@ var GateSessionStore = class {
7330
7752
  const set = this.read(sessionId);
7331
7753
  set.add(symbolId);
7332
7754
  const f = this.file(sessionId);
7333
- mkdirSync7(join33(this.cacheDir, "gate"), { recursive: true });
7755
+ mkdirSync7(join34(this.cacheDir, "gate"), { recursive: true });
7334
7756
  writeFileSync6(f, JSON.stringify([...set]), "utf-8");
7335
7757
  } catch (err) {
7336
- log7.error(`${err.message}`);
7758
+ log6.error(`${err.message}`);
7337
7759
  }
7338
7760
  }
7339
7761
  };
@@ -7364,13 +7786,13 @@ ${list}` : "",
7364
7786
  }
7365
7787
 
7366
7788
  // src/cli/gate-hook-command.ts
7367
- var log8 = createLogger("ctxo:gate-hook");
7789
+ var log7 = createLogger("ctxo:gate-hook");
7368
7790
  var RANK_ALL = 1e5;
7369
7791
  var GateHookCommand = class {
7370
7792
  constructor(projectRoot) {
7371
7793
  this.projectRoot = projectRoot;
7372
- this.ctxoRoot = join34(projectRoot, ".ctxo");
7373
- this.cacheDir = join34(this.ctxoRoot, ".cache");
7794
+ this.ctxoRoot = join35(projectRoot, ".ctxo");
7795
+ this.cacheDir = join35(this.ctxoRoot, ".cache");
7374
7796
  }
7375
7797
  projectRoot;
7376
7798
  ctxoRoot;
@@ -7386,7 +7808,7 @@ var GateHookCommand = class {
7386
7808
  const thresholds = resolveGateThresholds(loaded.config.gate);
7387
7809
  if (!thresholds.enabled) return { block: false };
7388
7810
  const relPath = relative6(this.projectRoot, filePath).split(sep3).join("/");
7389
- const indexFile = join34(this.ctxoRoot, "index", `${relPath}.json`);
7811
+ const indexFile = join35(this.ctxoRoot, "index", `${relPath}.json`);
7390
7812
  if (!existsSync29(indexFile)) return { block: false };
7391
7813
  const fileIndex = new JsonIndexReader(this.ctxoRoot).readSingle(indexFile);
7392
7814
  if (!fileIndex) return { block: false };
@@ -7420,7 +7842,7 @@ var GateHookCommand = class {
7420
7842
  const top = blast.impactedSymbols.filter((s) => s.confidence !== "potential").map((s) => s.symbolId);
7421
7843
  return { block: true, reason: formatBlockReason(name, riskCount, top) };
7422
7844
  } catch (err) {
7423
- log8.error(`${err.message}`);
7845
+ log7.error(`${err.message}`);
7424
7846
  return { block: false };
7425
7847
  }
7426
7848
  }
@@ -7432,7 +7854,7 @@ var GateHookCommand = class {
7432
7854
  if (decision.block) this.emitBlock(decision.reason ?? "ctxo guard");
7433
7855
  }
7434
7856
  } catch (err) {
7435
- log8.error(`${err.message}`);
7857
+ log7.error(`${err.message}`);
7436
7858
  }
7437
7859
  process.exit(0);
7438
7860
  }
@@ -7448,7 +7870,7 @@ var GateHookCommand = class {
7448
7870
  process.exit(0);
7449
7871
  }
7450
7872
  readStdin() {
7451
- return new Promise((resolve6) => {
7873
+ return new Promise((resolve5) => {
7452
7874
  let data = "";
7453
7875
  process.stdin.setEncoding("utf-8");
7454
7876
  process.stdin.on("data", (c) => {
@@ -7456,23 +7878,23 @@ var GateHookCommand = class {
7456
7878
  });
7457
7879
  process.stdin.on("end", () => {
7458
7880
  try {
7459
- resolve6(JSON.parse(data));
7881
+ resolve5(JSON.parse(data));
7460
7882
  } catch {
7461
- resolve6(null);
7883
+ resolve5(null);
7462
7884
  }
7463
7885
  });
7464
- process.stdin.on("error", () => resolve6(null));
7886
+ process.stdin.on("error", () => resolve5(null));
7465
7887
  });
7466
7888
  }
7467
7889
  };
7468
7890
 
7469
7891
  // src/cli/gate-command.ts
7470
- import { join as join35 } from "path";
7892
+ import { join as join36 } from "path";
7471
7893
  var RANK_ALL2 = 1e5;
7472
7894
  var GateCommand = class {
7473
7895
  ctxoRoot;
7474
7896
  constructor(projectRoot) {
7475
- this.ctxoRoot = join35(projectRoot, ".ctxo");
7897
+ this.ctxoRoot = join36(projectRoot, ".ctxo");
7476
7898
  }
7477
7899
  preview(options = {}) {
7478
7900
  const gate = loadConfig(this.ctxoRoot).config.gate;
@@ -7545,43 +7967,50 @@ function readInitPartsFlag(args, flag) {
7545
7967
  }
7546
7968
  return { present, parts };
7547
7969
  }
7548
- function getVersion() {
7549
- let dir = import.meta.dirname;
7550
- for (let i = 0; i < 10; i++) {
7551
- const pkg = join36(dir, "package.json");
7552
- if (existsSync30(pkg)) {
7553
- try {
7554
- const json = JSON.parse(readFileSync19(pkg, "utf-8"));
7555
- return json.version ?? "unknown";
7556
- } catch {
7557
- break;
7558
- }
7970
+ var BOUNDARIES_ERROR = "[ctxo] --boundaries requires by-rung or report";
7971
+ function readBoundariesFlag(args) {
7972
+ let mode;
7973
+ for (let i = 1; i < args.length; i++) {
7974
+ const arg = args[i];
7975
+ let value;
7976
+ if (arg === "--boundaries") {
7977
+ value = args[i + 1];
7978
+ i += 1;
7979
+ } else if (arg.startsWith("--boundaries=")) {
7980
+ value = arg.slice("--boundaries=".length);
7981
+ } else {
7982
+ continue;
7559
7983
  }
7560
- const parent = join36(dir, "..");
7561
- if (parent === dir) break;
7562
- dir = parent;
7984
+ if (value !== "by-rung" && value !== "report") return { error: BOUNDARIES_ERROR };
7985
+ mode = value;
7563
7986
  }
7564
- return "unknown";
7565
- }
7566
- function locateGitRoot(startDir) {
7567
- let dir = startDir;
7568
- for (let i = 0; i < 12; i++) {
7569
- if (existsSync30(join36(dir, ".git"))) return dir;
7570
- const parent = dirname10(dir);
7571
- if (parent === dir) break;
7572
- dir = parent;
7573
- }
7574
- return null;
7987
+ return mode !== void 0 ? { mode } : {};
7575
7988
  }
7576
7989
  var CliRouter = class {
7577
7990
  projectRoot;
7578
- constructor(projectRoot) {
7991
+ cliAnchor;
7992
+ modules;
7993
+ constructor(projectRoot, options = {}) {
7579
7994
  this.projectRoot = locateGitRoot(projectRoot) ?? projectRoot;
7995
+ this.cliAnchor = options.cliAnchor;
7996
+ }
7997
+ /**
7998
+ * The opt-in modules (@ctxo/domain), loaded on first use and only by the routes that need them: the
7999
+ * help, module and unknown commands, `index` and `doctor` (`version` reports them through its own
8000
+ * loader call, which shares this load). Never rejects; a failed load means no module.
8001
+ */
8002
+ loadModules() {
8003
+ this.modules ??= loadModulesFor(this.projectRoot, "cli", { cliAnchor: this.cliAnchor });
8004
+ return this.modules;
7580
8005
  }
7581
8006
  async route(args) {
7582
8007
  const command = args[0];
8008
+ if (command !== void 0 && isKnownModuleCommand(command)) {
8009
+ await this.dispatchModuleCommand(command, args);
8010
+ return;
8011
+ }
7583
8012
  if (!command || args.some((arg) => arg === "--help" || arg === "-h")) {
7584
- this.printHelp();
8013
+ await this.printHelp();
7585
8014
  return;
7586
8015
  }
7587
8016
  if (command === "--version" || command === "-v" || command === "-V") {
@@ -7662,6 +8091,7 @@ var CliRouter = class {
7662
8091
  process.exit(1);
7663
8092
  return;
7664
8093
  }
8094
+ const modules = await this.loadModules();
7665
8095
  await new IndexCommand(this.projectRoot).run({
7666
8096
  files: filesArg.length > 0 ? filesArg : void 0,
7667
8097
  commit: commitArg,
@@ -7671,7 +8101,8 @@ var CliRouter = class {
7671
8101
  maxHistory: maxHistoryArg,
7672
8102
  cochangeCommits: cochangeCommitsArg,
7673
8103
  cochangeSince: cochangeSinceArg,
7674
- installMissing
8104
+ installMissing,
8105
+ modules
7675
8106
  });
7676
8107
  break;
7677
8108
  }
@@ -7695,16 +8126,18 @@ var CliRouter = class {
7695
8126
  const dryRun = args.includes("--dry-run");
7696
8127
  const noInstall = args.includes("--no-install");
7697
8128
  const noGuard = args.includes("--no-guard");
8129
+ const domain = args.includes("--domain");
7698
8130
  const tools = toolsArg ? toolsArg.split(",").map((t) => t.trim()) : void 0;
7699
8131
  const only = readInitPartsFlag(args, "--only");
7700
8132
  const skip = readInitPartsFlag(args, "--skip");
7701
- const partsError = only.error ?? skip.error ?? (only.present && (skip.present || rulesOnly) ? "[ctxo] --only cannot be combined with --skip or --rules" : void 0);
8133
+ const boundaries = readBoundariesFlag(args);
8134
+ const partsError = only.error ?? skip.error ?? (only.present && (skip.present || rulesOnly) ? "[ctxo] --only cannot be combined with --skip or --rules" : void 0) ?? boundaries.error;
7702
8135
  if (partsError) {
7703
8136
  console.error(partsError);
7704
8137
  process.exit(1);
7705
8138
  return;
7706
8139
  }
7707
- await new InitCommand(this.projectRoot).run({
8140
+ await new InitCommand(this.projectRoot, { cliAnchor: this.cliAnchor }).run({
7708
8141
  tools,
7709
8142
  yes: yesArg,
7710
8143
  rulesOnly,
@@ -7712,7 +8145,9 @@ var CliRouter = class {
7712
8145
  noInstall,
7713
8146
  only: only.present ? only.parts : void 0,
7714
8147
  skip: skip.present ? skip.parts : void 0,
7715
- noGuard
8148
+ noGuard,
8149
+ domain,
8150
+ ...boundaries.mode !== void 0 ? { boundaries: boundaries.mode } : {}
7716
8151
  });
7717
8152
  break;
7718
8153
  }
@@ -7730,12 +8165,14 @@ var CliRouter = class {
7730
8165
  const fixArg = args.includes("--fix");
7731
8166
  const dryRunArg = args.includes("--dry-run");
7732
8167
  const yesArg = args.includes("--yes") || args.includes("-y");
8168
+ const modules = await this.loadModules();
7733
8169
  await new DoctorCommand(this.projectRoot).run({
7734
8170
  json: jsonArg,
7735
8171
  quiet: quietArg,
7736
8172
  fix: fixArg,
7737
8173
  dryRun: dryRunArg,
7738
- yes: yesArg
8174
+ yes: yesArg,
8175
+ modules
7739
8176
  });
7740
8177
  break;
7741
8178
  }
@@ -7864,13 +8301,51 @@ var CliRouter = class {
7864
8301
  break;
7865
8302
  }
7866
8303
  default:
8304
+ if (await this.dispatchModuleCommand(command, args)) return;
7867
8305
  console.error(`[ctxo] Unknown command: "${command}". Run "ctxo --help" for usage.`);
7868
8306
  process.exit(1);
7869
8307
  return;
7870
8308
  }
7871
8309
  }
7872
- printHelp() {
8310
+ /**
8311
+ * Runs a module command and sets `process.exitCode` from it. A known module command name without its
8312
+ * module prints the install hint and exits 1 (the hook command exits 0 silently, as on the fast route).
8313
+ * Resolves to false when no module registers `command` and the CLI does not know the name, so the caller
8314
+ * reports an unknown command.
8315
+ */
8316
+ async dispatchModuleCommand(command, args) {
8317
+ if (FAST_ROUTE_COMMANDS.has(command)) {
8318
+ process.exitCode = await runFastRoute(args, this.projectRoot, { cliAnchor: this.cliAnchor });
8319
+ return true;
8320
+ }
8321
+ let code;
8322
+ try {
8323
+ code = await runModuleCommand(command, args.slice(1), {
8324
+ projectRoot: this.projectRoot,
8325
+ modules: await this.loadModules()
8326
+ });
8327
+ } catch (err) {
8328
+ console.error(`[ctxo] "ctxo ${command}" failed: ${err.message}`);
8329
+ process.exitCode = 1;
8330
+ return true;
8331
+ }
8332
+ if (code !== void 0) {
8333
+ process.exitCode = typeof code === "number" && Number.isInteger(code) ? code : 1;
8334
+ return true;
8335
+ }
8336
+ if (isKnownModuleCommand(command)) {
8337
+ console.error(`[ctxo] "ctxo ${command}" needs @ctxo/domain. Install it with "ctxo install domain".`);
8338
+ process.exitCode = 1;
8339
+ return true;
8340
+ }
8341
+ return false;
8342
+ }
8343
+ async printHelp() {
7873
8344
  const v = getVersion();
8345
+ const moduleLines = moduleHelpLines(await this.loadModules());
8346
+ const moduleBlock = moduleLines.length > 0 ? `
8347
+
8348
+ ${moduleLines.join("\n")}` : "";
7874
8349
  console.error(`
7875
8350
  ctxo v${v} \u2014 MCP server for dependency-aware codebase context
7876
8351
 
@@ -7891,11 +8366,13 @@ Usage:
7891
8366
  ctxo status Show index manifest
7892
8367
  ctxo init Interactive setup (rules, skills, guard, MCP, git hooks, config); the post-commit hook runs ctxo index --commit HEAD
7893
8368
  ctxo init --tools a,b -y Non-interactive (claude-code,cursor,...); -y without --tools selects every tool
7894
- ctxo init --only <parts> Install only these parts: rules,skills,guard,mcp,git-hooks,config
8369
+ ctxo init --only <parts> Install only these parts: rules,skills,guard,mcp,git-hooks,config,domain
7895
8370
  ctxo init --skip <parts> Install everything except these parts
7896
8371
  ctxo init --no-guard Does not install the Claude Code safe-edit guard
7897
8372
  ctxo init --rules Deprecated: same as --skip git-hooks,guard
7898
8373
  ctxo init --dry-run List every file init would create or change
8374
+ ctxo init --domain Also set up the domain layer: registry, fold guard, skill (@ctxo/domain)
8375
+ ctxo init --boundaries <by-rung|report> Boundary mode for --domain, without the prompt
7899
8376
  ctxo stats Show usage statistics (--json, --days N, --clear)
7900
8377
  ctxo doctor Health check all subsystems (--json, --quiet)
7901
8378
  ctxo doctor --fix Apply fixes in dependency order: config, git hooks, skills, the safe-edit guard, rules blocks, plugins, index (--dry-run, --yes)
@@ -7908,6 +8385,7 @@ Usage:
7908
8385
  ctxo install ts --global Install globally
7909
8386
  ctxo install py --dry-run Show command without running
7910
8387
  ctxo install sql SQL plugin: .sql definitions, data edges from C#, TypeScript, Java and Go call sites
8388
+ ctxo install domain Install @ctxo/domain at this CLI's version (globally without package.json)
7911
8389
  ctxo update Check + apply updates for ctxo + plugins
7912
8390
  ctxo update --check Check only, no install
7913
8391
  ctxo update --print Print install command, never execute
@@ -7925,12 +8403,10 @@ Usage:
7925
8403
  ctxo --version --json Machine-readable version payload
7926
8404
  ctxo version Subcommand \u2014 verbose by default
7927
8405
  ctxo --help Show this help message (also ctxo <command> --help; nothing is run)
7928
- `.trim());
8406
+ `.trim() + moduleBlock);
7929
8407
  }
7930
8408
  };
7931
8409
  export {
7932
- CliRouter,
7933
- getVersion,
7934
- locateGitRoot
8410
+ CliRouter
7935
8411
  };
7936
- //# sourceMappingURL=cli-router-DDHH4WQH.js.map
8412
+ //# sourceMappingURL=cli-router-W56INOQ3.js.map