mcp-coordinator 0.4.0 → 0.6.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.
Files changed (36) hide show
  1. package/README.md +938 -846
  2. package/dashboard/Dockerfile +19 -19
  3. package/dashboard/public/index.html +1201 -1178
  4. package/dist/cli/server/start.js +33 -0
  5. package/dist/src/agent-activity.js +6 -6
  6. package/dist/src/agent-registry.js +6 -6
  7. package/dist/src/announce-workflow.d.ts +1 -0
  8. package/dist/src/announce-workflow.js +28 -0
  9. package/dist/src/consultation.js +20 -20
  10. package/dist/src/database.js +191 -126
  11. package/dist/src/dependency-map.js +3 -3
  12. package/dist/src/file-tracker.d.ts +2 -0
  13. package/dist/src/file-tracker.js +13 -12
  14. package/dist/src/git-cochange-builder.d.ts +32 -0
  15. package/dist/src/git-cochange-builder.js +238 -0
  16. package/dist/src/http/handle-health.d.ts +1 -1
  17. package/dist/src/http/handle-health.js +26 -0
  18. package/dist/src/http/handle-rest.js +98 -2
  19. package/dist/src/http/utils.d.ts +0 -4
  20. package/dist/src/http/utils.js +16 -2
  21. package/dist/src/impact-scorer.d.ts +5 -1
  22. package/dist/src/impact-scorer.js +98 -8
  23. package/dist/src/introspection.js +1 -1
  24. package/dist/src/metrics.d.ts +5 -0
  25. package/dist/src/metrics.js +33 -0
  26. package/dist/src/path-normalize.d.ts +17 -0
  27. package/dist/src/path-normalize.js +38 -0
  28. package/dist/src/serve-http.js +41 -2
  29. package/dist/src/server-setup.d.ts +6 -0
  30. package/dist/src/server-setup.js +23 -3
  31. package/dist/src/tools/consultation-tools.js +4 -2
  32. package/dist/src/tree-sitter-extractor.d.ts +36 -0
  33. package/dist/src/tree-sitter-extractor.js +354 -0
  34. package/dist/src/working-files-tracker.d.ts +42 -0
  35. package/dist/src/working-files-tracker.js +111 -0
  36. package/package.json +100 -83
