feat(layer4): agent-space distance metric + TCAS-style collision avoidance (v0)

The moat layer: spatial deconfliction for multiple agents (and humans) on one
codebase, modeled on aircraft TCAS — measure how close two agents are in
code-space, then transmit-intent (Traffic Advisory) and steer-away (Resolution
Advisory) before they collide at the git layer.

scripts/lib/agent-proximity/:
- distance.js — the math: per-channel collision probabilities combined via
  noisy-OR R = 1 - Π(1 - ω·r). Channels: edit overlap (file + line-range
  Jaccard), dependency coupling (γ^(d-1) over the import graph, direction-
  agnostic — catches 'edit there breaks here' even when tree-distant), and tree
  proximity (LCA-based, soft prior). TCAS advise(): clear / advisory(transmit) /
  resolution(steer), with deterministic right-of-way priority so the maneuver is
  coordinated. closureRate() for approach-speed escalation.
- graph.js — lightweight require/import dependency-graph builder (fs or in-memory).
- index.js — scanAirspace(): pairwise advisories + 3D vector embedding (space-
  filling path embedding pulled toward dependency neighbours) so a 'where are
  the agents' visualization can render the file-cloud and watch agents crawl /
  steer.

docs/design/agent-proximity.md — full mathematical formulation + protocol + viz
+ roadmap (v1 call-graph/symbol channels + live session-diff wiring; v2 cross-
machine airspace over Tailscale, the zero-conflict-swarm demo).

