Skip to main content

batty_cli/team/
openclaw_contract.rs

1//! Stable Batty <-> OpenClaw DTOs and capability negotiation.
2//!
3//! This module is the anti-corruption boundary between Batty's internal
4//! supervision model and the external OpenClaw-facing contract. Batty remains
5//! authoritative for prompts, workflow policy, and operator actions; this
6//! module exports only versioned DTOs, enums, and counters.
7
8use serde::{Deserialize, Serialize};
9
10use super::{events, status};
11
12#[cfg(test)]
13use crate::project_registry::RegisteredProject;
14
15pub const CONTRACT_SCHEMA_VERSION: u32 = 1;
16pub const MIN_SUPPORTED_SCHEMA_VERSION: u32 = 1;
17pub const CONTRACT_DESCRIPTOR_KIND: &str = "batty.openclaw.contractDescriptor";
18pub const TEAM_STATUS_KIND: &str = "batty.openclaw.teamStatus";
19pub const TEAM_EVENT_KIND: &str = "batty.openclaw.teamEvent";
20pub const TEAM_COMMAND_KIND: &str = "batty.openclaw.teamCommand";
21pub const CAPABILITY_NEGOTIATION_KIND: &str = "batty.openclaw.capabilityNegotiation";
22
23const SUPPORTED_CAPABILITIES: &[OpenClawCapability] = &[
24    OpenClawCapability::TeamStatus,
25    OpenClawCapability::TeamEvents,
26    OpenClawCapability::TeamCommands,
27    OpenClawCapability::EscalationSurface,
28    OpenClawCapability::ApprovalSurface,
29    OpenClawCapability::CapabilityNegotiation,
30];
31
32const SUPPORTED_EVENT_KINDS: &[TeamEventKind] = &[
33    TeamEventKind::TaskCompleted,
34    TeamEventKind::ReviewNudged,
35    TeamEventKind::ReviewEscalated,
36    TeamEventKind::ReviewStalled,
37    TeamEventKind::AgentStalled,
38    TeamEventKind::TaskStalled,
39    TeamEventKind::TaskMergedAutomatic,
40    TeamEventKind::TaskMergedManual,
41    TeamEventKind::TaskEscalated,
42    TeamEventKind::VerificationEscalated,
43    TeamEventKind::DeliveryFailed,
44    TeamEventKind::SessionStarted,
45    TeamEventKind::SessionReloading,
46    TeamEventKind::SessionReloaded,
47    TeamEventKind::SessionStopped,
48    TeamEventKind::AgentStarted,
49    TeamEventKind::AgentRestarted,
50    TeamEventKind::AgentCrashed,
51    TeamEventKind::AgentStopped,
52    TeamEventKind::AgentRespawned,
53    TeamEventKind::AgentContextExhausted,
54    TeamEventKind::AgentHealthChanged,
55    TeamEventKind::SessionTopologyChanged,
56    TeamEventKind::AgentRemoved,
57];
58
59const HUMAN_ONLY_DECISIONS: &[HumanDecisionKind] = &[
60    HumanDecisionKind::StopSession,
61    HumanDecisionKind::RestartSession,
62    HumanDecisionKind::ReviewDisposition,
63    HumanDecisionKind::MergeDisposition,
64    HumanDecisionKind::PolicyOverride,
65];
66
67const ESCALATION_KINDS: &[EscalationKind] = &[
68    EscalationKind::SessionUnavailable,
69    EscalationKind::MemberUnhealthy,
70    EscalationKind::ReviewQueueBlocked,
71    EscalationKind::TaskBlocked,
72    EscalationKind::HumanApprovalRequired,
73];
74
75#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
76#[serde(rename_all = "snake_case")]
77pub enum OpenClawCapability {
78    TeamStatus,
79    TeamEvents,
80    TeamCommands,
81    EscalationSurface,
82    ApprovalSurface,
83    CapabilityNegotiation,
84}
85
86#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
87#[serde(rename_all = "snake_case")]
88pub enum TeamLifecycle {
89    Running,
90    Stopped,
91    Degraded,
92    Recovering,
93}
94
95#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
96#[serde(rename_all = "snake_case")]
97pub enum MemberState {
98    Starting,
99    Idle,
100    Working,
101    Done,
102    Crashed,
103    Unknown,
104}
105
106#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
107#[serde(rename_all = "snake_case")]
108pub enum MemberHealth {
109    Healthy,
110    Warning,
111    Unhealthy,
112}
113
114#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
115#[serde(rename_all = "snake_case")]
116pub enum BackendHealth {
117    Healthy,
118    Degraded,
119    Unreachable,
120}
121
122#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
123#[serde(rename_all = "snake_case")]
124pub enum TeamEventTopic {
125    Completion,
126    Review,
127    Stall,
128    Merge,
129    Escalation,
130    DeliveryFailure,
131    Lifecycle,
132}
133
134#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
135#[serde(rename_all = "snake_case")]
136pub enum TeamEventKind {
137    TaskCompleted,
138    ReviewNudged,
139    ReviewEscalated,
140    ReviewStalled,
141    AgentStalled,
142    TaskStalled,
143    TaskMergedAutomatic,
144    TaskMergedManual,
145    TaskEscalated,
146    VerificationEscalated,
147    DeliveryFailed,
148    SessionStarted,
149    SessionReloading,
150    SessionReloaded,
151    SessionStopped,
152    AgentStarted,
153    AgentRestarted,
154    AgentCrashed,
155    AgentStopped,
156    AgentRespawned,
157    AgentContextExhausted,
158    AgentHealthChanged,
159    SessionTopologyChanged,
160    AgentRemoved,
161}
162
163impl TeamEventKind {
164    pub(crate) const fn legacy_event_type(self) -> &'static str {
165        match self {
166            Self::TaskCompleted => "task.completed",
167            Self::ReviewNudged => "review.nudged",
168            Self::ReviewEscalated => "review.escalated",
169            Self::ReviewStalled => "review.stalled",
170            Self::AgentStalled => "agent.stalled",
171            Self::TaskStalled => "task.stalled",
172            Self::TaskMergedAutomatic => "task.merged.automatic",
173            Self::TaskMergedManual => "task.merged.manual",
174            Self::TaskEscalated => "task.escalated",
175            Self::VerificationEscalated => "verification.escalated",
176            Self::DeliveryFailed => "delivery.failed",
177            Self::SessionStarted => "session.started",
178            Self::SessionReloading => "session.reloading",
179            Self::SessionReloaded => "session.reloaded",
180            Self::SessionStopped => "session.stopped",
181            Self::AgentStarted => "agent.started",
182            Self::AgentRestarted => "agent.restarted",
183            Self::AgentCrashed => "agent.crashed",
184            Self::AgentStopped => "agent.stopped",
185            Self::AgentRespawned => "agent.respawned",
186            Self::AgentContextExhausted => "agent.context_exhausted",
187            Self::AgentHealthChanged => "agent.health_changed",
188            Self::SessionTopologyChanged => "session.topology_changed",
189            Self::AgentRemoved => "agent.removed",
190        }
191    }
192}
193
194#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
195#[serde(rename_all = "snake_case")]
196pub enum TeamCommandKind {
197    Start,
198    Stop,
199    Restart,
200    Send,
201    Nudge,
202    Review,
203    Merge,
204}
205
206#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
207#[serde(rename_all = "snake_case")]
208pub enum CommandScope {
209    Team,
210    Member,
211    Task,
212}
213
214#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
215#[serde(rename_all = "snake_case")]
216pub enum ApprovalLevel {
217    NotRequired,
218    Suggested,
219    Required,
220}
221
222#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
223#[serde(rename_all = "snake_case")]
224pub enum HumanDecisionKind {
225    StopSession,
226    RestartSession,
227    ReviewDisposition,
228    MergeDisposition,
229    PolicyOverride,
230}
231
232#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
233#[serde(rename_all = "snake_case")]
234pub enum EscalationAuthority {
235    RecommendOnly,
236    HumanApproved,
237}
238
239#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
240#[serde(rename_all = "snake_case")]
241pub enum EscalationKind {
242    SessionUnavailable,
243    MemberUnhealthy,
244    ReviewQueueBlocked,
245    TaskBlocked,
246    HumanApprovalRequired,
247}
248
249#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
250#[serde(rename_all = "snake_case")]
251pub enum ReviewDisposition {
252    Request,
253    Approve,
254    Rework,
255}
256
257#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
258#[serde(rename_all = "snake_case")]
259pub enum MergeStrategy {
260    FastForward,
261    Squash,
262    Rebase,
263    Manual,
264}
265
266#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
267#[serde(rename_all = "camelCase")]
268pub struct DtoKinds {
269    pub team_status: String,
270    pub team_event: String,
271    pub team_command: String,
272}
273
274#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
275#[serde(rename_all = "camelCase")]
276pub struct VersioningPolicy {
277    pub current_schema_version: u32,
278    pub min_supported_schema_version: u32,
279    pub add_only_fields: bool,
280    pub new_enum_variants_require_capability_review: bool,
281    pub incompatible_changes_require_new_schema_version: bool,
282}
283
284#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
285#[serde(rename_all = "camelCase")]
286pub struct AntiCorruptionBoundary {
287    pub batty_is_system_of_record: bool,
288    pub prompt_wording_leaks_forbidden: bool,
289    pub command_intents_are_explicit: bool,
290    pub status_inputs: Vec<String>,
291    pub event_inputs: Vec<String>,
292}
293
294#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
295#[serde(rename_all = "camelCase")]
296pub struct CommandPolicy {
297    pub command: TeamCommandKind,
298    pub scope: CommandScope,
299    pub approval_level: ApprovalLevel,
300    #[serde(default)]
301    pub human_only_decisions: Vec<HumanDecisionKind>,
302}
303
304#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
305#[serde(rename_all = "camelCase")]
306pub struct EscalationSurface {
307    pub authority: EscalationAuthority,
308    #[serde(default)]
309    pub supported_kinds: Vec<EscalationKind>,
310}
311
312#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
313#[serde(rename_all = "camelCase")]
314pub struct ApprovalSurface {
315    #[serde(default)]
316    pub command_policies: Vec<CommandPolicy>,
317    #[serde(default)]
318    pub human_only_decisions: Vec<HumanDecisionKind>,
319}
320
321#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
322#[serde(rename_all = "camelCase")]
323pub struct ContractDescriptor {
324    pub kind: String,
325    pub schema_version: u32,
326    pub min_supported_schema_version: u32,
327    pub dto_kinds: DtoKinds,
328    pub versioning: VersioningPolicy,
329    pub anti_corruption_boundary: AntiCorruptionBoundary,
330    #[serde(default)]
331    pub capabilities: Vec<OpenClawCapability>,
332    #[serde(default)]
333    pub supported_event_kinds: Vec<TeamEventKind>,
334    pub escalation_surface: EscalationSurface,
335    pub approval_surface: ApprovalSurface,
336}
337
338#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
339#[serde(rename_all = "camelCase")]
340pub struct CapabilityNegotiationRequest {
341    pub kind: String,
342    pub requested_schema_version: u32,
343    pub min_compatible_schema_version: u32,
344    #[serde(default)]
345    pub requested_capabilities: Vec<OpenClawCapability>,
346}
347
348#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
349#[serde(rename_all = "camelCase")]
350pub struct CapabilityNegotiationResult {
351    pub kind: String,
352    pub schema_version: u32,
353    pub min_supported_schema_version: u32,
354    pub compatible: bool,
355    #[serde(skip_serializing_if = "Option::is_none")]
356    pub negotiated_schema_version: Option<u32>,
357    #[serde(default)]
358    pub granted_capabilities: Vec<OpenClawCapability>,
359    pub reason: String,
360}
361
362#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
363#[serde(rename_all = "camelCase")]
364pub struct MemberStatus {
365    pub name: String,
366    pub role: String,
367    pub role_type: String,
368    pub state: MemberState,
369    pub health: MemberHealth,
370    #[serde(default)]
371    pub active_task_ids: Vec<String>,
372    #[serde(default)]
373    pub review_task_ids: Vec<String>,
374    pub pending_inbox_count: usize,
375    pub triage_backlog_count: usize,
376    #[serde(skip_serializing_if = "Option::is_none")]
377    pub signal: Option<String>,
378    pub restart_count: u32,
379    pub context_exhaustion_count: u32,
380    pub delivery_failure_count: u32,
381    pub supervisory_digest_count: u32,
382    #[serde(skip_serializing_if = "Option::is_none")]
383    pub stall_reason: Option<String>,
384    #[serde(skip_serializing_if = "Option::is_none")]
385    pub stall_summary: Option<String>,
386    #[serde(skip_serializing_if = "Option::is_none")]
387    pub task_elapsed_secs: Option<u64>,
388    pub backend_health: BackendHealth,
389}
390
391#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
392#[serde(rename_all = "camelCase")]
393pub struct PipelineMetrics {
394    pub active_task_count: usize,
395    pub review_queue_count: usize,
396    pub runnable_count: u32,
397    pub blocked_count: u32,
398    pub in_review_count: u32,
399    pub in_progress_count: u32,
400    pub stale_in_progress_count: u32,
401    pub stale_review_count: u32,
402    pub triage_backlog_count: usize,
403    pub unhealthy_member_count: usize,
404    #[serde(skip_serializing_if = "Option::is_none")]
405    pub auto_merge_rate: Option<f64>,
406    #[serde(skip_serializing_if = "Option::is_none")]
407    pub rework_rate: Option<f64>,
408    #[serde(skip_serializing_if = "Option::is_none")]
409    pub avg_review_latency_secs: Option<f64>,
410}
411
412#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
413#[serde(rename_all = "camelCase")]
414pub struct TeamStatus {
415    pub kind: String,
416    pub schema_version: u32,
417    pub min_supported_schema_version: u32,
418    pub team_name: String,
419    pub session_name: String,
420    pub lifecycle: TeamLifecycle,
421    pub running: bool,
422    pub paused: bool,
423    #[serde(default)]
424    pub members: Vec<MemberStatus>,
425    pub pipeline: PipelineMetrics,
426    pub escalation_surface: EscalationSurface,
427    pub approval_surface: ApprovalSurface,
428    #[serde(default)]
429    pub capabilities: Vec<OpenClawCapability>,
430}
431
432#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
433#[serde(rename_all = "camelCase")]
434pub struct TeamEvent {
435    pub topic: TeamEventTopic,
436    pub event_kind: TeamEventKind,
437    pub ts: u64,
438    #[serde(skip_serializing_if = "Option::is_none")]
439    pub member_name: Option<String>,
440    #[serde(skip_serializing_if = "Option::is_none")]
441    pub task_id: Option<String>,
442    #[serde(skip_serializing_if = "Option::is_none")]
443    pub sender: Option<String>,
444    #[serde(skip_serializing_if = "Option::is_none")]
445    pub recipient: Option<String>,
446    #[serde(skip_serializing_if = "Option::is_none")]
447    pub reason: Option<String>,
448    #[serde(skip_serializing_if = "Option::is_none")]
449    pub detail: Option<String>,
450    #[serde(skip_serializing_if = "Option::is_none")]
451    pub action_type: Option<String>,
452    #[serde(skip_serializing_if = "Option::is_none")]
453    pub success: Option<bool>,
454    #[serde(skip_serializing_if = "Option::is_none")]
455    pub restart_count: Option<u32>,
456    #[serde(skip_serializing_if = "Option::is_none")]
457    pub load: Option<f64>,
458    #[serde(skip_serializing_if = "Option::is_none")]
459    pub uptime_secs: Option<u64>,
460    #[serde(skip_serializing_if = "Option::is_none")]
461    pub session_running: Option<bool>,
462}
463
464#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
465#[serde(rename_all = "camelCase")]
466pub struct ProjectEventEnvelope {
467    pub kind: String,
468    pub schema_version: u32,
469    pub min_supported_schema_version: u32,
470    pub project_id: String,
471    pub project_name: String,
472    pub project_root: String,
473    pub team_name: String,
474    pub session_name: String,
475    pub event: TeamEvent,
476}
477
478#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
479#[serde(rename_all = "camelCase")]
480pub struct CommandActor {
481    pub source: String,
482    #[serde(skip_serializing_if = "Option::is_none")]
483    pub source_role: Option<String>,
484}
485
486#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
487#[serde(rename_all = "camelCase")]
488pub struct ApprovalRequirement {
489    pub level: ApprovalLevel,
490    #[serde(default)]
491    pub human_only_decisions: Vec<HumanDecisionKind>,
492}
493
494#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
495#[serde(rename_all = "camelCase")]
496pub struct LifecycleCommand {
497    pub scope: CommandScope,
498    #[serde(skip_serializing_if = "Option::is_none")]
499    pub member_name: Option<String>,
500}
501
502#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
503#[serde(rename_all = "camelCase")]
504pub struct RestartCommand {
505    pub scope: CommandScope,
506    #[serde(skip_serializing_if = "Option::is_none")]
507    pub member_name: Option<String>,
508    #[serde(skip_serializing_if = "Option::is_none")]
509    pub reason_code: Option<String>,
510}
511
512#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
513#[serde(rename_all = "camelCase")]
514pub struct SendCommand {
515    pub from_role: String,
516    pub to_role: String,
517    pub message: String,
518}
519
520#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
521#[serde(rename_all = "camelCase")]
522pub struct NudgeCommand {
523    pub member_name: String,
524    pub reason_code: String,
525    #[serde(skip_serializing_if = "Option::is_none")]
526    pub summary: Option<String>,
527}
528
529#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
530#[serde(rename_all = "camelCase")]
531pub struct ReviewCommand {
532    pub task_id: String,
533    pub disposition: ReviewDisposition,
534    #[serde(skip_serializing_if = "Option::is_none")]
535    pub reviewer_role: Option<String>,
536}
537
538#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
539#[serde(rename_all = "camelCase")]
540pub struct MergeCommand {
541    pub task_id: String,
542    pub strategy: MergeStrategy,
543}
544
545#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
546#[serde(tag = "action", rename_all = "snake_case")]
547pub enum TeamCommandAction {
548    Start(LifecycleCommand),
549    Stop(LifecycleCommand),
550    Restart(RestartCommand),
551    Send(SendCommand),
552    Nudge(NudgeCommand),
553    Review(ReviewCommand),
554    Merge(MergeCommand),
555}
556
557#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
558#[serde(rename_all = "camelCase")]
559pub struct TeamCommand {
560    pub kind: String,
561    pub schema_version: u32,
562    pub min_supported_schema_version: u32,
563    pub project_id: String,
564    pub actor: CommandActor,
565    pub approval: ApprovalRequirement,
566    #[serde(flatten)]
567    pub action: TeamCommandAction,
568}
569
570impl TeamCommand {
571    pub fn start(project_id: impl Into<String>, source: impl Into<String>) -> Self {
572        Self::lifecycle_command(
573            project_id,
574            source,
575            TeamCommandAction::Start(LifecycleCommand {
576                scope: CommandScope::Team,
577                member_name: None,
578            }),
579        )
580    }
581
582    pub fn stop(project_id: impl Into<String>, source: impl Into<String>) -> Self {
583        Self::lifecycle_command(
584            project_id,
585            source,
586            TeamCommandAction::Stop(LifecycleCommand {
587                scope: CommandScope::Team,
588                member_name: None,
589            }),
590        )
591    }
592
593    pub fn restart(
594        project_id: impl Into<String>,
595        source: impl Into<String>,
596        member_name: Option<String>,
597        reason_code: Option<String>,
598    ) -> Self {
599        Self {
600            kind: TEAM_COMMAND_KIND.to_string(),
601            schema_version: CONTRACT_SCHEMA_VERSION,
602            min_supported_schema_version: MIN_SUPPORTED_SCHEMA_VERSION,
603            project_id: project_id.into(),
604            actor: CommandActor {
605                source: source.into(),
606                source_role: None,
607            },
608            approval: approval_for_command(TeamCommandKind::Restart),
609            action: TeamCommandAction::Restart(RestartCommand {
610                scope: if member_name.is_some() {
611                    CommandScope::Member
612                } else {
613                    CommandScope::Team
614                },
615                member_name,
616                reason_code,
617            }),
618        }
619    }
620
621    pub fn send(
622        project_id: impl Into<String>,
623        source: impl Into<String>,
624        from_role: impl Into<String>,
625        to_role: impl Into<String>,
626        message: impl Into<String>,
627    ) -> Self {
628        Self {
629            kind: TEAM_COMMAND_KIND.to_string(),
630            schema_version: CONTRACT_SCHEMA_VERSION,
631            min_supported_schema_version: MIN_SUPPORTED_SCHEMA_VERSION,
632            project_id: project_id.into(),
633            actor: CommandActor {
634                source: source.into(),
635                source_role: None,
636            },
637            approval: approval_for_command(TeamCommandKind::Send),
638            action: TeamCommandAction::Send(SendCommand {
639                from_role: from_role.into(),
640                to_role: to_role.into(),
641                message: message.into(),
642            }),
643        }
644    }
645
646    pub fn nudge(
647        project_id: impl Into<String>,
648        source: impl Into<String>,
649        member_name: impl Into<String>,
650        reason_code: impl Into<String>,
651        summary: Option<String>,
652    ) -> Self {
653        Self {
654            kind: TEAM_COMMAND_KIND.to_string(),
655            schema_version: CONTRACT_SCHEMA_VERSION,
656            min_supported_schema_version: MIN_SUPPORTED_SCHEMA_VERSION,
657            project_id: project_id.into(),
658            actor: CommandActor {
659                source: source.into(),
660                source_role: None,
661            },
662            approval: approval_for_command(TeamCommandKind::Nudge),
663            action: TeamCommandAction::Nudge(NudgeCommand {
664                member_name: member_name.into(),
665                reason_code: reason_code.into(),
666                summary,
667            }),
668        }
669    }
670
671    pub fn review(
672        project_id: impl Into<String>,
673        source: impl Into<String>,
674        task_id: impl Into<String>,
675        disposition: ReviewDisposition,
676        reviewer_role: Option<String>,
677    ) -> Self {
678        Self {
679            kind: TEAM_COMMAND_KIND.to_string(),
680            schema_version: CONTRACT_SCHEMA_VERSION,
681            min_supported_schema_version: MIN_SUPPORTED_SCHEMA_VERSION,
682            project_id: project_id.into(),
683            actor: CommandActor {
684                source: source.into(),
685                source_role: None,
686            },
687            approval: approval_for_command(TeamCommandKind::Review),
688            action: TeamCommandAction::Review(ReviewCommand {
689                task_id: task_id.into(),
690                disposition,
691                reviewer_role,
692            }),
693        }
694    }
695
696    pub fn merge(
697        project_id: impl Into<String>,
698        source: impl Into<String>,
699        task_id: impl Into<String>,
700        strategy: MergeStrategy,
701    ) -> Self {
702        Self {
703            kind: TEAM_COMMAND_KIND.to_string(),
704            schema_version: CONTRACT_SCHEMA_VERSION,
705            min_supported_schema_version: MIN_SUPPORTED_SCHEMA_VERSION,
706            project_id: project_id.into(),
707            actor: CommandActor {
708                source: source.into(),
709                source_role: None,
710            },
711            approval: approval_for_command(TeamCommandKind::Merge),
712            action: TeamCommandAction::Merge(MergeCommand {
713                task_id: task_id.into(),
714                strategy,
715            }),
716        }
717    }
718
719    fn lifecycle_command(
720        project_id: impl Into<String>,
721        source: impl Into<String>,
722        action: TeamCommandAction,
723    ) -> Self {
724        let kind = match &action {
725            TeamCommandAction::Start(_) => TeamCommandKind::Start,
726            TeamCommandAction::Stop(_) => TeamCommandKind::Stop,
727            TeamCommandAction::Restart(_) => TeamCommandKind::Restart,
728            TeamCommandAction::Send(_) => TeamCommandKind::Send,
729            TeamCommandAction::Nudge(_) => TeamCommandKind::Nudge,
730            TeamCommandAction::Review(_) => TeamCommandKind::Review,
731            TeamCommandAction::Merge(_) => TeamCommandKind::Merge,
732        };
733        Self {
734            kind: TEAM_COMMAND_KIND.to_string(),
735            schema_version: CONTRACT_SCHEMA_VERSION,
736            min_supported_schema_version: MIN_SUPPORTED_SCHEMA_VERSION,
737            project_id: project_id.into(),
738            actor: CommandActor {
739                source: source.into(),
740                source_role: None,
741            },
742            approval: approval_for_command(kind),
743            action,
744        }
745    }
746}
747
748pub fn descriptor() -> ContractDescriptor {
749    ContractDescriptor {
750        kind: CONTRACT_DESCRIPTOR_KIND.to_string(),
751        schema_version: CONTRACT_SCHEMA_VERSION,
752        min_supported_schema_version: MIN_SUPPORTED_SCHEMA_VERSION,
753        dto_kinds: DtoKinds {
754            team_status: TEAM_STATUS_KIND.to_string(),
755            team_event: TEAM_EVENT_KIND.to_string(),
756            team_command: TEAM_COMMAND_KIND.to_string(),
757        },
758        versioning: VersioningPolicy {
759            current_schema_version: CONTRACT_SCHEMA_VERSION,
760            min_supported_schema_version: MIN_SUPPORTED_SCHEMA_VERSION,
761            add_only_fields: true,
762            new_enum_variants_require_capability_review: true,
763            incompatible_changes_require_new_schema_version: true,
764        },
765        anti_corruption_boundary: AntiCorruptionBoundary {
766            batty_is_system_of_record: true,
767            prompt_wording_leaks_forbidden: true,
768            command_intents_are_explicit: true,
769            status_inputs: vec![
770                "team status report".to_string(),
771                "workflow metrics".to_string(),
772                "member health counters".to_string(),
773            ],
774            event_inputs: vec![
775                "team events jsonl".to_string(),
776                "project registry".to_string(),
777            ],
778        },
779        capabilities: supported_capabilities(),
780        supported_event_kinds: supported_event_kinds(),
781        escalation_surface: default_escalation_surface(),
782        approval_surface: default_approval_surface(),
783    }
784}
785
786pub fn negotiate_capabilities(
787    request: &CapabilityNegotiationRequest,
788) -> CapabilityNegotiationResult {
789    if request.requested_schema_version < MIN_SUPPORTED_SCHEMA_VERSION {
790        return CapabilityNegotiationResult {
791            kind: CAPABILITY_NEGOTIATION_KIND.to_string(),
792            schema_version: CONTRACT_SCHEMA_VERSION,
793            min_supported_schema_version: MIN_SUPPORTED_SCHEMA_VERSION,
794            compatible: false,
795            negotiated_schema_version: None,
796            granted_capabilities: Vec::new(),
797            reason: format!(
798                "requested schema version {} is older than the minimum supported {}",
799                request.requested_schema_version, MIN_SUPPORTED_SCHEMA_VERSION
800            ),
801        };
802    }
803
804    if request.min_compatible_schema_version > CONTRACT_SCHEMA_VERSION {
805        return CapabilityNegotiationResult {
806            kind: CAPABILITY_NEGOTIATION_KIND.to_string(),
807            schema_version: CONTRACT_SCHEMA_VERSION,
808            min_supported_schema_version: MIN_SUPPORTED_SCHEMA_VERSION,
809            compatible: false,
810            negotiated_schema_version: None,
811            granted_capabilities: Vec::new(),
812            reason: format!(
813                "minimum compatible schema version {} is newer than Batty's current {}",
814                request.min_compatible_schema_version, CONTRACT_SCHEMA_VERSION
815            ),
816        };
817    }
818
819    let granted_capabilities = if request.requested_capabilities.is_empty() {
820        supported_capabilities()
821    } else {
822        SUPPORTED_CAPABILITIES
823            .iter()
824            .copied()
825            .filter(|capability| request.requested_capabilities.contains(capability))
826            .collect()
827    };
828
829    CapabilityNegotiationResult {
830        kind: CAPABILITY_NEGOTIATION_KIND.to_string(),
831        schema_version: CONTRACT_SCHEMA_VERSION,
832        min_supported_schema_version: MIN_SUPPORTED_SCHEMA_VERSION,
833        compatible: true,
834        negotiated_schema_version: Some(
835            request
836                .requested_schema_version
837                .min(CONTRACT_SCHEMA_VERSION),
838        ),
839        granted_capabilities,
840        reason: "compatible via add-only schema evolution".to_string(),
841    }
842}
843
844pub(crate) fn team_status_from_report(report: &status::TeamStatusJsonReport) -> TeamStatus {
845    let workflow_metrics = report.workflow_metrics.clone().unwrap_or_default();
846    TeamStatus {
847        kind: TEAM_STATUS_KIND.to_string(),
848        schema_version: CONTRACT_SCHEMA_VERSION,
849        min_supported_schema_version: MIN_SUPPORTED_SCHEMA_VERSION,
850        team_name: report.team.clone(),
851        session_name: report.session.clone(),
852        lifecycle: team_lifecycle(report),
853        running: report.running,
854        paused: report.paused,
855        members: report.members.iter().map(member_status_from_row).collect(),
856        pipeline: PipelineMetrics {
857            active_task_count: report.active_tasks.len(),
858            review_queue_count: report.review_queue.len(),
859            runnable_count: workflow_metrics.runnable_count,
860            blocked_count: workflow_metrics.blocked_count,
861            in_review_count: workflow_metrics.in_review_count,
862            in_progress_count: workflow_metrics.in_progress_count,
863            stale_in_progress_count: workflow_metrics.stale_in_progress_count,
864            stale_review_count: workflow_metrics.stale_review_count,
865            triage_backlog_count: report.health.triage_backlog_count,
866            unhealthy_member_count: report.health.unhealthy_members.len(),
867            auto_merge_rate: workflow_metrics.auto_merge_rate,
868            rework_rate: workflow_metrics.rework_rate,
869            avg_review_latency_secs: workflow_metrics.avg_review_latency_secs,
870        },
871        escalation_surface: default_escalation_surface(),
872        approval_surface: default_approval_surface(),
873        capabilities: supported_capabilities(),
874    }
875}
876
877#[cfg(test)]
878pub(crate) fn project_event_from_internal(
879    project: &RegisteredProject,
880    event: &events::TeamEvent,
881) -> Option<ProjectEventEnvelope> {
882    let (topic, event_kind) = contract_for_internal_event(event)?;
883    Some(ProjectEventEnvelope {
884        kind: TEAM_EVENT_KIND.to_string(),
885        schema_version: CONTRACT_SCHEMA_VERSION,
886        min_supported_schema_version: MIN_SUPPORTED_SCHEMA_VERSION,
887        project_id: project.project_id.clone(),
888        project_name: project.name.clone(),
889        project_root: project.project_root.display().to_string(),
890        team_name: project.team_name.clone(),
891        session_name: project.session_name.clone(),
892        event: TeamEvent {
893            topic,
894            event_kind,
895            ts: event.ts,
896            member_name: event.role.clone(),
897            task_id: event.task.clone(),
898            sender: event.from.clone(),
899            recipient: event.recipient.clone().or_else(|| event.to.clone()),
900            reason: event.reason.clone(),
901            detail: event.details.clone(),
902            action_type: event.action_type.clone(),
903            success: event.success,
904            restart_count: event.restart_count,
905            load: event.load,
906            uptime_secs: event.uptime_secs,
907            session_running: event.session_running,
908        },
909    })
910}
911
912pub(crate) fn contract_for_internal_event(
913    event: &events::TeamEvent,
914) -> Option<(TeamEventTopic, TeamEventKind)> {
915    match event.event.as_str() {
916        "task_completed" => Some((TeamEventTopic::Completion, TeamEventKind::TaskCompleted)),
917        "review_nudge_sent" => Some((TeamEventTopic::Review, TeamEventKind::ReviewNudged)),
918        "review_escalated" => Some((TeamEventTopic::Review, TeamEventKind::ReviewEscalated)),
919        "review_stale" => Some((TeamEventTopic::Review, TeamEventKind::ReviewStalled)),
920        "stall_detected" => Some((TeamEventTopic::Stall, TeamEventKind::AgentStalled)),
921        "task_stale" => Some((TeamEventTopic::Stall, TeamEventKind::TaskStalled)),
922        "task_auto_merged" => Some((TeamEventTopic::Merge, TeamEventKind::TaskMergedAutomatic)),
923        "task_manual_merged" => Some((TeamEventTopic::Merge, TeamEventKind::TaskMergedManual)),
924        "task_escalated" => Some((TeamEventTopic::Escalation, TeamEventKind::TaskEscalated)),
925        "verification_max_iterations_reached" => Some((
926            TeamEventTopic::Escalation,
927            TeamEventKind::VerificationEscalated,
928        )),
929        "delivery_failed" => Some((
930            TeamEventTopic::DeliveryFailure,
931            TeamEventKind::DeliveryFailed,
932        )),
933        "daemon_started" => Some((TeamEventTopic::Lifecycle, TeamEventKind::SessionStarted)),
934        "daemon_reloading" => Some((TeamEventTopic::Lifecycle, TeamEventKind::SessionReloading)),
935        "daemon_reloaded" => Some((TeamEventTopic::Lifecycle, TeamEventKind::SessionReloaded)),
936        "daemon_stopped" => Some((TeamEventTopic::Lifecycle, TeamEventKind::SessionStopped)),
937        "agent_spawned" => Some((TeamEventTopic::Lifecycle, TeamEventKind::AgentStarted)),
938        "agent_restarted" => Some((TeamEventTopic::Lifecycle, TeamEventKind::AgentRestarted)),
939        "member_crashed" => Some((TeamEventTopic::Lifecycle, TeamEventKind::AgentCrashed)),
940        "pane_death" => Some((TeamEventTopic::Lifecycle, TeamEventKind::AgentStopped)),
941        "pane_respawned" => Some((TeamEventTopic::Lifecycle, TeamEventKind::AgentRespawned)),
942        "context_exhausted" => Some((
943            TeamEventTopic::Lifecycle,
944            TeamEventKind::AgentContextExhausted,
945        )),
946        "health_changed" => Some((TeamEventTopic::Lifecycle, TeamEventKind::AgentHealthChanged)),
947        "topology_changed" => Some((
948            TeamEventTopic::Lifecycle,
949            TeamEventKind::SessionTopologyChanged,
950        )),
951        "agent_removed" => Some((TeamEventTopic::Lifecycle, TeamEventKind::AgentRemoved)),
952        _ => None,
953    }
954}
955
956pub(crate) fn event_kind_from_legacy_event_type(event_type: &str) -> Option<TeamEventKind> {
957    SUPPORTED_EVENT_KINDS
958        .iter()
959        .copied()
960        .find(|kind| kind.legacy_event_type() == event_type)
961}
962
963fn supported_capabilities() -> Vec<OpenClawCapability> {
964    SUPPORTED_CAPABILITIES.to_vec()
965}
966
967fn supported_event_kinds() -> Vec<TeamEventKind> {
968    SUPPORTED_EVENT_KINDS.to_vec()
969}
970
971fn default_escalation_surface() -> EscalationSurface {
972    EscalationSurface {
973        authority: EscalationAuthority::RecommendOnly,
974        supported_kinds: ESCALATION_KINDS.to_vec(),
975    }
976}
977
978fn default_approval_surface() -> ApprovalSurface {
979    ApprovalSurface {
980        command_policies: vec![
981            CommandPolicy {
982                command: TeamCommandKind::Start,
983                scope: CommandScope::Team,
984                approval_level: ApprovalLevel::NotRequired,
985                human_only_decisions: Vec::new(),
986            },
987            CommandPolicy {
988                command: TeamCommandKind::Stop,
989                scope: CommandScope::Team,
990                approval_level: ApprovalLevel::Required,
991                human_only_decisions: vec![HumanDecisionKind::StopSession],
992            },
993            CommandPolicy {
994                command: TeamCommandKind::Restart,
995                scope: CommandScope::Member,
996                approval_level: ApprovalLevel::Suggested,
997                human_only_decisions: vec![HumanDecisionKind::RestartSession],
998            },
999            CommandPolicy {
1000                command: TeamCommandKind::Send,
1001                scope: CommandScope::Member,
1002                approval_level: ApprovalLevel::NotRequired,
1003                human_only_decisions: Vec::new(),
1004            },
1005            CommandPolicy {
1006                command: TeamCommandKind::Nudge,
1007                scope: CommandScope::Member,
1008                approval_level: ApprovalLevel::Suggested,
1009                human_only_decisions: Vec::new(),
1010            },
1011            CommandPolicy {
1012                command: TeamCommandKind::Review,
1013                scope: CommandScope::Task,
1014                approval_level: ApprovalLevel::Required,
1015                human_only_decisions: vec![HumanDecisionKind::ReviewDisposition],
1016            },
1017            CommandPolicy {
1018                command: TeamCommandKind::Merge,
1019                scope: CommandScope::Task,
1020                approval_level: ApprovalLevel::Required,
1021                human_only_decisions: vec![HumanDecisionKind::MergeDisposition],
1022            },
1023        ],
1024        human_only_decisions: HUMAN_ONLY_DECISIONS.to_vec(),
1025    }
1026}
1027
1028fn approval_for_command(command: TeamCommandKind) -> ApprovalRequirement {
1029    let policy = default_approval_surface()
1030        .command_policies
1031        .into_iter()
1032        .find(|policy| policy.command == command)
1033        .unwrap_or(CommandPolicy {
1034            command,
1035            scope: CommandScope::Team,
1036            approval_level: ApprovalLevel::Required,
1037            human_only_decisions: vec![HumanDecisionKind::PolicyOverride],
1038        });
1039    ApprovalRequirement {
1040        level: policy.approval_level,
1041        human_only_decisions: policy.human_only_decisions,
1042    }
1043}
1044
1045fn team_lifecycle(report: &status::TeamStatusJsonReport) -> TeamLifecycle {
1046    if !report.running {
1047        TeamLifecycle::Stopped
1048    } else if report.watchdog.current_backoff_secs.is_some()
1049        || report.watchdog.state.eq_ignore_ascii_case("recovering")
1050        || report.watchdog.state.eq_ignore_ascii_case("backoff")
1051    {
1052        TeamLifecycle::Recovering
1053    } else if !report.health.unhealthy_members.is_empty() {
1054        TeamLifecycle::Degraded
1055    } else {
1056        TeamLifecycle::Running
1057    }
1058}
1059
1060fn member_status_from_row(row: &status::TeamStatusRow) -> MemberStatus {
1061    MemberStatus {
1062        name: row.name.clone(),
1063        role: row.role.clone(),
1064        role_type: row.role_type.clone(),
1065        state: member_state(&row.state),
1066        health: member_health(row),
1067        active_task_ids: row.active_owned_tasks.iter().map(u32::to_string).collect(),
1068        review_task_ids: row.review_owned_tasks.iter().map(u32::to_string).collect(),
1069        pending_inbox_count: row.pending_inbox,
1070        triage_backlog_count: row.triage_backlog,
1071        signal: row.signal.clone(),
1072        restart_count: row.health.restart_count,
1073        context_exhaustion_count: row.health.context_exhaustion_count,
1074        delivery_failure_count: row.health.delivery_failure_count,
1075        supervisory_digest_count: row.health.supervisory_digest_count,
1076        stall_reason: row.health.stall_reason.clone(),
1077        stall_summary: row.health.stall_summary.clone(),
1078        task_elapsed_secs: row.health.task_elapsed_secs,
1079        backend_health: backend_health(row.health.backend_health),
1080    }
1081}
1082
1083fn member_state(raw: &str) -> MemberState {
1084    match raw {
1085        "starting" => MemberState::Starting,
1086        "idle" => MemberState::Idle,
1087        "working" => MemberState::Working,
1088        "done" => MemberState::Done,
1089        "crashed" => MemberState::Crashed,
1090        _ => MemberState::Unknown,
1091    }
1092}
1093
1094fn member_health(row: &status::TeamStatusRow) -> MemberHealth {
1095    if matches!(
1096        row.health.backend_health,
1097        crate::agent::BackendHealth::Unreachable
1098    ) || row.state == "crashed"
1099    {
1100        MemberHealth::Unhealthy
1101    } else if row.health.has_operator_warning() {
1102        MemberHealth::Warning
1103    } else {
1104        MemberHealth::Healthy
1105    }
1106}
1107
1108fn backend_health(health: crate::agent::BackendHealth) -> BackendHealth {
1109    match health {
1110        crate::agent::BackendHealth::Healthy => BackendHealth::Healthy,
1111        crate::agent::BackendHealth::Degraded => BackendHealth::Degraded,
1112        crate::agent::BackendHealth::Unreachable => BackendHealth::Unreachable,
1113        crate::agent::BackendHealth::QuotaExhausted => BackendHealth::Unreachable,
1114    }
1115}
1116
1117#[cfg(test)]
1118mod tests {
1119    use super::*;
1120
1121    fn sample_report() -> status::TeamStatusJsonReport {
1122        status::TeamStatusJsonReport {
1123            team: "fixture-team".to_string(),
1124            session: "batty-fixture-team".to_string(),
1125            running: true,
1126            paused: false,
1127            watchdog: status::WatchdogStatus {
1128                state: "running".to_string(),
1129                restart_count: 0,
1130                current_backoff_secs: None,
1131                last_exit_reason: None,
1132            },
1133            health: status::TeamStatusHealth {
1134                session_running: true,
1135                paused: false,
1136                member_count: 2,
1137                active_member_count: 1,
1138                pending_inbox_count: 1,
1139                triage_backlog_count: 2,
1140                unhealthy_members: vec!["eng-1".to_string()],
1141            },
1142            workflow_metrics: Some(status::WorkflowMetrics {
1143                runnable_count: 3,
1144                blocked_count: 1,
1145                in_review_count: 1,
1146                in_progress_count: 2,
1147                stale_in_progress_count: 1,
1148                aged_todo_count: 0,
1149                stale_review_count: 1,
1150                idle_with_runnable: vec!["manager".to_string()],
1151                top_runnable_tasks: vec!["#42 (high) Inbox fix".to_string()],
1152                oldest_review_age_secs: Some(120),
1153                oldest_assignment_age_secs: Some(60),
1154                auto_merge_count: 1,
1155                manual_merge_count: 0,
1156                direct_root_merge_count: 1,
1157                isolated_integration_merge_count: 0,
1158                direct_root_failure_count: 0,
1159                isolated_integration_failure_count: 0,
1160                auto_merge_rate: Some(0.5),
1161                rework_count: 1,
1162                rework_rate: Some(0.25),
1163                review_nudge_count: 1,
1164                review_escalation_count: 1,
1165                avg_review_latency_secs: Some(42.0),
1166            }),
1167            active_tasks: vec![status::StatusTaskEntry {
1168                id: 42,
1169                title: "Fix contract".to_string(),
1170                status: "in-progress".to_string(),
1171                priority: "high".to_string(),
1172                claimed_by: Some("eng-1".to_string()),
1173                review_owner: None,
1174                blocked_on: None,
1175                branch: None,
1176                worktree_path: None,
1177                commit: None,
1178                branch_mismatch: None,
1179                next_action: None,
1180                test_summary: None,
1181            }],
1182            review_queue: vec![status::StatusTaskEntry {
1183                id: 43,
1184                title: "Review contract".to_string(),
1185                status: "review".to_string(),
1186                priority: "medium".to_string(),
1187                claimed_by: None,
1188                review_owner: Some("manager".to_string()),
1189                blocked_on: None,
1190                branch: None,
1191                worktree_path: None,
1192                commit: None,
1193                branch_mismatch: None,
1194                next_action: None,
1195                test_summary: None,
1196            }],
1197            optional_subsystems: None,
1198            engineer_profiles: None,
1199            members: vec![
1200                status::TeamStatusRow {
1201                    name: "eng-1".to_string(),
1202                    role: "engineer".to_string(),
1203                    role_type: "engineer".to_string(),
1204                    agent: Some("codex".to_string()),
1205                    reports_to: Some("manager".to_string()),
1206                    state: "working".to_string(),
1207                    pending_inbox: 1,
1208                    triage_backlog: 2,
1209                    active_owned_tasks: vec![42],
1210                    review_owned_tasks: Vec::new(),
1211                    signal: Some("nudge sent".to_string()),
1212                    runtime_label: Some("working".to_string()),
1213                    worktree_staleness: None,
1214                    health: status::AgentHealthSummary {
1215                        restart_count: 1,
1216                        context_exhaustion_count: 1,
1217                        delivery_failure_count: 0,
1218                        supervisory_digest_count: 2,
1219                        dispatch_fallback_count: 0,
1220                        dispatch_fallback_reason: None,
1221                        task_elapsed_secs: Some(300),
1222                        stall_reason: Some("supervisory_stalled".to_string()),
1223                        stall_summary: Some(
1224                            "eng-1 stayed in Working for 5m (timeout=60s)".to_string(),
1225                        ),
1226                        backend_health: crate::agent::BackendHealth::Degraded,
1227                    },
1228                    health_summary: "warning".to_string(),
1229                    eta: "soon".to_string(),
1230                },
1231                status::TeamStatusRow {
1232                    name: "manager".to_string(),
1233                    role: "manager".to_string(),
1234                    role_type: "manager".to_string(),
1235                    agent: Some("codex".to_string()),
1236                    reports_to: None,
1237                    state: "idle".to_string(),
1238                    pending_inbox: 0,
1239                    triage_backlog: 0,
1240                    active_owned_tasks: Vec::new(),
1241                    review_owned_tasks: vec![43],
1242                    signal: None,
1243                    runtime_label: Some("idle".to_string()),
1244                    worktree_staleness: None,
1245                    health: status::AgentHealthSummary {
1246                        restart_count: 0,
1247                        context_exhaustion_count: 0,
1248                        delivery_failure_count: 0,
1249                        supervisory_digest_count: 0,
1250                        dispatch_fallback_count: 0,
1251                        dispatch_fallback_reason: None,
1252                        task_elapsed_secs: None,
1253                        stall_reason: None,
1254                        stall_summary: None,
1255                        backend_health: crate::agent::BackendHealth::Healthy,
1256                    },
1257                    health_summary: "healthy".to_string(),
1258                    eta: "idle".to_string(),
1259                },
1260            ],
1261        }
1262    }
1263
1264    #[test]
1265    fn descriptor_exposes_versioning_and_surfaces() {
1266        let descriptor = descriptor();
1267
1268        assert_eq!(descriptor.kind, CONTRACT_DESCRIPTOR_KIND);
1269        assert_eq!(descriptor.schema_version, CONTRACT_SCHEMA_VERSION);
1270        assert!(
1271            descriptor
1272                .anti_corruption_boundary
1273                .batty_is_system_of_record
1274        );
1275        assert!(
1276            descriptor
1277                .approval_surface
1278                .human_only_decisions
1279                .contains(&HumanDecisionKind::MergeDisposition)
1280        );
1281        assert!(
1282            descriptor
1283                .supported_event_kinds
1284                .contains(&TeamEventKind::TaskEscalated)
1285        );
1286    }
1287
1288    #[test]
1289    fn team_status_from_report_normalizes_internal_status() {
1290        let status = team_status_from_report(&sample_report());
1291
1292        assert_eq!(status.kind, TEAM_STATUS_KIND);
1293        assert_eq!(status.lifecycle, TeamLifecycle::Degraded);
1294        assert_eq!(status.pipeline.active_task_count, 1);
1295        assert_eq!(status.pipeline.review_queue_count, 1);
1296        assert_eq!(status.pipeline.triage_backlog_count, 2);
1297        assert_eq!(status.members[0].state, MemberState::Working);
1298        assert_eq!(status.members[0].health, MemberHealth::Warning);
1299        assert_eq!(status.members[0].backend_health, BackendHealth::Degraded);
1300        assert_eq!(status.members[0].supervisory_digest_count, 2);
1301        assert_eq!(
1302            status.members[0].stall_reason.as_deref(),
1303            Some("supervisory_stalled")
1304        );
1305        assert_eq!(
1306            status.members[0].stall_summary.as_deref(),
1307            Some("eng-1 stayed in Working for 5m (timeout=60s)")
1308        );
1309        assert_eq!(status.members[1].review_task_ids, vec!["43".to_string()]);
1310    }
1311
1312    #[test]
1313    fn member_health_warns_on_supervisory_stall_only() {
1314        let row = status::TeamStatusRow {
1315            name: "eng-1".to_string(),
1316            role: "engineer".to_string(),
1317            role_type: "engineer".to_string(),
1318            agent: Some("codex".to_string()),
1319            reports_to: Some("manager".to_string()),
1320            state: "working".to_string(),
1321            pending_inbox: 0,
1322            triage_backlog: 0,
1323            active_owned_tasks: Vec::new(),
1324            review_owned_tasks: Vec::new(),
1325            signal: None,
1326            runtime_label: Some("working".to_string()),
1327            worktree_staleness: None,
1328            health: status::AgentHealthSummary {
1329                stall_reason: Some("supervisory_stalled".to_string()),
1330                stall_summary: Some("eng-1 stayed in Working for 5m".to_string()),
1331                ..status::AgentHealthSummary::default()
1332            },
1333            health_summary: "stall:supervisory_stalled".to_string(),
1334            eta: "-".to_string(),
1335        };
1336
1337        assert_eq!(member_health(&row), MemberHealth::Warning);
1338    }
1339
1340    #[test]
1341    fn project_event_from_internal_uses_explicit_event_kind() {
1342        let project = RegisteredProject {
1343            project_id: "fixture".to_string(),
1344            name: "Fixture".to_string(),
1345            aliases: Vec::new(),
1346            project_root: std::path::PathBuf::from("/tmp/fixture"),
1347            board_dir: std::path::PathBuf::from("/tmp/fixture/.batty/team_config/board"),
1348            team_name: "fixture-team".to_string(),
1349            session_name: "batty-fixture-team".to_string(),
1350            channel_bindings: Vec::new(),
1351            owner: None,
1352            tags: vec!["openclaw".to_string()],
1353            policy_flags: crate::project_registry::ProjectPolicyFlags {
1354                allow_openclaw_supervision: true,
1355                allow_cross_project_routing: false,
1356                allow_shared_service_routing: false,
1357                archived: false,
1358            },
1359            created_at: 0,
1360            updated_at: 0,
1361        };
1362        let event = events::TeamEvent::task_escalated("eng-1", "42", Some("tests_failed"));
1363
1364        let envelope = project_event_from_internal(&project, &event).unwrap();
1365
1366        assert_eq!(envelope.kind, TEAM_EVENT_KIND);
1367        assert_eq!(envelope.event.topic, TeamEventTopic::Escalation);
1368        assert_eq!(envelope.event.event_kind, TeamEventKind::TaskEscalated);
1369        assert_eq!(envelope.event.member_name.as_deref(), Some("eng-1"));
1370        assert_eq!(envelope.event.task_id.as_deref(), Some("42"));
1371    }
1372
1373    #[test]
1374    fn capability_negotiation_rejects_incompatible_schema_versions() {
1375        let result = negotiate_capabilities(&CapabilityNegotiationRequest {
1376            kind: CAPABILITY_NEGOTIATION_KIND.to_string(),
1377            requested_schema_version: 0,
1378            min_compatible_schema_version: 0,
1379            requested_capabilities: vec![OpenClawCapability::TeamStatus],
1380        });
1381
1382        assert!(!result.compatible);
1383        assert!(result.negotiated_schema_version.is_none());
1384    }
1385
1386    #[test]
1387    fn team_command_serialization_keeps_action_explicit() {
1388        let command = TeamCommand::merge("fixture", "openclaw", "42", MergeStrategy::Manual);
1389        let json = serde_json::to_value(&command).unwrap();
1390
1391        assert_eq!(json["kind"], TEAM_COMMAND_KIND);
1392        assert_eq!(json["action"], "merge");
1393        assert_eq!(json["taskId"], "42");
1394        assert_eq!(json["approval"]["level"], "required");
1395    }
1396}