diff --git a/scripts/lib/control-pane/proximity.js b/scripts/lib/control-pane/proximity.js new file mode 100644 index 00000000..bc7378d3 --- /dev/null +++ b/scripts/lib/control-pane/proximity.js @@ -0,0 +1,111 @@ +'use strict'; + +/** + * Control-pane integration for the agent-space proximity metric. + * + * Turns live sessions into agent working sets (the files each session's worktree + * has changed), builds the dependency graph over those files, and runs the + * TCAS-style airspace scan — so the board can surface "two agents are converging" + * advisories and a 3D position per agent. See docs/design/agent-proximity.md. + */ + +const path = require('path'); +const { execFileSync } = require('child_process'); + +const { scanAirspace } = require('../agent-proximity'); +const { buildDependencyGraph } = require('../agent-proximity/graph'); + +/** + * Default working-set source: `git diff --name-only ` inside a session's + * worktree, returning repo-relative changed files. Returns [] on any failure so + * proximity degrades gracefully (never throws into the snapshot path). + */ +function defaultChangedFilesFor(session) { + const wt = session && session.worktree; + if (!wt || !wt.path) return []; + const base = wt.base || 'HEAD'; + try { + const out = execFileSync('git', ['-C', wt.path, 'diff', '--name-only', `${base}...HEAD`], { + encoding: 'utf8', + timeout: 4000, + stdio: ['ignore', 'pipe', 'ignore'] + }); + return out + .split('\n') + .map(s => s.trim()) + .filter(Boolean); + } catch { + return []; + } +} + +/** + * Map sessions to agent working sets. Only sessions with a worktree and at least + * one changed file participate (an agent with no edits cannot collide). + */ +function sessionsToAgents(sessions, deps = {}) { + const changedFilesFor = deps.changedFilesFor || defaultChangedFilesFor; + const agents = []; + for (const session of sessions || []) { + const files = changedFilesFor(session).map(p => ({ path: p, weight: 1 })); + if (files.length === 0) continue; + agents.push({ + agentId: session.id, + label: session.task || session.id, + startedAt: session.createdAt || null, + files + }); + } + return agents; +} + +/** + * Compute the proximity snapshot from the control-pane sessions. + * + * @param {Array} sessions normalized control-pane sessions + * @param {object} [options] { repoRoot, changedFilesFor, ...scanOptions } + * @returns {{ enabled, advisories, positions, links, counts }} + */ +function buildProximitySnapshot(sessions, options = {}) { + const repoRoot = path.resolve(options.repoRoot || path.join(__dirname, '..', '..', '..')); + const agents = sessionsToAgents(sessions, options); + + // Need at least two participating agents for a collision to be possible. + if (agents.length < 2) { + return { + enabled: true, + advisories: [], + positions: agents.map(a => ({ agentId: a.agentId, position: [0, 0, 0], fileCount: a.files.length })), + links: [], + counts: { agents: agents.length, advisories: 0, resolutions: 0 } + }; + } + + const touched = [...new Set(agents.flatMap(a => a.files.map(f => f.path)))]; + let graph = { adjacency: {}, files: [] }; + try { + graph = options.graph || buildDependencyGraph(repoRoot, touched, options.graphDeps || {}); + } catch { + graph = { adjacency: {}, files: [] }; + } + + const scan = scanAirspace(agents, graph, options); + const labels = new Map(agents.map(a => [a.agentId, a.label])); + return { + enabled: true, + advisories: scan.advisories.map(adv => ({ + ...adv, + aLabel: labels.get(adv.a) || adv.a, + bLabel: labels.get(adv.b) || adv.b + })), + positions: scan.positions, + links: scan.links, + counts: scan.counts + }; +} + +module.exports = { + buildProximitySnapshot, + sessionsToAgents, + defaultChangedFilesFor +}; diff --git a/scripts/lib/control-pane/state.js b/scripts/lib/control-pane/state.js index af10fc6a..b6c41d05 100644 --- a/scripts/lib/control-pane/state.js +++ b/scripts/lib/control-pane/state.js @@ -633,6 +633,14 @@ async function buildControlPaneSnapshot(options = {}) { const entities = readEntities(db); const observations = readObservations(db); const relationCounts = readRelationCounts(db); + // Proximity (agent-space collision avoidance) is opt-in: it shells `git diff` + // per worktree, so we only compute it when explicitly requested to keep the + // default snapshot fast. + let proximity = null; + if (options.includeProximity) { + const { buildProximitySnapshot } = require('./proximity'); + proximity = buildProximitySnapshot(sessions, { repoRoot, ...(options.proximityOptions || {}) }); + } return { ...base, summary: summarizeSessions(sessions), @@ -649,7 +657,8 @@ async function buildControlPaneSnapshot(options = {}) { limit }) }, - connectors: connectorStatus(config, db) + connectors: connectorStatus(config, db), + proximity }; } finally { db.close(); diff --git a/tests/lib/control-pane-proximity.test.js b/tests/lib/control-pane-proximity.test.js new file mode 100644 index 00000000..e15bc51d --- /dev/null +++ b/tests/lib/control-pane-proximity.test.js @@ -0,0 +1,91 @@ +'use strict'; +/** + * Tests for the control-pane proximity integration (sessions -> airspace scan). + */ + +const assert = require('assert'); + +const { buildProximitySnapshot, sessionsToAgents } = require('../../scripts/lib/control-pane/proximity'); + +let passed = 0; +let failed = 0; +function test(name, fn) { + try { + fn(); + console.log(` PASS ${name}`); + passed += 1; + } catch (e) { + console.log(` FAIL ${name}`); + console.log(` ${e.message}`); + failed += 1; + } +} + +const sessions = [ + { + id: 'lead-hermes', + task: 'Build the API', + createdAt: '2026-06-19T10:00:00Z', + worktree: { path: '/wt/lead', base: 'main' } + }, + { + id: 'worker-kb', + task: 'Also touch the API', + createdAt: '2026-06-19T10:05:00Z', + worktree: { path: '/wt/worker', base: 'main' } + }, + { + id: 'docs-bot', + task: 'Write docs', + createdAt: '2026-06-19T10:06:00Z', + worktree: { path: '/wt/docs', base: 'main' } + }, + { id: 'no-worktree', task: 'idle', createdAt: '2026-06-19T10:07:00Z', worktree: null } +]; + +// Injected working sets: lead + worker both edit the same API file (collision); +// docs-bot edits an unrelated file (clear). +const changedFilesFor = session => + ({ + 'lead-hermes': ['src/api/users.js'], + 'worker-kb': ['src/api/users.js'], + 'docs-bot': ['docs/guide.md'], + 'no-worktree': [] + })[session.id] || []; + +test('sessionsToAgents: only worktree sessions with edits participate', () => { + const agents = sessionsToAgents(sessions, { changedFilesFor }); + assert.deepStrictEqual(agents.map(a => a.agentId).sort(), ['docs-bot', 'lead-hermes', 'worker-kb']); + assert.strictEqual(agents.find(a => a.agentId === 'lead-hermes').files[0].path, 'src/api/users.js'); +}); + +test('buildProximitySnapshot: same-file editors get a resolution; the later one steers', () => { + const prox = buildProximitySnapshot(sessions, { changedFilesFor, graph: { adjacency: {} } }); + assert.strictEqual(prox.enabled, true); + assert.strictEqual(prox.counts.agents, 3); + const collision = prox.advisories.find(a => [a.a, a.b].includes('lead-hermes') && [a.a, a.b].includes('worker-kb')); + assert.ok(collision, 'lead/worker should produce an advisory'); + assert.strictEqual(collision.level, 'resolution', `level ${collision.level} risk ${collision.risk}`); + // lead started earlier ⇒ holds; worker steers. + assert.strictEqual(collision.steer, 'worker-kb'); + assert.strictEqual(collision.hold, 'lead-hermes'); + // docs-bot is clear of both. + assert.ok(!prox.advisories.some(a => a.a === 'docs-bot' || a.b === 'docs-bot')); + // every participating agent gets a 3D position. + assert.strictEqual(prox.positions.length, 3); +}); + +test('buildProximitySnapshot: fewer than two participants ⇒ no advisories', () => { + const single = buildProximitySnapshot([sessions[0]], { changedFilesFor }); + assert.strictEqual(single.counts.agents, 1); + assert.strictEqual(single.advisories.length, 0); +}); + +test('buildProximitySnapshot: advisories carry human-readable labels', () => { + const prox = buildProximitySnapshot(sessions, { changedFilesFor, graph: { adjacency: {} } }); + const collision = prox.advisories[0]; + assert.ok(collision.aLabel && collision.bLabel, 'labels present'); +}); + +console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); +if (failed > 0) process.exit(1);