mcp-coordinator 0.4.0 → 0.5.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 CHANGED
@@ -666,6 +666,20 @@ Resolution priority (highest to lowest): CLI flag → env var → config.json
666
666
  | `COORDINATOR_ADMIN_SECRET` | — | Separate secret for admin token creation |
667
667
  | `MAX_QUOTA_PCT` | `95` | Pre-flight abort threshold for Anthropic quota |
668
668
 
669
+ ### Environment variables (v0.6)
670
+
671
+ | Variable | Default | Effect |
672
+ |---|---|---|
673
+ | `COORDINATOR_REPO_ROOT` | (unset → team-mode) | Repo root for path-guard, FS fallback, Layer 4 |
674
+ | `COORDINATOR_MAX_BODY_BYTES` | `1048576` | parseBody hard cap |
675
+ | `COORDINATOR_LAYER4_DENYLIST` | (uses defaults) | Comma-separated globs appended to denylist |
676
+ | `COORDINATOR_LAYER4_SINCE_DAYS` | `7` | git log --since window |
677
+ | `COORDINATOR_LAYER4_MAX_COMMITS` | `2000` | git log --max-count |
678
+ | `COORDINATOR_LAYER4_REFRESH_INTERVAL_MS` | `1800000` | Refresh on success |
679
+ | `COORDINATOR_LAYER4_RETRY_MS` | `300000` | Retry on timeout |
680
+ | `COORDINATOR_WORKING_FILES_TTL_MIN` | `30` | working_files claim TTL |
681
+ | `COORDINATOR_WORKING_FILES_SWEEP_INTERVAL_MS` | `60000` | TTL sweeper tick |
682
+
669
683
  ---
670
684
 
671
685
  ## Structured Logging
@@ -217,6 +217,16 @@
217
217
  <div id="token-total"></div>
218
218
  <div id="token-agents"></div>
219
219
 
220
+ <div class="panel-title" style="margin-top:16px;">Conflict signals (24h)</div>
221
+ <div id="conflict-signals">
222
+ <div class="metric"><span class="metric-label">L0 announced</span><span class="metric-value" data-layer="L0">—</span></div>
223
+ <div class="metric"><span class="metric-label">L1 same file</span><span class="metric-value" data-layer="L1">—</span></div>
224
+ <div class="metric"><span class="metric-label">L0.5 disjoint</span><span class="metric-value" data-layer="L0.5">—</span></div>
225
+ <div class="metric"><span class="metric-label">L2 depends_on</span><span class="metric-value" data-layer="L2">—</span></div>
226
+ <div class="metric"><span class="metric-label">L3 module</span><span class="metric-value" data-layer="L3">—</span></div>
227
+ <div class="metric"><span class="metric-label">L4 co-change</span><span class="metric-value" data-layer="L4">—</span></div>
228
+ </div>
229
+
220
230
  <div class="panel-title" style="margin-top:16px;">Configuration</div>
221
231
  <div id="run-config">
222
232
  <div style="color:#64748b;font-size:12px;">Aucun run actif</div>
@@ -1171,6 +1181,19 @@
1171
1181
  // Fetch config on load
1172
1182
  fetch(`${COORDINATOR_URL}/api/run-config`).then(r => r.json()).then(renderRunConfig).catch(() => {});
1173
1183
 
1184
+ async function refreshConflictSignals() {
1185
+ try {
1186
+ const r = await fetch(`${COORDINATOR_URL}/api/scoring-stats?since=24h`);
1187
+ const j = await r.json();
1188
+ for (const layer of j.layers) {
1189
+ const el = document.querySelector(`[data-layer="${layer.layer}"]`);
1190
+ if (el) el.textContent = layer.fire_count;
1191
+ }
1192
+ } catch {}
1193
+ }
1194
+ refreshConflictSignals();
1195
+ setInterval(refreshConflictSignals, 30000);
1196
+
1174
1197
  connectSSE();
1175
1198
  updateAgents();
