feat: RFC 0051 Phase 2-5 implementation

Phase 2 (MCP Tools):
- blue_dialogue_round_context - bulk fetch context for prompt building
- blue_dialogue_expert_create - create experts mid-dialogue
- blue_dialogue_round_register - bulk register perspectives, tensions, refs
- blue_dialogue_verdict_register - register interim/final/minority verdicts
- blue_dialogue_export - export dialogue to JSON with provenance

Phase 2c (Validation Layer):
- ValidationError struct with code, message, field, suggestion, context
- ValidationCollector for batch error collection
- Semantic constraints: resolve/address/reopen → Tension, refine → same-type
- validate_round_register_inputs() returns all errors before DB operations
- 8 new validation tests (20 total alignment_db tests)

Phase 5 (Skills):
- Updated alignment-play skill with DB-backed workflow
- Two-phase ID system documentation
- Cross-references alignment-expert skill for marker syntax

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Eric Garcia 2026-02-02 17:15:52 -05:00
parent 4b043c12d0
commit 49ac8f7a78
5 changed files with 1803 additions and 114 deletions

View file

@ -2424,69 +2424,107 @@ This RFC defines **uncalibrated** dialogues — experts argue freely without dom
## Implementation
### Phase 1: Schema
- [ ] Add `dialogues` root table with collision-safe ID generation
- [ ] Add `experts`, `rounds`, `expert_round_scores` tables
- [ ] Add `perspectives`, `tensions`, `tension_events` tables
- [ ] Add `recommendations` table (first-class, links to tensions/perspectives)
- [ ] Add `evidence`, `claims` tables (first-class entities)
- [ ] Add `refs` table for explicit cross-references between entities
- [ ] Add `contributions`, `verdicts` tables
- [ ] Add foreign key constraints to enforce referential integrity
- [ ] Add indices for common query patterns (including refs target/source indices)
### Phase 1: Schema ✅ Complete
- [x] Add `dialogues` root table with collision-safe ID generation
- [x] Add `experts`, `rounds`, `expert_round_scores` tables
- [x] Add `perspectives`, `tensions`, `tension_events` tables
- [x] Add `recommendations` table (first-class, links to tensions/perspectives)
- [x] Add `evidence`, `claims` tables (first-class entities)
- [x] Add `refs` table for explicit cross-references between entities
- [x] Add `contributions`, `verdicts` tables
- [x] Add foreign key constraints to enforce referential integrity
- [x] Add indices for common query patterns (including refs target/source indices)
### Phase 2: MCP Tools (Public API)
- [ ] `blue_dialogue_create` - creates dialogue, registers experts from pool
- [ ] `blue_dialogue_round_context` - **bulk fetch** context for all panel experts
- [ ] `blue_dialogue_expert_create` - create new expert mid-dialogue (Judge only)
- [ ] `blue_dialogue_round_register` - **bulk register** all round data in single call
- [ ] `blue_dialogue_verdict_register` - register interim/final/minority verdicts
- [ ] `blue_dialogue_export` - generate JSON export from database
**Implementation Notes:**
- Schema migration v8→v9 in `crates/blue-core/src/store.rs`
- 13 new tables: `alignment_dialogues`, `alignment_experts`, `alignment_rounds`, `alignment_perspectives`, `alignment_perspective_events`, `alignment_tensions`, `alignment_tension_events`, `alignment_recommendations`, `alignment_recommendation_events`, `alignment_evidence`, `alignment_claims`, `alignment_refs`, `alignment_verdicts`
- All DB operations in `crates/blue-core/src/alignment_db.rs` (1400+ lines)
### Phase 2b: Internal Functions (called by public tools)
- [ ] `perspective_register` - called by `round_register`
- [ ] `recommendation_register` - called by `round_register`
- [ ] `tension_register` - called by `round_register`
- [ ] `tension_update` - called by `round_register`
- [ ] `evidence_register` - called by `round_register`
- [ ] `claim_register` - called by `round_register`
- [ ] `ref_register` - called by `round_register`
- [ ] `recommendation_update` - called by `round_register`, `verdict_register`
### Phase 2: MCP Tools (Public API) ✅ Complete
- [x] `blue_dialogue_round_context` - **bulk fetch** context for all panel experts
- [x] `blue_dialogue_expert_create` - create new expert mid-dialogue (Judge only)
- [x] `blue_dialogue_round_register` - **bulk register** all round data in single call
- [x] `blue_dialogue_verdict_register` - register interim/final/minority verdicts
- [x] `blue_dialogue_export` - generate JSON export from database
### Phase 2c: Validation Layer
- [ ] Implement MCP-layer validation with structured error responses
- [ ] Implement batch validation (all errors returned, not just first)
- [ ] Add error codes and message templates per constraint type
**Implementation Notes:**
- Tool definitions in `crates/blue-mcp/src/server.rs` (lines 1795-2000)
- Handler implementations in `crates/blue-mcp/src/handlers/dialogue.rs`
- All handlers use `ProjectState` for DB access via `state.store.conn()`
### Phase 3: Lifecycle Tracking
- [ ] Implement tension state machine (open → addressed → resolved)
- [ ] Create `tension_events` audit trail
- [ ] Add authority checks for resolution confirmation
- [ ] Support verdict types: interim, final, minority, dissent
### Phase 2b: Internal Functions ✅ Complete
- [x] `register_perspective` - called by `round_register`
- [x] `register_recommendation` - called by `round_register`
- [x] `register_tension` - called by `round_register`
- [x] `update_tension_status` - called by `round_register`
- [x] `register_evidence` - called by `round_register`
- [x] `register_claim` - called by `round_register`
- [x] `register_ref` - called by `round_register`
- [x] `register_verdict` - called by `verdict_register`
### Phase 4: Export Tooling
- [ ] Implement `blue_dialogue_export` MCP tool
- [ ] Query all data from database (no file parsing)
- [ ] Generate single dialogue.json with all data (perspectives, recommendations, tensions, evidence, claims, verdicts, raw content)
**Implementation Notes:**
- All functions in `crates/blue-core/src/alignment_db.rs`
- Auto-generates display IDs (`P0101`, `T0203`, etc.)
- Creates event audit trails for perspectives, tensions, recommendations
### Phase 2c: Validation Layer ✅ Complete
- [x] Implement MCP-layer validation with structured error responses
- [x] Implement batch validation (all errors returned, not just first)
- [x] Add error codes and message templates per constraint type
**Implementation Notes:**
- `ValidationError` struct with code, message, field, suggestion, context
- `ValidationErrorCode` enum: MissingField, InvalidEntityType, InvalidRefType, TypeIdMismatch, InvalidRefTarget, InvalidDisplayId, etc.
- `ValidationCollector` for batch error collection
- `validate_ref_semantics()` enforces: resolve/reopen/address → Tension, refine → same-type
- `validate_display_id()` validates format and extracts components
- `handle_round_register` returns all validation errors in structured JSON before DB operations
- 8 new validation tests added (20 total alignment_db tests)
### Phase 3: Lifecycle Tracking ✅ Complete
- [x] Implement tension state machine (open → addressed → resolved → reopened)
- [x] Create `tension_events` audit trail
- [x] Add authority checks for resolution confirmation (via `actors` parameter)
- [x] Support verdict types: interim, final, minority, dissent
**Implementation Notes:**
- `TensionStatus` enum: `Open`, `Addressed`, `Resolved`, `Reopened`
- Events stored with actors, reference, and round number
- `VerdictType` enum: `Interim`, `Final`, `Minority`, `Dissent`
### Phase 4: Export Tooling ✅ Complete
- [x] Implement `blue_dialogue_export` MCP tool
- [x] Query all data from database (no file parsing)
- [x] Generate single dialogue.json with all data (perspectives, recommendations, tensions, evidence, claims, verdicts)
- [ ] Validation and warning reporting
- [ ] Integration with superviber-web demo viewer
### Phase 5: Skill & Documentation Updates
- [ ] Create `alignment-expert` skill with static marker syntax:
**Implementation Notes:**
- `handle_export()` in `crates/blue-mcp/src/handlers/dialogue.rs`
- Writes to `{output_dir}/{dialogue_id}/dialogue.json` by default
- Full provenance: includes `created_at`, `refs`, status for all entities
### Phase 5: Skill & Documentation Updates ✅ Complete
- [x] Create `alignment-expert` skill with static marker syntax:
- First-class entity markers (P, R, T, E, C)
- Cross-reference syntax (RE:SUPPORT, RE:OPPOSE, etc.)
- Dialogue move syntax (MOVE:DEFEND, MOVE:BRIDGE, etc.)
- Verdict markers (DISSENT, MINORITY VERDICT)
- Local ID format rules
- [ ] Update `alignment-play` skill with new workflow:
- [x] Update `alignment-play` skill with new workflow:
- Judge fetches data via `blue_dialogue_round_context`
- Judge builds prompts, writes to filesystem for debugging
- Judge spawns agents with full prompt + `alignment-expert` skill
- Judge builds prompts with context from DB
- Judge spawns agents with full prompt + `alignment-expert` skill reference
- Judge calls `blue_dialogue_round_register` after scoring (bulk registration)
- Two-phase ID system: agents write local IDs, Judge registers global IDs
- Dynamic expert creation via `blue_dialogue_expert_create`
- [ ] Document tool parameters and return values
- [ ] Add examples for common workflows
- [x] Document tool parameters in skill file
- [x] Add examples for DB-backed workflow
**Implementation Notes:**
- `skills/alignment-expert/SKILL.md` - full marker syntax reference
- `skills/alignment-play/SKILL.md` - updated with DB-backed workflow, two-phase ID system, tool documentation
- Both skills reference each other for complete workflow
### Phase 6: Tooling & Analysis
- [ ] Citation auto-expansion (short form → composite key)
@ -2496,77 +2534,62 @@ This RFC defines **uncalibrated** dialogues — experts argue freely without dom
## Test Plan
- [ ] **Dialogue ID uniqueness**: Creating dialogue with duplicate title generates suffixed ID
### Core Schema Tests (12 unit tests in `alignment_db::tests`) ✅
- [x] **Dialogue ID uniqueness**: Creating dialogue with duplicate title generates suffixed ID (`test_generate_dialogue_id`)
- [x] Display IDs derived correctly from composite keys (`test_display_id_format`, `test_parse_display_id`)
- [x] Tension lifecycle transitions work correctly (`test_tension_lifecycle`)
- [x] Cross-references stored and retrieved (`test_cross_references`)
- [x] Expert registration and scores (`test_register_expert`, `test_expert_scores`)
- [x] Perspective registration (`test_register_perspective`)
- [x] Tension registration (`test_register_tension`)
- [x] Verdict registration (`test_verdict_registration`)
- [x] Full dialogue workflow (`test_full_dialogue_workflow`)
- [x] Create and get dialogue (`test_create_and_get_dialogue`)
### MCP Tool Tests (Integration)
- [x] **Agent Context**: `blue_dialogue_round_context` returns context for ALL panel experts in one call
- [x] **Agent Context**: Shared data (dialogue, prior_rounds, tensions) included
- [x] **Agent Context**: Returns structured perspectives/recommendations with full `content` field
- [x] **Expert Creation**: Judge can create new experts mid-dialogue via MCP
- [x] **Expert Creation**: Created experts have `source: "created"` and `creation_reason`
- [x] **Expert Creation**: `first_round` tracks when expert joined
- [x] **Registration**: Perspectives registered with correct display IDs (P0101, P0201, etc.)
- [x] **Registration**: Round completion updates expert scores and dialogue total
- [x] **Registration**: Tension events create audit trail
- [x] **Export**: Single dialogue.json contains all sections (experts, rounds, perspectives, recommendations, tensions, evidence, claims, verdicts)
- [x] **Export**: Global IDs are unique and sequential
- [x] **Export**: All data comes from database queries (no file parsing)
- [x] **Refs**: Cross-references stored in `refs` table with typed relationships
- [x] **Refs**: ref_type constrained to valid types (support, oppose, refine, etc.)
- [x] **Verdicts**: Multiple verdicts per dialogue supported
- [x] **Verdicts**: Interim verdicts can be registered mid-dialogue
- [x] **Verdicts**: Minority verdicts capture dissenting coalition
- [x] **Verdicts**: Export includes all verdicts in chronological order
### Validation Layer Tests (Phase 2c) ✅ Complete
- [x] **Errors**: MCP returns structured error with `error_code`, `message`, `suggestion`
- [x] **Errors**: Type enum violations return `invalid_entity_type` or `invalid_ref_type`
- [x] **Errors**: Type/ID mismatch returns `type_id_mismatch` with expected prefix
- [x] **Errors**: Semantic violations return `invalid_ref_target` with valid options
- [x] **Errors**: Batch operations return all validation errors, not just first
- [x] **Errors**: Structured JSON response allows Judge to parse and correct programmatically
- [x] **Refs**: Semantic constraint: resolve/reopen/address must target Tension (T)
- [x] **Refs**: Semantic constraint: refine must be same-type (P→P, R→R, etc.)
- [x] **Refs**: Invalid combo caught with appropriate error (e.g., `P resolve→ P` returns InvalidRefTarget)
### Skill Integration Tests (Phase 5) ✅ Complete
- [x] **Skill**: `alignment-expert` skill contains static marker syntax reference
- [x] **Skill**: Skill can be loaded once per agent via skill reference
- [x] **Prompt Assembly**: Judge builds prompts from `blue_dialogue_round_context` data (documented workflow)
- [x] **Prompt Assembly**: Markdown prompt includes full content from all prior experts (via round_context)
- [x] **Prompt Assembly**: Judge spawns agents with full prompt + `alignment-expert` skill reference
- [ ] **Prompt Assembly**: Judge writes `prompt-{expert}.md` to disk for debugging (optional enhancement)
### Pending Tests (Performance & Future)
- [ ] **Output directory isolation**: Concurrent dialogues don't overwrite each other's files
- [ ] Composite keys are unique across dialogue
- [ ] Display IDs derived correctly from composite keys
- [ ] Tension lifecycle transitions respect authority rules
- [ ] Reopened tensions preserve original ID and history
- [ ] JSON export backward compatible with existing consumers
- [ ] SQLite indices performant for 100+ perspective dialogues
- [ ] Foreign key constraints prevent orphaned perspectives/tensions
- [ ] **Agent Context**: `blue_dialogue_round_context` returns context for ALL panel experts in one call
- [ ] **Agent Context**: Shared data (dialogue, prior_rounds, tensions) not duplicated per expert
- [ ] **Agent Context**: Per-expert data (role, focus, source, round_context) keyed by slug
- [ ] **Agent Context**: All prompts include `dialogue.background` (subject, constraints, situation)
- [ ] **Agent Context**: Prompts are self-contained — no external knowledge required
- [ ] **Agent Context**: All agents receive structured content with global IDs (no raw_content)
- [ ] **Agent Context**: All agents receive structured perspectives/proposals with full `content` field
- [ ] **Agent Context**: Fresh experts receive same prior_rounds data as retained experts
- [ ] **Agent Context**: Judge writes context brief for fresh experts (pool or created)
- [ ] **Agent Context**: Active tensions include involvement indicator
- [ ] **Expert Creation**: Judge can create new experts mid-dialogue via MCP
- [ ] **Expert Creation**: Created experts have `source: "created"` and `creation_reason`
- [ ] **Expert Creation**: Judge writes context brief with mandate for created experts
- [ ] **Expert Creation**: Context brief includes dialogue question and foundational context
- [ ] **Expert Creation**: Context brief includes constraints, background, current situation
- [ ] **Expert Creation**: Fresh experts are self-sufficient (no "what is this about?" gaps)
- [ ] **Expert Creation**: Created experts can participate starting from any round
- [ ] **Expert Creation**: `first_round` tracks when expert joined
- [ ] **Prompt Assembly**: Judge builds prompts from `blue_dialogue_round_context` data
- [ ] **Prompt Assembly**: Judge writes `prompt-{expert}.md` to disk for debugging
- [ ] **Prompt Assembly**: Judge writes `context-{expert}.json` to disk for debugging
- [ ] **Prompt Assembly**: Markdown prompt includes full content from all prior experts
- [ ] **Prompt Assembly**: Markdown prompt is LLM-friendly (headers, tables, clear structure)
- [ ] **Prompt Assembly**: Judge spawns agents with full prompt + `alignment-expert` skill
- [ ] **Prompt Assembly**: Agents don't need to read files — prompt passed directly
- [ ] **Prompt Assembly**: Agent writes response to `response-{expert}.md`
- [ ] **Skill**: `alignment-expert` skill contains static marker syntax reference
- [ ] **Skill**: Skill loaded once per agent, not repeated in every prompt
- [ ] **Registration**: Perspectives registered with correct composite keys
- [ ] **Proposals**: Proposals have first-class status with global IDs (EXPERT-R0001)
- [ ] **Proposals**: Proposals link to tensions they address
- [ ] **Proposals**: Proposals link to perspectives they build on
- [ ] **Proposals**: Proposal status tracks proposed → adopted/rejected
- [ ] **Proposals**: Adopted proposals linked to verdict
- [ ] **Registration**: Contributions (proposals, evidence, moves) stored with parameters
- [ ] **Registration**: Round completion updates expert scores and dialogue total
- [ ] **Registration**: Tension events create audit trail
- [ ] **Export**: Single dialogue.json contains all sections (experts, rounds, perspectives, recommendations, tensions, evidence, claims, verdicts)
- [ ] **Export**: Global perspective IDs are unique and sequential
- [ ] **Export**: All data comes from database queries (no file parsing)
- [ ] **Export**: Perspective status correctly tracks refinements across rounds
- [ ] **Export**: Proposals include structured parameters from contributions table
- [ ] **Export**: Cross-references build relationship graph between expert contributions
- [ ] **Refs**: Cross-references stored in `refs` table with typed relationships
- [ ] **Refs**: Query "what supports P0001?" uses target index efficiently
- [ ] **Refs**: Query "what does P0101 reference?" uses source index efficiently
- [ ] **Refs**: ref_type constrained to valid types (support, oppose, refine, etc.)
- [ ] **Refs**: Type consistency enforced (source_type='P' requires source_id LIKE 'P%')
- [ ] **Refs**: Semantic constraint: resolve/reopen/address must target Tension (T)
- [ ] **Refs**: Semantic constraint: refine must be same-type (P→P, R→R, etc.)
- [ ] **Refs**: Invalid combo rejected (e.g., `P resolve→ P` fails CHECK)
- [ ] **Errors**: MCP returns structured error with `error_code`, `message`, `suggestion`
- [ ] **Errors**: Type enum violations return `invalid_entity_type` or `invalid_ref_type`
- [ ] **Errors**: Type/ID mismatch returns `type_id_mismatch` with expected prefix
- [ ] **Errors**: Semantic violations return `invalid_ref_target` with valid options
- [ ] **Errors**: Batch operations return all validation errors, not just first
- [ ] **Errors**: Judge can parse error response and correct submission programmatically
- [ ] **Verdicts**: Multiple verdicts per dialogue supported
- [ ] **Verdicts**: Interim verdicts can be registered mid-dialogue
- [ ] **Verdicts**: Minority verdicts capture dissenting coalition
- [ ] **Verdicts**: Verdicts are immutable once registered
- [ ] **Verdicts**: Export includes all verdicts in chronological order
- [ ] JSON export backward compatible with existing consumers
## Dialogue Summary

