Files
diligence/index.mjs
Marc J. Schmidt bd178fcaf0 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.
2026-01-22 06:22:59 +01:00

548 lines
18 KiB
JavaScript

#!/usr/bin/env node
/**
* Diligence MCP Server
*
* Enforces diligent code changes through a worker-reviewer loop.
* Prevents quick fixes that miss patterns, events, and edge cases.
*
* Workflow:
* 1. User describes task → conversation phase
* 2. Start work → research phase begins
* 3. Worker proposes → Reviewer challenges → repeat until approved
* 4. Approved → implementation phase
* 5. Complete → back to conversation
*
* Project-specific context is loaded from .claude/CODEBASE_CONTEXT.md
* Generic prompts are bundled with this server.
*/
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from 'fs';
import { join, dirname, basename } from 'path';
import { fileURLToPath } from 'url';
import { execSync } from 'child_process';
// =============================================================================
// Configuration
// =============================================================================
const __dirname = dirname(fileURLToPath(import.meta.url));
const MAX_ROUNDS = 5;
/**
* Find the project root by looking for .git directory.
* Falls back to current working directory.
*/
function findProjectRoot() {
try {
const gitRoot = execSync('git rev-parse --show-toplevel', {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe']
}).trim();
return gitRoot;
} catch {
return process.cwd();
}
}
const PROJECT_ROOT = findProjectRoot();
const STATE_DIR = join(PROJECT_ROOT, '.claude');
const STATE_FILE = join(STATE_DIR, '.diligence-state.json');
const CODEBASE_CONTEXT_FILE = join(STATE_DIR, 'CODEBASE_CONTEXT.md');
// Bundled prompts (shipped with this package)
const WORKER_PROMPT_FILE = join(__dirname, 'WORKER_PROMPT.md');
const REVIEWER_PROMPT_FILE = join(__dirname, 'REVIEWER_PROMPT.md');
// =============================================================================
// State Management
// =============================================================================
/**
* @typedef {Object} WorkflowState
* @property {'conversation' | 'researching' | 'approved' | 'implementing'} phase
* @property {string | null} task
* @property {number} round
* @property {string | null} proposal
* @property {Array<{round: number, feedback: string}>} feedback
* @property {Array<string>} userContext
* @property {string | null} approvalReason
* @property {string | null} startedAt
*/
/** @type {WorkflowState} */
let state = {
phase: 'conversation',
task: null,
round: 0,
proposal: null,
feedback: [],
userContext: [],
approvalReason: null,
startedAt: null,
};
function loadState() {
try {
if (existsSync(STATE_FILE)) {
const saved = JSON.parse(readFileSync(STATE_FILE, 'utf-8'));
state = { ...state, ...saved };
}
} catch {
// Use default state
}
}
function saveState() {
try {
if (!existsSync(STATE_DIR)) {
mkdirSync(STATE_DIR, { recursive: true });
}
writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
} catch {
// Ignore write errors
}
}
function resetState() {
state = {
phase: 'conversation',
task: null,
round: 0,
proposal: null,
feedback: [],
userContext: [],
approvalReason: null,
startedAt: null,
};
saveState();
}
// =============================================================================
// Context Loading
// =============================================================================
// Project-specific context files
const PROJECT_WORKER_CONTEXT = join(STATE_DIR, 'WORKER_CONTEXT.md');
const PROJECT_REVIEWER_CONTEXT = join(STATE_DIR, 'REVIEWER_CONTEXT.md');
const PROJECT_CONTEXT_DIR = join(STATE_DIR, 'context');
function loadFile(filePath) {
try {
if (existsSync(filePath)) {
return readFileSync(filePath, 'utf-8');
}
} catch {
// Ignore
}
return '';
}
/**
* Load all .md files from a directory.
* @param {string} dirPath
* @returns {Array<{name: string, content: string}>}
*/
function loadContextDir(dirPath) {
const files = [];
try {
if (existsSync(dirPath)) {
const entries = readdirSync(dirPath);
for (const entry of entries) {
if (entry.endsWith('.md')) {
const content = loadFile(join(dirPath, entry));
if (content) {
files.push({ name: basename(entry, '.md'), content });
}
}
}
}
} catch {
// Ignore
}
return files;
}
function getCodebaseContext() {
let context = '';
// Main codebase context
const mainContent = loadFile(CODEBASE_CONTEXT_FILE);
if (mainContent) {
context += mainContent;
} else {
context += `(No CODEBASE_CONTEXT.md found at ${CODEBASE_CONTEXT_FILE})\n\nCreate this file to provide project-specific architecture and patterns.`;
}
// Additional context files from .claude/context/
const extraFiles = loadContextDir(PROJECT_CONTEXT_DIR);
if (extraFiles.length > 0) {
context += '\n\n---\n\n# Additional Context\n\n';
for (const file of extraFiles) {
context += `## ${file.name}\n\n${file.content}\n\n`;
}
}
return context;
}
function getWorkerPrompt() {
// Bundled generic prompt
let prompt = loadFile(WORKER_PROMPT_FILE);
// Project-specific additions
const projectWorker = loadFile(PROJECT_WORKER_CONTEXT);
if (projectWorker) {
prompt += '\n\n---\n\n# Project-Specific Worker Guidance\n\n';
prompt += projectWorker;
}
return prompt;
}
function getReviewerPrompt() {
// Bundled generic prompt
let prompt = loadFile(REVIEWER_PROMPT_FILE);
// Project-specific additions
const projectReviewer = loadFile(PROJECT_REVIEWER_CONTEXT);
if (projectReviewer) {
prompt += '\n\n---\n\n# Project-Specific Reviewer Guidance\n\n';
prompt += projectReviewer;
}
return prompt;
}
// =============================================================================
// MCP Server
// =============================================================================
const server = new Server({
name: 'diligence',
version: '0.1.0',
}, {
capabilities: { tools: {} }
});
// =============================================================================
// Tool Definitions
// =============================================================================
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: 'status',
description: 'Get current diligence workflow state (phase, round, task). Check this before taking actions.',
inputSchema: { type: 'object', properties: {} }
},
{
name: 'start',
description: 'Start the diligence workflow for a task. Worker will research and propose, Reviewer will verify.',
inputSchema: {
type: 'object',
properties: {
task: { type: 'string', description: 'Description of the task/bug to fix' }
},
required: ['task']
}
},
{
name: 'propose',
description: 'Worker submits a proposal with code citations, data flow analysis, and implementation plan.',
inputSchema: {
type: 'object',
properties: {
proposal: { type: 'string', description: 'Full proposal in markdown with file:line citations' }
},
required: ['proposal']
}
},
{
name: 'review',
description: 'Reviewer submits APPROVED or NEEDS_WORK after verifying claims by searching the codebase.',
inputSchema: {
type: 'object',
properties: {
decision: { type: 'string', enum: ['APPROVED', 'NEEDS_WORK'], description: 'Review decision' },
reasoning: { type: 'string', description: 'Verification results and feedback' }
},
required: ['decision', 'reasoning']
}
},
{
name: 'add_context',
description: 'Add user feedback to incorporate in the next round.',
inputSchema: {
type: 'object',
properties: {
context: { type: 'string', description: 'Additional context from user' }
},
required: ['context']
}
},
{
name: 'get_worker_brief',
description: 'Get the full brief for the Worker: task, codebase context, previous feedback, instructions.',
inputSchema: { type: 'object', properties: {} }
},
{
name: 'get_reviewer_brief',
description: 'Get the full brief for the Reviewer: proposal to verify, codebase context, instructions.',
inputSchema: { type: 'object', properties: {} }
},
{
name: 'approve',
description: 'User manually approves, bypassing further review. Use when proposal is clearly correct.',
inputSchema: {
type: 'object',
properties: {
reason: { type: 'string', description: 'Why approving manually' }
},
required: ['reason']
}
},
{
name: 'implement',
description: 'Begin implementation phase. Only allowed after approval.',
inputSchema: { type: 'object', properties: {} }
},
{
name: 'complete',
description: 'Mark implementation complete. Returns to conversation phase.',
inputSchema: {
type: 'object',
properties: {
summary: { type: 'string', description: 'Summary of changes made' }
},
required: ['summary']
}
},
{
name: 'abort',
description: 'Cancel workflow and reset to conversation.',
inputSchema: {
type: 'object',
properties: {
reason: { type: 'string', description: 'Why aborting' }
}
}
}
]
}));
// =============================================================================
// Tool Handlers
// =============================================================================
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
try {
switch (name) {
case 'status': {
const lines = [
`Phase: ${state.phase}`,
`Task: ${state.task || '(none)'}`,
`Round: ${state.round}/${MAX_ROUNDS}`,
`Proposal: ${state.proposal ? `${state.proposal.length} chars` : '(none)'}`,
`Feedback rounds: ${state.feedback.length}`,
state.userContext.length ? `User context: ${state.userContext.length} items` : null,
state.approvalReason ? `Approved: ${state.approvalReason.slice(0, 80)}...` : null,
`Project: ${PROJECT_ROOT}`,
].filter(Boolean);
return { content: [{ type: 'text', text: lines.join('\n') }] };
}
case 'start': {
if (state.phase !== 'conversation') {
return { content: [{ type: 'text', text: `Already in "${state.phase}" phase. Use abort first.` }], isError: true };
}
state.phase = 'researching';
state.task = args.task;
state.round = 1;
state.proposal = null;
state.feedback = [];
state.userContext = [];
state.approvalReason = null;
state.startedAt = new Date().toISOString();
saveState();
return { content: [{ type: 'text', text: `Started: "${args.task}"\n\nPhase: researching\nRound: 1/${MAX_ROUNDS}\n\nWorker should use get_worker_brief for context, then research and propose.` }] };
}
case 'propose': {
if (state.phase !== 'researching') {
return { content: [{ type: 'text', text: `Not in researching phase (current: ${state.phase})` }], isError: true };
}
state.proposal = args.proposal;
saveState();
return { content: [{ type: 'text', text: `Proposal submitted (${args.proposal.length} chars).\n\nRound: ${state.round}/${MAX_ROUNDS}\n\nReviewer should use get_reviewer_brief, verify claims by searching codebase, then review.` }] };
}
case 'review': {
if (state.phase !== 'researching') {
return { content: [{ type: 'text', text: `Not in researching phase (current: ${state.phase})` }], isError: true };
}
if (!state.proposal) {
return { content: [{ type: 'text', text: 'No proposal to review yet' }], isError: true };
}
const { decision, reasoning } = args;
if (decision === 'APPROVED') {
state.phase = 'approved';
state.approvalReason = reasoning;
saveState();
return { content: [{ type: 'text', text: `APPROVED after ${state.round} round(s).\n\n${reasoning.slice(0, 500)}\n\nUse implement to begin making changes.` }] };
}
if (decision === 'NEEDS_WORK') {
state.feedback.push({ round: state.round, feedback: reasoning });
state.round++;
state.userContext = [];
if (state.round > MAX_ROUNDS) {
const lastFeedback = reasoning;
resetState();
return { content: [{ type: 'text', text: `MAX ROUNDS (${MAX_ROUNDS}) reached. Workflow reset.\n\nLast feedback: ${lastFeedback.slice(0, 500)}\n\nBreak down the task or discuss with user.` }] };
}
saveState();
return { content: [{ type: 'text', text: `NEEDS_WORK - Round ${state.round}/${MAX_ROUNDS}\n\n${reasoning}\n\nWorker should revise addressing this feedback.` }] };
}
return { content: [{ type: 'text', text: `Invalid decision: ${decision}` }], isError: true };
}
case 'add_context': {
if (state.phase !== 'researching') {
return { content: [{ type: 'text', text: `Not in researching phase` }], isError: true };
}
state.userContext.push(args.context);
saveState();
return { content: [{ type: 'text', text: `Context added. ${state.userContext.length} item(s) pending.` }] };
}
case 'get_worker_brief': {
let brief = '# Worker Brief\n\n';
brief += `## Task\n\n${state.task || '(No task - use start first)'}\n\n`;
brief += `## Round\n\n${state.round}/${MAX_ROUNDS}\n\n`;
if (state.feedback.length > 0) {
brief += '## Previous Feedback (Address ALL)\n\n';
for (const f of state.feedback) {
brief += `### Round ${f.round}\n\n${f.feedback}\n\n`;
}
}
if (state.userContext.length > 0) {
brief += '## User Context\n\n';
state.userContext.forEach(c => brief += `- ${c}\n`);
brief += '\n';
}
brief += '## Codebase Context\n\n';
brief += getCodebaseContext();
brief += '\n\n';
const workerPrompt = getWorkerPrompt();
if (workerPrompt) {
brief += '## Instructions\n\n';
brief += workerPrompt;
}
return { content: [{ type: 'text', text: brief }] };
}
case 'get_reviewer_brief': {
let brief = '# Reviewer Brief\n\n';
brief += `## Task\n\n${state.task || '(No task)'}\n\n`;
brief += `## Round\n\n${state.round}/${MAX_ROUNDS}\n\n`;
if (state.proposal) {
brief += '## Proposal to Review\n\n';
brief += state.proposal;
brief += '\n\n';
} else {
brief += '## Proposal\n\n(No proposal yet)\n\n';
}
if (state.feedback.length > 0) {
brief += '## Previous Feedback\n\n';
for (const f of state.feedback) {
brief += `### Round ${f.round}\n\n${f.feedback}\n\n`;
}
}
brief += '## Codebase Context\n\n';
brief += getCodebaseContext();
brief += '\n\n';
const reviewerPrompt = getReviewerPrompt();
if (reviewerPrompt) {
brief += '## Instructions\n\n';
brief += reviewerPrompt;
}
return { content: [{ type: 'text', text: brief }] };
}
case 'approve': {
if (state.phase !== 'researching') {
return { content: [{ type: 'text', text: `Not in researching phase` }], isError: true };
}
if (!state.proposal) {
return { content: [{ type: 'text', text: 'No proposal to approve' }], isError: true };
}
state.phase = 'approved';
state.approvalReason = `USER: ${args.reason}`;
saveState();
return { content: [{ type: 'text', text: `Manually approved.\n\nUse implement to begin.` }] };
}
case 'implement': {
if (state.phase !== 'approved') {
return { content: [{ type: 'text', text: `Not approved yet (current: ${state.phase})` }], isError: true };
}
state.phase = 'implementing';
saveState();
return { content: [{ type: 'text', text: `Implementation phase.\n\nTask: ${state.task}\n\nMake changes following the approved proposal.` }] };
}
case 'complete': {
if (state.phase !== 'implementing') {
return { content: [{ type: 'text', text: `Not in implementing phase` }], isError: true };
}
const task = state.task;
const rounds = state.round;
resetState();
return { content: [{ type: 'text', text: `Complete!\n\nTask: ${task}\nRounds: ${rounds}\nSummary: ${args.summary}\n\nReset to conversation.` }] };
}
case 'abort': {
const prev = { phase: state.phase, task: state.task };
resetState();
return { content: [{ type: 'text', text: `Aborted.\n\nWas: ${prev.phase}, "${prev.task || ''}"\nReason: ${args.reason || '(none)'}\n\nReset to conversation.` }] };
}
default:
throw new Error(`Unknown tool: ${name}`);
}
} catch (e) {
return { content: [{ type: 'text', text: `Error: ${e.message}` }], isError: true };
}
});
// =============================================================================
// Lifecycle
// =============================================================================
process.on('SIGTERM', () => { saveState(); process.exit(0); });
process.on('SIGINT', () => { saveState(); process.exit(0); });
loadState();
const transport = new StdioServerTransport();
await server.connect(transport);