From 90f9f135e0affc2e7e09f1c5904b25f6fbdc42e8 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sat, 29 Nov 2025 23:09:58 +0000 Subject: [PATCH] feat: add Board of Directors consensus mechanism - Implements a 'Board of Directors' workflow with 5 distinct AI personas (Pragmatist, Architect, Security Officer, Performance Zealot, User Advocate). - Adds `bin/coding board` command to trigger the consensus process. - Implements `BoardMeeting` class for orchestration (Proposal -> Anonymization -> Voting -> Tally). - Adds `LLMClient` for lightweight API interaction (Anthropic/OpenAI). - Adds unit tests for the voting logic. --- .eslintrc.cjs | 1 + bin/coding | 4 + lib/consensus/board-meeting.js | 146 ++++++++++++++++++++++++++++++++ lib/consensus/directors.js | 49 +++++++++++ lib/consensus/llm-client.js | 78 +++++++++++++++++ scripts/run-board-meeting.js | 80 +++++++++++++++++ test/setup.js | 1 + tests/unit/BoardMeeting.test.js | 76 +++++++++++++++++ 8 files changed, 435 insertions(+) create mode 100644 .eslintrc.cjs create mode 100644 lib/consensus/board-meeting.js create mode 100644 lib/consensus/directors.js create mode 100644 lib/consensus/llm-client.js create mode 100755 scripts/run-board-meeting.js create mode 100644 test/setup.js create mode 100644 tests/unit/BoardMeeting.test.js diff --git a/.eslintrc.cjs b/.eslintrc.cjs new file mode 100644 index 000000000..1d4ff7b8c --- /dev/null +++ b/.eslintrc.cjs @@ -0,0 +1 @@ +module.exports = { extends: "eslint:recommended", env: { node: true, es6: true }, parserOptions: { ecmaVersion: 2022, sourceType: "module" } }; diff --git a/bin/coding b/bin/coding index 79bafc302..5566462b6 100755 --- a/bin/coding +++ b/bin/coding @@ -106,6 +106,10 @@ while [[ $# -gt 0 ]]; do --lsl-validate) exec node "$SCRIPT_DIR/../tests/integration/full-system-validation.test.js" ;; + board|consensus) + shift + exec node "$SCRIPT_DIR/../scripts/run-board-meeting.js" "$@" + ;; --no-vkb|--no-constraints|--no-transcript|--no-logging|--no-health) SERVICE_ARGS+=("$1") shift diff --git a/lib/consensus/board-meeting.js b/lib/consensus/board-meeting.js new file mode 100644 index 000000000..2c2b47379 --- /dev/null +++ b/lib/consensus/board-meeting.js @@ -0,0 +1,146 @@ +import LLMClient from './llm-client.js'; +import DIRECTORS from './directors.js'; + +class BoardMeeting { + constructor() { + this.llm = new LLMClient(); + this.directors = Object.values(DIRECTORS); + } + + async conductMeeting(topic) { + console.log(`\nšŸ“¢ Board Meeting Called: "${topic}"\n`); + + // Phase 1: Proposal Generation + console.log("Phase 1: Generating Proposals..."); + const proposals = await this.generateProposals(topic); + + // Phase 2: Anonymization + console.log("Phase 2: Anonymizing Proposals..."); + const anonymizedProposals = this.anonymizeProposals(proposals); + + // Phase 3: Voting + console.log("Phase 3: Voting..."); + const votes = await this.collectVotes(topic, anonymizedProposals); + + // Phase 4: Tally + const result = this.tallyVotes(votes, anonymizedProposals); + + return result; + } + + async generateProposals(topic) { + const proposalPromises = this.directors.map(async (director) => { + try { + console.log(` - ${director.name} is thinking...`); + const response = await this.llm.complete( + director.systemPrompt, + `Please provide your perspective and a concrete proposal on the following topic:\n"${topic}"\nKeep your response concise (under 200 words).` + ); + return { director: director.name, content: response }; + } catch (error) { + console.error(`Error from ${director.name}:`, error.message); + return { director: director.name, content: "Abstained due to technical difficulties." }; + } + }); + + return Promise.all(proposalPromises); + } + + anonymizeProposals(proposals) { + return proposals.map((p, index) => ({ + id: String.fromCharCode(65 + index), // A, B, C, D, E + originalDirector: p.director, + content: p.content + })); + } + + async collectVotes(topic, anonymizedProposals) { + // Format proposals for reading + const proposalsText = anonymizedProposals + .map(p => `Proposal ${p.id}:\n${p.content}\n---`) + .join('\n'); + + const votePromises = this.directors.map(async (director) => { + try { + // Find their own proposal ID to exclude (optional, but good for fairness) + const ownProposal = anonymizedProposals.find(p => p.originalDirector === director.name); + const ownId = ownProposal ? ownProposal.id : null; + + const prompt = ` +Topic: "${topic}" + +Here are 5 anonymous proposals from the board: + +${proposalsText} + +Your Task: +1. Evaluate these proposals based on your core values (${director.role}). +2. Vote for the single best proposal. +3. You CANNOT vote for Proposal ${ownId} (which is your own). +4. Provide a brief 1-sentence reason. + +Format your response exactly like this: +VOTE: [Proposal ID] +REASON: [Your reason] +`; + console.log(` - ${director.name} is voting...`); + const response = await this.llm.complete(director.systemPrompt, prompt); + return this.parseVote(response, director.name); + } catch (error) { + console.error(`Error collecting vote from ${director.name}:`, error.message); + return null; + } + }); + + return Promise.all(votePromises); + } + + parseVote(response, voterName) { + const voteMatch = response.match(/VOTE:\s*([A-E])/i); + const reasonMatch = response.match(/REASON:\s*(.*)/i); + + return { + voter: voterName, + choice: voteMatch ? voteMatch[1].toUpperCase() : null, + reason: reasonMatch ? reasonMatch[1] : "No reason provided." + }; + } + + tallyVotes(votes, anonymizedProposals) { + const validVotes = votes.filter(v => v && v.choice); + const scores = {}; + + // Initialize scores + anonymizedProposals.forEach(p => scores[p.id] = 0); + + // Tally + validVotes.forEach(v => { + if (scores[v.choice] !== undefined) { + scores[v.choice]++; + } + }); + + // Find winner + let maxScore = -1; + let winnerId = null; + + Object.entries(scores).forEach(([id, score]) => { + if (score > maxScore) { + maxScore = score; + winnerId = id; + } + }); + + const winner = anonymizedProposals.find(p => p.id === winnerId); + + return { + topic: "", + winner: winner, + scores: scores, + votes: validVotes, + proposals: anonymizedProposals + }; + } +} + +export default BoardMeeting; diff --git a/lib/consensus/directors.js b/lib/consensus/directors.js new file mode 100644 index 000000000..e170e461c --- /dev/null +++ b/lib/consensus/directors.js @@ -0,0 +1,49 @@ +export const DIRECTORS = { + PRAGMATIST: { + name: "The Pragmatist", + role: "Senior Engineer focused on delivery", + systemPrompt: `You are The Pragmatist. +Your core values are: Simplicity, Speed, MVP, YAGNI (You Ain't Gonna Need It). +You dislike over-engineering, complex abstractions, and premature optimization. +When analyzing a problem, propose the simplest, most direct solution that works. +Focus on "boring" technology and proven patterns.` + }, + ARCHITECT: { + name: "The Architect", + role: "Principal Software Architect", + systemPrompt: `You are The Architect. +Your core values are: Scalability, Maintainability, Design Patterns, SOLID principles. +You think in terms of systems, interfaces, and long-term evolution. +When analyzing a problem, propose a solution that is robust, decoupled, and extensible. +You are willing to accept initial complexity for future flexibility.` + }, + SECURITY: { + name: "The Security Officer", + role: "Security Engineer", + systemPrompt: `You are The Security Officer. +Your core values are: Zero Trust, Data Privacy, Input Validation, Secure Defaults. +You view every feature as a potential attack vector. +When analyzing a problem, focus on how it could be exploited and how to prevent it. +Prioritize safety over convenience or speed.` + }, + PERFORMANCE: { + name: "The Performance Zealot", + role: "Performance Engineer", + systemPrompt: `You are The Performance Zealot. +Your core values are: Latency, Throughput, Memory Usage, O(n). +You obsess over CPU cycles and database queries. +When analyzing a problem, propose the solution that is most efficient. +You are willing to sacrifice readability for raw speed if necessary.` + }, + USER_ADVOCATE: { + name: "The User Advocate", + role: "Product Engineer / DX Specialist", + systemPrompt: `You are The User Advocate. +Your core values are: User Experience (UX), Developer Experience (DX), Clarity, Documentation. +You care about how the code feels to use and read. +When analyzing a problem, propose the solution that is most intuitive and well-documented. +You believe code is for humans first, machines second.` + } +}; + +export default DIRECTORS; diff --git a/lib/consensus/llm-client.js b/lib/consensus/llm-client.js new file mode 100644 index 000000000..129c79fe2 --- /dev/null +++ b/lib/consensus/llm-client.js @@ -0,0 +1,78 @@ +import { spawn } from 'child_process'; + +/** + * Lightweight wrapper to call an LLM. + * Since specific SDKs might not be installed, we'll try to use fetch if API keys are present. + * If not, we'll warn the user. + */ +export class LLMClient { + constructor() { + this.anthropicKey = process.env.ANTHROPIC_API_KEY; + this.openaiKey = process.env.OPENAI_API_KEY; + this.provider = this.anthropicKey ? 'anthropic' : (this.openaiKey ? 'openai' : null); + } + + async complete(systemPrompt, userPrompt) { + if (!this.provider) { + throw new Error('No API keys found. Please set ANTHROPIC_API_KEY or OPENAI_API_KEY.'); + } + + if (this.provider === 'anthropic') { + return this.callAnthropic(systemPrompt, userPrompt); + } else { + return this.callOpenAI(systemPrompt, userPrompt); + } + } + + async callAnthropic(systemPrompt, userPrompt) { + const response = await fetch('https://api.anthropic.com/v1/messages', { + method: 'POST', + headers: { + 'x-api-key': this.anthropicKey, + 'anthropic-version': '2023-06-01', + 'content-type': 'application/json' + }, + body: JSON.stringify({ + model: 'claude-3-5-sonnet-20241022', + max_tokens: 4000, + system: systemPrompt, + messages: [{ role: 'user', content: userPrompt }] + }) + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Anthropic API error: ${response.status} ${response.statusText} - ${errorText}`); + } + + const data = await response.json(); + return data.content[0].text; + } + + async callOpenAI(systemPrompt, userPrompt) { + const response = await fetch('https://api.openai.com/v1/chat/completions', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${this.openaiKey}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + model: 'gpt-4o', + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userPrompt } + ] + }) + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`OpenAI API error: ${response.status} ${response.statusText} - ${errorText}`); + } + + const data = await response.json(); + return data.choices[0].message.content; + } +} + +export default LLMClient; diff --git a/scripts/run-board-meeting.js b/scripts/run-board-meeting.js new file mode 100755 index 000000000..a40c3de89 --- /dev/null +++ b/scripts/run-board-meeting.js @@ -0,0 +1,80 @@ +#!/usr/bin/env node + +import BoardMeeting from '../lib/consensus/board-meeting.js'; +import readline from 'readline'; + +async function main() { + const args = process.argv.slice(2); + let topic = args.join(' '); + + if (!topic) { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout + }); + + topic = await new Promise(resolve => { + rl.question('Enter the topic for the Board Meeting: ', (answer) => { + rl.close(); + resolve(answer); + }); + }); + } + + if (!topic) { + console.error("Error: Topic is required."); + process.exit(1); + } + + // Check for API keys + if (!process.env.ANTHROPIC_API_KEY && !process.env.OPENAI_API_KEY) { + console.error("Error: ANTHROPIC_API_KEY or OPENAI_API_KEY environment variable is required."); + process.exit(1); + } + + const meeting = new BoardMeeting(); + + try { + const result = await meeting.conductMeeting(topic); + + console.log("\n" + "=".repeat(50)); + console.log("šŸ MEETING ADJOURNED šŸ"); + console.log("=".repeat(50)); + + if (result.winner) { + console.log(`\nšŸ† WINNER: Proposal ${result.winner.id} (by ${result.winner.originalDirector})`); + console.log(`\nšŸ“„ THE WINNING PROPOSAL:\n${result.winner.content}`); + } else { + console.log("\nšŸ† WINNER: None (No votes cast or tie with no votes)"); + } + + console.log("\n" + "-".repeat(50)); + console.log("šŸ“Š VOTE TALLY"); + console.log("-".repeat(50)); + + // Sort scores + const sortedScores = Object.entries(result.scores).sort((a, b) => b[1] - a[1]); + + sortedScores.forEach(([id, score]) => { + const p = result.proposals.find(prop => prop.id === id); + console.log(`Proposal ${id} (${p.originalDirector}): ${score} votes`); + }); + + console.log("\n" + "-".repeat(50)); + console.log("šŸ—³ļø VOTING RECORDS"); + console.log("-".repeat(50)); + + result.votes.forEach(vote => { + if (vote) { + console.log(`${vote.voter} voted for ${vote.choice}`); + console.log(`Reason: ${vote.reason}\n`); + } + }); + + } catch (error) { + console.error("Board meeting failed:", error); + process.exit(1); + } +} + +main(); diff --git a/test/setup.js b/test/setup.js new file mode 100644 index 000000000..f36f7d1c6 --- /dev/null +++ b/test/setup.js @@ -0,0 +1 @@ +console.log('Test setup loaded'); diff --git a/tests/unit/BoardMeeting.test.js b/tests/unit/BoardMeeting.test.js new file mode 100644 index 000000000..3a720fec4 --- /dev/null +++ b/tests/unit/BoardMeeting.test.js @@ -0,0 +1,76 @@ +import { jest } from '@jest/globals'; +import BoardMeeting from '../../lib/consensus/board-meeting.js'; +import LLMClient from '../../lib/consensus/llm-client.js'; + +// Mock LLMClient +jest.mock('../../lib/consensus/llm-client.js'); + +describe('BoardMeeting', () => { + let meeting; + let mockLLM; + + beforeEach(() => { + // Setup mock implementation + mockLLM = { + complete: jest.fn() + }; + + // In ESM mocking with Jest, we might need to handle the mock differently + // if LLMClient is the default export. + // However, since we are mocking the module, we assume the constructor calls will rely on the mocked class. + + // For this specific test, we can just spy on the methods or ensure the class is mocked correctly. + // Given the error "mockClear is not a function", it seems LLMClient isn't being treated as a Jest mock function automatically in this ESM context. + + // Workaround: Mock the instance method on the prototype if strict mocking fails + // But better: Use the mocked module instance. + }); + + test('generateProposals should call LLM for each director', async () => { + // Manually injecting mock into the meeting instance for this test + // to bypass module mocking complexity in ESM/Jest for now. + meeting = new BoardMeeting(); + meeting.llm = mockLLM; + mockLLM.complete.mockResolvedValue('A proposal'); + + const proposals = await meeting.generateProposals('Topic'); + + expect(proposals.length).toBe(5); + expect(mockLLM.complete).toHaveBeenCalledTimes(5); + expect(proposals[0].content).toBe('A proposal'); + }); + + test('anonymizeProposals should assign IDs', () => { + meeting = new BoardMeeting(); + const proposals = [ + { director: 'Dir 1', content: 'Content 1' }, + { director: 'Dir 2', content: 'Content 2' } + ]; + + const result = meeting.anonymizeProposals(proposals); + + expect(result[0].id).toBe('A'); + expect(result[1].id).toBe('B'); + expect(result[0].originalDirector).toBe('Dir 1'); + }); + + test('tallyVotes should determine the winner', () => { + meeting = new BoardMeeting(); + const anonymizedProposals = [ + { id: 'A', originalDirector: 'Dir 1', content: 'Content 1' }, + { id: 'B', originalDirector: 'Dir 2', content: 'Content 2' } + ]; + + const votes = [ + { voter: 'Voter 1', choice: 'A', reason: 'Good' }, + { voter: 'Voter 2', choice: 'A', reason: 'Better' }, + { voter: 'Voter 3', choice: 'B', reason: 'Okay' } + ]; + + const result = meeting.tallyVotes(votes, anonymizedProposals); + + expect(result.winner.id).toBe('A'); + expect(result.scores['A']).toBe(2); + expect(result.scores['B']).toBe(1); + }); +});