1176
1199
  </script>
@@ -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],
@@ -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";
@@ -3,6 +3,7 @@ import { mkdirSync } from "fs";
3
3
  import { createRequire } from "module";
4
4
  const require = createRequire(import.meta.url);
5
5
  let db;
6
+ const CURRENT_USER_VERSION = 6;
6
7
  const SCHEMA = `
7
8
  CREATE TABLE IF NOT EXISTS agents (
8
9
  id TEXT PRIMARY KEY,
@@ -129,6 +130,45 @@ const SCHEMA = `
129
130
  revoked_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
130
131
  revoked_by TEXT NOT NULL
131
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)
@@ -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();
@@ -0,0 +1,32 @@
1
+ import { type Logger } from "./logger.js";
2
+ import type { Metrics } from "./metrics.js";
3
+ interface BuilderOpts {
4
+ repoRoot: string;
5
+ sinceDays?: number;
6
+ maxCount?: number;
7
+ timeoutMs?: number;
8
+ refreshMs?: number;
9
+ retryMs?: number;
10
+ logger?: Logger;
11
+ metrics?: Metrics;
12
+ }
13
+ export declare class GitCochangeBuilder {
14
+ private repoRoot;
15
+ private sinceDays;
16
+ private maxCount;
17
+ private timeoutMs;
18
+ private refreshMs;
19
+ private retryMs;
20
+ private log;
21
+ private metrics?;
22
+ private timer;
23
+ constructor(opts: BuilderOpts);
24
+ /** Build once. Resolves after persistence. */
25
+ build(): Promise<void>;
26
+ private runGitLog;
27
+ private parseLog;
28
+ /** Schedule a refresh loop. unref() so it doesn't keep the loop alive. */
29
+ startScheduler(): void;
30
+ stopScheduler(): void;
31
+ }
32
+ export {};
@@ -0,0 +1,238 @@
1
+ // src/git-cochange-builder.ts
2
+ import { spawn } from "child_process";
3
+ import { existsSync } from "fs";
4
+ import path from "path";
5
+ import { getDb } from "./database.js";
6
+ import { silentLogger } from "./logger.js";
7
+ const DEFAULT_DENYLIST = [
8
+ /package-lock\.json$/, /pnpm-lock\.yaml$/, /yarn\.lock$/, /\.lock$/,
9
+ /\/dist\//, /\/build\//, /\/\.next\//, /\/__snapshots__\//,
10
+ /\.min\.js$/, /\.map$/, /\/coverage\//, /\/node_modules\//, /\.snap$/,
11
+ ];
12
+ export class GitCochangeBuilder {
13
+ repoRoot;
14
+ sinceDays;
15
+ maxCount;
16
+ timeoutMs;
17
+ refreshMs;
18
+ retryMs;
19
+ log;
20
+ metrics;
21
+ timer = null;
22
+ constructor(opts) {
23
+ this.repoRoot = opts.repoRoot;
24
+ this.sinceDays = opts.sinceDays ?? 7;
25
+ this.maxCount = opts.maxCount ?? 2000;
26
+ this.timeoutMs = opts.timeoutMs ?? 5000;
27
+ this.refreshMs = opts.refreshMs ?? 1800000;
28
+ this.retryMs = opts.retryMs ?? 300000;
29
+ this.log = opts.logger || silentLogger;
30
+ this.metrics = opts.metrics;
31
+ }
32
+ /** Build once. Resolves after persistence. */
33
+ async build() {
34
+ const db = getDb();
35
+ const setMeta = (k, v) => db.prepare("INSERT OR REPLACE INTO git_cochange_meta (k, v) VALUES (?, ?)").run(k, v);
36
+ if (!existsSync(path.join(this.repoRoot, ".git"))) {
37
+ this.log.info({}, "Layer 4 unavailable: no .git");
38
+ setMeta("available", "false");
39
+ this.metrics?.gitCochangeBuilds.inc({ outcome: "failed" });
40
+ return;
41
+ }
42
+ if (existsSync(path.join(this.repoRoot, ".git", "shallow"))) {
43
+ this.log.info({}, "Layer 4 unavailable: shallow clone");
44
+ setMeta("available", "false");
45
+ this.metrics?.gitCochangeBuilds.inc({ outcome: "shallow_skipped" });
46
+ return;
47
+ }
48
+ let stdout = null;
49
+ try {
50
+ stdout = await this.runGitLog();
51
+ }
52
+ catch (err) {
53
+ this.log.warn({ err }, "git log failed");
54
+ setMeta("available", "false");
55
+ setMeta("last_error", String(err.message));
56
+ this.metrics?.gitCochangeBuilds.inc({ outcome: "failed" });
57
+ return;
58
+ }
59
+ if (stdout === "TIMEOUT") {
60
+ setMeta("available", "stale_partial");
61
+ this.log.warn({}, "git log timed out — Layer 4 stale_partial");
62
+ this.metrics?.gitCochangeBuilds.inc({ outcome: "timeout" });
63
+ return;
64
+ }
65
+ const { pairs, totalCommits } = this.parseLog(stdout);
66
+ // Dynamic predictor cap: any file appearing in > 40% of effective commits is
67
+ // excluded as a *predictor* (still allowed as a *target*). Prevents hotspot files
68
+ // like config or barrel index from saturating co-change with every other file.
69
+ const PREDICTOR_CAP_RATIO = 0.4;
70
+ const fileCommitCount = new Map();
71
+ for (const key of pairs.keys()) {
72
+ const [a, b] = key.split("|");
73
+ fileCommitCount.set(a, (fileCommitCount.get(a) ?? 0) + (pairs.get(key) ?? 0));
74
+ fileCommitCount.set(b, (fileCommitCount.get(b) ?? 0) + (pairs.get(key) ?? 0));
75
+ }
76
+ const promiscuous = new Set();
77
+ for (const [file, count] of fileCommitCount) {
78
+ // count is total times the file appeared in any pair; max possible is roughly
79
+ // (totalCommits) per file. Use raw count / totalCommits as an approximation
80
+ // of the file's commit frequency.
81
+ if (totalCommits > 0 && count / totalCommits > PREDICTOR_CAP_RATIO) {
82
+ promiscuous.add(file);
83
+ }
84
+ }
85
+ if (promiscuous.size > 0) {
86
+ this.log.info({ count: promiscuous.size, files: Array.from(promiscuous) }, "Layer 4 dynamic predictor cap excluded files");
87
+ }
88
+ db.exec("DELETE FROM git_cochange");
89
+ const stmt = db.prepare("INSERT INTO git_cochange (file_a, file_b, count, total_commits, computed_at) VALUES (?, ?, ?, ?, datetime('now'))");
90
+ const insertMany = db.transaction(() => {
91
+ for (const [key, count] of pairs.entries()) {
92
+ const [a, b] = key.split("|");
93
+ // Skip pairs where EITHER file is a promiscuous predictor (file is allowed
94
+ // as target only, but a pair where it's a predictor is dropped). For the
95
+ // index entry to be useful, both files must be non-promiscuous predictors.
96
+ if (promiscuous.has(a) || promiscuous.has(b))
97
+ continue;
98
+ if (a < b)
99
+ stmt.run(a, b, count, totalCommits);
100
+ }
101
+ });
102
+ insertMany();
103
+ setMeta("available", "true");
104
+ setMeta("last_built_at", new Date().toISOString());
105
+ this.metrics?.gitCochangeBuilds.inc({ outcome: "success" });
106
+ this.metrics?.gitCochangePairs.set(pairs.size);
107
+ }
108
+ runGitLog() {
109
+ return new Promise((resolve, reject) => {
110
+ const args = [
111
+ "log",
112
+ `--max-count=${this.maxCount}`,
113
+ "--diff-filter=AMRD",
114
+ `--since=${this.sinceDays} days ago`,
115
+ "--no-renames",
116
+ "--pretty=format:%H",
117
+ "--name-only",
118
+ "-z",
119
+ ];
120
+ const proc = spawn("git", args, { cwd: this.repoRoot });
121
+ let buf = "";
122
+ const timer = setTimeout(() => {
123
+ proc.kill();
124
+ resolve("TIMEOUT");
125
+ }, this.timeoutMs);
126
+ proc.stdout.on("data", (c) => (buf += c.toString("utf-8")));
127
+ proc.on("error", (err) => { clearTimeout(timer); reject(err); });
128
+ proc.on("close", (code) => {
129
+ clearTimeout(timer);
130
+ if (code === 0)
131
+ resolve(buf);
132
+ else
133
+ reject(new Error(`git log exit ${code}`));
134
+ });
135
+ });
136
+ }
137
+ parseLog(stdout) {
138
+ // git log -z --pretty=format:%H --name-only output format:
139
+ // Each commit entry: <SHA>\n<file1>\0<file2>\0...\0
140
+ // Between commits the NUL separator also acts as delimiter.
141
+ // We split on NUL first, then detect SHA boundaries within tokens.
142
+ const tokens = stdout.split("\0").filter(t => t.length > 0);
143
+ const pairs = new Map();
144
+ let totalCommits = 0;
145
+ let currentFiles = [];
146
+ const flush = () => {
147
+ if (currentFiles.length === 0)
148
+ return;
149
+ // Skip massive commits (likely sweeps)
150
+ if (currentFiles.length > 200) {
151
+ currentFiles = [];
152
+ return;
153
+ }
154
+ // Apply denylist
155
+ const eligible = currentFiles.filter(f => !DEFAULT_DENYLIST.some(re => re.test(f)));
156
+ for (let i = 0; i < eligible.length; i++) {
157
+ for (let j = i + 1; j < eligible.length; j++) {
158
+ const [a, b] = eligible[i] < eligible[j] ? [eligible[i], eligible[j]] : [eligible[j], eligible[i]];
159
+ const key = `${a}|${b}`;
160
+ pairs.set(key, (pairs.get(key) ?? 0) + 1);
161
+ }
162
+ }
163
+ totalCommits++;
164
+ currentFiles = [];
165
+ };
166
+ // SHA pattern: 40 hex chars
167
+ const shaRe = /^([0-9a-f]{40})\n(.*)$/s;
168
+ for (const t of tokens) {
169
+ // Each token after splitting on \0 may look like:
170
+ // "\nSHA40\npath" (commit boundary with preceding newline)
171
+ // "SHA40\npath" (commit boundary at start)
172
+ // "path" (file path continuation)
173
+ // "\nSHA40" (SHA only, no file on same token)
174
+ // Strip leading newlines to normalize
175
+ const stripped = t.replace(/^\n+/, "");
176
+ const shaMatch = stripped.match(shaRe);
177
+ if (shaMatch) {
178
+ // We found a SHA — flush the previous commit's files
179
+ flush();
180
+ const trailingPath = shaMatch[2].trim();
181
+ if (trailingPath)
182
+ currentFiles.push(trailingPath);
183
+ }
184
+ else {
185
+ // Check if this token itself IS a SHA (no file attached, happens when
186
+ // --pretty=format:%H emits the SHA on its own NUL-terminated chunk)
187
+ const pureSha = stripped.match(/^[0-9a-f]{40}$/);
188
+ if (pureSha) {
189
+ flush();
190
+ }
191
+ else {
192
+ // It's a file path (or part of one); newlines indicate embedded commit
193
+ // boundaries when a file is on the same NUL chunk as the next SHA.
194
+ // Handle the case where "path\nSHA\npath" might appear.
195
+ const parts = stripped.split("\n");
196
+ for (let i = 0; i < parts.length; i++) {
197
+ const part = parts[i].trim();
198
+ if (!part)
199
+ continue;
200
+ if (/^[0-9a-f]{40}$/.test(part)) {
201
+ flush();
202
+ }
203
+ else {
204
+ currentFiles.push(part);
205
+ }
206
+ }
207
+ }
208
+ }
209
+ }
210
+ flush();
211
+ return { pairs, totalCommits };
212
+ }
213
+ /** Schedule a refresh loop. unref() so it doesn't keep the loop alive. */
214
+ startScheduler() {
215
+ const tick = async () => {
216
+ try {
217
+ await this.build();
218
+ this.timer = setTimeout(tick, this.refreshMs);
219
+ }
220
+ catch (err) {
221
+ this.log.warn({ err }, "build failed, retrying");
222
+ this.timer = setTimeout(tick, this.retryMs);
223
+ }
224
+ if (this.timer && typeof this.timer.unref === "function")
225
+ this.timer.unref();
226
+ };
227
+ // First build after 5s grace
228
+ this.timer = setTimeout(tick, 5000);
229
+ if (this.timer && typeof this.timer.unref === "function")
230
+ this.timer.unref();
231
+ }
232
+ stopScheduler() {
233
+ if (this.timer) {
234
+ clearTimeout(this.timer);
235
+ this.timer = null;
236
+ }
237
+ }
238
+ }
@@ -13,7 +13,7 @@ export declare function handleLivez(_req: IncomingMessage, res: ServerResponse):
13
13
  * structured `{ok:false,error:"…"}` instead of a 500. The response shape is
