@ctxo/cli 0.9.2 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  ArchitecturalOverlay,
4
+ BlastRadiusCalculator,
4
5
  BoundaryViolationDetector,
5
6
  CommunitySnapshotWriter,
6
7
  DeadCodeDetector,
@@ -18,7 +19,7 @@ import {
18
19
  loadConfig,
19
20
  makeGlobMatcher,
20
21
  watchSnapshotMinFileChanges
21
- } from "./chunk-HXEPS2XY.js";
22
+ } from "./chunk-I6HLEK4G.js";
22
23
  import {
23
24
  atomicWrite,
24
25
  createLogger
@@ -31,8 +32,8 @@ import {
31
32
  } from "./chunk-PSKDDLSF.js";
32
33
 
33
34
  // src/cli/cli-router.ts
34
- import { readFileSync as readFileSync15, existsSync as existsSync23 } from "fs";
35
- import { dirname as dirname8, join as join25 } from "path";
35
+ import { readFileSync as readFileSync17, existsSync as existsSync25 } from "fs";
36
+ import { dirname as dirname8, join as join29 } from "path";
36
37
 
37
38
  // src/cli/index-command.ts
38
39
  import { execFileSync as execFileSync2 } from "child_process";
@@ -494,6 +495,25 @@ function detectFromLockfileWalkUp(startDir) {
494
495
  }
495
496
  return null;
496
497
  }