17 tests; full suite 2869/2869; lint green.
This commit is contained in:
Affaan Mustafa
2026-06-20 15:40:40 -04:00
parent 34faa39bd3
commit 726972d735
5 changed files with 953 additions and 0 deletions
+326
View File
@@ -0,0 +1,326 @@
'use strict';
/**
* Agent-space distance metric + collision avoidance (ECC 2.0, Layer 4 v0).
*
* Two agents editing the same codebase are like two aircraft sharing airspace:
* we want a continuous notion of "how close are they" so that, as they approach,
* we fire a TCAS-style protocol — first a Traffic Advisory (exchange intent),
* then a Resolution Advisory (one steers away) — *before* they collide at the
* git/merge layer.
*
* ── The state of an agent ──────────────────────────────────────────────────
* At time t, agent a has a working set
* W_a = { (f, R_f, w_f) } (1)
* where f is a file it has touched, R_f the set of line ranges it edited in f,
* and w_f ∈ (0,1] a recency weight (older edits decay). Optionally an agent
* declares an intent set I_a of files it is about to touch.
*
* ── Collision is multi-channel ─────────────────────────────────────────────
* Two agents can collide through several independent channels, so we model a
* per-channel collision probability r_i ∈ [0,1] and combine with a noisy-OR
* (probability of colliding through *at least one* channel):
* R(a,b) = 1 Π_i (1 ω_i · r_i) (2)
* with channel weights ω_i ∈ [0,1]. R is the agent-distance's dual: we report
* both the risk R ∈ [0,1] and a distance D = 1 R.
*
* Channels (each defined below):
* r_overlap — same file / overlapping line ranges (imminent)
* r_dep — one agent's files depend on the other's (collision even when
* far apart in the tree: edit there breaks here)
* r_tree — proximity in the directory tree (a soft prior)
*
* ── Channel 1: edit overlap ────────────────────────────────────────────────
* For the shared files S = files(W_a) ∩ files(W_b):
* - same file, no line info → Jaccard of the file sets is the floor.
* - same file with line ranges → fraction of overlapping lines:
* lineOverlap(f) = |R_f^a ∩ R_f^b| / |R_f^a R_f^b|.
* r_overlap = max( jaccard(files_a, files_b), max_{f∈S} lineOverlap(f) ). (3)
*
* ── Channel 2: dependency coupling ─────────────────────────────────────────
* Build a directed dependency graph G=(V,E), V=files, edge f→g iff f imports g.
* Even if f and g are in distant subtrees, if f (agent a) depends on g (agent b)
* then b's edit to g can break a. Coupling decays with graph distance:
* coupling(f,g) = γ^{ d_G(f,g) 1 } (γ∈(0,1)), 0 if unreachable. (4)
* A direct edge (d_G=1) ⇒ coupling=1. We take the recency-weighted max over
* cross pairs:
* r_dep = max_{f∈W_a, g∈W_b} w_f·w_g·max(coupling(f,g), coupling(g,f)). (5)
*
* ── Channel 3: tree proximity ──────────────────────────────────────────────
* For two paths split into segments with lowest-common-ancestor depth L:
* treeDistance(f,g) = ((depth_f L) + (depth_g L)) / (depth_f + depth_g) (6)
* (0 = same file, 1 = disjoint roots). r_tree = 1 min cross-pair treeDist.
* Tree proximity alone rarely causes a collision, so ω_tree is small — it nudges
* the metric, it does not dominate it.
*
* ── TCAS protocol ──────────────────────────────────────────────────────────
* Two thresholds carve a protected zone:
* R < τ_TA → CLEAR
* τ_TA ≤ R < τ_RA → TRAFFIC ADVISORY: each agent transmits what it is
* doing/has done to the other (the scout handshake)
* R ≥ τ_RA → RESOLUTION ADVISORY: the lower-priority agent steers
* away; the higher-priority one holds course.
* Like TCAS coordinating climb/descend, the resolution is *coordinated* and
* deterministic so both agents never pick the same maneuver: priority(a) breaks
* the tie (right-of-way to the agent with more committed work / earlier start;
* stable agentId as the final tiebreak). See advise().
*
* ── Vector-space view ──────────────────────────────────────────────────────
* embedAgent() places each agent at the recency-weighted centroid of its files'
* coordinates, where a file's coordinate is a low-dim hash of its path segments
* smoothed toward its dependency neighbours. Then ‖v_a v_b‖ tracks R, which is
* what a 3D "where are the agents" visualization renders. See embed.js.
*/
const DEFAULTS = {
channelWeights: { overlap: 1.0, dependency: 0.9, tree: 0.25 },
depDecay: 0.5, // γ in (4)
recencyFloor: 0.15, // weight never decays below this so stale-but-relevant files still count
thresholds: { ta: 0.35, ra: 0.7 } // τ_TA, τ_RA
};
function clamp01(x) {
if (!Number.isFinite(x)) return 0;
return x < 0 ? 0 : x > 1 ? 1 : x;
}
function normalizePath(p) {
return String(p || '')
.replace(/\\/g, '/')
.replace(/^\.\//, '')
.replace(/\/+$/, '');
}
function segments(p) {
return normalizePath(p).split('/').filter(Boolean);
}
/**
* Tree distance ∈ [0,1] between two file paths — eq. (6). 0 = same file.
*/
function treeDistance(a, b) {
const sa = segments(a);
const sb = segments(b);
if (sa.length === 0 || sb.length === 0) return 1;
let lca = 0;
while (lca < sa.length && lca < sb.length && sa[lca] === sb[lca]) lca += 1;
const da = sa.length;
const db = sb.length;
if (da === db && lca === da) return 0; // identical path
return clamp01((da - lca + (db - lca)) / (da + db));
}
/**
* Line-range overlap fraction (Jaccard over covered lines) for two range lists.
* Each range is [start, end] inclusive. Empty/absent ranges ⇒ treat the whole
* file as touched, so two file-level edits to the same file count as full overlap.
*/
function lineRangeOverlap(rangesA, rangesB) {
const a = Array.isArray(rangesA) ? rangesA : [];
const b = Array.isArray(rangesB) ? rangesB : [];
if (a.length === 0 || b.length === 0) return 1; // file-level edit ⇒ whole-file overlap
const covered = ranges => {
const set = new Set();
for (const [s, e] of ranges) {
const lo = Math.min(s, e);
const hi = Math.max(s, e);
for (let i = lo; i <= hi; i += 1) set.add(i);
}
return set;
};
const ca = covered(a);
const cb = covered(b);
let inter = 0;
for (const v of ca) if (cb.has(v)) inter += 1;
const union = ca.size + cb.size - inter;
return union === 0 ? 0 : inter / union;
}
function fileSet(workingSet) {
return new Set((workingSet.files || []).map(f => normalizePath(f.path)));
}
function jaccard(setA, setB) {
if (setA.size === 0 && setB.size === 0) return 0;
let inter = 0;
for (const v of setA) if (setB.has(v)) inter += 1;
const union = setA.size + setB.size - inter;
return union === 0 ? 0 : inter / union;
}
/**
* Channel 1 — edit overlap, eq. (3).
*/
function overlapRisk(a, b) {
const filesA = a.files || [];
const filesB = b.files || [];
const setA = fileSet(a);
const setB = fileSet(b);
let r = jaccard(setA, setB);
const byPathB = new Map(filesB.map(f => [normalizePath(f.path), f]));
for (const fa of filesA) {
const fb = byPathB.get(normalizePath(fa.path));
if (fb) {
const w = (fa.weight ?? 1) * (fb.weight ?? 1);
r = Math.max(r, w * lineRangeOverlap(fa.lines, fb.lines));
}
}
return clamp01(r);
}
/**
* Shortest-path distance in a directed dependency graph, treated as undirected
* for reachability (a depends-on edge couples both endpoints). BFS, capped.
*/
function graphDistance(graph, from, to, cap = 6) {
const start = normalizePath(from);
const goal = normalizePath(to);
if (start === goal) return 0;
const adj = graph && graph.adjacency ? graph.adjacency : graph || {};
const seen = new Set([start]);
let frontier = [start];
for (let depth = 1; depth <= cap; depth += 1) {
const next = [];
for (const node of frontier) {
const neighbours = adj[node] || [];
for (const nb of neighbours) {
const n = normalizePath(nb);
if (n === goal) return depth;
if (!seen.has(n)) {
seen.add(n);
next.push(n);
}
}
}
if (next.length === 0) break;
frontier = next;
}
return Infinity;
}
/**
* Channel 2 — dependency coupling, eqs. (4)-(5).
*/
function dependencyRisk(a, b, graph, opts = {}) {
const decay = opts.depDecay ?? DEFAULTS.depDecay;
const filesA = a.files || [];
const filesB = b.files || [];
let r = 0;
for (const fa of filesA) {
for (const fb of filesB) {
// A depends-on edge couples both endpoints, so use the smaller of the two
// directed distances (importer→imported or imported→importer).
const d = Math.min(graphDistance(graph, fa.path, fb.path), graphDistance(graph, fb.path, fa.path));
if (d === Infinity || d === 0) continue;
const coupling = Math.pow(decay, d - 1); // γ^{d-1}
const w = (fa.weight ?? 1) * (fb.weight ?? 1);
r = Math.max(r, w * coupling);
}
}
return clamp01(r);
}
/**
* Channel 3 — tree proximity (soft prior), eq. (6).
*/
function treeRisk(a, b) {
const filesA = a.files || [];
const filesB = b.files || [];
let minDist = 1;
for (const fa of filesA) {
for (const fb of filesB) {
minDist = Math.min(minDist, treeDistance(fa.path, fb.path));
}
}
return clamp01(1 - minDist);
}
/**
* Collision risk R(a,b) ∈ [0,1] via the noisy-OR of channels, eq. (2).
* Returns the risk, its dual distance, and the per-channel breakdown.
*/
function collisionRisk(a, b, graph = {}, options = {}) {
const weights = { ...DEFAULTS.channelWeights, ...(options.channelWeights || {}) };
const channels = {
overlap: overlapRisk(a, b),
dependency: dependencyRisk(a, b, graph, options),
tree: treeRisk(a, b)
};
let product = 1;
for (const key of Object.keys(channels)) {
const w = clamp01(weights[key] ?? 0);
product *= 1 - w * channels[key];
}
const risk = clamp01(1 - product);
return { risk, distance: clamp01(1 - risk), channels };
}
/**
* Right-of-way priority: the agent with more committed work and the earlier
* start holds course; the other steers. Higher number = higher priority.
*/
function agentPriority(agent) {
const progress = (agent.files || []).reduce((s, f) => s + (f.weight ?? 1), 0);
const startedAt = agent.startedAt ? Date.parse(agent.startedAt) || 0 : 0;
// Earlier start ⇒ larger right-of-way term (negative ms, so earlier = larger).
return { progress, ageMs: startedAt ? Date.now() - startedAt : 0 };
}
/**
* TCAS-style advisory between two agents given their collision risk.
* Returns { level: 'clear'|'advisory'|'resolution', risk, transmit, steer, hold }.
* - advisory: both should transmit intent to each other.
* - resolution: `steer` is the agentId that must move; `hold` holds course.
*/
function advise(a, b, graph = {}, options = {}) {
const thresholds = { ...DEFAULTS.thresholds, ...(options.thresholds || {}) };
const { risk, channels, distance } = collisionRisk(a, b, graph, options);
if (risk < thresholds.ta) {
return { level: 'clear', risk, distance, channels, transmit: false, steer: null, hold: null };
}
const pa = agentPriority(a);
const pb = agentPriority(b);
// Right-of-way: more progress wins; tie → earlier start (greater age) wins;
// final deterministic tiebreak on agentId so the maneuver is coordinated.
let aHasPriority;
if (pa.progress !== pb.progress) aHasPriority = pa.progress > pb.progress;
else if (pa.ageMs !== pb.ageMs) aHasPriority = pa.ageMs > pb.ageMs;
else aHasPriority = String(a.agentId) < String(b.agentId);
const hold = aHasPriority ? a.agentId : b.agentId;
const steer = aHasPriority ? b.agentId : a.agentId;
if (risk < thresholds.ra) {
// Traffic advisory: exchange intent, no one has to move yet.
return { level: 'advisory', risk, distance, channels, transmit: true, steer: null, hold: null };
}
// Resolution advisory: the lower-priority agent steers away.
return { level: 'resolution', risk, distance, channels, transmit: true, steer, hold };
}
/**
* Closure rate: how fast two agents are converging, from two risk samples
* Δt apart (TCAS uses closure rate, not just separation, to decide urgency).
* Positive ⇒ approaching. Used to escalate before the protected zone is reached.
*/
function closureRate(prevRisk, currRisk, dtMs) {
const dt = Number(dtMs) > 0 ? Number(dtMs) : 1;
return (clamp01(currRisk) - clamp01(prevRisk)) / (dt / 1000);
}
module.exports = {
DEFAULTS,
treeDistance,
lineRangeOverlap,
graphDistance,
overlapRisk,
dependencyRisk,
treeRisk,
collisionRisk,
agentPriority,
advise,
closureRate,
_internal: { normalizePath, segments, jaccard }
};
+140
View File
@@ -0,0 +1,140 @@
'use strict';
/**
* Lightweight dependency-graph builder for the agent-proximity metric.
*
* Edge f → g iff f imports/requires g. This is the structure the dependency
* channel (distance.js, eqs. 4-5) walks: two agents far apart in the tree still
* collide if one edits a file the other imports.
*
* v0 scans JS/TS `require()` / `import ... from` / `import(...)` for relative
* specifiers and resolves them to repo-relative paths. It is intentionally
* static and dependency-free; richer languages and call-graph edges are future
* channels that slot into the same adjacency shape.
*/
const fs = require('fs');
const path = require('path');
const SOURCE_EXTENSIONS = ['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx'];
const RESOLVE_EXTENSIONS = ['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.json'];
function toRepoRel(repoRoot, absPath) {
return path.relative(repoRoot, absPath).split(path.sep).join('/');
}
// Match relative specifiers only (./ or ../). Bare specifiers are node_modules
// and never the target of an in-repo collision.
const SPEC_PATTERNS = [
/require\(\s*['"](\.[^'"]+)['"]\s*\)/g,
/import\s+(?:[^'"]*?\s+from\s+)?['"](\.[^'"]+)['"]/g,
/import\(\s*['"](\.[^'"]+)['"]\s*\)/g,
/export\s+(?:\*|\{[^}]*\})\s+from\s+['"](\.[^'"]+)['"]/g
];
function extractRelativeSpecifiers(source) {
const specs = new Set();
for (const re of SPEC_PATTERNS) {
re.lastIndex = 0;
let m;
while ((m = re.exec(source)) !== null) {
specs.add(m[1]);
}
}
return [...specs];
}
/**
* Resolve a relative specifier from `fromFile` to a repo-relative path, trying
* extension and /index resolution like Node/TS would.
*/
function resolveSpecifier(repoRoot, fromFile, spec) {
const baseDir = path.dirname(path.join(repoRoot, fromFile));
const target = path.resolve(baseDir, spec);
const candidates = [target];
for (const ext of RESOLVE_EXTENSIONS) candidates.push(target + ext);
for (const ext of RESOLVE_EXTENSIONS) candidates.push(path.join(target, 'index' + ext));
for (const cand of candidates) {
try {
if (fs.existsSync(cand) && fs.statSync(cand).isFile()) {
return toRepoRel(repoRoot, cand);
}
} catch {
/* ignore unreadable candidate */
}
}
return null;
}
function isSourceFile(p) {
return SOURCE_EXTENSIONS.includes(path.extname(p));
}
/**
* Build a dependency graph from an explicit list of repo-relative files.
* Returns { adjacency: { file: [importedFile, ...] }, files: [...] }.
*
* @param {string} repoRoot
* @param {string[]} files repo-relative paths to scan
* @param {object} [deps] injectable fs for testing: { readFileSync, existsSync, statSync }
*/
function buildDependencyGraph(repoRoot, files, deps = {}) {
const read = deps.readFileSync || fs.readFileSync;
const adjacency = {};
const scanned = [];
for (const rel of files || []) {
const normalized = String(rel).replace(/\\/g, '/');
if (!isSourceFile(normalized)) continue;
scanned.push(normalized);
let source = '';
try {
source = String(read(path.join(repoRoot, normalized), 'utf8'));
} catch {
adjacency[normalized] = adjacency[normalized] || [];
continue;
}
const edges = new Set(adjacency[normalized] || []);
for (const spec of extractRelativeSpecifiers(source)) {
const resolved = resolveSpecifier(repoRoot, normalized, spec);
if (resolved && resolved !== normalized) edges.add(resolved);
}
adjacency[normalized] = [...edges];
}
return { adjacency, files: scanned };
}
/**
* Build a graph directly from an in-memory map of { file: sourceText }, for
* callers that already have file contents (and for tests). Specifiers are
* resolved against the provided file set rather than the filesystem.
*/
function buildDependencyGraphFromSources(sources = {}) {
const adjacency = {};
const fileList = Object.keys(sources).map(f => f.replace(/\\/g, '/'));
const fileSet = new Set(fileList);
const tryResolve = (fromFile, spec) => {
const base = path.posix.dirname(fromFile);
const target = path.posix.normalize(path.posix.join(base, spec));
const candidates = [target];
for (const ext of RESOLVE_EXTENSIONS) candidates.push(target + ext);
for (const ext of RESOLVE_EXTENSIONS) candidates.push(path.posix.join(target, 'index' + ext));
return candidates.find(c => fileSet.has(c)) || null;
};
for (const file of fileList) {
const edges = new Set();
for (const spec of extractRelativeSpecifiers(String(sources[file] || ''))) {
const resolved = tryResolve(file, spec);
if (resolved && resolved !== file) edges.add(resolved);
}
adjacency[file] = [...edges];
}
return { adjacency, files: fileList };
}
module.exports = {
buildDependencyGraph,
buildDependencyGraphFromSources,
extractRelativeSpecifiers,
resolveSpecifier,
isSourceFile
};
+170
View File
@@ -0,0 +1,170 @@
'use strict';
/**
* Agent-proximity orchestration: scan all agents in a codebase, compute the
* pairwise TCAS advisories that drive the steer/transmit triggers, and embed
* each agent in 3D space for the "where are the agents" visualization.
*
* This is the call the control pane / hook layer makes each tick:
* const scan = scanAirspace(agents, graph)
* for (const a of scan.advisories) fireTrigger(a) // transmit / steer
* renderViz(scan.positions, scan.advisories) // 3D crawl view
*/
const crypto = require('crypto');
const { advise, collisionRisk, DEFAULTS } = require('./distance');
const { buildDependencyGraph, buildDependencyGraphFromSources } = require('./graph');
const { normalizePath, segments } = require('./distance')._internal;
/**
* Deterministic hash of a string to a unit-ish vector in R^dims (components in
* roughly [-1, 1]). Used to place tree prefixes in space.
*/
function hashVec(str, dims) {
const digest = crypto.createHash('sha256').update(String(str)).digest();
const v = new Array(dims).fill(0);
for (let d = 0; d < dims; d += 1) {
// Two bytes per dim → [-1, 1).
const hi = digest[(d * 2) % digest.length];
const lo = digest[(d * 2 + 1) % digest.length];
v[d] = ((hi << 8) | lo) / 32768 - 1;
}
return v;
}
/**
* Coordinate of a file: a space-filling embedding of its path. Files that share
* a long directory prefix share most of their coordinate (deeper segments
* perturb less), so tree-close files are space-close — exactly what eq. (6)
* wants the visualization to show.
*/
function fileCoordinate(filePath, dims = 3) {
const segs = segments(filePath);
const v = new Array(dims).fill(0);
let prefix = '';
for (let i = 0; i < segs.length; i += 1) {
prefix += '/' + segs[i];
const h = hashVec(prefix, dims);
const scale = 1 / Math.pow(2, i);
for (let d = 0; d < dims; d += 1) v[d] += h[d] * scale;
}
return v;
}
/**
* Pull a file's coordinate toward the coordinates of its dependency neighbours
* (one averaging step), so coupled files that are far in the tree are drawn
* closer in space — the dependency channel made visible.
*/
function smoothByDependency(coords, graph, alpha = 0.35) {
const adj = (graph && graph.adjacency) || {};
const out = {};
for (const file of Object.keys(coords)) {
const base = coords[file];
const neighbours = (adj[file] || []).map(normalizePath).filter(n => coords[n]);
if (neighbours.length === 0) {
out[file] = base.slice();
continue;
}
const dims = base.length;
const avg = new Array(dims).fill(0);
for (const n of neighbours) for (let d = 0; d < dims; d += 1) avg[d] += coords[n][d];
for (let d = 0; d < dims; d += 1) avg[d] /= neighbours.length;
out[file] = base.map((x, d) => (1 - alpha) * x + alpha * avg[d]);
}
return out;
}
function weightedCentroid(files, fileCoords, dims) {
const v = new Array(dims).fill(0);
let wsum = 0;
for (const f of files) {
const c = fileCoords[normalizePath(f.path)];
if (!c) continue;
const w = f.weight ?? 1;
for (let d = 0; d < dims; d += 1) v[d] += c[d] * w;
wsum += w;
}
if (wsum > 0) for (let d = 0; d < dims; d += 1) v[d] /= wsum;
return v;
}
/**
* Embed agents in R^dims for visualization. Returns one position per agent plus
* the file coordinates used, so a renderer can draw both the agents and the
* file-cloud they sit in.
*/
function embedAgents(agents, graph = {}, options = {}) {
const dims = options.dims || 3;
const fileCoords = {};
for (const agent of agents) {
for (const f of agent.files || []) {
const p = normalizePath(f.path);
if (!fileCoords[p]) fileCoords[p] = fileCoordinate(p, dims);
}
}
const smoothed = smoothByDependency(fileCoords, graph, options.dependencyPull ?? 0.35);
const positions = agents.map(agent => ({
agentId: agent.agentId,
position: weightedCentroid(agent.files || [], smoothed, dims),
fileCount: (agent.files || []).length
}));
return { dims, positions, fileCoordinates: smoothed };
}
/**
* Scan the whole airspace: pairwise advisories + 3D positions in one pass.
*
* @param {Array<{agentId,files,startedAt?,intent?}>} agents
* @param {object} graph dependency graph (adjacency)
* @param {object} [options]
* @returns {{ advisories, positions, links, generatedAt }}
*/
function scanAirspace(agents, graph = {}, options = {}) {
const list = Array.isArray(agents) ? agents.filter(a => a && a.agentId !== null && a.agentId !== undefined) : [];
const advisories = [];
const links = [];
for (let i = 0; i < list.length; i += 1) {
for (let j = i + 1; j < list.length; j += 1) {
const a = list[i];
const b = list[j];
const verdict = advise(a, b, graph, options);
links.push({
a: a.agentId,
b: b.agentId,
risk: verdict.risk,
distance: verdict.distance,
level: verdict.level
});
if (verdict.level !== 'clear') {
advisories.push({ a: a.agentId, b: b.agentId, ...verdict });
}
}
}
advisories.sort((x, y) => y.risk - x.risk);
links.sort((x, y) => y.risk - x.risk);
const embedding = embedAgents(list, graph, options);
return {
advisories,
positions: embedding.positions,
fileCoordinates: embedding.fileCoordinates,
links,
counts: {
agents: list.length,
advisories: advisories.length,
resolutions: advisories.filter(a => a.level === 'resolution').length
}
};
}
module.exports = {
DEFAULTS,
scanAirspace,
embedAgents,
fileCoordinate,
collisionRisk,
advise,
buildDependencyGraph,
buildDependencyGraphFromSources
};