14
14
  * identical between 200 and 503 so consumers can parse uniformly.
15
15
  */
16
- export declare function handleReadyz(_req: IncomingMessage, res: ServerResponse, services: Pick<CoordinatorServices, "mqttBridge">): void;
16
+ export declare function handleReadyz(_req: IncomingMessage, res: ServerResponse, services: Pick<CoordinatorServices, "mqttBridge" | "treeSitter" | "gitCochange">): void;
17
17
  /**
18
18
  * Backwards-compatible alias. The original /health route returned a fixed
19
19
  * {status:"ok",version} payload with no dep checks; semantically that is a
@@ -47,6 +47,8 @@ export function handleReadyz(_req, res, services) {
47
47
  const checks = {
48
48
  db: { ok: false },
49
49
  mqtt: { ok: false },
50
+ tree_sitter: { ok: false, grammars_loaded: 0, total_grammars: 7, optional: true },
51
+ git_cochange: { available: false, status: "unavailable", optional: true },
50
52
  };
51
53
  try {
52
54
  // Cheapest possible round-trip that exercises the connection without
@@ -69,6 +71,30 @@ export function handleReadyz(_req, res, services) {
69
71
  catch (err) {
70
72
  checks.mqtt.error = err.message;
71
73
  }
74
+ // Optional: tree-sitter status (does NOT gate readiness — Layer 0.5 degrades gracefully)
75
+ try {
76
+ if (services.treeSitter) {
77
+ checks.tree_sitter = services.treeSitter.status();
78
+ }
79
+ }
80
+ catch {
81
+ // keep default { ok: false, grammars_loaded: 0, total_grammars: 7, optional: true }
82
+ }
83
+ // Optional: git_cochange availability (does NOT gate readiness — Layer 4 degrades gracefully)
84
+ try {
85
+ const row = getDb()
86
+ .prepare("SELECT v FROM git_cochange_meta WHERE k = ?")
87
+ .get("available");
88
+ checks.git_cochange = {
89
+ available: row?.v === "true",
90
+ status: row?.v ?? "unavailable",
91
+ optional: true,
92
+ };
93
+ }
94
+ catch {
95
+ // keep default { available: false, status: "unavailable", optional: true }
96
+ }
97
+ // Gating: only db + mqtt block readiness. tree_sitter and git_cochange are reported but optional.
72
98
  const allOk = checks.db.ok && checks.mqtt.ok;
73
99
  json(res, {
74
100
  status: allOk ? "ready" : "not_ready",