498
+ function readWorkspaceLinks(projectRoot) {
499
+ const links = /* @__PURE__ */ new Set();
500
+ const pkgPath = join3(projectRoot, "package.json");
501
+ if (!existsSync4(pkgPath)) return links;
502
+ try {
503
+ const pkg = JSON.parse(readFileSync3(pkgPath, "utf-8"));
504
+ for (const group of [pkg.dependencies, pkg.devDependencies, pkg.peerDependencies, pkg.optionalDependencies]) {
505
+ if (!group) continue;
506
+ for (const [name, spec] of Object.entries(group)) {
507
+ if (typeof spec === "string" && spec.startsWith("workspace:")) {
508
+ links.add(name);
509
+ }
510
+ }
511
+ }
512
+ return links;
513
+ } catch {
514
+ return links;
515
+ }
516
+ }
497
517
  function isWorkspaceRoot(projectRoot) {
498
518
  if (existsSync4(join3(projectRoot, "pnpm-workspace.yaml"))) return true;
499
519
  const pkgPath = join3(projectRoot, "package.json");
@@ -1753,6 +1773,34 @@ import { createRequire as createRequire4 } from "module";
1753
1773
  // src/cli/ai-rules.ts
1754
1774
  import { existsSync as existsSync11, readFileSync as readFileSync7, writeFileSync as writeFileSync3, mkdirSync as mkdirSync4 } from "fs";
1755
1775
  import { join as join12, dirname as dirname6 } from "path";
1776
+ var CTXO_HOOK_COMMAND = "ctxo gate-hook";
1777
+ function ensureClaudeHook(projectRoot) {
1778
+ const filePath = join12(projectRoot, ".claude", "settings.json");
1779
+ const dir = dirname6(filePath);
1780
+ if (!existsSync11(dir)) mkdirSync4(dir, { recursive: true });
1781
+ const ctxoEntry = { matcher: "Edit", hooks: [{ type: "command", command: CTXO_HOOK_COMMAND }] };
1782
+ if (!existsSync11(filePath)) {
1783
+ writeFileSync3(filePath, JSON.stringify({ hooks: { PreToolUse: [ctxoEntry] } }, null, 2) + "\n", "utf-8");
1784
+ return { file: ".claude/settings.json", action: "created" };
1785
+ }
1786
+ let existing;
1787
+ try {
1788
+ existing = JSON.parse(readFileSync7(filePath, "utf-8"));
1789
+ } catch {
1790
+ writeFileSync3(filePath, JSON.stringify({ hooks: { PreToolUse: [ctxoEntry] } }, null, 2) + "\n", "utf-8");
1791
+ return { file: ".claude/settings.json", action: "updated" };
1792
+ }
1793
+ const hooks = existing.hooks ?? {};
1794
+ const pre = hooks.PreToolUse ?? [];
1795
+ if (pre.some((e) => e.hooks?.some((h) => h.command === CTXO_HOOK_COMMAND))) {
1796
+ return { file: ".claude/settings.json", action: "skipped" };
1797
+ }
1798
+ pre.push(ctxoEntry);
1799
+ hooks.PreToolUse = pre;
1800
+ existing.hooks = hooks;
1801
+ writeFileSync3(filePath, JSON.stringify(existing, null, 2) + "\n", "utf-8");
1802
+ return { file: ".claude/settings.json", action: "updated" };
1803
+ }
1756
1804
  var PLATFORMS = [
1757
1805
  { id: "claude-code", name: "Claude Code", file: "CLAUDE.md", mode: "append", detectPaths: ["CLAUDE.md", ".claude"], starred: true },
1758
1806
  { id: "cursor", name: "Cursor", file: ".cursor/rules/ctxo.mdc", mode: "create", detectPaths: [".cursor", ".cursorrules"], starred: false },
@@ -1859,6 +1907,37 @@ ${content}${SECTION_END}
1859
1907
  function escapeRegExp(s) {
1860
1908
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1861
1909
  }
1910
+ var SKILLS = ["ctxo-understand", "ctxo-safe-edit", "ctxo-review-pr"];
1911
+ var SKILL_TARGET_DIR = {
1912
+ "claude-code": (root, skill) => join12(root, ".claude", "skills", skill, "SKILL.md"),
1913
+ cursor: (root, skill) => join12(root, ".cursor", "rules", `${skill}.mdc`)
1914
+ };
1915
+ function skillsSourceDir() {
1916
+ let dir = import.meta.dirname;
1917
+ for (let i = 0; i < 10; i++) {
1918
+ const candidate = join12(dir, "skills");
1919
+ if (existsSync11(join12(candidate, "ctxo-safe-edit", "SKILL.md"))) return candidate;
1920
+ const parent = join12(dir, "..");
1921
+ if (parent === dir) break;
1922
+ dir = parent;
1923
+ }
1924
+ throw new Error("Could not locate ctxo skills/ directory.");
1925
+ }
1926
+ function installSkills(projectRoot, platformId) {
1927
+ const target = SKILL_TARGET_DIR[platformId];
1928
+ if (!target) return [];
1929
+ const src = skillsSourceDir();
1930
+ const out = [];
1931
+ for (const skill of SKILLS) {
1932
+ const from = join12(src, skill, "SKILL.md");
1933
+ const to = target(projectRoot, skill);
1934
+ mkdirSync4(dirname6(to), { recursive: true });
1935
+ const existed = existsSync11(to);
1936
+ writeFileSync3(to, readFileSync7(from, "utf-8"), "utf-8");
1937
+ out.push({ file: to, action: existed ? "updated" : "created" });
1938
+ }
1939
+ return out;
1940
+ }
1862
1941
  var GITIGNORE_ENTRY = ".ctxo/.cache/";
1863
1942
  function ensureGitignore(projectRoot) {
1864
1943
  const filePath = join12(projectRoot, ".gitignore");
@@ -1897,6 +1976,10 @@ index:
1897
1976
  ignoreProjects: []
1898
1977
  # - "packages/experimental-*"
1899
1978
  # - "examples/*"
1979
+
1980
+ gate:
1981
+ enabled: true
1982
+ sensitivity: balanced # strict | balanced | lenient
1900
1983
  `;
1901
1984
  function ensureConfig(projectRoot) {
1902
1985
  const filePath = join12(projectRoot, ".ctxo", "config.yaml");
@@ -2190,6 +2273,18 @@ var InitCommand = class {
2190
2273
  }
2191
2274
  console.error("");
2192
2275
  }
2276
+ let installGuard = false;
2277
+ if (!options.rulesOnly && selectedTools.includes("claude-code")) {
2278
+ const guard = await clack.confirm({
2279
+ message: pc.cyan("Install the safe-edit guard? Blocks edits to high-impact symbols until blast radius is checked."),
2280
+ initialValue: true
2281
+ });
2282
+ if (clack.isCancel(guard)) {
2283
+ clack.cancel("Setup cancelled.");
2284
+ process.exit(0);
2285
+ }
2286
+ installGuard = guard;
2287
+ }
2193
2288
  const s = clack.spinner();
2194
2289
  s.start(pc.dim("Configuring..."));
2195
2290
  const results = [];
@@ -2207,6 +2302,9 @@ var InitCommand = class {
2207
2302
  for (const toolId of selectedTools) {
2208
2303
  const result = installRules(this.projectRoot, toolId);
2209
2304
  results.push(`${pc.green("\u2713")} ${pc.bold(result.file)} ${pc.dim(result.action)}`);
2305
+ for (const sk of installSkills(this.projectRoot, toolId)) {
2306
+ results.push(`${pc.green("\u2713")} ${pc.bold(sk.file)} ${pc.dim(sk.action + " \u2014 skill")}`);
2307
+ }
2210
2308
  }
2211
2309
  const mcpTargets = getMcpConfigTargets(selectedTools);
2212
2310
  for (const target of mcpTargets) {
@@ -2219,6 +2317,10 @@ var InitCommand = class {
2219
2317
  this.installHooks();
2220
2318
  results.push(`${pc.green("\u2713")} ${pc.bold("post-commit, post-merge")} ${pc.dim("hooks installed")}`);
2221
2319
  }
2320
+ if (installGuard) {
2321
+ const r = ensureClaudeHook(this.projectRoot);
2322
+ if (r.action !== "skipped") results.push(`${pc.green("\u2713")} ${pc.bold(r.file)} ${pc.dim(r.action + " \u2014 safe-edit guard")}`);
2323
+ }
2222
2324
  if (pluginsToInstall.length > 0) {
2223
2325
  s.stop(pc.dim("Installing language plugins..."));
2224
2326
  const installer = new InstallCommand(this.projectRoot);
@@ -2271,6 +2373,9 @@ var InitCommand = class {
2271
2373
  for (const toolId of toolIds) {
2272
2374
  const result = installRules(this.projectRoot, toolId);
2273
2375
  console.error(`[ctxo] \u2713 ${result.file} \u2014 ${result.action}`);
2376
+ for (const sk of installSkills(this.projectRoot, toolId)) {
2377
+ console.error(`[ctxo] \u2713 ${sk.file} \u2014 ${sk.action} (skill)`);
2378
+ }
2274
2379
  }
2275
2380
  const mcpTargets = getMcpConfigTargets(toolIds);
2276
2381
  for (const target of mcpTargets) {
@@ -2283,6 +2388,10 @@ var InitCommand = class {
2283
2388
  this.installHooks();
2284
2389
  console.error("[ctxo] \u2713 Git hooks installed");
2285
2390
  }
2391
+ if (!options.rulesOnly && toolIds.includes("claude-code")) {
2392
+ const r = ensureClaudeHook(this.projectRoot);
2393
+ if (r.action !== "skipped") console.error(`[ctxo] \u2713 ${r.file} \u2014 ${r.action} (safe-edit guard)`);
2394
+ }
2286
2395
  if (!options.rulesOnly && !options.noInstall) {
2287
2396
  const detection = detectLanguages(this.projectRoot);
2288
2397
  const needed = decideNeededLanguages(detection);
@@ -2320,6 +2429,10 @@ var InitCommand = class {
2320
2429
  console.error(" .git/hooks/post-commit (git hook)");
2321
2430
  console.error(" .git/hooks/post-merge (git hook)");
2322
2431
  }
2432
+ if (!options.rulesOnly && toolIds.includes("claude-code")) {
2433
+ console.error(" .claude/settings.json (safe-edit guard hook)");
2434
+ console.error(" .claude/skills/ctxo-*/SKILL.md (skills)");
2435
+ }
2323
2436
  }
2324
2437
  /* ---------------------------------------------------------------- */
2325
2438
  /* Helpers */
@@ -2648,8 +2761,8 @@ var StatsCommand = class {
2648
2761
  if (options.clear) {
2649
2762
  recorder.clear();
2650
2763
  const data = db.export();
2651
- const { writeFileSync: writeFileSync6 } = await import("fs");
2652
- writeFileSync6(dbPath, Buffer.from(data));
2764
+ const { writeFileSync: writeFileSync7 } = await import("fs");
2765
+ writeFileSync7(dbPath, Buffer.from(data));
2653
2766
  console.error("[ctxo] Session data cleared.");
2654
2767
  return;
2655
2768
  }
@@ -3632,7 +3745,7 @@ var DoctorCommand = class {
3632
3745
  this.ctxoRoot = join22(projectRoot, ".ctxo");
3633
3746
  }
3634
3747
  async run(options = {}) {
3635
- const { getVersion: getVersion2 } = await import("./cli-router-IK3NJEKJ.js");
3748
+ const { getVersion: getVersion2 } = await import("./cli-router-45W4IRYE.js");
3636
3749
  console.error(`ctxo v${getVersion2()} \u2014 health check
3637
3750
  `);
3638
3751
  const checks = [
@@ -3699,6 +3812,17 @@ var DoctorCommand = class {
3699
3812
  import { readFileSync as readFileSync13, writeFileSync as writeFileSync5, existsSync as existsSync21 } from "fs";
3700
3813
  import { join as join23, basename } from "path";
3701
3814
  import { execFile } from "child_process";
3815
+
3816
+ // src/cli/graph-loader.ts
3817
+ function loadGraph(ctxoRoot) {
3818
+ const indices = new JsonIndexReader(ctxoRoot).readAll();
3819
+ const graph = new SymbolGraph();
3820
+ for (const fi of indices) for (const sym of fi.symbols) graph.addNode(sym);
3821
+ for (const fi of indices) for (const edge of fi.edges) graph.addEdge(edge);
3822
+ return { graph, indices };
3823
+ }
3824
+
3825
+ // src/cli/visualize-command.ts
3702
3826
  var VisualizeCommand = class {
3703
3827
  constructor(projectRoot) {
3704
3828
  this.projectRoot = projectRoot;
@@ -3707,25 +3831,13 @@ var VisualizeCommand = class {
3707
3831
  projectRoot;
3708
3832
  ctxoRoot;
3709
3833
  async run(options = {}) {
3710
- const reader = new JsonIndexReader(this.ctxoRoot);
3711
- const indices = reader.readAll();
3834
+ const { graph, indices } = loadGraph(this.ctxoRoot);
3712
3835
  if (indices.length === 0) {
3713
3836
  console.error('[ctxo] No index found. Run "ctxo index" first.');
3714
3837
  process.exit(1);
3715
3838
  return;
3716
3839
  }
3717
3840
  console.error(`[ctxo] Building graph from ${indices.length} files...`);
3718
- const graph = new SymbolGraph();
3719
- for (const fileIndex of indices) {
3720
- for (const sym of fileIndex.symbols) {
3721
- graph.addNode(sym);
3722
- }
3723
- }
3724
- for (const fileIndex of indices) {
3725
- for (const edge of fileIndex.edges) {
3726
- graph.addEdge(edge);
3727
- }
3728
- }
3729
3841
  console.error(`[ctxo] Graph: ${graph.nodeCount} symbols, ${graph.edgeCount} edges`);
3730
3842
  const pageRank = new PageRankCalculator();
3731
3843
  const prResult = pageRank.calculate(graph, { limit: 1e5 });
@@ -4038,6 +4150,11 @@ function computePackageStates(installed, results) {
4038
4150
  function selectInstallTargets(states) {
4039
4151
  return states.filter((s) => s.status === "update" && typeof s.latest === "string").filter((s) => isSafeVersionSpecifier(s.latest)).map((s) => ({ name: s.name, version: s.latest }));
4040
4152
  }
4153
+ function markWorkspaceLinks(states, workspaceLinkNames) {
4154
+ return states.map(
4155
+ (s) => workspaceLinkNames.has(s.name) ? { ...s, status: "workspace", latest: null } : s
4156
+ );
4157
+ }
4041
4158
  var SAFE_SPECIFIER_RE = /^[0-9A-Za-z.+-]+$/;
4042
4159
  function isSafeVersionSpecifier(version) {
4043
4160
  return SAFE_SPECIFIER_RE.test(version);
@@ -4056,17 +4173,20 @@ function formatText(report) {
4056
4173
  lines.push(`All ${total} package${total === 1 ? "" : "s"} are up to date.`);
4057
4174
  return lines.join("\n");
4058
4175
  }
4176
+ const latestCell = (p) => {
4177
+ if (p.status === "workspace") return "(local)";
4178
+ return p.latest ?? "(not found)";
4179
+ };
4059
4180
  const nameCol = Math.max(7, ...report.packages.map((p) => p.name.length));
4060
4181
  const curCol = Math.max(7, ...report.packages.map((p) => p.current.length));
4061
- const latestCol = Math.max(15, ...report.packages.map((p) => (p.latest ?? "(not found)").length));
4182
+ const latestCol = Math.max(15, ...report.packages.map((p) => latestCell(p).length));
4062
4183
  lines.push(
4063
4184
  `${"PACKAGE".padEnd(nameCol)} ${"CURRENT".padEnd(curCol)} ${`LATEST (${report.channel})`.padEnd(latestCol)} STATUS`
4064
4185
  );
4065
4186
  for (const pkg of report.packages) {
4066
- const latestText = pkg.latest ?? "(not found)";
4067
4187
  const statusText = renderStatus(pkg, report.channel);
4068
4188
  lines.push(
4069
- `${pkg.name.padEnd(nameCol)} ${pkg.current.padEnd(curCol)} ${latestText.padEnd(latestCol)} ${statusText}`
4189
+ `${pkg.name.padEnd(nameCol)} ${pkg.current.padEnd(curCol)} ${latestCell(pkg).padEnd(latestCol)} ${statusText}`
4070
4190
  );
4071
4191
  }
4072
4192
  if (report.plan && report.strategy === "execute") {
@@ -4088,6 +4208,8 @@ function renderStatus(pkg, reportChannel) {
4088
4208
  return "up to date";
4089
4209
  case "ahead":
4090
4210
  return "ahead of registry";
4211
+ case "workspace":
4212
+ return "workspace link";
4091
4213
  case "unknown":
4092
4214
  return pkg.reason === "registry-404" ? "skipped" : `error${pkg.reason ? ` (${pkg.reason})` : ""}`;
4093
4215
  case "update":
@@ -4138,7 +4260,8 @@ var UpdateCommand = class {
4138
4260
  this.deps.setExitCode?.(1);
4139
4261
  return;
4140
4262
  }
4141
- const states = computePackageStates(installed, results);
4263
+ const rawStates = computePackageStates(installed, results);
4264
+ const states = markWorkspaceLinks(rawStates, readWorkspaceLinks(this.projectRoot));
4142
4265
  const targets = selectInstallTargets(states);
4143
4266
  const cliVersion = getVersion();
4144
4267
  const channel = detectChannel(cliVersion);
@@ -4239,20 +4362,326 @@ function isLockedCI2(env) {
4239
4362
  return env["CI"] === "true" || env["CI"] === "1";
4240
4363
  }
4241
4364
 
4365
+ // src/cli/blast-radius-command.ts
4366
+ import { join as join25 } from "path";
4367
+ var BlastRadiusCommand = class {
4368
+ ctxoRoot;
4369
+ constructor(projectRoot) {
4370
+ this.ctxoRoot = join25(projectRoot, ".ctxo");
4371
+ }
4372
+ run(options) {
4373
+ const { graph } = loadGraph(this.ctxoRoot);
4374
+ if (!graph.hasNode(options.symbolId)) {
4375
+ process.stdout.write(JSON.stringify({ found: false, symbolId: options.symbolId }) + "\n");
4376
+ return;
4377
+ }
4378
+ const result = new BlastRadiusCalculator().calculate(graph, options.symbolId);
4379
+ const payload = {
4380
+ found: true,
4381
+ symbolId: options.symbolId,
4382
+ directDependentsCount: result.directDependentsCount,
4383
+ confirmedCount: result.confirmedCount,
4384
+ likelyCount: result.likelyCount,
4385
+ potentialCount: result.potentialCount,
4386
+ overallRiskScore: result.overallRiskScore,
4387
+ impactedSymbols: result.impactedSymbols.map((s) => ({
4388
+ symbolId: s.symbolId,
4389
+ confidence: s.confidence,
4390
+ depth: s.depth
4391
+ }))
4392
+ };
4393
+ process.stdout.write(JSON.stringify(payload) + "\n");
4394
+ }
4395
+ };
4396
+
4397
+ // src/cli/gate-hook-command.ts
4398
+ import { join as join27, relative as relative3, sep as sep2 } from "path";
4399
+ import { existsSync as existsSync24, readFileSync as readFileSync16 } from "fs";
4400
+
4401
+ // src/core/gate/gate-config.ts
4402
+ var DIAL = {
4403
+ strict: { percentile: 30, minDependents: 2 },
4404
+ balanced: { percentile: 15, minDependents: 3 },
4405
+ lenient: { percentile: 5, minDependents: 5 }
4406
+ };
4407
+ function resolveGateThresholds(gate) {
4408
+ const dial = DIAL[gate?.sensitivity ?? "balanced"];
4409
+ return {
4410
+ enabled: gate?.enabled ?? true,
4411
+ percentile: gate?.percentile ?? dial.percentile,
4412
+ minDependents: gate?.minDependents ?? dial.minDependents
4413
+ };
4414
+ }
4415
+
4416
+ // src/core/gate/symbol-locator.ts
4417
+ function lineOfSnippet(source, snippet) {
4418
+ const idx = source.indexOf(snippet);
4419
+ if (idx === -1) return null;
4420
+ let line = 0;
4421
+ for (let i = 0; i < idx; i++) {
4422
+ if (source.codePointAt(i) === 10) line++;
4423
+ }
4424
+ return line;
4425
+ }
4426
+ function locateSymbolAtLine(fileIndex, line) {
4427
+ let best = null;
4428
+ for (const s of fileIndex.symbols) {
4429
+ if (line >= s.startLine && line <= s.endLine) {
4430
+ const span = s.endLine - s.startLine;
4431
+ if (best === null || span < best.span) best = { id: s.symbolId, span };
4432
+ }
4433
+ }
4434
+ return best?.id ?? null;
4435
+ }
4436
+
4437
+ // src/core/gate/transcript-scan.ts
4438
+ function fileAndName(symbolId) {
4439
+ const [file, name] = symbolId.split("::");
4440
+ return `${file}::${name}`;
4441
+ }
4442
+ function transcriptHasBlastCheck(lines, symbolId) {
4443
+ const target = fileAndName(symbolId);
4444
+ for (const line of lines) {
4445
+ if (!line.includes("get_blast_radius")) continue;
4446
+ let entry;
4447
+ try {
4448
+ entry = JSON.parse(line);
4449
+ } catch {
4450
+ continue;
4451
+ }
4452
+ const content = entry?.message?.content;
4453
+ if (!Array.isArray(content)) continue;
4454
+ for (const block of content) {
4455
+ const b = block;
4456
+ if (b?.type === "tool_use" && typeof b.name === "string" && b.name.includes("get_blast_radius")) {
4457
+ const id = b.input?.symbolId;
4458
+ if (typeof id === "string" && fileAndName(id) === target) return true;
4459
+ }
4460
+ }
4461
+ }
4462
+ return false;
4463
+ }
4464
+
4465
+ // src/adapters/gate/gate-session-store.ts
4466
+ import { existsSync as existsSync23, mkdirSync as mkdirSync7, readFileSync as readFileSync15, writeFileSync as writeFileSync6 } from "fs";
4467
+ import { join as join26 } from "path";
4468
+ var log7 = createLogger("ctxo:gate-store");
4469
+ var GateSessionStore = class {
4470
+ constructor(cacheDir) {
4471
+ this.cacheDir = cacheDir;
4472
+ }
4473
+ cacheDir;
4474
+ file(sessionId) {
4475
+ const safe = sessionId.replace(/[^a-zA-Z0-9_-]/g, "_");
4476
+ return join26(this.cacheDir, "gate", `${safe}.json`);
4477
+ }
4478
+ read(sessionId) {
4479
+ const f = this.file(sessionId);
4480
+ if (!existsSync23(f)) return /* @__PURE__ */ new Set();
4481
+ try {
4482
+ return new Set(JSON.parse(readFileSync15(f, "utf-8")));
4483
+ } catch (err) {
4484
+ log7.error(`${err.message}`);
4485
+ return /* @__PURE__ */ new Set();
4486
+ }
4487
+ }
4488
+ hasWarned(sessionId, symbolId) {
4489
+ return this.read(sessionId).has(symbolId);
4490
+ }
4491
+ recordWarned(sessionId, symbolId) {
4492
+ try {
4493
+ const set = this.read(sessionId);
4494
+ set.add(symbolId);
4495
+ const f = this.file(sessionId);
4496
+ mkdirSync7(join26(this.cacheDir, "gate"), { recursive: true });
4497
+ writeFileSync6(f, JSON.stringify([...set]), "utf-8");
4498
+ } catch (err) {
4499
+ log7.error(`${err.message}`);
4500
+ }
4501
+ }
4502
+ };
4503
+
4504
+ // src/core/gate/percentile.ts
4505
+ function percentileCutoff(scores, topPercent) {
4506
+ if (scores.length === 0) return Infinity;
4507
+ const sorted = [...scores].sort((a, b) => a - b);
4508
+ const rank = Math.ceil((100 - topPercent) / 100 * sorted.length);
4509
+ const idx = Math.min(Math.max(rank, 0), sorted.length - 1);
4510
+ return sorted[idx];
4511
+ }
4512
+
4513
+ // src/core/gate/gate-decision.ts
4514
+ var MAX_TOP_DEPENDENTS = 8;
4515
+ function shouldBlock(input, t) {
4516
+ if (!t.enabled) return false;
4517
+ return input.riskCount >= t.minDependents && input.importanceScore >= input.importanceCutoff;
4518
+ }
4519
+ function formatBlockReason(symbolName, riskCount, topDependents) {
4520
+ const list = topDependents.slice(0, MAX_TOP_DEPENDENTS).map((d) => ` - ${d}`).join("\n");
4521
+ return [
4522
+ `\u26A0 ctxo guard: "${symbolName}" is a high-impact symbol - ${riskCount} confirmed/likely dependents may break.`,
4523
+ topDependents.length > 0 ? `Top dependents:
4524
+ ${list}` : "",
4525
+ `Call get_blast_radius / get_why_context before editing. This guard fires once per symbol per session - re-issue the edit to proceed.`
4526
+ ].filter(Boolean).join("\n");
4527
+ }
4528
+
4529
+ // src/cli/gate-hook-command.ts
4530
+ var log8 = createLogger("ctxo:gate-hook");
4531
+ var RANK_ALL = 1e5;
4532
+ var GateHookCommand = class {
4533
+ constructor(projectRoot) {
4534
+ this.projectRoot = projectRoot;
4535
+ this.ctxoRoot = join27(projectRoot, ".ctxo");
4536
+ this.cacheDir = join27(this.ctxoRoot, ".cache");
4537
+ }
4538
+ projectRoot;
4539
+ ctxoRoot;
4540
+ cacheDir;
4541
+ /** Pure-ish decision: never throws (fail-open). */
4542
+ evaluate(payload) {
4543
+ try {
4544
+ if (payload.tool_name !== "Edit") return { block: false };
4545
+ const filePath = payload.tool_input?.file_path;
4546
+ const oldString = payload.tool_input?.old_string;
4547
+ if (!filePath || !oldString) return { block: false };
4548
+ const loaded = loadConfig(this.ctxoRoot);
4549
+ const thresholds = resolveGateThresholds(loaded.config.gate);
4550
+ if (!thresholds.enabled) return { block: false };
4551
+ const relPath = relative3(this.projectRoot, filePath).split(sep2).join("/");
4552
+ const indexFile = join27(this.ctxoRoot, "index", `${relPath}.json`);
4553
+ if (!existsSync24(indexFile)) return { block: false };
4554
+ const fileIndex = new JsonIndexReader(this.ctxoRoot).readSingle(indexFile);
4555
+ if (!fileIndex) return { block: false };
4556
+ if (!existsSync24(filePath)) return { block: false };
4557
+ const source = readFileSync16(filePath, "utf-8");
4558
+ const line = lineOfSnippet(source, oldString);
4559
+ if (line === null) return { block: false };
4560
+ const symbolId = locateSymbolAtLine(fileIndex, line);
4561
+ if (!symbolId) return { block: false };
4562
+ const store = new GateSessionStore(this.cacheDir);
4563
+ if (store.hasWarned(payload.session_id, symbolId)) return { block: false };
4564
+ if (payload.transcript_path && existsSync24(payload.transcript_path)) {
4565
+ const lines = readFileSync16(payload.transcript_path, "utf-8").split("\n").filter(Boolean);
4566
+ if (transcriptHasBlastCheck(lines, symbolId)) return { block: false };
4567
+ }
4568
+ const { graph } = loadGraph(this.ctxoRoot);
4569
+ if (!graph.hasNode(symbolId)) return { block: false };
4570
+ const blast = new BlastRadiusCalculator().calculate(graph, symbolId);
4571
+ const riskCount = blast.confirmedCount + blast.likelyCount;
4572
+ const pr = new PageRankCalculator().calculate(graph, { limit: RANK_ALL });
4573
+ const prMap = new Map(pr.rankings.map((r) => [r.symbolId, r.score]));
4574
+ const cutoff = percentileCutoff([...prMap.values()], thresholds.percentile);
4575
+ const importanceScore = prMap.get(symbolId) ?? 0;
4576
+ const block = shouldBlock({ riskCount, importanceScore, importanceCutoff: cutoff }, thresholds);
4577
+ if (!block) return { block: false };
4578
+ store.recordWarned(payload.session_id, symbolId);
4579
+ const name = symbolId.split("::")[1] ?? symbolId;
4580
+ const top = blast.impactedSymbols.filter((s) => s.confidence !== "potential").map((s) => s.symbolId);
4581
+ return { block: true, reason: formatBlockReason(name, riskCount, top) };
4582
+ } catch (err) {
4583
+ log8.error(`${err.message}`);
4584
+ return { block: false };
4585
+ }
4586
+ }
4587
+ async run() {
4588
+ try {
4589
+ const payload = await this.readStdin();
4590
+ if (payload) {
4591
+ const decision = this.evaluate(payload);
4592
+ if (decision.block) this.emitBlock(decision.reason ?? "ctxo guard");
4593
+ }
4594
+ } catch (err) {
4595
+ log8.error(`${err.message}`);
4596
+ }
4597
+ process.exit(0);
4598
+ }
4599
+ /** PreToolUse block contract (verified): exit 0 + stdout JSON deny. */
4600
+ emitBlock(reason) {
4601
+ process.stdout.write(JSON.stringify({
4602
+ hookSpecificOutput: {
4603
+ hookEventName: "PreToolUse",
4604
+ permissionDecision: "deny",
4605
+ permissionDecisionReason: reason
4606
+ }
4607
+ }) + "\n");
4608
+ process.exit(0);
4609
+ }
4610
+ readStdin() {
4611
+ return new Promise((resolve4) => {
4612
+ let data = "";
4613
+ process.stdin.setEncoding("utf-8");
4614
+ process.stdin.on("data", (c) => {
4615
+ data += c;
4616
+ });
4617
+ process.stdin.on("end", () => {
4618
+ try {
4619
+ resolve4(JSON.parse(data));
4620
+ } catch {
4621
+ resolve4(null);
4622
+ }
4623
+ });
4624
+ process.stdin.on("error", () => resolve4(null));
4625
+ });
4626
+ }
4627
+ };
4628
+
4629
+ // src/cli/gate-command.ts
4630
+ import { join as join28 } from "path";
4631
+ var RANK_ALL2 = 1e5;
4632
+ var GateCommand = class {
4633
+ ctxoRoot;
4634
+ constructor(projectRoot) {
4635
+ this.ctxoRoot = join28(projectRoot, ".ctxo");
4636
+ }
4637
+ preview(options = {}) {
4638
+ const gate = loadConfig(this.ctxoRoot).config.gate;
4639
+ const thresholds = resolveGateThresholds(gate);
4640
+ const { graph } = loadGraph(this.ctxoRoot);
4641
+ const pr = new PageRankCalculator().calculate(graph, { limit: RANK_ALL2 });
4642
+ const prMap = new Map(pr.rankings.map((r) => [r.symbolId, r.score]));
4643
+ const cutoff = percentileCutoff([...prMap.values()], thresholds.percentile);
4644
+ const calc = new BlastRadiusCalculator();
4645
+ const firing = [];
4646
+ for (const [symbolId, importance] of prMap) {
4647
+ if (importance < cutoff) continue;
4648
+ const blast = calc.calculate(graph, symbolId);
4649
+ const riskCount = blast.confirmedCount + blast.likelyCount;
4650
+ if (shouldBlock({ riskCount, importanceScore: importance, importanceCutoff: cutoff }, thresholds)) {
4651
+ firing.push({ symbolId, riskCount, importance: Math.round(importance * 1e4) / 1e4 });
4652
+ }
4653
+ }
4654
+ firing.sort((a, b) => b.riskCount - a.riskCount);
4655
+ const payload = {
4656
+ sensitivity: gate?.sensitivity ?? "balanced",
4657
+ thresholds,
4658
+ totalSymbols: graph.nodeCount,
4659
+ wouldFireCount: firing.length,
4660
+ symbols: firing.slice(0, 50)
4661
+ };
4662
+ if (options.json) {
4663
+ process.stdout.write(JSON.stringify(payload, null, 2) + "\n");
4664
+ return;
4665
+ }
4666
+ console.error(`[ctxo] gate preview (${payload.sensitivity}): ${firing.length}/${graph.nodeCount} symbols would gate.`);
4667
+ for (const f of firing.slice(0, 25)) console.error(` ${f.riskCount} dep ${f.symbolId}`);
4668
+ }
4669
+ };
4670
+
4242
4671
  // src/cli/cli-router.ts
4243
4672
  function getVersion() {
4244
4673
  let dir = import.meta.dirname;
4245
4674
  for (let i = 0; i < 10; i++) {
4246
- const pkg = join25(dir, "package.json");
4247
- if (existsSync23(pkg)) {
4675
+ const pkg = join29(dir, "package.json");
4676
+ if (existsSync25(pkg)) {
4248
4677
  try {
4249
- const json = JSON.parse(readFileSync15(pkg, "utf-8"));
4678
+ const json = JSON.parse(readFileSync17(pkg, "utf-8"));
4250
4679
  return json.version ?? "unknown";
4251
4680
  } catch {
4252
4681
  break;
4253
4682
  }
4254
4683
  }
4255
- const parent = join25(dir, "..");
4684
+ const parent = join29(dir, "..");
4256
4685
  if (parent === dir) break;
4257
4686
  dir = parent;
4258
4687
  }
@@ -4261,7 +4690,7 @@ function getVersion() {
4261
4690
  function locateGitRoot(startDir) {
4262
4691
  let dir = startDir;
4263
4692
  for (let i = 0; i < 12; i++) {
4264
- if (existsSync23(join25(dir, ".git"))) return dir;
4693
+ if (existsSync25(join29(dir, ".git"))) return dir;
4265
4694
  const parent = dirname8(dir);
4266
4695
  if (parent === dir) break;
4267
4696
  dir = parent;
@@ -4451,6 +4880,28 @@ var CliRouter = class {
4451
4880
  });
4452
4881
  break;
4453
4882
  }
4883
+ case "gate": {
4884
+ if (args.includes("--preview")) {
4885
+ new GateCommand(this.projectRoot).preview({ json: args.includes("--json") });
4886
+ } else {
4887
+ console.error("[ctxo] usage: ctxo gate --preview [--json]");
4888
+ process.exit(1);
4889
+ }
4890
+ break;
4891
+ }
4892
+ case "gate-hook":
4893
+ await new GateHookCommand(this.projectRoot).run();
4894
+ break;
4895
+ case "blast-radius": {
4896
+ const symbolId = args[1];
4897
+ if (!symbolId || symbolId.startsWith("--")) {
4898
+ console.error("[ctxo] usage: ctxo blast-radius <symbolId> [--json]");
4899
+ process.exit(1);
4900
+ return;
4901
+ }
4902
+ new BlastRadiusCommand(this.projectRoot).run({ symbolId, json: args.includes("--json") });
4903
+ break;
4904
+ }
4454
4905
  case "visualize": {
4455
4906
  const outputIdx = args.indexOf("--output");
4456
4907
  const outputArg = outputIdx !== -1 ? args[outputIdx + 1] : void 0;
@@ -4505,6 +4956,9 @@ Usage:
4505
4956
  ctxo update --json Machine-readable output
4506
4957
  ctxo update --pm <pm> Override package manager (npm|pnpm|yarn|bun)
4507
4958
  ctxo update --force Bypass CI lockfile guard
4959
+ ctxo blast-radius <symbolId> Blast radius for one symbol (--json for machine output)
4960
+ ctxo gate --preview Preview which symbols the safe-edit guard would block
4961
+ ctxo gate --preview --json Same, machine-readable output
4508
4962
  ctxo --version Print core version
4509
4963
  ctxo --version --verbose Print core + plugins + runtime
4510
4964
  ctxo --version --json Machine-readable version payload
@@ -4518,4 +4972,4 @@ export {
4518
4972
  getVersion,
4519
4973
  locateGitRoot
4520
4974
  };
4521
- //# sourceMappingURL=cli-router-IK3NJEKJ.js.map
4975
+ //# sourceMappingURL=cli-router-45W4IRYE.js.map