@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.
- package/README.md +39 -0
- package/dist/{chunk-HXEPS2XY.js → chunk-I6HLEK4G.js} +100 -3
- package/dist/chunk-I6HLEK4G.js.map +1 -0
- package/dist/{cli-router-IK3NJEKJ.js → cli-router-45W4IRYE.js} +483 -29
- package/dist/cli-router-45W4IRYE.js.map +1 -0
- package/dist/index.js +3 -90
- package/dist/index.js.map +1 -1
- package/llms-full.txt +46 -0
- package/llms.txt +7 -0
- package/package.json +4 -3
- package/skills/ctxo-review-pr/SKILL.md +10 -0
- package/skills/ctxo-safe-edit/SKILL.md +15 -0
- package/skills/ctxo-understand/SKILL.md +14 -0
- package/dist/chunk-HXEPS2XY.js.map +0 -1
- package/dist/cli-router-IK3NJEKJ.js.map +0 -1
|
@@ -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-
|
|
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
|
|
35
|
-
import { dirname as dirname8, join as
|
|
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:
|
|
2652
|
-
|
|
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-
|
|
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
|
|
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
|
|
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)} ${
|
|
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
|
|
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 =
|
|
4247
|
-
if (
|
|
4675
|
+
const pkg = join29(dir, "package.json");
|
|
4676
|
+
if (existsSync25(pkg)) {
|
|
4248
4677
|
try {
|
|
4249
|
-
const json = JSON.parse(
|
|
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 =
|
|
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 (
|
|
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-
|
|
4975
|
+
//# sourceMappingURL=cli-router-45W4IRYE.js.map
|