@ctxo/cli 0.9.3 → 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-CQJ7CN6E.js → cli-router-45W4IRYE.js} +449 -25
- 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-CQJ7CN6E.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";
|
|
@@ -1772,6 +1773,34 @@ import { createRequire as createRequire4 } from "module";
|
|
|
1772
1773
|
// src/cli/ai-rules.ts
|
|
1773
1774
|
import { existsSync as existsSync11, readFileSync as readFileSync7, writeFileSync as writeFileSync3, mkdirSync as mkdirSync4 } from "fs";
|
|
1774
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
|
+
}
|
|
1775
1804
|
var PLATFORMS = [
|
|
1776
1805
|
{ id: "claude-code", name: "Claude Code", file: "CLAUDE.md", mode: "append", detectPaths: ["CLAUDE.md", ".claude"], starred: true },
|
|
1777
1806
|
{ id: "cursor", name: "Cursor", file: ".cursor/rules/ctxo.mdc", mode: "create", detectPaths: [".cursor", ".cursorrules"], starred: false },
|
|
@@ -1878,6 +1907,37 @@ ${content}${SECTION_END}
|
|
|
1878
1907
|
function escapeRegExp(s) {
|
|
1879
1908
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1880
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
|
+
}
|
|
1881
1941
|
var GITIGNORE_ENTRY = ".ctxo/.cache/";
|
|
1882
1942
|
function ensureGitignore(projectRoot) {
|
|
1883
1943
|
const filePath = join12(projectRoot, ".gitignore");
|
|
@@ -1916,6 +1976,10 @@ index:
|
|
|
1916
1976
|
ignoreProjects: []
|
|
1917
1977
|
# - "packages/experimental-*"
|
|
1918
1978
|
# - "examples/*"
|
|
1979
|
+
|
|
1980
|
+
gate:
|
|
1981
|
+
enabled: true
|
|
1982
|
+
sensitivity: balanced # strict | balanced | lenient
|
|
1919
1983
|
`;
|
|
1920
1984
|
function ensureConfig(projectRoot) {
|
|
1921
1985
|
const filePath = join12(projectRoot, ".ctxo", "config.yaml");
|
|
@@ -2209,6 +2273,18 @@ var InitCommand = class {
|
|
|
2209
2273
|
}
|
|
2210
2274
|
console.error("");
|
|
2211
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
|
+
}
|
|
2212
2288
|
const s = clack.spinner();
|
|
2213
2289
|
s.start(pc.dim("Configuring..."));
|
|
2214
2290
|
const results = [];
|
|
@@ -2226,6 +2302,9 @@ var InitCommand = class {
|
|
|
2226
2302
|
for (const toolId of selectedTools) {
|
|
2227
2303
|
const result = installRules(this.projectRoot, toolId);
|
|
2228
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
|
+
}
|
|
2229
2308
|
}
|
|
2230
2309
|
const mcpTargets = getMcpConfigTargets(selectedTools);
|
|
2231
2310
|
for (const target of mcpTargets) {
|
|
@@ -2238,6 +2317,10 @@ var InitCommand = class {
|
|
|
2238
2317
|
this.installHooks();
|
|
2239
2318
|
results.push(`${pc.green("\u2713")} ${pc.bold("post-commit, post-merge")} ${pc.dim("hooks installed")}`);
|
|
2240
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
|
+
}
|
|
2241
2324
|
if (pluginsToInstall.length > 0) {
|
|
2242
2325
|
s.stop(pc.dim("Installing language plugins..."));
|
|
2243
2326
|
const installer = new InstallCommand(this.projectRoot);
|
|
@@ -2290,6 +2373,9 @@ var InitCommand = class {
|
|
|
2290
2373
|
for (const toolId of toolIds) {
|
|
2291
2374
|
const result = installRules(this.projectRoot, toolId);
|
|
2292
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
|
+
}
|
|
2293
2379
|
}
|
|
2294
2380
|
const mcpTargets = getMcpConfigTargets(toolIds);
|
|
2295
2381
|
for (const target of mcpTargets) {
|
|
@@ -2302,6 +2388,10 @@ var InitCommand = class {
|
|
|
2302
2388
|
this.installHooks();
|
|
2303
2389
|
console.error("[ctxo] \u2713 Git hooks installed");
|
|
2304
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
|
+
}
|
|
2305
2395
|
if (!options.rulesOnly && !options.noInstall) {
|
|
2306
2396
|
const detection = detectLanguages(this.projectRoot);
|
|
2307
2397
|
const needed = decideNeededLanguages(detection);
|
|
@@ -2339,6 +2429,10 @@ var InitCommand = class {
|
|
|
2339
2429
|
console.error(" .git/hooks/post-commit (git hook)");
|
|
2340
2430
|
console.error(" .git/hooks/post-merge (git hook)");
|
|
2341
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
|
+
}
|
|
2342
2436
|
}
|
|
2343
2437
|
/* ---------------------------------------------------------------- */
|
|
2344
2438
|
/* Helpers */
|
|
@@ -2667,8 +2761,8 @@ var StatsCommand = class {
|
|
|
2667
2761
|
if (options.clear) {
|
|
2668
2762
|
recorder.clear();
|
|
2669
2763
|
const data = db.export();
|
|
2670
|
-
const { writeFileSync:
|
|
2671
|
-
|
|
2764
|
+
const { writeFileSync: writeFileSync7 } = await import("fs");
|
|
2765
|
+
writeFileSync7(dbPath, Buffer.from(data));
|
|
2672
2766
|
console.error("[ctxo] Session data cleared.");
|
|
2673
2767
|
return;
|
|
2674
2768
|
}
|
|
@@ -3651,7 +3745,7 @@ var DoctorCommand = class {
|
|
|
3651
3745
|
this.ctxoRoot = join22(projectRoot, ".ctxo");
|
|
3652
3746
|
}
|
|
3653
3747
|
async run(options = {}) {
|
|
3654
|
-
const { getVersion: getVersion2 } = await import("./cli-router-
|
|
3748
|
+
const { getVersion: getVersion2 } = await import("./cli-router-45W4IRYE.js");
|
|
3655
3749
|
console.error(`ctxo v${getVersion2()} \u2014 health check
|
|
3656
3750
|
`);
|
|
3657
3751
|
const checks = [
|
|
@@ -3718,6 +3812,17 @@ var DoctorCommand = class {
|
|
|
3718
3812
|
import { readFileSync as readFileSync13, writeFileSync as writeFileSync5, existsSync as existsSync21 } from "fs";
|
|
3719
3813
|
import { join as join23, basename } from "path";
|
|
3720
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
|
|
3721
3826
|
var VisualizeCommand = class {
|
|
3722
3827
|
constructor(projectRoot) {
|
|
3723
3828
|
this.projectRoot = projectRoot;
|
|
@@ -3726,25 +3831,13 @@ var VisualizeCommand = class {
|
|
|
3726
3831
|
projectRoot;
|
|
3727
3832
|
ctxoRoot;
|
|
3728
3833
|
async run(options = {}) {
|
|
3729
|
-
const
|
|
3730
|
-
const indices = reader.readAll();
|
|
3834
|
+
const { graph, indices } = loadGraph(this.ctxoRoot);
|
|
3731
3835
|
if (indices.length === 0) {
|
|
3732
3836
|
console.error('[ctxo] No index found. Run "ctxo index" first.');
|
|
3733
3837
|
process.exit(1);
|
|
3734
3838
|
return;
|
|
3735
3839
|
}
|
|
3736
3840
|
console.error(`[ctxo] Building graph from ${indices.length} files...`);
|
|
3737
|
-
const graph = new SymbolGraph();
|
|
3738
|
-
for (const fileIndex of indices) {
|
|
3739
|
-
for (const sym of fileIndex.symbols) {
|
|
3740
|
-
graph.addNode(sym);
|
|
3741
|
-
}
|
|
3742
|
-
}
|
|
3743
|
-
for (const fileIndex of indices) {
|
|
3744
|
-
for (const edge of fileIndex.edges) {
|
|
3745
|
-
graph.addEdge(edge);
|
|
3746
|
-
}
|
|
3747
|
-
}
|
|
3748
3841
|
console.error(`[ctxo] Graph: ${graph.nodeCount} symbols, ${graph.edgeCount} edges`);
|
|
3749
3842
|
const pageRank = new PageRankCalculator();
|
|
3750
3843
|
const prResult = pageRank.calculate(graph, { limit: 1e5 });
|
|
@@ -4269,20 +4362,326 @@ function isLockedCI2(env) {
|
|
|
4269
4362
|
return env["CI"] === "true" || env["CI"] === "1";
|
|
4270
4363
|
}
|
|
4271
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
|
+
|
|
4272
4671
|
// src/cli/cli-router.ts
|
|
4273
4672
|
function getVersion() {
|
|
4274
4673
|
let dir = import.meta.dirname;
|
|
4275
4674
|
for (let i = 0; i < 10; i++) {
|
|
4276
|
-
const pkg =
|
|
4277
|
-
if (
|
|
4675
|
+
const pkg = join29(dir, "package.json");
|
|
4676
|
+
if (existsSync25(pkg)) {
|
|
4278
4677
|
try {
|
|
4279
|
-
const json = JSON.parse(
|
|
4678
|
+
const json = JSON.parse(readFileSync17(pkg, "utf-8"));
|
|
4280
4679
|
return json.version ?? "unknown";
|
|
4281
4680
|
} catch {
|
|
4282
4681
|
break;
|
|
4283
4682
|
}
|
|
4284
4683
|
}
|
|
4285
|
-
const parent =
|
|
4684
|
+
const parent = join29(dir, "..");
|
|
4286
4685
|
if (parent === dir) break;
|
|
4287
4686
|
dir = parent;
|
|
4288
4687
|
}
|
|
@@ -4291,7 +4690,7 @@ function getVersion() {
|
|
|
4291
4690
|
function locateGitRoot(startDir) {
|
|
4292
4691
|
let dir = startDir;
|
|
4293
4692
|
for (let i = 0; i < 12; i++) {
|
|
4294
|
-
if (
|
|
4693
|
+
if (existsSync25(join29(dir, ".git"))) return dir;
|
|
4295
4694
|
const parent = dirname8(dir);
|
|
4296
4695
|
if (parent === dir) break;
|
|
4297
4696
|
dir = parent;
|
|
@@ -4481,6 +4880,28 @@ var CliRouter = class {
|
|
|
4481
4880
|
});
|
|
4482
4881
|
break;
|
|
4483
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
|
+
}
|
|
4484
4905
|
case "visualize": {
|
|
4485
4906
|
const outputIdx = args.indexOf("--output");
|
|
4486
4907
|
const outputArg = outputIdx !== -1 ? args[outputIdx + 1] : void 0;
|
|
@@ -4535,6 +4956,9 @@ Usage:
|
|
|
4535
4956
|
ctxo update --json Machine-readable output
|
|
4536
4957
|
ctxo update --pm <pm> Override package manager (npm|pnpm|yarn|bun)
|
|
4537
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
|
|
4538
4962
|
ctxo --version Print core version
|
|
4539
4963
|
ctxo --version --verbose Print core + plugins + runtime
|
|
4540
4964
|
ctxo --version --json Machine-readable version payload
|
|
@@ -4548,4 +4972,4 @@ export {
|
|
|
4548
4972
|
getVersion,
|
|
4549
4973
|
locateGitRoot
|
|
4550
4974
|
};
|
|
4551
|
-
//# sourceMappingURL=cli-router-
|
|
4975
|
+
//# sourceMappingURL=cli-router-45W4IRYE.js.map
|