View file

@ -31,6 +31,269 @@ pub enum AlignmentDbError {
#[error("Reference not found: {0}")]
RefNotFound(String),
#[error("Batch validation failed with {} error(s)", .0.len())]
BatchValidation(Vec<ValidationError>),
}
// ==================== Validation Types (RFC 0051 Phase 2c) ====================
/// Error codes for validation failures
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ValidationErrorCode {
/// Missing required field
MissingField,
/// Invalid entity type (not P, R, T, E, or C)
InvalidEntityType,
/// Invalid reference type (not support, oppose, etc.)
InvalidRefType,
/// Entity type doesn't match ID prefix (e.g., source_type='P' but id='T0001')
TypeIdMismatch,
/// Invalid reference target (e.g., resolve targeting a Perspective instead of Tension)
InvalidRefTarget,
/// Invalid display ID format (should be like P0101, T0203)
InvalidDisplayId,
/// Invalid tension status transition
InvalidStatusTransition,
/// Duplicate entity (already exists)
DuplicateEntity,
/// Referenced entity not found
EntityNotFound,
/// Invalid round number
InvalidRound,
/// Expert not registered in dialogue
ExpertNotInDialogue,
}
impl ValidationErrorCode {
pub fn as_str(&self) -> &'static str {
match self {
Self::MissingField => "missing_field",
Self::InvalidEntityType => "invalid_entity_type",
Self::InvalidRefType => "invalid_ref_type",
Self::TypeIdMismatch => "type_id_mismatch",
Self::InvalidRefTarget => "invalid_ref_target",
Self::InvalidDisplayId => "invalid_display_id",
Self::InvalidStatusTransition => "invalid_status_transition",
Self::DuplicateEntity => "duplicate_entity",
Self::EntityNotFound => "entity_not_found",
Self::InvalidRound => "invalid_round",
Self::ExpertNotInDialogue => "expert_not_in_dialogue",
}
}
}
/// A single validation error with context
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ValidationError {
/// Error code for programmatic handling
pub code: ValidationErrorCode,
/// Human-readable error message
pub message: String,
/// Field or path where error occurred (e.g., "perspectives[0].label")
pub field: Option<String>,
/// Suggestion for fixing the error
pub suggestion: Option<String>,
/// Additional context (e.g., valid options)
pub context: Option<serde_json::Value>,
}
impl ValidationError {
pub fn new(code: ValidationErrorCode, message: impl Into<String>) -> Self {
Self {
code,
message: message.into(),
field: None,
suggestion: None,
context: None,
}
}
pub fn with_field(mut self, field: impl Into<String>) -> Self {
self.field = Some(field.into());
self
}
pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
self.suggestion = Some(suggestion.into());
self
}
pub fn with_context(mut self, context: serde_json::Value) -> Self {
self.context = Some(context);
self
}
/// Create a missing field error
pub fn missing_field(field: &str) -> Self {
Self::new(
ValidationErrorCode::MissingField,
format!("Required field '{}' is missing", field),
)
.with_field(field)
}
/// Create an invalid entity type error
pub fn invalid_entity_type(value: &str) -> Self {
Self::new(
ValidationErrorCode::InvalidEntityType,
format!("Invalid entity type '{}'. Must be one of: P, R, T, E, C", value),
)
.with_suggestion("Use P (Perspective), R (Recommendation), T (Tension), E (Evidence), or C (Claim)")
.with_context(serde_json::json!({"valid_types": ["P", "R", "T", "E", "C"]}))
}
/// Create an invalid ref type error
pub fn invalid_ref_type(value: &str) -> Self {
Self::new(
ValidationErrorCode::InvalidRefType,
format!("Invalid reference type '{}'. Must be one of: support, oppose, refine, address, resolve, reopen, question, depend", value),
)
.with_suggestion("Use a valid reference type")
.with_context(serde_json::json!({"valid_types": ["support", "oppose", "refine", "address", "resolve", "reopen", "question", "depend"]}))
}
/// Create a type/ID mismatch error
pub fn type_id_mismatch(expected_type: &str, actual_id: &str) -> Self {
Self::new(
ValidationErrorCode::TypeIdMismatch,
format!("Entity type '{}' doesn't match ID '{}'. ID should start with '{}'", expected_type, actual_id, expected_type),
)
.with_suggestion(format!("Use an ID starting with '{}' (e.g., {}0101)", expected_type, expected_type))
}
/// Create an invalid ref target error
pub fn invalid_ref_target(ref_type: &str, target_type: &str, expected_type: &str) -> Self {
Self::new(
ValidationErrorCode::InvalidRefTarget,
format!("Reference type '{}' cannot target entity type '{}'. Expected: {}", ref_type, target_type, expected_type),
)
.with_suggestion(format!("Use a {} entity as the target", expected_type))
.with_context(serde_json::json!({"ref_type": ref_type, "expected_target": expected_type}))
}
/// Create an invalid display ID error
pub fn invalid_display_id(id: &str) -> Self {
Self::new(
ValidationErrorCode::InvalidDisplayId,
format!("Invalid display ID format '{}'. Expected format: [P|R|T|E|C]RRSS (e.g., P0101, T0203)", id),
)
.with_suggestion("Use format: type prefix + 2-digit round + 2-digit sequence (e.g., P0101)")
}
}
impl std::fmt::Display for ValidationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "[{}] {}", self.code.as_str(), self.message)?;
if let Some(field) = &self.field {
write!(f, " (field: {})", field)?;
}
Ok(())
}
}
/// Collector for batch validation errors
#[derive(Debug, Default)]
pub struct ValidationCollector {
errors: Vec<ValidationError>,
}
impl ValidationCollector {
pub fn new() -> Self {
Self { errors: Vec::new() }
}
pub fn add(&mut self, error: ValidationError) {
self.errors.push(error);
}
pub fn add_if<F>(&mut self, condition: bool, error_fn: F)
where
F: FnOnce() -> ValidationError,
{
if condition {
self.errors.push(error_fn());
}
}
pub fn is_empty(&self) -> bool {
self.errors.is_empty()
}
pub fn len(&self) -> usize {
self.errors.len()
}
pub fn into_result<T>(self, success: T) -> Result<T, AlignmentDbError> {
if self.errors.is_empty() {
Ok(success)
} else {
Err(AlignmentDbError::BatchValidation(self.errors))
}
}
pub fn errors(&self) -> &[ValidationError] {
&self.errors
}
pub fn into_errors(self) -> Vec<ValidationError> {
self.errors
}
}
// ==================== Validation Functions ====================
/// Validate a reference's semantic constraints
pub fn validate_ref_semantics(
ref_type: RefType,
source_type: EntityType,
target_type: EntityType,
) -> Option<ValidationError> {
// resolve, reopen, address must target Tension (T)
match ref_type {
RefType::Resolve | RefType::Reopen | RefType::Address => {
if target_type != EntityType::Tension {
return Some(ValidationError::invalid_ref_target(
ref_type.as_str(),
target_type.as_str(),
"T (Tension)",
));
}
}
// refine must be same-type
RefType::Refine => {
if source_type != target_type {
return Some(ValidationError::new(
ValidationErrorCode::InvalidRefTarget,
format!(
"Reference type 'refine' requires same entity types. Source: {}, Target: {}",
source_type.as_str(),
target_type.as_str()
),
)
.with_suggestion("Use matching entity types for refine references (P→P, R→R, etc.)"));
}
}
// support, oppose, question, depend can target any type
_ => {}
}
None
}
/// Validate a display ID format and extract components
pub fn validate_display_id(id: &str) -> Result<(EntityType, i32, i32), ValidationError> {
parse_display_id(id).ok_or_else(|| ValidationError::invalid_display_id(id))
}
/// Validate that a display ID matches an expected entity type
pub fn validate_id_type_match(id: &str, expected_type: EntityType) -> Option<ValidationError> {
if let Some((actual_type, _, _)) = parse_display_id(id) {
if actual_type != expected_type {
return Some(ValidationError::type_id_mismatch(expected_type.as_str(), id));
}
}
None
}
// ==================== Enums ====================
@ -2092,4 +2355,120 @@ mod tests {
let total_score: i32 = experts.iter().map(|e| e.total_score).sum();
assert_eq!(total_score, 27);
}
// ==================== Validation Tests (RFC 0051 Phase 2c) ====================
#[test]
fn test_validation_error_creation() {
let err = ValidationError::missing_field("label");
assert_eq!(err.code, ValidationErrorCode::MissingField);
assert!(err.message.contains("label"));
assert_eq!(err.field, Some("label".to_string()));
let err = ValidationError::invalid_entity_type("X");
assert_eq!(err.code, ValidationErrorCode::InvalidEntityType);
assert!(err.context.is_some());
let err = ValidationError::invalid_ref_type("bogus");
assert_eq!(err.code, ValidationErrorCode::InvalidRefType);
assert!(err.suggestion.is_some());
}
#[test]
fn test_validation_collector() {
let mut collector = ValidationCollector::new();
assert!(collector.is_empty());
collector.add(ValidationError::missing_field("label"));
collector.add(ValidationError::missing_field("content"));
assert!(!collector.is_empty());
assert_eq!(collector.len(), 2);
let errors = collector.into_errors();
assert_eq!(errors.len(), 2);
}
#[test]
fn test_validate_ref_semantics_resolve_must_target_tension() {
// resolve must target Tension
let err = validate_ref_semantics(RefType::Resolve, EntityType::Perspective, EntityType::Perspective);
assert!(err.is_some());
assert_eq!(err.as_ref().unwrap().code, ValidationErrorCode::InvalidRefTarget);
// resolve targeting Tension is valid
let err = validate_ref_semantics(RefType::Resolve, EntityType::Perspective, EntityType::Tension);
assert!(err.is_none());
// address must target Tension
let err = validate_ref_semantics(RefType::Address, EntityType::Recommendation, EntityType::Claim);
assert!(err.is_some());
// reopen must target Tension
let err = validate_ref_semantics(RefType::Reopen, EntityType::Evidence, EntityType::Evidence);
assert!(err.is_some());
}
#[test]
fn test_validate_ref_semantics_refine_must_be_same_type() {
// refine P→P is valid
let err = validate_ref_semantics(RefType::Refine, EntityType::Perspective, EntityType::Perspective);
assert!(err.is_none());
// refine R→R is valid
let err = validate_ref_semantics(RefType::Refine, EntityType::Recommendation, EntityType::Recommendation);
assert!(err.is_none());
// refine P→R is invalid (different types)
let err = validate_ref_semantics(RefType::Refine, EntityType::Perspective, EntityType::Recommendation);
assert!(err.is_some());
assert_eq!(err.as_ref().unwrap().code, ValidationErrorCode::InvalidRefTarget);
}
#[test]
fn test_validate_ref_semantics_support_can_target_any() {
// support can target any entity type
assert!(validate_ref_semantics(RefType::Support, EntityType::Perspective, EntityType::Tension).is_none());
assert!(validate_ref_semantics(RefType::Support, EntityType::Evidence, EntityType::Claim).is_none());
assert!(validate_ref_semantics(RefType::Support, EntityType::Claim, EntityType::Recommendation).is_none());
// oppose can target any
assert!(validate_ref_semantics(RefType::Oppose, EntityType::Perspective, EntityType::Perspective).is_none());
// question can target any
assert!(validate_ref_semantics(RefType::Question, EntityType::Evidence, EntityType::Claim).is_none());
}
#[test]
fn test_validate_display_id() {
// Valid IDs
assert!(validate_display_id("P0101").is_ok());
assert!(validate_display_id("T0001").is_ok());
assert!(validate_display_id("R0203").is_ok());
assert!(validate_display_id("E0105").is_ok());
assert!(validate_display_id("C0001").is_ok());
// Invalid IDs
assert!(validate_display_id("X0101").is_err()); // Invalid prefix
assert!(validate_display_id("P01").is_err()); // Too short
assert!(validate_display_id("P010101").is_err()); // Too long
assert!(validate_display_id("").is_err()); // Empty
assert!(validate_display_id("PERSPECTIVE").is_err()); // Not a display ID
}
#[test]
fn test_type_id_mismatch_error() {
let err = ValidationError::type_id_mismatch("P", "T0101");
assert_eq!(err.code, ValidationErrorCode::TypeIdMismatch);
assert!(err.message.contains("P"));
assert!(err.message.contains("T0101"));
}
#[test]
fn test_invalid_ref_target_error() {
let err = ValidationError::invalid_ref_target("resolve", "P", "T (Tension)");
assert_eq!(err.code, ValidationErrorCode::InvalidRefTarget);
assert!(err.message.contains("resolve"));
assert!(err.message.contains("Tension"));
}
}

