The Atlas Lavern's documentation, bound to its code
111 documents

How the engine works

From the prescriptive architecture to the actual dispatch loop: the 4-stage pipeline as designed, then the code that runs engagements.

src/mcp/tools/debate-board.ts615 lines
Outline 3 symbols
1/**
2 * Debate Board MCP Tool — Shared state for agent collaboration.
3 *
4 * v3: Refactored to factory pattern — all state lives in SessionState.
5 * Events emitted on every state mutation for visualization + API.
6 *
7 * The orchestrator MUST call resolve_debate to formally close each debate.
8 * Resolution is a first-class auditable event — insurance reviewers can see
9 * "Why did the system resolve this dispute this way?"
10 */
11
12import { z } from 'zod';
13import { tool } from '@anthropic-ai/claude-agent-sdk';
14import type { SessionState } from '../../session/session-state.js';
15import { boundedPush } from '../../session/session-state.js';
16import { eventTimestamp } from '../../events/event-bus.js';
17import type { Finding, Challenge, Response, DebateResolution } from '../../types/debate.js';
18
19/**
20 * Strip process-text preambles from finding content before storing.
21 * Agents sometimes prefix findings with "I'll analyze...", "Let me review..." —
22 * this strips that preamble so users see only the substantive finding.
23 */
24const FINDING_PROCESS_PATTERNS = [
25 /^I'll /i, /^I will /i, /^Let me /i, /^I need to/i, /^I see /i,
26 /^I can see/i, /^I have /i, /^I've /i,
27 /^OK[,.\s]/i, /^Okay/i, /^Sure/i, /^Certainly/i,
28 /^Here is/i, /^Here's /i, /^Based on my/i,
29 /^Looking at/i, /^After review/i, /^I'll get started/i,
30 /^Let me check/i, /^I'll start/i, /^I'll now/i,
31];
32
33export function sanitizeFindingContent(content: string): string {
34 const trimmed = content.trim();
35 if (!trimmed) return trimmed;
36
37 // If the entire content is a single sentence matching a process pattern, keep it
38 // (better than returning empty). Only strip if there's substantive content after.
39 const sentences = trimmed.split(/(?<=\.)\s+/);
40 if (sentences.length <= 1) return trimmed;
41
42 // Find the first sentence that doesn't match a process pattern
43 const firstSubstantiveIdx = sentences.findIndex(
44 s => !FINDING_PROCESS_PATTERNS.some(p => p.test(s.trim())),
45 );
46
47 if (firstSubstantiveIdx <= 0) return trimmed; // No preamble or all substantive
48 return sentences.slice(firstSubstantiveIdx).join(' ').trim();
49}
50
51export function createDebateBoardTools(session: SessionState) {
52 const state = session.debate;
53 const counters = session.debateCounters;
54
55 const postFinding = tool(
56 'post_finding',
57 'Post a finding to the debate board. Used by agents to share their analysis results with other agents. Include confidence score (0.0-1.0) based on evidence strength.',
58 {
59 agent_role: z.string().describe('The role of the agent posting'),
60 finding_type: z.enum([
61 // Legacy legal-design types
62 'score', 'dark-pattern', 'transformation', 'meaning-concern', 'comprehension',
63 // Contract review types
64 'contract-risk', 'contract-deviation', 'contract-standard',
65 // Research types
66 'research-citation', 'research-conflict', 'research-gap',
67 // Adversarial types
68 'adversarial-vulnerability', 'adversarial-edge-case', 'adversarial-ambiguity',
69 // Counsel types
70 'direct-answer', 'caveat',
71 // Roundtable types
72 'panel-insight', 'cross-domain-connection', 'dissenting-view',
73 // Full Bench types
74 'workstream-output', 'synthesis-gap', 'integration-risk',
75 // Ethics reviewer
76 'ETHICAL_CONCERN',
77 // Uncertainty — agent declines to make a determination
78 'UNCERTAIN', 'INSUFFICIENT_EVIDENCE', 'AMBIGUOUS_DOCUMENT',
79 ]).describe('Type of finding'),
80 content: z.string().describe('The finding content — what was discovered'),
81 severity: z.enum(['RED', 'YELLOW', 'GREEN']).describe('Severity classification'),
82 evidence: z.array(z.string().min(1)).min(1)
83 .describe('Specific quotes or references from the document as evidence. At least one citation is required — findings without evidence cannot be posted.'),
84 confidence: z.number().min(0).max(1).optional()
85 .describe('Confidence in this finding (0.0-1.0). Based on evidence strength, not self-assessment. Default: 0.8'),
86 },
87 async (args) => {
88 // Runtime guard: schema validation is enforced at the MCP boundary, but mirror it here
89 // so direct handler invocation (tests, future callers) cannot bypass the invariant.
90 const cleanedEvidence = args.evidence.filter(e => e.trim().length > 0);
91 if (cleanedEvidence.length === 0) {
92 return {
93 content: [{ type: 'text' as const, text: 'Error: evidence is required. Every finding must cite at least one specific quote or reference from the document.' }],
94 };
95 }
96 const sanitizedContent = sanitizeFindingContent(args.content);
97 const finding: Finding = {
98 id: `F-${String(++counters.finding).padStart(3, '0')}`,
99 agentRole: args.agent_role as Finding['agentRole'],
100 findingType: args.finding_type,
101 content: sanitizedContent,
102 severity: args.severity,
103 evidence: cleanedEvidence,
104 confidence: args.confidence ?? 0.8,
105 timestamp: eventTimestamp(),
106 resolved: false,
107 };
108 boundedPush(state.findings, finding);
109
110 session.events.emitEvent({
111 type: 'finding_posted',
112 findingId: finding.id,
113 agent: args.agent_role,
114 category: args.finding_type,
115 severity: args.severity,
116 confidence: finding.confidence,
117 content: sanitizedContent,
118 evidence: cleanedEvidence,
119 timestamp: eventTimestamp(),
120 });
121
122 return {
123 content: [{ type: 'text' as const, text: `Finding ${finding.id} posted. Severity: ${finding.severity}. Confidence: ${(finding.confidence * 100).toFixed(0)}%. Total findings: ${state.findings.length}.` }],
124 };
125 }
126 );
127
128 const declineToFind = tool(
129 'decline_to_find',
130 'Explicitly declare that you cannot make a confident determination about something. Use this when evidence is insufficient, the document is ambiguous, or you would be guessing. This is better than posting a low-confidence finding. It triggers human review.',
131 {
132 agent_role: z.string().describe('The role of the agent declining'),
133 category: z.enum(['UNCERTAIN', 'INSUFFICIENT_EVIDENCE', 'AMBIGUOUS_DOCUMENT'])
134 .describe('Why you are declining: UNCERTAIN (general), INSUFFICIENT_EVIDENCE (document lacks info), AMBIGUOUS_DOCUMENT (could be read multiple ways)'),
135 subject: z.string().describe('What you cannot determine (e.g., "whether the indemnification clause is mutual or one-sided")'),
136 reason: z.string().describe('Why you cannot determine this — what specific evidence is missing or ambiguous'),
137 attempted_evidence: z.array(z.string()).optional()
138 .describe('Any partial evidence you did find that was not sufficient'),
139 },
140 async (args) => {
141 const finding: Finding = {
142 id: `F-${String(++counters.finding).padStart(3, '0')}`,
143 agentRole: args.agent_role as Finding['agentRole'],
144 findingType: args.category,
145 content: `[DECLINED] ${args.subject}: ${args.reason}`,
146 severity: 'YELLOW', // Uncertainty is always YELLOW — not RED (that would overreact), not GREEN (that would hide it)
147 evidence: args.attempted_evidence ?? [],
148 confidence: 0.0, // Explicitly zero — the agent is saying "I don't know"
149 timestamp: eventTimestamp(),
150 resolved: false,
151 };
152 boundedPush(state.findings, finding);
153
154 session.events.emitEvent({
155 type: 'uncertainty_declared',
156 findingId: finding.id,
157 agent: args.agent_role,
158 reason: args.reason,
159 category: args.category,
160 timestamp: eventTimestamp(),
161 });
162
163 return {
164 content: [{ type: 'text' as const, text: `Uncertainty ${finding.id} declared: "${args.subject}". Category: ${args.category}. This will be flagged for human review. Total findings: ${state.findings.length}.` }],
165 };
166 }
167 );
168
169 const postChallenge = tool(
170 'post_challenge',
171 'Challenge another agent\'s finding. Used when an agent disagrees with or wants to question a finding posted by another agent.',
172 {
173 challenger_role: z.string().describe('The role of the agent issuing the challenge'),
174 target_finding_id: z.string().describe('The ID of the finding being challenged (e.g., "F-001")'),
175 challenge_text: z.string().describe('The challenge — why this finding is questioned'),
176 evidence: z.array(z.string().min(1)).min(1)
177 .describe('Counter-evidence supporting the challenge — at least one specific quote or reference. Challenges without evidence are rejected.'),
178 },
179 async (args) => {
180 const targetFinding = state.findings.find(f => f.id === args.target_finding_id);
181 if (!targetFinding) {
182 return {
183 content: [{ type: 'text' as const, text: `Error: Finding ${args.target_finding_id} not found.` }],
184 };
185 }
186
187 // Runtime guard: mirror the schema constraint for direct handler callers.
188 const cleanedEvidence = args.evidence.filter(e => e.trim().length > 0);
189 if (cleanedEvidence.length === 0) {
190 return {
191 content: [{ type: 'text' as const, text: `Error: evidence is required. Every challenge must cite at least one counter-quote or reference.` }],
192 };
193 }
194
195 const challenge: Challenge = {
196 id: `C-${String(++counters.challenge).padStart(3, '0')}`,
197 challengerRole: args.challenger_role as Challenge['challengerRole'],
198 targetFindingId: args.target_finding_id,
199 challengeText: args.challenge_text,
200 evidence: cleanedEvidence,
201 timestamp: eventTimestamp(),
202 resolved: false,
203 };
204 boundedPush(state.challenges, challenge);
205
206 session.events.emitEvent({
207 type: 'challenge_posted',
208 challengeId: challenge.id,
209 challenger: args.challenger_role,
210 targetFindingId: args.target_finding_id,
211 challengeText: args.challenge_text,
212 evidence: cleanedEvidence,
213 timestamp: eventTimestamp(),
214 });
215
216 return {
217 content: [{ type: 'text' as const, text: `Challenge ${challenge.id} posted against ${args.target_finding_id} (by ${targetFinding.agentRole}). Awaiting response.` }],
218 };
219 }
220 );
221
222 const postResponse = tool(
223 'post_response',
224 'Respond to a challenge. Used by the challenged agent to defend, revise, or accept the challenge.',
225 {
226 responder_role: z.string().describe('The role of the agent responding'),
227 challenge_id: z.string().describe('The ID of the challenge being responded to'),
228 response_text: z.string().describe('The response — defense, revision, or acceptance'),
229 revised_position: z.string().optional().describe('If revising, the new position'),
230 accepted: z.boolean().describe('Whether the challenge is accepted (true = revised, false = maintained)'),
231 },
232 async (args) => {
233 const targetChallenge = state.challenges.find(c => c.id === args.challenge_id);
234 if (!targetChallenge) {
235 return {
236 content: [{ type: 'text' as const, text: `Error: Challenge ${args.challenge_id} not found.` }],
237 };
238 }
239
240 const response: Response = {
241 id: `R-${String(++counters.response).padStart(3, '0')}`,
242 responderRole: args.responder_role as Response['responderRole'],
243 challengeId: args.challenge_id,
244 responseText: args.response_text,
245 revisedPosition: args.revised_position,
246 accepted: args.accepted,
247 timestamp: eventTimestamp(),
248 };
249 boundedPush(state.responses, response);
250 targetChallenge.resolved = true;
251
252 if (args.accepted && args.revised_position) {
253 const finding = state.findings.find(f => f.id === targetChallenge.targetFindingId);
254 if (finding) {
255 finding.content = args.revised_position;
256 finding.resolved = true;
257 }
258 }
259
260 session.events.emitEvent({
261 type: 'response_posted',
262 responseId: response.id,
263 responder: args.responder_role,
264 challengeId: args.challenge_id,
265 accepted: args.accepted,
266 responseText: args.response_text,
267 revisedPosition: args.revised_position,
268 timestamp: eventTimestamp(),
269 });
270
271 return {
272 content: [{ type: 'text' as const, text: `Response ${response.id} to ${args.challenge_id}: ${args.accepted ? 'ACCEPTED (revised)' : 'MAINTAINED (defended)'}` }],
273 };
274 }
275 );
276
277 const resolveDebate = tool(
278 'resolve_debate',
279 'Formally resolve a debate. The orchestrator MUST call this to close each debate topic. Creates a first-class auditable resolution record. Insurance reviewers will see this.',
280 {
281 debate_topic: z.string().describe('What the debate was about'),
282 finding_ids: z.array(z.string()).describe('IDs of findings involved in the debate'),
283 resolution: z.string().describe('What was decided'),
284 winning_position: z.string().describe('Which position prevailed and why'),
285 evidence_weight: z.string().describe('Why this position won — what evidence was most compelling'),
286 confidence: z.number().min(0).max(1).describe('Confidence in the resolution (0.0-1.0)'),
287 escalation_needed: z.boolean().describe('Should this be escalated to human review?'),
288 resolved_by: z.string().describe('Which agent role resolved this (usually "orchestrator")'),
289 },
290 async (args) => {
291 const resolution: DebateResolution = {
292 id: `DR-${String(++counters.resolution).padStart(3, '0')}`,
293 debateTopic: args.debate_topic,
294 findingIds: args.finding_ids,
295 resolution: args.resolution,
296 winningPosition: args.winning_position,
297 evidenceWeight: args.evidence_weight,
298 confidence: args.confidence,
299 escalationNeeded: args.escalation_needed,
300 resolvedBy: args.resolved_by as DebateResolution['resolvedBy'],
301 timestamp: eventTimestamp(),
302 };
303 boundedPush(state.resolutions, resolution);
304
305 for (const fid of args.finding_ids) {
306 const finding = state.findings.find(f => f.id === fid);
307 if (finding) finding.resolved = true;
308 }
309
310 session.events.emitEvent({
311 type: 'debate_resolved',
312 resolutionId: resolution.id,
313 topic: args.debate_topic,
314 resolution: args.resolution,
315 confidence: args.confidence,
316 winningPosition: args.winning_position,
317 evidenceWeight: args.evidence_weight,
318 escalationNeeded: args.escalation_needed,
319 timestamp: eventTimestamp(),
320 });
321
322 return {
323 content: [{
324 type: 'text' as const,
325 text: `Debate resolved: ${resolution.id}\n**Topic**: ${args.debate_topic}\n**Winner**: ${args.winning_position}\n**Confidence**: ${(args.confidence * 100).toFixed(0)}%\n${args.escalation_needed ? '\u26a0\ufe0f ESCALATION RECOMMENDED \u2014 route to human review.' : '\u2705 Resolution accepted.'}`,
326 }],
327 };
328 }
329 );
330
331 const getUnresolvedDebates = tool(
332 'get_unresolved_debates',
333 'Get all unresolved debate topics \u2014 findings that have been challenged but not formally resolved. The orchestrator must resolve ALL debates before advancing the workflow.',
334 {},
335 async () => {
336 const unresolvedChallenges = state.challenges.filter(c => !c.resolved);
337 const challengedButUnresolved = state.challenges
338 .filter(c => c.resolved)
339 .filter(c => {
340 return !state.resolutions.some(r => r.findingIds.includes(c.targetFindingId));
341 });
342
343 const allUnresolved = [
344 ...unresolvedChallenges.map(c => `PENDING CHALLENGE: ${c.id} against ${c.targetFindingId} \u2014 ${c.challengeText.slice(0, 80)}`),
345 ...challengedButUnresolved.map(c => `NEEDS RESOLUTION: ${c.id} (responded but not formally resolved) \u2014 ${c.challengeText.slice(0, 80)}`),
346 ...state.findings.filter(f => !f.resolved && f.severity === 'RED').map(f => `RED FINDING UNRESOLVED: ${f.id} \u2014 ${f.content.slice(0, 80)}`),
347 ];
348
349 return {
350 content: [{
351 type: 'text' as const,
352 text: allUnresolved.length > 0
353 ? `## Unresolved Debates (${allUnresolved.length})\n\n${allUnresolved.join('\n')}\n\n\u26a0\ufe0f All debates must be formally resolved before advancing the workflow.`
354 : '\u2705 All debates have been formally resolved.',
355 }],
356 };
357 },
358 { annotations: { readOnly: true } }
359 );
360
361 const getFindings = tool(
362 'get_findings',
363 'Get all findings on the debate board, optionally filtered by agent or severity.',
364 {
365 filter_by_agent: z.string().optional().describe('Filter by agent role'),
366 filter_by_severity: z.enum(['RED', 'YELLOW', 'GREEN']).optional().describe('Filter by severity'),
367 unresolved_only: z.boolean().optional().describe('Only return unresolved findings'),
368 },
369 async (args) => {
370 let results = [...state.findings];
371 if (args.filter_by_agent) results = results.filter(f => f.agentRole === args.filter_by_agent);
372 if (args.filter_by_severity) results = results.filter(f => f.severity === args.filter_by_severity);
373 if (args.unresolved_only) results = results.filter(f => !f.resolved);
374
375 const summary = results.map(f =>
376 `${f.id} [${f.severity}] (${f.agentRole}) [${(f.confidence * 100).toFixed(0)}%]: ${f.content}\n Evidence: ${f.evidence.join('; ')}`
377 ).join('\n\n');
378
379 return {
380 content: [{ type: 'text' as const, text: results.length > 0 ? summary : 'No findings match the criteria.' }],
381 };
382 },
383 { annotations: { readOnly: true } }
384 );
385
386 const getChallenges = tool(
387 'get_challenges',
388 'Get all challenges on the debate board, optionally filtered by target agent.',
389 {
390 target_agent: z.string().optional().describe('Filter challenges targeting this agent'),
391 unresolved_only: z.boolean().optional().describe('Only return unresolved challenges'),
392 },
393 async (args) => {
394 let results = [...state.challenges];
395 if (args.target_agent) {
396 results = results.filter(c => {
397 const finding = state.findings.find(f => f.id === c.targetFindingId);
398 return finding?.agentRole === args.target_agent;
399 });
400 }
401 if (args.unresolved_only) results = results.filter(c => !c.resolved);
402
403 const summary = results.map(c => {
404 const finding = state.findings.find(f => f.id === c.targetFindingId);
405 const response = state.responses.find(r => r.challengeId === c.id);
406 return `${c.id} (${c.challengerRole} challenges ${finding?.agentRole}): ${c.challengeText}\n Target: ${c.targetFindingId}\n Status: ${response ? (response.accepted ? 'ACCEPTED' : 'DEFENDED') : 'PENDING'}`;
407 }).join('\n\n');
408
409 return {
410 content: [{ type: 'text' as const, text: results.length > 0 ? summary : 'No challenges match the criteria.' }],
411 };
412 },
413 { annotations: { readOnly: true } }
414 );
415
416 const auditDebateCoherence = tool(
417 'audit_debate_coherence',
418 'Audit debate resolutions for coherence before synthesis. Checks for contradictions, confidence inversions, orphan findings, topic overlaps, and ignored challenges. Run this AFTER resolving all debates and BEFORE advancing to verification or synthesis.',
419 {},
420 async () => {
421 const issues: Array<{
422 type: 'coverage_gap' | 'confidence_inversion' | 'orphan_finding' | 'topic_overlap' | 'ignored_challenge';
423 severity: 'RED' | 'YELLOW' | 'GREEN';
424 description: string;
425 resolutionIds: string[];
426 findingIds: string[];
427 }> = [];
428
429 // Check 1: Coverage — every RED finding must appear in at least one resolution
430 const redFindings = state.findings.filter(f => f.severity === 'RED');
431 const resolvedFindingIds = new Set(state.resolutions.flatMap(r => r.findingIds));
432 for (const f of redFindings) {
433 if (!resolvedFindingIds.has(f.id)) {
434 issues.push({
435 type: 'coverage_gap',
436 severity: 'RED',
437 description: `RED finding ${f.id} ("${f.content.slice(0, 80)}") is not covered by any resolution`,
438 resolutionIds: [],
439 findingIds: [f.id],
440 });
441 }
442 }
443
444 // Check 2: Confidence inversion — resolution confidence >0.2 below finding average
445 for (const r of state.resolutions) {
446 const resolvedFindings = state.findings.filter(f => r.findingIds.includes(f.id));
447 if (resolvedFindings.length === 0) continue;
448 const avgFindingConfidence = resolvedFindings.reduce((s, f) => s + f.confidence, 0) / resolvedFindings.length;
449 const delta = avgFindingConfidence - r.confidence;
450 if (delta > 0.2) {
451 issues.push({
452 type: 'confidence_inversion',
453 severity: 'YELLOW',
454 description: `Resolution ${r.id} ("${r.debateTopic}") has confidence ${(r.confidence * 100).toFixed(0)}% but resolves findings averaging ${(avgFindingConfidence * 100).toFixed(0)}% — gap of ${(delta * 100).toFixed(0)}pp`,
455 resolutionIds: [r.id],
456 findingIds: r.findingIds,
457 });
458 }
459 }
460
461 // Check 3: Orphan detection — findings not in ANY resolution (YELLOW/RED only)
462 for (const f of state.findings) {
463 if (f.severity === 'GREEN') continue;
464 if (!resolvedFindingIds.has(f.id)) {
465 // RED orphans already caught in Check 1; only add YELLOW here
466 if (f.severity === 'YELLOW') {
467 issues.push({
468 type: 'orphan_finding',
469 severity: 'YELLOW',
470 description: `YELLOW finding ${f.id} ("${f.content.slice(0, 80)}") is not referenced by any resolution`,
471 resolutionIds: [],
472 findingIds: [f.id],
473 });
474 }
475 }
476 }
477
478 // Check 4: Topic overlap — same finding referenced by multiple resolutions
479 const findingToResolutions = new Map<string, string[]>();
480 for (const r of state.resolutions) {
481 for (const fid of r.findingIds) {
482 const existing = findingToResolutions.get(fid) || [];
483 existing.push(r.id);
484 findingToResolutions.set(fid, existing);
485 }
486 }
487 for (const [fid, rids] of findingToResolutions) {
488 if (rids.length > 1) {
489 issues.push({
490 type: 'topic_overlap',
491 severity: 'RED',
492 description: `Finding ${fid} is resolved by multiple resolutions (${rids.join(', ')}) — potential contradiction`,
493 resolutionIds: rids,
494 findingIds: [fid],
495 });
496 }
497 }
498
499 // Check 5: Challenge coverage — unanswered challenges on resolved findings
500 for (const c of state.challenges) {
501 const hasResponse = state.responses.some(r => r.challengeId === c.id);
502 if (!hasResponse) {
503 const finding = state.findings.find(f => f.id === c.targetFindingId);
504 if (finding?.resolved) {
505 issues.push({
506 type: 'ignored_challenge',
507 severity: 'YELLOW',
508 description: `Challenge ${c.id} against ${c.targetFindingId} ("${c.challengeText.slice(0, 80)}") was never answered but finding was resolved anyway`,
509 resolutionIds: state.resolutions.filter(r => r.findingIds.includes(c.targetFindingId)).map(r => r.id),
510 findingIds: [c.targetFindingId],
511 });
512 }
513 }
514 }
515
516 // Metrics
517 const redFindingsTotal = redFindings.length;
518 const redFindingsCovered = redFindings.filter(f => resolvedFindingIds.has(f.id)).length;
519 const avgResConf = state.resolutions.length > 0
520 ? state.resolutions.reduce((s, r) => s + r.confidence, 0) / state.resolutions.length
521 : 0;
522 const avgFindConf = state.findings.length > 0
523 ? state.findings.reduce((s, f) => s + f.confidence, 0) / state.findings.length
524 : 0;
525
526 const passed = !issues.some(i => i.severity === 'RED' || i.severity === 'YELLOW');
527
528 const report = {
529 passed,
530 issues,
531 metrics: {
532 totalResolutions: state.resolutions.length,
533 totalFindings: state.findings.length,
534 redFindingsCovered,
535 redFindingsTotal,
536 averageResolutionConfidence: Math.round(avgResConf * 100) / 100,
537 averageFindingConfidence: Math.round(avgFindConf * 100) / 100,
538 },
539 };
540
541 const issueText = issues.length > 0
542 ? issues.map(i => `- [${i.severity}] ${i.type}: ${i.description}`).join('\n')
543 : '(none)';
544
545 return {
546 content: [{
547 type: 'text' as const,
548 text: `## Debate Coherence Audit — ${passed ? '\u2705 PASSED' : '\u274c FAILED'}
549
550**Issues**: ${issues.length} (RED: ${issues.filter(i => i.severity === 'RED').length}, YELLOW: ${issues.filter(i => i.severity === 'YELLOW').length}, GREEN: ${issues.filter(i => i.severity === 'GREEN').length})
551${issueText}
552
553**Metrics**:
554- Resolutions: ${report.metrics.totalResolutions} | Findings: ${report.metrics.totalFindings}
555- RED coverage: ${redFindingsCovered}/${redFindingsTotal}
556- Avg resolution confidence: ${(avgResConf * 100).toFixed(0)}% | Avg finding confidence: ${(avgFindConf * 100).toFixed(0)}%`,
557 }],
558 };
559 },
560 { annotations: { readOnly: true } }
561 );
562
563 const getDebateSummary = tool(
564 'get_debate_summary',
565 'Get a full summary of the debate board \u2014 all findings, challenges, resolutions, and formal debate closures.',
566 {},
567 async () => {
568 const redFindings = state.findings.filter(f => f.severity === 'RED').length;
569 const yellowFindings = state.findings.filter(f => f.severity === 'YELLOW').length;
570 const greenFindings = state.findings.filter(f => f.severity === 'GREEN').length;
571 const unresolvedChallenges = state.challenges.filter(c => !c.resolved).length;
572 const avgConfidence = state.findings.length > 0
573 ? state.findings.reduce((sum, f) => sum + f.confidence, 0) / state.findings.length
574 : 0;
575
576 const summary = `
577## Debate Board Summary
578
579**Findings**: ${state.findings.length} total (RED: ${redFindings}, YELLOW: ${yellowFindings}, GREEN: ${greenFindings})
580**Average Confidence**: ${(avgConfidence * 100).toFixed(0)}%
581**Challenges**: ${state.challenges.length} total (${unresolvedChallenges} unresolved)
582**Responses**: ${state.responses.length} total
583**Formal Resolutions**: ${state.resolutions.length}
584
585### All Findings
586${state.findings.map(f => `- ${f.id} [${f.severity}] (${f.agentRole}) [${(f.confidence * 100).toFixed(0)}%]: ${f.content}`).join('\n')}
587
588### Formal Debate Resolutions
589${state.resolutions.map(r => `- ${r.id}: "${r.debateTopic}" \u2192 ${r.winningPosition} (${(r.confidence * 100).toFixed(0)}% confidence)${r.escalationNeeded ? ' \u26a0\ufe0f ESCALATION' : ''}`).join('\n') || '(none yet \u2014 orchestrator must call resolve_debate)'}
590
591### Unresolved Challenges
592${state.challenges.filter(c => !c.resolved).map(c => `- ${c.id}: ${c.challengeText}`).join('\n') || '(none)'}
593`.trim();
594
595 return {
596 content: [{ type: 'text' as const, text: summary }],
597 };
598 },
599 { annotations: { readOnly: true } }
600 );
601
602 return [
603 postFinding,
604 declineToFind,
605 postChallenge,
606 postResponse,
607 resolveDebate,
608 getFindings,
609 getChallenges,
610 getUnresolvedDebates,
611 getDebateSummary,
612 auditDebateCoherence,
613 ];
614}
615