Initial release: MCP server enforcing Worker-Reviewer loop

Diligence prevents AI agents from shipping quick fixes that break things
by enforcing a research-propose-verify loop before any code changes.

Key features:
- Worker sub-agent researches and proposes with file:line citations
- Reviewer sub-agent independently verifies claims by searching codebase
- Iterates until approved (max 5 rounds)
- Loads project-specific context from .claude/CODEBASE_CONTEXT.md
- State persisted across sessions

Validated on production codebase: caught architectural mistake (broker
subscriptions on client-side code) that naive agent would have shipped.
This commit is contained in:
2026-01-22 06:22:59 +01:00
commit bd178fcaf0
23 changed files with 4001 additions and 0 deletions

229
test/mcp-client.mjs Normal file
View File

@@ -0,0 +1,229 @@
#!/usr/bin/env node
/**
* MCP Test Client
*
* Programmatically tests the diligence MCP server by:
* 1. Spawning the server as a child process
* 2. Sending JSON-RPC messages via stdio
* 3. Receiving and parsing responses
*
* Usage:
* const client = new McpClient();
* await client.connect();
* const result = await client.callTool('status', {});
* await client.disconnect();
*/
import { spawn } from 'child_process';
import { createInterface } from 'readline';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
export class McpClient {
constructor(serverPath = join(__dirname, '..', 'index.mjs')) {
this.serverPath = serverPath;
this.process = null;
this.requestId = 0;
this.pendingRequests = new Map();
this.readline = null;
}
async connect() {
return new Promise((resolve, reject) => {
this.process = spawn('node', [this.serverPath], {
stdio: ['pipe', 'pipe', 'pipe'],
cwd: join(__dirname, 'fixture'), // Run in fixture directory
});
this.readline = createInterface({
input: this.process.stdout,
crlfDelay: Infinity,
});
this.readline.on('line', (line) => {
try {
const message = JSON.parse(line);
if (message.id !== undefined && this.pendingRequests.has(message.id)) {
const { resolve, reject } = this.pendingRequests.get(message.id);
this.pendingRequests.delete(message.id);
if (message.error) {
reject(new Error(message.error.message || JSON.stringify(message.error)));
} else {
resolve(message.result);
}
}
} catch (e) {
// Ignore non-JSON lines
}
});
this.process.stderr.on('data', (data) => {
// Server logs to stderr
if (process.env.DEBUG) {
console.error('[server]', data.toString());
}
});
this.process.on('error', reject);
this.process.on('exit', (code) => {
if (code !== 0 && code !== null) {
console.error(`Server exited with code ${code}`);
}
});
// Initialize the MCP connection
this._send({
jsonrpc: '2.0',
id: this.requestId++,
method: 'initialize',
params: {
protocolVersion: '0.1.0',
clientInfo: { name: 'test-client', version: '1.0.0' },
capabilities: {},
},
}).then(() => {
// Send initialized notification
this._sendNotification('notifications/initialized', {});
resolve();
}).catch(reject);
});
}
async disconnect() {
if (this.process) {
this.process.kill('SIGTERM');
this.process = null;
}
if (this.readline) {
this.readline.close();
this.readline = null;
}
}
_send(message) {
return new Promise((resolve, reject) => {
if (!this.process) {
reject(new Error('Not connected'));
return;
}
this.pendingRequests.set(message.id, { resolve, reject });
this.process.stdin.write(JSON.stringify(message) + '\n');
// Timeout after 10 seconds
setTimeout(() => {
if (this.pendingRequests.has(message.id)) {
this.pendingRequests.delete(message.id);
reject(new Error('Request timeout'));
}
}, 10000);
});
}
_sendNotification(method, params) {
if (!this.process) return;
this.process.stdin.write(JSON.stringify({
jsonrpc: '2.0',
method,
params,
}) + '\n');
}
async listTools() {
const result = await this._send({
jsonrpc: '2.0',
id: this.requestId++,
method: 'tools/list',
params: {},
});
return result.tools;
}
async callTool(name, args = {}) {
const result = await this._send({
jsonrpc: '2.0',
id: this.requestId++,
method: 'tools/call',
params: { name, arguments: args },
});
// Extract text from content array
if (result.content && result.content[0] && result.content[0].text) {
return {
text: result.content[0].text,
isError: result.isError || false,
};
}
return result;
}
// Convenience methods for common workflows
async status() {
return this.callTool('status');
}
async start(task) {
return this.callTool('start', { task });
}
async propose(proposal) {
return this.callTool('propose', { proposal });
}
async review(decision, reasoning) {
return this.callTool('review', { decision, reasoning });
}
async getWorkerBrief() {
return this.callTool('get_worker_brief');
}
async getReviewerBrief() {
return this.callTool('get_reviewer_brief');
}
async implement() {
return this.callTool('implement');
}
async complete(summary) {
return this.callTool('complete', { summary });
}
async abort(reason) {
return this.callTool('abort', { reason });
}
async approve(reason) {
return this.callTool('approve', { reason });
}
}
// CLI usage for quick testing
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const client = new McpClient();
try {
console.log('Connecting to MCP server...');
await client.connect();
console.log('Connected!\n');
// List tools
const tools = await client.listTools();
console.log('Available tools:');
tools.forEach(t => console.log(` - ${t.name}: ${t.description.slice(0, 60)}...`));
console.log();
// Check status
const status = await client.status();
console.log('Status:');
console.log(status.text);
await client.disconnect();
console.log('\nDisconnected.');
} catch (err) {
console.error('Error:', err.message);
await client.disconnect();
process.exit(1);
}
}