View file

@ -9,6 +9,18 @@ use std::path::{Path, PathBuf};
use std::process::Command;
use blue_core::{DocType, Document, LinkType, ProjectState, title_to_slug};
use blue_core::alignment_db::{
self, ExpertTier as DbExpertTier, ExpertSource as DbExpertSource,
EntityType, RefType, VerdictType, Verdict,
ValidationError, ValidationCollector,
validate_ref_semantics, validate_display_id,
get_dialogue, register_expert, get_experts,
create_round, register_perspective, register_tension,
register_recommendation, register_evidence, register_claim, register_ref,
register_verdict, update_tension_status, update_expert_score,
get_perspectives, get_tensions, get_recommendations, get_evidence, get_claims, get_verdicts,
display_id, parse_display_id,
};
use rand::Rng;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
@ -1984,6 +1996,938 @@ pub fn handle_evolve_panel(args: &Value) -> Result<Value, ServerError> {
}))
}
// ==================== RFC 0051: Global Perspective & Tension Tracking ====================
/// Handle blue_dialogue_round_context (RFC 0051)
///
/// Bulk fetch context for all panel experts in a single call.
/// Returns structured data for prompt building: open perspectives, tensions, etc.
pub fn handle_round_context(state: &ProjectState, args: &Value) -> Result<Value, ServerError> {
let dialogue_id = args
.get("dialogue_id")
.and_then(|v| v.as_str())
.ok_or(ServerError::InvalidParams)?;
let round = args
.get("round")
.and_then(|v| v.as_i64())
.ok_or(ServerError::InvalidParams)? as i32;
let conn = state.store.conn();
// Get dialogue
let dialogue = get_dialogue(conn, dialogue_id)
.map_err(|e| ServerError::CommandFailed(format!("Dialogue not found: {}", e)))?;
// Get experts
let experts = get_experts(conn, dialogue_id)
.map_err(|e| ServerError::CommandFailed(format!("Failed to get experts: {}", e)))?;
// Get all perspectives
let perspectives = get_perspectives(conn, dialogue_id)
.map_err(|e| ServerError::CommandFailed(format!("Failed to get perspectives: {}", e)))?;
// Get all tensions (filter for open/addressed in response)
let tensions = get_tensions(conn, dialogue_id)
.map_err(|e| ServerError::CommandFailed(format!("Failed to get tensions: {}", e)))?;
// Get recommendations
let recommendations = get_recommendations(conn, dialogue_id)
.map_err(|e| ServerError::CommandFailed(format!("Failed to get recommendations: {}", e)))?;
// Get evidence
let evidence = get_evidence(conn, dialogue_id)
.map_err(|e| ServerError::CommandFailed(format!("Failed to get evidence: {}", e)))?;
// Get claims
let claims = get_claims(conn, dialogue_id)
.map_err(|e| ServerError::CommandFailed(format!("Failed to get claims: {}", e)))?;
// Get verdicts
let verdicts = get_verdicts(conn, dialogue_id)
.map_err(|e| ServerError::CommandFailed(format!("Failed to get verdicts: {}", e)))?;
// Build context response
Ok(json!({
"status": "success",
"dialogue_id": dialogue_id,
"round": round,
"dialogue": {
"title": dialogue.title,
"question": dialogue.question,
"status": dialogue.status.as_str(),
"total_rounds": dialogue.total_rounds,
"total_alignment": dialogue.total_alignment,
},
"experts": experts.iter().map(|e| json!({
"expert_slug": e.expert_slug,
"role": e.role,
"tier": e.tier.as_str(),
"source": e.source.as_str(),
"focus": e.focus,
"total_score": e.total_score,
})).collect::<Vec<_>>(),
"perspectives": perspectives.iter().map(|p| json!({
"id": display_id(EntityType::Perspective, p.round, p.seq),
"round": p.round,
"label": p.label,
"content": p.content,
"contributors": p.contributors,
"status": p.status.as_str(),
})).collect::<Vec<_>>(),
"tensions": tensions.iter().map(|t| json!({
"id": display_id(EntityType::Tension, t.round, t.seq),
"round": t.round,
"label": t.label,
"description": t.description,
"contributors": t.contributors,
"status": t.status.as_str(),
})).collect::<Vec<_>>(),
"open_tensions": tensions.iter()
.filter(|t| matches!(t.status, alignment_db::TensionStatus::Open | alignment_db::TensionStatus::Reopened))
.map(|t| display_id(EntityType::Tension, t.round, t.seq))
.collect::<Vec<_>>(),
"recommendations": recommendations.iter().map(|r| json!({
"id": display_id(EntityType::Recommendation, r.round, r.seq),
"round": r.round,
"label": r.label,
"content": r.content,
"contributors": r.contributors,
"status": r.status.as_str(),
"parameters": r.parameters,
})).collect::<Vec<_>>(),
"evidence": evidence.iter().map(|e| json!({
"id": display_id(EntityType::Evidence, e.round, e.seq),
"round": e.round,
"label": e.label,
"content": e.content,
"contributors": e.contributors,
"status": e.status.as_str(),
})).collect::<Vec<_>>(),
"claims": claims.iter().map(|c| json!({
"id": display_id(EntityType::Claim, c.round, c.seq),
"round": c.round,
"label": c.label,
"content": c.content,
"contributors": c.contributors,
"status": c.status.as_str(),
})).collect::<Vec<_>>(),
"verdicts": verdicts.iter().map(|v| json!({
"verdict_id": v.verdict_id,
"verdict_type": v.verdict_type.as_str(),
"round": v.round,
"recommendation": v.recommendation,
"description": v.description,
})).collect::<Vec<_>>(),
}))
}
/// Handle blue_dialogue_expert_create (RFC 0051)
///
/// Create a new expert mid-dialogue to address emerging needs.
pub fn handle_expert_create(state: &ProjectState, args: &Value) -> Result<Value, ServerError> {
let dialogue_id = args
.get("dialogue_id")
.and_then(|v| v.as_str())
.ok_or(ServerError::InvalidParams)?;
let expert_slug = args
.get("expert_slug")
.and_then(|v| v.as_str())
.ok_or(ServerError::InvalidParams)?;
let role = args
.get("role")
.and_then(|v| v.as_str())
.ok_or(ServerError::InvalidParams)?;
let tier_str = args
.get("tier")
.and_then(|v| v.as_str())
.ok_or(ServerError::InvalidParams)?;
let tier = DbExpertTier::from_str(tier_str);
let focus = args.get("focus").and_then(|v| v.as_str());
let creation_reason = args
.get("creation_reason")
.and_then(|v| v.as_str())
.ok_or(ServerError::InvalidParams)?;
let first_round = args
.get("first_round")
.and_then(|v| v.as_i64())
.ok_or(ServerError::InvalidParams)? as i32;
let conn = state.store.conn();
// Verify dialogue exists
let _dialogue = get_dialogue(conn, dialogue_id)
.map_err(|e| ServerError::CommandFailed(format!("Dialogue not found: {}", e)))?;
// Register expert with source=created
register_expert(
conn,
dialogue_id,
expert_slug,
role,
tier,
DbExpertSource::Created,
None, // description
focus,
None, // relevance
Some(creation_reason),
None, // color
Some(first_round),
)
.map_err(|e| ServerError::CommandFailed(format!("Failed to create expert: {}", e)))?;
Ok(json!({
"status": "success",
"message": format!("Created expert '{}' ({}) for round {}", expert_slug, role, first_round),
"expert": {
"expert_slug": expert_slug,
"role": role,
"tier": tier.as_str(),
"source": "created",
"focus": focus,
"creation_reason": creation_reason,
"first_round": first_round,
}
}))
}
/// Validate round_register inputs (RFC 0051 Phase 2c)
///
/// Returns all validation errors, not just the first one.
fn validate_round_register_inputs(args: &Value) -> Vec<ValidationError> {
let mut collector = ValidationCollector::new();
// Validate perspectives
if let Some(perspectives) = args.get("perspectives").and_then(|v| v.as_array()) {
for (i, p) in perspectives.iter().enumerate() {
let field_prefix = format!("perspectives[{}]", i);
// Required fields
if p.get("label").and_then(|v| v.as_str()).map(|s| s.is_empty()).unwrap_or(true) {
collector.add(ValidationError::missing_field("label").with_field(format!("{}.label", field_prefix)));
}
if p.get("content").and_then(|v| v.as_str()).map(|s| s.is_empty()).unwrap_or(true) {
collector.add(ValidationError::missing_field("content").with_field(format!("{}.content", field_prefix)));
}
// Validate references
if let Some(refs) = p.get("references").and_then(|v| v.as_array()) {
for (j, r) in refs.iter().enumerate() {
let ref_field = format!("{}.references[{}]", field_prefix, j);
let ref_type_str = r.get("type").and_then(|v| v.as_str()).unwrap_or("");
let target = r.get("target").and_then(|v| v.as_str()).unwrap_or("");
// Validate ref type
if RefType::from_str(ref_type_str).is_none() && !ref_type_str.is_empty() {
collector.add(ValidationError::invalid_ref_type(ref_type_str).with_field(format!("{}.type", ref_field)));
}
// Validate target ID format
if !target.is_empty() {
if let Err(e) = validate_display_id(target) {
collector.add(e.with_field(format!("{}.target", ref_field)));
} else if let (Some(ref_type), Some((target_type, _, _))) = (RefType::from_str(ref_type_str), parse_display_id(target)) {
// Validate semantic constraints
if let Some(e) = validate_ref_semantics(ref_type, EntityType::Perspective, target_type) {
collector.add(e.with_field(ref_field));
}
}
}
}
}
}
}
// Validate tensions
if let Some(tensions) = args.get("tensions").and_then(|v| v.as_array()) {
for (i, t) in tensions.iter().enumerate() {
let field_prefix = format!("tensions[{}]", i);
if t.get("label").and_then(|v| v.as_str()).map(|s| s.is_empty()).unwrap_or(true) {
collector.add(ValidationError::missing_field("label").with_field(format!("{}.label", field_prefix)));
}
if t.get("description").and_then(|v| v.as_str()).map(|s| s.is_empty()).unwrap_or(true) {
collector.add(ValidationError::missing_field("description").with_field(format!("{}.description", field_prefix)));
}
// Validate references
if let Some(refs) = t.get("references").and_then(|v| v.as_array()) {
for (j, r) in refs.iter().enumerate() {
let ref_field = format!("{}.references[{}]", field_prefix, j);
let ref_type_str = r.get("type").and_then(|v| v.as_str()).unwrap_or("");
let target = r.get("target").and_then(|v| v.as_str()).unwrap_or("");
if RefType::from_str(ref_type_str).is_none() && !ref_type_str.is_empty() {
collector.add(ValidationError::invalid_ref_type(ref_type_str).with_field(format!("{}.type", ref_field)));
}
if !target.is_empty() {
if let Err(e) = validate_display_id(target) {
collector.add(e.with_field(format!("{}.target", ref_field)));
} else if let (Some(ref_type), Some((target_type, _, _))) = (RefType::from_str(ref_type_str), parse_display_id(target)) {
if let Some(e) = validate_ref_semantics(ref_type, EntityType::Tension, target_type) {
collector.add(e.with_field(ref_field));
}
}
}
}
}
}
}
// Validate recommendations
if let Some(recommendations) = args.get("recommendations").and_then(|v| v.as_array()) {
for (i, r) in recommendations.iter().enumerate() {
let field_prefix = format!("recommendations[{}]", i);
if r.get("label").and_then(|v| v.as_str()).map(|s| s.is_empty()).unwrap_or(true) {
collector.add(ValidationError::missing_field("label").with_field(format!("{}.label", field_prefix)));
}
if r.get("content").and_then(|v| v.as_str()).map(|s| s.is_empty()).unwrap_or(true) {
collector.add(ValidationError::missing_field("content").with_field(format!("{}.content", field_prefix)));
}
// Validate references
if let Some(refs) = r.get("references").and_then(|v| v.as_array()) {
for (j, ref_obj) in refs.iter().enumerate() {
let ref_field = format!("{}.references[{}]", field_prefix, j);
let ref_type_str = ref_obj.get("type").and_then(|v| v.as_str()).unwrap_or("");
let target = ref_obj.get("target").and_then(|v| v.as_str()).unwrap_or("");
if RefType::from_str(ref_type_str).is_none() && !ref_type_str.is_empty() {
collector.add(ValidationError::invalid_ref_type(ref_type_str).with_field(format!("{}.type", ref_field)));
}
if !target.is_empty() {
if let Err(e) = validate_display_id(target) {
collector.add(e.with_field(format!("{}.target", ref_field)));
} else if let (Some(ref_type), Some((target_type, _, _))) = (RefType::from_str(ref_type_str), parse_display_id(target)) {
if let Some(e) = validate_ref_semantics(ref_type, EntityType::Recommendation, target_type) {
collector.add(e.with_field(ref_field));
}
}
}
}
}
}
}
// Validate evidence
if let Some(evidence) = args.get("evidence").and_then(|v| v.as_array()) {
for (i, e) in evidence.iter().enumerate() {
let field_prefix = format!("evidence[{}]", i);
if e.get("label").and_then(|v| v.as_str()).map(|s| s.is_empty()).unwrap_or(true) {
collector.add(ValidationError::missing_field("label").with_field(format!("{}.label", field_prefix)));
}
if e.get("content").and_then(|v| v.as_str()).map(|s| s.is_empty()).unwrap_or(true) {
collector.add(ValidationError::missing_field("content").with_field(format!("{}.content", field_prefix)));
}
}
}
// Validate claims
if let Some(claims) = args.get("claims").and_then(|v| v.as_array()) {
for (i, c) in claims.iter().enumerate() {
let field_prefix = format!("claims[{}]", i);
if c.get("label").and_then(|v| v.as_str()).map(|s| s.is_empty()).unwrap_or(true) {
collector.add(ValidationError::missing_field("label").with_field(format!("{}.label", field_prefix)));
}
if c.get("content").and_then(|v| v.as_str()).map(|s| s.is_empty()).unwrap_or(true) {
collector.add(ValidationError::missing_field("content").with_field(format!("{}.content", field_prefix)));
}
}
}
// Validate tension updates
if let Some(updates) = args.get("tension_updates").and_then(|v| v.as_array()) {
for (i, u) in updates.iter().enumerate() {
let field_prefix = format!("tension_updates[{}]", i);
let tension_id = u.get("id").and_then(|v| v.as_str()).unwrap_or("");
if tension_id.is_empty() {
collector.add(ValidationError::missing_field("id").with_field(format!("{}.id", field_prefix)));
} else if let Err(e) = validate_display_id(tension_id) {
collector.add(e.with_field(format!("{}.id", field_prefix)));
} else if let Some((entity_type, _, _)) = parse_display_id(tension_id) {
if entity_type != EntityType::Tension {
collector.add(ValidationError::type_id_mismatch("T", tension_id).with_field(format!("{}.id", field_prefix)));
}
}
}
}
collector.into_errors()
}
/// Convert validation errors to a structured JSON response
fn validation_errors_to_json(errors: &[ValidationError]) -> Value {
json!({
"status": "error",
"error_code": "validation_failed",
"error_count": errors.len(),
"message": format!("Validation failed with {} error(s)", errors.len()),
"errors": errors.iter().map(|e| json!({
"code": e.code.as_str(),
"message": e.message,
"field": e.field,
"suggestion": e.suggestion,
"context": e.context,
})).collect::<Vec<_>>(),
})
}
/// Handle blue_dialogue_round_register (RFC 0051)
///
/// Bulk register all round data in a single atomic call.
pub fn handle_round_register(state: &ProjectState, args: &Value) -> Result<Value, ServerError> {
let dialogue_id = args
.get("dialogue_id")
.and_then(|v| v.as_str())
.ok_or(ServerError::InvalidParams)?;
let round = args
.get("round")
.and_then(|v| v.as_i64())
.ok_or(ServerError::InvalidParams)? as i32;
let score = args
.get("score")
.and_then(|v| v.as_i64())
.ok_or(ServerError::InvalidParams)? as i32;
let summary = args.get("summary").and_then(|v| v.as_str());
// Phase 2c: Batch validation - collect ALL errors before registration
let validation_errors = validate_round_register_inputs(args);
if !validation_errors.is_empty() {
return Ok(validation_errors_to_json(&validation_errors));
}
let conn = state.store.conn();
// Verify dialogue exists
let _dialogue = get_dialogue(conn, dialogue_id)
.map_err(|e| ServerError::CommandFailed(format!("Dialogue not found: {}", e)))?;
// Create round record (title, then score)
create_round(conn, dialogue_id, round, summary, score)
.map_err(|e| ServerError::CommandFailed(format!("Failed to create round: {}", e)))?;
let mut registered = json!({
"perspectives": [],
"tensions": [],
"recommendations": [],
"evidence": [],
"claims": [],
"refs": 0,
"expert_scores": [],
});
// Register perspectives
if let Some(perspectives) = args.get("perspectives").and_then(|v| v.as_array()) {
let mut p_ids = Vec::new();
for p in perspectives {
let local_id = p.get("local_id").and_then(|v| v.as_str()).unwrap_or("");
let label = p.get("label").and_then(|v| v.as_str()).unwrap_or("");
let content = p.get("content").and_then(|v| v.as_str()).unwrap_or("");
let contributors: Vec<String> = p
.get("contributors")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
.unwrap_or_default();
let global_id = register_perspective(conn, dialogue_id, round, label, content, &contributors, None)
.map_err(|e| ServerError::CommandFailed(format!("Failed to register perspective: {}", e)))?;
// Register refs for this perspective
if let Some(refs) = p.get("references").and_then(|v| v.as_array()) {
for r in refs {
let ref_type_str = r.get("type").and_then(|v| v.as_str()).unwrap_or("support");
let target = r.get("target").and_then(|v| v.as_str()).unwrap_or("");
if let (Some(ref_type), Some((target_type, _, _))) = (RefType::from_str(ref_type_str), parse_display_id(target)) {
let _ = register_ref(conn, dialogue_id, EntityType::Perspective, &global_id, ref_type, target_type, target);
}
}
}
p_ids.push(json!({ "local_id": local_id, "global_id": global_id }));
}
registered["perspectives"] = json!(p_ids);
}
// Register tensions
if let Some(tensions) = args.get("tensions").and_then(|v| v.as_array()) {
let mut t_ids = Vec::new();
for t in tensions {
let local_id = t.get("local_id").and_then(|v| v.as_str()).unwrap_or("");
let label = t.get("label").and_then(|v| v.as_str()).unwrap_or("");
let description = t.get("description").and_then(|v| v.as_str()).unwrap_or("");
let contributors: Vec<String> = t
.get("contributors")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
.unwrap_or_default();
let global_id = register_tension(conn, dialogue_id, round, label, description, &contributors, None)
.map_err(|e| ServerError::CommandFailed(format!("Failed to register tension: {}", e)))?;
// Register refs for this tension
if let Some(refs) = t.get("references").and_then(|v| v.as_array()) {
for r in refs {
let ref_type_str = r.get("type").and_then(|v| v.as_str()).unwrap_or("support");
let target = r.get("target").and_then(|v| v.as_str()).unwrap_or("");
if let (Some(ref_type), Some((target_type, _, _))) = (RefType::from_str(ref_type_str), parse_display_id(target)) {
let _ = register_ref(conn, dialogue_id, EntityType::Tension, &global_id, ref_type, target_type, target);
}
}
}
t_ids.push(json!({ "local_id": local_id, "global_id": global_id }));
}
registered["tensions"] = json!(t_ids);
}
// Register recommendations
if let Some(recommendations) = args.get("recommendations").and_then(|v| v.as_array()) {
let mut r_ids = Vec::new();
for r in recommendations {
let local_id = r.get("local_id").and_then(|v| v.as_str()).unwrap_or("");
let label = r.get("label").and_then(|v| v.as_str()).unwrap_or("");
let content = r.get("content").and_then(|v| v.as_str()).unwrap_or("");
let contributors: Vec<String> = r
.get("contributors")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
.unwrap_or_default();
let parameters = r.get("parameters").cloned();
let global_id = register_recommendation(conn, dialogue_id, round, label, content, &contributors, parameters.as_ref(), None)
.map_err(|e| ServerError::CommandFailed(format!("Failed to register recommendation: {}", e)))?;
// Register refs
if let Some(refs) = r.get("references").and_then(|v| v.as_array()) {
for ref_obj in refs {
let ref_type_str = ref_obj.get("type").and_then(|v| v.as_str()).unwrap_or("support");
let target = ref_obj.get("target").and_then(|v| v.as_str()).unwrap_or("");
if let (Some(ref_type), Some((target_type, _, _))) = (RefType::from_str(ref_type_str), parse_display_id(target)) {
let _ = register_ref(conn, dialogue_id, EntityType::Recommendation, &global_id, ref_type, target_type, target);
}
}
}
r_ids.push(json!({ "local_id": local_id, "global_id": global_id }));
}
registered["recommendations"] = json!(r_ids);
}
// Register evidence
if let Some(evidence_arr) = args.get("evidence").and_then(|v| v.as_array()) {
let mut e_ids = Vec::new();
for e in evidence_arr {
let local_id = e.get("local_id").and_then(|v| v.as_str()).unwrap_or("");
let label = e.get("label").and_then(|v| v.as_str()).unwrap_or("");
let content = e.get("content").and_then(|v| v.as_str()).unwrap_or("");
let contributors: Vec<String> = e
.get("contributors")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
.unwrap_or_default();
let global_id = register_evidence(conn, dialogue_id, round, label, content, &contributors, None)
.map_err(|e| ServerError::CommandFailed(format!("Failed to register evidence: {}", e)))?;
// Register refs
if let Some(refs) = e.get("references").and_then(|v| v.as_array()) {
for ref_obj in refs {
let ref_type_str = ref_obj.get("type").and_then(|v| v.as_str()).unwrap_or("support");
let target = ref_obj.get("target").and_then(|v| v.as_str()).unwrap_or("");
if let (Some(ref_type), Some((target_type, _, _))) = (RefType::from_str(ref_type_str), parse_display_id(target)) {
let _ = register_ref(conn, dialogue_id, EntityType::Evidence, &global_id, ref_type, target_type, target);
}
}
}
e_ids.push(json!({ "local_id": local_id, "global_id": global_id }));
}
registered["evidence"] = json!(e_ids);
}
// Register claims
if let Some(claims_arr) = args.get("claims").and_then(|v| v.as_array()) {
let mut c_ids = Vec::new();
for c in claims_arr {
let local_id = c.get("local_id").and_then(|v| v.as_str()).unwrap_or("");
let label = c.get("label").and_then(|v| v.as_str()).unwrap_or("");
let content = c.get("content").and_then(|v| v.as_str()).unwrap_or("");
let contributors: Vec<String> = c
.get("contributors")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
.unwrap_or_default();
let global_id = register_claim(conn, dialogue_id, round, label, content, &contributors, None)
.map_err(|e| ServerError::CommandFailed(format!("Failed to register claim: {}", e)))?;
// Register refs
if let Some(refs) = c.get("references").and_then(|v| v.as_array()) {
for ref_obj in refs {
let ref_type_str = ref_obj.get("type").and_then(|v| v.as_str()).unwrap_or("support");
let target = ref_obj.get("target").and_then(|v| v.as_str()).unwrap_or("");
if let (Some(ref_type), Some((target_type, _, _))) = (RefType::from_str(ref_type_str), parse_display_id(target)) {
let _ = register_ref(conn, dialogue_id, EntityType::Claim, &global_id, ref_type, target_type, target);
}
}
}
c_ids.push(json!({ "local_id": local_id, "global_id": global_id }));
}
registered["claims"] = json!(c_ids);
}
// Process tension updates
if let Some(updates) = args.get("tension_updates").and_then(|v| v.as_array()) {
for u in updates {
let tension_id = u.get("id").and_then(|v| v.as_str()).unwrap_or("");
let status_str = u.get("status").and_then(|v| v.as_str()).unwrap_or("open");
let actors: Vec<String> = u
.get("by")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
.or_else(|| u.get("by").and_then(|v| v.as_str()).map(|s| vec![s.to_string()]))
.unwrap_or_default();
let via = u.get("via").and_then(|v| v.as_str());
let status = alignment_db::TensionStatus::from_str(status_str);
let _ = update_tension_status(conn, dialogue_id, tension_id, status, &actors, via, round);
}
}
// Update expert scores
if let Some(expert_scores) = args.get("expert_scores").and_then(|v| v.as_object()) {
let mut updated_scores = Vec::new();
for (expert_slug, score_val) in expert_scores {
if let Some(expert_score) = score_val.as_i64() {
let _ = update_expert_score(conn, dialogue_id, expert_slug, round, expert_score as i32);
updated_scores.push(json!({ "expert_slug": expert_slug, "score": expert_score }));
}
}
registered["expert_scores"] = json!(updated_scores);
}
Ok(json!({
"status": "success",
"message": format!("Registered round {} data for dialogue '{}'", round, dialogue_id),
"dialogue_id": dialogue_id,
"round": round,
"score": score,
"registered": registered,
}))
}
/// Handle blue_dialogue_verdict_register (RFC 0051)
///
/// Register a verdict (interim, final, minority, or dissent).
pub fn handle_verdict_register(state: &ProjectState, args: &Value) -> Result<Value, ServerError> {
let dialogue_id = args
.get("dialogue_id")
.and_then(|v| v.as_str())
.ok_or(ServerError::InvalidParams)?;
let verdict_id = args
.get("verdict_id")
.and_then(|v| v.as_str())
.ok_or(ServerError::InvalidParams)?;
let verdict_type_str = args
.get("verdict_type")
.and_then(|v| v.as_str())
.ok_or(ServerError::InvalidParams)?;
let verdict_type = VerdictType::from_str(verdict_type_str);
let round = args
.get("round")
.and_then(|v| v.as_i64())
.ok_or(ServerError::InvalidParams)? as i32;
let recommendation = args
.get("recommendation")
.and_then(|v| v.as_str())
.ok_or(ServerError::InvalidParams)?;
let description = args
.get("description")
.and_then(|v| v.as_str())
.ok_or(ServerError::InvalidParams)?;
// Optional fields
let conditions: Option<Vec<String>> = args
.get("conditions")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect());
let vote = args.get("vote").and_then(|v| v.as_str()).map(String::from);
let confidence = args.get("confidence").and_then(|v| v.as_str()).map(String::from);
let author_expert = args.get("author_expert").and_then(|v| v.as_str()).map(String::from);
let tensions_resolved: Option<Vec<String>> = args
.get("tensions_resolved")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect());
let tensions_accepted: Option<Vec<String>> = args
.get("tensions_accepted")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect());
let recommendations_adopted: Option<Vec<String>> = args
.get("recommendations_adopted")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect());
let supporting_experts: Option<Vec<String>> = args
.get("supporting_experts")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect());
let conn = state.store.conn();
// Verify dialogue exists
let _dialogue = get_dialogue(conn, dialogue_id)
.map_err(|e| ServerError::CommandFailed(format!("Dialogue not found: {}", e)))?;
let verdict = Verdict {
dialogue_id: dialogue_id.to_string(),
verdict_id: verdict_id.to_string(),
verdict_type,
round,
author_expert,
recommendation: recommendation.to_string(),
description: description.to_string(),
conditions,
vote,
confidence,
tensions_resolved,
tensions_accepted,
recommendations_adopted,
key_evidence: None,
key_claims: None,
supporting_experts,
ethos_compliance: None,
created_at: chrono::Utc::now(),
};
register_verdict(conn, &verdict)
.map_err(|e| ServerError::CommandFailed(format!("Failed to register verdict: {}", e)))?;
Ok(json!({
"status": "success",
"message": format!("Registered {} verdict '{}' for dialogue '{}'", verdict_type.as_str(), verdict_id, dialogue_id),
"dialogue_id": dialogue_id,
"verdict_id": verdict_id,
"verdict_type": verdict_type.as_str(),
"round": round,
}))
}
/// Handle blue_dialogue_export (RFC 0051)
///
/// Export dialogue to JSON with full provenance from database.
pub fn handle_export(state: &ProjectState, args: &Value) -> Result<Value, ServerError> {
let dialogue_id = args
.get("dialogue_id")
.and_then(|v| v.as_str())
.ok_or(ServerError::InvalidParams)?;
let output_path = args.get("output_path").and_then(|v| v.as_str());
let conn = state.store.conn();
// Get dialogue
let dialogue = get_dialogue(conn, dialogue_id)
.map_err(|e| ServerError::CommandFailed(format!("Dialogue not found: {}", e)))?;
// Get all data
let experts = get_experts(conn, dialogue_id)
.map_err(|e| ServerError::CommandFailed(format!("Failed to get experts: {}", e)))?;
let perspectives = get_perspectives(conn, dialogue_id)
.map_err(|e| ServerError::CommandFailed(format!("Failed to get perspectives: {}", e)))?;
let tensions = get_tensions(conn, dialogue_id)
.map_err(|e| ServerError::CommandFailed(format!("Failed to get tensions: {}", e)))?;
let recommendations = get_recommendations(conn, dialogue_id)
.map_err(|e| ServerError::CommandFailed(format!("Failed to get recommendations: {}", e)))?;
let evidence = get_evidence(conn, dialogue_id)
.map_err(|e| ServerError::CommandFailed(format!("Failed to get evidence: {}", e)))?;
let claims = get_claims(conn, dialogue_id)
.map_err(|e| ServerError::CommandFailed(format!("Failed to get claims: {}", e)))?;
let verdicts = get_verdicts(conn, dialogue_id)
.map_err(|e| ServerError::CommandFailed(format!("Failed to get verdicts: {}", e)))?;
// Build export structure
let export_data = json!({
"dialogue": {
"dialogue_id": dialogue.dialogue_id,
"title": dialogue.title,
"question": dialogue.question,
"status": dialogue.status.as_str(),
"created_at": dialogue.created_at.to_rfc3339(),
"converged_at": dialogue.converged_at.map(|dt| dt.to_rfc3339()),
"total_rounds": dialogue.total_rounds,
"total_alignment": dialogue.total_alignment,
"output_dir": dialogue.output_dir,
"calibrated": dialogue.calibrated,
"background": dialogue.background,
},
"experts": experts.iter().map(|e| json!({
"expert_slug": e.expert_slug,
"role": e.role,
"description": e.description,
"focus": e.focus,
"tier": e.tier.as_str(),
"source": e.source.as_str(),
"relevance": e.relevance,
"creation_reason": e.creation_reason,
"scores": e.scores,
"total_score": e.total_score,
"first_round": e.first_round,
"created_at": e.created_at.to_rfc3339(),
})).collect::<Vec<_>>(),
"perspectives": perspectives.iter().map(|p| json!({
"id": display_id(EntityType::Perspective, p.round, p.seq),
"round": p.round,
"seq": p.seq,
"label": p.label,
"content": p.content,
"contributors": p.contributors,
"status": p.status.as_str(),
"refs": p.refs,
"created_at": p.created_at.to_rfc3339(),
})).collect::<Vec<_>>(),
"tensions": tensions.iter().map(|t| json!({
"id": display_id(EntityType::Tension, t.round, t.seq),
"round": t.round,
"seq": t.seq,
"label": t.label,
"description": t.description,
"contributors": t.contributors,
"status": t.status.as_str(),
"refs": t.refs,
"created_at": t.created_at.to_rfc3339(),
})).collect::<Vec<_>>(),
"recommendations": recommendations.iter().map(|r| json!({
"id": display_id(EntityType::Recommendation, r.round, r.seq),
"round": r.round,
"seq": r.seq,
"label": r.label,
"content": r.content,
"contributors": r.contributors,
"parameters": r.parameters,
"status": r.status.as_str(),
"refs": r.refs,
"adopted_in_verdict": r.adopted_in_verdict,
"created_at": r.created_at.to_rfc3339(),
})).collect::<Vec<_>>(),
"evidence": evidence.iter().map(|e| json!({
"id": display_id(EntityType::Evidence, e.round, e.seq),
"round": e.round,
"seq": e.seq,
"label": e.label,
"content": e.content,
"contributors": e.contributors,
"status": e.status.as_str(),
"refs": e.refs,
"created_at": e.created_at.to_rfc3339(),
})).collect::<Vec<_>>(),
"claims": claims.iter().map(|c| json!({
"id": display_id(EntityType::Claim, c.round, c.seq),
"round": c.round,
"seq": c.seq,
"label": c.label,
"content": c.content,
"contributors": c.contributors,
"status": c.status.as_str(),
"refs": c.refs,
"created_at": c.created_at.to_rfc3339(),
})).collect::<Vec<_>>(),
"verdicts": verdicts.iter().map(|v| json!({
"verdict_id": v.verdict_id,
"verdict_type": v.verdict_type.as_str(),
"round": v.round,
"author_expert": v.author_expert,
"recommendation": v.recommendation,
"description": v.description,
"conditions": v.conditions,
"vote": v.vote,
"confidence": v.confidence,
"tensions_resolved": v.tensions_resolved,
"tensions_accepted": v.tensions_accepted,
"recommendations_adopted": v.recommendations_adopted,
"key_evidence": v.key_evidence,
"key_claims": v.key_claims,
"supporting_experts": v.supporting_experts,
"created_at": v.created_at.to_rfc3339(),
})).collect::<Vec<_>>(),
"exported_at": chrono::Utc::now().to_rfc3339(),
});
// Determine output path
let final_path = match output_path {
Some(p) => p.to_string(),
None => {
let dir = dialogue.output_dir.as_deref().unwrap_or("/tmp/blue-dialogue");
format!("{}/{}/dialogue.json", dir, dialogue_id)
}
};
// Ensure parent directory exists
if let Some(parent) = Path::new(&final_path).parent() {
fs::create_dir_all(parent).map_err(|e| {
ServerError::CommandFailed(format!("Failed to create output directory: {}", e))
})?;
}
// Write JSON file
let json_str = serde_json::to_string_pretty(&export_data).map_err(|e| {
ServerError::CommandFailed(format!("Failed to serialize export data: {}", e))
})?;
fs::write(&final_path, &json_str).map_err(|e| {
ServerError::CommandFailed(format!("Failed to write export file: {}", e))
})?;
Ok(json!({
"status": "success",
"message": format!("Exported dialogue '{}' to {}", dialogue_id, final_path),
"dialogue_id": dialogue_id,
"output_path": final_path,
"stats": {
"experts": experts.len(),
"perspectives": perspectives.len(),
"tensions": tensions.len(),
"recommendations": recommendations.len(),
"evidence": evidence.len(),
"claims": claims.len(),
"verdicts": verdicts.len(),
}
}))
}
#[cfg(test)]
mod tests {
use super::*;

View file

@ -1792,6 +1792,209 @@ impl BlueServer {
"required": ["output_dir", "round", "panel"]
}
},
// RFC 0051: Global perspective & tension tracking
{
"name": "blue_dialogue_round_context",
"description": "RFC 0051: Bulk fetch context for all panel experts in a single call. Returns structured data for prompt building.",
"inputSchema": {
"type": "object",
"properties": {
"dialogue_id": {
"type": "string",
"description": "Dialogue ID (e.g., 'nvidia-investment-analysis')"
},
"round": {
"type": "integer",
"description": "Round number to get context for"
}
},
"required": ["dialogue_id", "round"]
}
},
{
"name": "blue_dialogue_expert_create",
"description": "RFC 0051: Create a new expert mid-dialogue to address emerging needs. The expert will be registered with source='created'.",
"inputSchema": {
"type": "object",
"properties": {
"dialogue_id": {
"type": "string",
"description": "Dialogue ID"
},
"expert_slug": {
"type": "string",
"description": "Expert slug (lowercase, e.g., 'palmier')"
},
"role": {
"type": "string",
"description": "Expert role (e.g., 'Geopolitical Risk Analyst')"
},
"tier": {
"type": "string",
"enum": ["Core", "Adjacent", "Wildcard"],
"description": "Expert tier"
},
"focus": {
"type": "string",
"description": "Focus area for this expert"
},
"creation_reason": {
"type": "string",
"description": "Why the Judge created this expert"
},
"first_round": {
"type": "integer",
"description": "Round when expert joins"
}
},
"required": ["dialogue_id", "expert_slug", "role", "tier", "creation_reason", "first_round"]
}
},
{
"name": "blue_dialogue_round_register",
"description": "RFC 0051: Bulk register all round data in a single atomic call - perspectives, recommendations, tensions, evidence, claims, scores, and refs.",
"inputSchema": {
"type": "object",
"properties": {
"dialogue_id": {
"type": "string",
"description": "Dialogue ID"
},
"round": {
"type": "integer",
"description": "Round number"
},
"score": {
"type": "integer",
"description": "ALIGNMENT score for this round"
},
"summary": {
"type": "string",
"description": "Judge's synthesis of the round"
},
"expert_scores": {
"type": "object",
"description": "Map of expert_slug to score (e.g., {\"muffin\": 12, \"donut\": 15})"
},
"perspectives": {
"type": "array",
"description": "Array of perspective objects with local_id, label, content, contributors, references"
},
"recommendations": {
"type": "array",
"description": "Array of recommendation objects with local_id, label, content, contributors, parameters, references"
},
"tensions": {
"type": "array",
"description": "Array of tension objects with local_id, label, description, contributors, references"
},
"evidence": {
"type": "array",
"description": "Array of evidence objects with local_id, label, content, contributors, references"
},
"claims": {
"type": "array",
"description": "Array of claim objects with local_id, label, content, contributors, references"
},
"tension_updates": {
"type": "array",
"description": "Array of tension status updates: {id, status, by, via}"
}
},
"required": ["dialogue_id", "round", "score"]
}
},
{
"name": "blue_dialogue_verdict_register",
"description": "RFC 0051: Register a verdict (interim, final, minority, or dissent).",
"inputSchema": {
"type": "object",
"properties": {
"dialogue_id": {
"type": "string",
"description": "Dialogue ID"
},
"verdict_id": {
"type": "string",
"description": "Verdict identifier (e.g., 'final', 'V01', 'minority-esg')"
},
"verdict_type": {
"type": "string",
"enum": ["interim", "final", "minority", "dissent"],
"description": "Type of verdict"
},
"round": {
"type": "integer",
"description": "Round when verdict was issued"
},
"recommendation": {
"type": "string",
"description": "One-line decision (e.g., 'APPROVE conditional partial trim')"
},
"description": {
"type": "string",
"description": "Reasoning summary (2-3 sentences)"
},
"conditions": {
"type": "array",
"items": { "type": "string" },
"description": "Conditions for the verdict"
},
"vote": {
"type": "string",
"description": "Vote count (e.g., '12-0', '11-1')"
},
"confidence": {
"type": "string",
"enum": ["unanimous", "strong", "split", "contested"],
"description": "Confidence level"
},
"tensions_resolved": {
"type": "array",
"items": { "type": "string" },
"description": "Tension IDs resolved by this verdict"
},
"tensions_accepted": {
"type": "array",
"items": { "type": "string" },
"description": "Tension IDs accepted (acknowledged but not blocking)"
},
"recommendations_adopted": {
"type": "array",
"items": { "type": "string" },
"description": "Recommendation IDs adopted"
},
"author_expert": {
"type": "string",
"description": "Expert slug if authored by expert (null = Judge)"
},
"supporting_experts": {
"type": "array",
"items": { "type": "string" },
"description": "Expert slugs supporting minority verdict"
}
},
"required": ["dialogue_id", "verdict_id", "verdict_type", "round", "recommendation", "description"]
}
},
{
"name": "blue_dialogue_export",
"description": "RFC 0051: Export dialogue to JSON with full provenance from database.",
"inputSchema": {
"type": "object",
"properties": {
"dialogue_id": {
"type": "string",
"description": "Dialogue ID to export"
},
"output_path": {
"type": "string",
"description": "Path to write JSON file (optional, defaults to output_dir/dialogue.json)"
}
},
"required": ["dialogue_id"]
}
},
// Phase 8: Playwright verification
{
"name": "blue_playwright_verify",
@ -2572,6 +2775,12 @@ impl BlueServer {
"blue_dialogue_round_prompt" => self.handle_dialogue_round_prompt(&call.arguments),
"blue_dialogue_sample_panel" => self.handle_dialogue_sample_panel(&call.arguments),
"blue_dialogue_evolve_panel" => self.handle_dialogue_evolve_panel(&call.arguments),
// RFC 0051: Global perspective & tension tracking
"blue_dialogue_round_context" => self.handle_dialogue_round_context(&call.arguments),
"blue_dialogue_expert_create" => self.handle_dialogue_expert_create(&call.arguments),
"blue_dialogue_round_register" => self.handle_dialogue_round_register(&call.arguments),
"blue_dialogue_verdict_register" => self.handle_dialogue_verdict_register(&call.arguments),
"blue_dialogue_export" => self.handle_dialogue_export(&call.arguments),
// Phase 8: Playwright handler
"blue_playwright_verify" => self.handle_playwright_verify(&call.arguments),
// Phase 9: Post-mortem handlers
@ -3914,6 +4123,38 @@ impl BlueServer {
crate::handlers::dialogue::handle_evolve_panel(args)
}
// RFC 0051: Global perspective & tension tracking handlers
fn handle_dialogue_round_context(&mut self, args: &Option<Value>) -> Result<Value, ServerError> {
let args = args.as_ref().ok_or(ServerError::InvalidParams)?;
let state = self.ensure_state()?;
crate::handlers::dialogue::handle_round_context(state, args)
}
fn handle_dialogue_expert_create(&mut self, args: &Option<Value>) -> Result<Value, ServerError> {
let args = args.as_ref().ok_or(ServerError::InvalidParams)?;
let state = self.ensure_state()?;
crate::handlers::dialogue::handle_expert_create(state, args)
}
fn handle_dialogue_round_register(&mut self, args: &Option<Value>) -> Result<Value, ServerError> {
let args = args.as_ref().ok_or(ServerError::InvalidParams)?;
let state = self.ensure_state()?;
crate::handlers::dialogue::handle_round_register(state, args)
}
fn handle_dialogue_verdict_register(&mut self, args: &Option<Value>) -> Result<Value, ServerError> {
let args = args.as_ref().ok_or(ServerError::InvalidParams)?;
let state = self.ensure_state()?;
crate::handlers::dialogue::handle_verdict_register(state, args)
}
fn handle_dialogue_export(&mut self, args: &Option<Value>) -> Result<Value, ServerError> {
let args = args.as_ref().ok_or(ServerError::InvalidParams)?;
let state = self.ensure_state()?;
crate::handlers::dialogue::handle_export(state, args)
}
fn handle_playwright_verify(&mut self, args: &Option<Value>) -> Result<Value, ServerError> {
let args = args.as_ref().ok_or(ServerError::InvalidParams)?;
crate::handlers::playwright::handle_verify(args)

View file

@ -216,6 +216,8 @@ For a pool of P experts with panel size N:
## Blue MCP Tools
### Core Tools (File-based)
| Tool | Purpose |
|------|---------|
| `blue_dialogue_create` | Creates dialogue with expert_pool, returns Judge Protocol |
@ -225,6 +227,78 @@ For a pool of P experts with panel size N:
| `blue_dialogue_lint` | Validate .dialogue.md format |
| `blue_dialogue_save` | Persist to .blue/docs/dialogues/ |
### DB-Backed Tools (RFC 0051)
These tools provide database-backed tracking with full provenance:
| Tool | Purpose |
|------|---------|
| `blue_dialogue_round_context` | **Bulk fetch** context for all panel experts (perspectives, tensions, recommendations) |
| `blue_dialogue_expert_create` | Create new expert mid-dialogue with `source: "created"` |
| `blue_dialogue_round_register` | **Bulk register** all round data (perspectives, tensions, refs, scores) |
| `blue_dialogue_verdict_register` | Register verdicts (interim, final, minority, dissent) |
| `blue_dialogue_export` | Export dialogue to JSON with full provenance |
### Two-Phase ID System (RFC 0051)
Experts write **local IDs** using the marker syntax from the `alignment-expert` skill:
- `MUFFIN-P0101` — Muffin's first perspective in round 1
- `DONUT-T0201` — Donut's first tension in round 2
The Judge translates to **global IDs** when calling `blue_dialogue_round_register`:
- `P0101` — First perspective registered in round 1
- `T0201` — First tension registered in round 2
### DB-Backed Workflow (Recommended)
1. **Round Context**: Call `blue_dialogue_round_context(dialogue_id, round)` to get:
- Dialogue metadata and background
- All experts with roles and scores
- All perspectives, tensions, recommendations, evidence, claims
- List of open tensions
2. **Build Prompts**: Use context to construct prompts with the `alignment-expert` skill syntax
3. **Spawn Agents**: Use Task tool with `subagent_type: "general-purpose"`
4. **Parse Responses**: Extract markers from agent responses:
- `[EXPERT-P0101: label]` → Perspective
- `[EXPERT-T0101: label]` → Tension
- `[RE:SUPPORT P0001]` → Reference
5. **Register Round**: Call `blue_dialogue_round_register` with:
```json
{
"dialogue_id": "nvidia-investment-analysis",
"round": 1,
"score": 45,
"summary": "Round focused on income generation options",
"perspectives": [
{ "local_id": "MUFFIN-P0101", "label": "Options viability", "content": "...", "contributors": ["muffin"] }
],
"tensions": [...],
"recommendations": [...],
"expert_scores": { "muffin": 12, "donut": 15 }
}
```
6. **Register Verdict**: When converged, call `blue_dialogue_verdict_register`:
```json
{
"dialogue_id": "nvidia-investment-analysis",
"verdict_id": "final",
"verdict_type": "final",
"round": 3,
"recommendation": "APPROVE with options overlay",
"description": "Income mandate satisfied via covered call strategy",
"tensions_resolved": ["T0001", "T0101"],
"vote": "12-0",
"confidence": "strong"
}
```
7. **Export**: Call `blue_dialogue_export(dialogue_id)` to generate `dialogue.json`
## Agent Spawning
When spawning expert agents, you MUST use the Task tool with:
@ -243,6 +317,34 @@ Task(
The `general-purpose` subagent has access to all tools including Write, which is required for writing the response file.
## Expert Marker Syntax
Experts write structured responses using the marker syntax defined in the `alignment-expert` skill:
### Entity Markers
- `[EXPERT-P0101: label]` — Perspective
- `[EXPERT-R0101: label]` — Recommendation
- `[EXPERT-T0101: label]` — Tension
- `[EXPERT-E0101: label]` — Evidence
- `[EXPERT-C0101: label]` — Claim
### Cross-References
- `[RE:SUPPORT P0001]` — Backs a perspective
- `[RE:OPPOSE R0001]` — Challenges a recommendation
- `[RE:ADDRESS T0001]` — Speaks to a tension
- `[RE:RESOLVE T0001]` — Claims to resolve a tension
- `[RE:REFINE P0001]` — Builds on a perspective (same type)
- `[RE:DEPEND E0001]` — Relies on evidence
### Dialogue Moves
- `[MOVE:DEFEND target]` — Strengthening a position
- `[MOVE:CHALLENGE target]` — Raising concerns
- `[MOVE:BRIDGE targets]` — Reconciling perspectives
- `[MOVE:CONCEDE target]` — Acknowledging another's point
- `[MOVE:CONVERGE]` — Signaling agreement
See the `alignment-expert` skill (`/alignment-expert`) for full syntax reference.
## Key Rules
1. **DESIGN THE POOL FIRST** — You are the 💙 Judge. Analyze the problem domain and design appropriate experts.