@@ -8,7 +8,32 @@ export function createServerStartCommand() {
8
8
  .option("--port <port>", "Server port")
9
9
  .option("--data-dir <path>", "Data directory")
10
10
  .option("--daemon", "Run as background daemon")
11
+ .option("--repo-root <path>", "Project repo root (enables Layer 4 + FS fallback). Default env COORDINATOR_REPO_ROOT.")
12
+ .option("--max-body-bytes <bytes>", "Max HTTP request body in bytes. Default 1048576.")
13
+ .option("--working-files-ttl-min <minutes>", "TTL for working_files claims. Default 30.")
14
+ .option("--working-files-sweep-ms <ms>", "TTL sweeper tick interval. Default 60000.")
15
+ .option("--layer4-since-days <days>", "git log --since window. Default 7.")
16
+ .option("--layer4-max-commits <count>", "git log --max-count. Default 2000.")
17
+ .option("--layer4-refresh-ms <ms>", "Layer 4 successful-build refresh interval. Default 1800000.")
18
+ .option("--layer4-retry-ms <ms>", "Layer 4 retry interval on timeout. Default 300000.")
11
19
  .action(async (opts) => {
20
+ // Wire CLI flags to env vars (CLI takes precedence; rest of codebase reads from env)
21
+ if (opts.repoRoot)
22
+ process.env.COORDINATOR_REPO_ROOT = opts.repoRoot;
23
+ if (opts.maxBodyBytes)
24
+ process.env.COORDINATOR_MAX_BODY_BYTES = opts.maxBodyBytes;
25
+ if (opts.workingFilesTtlMin)
26
+ process.env.COORDINATOR_WORKING_FILES_TTL_MIN = opts.workingFilesTtlMin;
27
+ if (opts.workingFilesSweepMs)
28
+ process.env.COORDINATOR_WORKING_FILES_SWEEP_INTERVAL_MS = opts.workingFilesSweepMs;
29
+ if (opts.layer4SinceDays)
30
+ process.env.COORDINATOR_LAYER4_SINCE_DAYS = opts.layer4SinceDays;
31
+ if (opts.layer4MaxCommits)
32
+ process.env.COORDINATOR_LAYER4_MAX_COMMITS = opts.layer4MaxCommits;
33
+ if (opts.layer4RefreshMs)
34
+ process.env.COORDINATOR_LAYER4_REFRESH_INTERVAL_MS = opts.layer4RefreshMs;
35
+ if (opts.layer4RetryMs)
36
+ process.env.COORDINATOR_LAYER4_RETRY_MS = opts.layer4RetryMs;
12
37
  const config = loadConfig();
13
38
  const port = parseInt(opts.port ?? process.env.PORT ?? String(config.server.port), 10);
14
39
  const dataDir = opts.dataDir ?? process.env.COORDINATOR_DATA_DIR ?? config.server.data_dir;
@@ -47,6 +72,14 @@ export function createServerStartCommand() {
47
72
  fwd("COORDINATOR_ADMIN_SECRET", process.env.COORDINATOR_ADMIN_SECRET);
48
73
  fwd("COORDINATOR_MQTT_TCP_PORT", process.env.COORDINATOR_MQTT_TCP_PORT);
49
74
  fwd("COORDINATOR_MQTT_WS_PATH", process.env.COORDINATOR_MQTT_WS_PATH);
75
+ fwd("COORDINATOR_REPO_ROOT", process.env.COORDINATOR_REPO_ROOT);
76
+ fwd("COORDINATOR_MAX_BODY_BYTES", process.env.COORDINATOR_MAX_BODY_BYTES);
77
+ fwd("COORDINATOR_WORKING_FILES_TTL_MIN", process.env.COORDINATOR_WORKING_FILES_TTL_MIN);
78
+ fwd("COORDINATOR_WORKING_FILES_SWEEP_INTERVAL_MS", process.env.COORDINATOR_WORKING_FILES_SWEEP_INTERVAL_MS);
79
+ fwd("COORDINATOR_LAYER4_SINCE_DAYS", process.env.COORDINATOR_LAYER4_SINCE_DAYS);
80
+ fwd("COORDINATOR_LAYER4_MAX_COMMITS", process.env.COORDINATOR_LAYER4_MAX_COMMITS);
81
+ fwd("COORDINATOR_LAYER4_REFRESH_INTERVAL_MS", process.env.COORDINATOR_LAYER4_REFRESH_INTERVAL_MS);
82
+ fwd("COORDINATOR_LAYER4_RETRY_MS", process.env.COORDINATOR_LAYER4_RETRY_MS);
50
83
  const child = spawn(cmd, args, {
51
84
  detached: true,
52
85
  stdio: ["ignore", logFd, logFd],
@@ -59,12 +59,12 @@ export class AgentActivityTracker {
59
59
  // ── Private ──
60
60
  upsert(agentId, status, file, thread) {
61
61
  const db = getDb();
62
- db.prepare(`INSERT INTO agent_activity_status (agent_id, activity_status, current_file, current_thread, last_activity_at)
63
- VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
64
- ON CONFLICT(agent_id) DO UPDATE SET
65
- activity_status = excluded.activity_status,
66
- current_file = excluded.current_file,
67
- current_thread = excluded.current_thread,
62
+ db.prepare(`INSERT INTO agent_activity_status (agent_id, activity_status, current_file, current_thread, last_activity_at)
63
+ VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
64
+ ON CONFLICT(agent_id) DO UPDATE SET
65
+ activity_status = excluded.activity_status,
66
+ current_file = excluded.current_file,
67
+ current_thread = excluded.current_thread,
68
68
  last_activity_at = CURRENT_TIMESTAMP`).run(agentId, status, file, thread);
69
69
  }
70
70
  }
@@ -2,12 +2,12 @@ import { getDb } from "./database.js";
2
2
  export class AgentRegistry {
3
3
  register(agentId, name, modules) {
4
4
  const db = getDb();
5
- db.prepare(`INSERT INTO agents (id, name, modules, status, registered_at, last_seen_at)
6
- VALUES (?, ?, ?, 'online', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
7
- ON CONFLICT(id) DO UPDATE SET
8
- name = excluded.name,
9
- modules = excluded.modules,
10
- status = 'online',
5
+ db.prepare(`INSERT INTO agents (id, name, modules, status, registered_at, last_seen_at)
6
+ VALUES (?, ?, ?, 'online', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
7
+ ON CONFLICT(id) DO UPDATE SET
8
+ name = excluded.name,
9
+ modules = excluded.modules,
10
+ status = 'online',
11
11
  last_seen_at = CURRENT_TIMESTAMP`).run(agentId, name, JSON.stringify(modules));
12
12
  return this.get(agentId);
13
13
  }
@@ -42,6 +42,7 @@ export interface CommonFlowParams {
42
42
  depends_on_files?: string[];
43
43
  exports_affected?: string[];
44
44
  keep_open?: boolean;
45
+ target_symbols?: string[];
45
46
  }
46
47
  /**
47
48
  * Run the common post-`announceWork` orchestration. Mutates the thread row
@@ -15,7 +15,16 @@ export function runCommonAnnounceFlow(services, threadId, params) {
15
15
  target_files: params.target_files,
16
16
  depends_on_files: params.depends_on_files,
17
17
  exports_affected: params.exports_affected,
18
+ target_symbols: params.target_symbols,
18
19
  });
20
+ // Layer firing log: one row per concerned/gray-zone scored agent.
21
+ // Used by /api/scoring-stats and the dashboard "Conflict signals" panel.
22
+ const dbForFirings = getDb();
23
+ const insertFiring = dbForFirings.prepare("INSERT INTO layer_firings (thread_id, layer, score, agent_id) VALUES (?, ?, ?, ?)");
24
+ for (const s of [...categorized.concerned, ...categorized.gray_zone]) {
25
+ const layer = inferLayerFromReasons(s.reasons);
26
+ insertFiring.run(threadId, layer, s.score, s.agent_id);
27
+ }
19
28
  // 2. Override expected_respondents on the thread with the scored set.
20
29
  // Auto-resolve only when truly alone — if peers are online but not concerned
21
30
  // (e.g., they haven't announced yet), keep the thread open so a subsequent
@@ -72,6 +81,25 @@ export function runCommonAnnounceFlow(services, threadId, params) {
72
81
  const respondents = JSON.parse(updated.expected_respondents || "[]");
73
82
  return { updated, categorized, respondents, planQuality };
74
83
  }
84
+ function inferLayerFromReasons(reasons) {
85
+ for (const r of reasons) {
86
+ if (r.includes("disjoint symbols"))
87
+ return "L0.5";
88
+ if (r.includes("announced same file") || r.includes("modifies my dependency") || r.includes("they depend on my target"))
89
+ return "L0";
90
+ if (r.includes("same file (in flight)"))
91
+ return "L1";
92
+ if (r.includes("same file"))
93
+ return "L1";
94
+ if (r.includes("co-change"))
95
+ return "L4";
96
+ if (r.includes("depends on"))
97
+ return "L2";
98
+ if (r.includes("module overlap"))
99
+ return "L3";
100
+ }
101
+ return "L1";
102
+ }
75
103
  function scoredCategory(s) {
76
104
  if (s.score >= 90)
77
105
  return "concerned";
@@ -92,7 +92,7 @@ export class Consultation {
92
92
  const assignedTo = params.assigned_to ?? null;
93
93
  const keepOpen = params.keep_open || assignedTo !== null;
94
94
  const autoResolve = respondentIds.length === 0 && !keepOpen;
95
- db.prepare(`INSERT INTO threads (id, initiator_id, subject, plan, target_modules, target_files, status, expected_respondents, resolved_at, depends_on_files, exports_affected, timeout_seconds, assigned_to)
95
+ db.prepare(`INSERT INTO threads (id, initiator_id, subject, plan, target_modules, target_files, status, expected_respondents, resolved_at, depends_on_files, exports_affected, timeout_seconds, assigned_to)
96
96
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, params.agent_id, params.subject, params.plan || null, JSON.stringify(params.target_modules), JSON.stringify(params.target_files), autoResolve ? "resolved" : "open", JSON.stringify(respondentIds), autoResolve ? new Date().toISOString() : null, JSON.stringify(params.depends_on_files || []), JSON.stringify(params.exports_affected || []), keepOpen ? 0 : 600, assignedTo);
97
97
  return { autoResolve, respondentIds, assignedTo };
98
98
  });
@@ -122,7 +122,7 @@ export class Consultation {
122
122
  const id = randomUUID();
123
123
  // Simple token estimate: ~4 chars per token for English/French
124
124
  const tokenEstimate = Math.ceil(params.content.length / 4);
125
- db.prepare(`INSERT INTO thread_messages (id, thread_id, agent_id, agent_name, type, content, context_snapshot, in_reply_to, round, token_estimate)
125
+ db.prepare(`INSERT INTO thread_messages (id, thread_id, agent_id, agent_name, type, content, context_snapshot, in_reply_to, round, token_estimate)
126
126
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, params.thread_id, params.agent_id, params.agent_name || null, params.type, params.content, params.context_snapshot || null, params.in_reply_to || null, thread.round, tokenEstimate);
127
127
  this.log.debug({
128
128
  thread_id: params.thread_id,
@@ -252,21 +252,21 @@ export class Consultation {
252
252
  // thread IDs to emit for. The transaction makes both observe the same
253
253
  // snapshot.
254
254
  const tx = db.transaction(() => {
255
- const timedOut = db.prepare(`
256
- SELECT id FROM threads
257
- WHERE status IN ('open', 'resolving')
258
- AND timeout_seconds > 0
259
- AND datetime(created_at, '+' || (timeout_seconds * round) || ' seconds') < CURRENT_TIMESTAMP
255
+ const timedOut = db.prepare(`
256
+ SELECT id FROM threads
257
+ WHERE status IN ('open', 'resolving')
258
+ AND timeout_seconds > 0
259
+ AND datetime(created_at, '+' || (timeout_seconds * round) || ' seconds') < CURRENT_TIMESTAMP
260
260
  `).all();
261
261
  if (timedOut.length === 0)
262
262
  return [];
263
- db.prepare(`
264
- UPDATE threads SET status = 'resolved',
265
- resolution_summary = 'Résolu par timeout — pas de réponse dans le délai',
266
- resolved_at = CURRENT_TIMESTAMP
267
- WHERE status IN ('open', 'resolving')
268
- AND timeout_seconds > 0
269
- AND datetime(created_at, '+' || (timeout_seconds * round) || ' seconds') < CURRENT_TIMESTAMP
263
+ db.prepare(`
264
+ UPDATE threads SET status = 'resolved',
265
+ resolution_summary = 'Résolu par timeout — pas de réponse dans le délai',
266
+ resolved_at = CURRENT_TIMESTAMP
267
+ WHERE status IN ('open', 'resolving')
268
+ AND timeout_seconds > 0
269
+ AND datetime(created_at, '+' || (timeout_seconds * round) || ' seconds') < CURRENT_TIMESTAMP
270
270
  `).run();
271
271
  return timedOut;
272
272
  });
@@ -333,9 +333,9 @@ export class Consultation {
333
333
  }
334
334
  getThreadUpdates(agentId, since) {
335
335
  const db = getDb();
336
- let sql = `SELECT tm.* FROM thread_messages tm
337
- JOIN threads t ON tm.thread_id = t.id
338
- WHERE t.status IN ('open', 'resolving')
336
+ let sql = `SELECT tm.* FROM thread_messages tm
337
+ JOIN threads t ON tm.thread_id = t.id
338
+ WHERE t.status IN ('open', 'resolving')
339
339
  AND tm.agent_id != ?`;
340
340
  const params = [agentId];
341
341
  if (since) {
@@ -357,7 +357,7 @@ export class Consultation {
357
357
  logActionSummary(params) {
358
358
  const db = getDb();
359
359
  const id = randomUUID();
360
- db.prepare(`INSERT INTO action_summaries (id, session_id, agent_id, file_path, summary)
360
+ db.prepare(`INSERT INTO action_summaries (id, session_id, agent_id, file_path, summary)
361
361
  VALUES (?, ?, ?, ?, ?)`).run(id, params.session_id, params.agent_id, params.file_path || null, params.summary);
362
362
  return db.prepare("SELECT * FROM action_summaries WHERE id = ?").get(id);
363
363
  }
@@ -383,7 +383,7 @@ export class Consultation {
383
383
  const db = getDb();
384
384
  const thread = this.getThread(threadId);
385
385
  const id = randomUUID();
386
- db.prepare(`INSERT INTO thread_messages (id, thread_id, agent_id, type, content, round)
386
+ db.prepare(`INSERT INTO thread_messages (id, thread_id, agent_id, type, content, round)
387
387
  VALUES (?, ?, ?, ?, ?, ?)`).run(id, threadId, agentId, type, content, thread.round);
388
388
  }
389
389
  allRespondentsApproved(threadId) {
@@ -396,7 +396,7 @@ export class Consultation {
396
396
  // increments the round, and prior-round approvals must be re-collected
397
397
  // for the new proposal.
398
398
  const approvals = db
399
- .prepare(`SELECT DISTINCT agent_id FROM thread_messages
399
+ .prepare(`SELECT DISTINCT agent_id FROM thread_messages
400
400
  WHERE thread_id = ? AND type = 'approve' AND round = ?`)
401
401
  .all(threadId, thread.round);
402
402
  const approvedIds = new Set(approvals.map((a) => a.agent_id));
@@ -3,132 +3,172 @@ import { mkdirSync } from "fs";
3
3
  import { createRequire } from "module";
4
4
  const require = createRequire(import.meta.url);
5
5
  let db;
6
- const SCHEMA = `
7
- CREATE TABLE IF NOT EXISTS agents (
8
- id TEXT PRIMARY KEY,
9
- name TEXT NOT NULL,
10
- modules TEXT DEFAULT '[]',
11
- status TEXT DEFAULT 'offline',
12
- registered_at TEXT DEFAULT CURRENT_TIMESTAMP,
13
- last_seen_at TEXT DEFAULT CURRENT_TIMESTAMP
14
- );
15
-
16
- CREATE TABLE IF NOT EXISTS threads (
17
- id TEXT PRIMARY KEY,
18
- initiator_id TEXT NOT NULL,
19
- subject TEXT NOT NULL,
20
- plan TEXT,
21
- target_modules TEXT DEFAULT '[]',
22
- target_files TEXT DEFAULT '[]',
23
- status TEXT DEFAULT 'open',
24
- resolution_summary TEXT,
25
- conflicts TEXT,
26
- round INTEGER DEFAULT 1,
27
- max_rounds INTEGER DEFAULT 4,
28
- timeout_seconds INTEGER DEFAULT 600,
29
- created_at TEXT DEFAULT CURRENT_TIMESTAMP,
30
- resolved_at TEXT,
31
- expected_respondents TEXT,
32
- depends_on_files TEXT,
33
- exports_affected TEXT,
34
- claimed_by TEXT,
35
- claimed_at TEXT,
36
- FOREIGN KEY (initiator_id) REFERENCES agents(id)
37
- );
38
-
39
- CREATE TABLE IF NOT EXISTS thread_messages (
40
- id TEXT PRIMARY KEY,
41
- thread_id TEXT NOT NULL,
42
- agent_id TEXT NOT NULL,
43
- agent_name TEXT,
44
- type TEXT NOT NULL,
45
- content TEXT NOT NULL,
46
- context_snapshot TEXT,
47
- in_reply_to TEXT,
48
- round INTEGER NOT NULL,
49
- token_estimate INTEGER DEFAULT 0,
50
- created_at TEXT DEFAULT CURRENT_TIMESTAMP,
51
- FOREIGN KEY (thread_id) REFERENCES threads(id),
52
- FOREIGN KEY (agent_id) REFERENCES agents(id)
53
- );
54
-
55
- CREATE TABLE IF NOT EXISTS action_summaries (
56
- id TEXT PRIMARY KEY,
57
- session_id TEXT NOT NULL,
58
- agent_id TEXT NOT NULL,
59
- file_path TEXT,
60
- summary TEXT NOT NULL,
61
- created_at TEXT DEFAULT CURRENT_TIMESTAMP,
62
- FOREIGN KEY (agent_id) REFERENCES agents(id)
63
- );
64
-
65
- CREATE TABLE IF NOT EXISTS events (
66
- id INTEGER PRIMARY KEY AUTOINCREMENT,
67
- type TEXT NOT NULL,
68
- payload TEXT NOT NULL,
69
- created_at TEXT DEFAULT CURRENT_TIMESTAMP
70
- );
71
-
72
- CREATE TABLE IF NOT EXISTS dependency_map (
73
- module_id TEXT PRIMARY KEY,
74
- depends_on TEXT DEFAULT '[]',
75
- exports TEXT DEFAULT '[]',
76
- owners TEXT DEFAULT '[]'
77
- );
78
-
79
- CREATE TABLE IF NOT EXISTS file_activity (
80
- id INTEGER PRIMARY KEY AUTOINCREMENT,
81
- session_id TEXT NOT NULL,
82
- agent_id TEXT NOT NULL,
83
- agent_name TEXT,
84
- tool_name TEXT NOT NULL,
85
- file_path TEXT NOT NULL,
86
- module TEXT,
87
- created_at TEXT DEFAULT CURRENT_TIMESTAMP
88
- );
89
-
90
- CREATE INDEX IF NOT EXISTS idx_threads_status ON threads(status);
91
- CREATE INDEX IF NOT EXISTS idx_threads_initiator ON threads(initiator_id);
92
- CREATE INDEX IF NOT EXISTS idx_messages_thread ON thread_messages(thread_id);
93
- CREATE INDEX IF NOT EXISTS idx_messages_agent ON thread_messages(agent_id);
94
- CREATE INDEX IF NOT EXISTS idx_summaries_agent ON action_summaries(agent_id);
95
- CREATE INDEX IF NOT EXISTS idx_summaries_session ON action_summaries(session_id);
96
- CREATE INDEX IF NOT EXISTS idx_events_type ON events(type);
97
- CREATE INDEX IF NOT EXISTS idx_file_activity_agent ON file_activity(agent_id);
98
- CREATE INDEX IF NOT EXISTS idx_file_activity_path ON file_activity(file_path);
99
-
100
- CREATE TABLE IF NOT EXISTS introspections (
101
- id TEXT PRIMARY KEY,
102
- thread_id TEXT NOT NULL,
103
- agent_id TEXT NOT NULL,
104
- score INTEGER NOT NULL,
105
- reasons TEXT,
106
- status TEXT DEFAULT 'pending',
107
- response TEXT,
108
- concerned INTEGER DEFAULT 0,
109
- created_at TEXT DEFAULT CURRENT_TIMESTAMP,
110
- responded_at TEXT,
111
- FOREIGN KEY (thread_id) REFERENCES threads(id),
112
- FOREIGN KEY (agent_id) REFERENCES agents(id)
113
- );
114
-
115
- CREATE INDEX IF NOT EXISTS idx_introspections_agent ON introspections(agent_id);
116
- CREATE INDEX IF NOT EXISTS idx_introspections_status ON introspections(status);
117
-
118
- CREATE TABLE IF NOT EXISTS agent_activity_status (
119
- agent_id TEXT PRIMARY KEY,
120
- activity_status TEXT DEFAULT 'idle',
121
- current_file TEXT,
122
- current_thread TEXT,
123
- last_activity_at TEXT DEFAULT CURRENT_TIMESTAMP,
124
- FOREIGN KEY (agent_id) REFERENCES agents(id)
125
- );
126
-
127
- CREATE TABLE IF NOT EXISTS revoked_agents (
128
- agent_id TEXT PRIMARY KEY,
129
- revoked_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
130
- revoked_by TEXT NOT NULL
131
- );
6
+ const CURRENT_USER_VERSION = 6;
7
+ const SCHEMA = `
8
+ CREATE TABLE IF NOT EXISTS agents (
9
+ id TEXT PRIMARY KEY,
10
+ name TEXT NOT NULL,
11
+ modules TEXT DEFAULT '[]',
12
+ status TEXT DEFAULT 'offline',
13
+ registered_at TEXT DEFAULT CURRENT_TIMESTAMP,
14
+ last_seen_at TEXT DEFAULT CURRENT_TIMESTAMP
15
+ );
16
+
17
+ CREATE TABLE IF NOT EXISTS threads (
18
+ id TEXT PRIMARY KEY,
19
+ initiator_id TEXT NOT NULL,
20
+ subject TEXT NOT NULL,
21
+ plan TEXT,
22
+ target_modules TEXT DEFAULT '[]',
23
+ target_files TEXT DEFAULT '[]',
24
+ status TEXT DEFAULT 'open',
25
+ resolution_summary TEXT,
26
+ conflicts TEXT,
27
+ round INTEGER DEFAULT 1,
28
+ max_rounds INTEGER DEFAULT 4,
29
+ timeout_seconds INTEGER DEFAULT 600,
30
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
31
+ resolved_at TEXT,
32
+ expected_respondents TEXT,
33
+ depends_on_files TEXT,
34
+ exports_affected TEXT,
35
+ claimed_by TEXT,
36
+ claimed_at TEXT,
37
+ FOREIGN KEY (initiator_id) REFERENCES agents(id)
38
+ );
39
+
40
+ CREATE TABLE IF NOT EXISTS thread_messages (
41
+ id TEXT PRIMARY KEY,
42
+ thread_id TEXT NOT NULL,
43
+ agent_id TEXT NOT NULL,
44
+ agent_name TEXT,
45
+ type TEXT NOT NULL,
46
+ content TEXT NOT NULL,
47
+ context_snapshot TEXT,
48
+ in_reply_to TEXT,
49
+ round INTEGER NOT NULL,
50
+ token_estimate INTEGER DEFAULT 0,
51
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
52
+ FOREIGN KEY (thread_id) REFERENCES threads(id),
53
+ FOREIGN KEY (agent_id) REFERENCES agents(id)
54
+ );
55
+
56
+ CREATE TABLE IF NOT EXISTS action_summaries (
57
+ id TEXT PRIMARY KEY,
58
+ session_id TEXT NOT NULL,
59
+ agent_id TEXT NOT NULL,
60
+ file_path TEXT,
61
+ summary TEXT NOT NULL,
62
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
63
+ FOREIGN KEY (agent_id) REFERENCES agents(id)
64
+ );
65
+
66
+ CREATE TABLE IF NOT EXISTS events (
67
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
68
+ type TEXT NOT NULL,
69
+ payload TEXT NOT NULL,
70
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP
71
+ );
72
+
73
+ CREATE TABLE IF NOT EXISTS dependency_map (
74
+ module_id TEXT PRIMARY KEY,
75
+ depends_on TEXT DEFAULT '[]',
76
+ exports TEXT DEFAULT '[]',
77
+ owners TEXT DEFAULT '[]'
78
+ );
79
+
80
+ CREATE TABLE IF NOT EXISTS file_activity (
81
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
82
+ session_id TEXT NOT NULL,
83
+ agent_id TEXT NOT NULL,
84
+ agent_name TEXT,
85
+ tool_name TEXT NOT NULL,
86
+ file_path TEXT NOT NULL,
87
+ module TEXT,
88
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP
89
+ );
90
+
91
+ CREATE INDEX IF NOT EXISTS idx_threads_status ON threads(status);
92
+ CREATE INDEX IF NOT EXISTS idx_threads_initiator ON threads(initiator_id);
93
+ CREATE INDEX IF NOT EXISTS idx_messages_thread ON thread_messages(thread_id);
94
+ CREATE INDEX IF NOT EXISTS idx_messages_agent ON thread_messages(agent_id);
95
+ CREATE INDEX IF NOT EXISTS idx_summaries_agent ON action_summaries(agent_id);
96
+ CREATE INDEX IF NOT EXISTS idx_summaries_session ON action_summaries(session_id);
97
+ CREATE INDEX IF NOT EXISTS idx_events_type ON events(type);
98
+ CREATE INDEX IF NOT EXISTS idx_file_activity_agent ON file_activity(agent_id);
99
+ CREATE INDEX IF NOT EXISTS idx_file_activity_path ON file_activity(file_path);
100
+
101
+ CREATE TABLE IF NOT EXISTS introspections (
102
+ id TEXT PRIMARY KEY,
103
+ thread_id TEXT NOT NULL,
104
+ agent_id TEXT NOT NULL,
105
+ score INTEGER NOT NULL,
106
+ reasons TEXT,
107
+ status TEXT DEFAULT 'pending',
108
+ response TEXT,
109
+ concerned INTEGER DEFAULT 0,
110
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
111
+ responded_at TEXT,
112
+ FOREIGN KEY (thread_id) REFERENCES threads(id),
113
+ FOREIGN KEY (agent_id) REFERENCES agents(id)
114
+ );
115
+
116
+ CREATE INDEX IF NOT EXISTS idx_introspections_agent ON introspections(agent_id);
117
+ CREATE INDEX IF NOT EXISTS idx_introspections_status ON introspections(status);
118
+
119
+ CREATE TABLE IF NOT EXISTS agent_activity_status (
120
+ agent_id TEXT PRIMARY KEY,
121
+ activity_status TEXT DEFAULT 'idle',
122
+ current_file TEXT,
123
+ current_thread TEXT,
124
+ last_activity_at TEXT DEFAULT CURRENT_TIMESTAMP,
125
+ FOREIGN KEY (agent_id) REFERENCES agents(id)
126
+ );
127
+
128
+ CREATE TABLE IF NOT EXISTS revoked_agents (
129
+ agent_id TEXT PRIMARY KEY,
130
+ revoked_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
131
+ revoked_by TEXT NOT NULL
132
+ );
133
+
134
+ CREATE TABLE IF NOT EXISTS working_files (
135
+ agent_id TEXT NOT NULL,
136
+ file_path TEXT NOT NULL,
137
+ started_at TEXT NOT NULL,
138
+ last_activity_at TEXT NOT NULL,
139
+ claim_until TEXT NOT NULL,
140
+ PRIMARY KEY (agent_id, file_path)
141
+ );
142
+ CREATE INDEX IF NOT EXISTS idx_working_files_path ON working_files(file_path);
143
+ CREATE INDEX IF NOT EXISTS idx_working_files_until ON working_files(claim_until);
144
+
145
+ CREATE TABLE IF NOT EXISTS git_cochange (
146
+ file_a TEXT NOT NULL,
147
+ file_b TEXT NOT NULL,
148
+ count INTEGER NOT NULL,
149
+ total_commits INTEGER NOT NULL,
150
+ computed_at TEXT NOT NULL,
151
+ PRIMARY KEY (file_a, file_b),
152
+ CHECK (file_a < file_b)
153
+ );
154
+ CREATE INDEX IF NOT EXISTS idx_cochange_a ON git_cochange(file_a);
155
+ CREATE INDEX IF NOT EXISTS idx_cochange_b ON git_cochange(file_b);
156
+
157
+ CREATE TABLE IF NOT EXISTS git_cochange_meta (
158
+ k TEXT PRIMARY KEY,
159
+ v TEXT
160
+ );
161
+
162
+ CREATE TABLE IF NOT EXISTS layer_firings (
163
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
164
+ thread_id TEXT,
165
+ layer TEXT NOT NULL,
166
+ score INTEGER NOT NULL,
167
+ agent_id TEXT,
168
+ fired_at TEXT DEFAULT CURRENT_TIMESTAMP
169
+ );
170
+ CREATE INDEX IF NOT EXISTS idx_firings_layer ON layer_firings(layer, fired_at);
171
+ CREATE INDEX IF NOT EXISTS idx_firings_thread ON layer_firings(thread_id);
132
172
  `;
133
173
  function createBetterSqlite3(dataDir) {
134
174
  mkdirSync(dataDir, { recursive: true });
@@ -157,6 +197,20 @@ export function initDatabase(dataDir) {
157
197
  else {
158
198
  db = createBetterSqlite3(dataDir);
159
199
  }
200
+ // Check for downgrade: refuse if DB was written by a newer binary
201
+ let foundVersion = 0;
202
+ try {
203
+ const v = db
204
+ .prepare("PRAGMA user_version")
205
+ .get();
206
+ foundVersion = v?.user_version ?? 0;
207
+ }
208
+ catch {
209
+ foundVersion = 0;
210
+ }
211
+ if (foundVersion > CURRENT_USER_VERSION) {
212
+ throw new Error(`Database schema is from a newer version (${foundVersion}) than this binary supports (${CURRENT_USER_VERSION}). Downgrade not supported.`);
213
+ }
160
214
  db.exec(SCHEMA);
161
215
  // Migrations for existing databases — columns may already exist
162
216
  try {
@@ -182,6 +236,17 @@ export function initDatabase(dataDir) {
182
236
  db.exec("ALTER TABLE threads ADD COLUMN assigned_to TEXT");
183
237
  }
184
238
  catch { /* already exists */ }
239
+ // v0.6: per-edit symbol metadata on file_activity
240
+ try {
241
+ db.exec("ALTER TABLE file_activity ADD COLUMN symbols_touched TEXT");
242
+ }
243
+ catch { /* already exists */ }
244
+ try {
245
+ db.exec("ALTER TABLE file_activity ADD COLUMN content_hash TEXT");
246
+ }
247
+ catch { /* already exists */ }
248
+ // v0.6: schema version marker. Used by cli/server/restore.ts to refuse downgrades.
249
+ db.exec("PRAGMA user_version = 6");
185
250
  }
186
251
  export function getDb() {
187
252
  if (!db)
@@ -17,9 +17,9 @@ export class DependencyMapper {
17
17
  }
18
18
  setMap(map) {
19
19
  const db = getDb();
20
- const stmt = db.prepare(`INSERT INTO dependency_map (module_id, depends_on, exports, owners)
21
- VALUES (?, ?, ?, ?)
22
- ON CONFLICT(module_id) DO UPDATE SET
20
+ const stmt = db.prepare(`INSERT INTO dependency_map (module_id, depends_on, exports, owners)
21
+ VALUES (?, ?, ?, ?)
22
+ ON CONFLICT(module_id) DO UPDATE SET
23
23
  depends_on = excluded.depends_on, exports = excluded.exports, owners = excluded.owners`);
24
24
  withTransaction(db, () => {
25
25
  for (const [id, info] of Object.entries(map)) {
@@ -6,6 +6,8 @@ export declare class FileTracker {
6
6
  agent_name?: string;
7
7
  tool_name: string;
8
8
  file_path: string;
9
+ content_hash?: string | null;
10
+ symbols_touched?: string[] | null;
9
11
  }): void;
10
12
  getBySession(sessionId: string): FileActivity[];
11
13
  getHotFiles(sinceMinutes?: number): {
@@ -3,8 +3,9 @@ export class FileTracker {
3
3
  log(params) {
4
4
  const db = getDb();
5
5
  const module = this.fileToModule(params.file_path);
6
- db.prepare(`INSERT INTO file_activity (session_id, agent_id, agent_name, tool_name, file_path, module)
7
- VALUES (?, ?, ?, ?, ?, ?)`).run(params.session_id, params.agent_id, params.agent_name || null, params.tool_name, params.file_path, module);
6
+ db.prepare(`INSERT INTO file_activity
7
+ (session_id, agent_id, agent_name, tool_name, file_path, module, content_hash, symbols_touched)
8
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(params.session_id, params.agent_id, params.agent_name || null, params.tool_name, params.file_path, module, params.content_hash || null, params.symbols_touched ? JSON.stringify(params.symbols_touched) : null);
8
9
  }
9
10
  getBySession(sessionId) {
10
11
  const db = getDb();
@@ -12,11 +13,11 @@ export class FileTracker {
12
13
  }
13
14
  getHotFiles(sinceMinutes = 30) {
14
15
  const db = getDb();
15
- const rows = db.prepare(`SELECT file_path, COUNT(DISTINCT agent_id) as agent_count, GROUP_CONCAT(DISTINCT agent_id) as agents
16
- FROM file_activity
17
- WHERE created_at > datetime('now', '-' || ? || ' minutes')
18
- GROUP BY file_path
19
- HAVING COUNT(DISTINCT agent_id) > 1
16
+ const rows = db.prepare(`SELECT file_path, COUNT(DISTINCT agent_id) as agent_count, GROUP_CONCAT(DISTINCT agent_id) as agents
17
+ FROM file_activity
18
+ WHERE created_at > datetime('now', '-' || ? || ' minutes')
19
+ GROUP BY file_path
20
+ HAVING COUNT(DISTINCT agent_id) > 1
20
21
  ORDER BY agent_count DESC`).all(sinceMinutes);
21
22
  return rows.map((r) => ({
22
23
  file_path: r.file_path,
@@ -26,8 +27,8 @@ export class FileTracker {
26
27
  }
27
28
  checkFileConflict(filePath, agentId, withinMinutes = 30) {
28
29
  const db = getDb();
29
- const rows = db.prepare(`SELECT DISTINCT agent_id FROM file_activity
30
- WHERE file_path = ? AND agent_id != ?
30
+ const rows = db.prepare(`SELECT DISTINCT agent_id FROM file_activity
31
+ WHERE file_path = ? AND agent_id != ?
31
32
  AND created_at > datetime('now', '-' || ? || ' minutes')`).all(filePath, agentId, withinMinutes);
32
33
  return { conflict: rows.length > 0, agents: rows.map((r) => r.agent_id) };
33
34
  }
@@ -49,9 +50,9 @@ export class FileTracker {
49
50
  // the impact scorer only passes target_files + depends_on_files (typically
50
51
  // a handful of files per announce_work call).
51
52
  const placeholders = filePaths.map(() => "?").join(",");
52
- const rows = db.prepare(`SELECT DISTINCT file_path, agent_id FROM file_activity
53
- WHERE file_path IN (${placeholders})
54
- AND agent_id != ?
53
+ const rows = db.prepare(`SELECT DISTINCT file_path, agent_id FROM file_activity
54
+ WHERE file_path IN (${placeholders})
55
+ AND agent_id != ?
55
56
  AND created_at > datetime('now', '-' || ? || ' minutes')`).all(...filePaths, excludeAgentId, withinMinutes);
56
57
  for (const r of rows) {
57
58
  let set = index.get(r.file_path);