# AD-381: InitiativeEngine — SIF Violations → Remediation Proposals

## Goal

Bridge the gap between read-only monitoring (SIF, EmergentDetector, Counselor) and the self-mod pipeline. Today, ProbOS only acts when the Captain types. The InitiativeEngine monitors system health signals and generates `RemediationProposal` objects when problems persist — giving the system proactive initiative within trust-gated boundaries.

## Architecture

**Pattern:** Ship's Computer service, same lifecycle as SIF (AD-370) and TaskTracker (AD-316). Background async loop with configurable check interval.

## Reference Files (read these first)

- `src/probos/sif.py` — SIF invariant checker (read-only, reporting pattern)
- `src/probos/watch_rotation.py` — WatchManager with StandingTasks and CaptainOrders
- `src/probos/task_tracker.py` — TaskTracker (event emission pattern)
- `src/probos/cognitive/counselor.py` — CounselorAgent with CounselorAssessment and alert levels

## Files to Create

### `src/probos/initiative.py` (~250 lines)

```python
"""AD-381: InitiativeEngine — proactive remediation from system health signals."""

from __future__ import annotations

import asyncio
import logging
import time
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Callable

logger = logging.getLogger(__name__)


class TriggerSource(str, Enum):
    SIF = "sif"
    EMERGENT = "emergent"
    COUNSELOR = "counselor"


class ActionType(str, Enum):
    DIAGNOSE = "diagnose"      # run diagnostics — low risk
    SCALE = "scale"            # adjust pool size — low risk
    RECYCLE = "recycle"        # restart degraded agent — medium risk
    PATCH = "patch"            # trigger self-mod pipeline — high risk
    ALERT_CAPTAIN = "alert_captain"  # escalate to human — always allowed


class ActionGate(str, Enum):
    """Trust tier required to auto-execute an action."""
    AUTO = "auto"              # always executes (diagnose, alert_captain)
    COMMANDER = "commander"    # auto if system trust > 0.7 (scale, recycle)
    CAPTAIN = "captain"        # always requires Captain approval (patch)


@dataclass
class RemediationProposal:
    """A proposed remediation action generated by the InitiativeEngine."""
    id: str  # uuid
    trigger_source: TriggerSource
    trigger_detail: str  # human-readable description of what triggered this
    action_type: ActionType
    action_detail: str  # what specifically should be done
    gate: ActionGate
    severity: str  # "low", "medium", "high"
    affected_agents: list[str] = field(default_factory=list)
    persistence_count: int = 1  # how many consecutive checks this trigger has persisted
    created_at: float = field(default_factory=time.time)
    status: str = "proposed"  # proposed, approved, rejected, executed

    def to_dict(self) -> dict[str, Any]: ...


@dataclass
class TriggerState:
    """Tracks a persistent trigger across consecutive check cycles."""
    source: TriggerSource
    key: str  # unique identifier for this specific trigger (e.g., "sif:trust_bounds")
    detail: str
    first_seen: float = field(default_factory=time.time)
    consecutive_count: int = 1
    last_seen: float = field(default_factory=time.time)


class InitiativeEngine:
    """Monitors system health signals and proposes remediation actions.

    The engine runs a periodic check loop. When a trigger persists for
    `persistence_threshold` consecutive checks, it generates a RemediationProposal.

    Trust-gated execution:
    - AUTO actions (diagnose, alert_captain): always execute
    - COMMANDER actions (scale, recycle): auto-execute if system is healthy
    - CAPTAIN actions (patch): always require human approval via on_proposal callback

    Fails-open: if any check raises an exception, log and continue.
    """

    def __init__(
        self,
        *,
        check_interval: float = 30.0,  # seconds between checks
        persistence_threshold: int = 3,  # consecutive occurrences before proposing
        on_proposal: Callable[[RemediationProposal], Any] | None = None,
        on_event: Callable[[dict], Any] | None = None,
    ) -> None:
        self._check_interval = check_interval
        self._persistence_threshold = persistence_threshold
        self._on_proposal = on_proposal
        self._on_event = on_event
        self._triggers: dict[str, TriggerState] = {}
        self._proposals: list[RemediationProposal] = []
        self._task: asyncio.Task | None = None
        # External references set via set_*() after construction
        self._sif = None           # StructuralIntegrityField
        self._detector = None      # EmergentDetector
        self._counselor_fn = None  # Callable returning list[CounselorAssessment]

    def set_sif(self, sif) -> None:
        """Wire the SIF reference for invariant checks."""
        self._sif = sif

    def set_detector(self, detector) -> None:
        """Wire the EmergentDetector for trend checks."""
        self._detector = detector

    def set_counselor_fn(self, fn) -> None:
        """Wire a callable that returns recent counselor assessments."""
        self._counselor_fn = fn

    async def start(self) -> None:
        """Start the periodic check loop."""
        self._task = asyncio.create_task(self._check_loop())

    async def stop(self) -> None:
        """Stop the check loop."""
        if self._task:
            self._task.cancel()
            try:
                await self._task
            except asyncio.CancelledError:
                pass
            self._task = None

    async def _check_loop(self) -> None:
        """Main loop: check signals, update triggers, generate proposals."""
        while True:
            try:
                await self._run_checks()
            except asyncio.CancelledError:
                raise
            except Exception:
                logger.exception("InitiativeEngine check failed (fail-open)")
            await asyncio.sleep(self._check_interval)

    async def _run_checks(self) -> None:
        """Run all signal checks and update trigger states.

        For each signal source, collect current violations/alerts.
        For each violation:
        - If already tracked in _triggers: increment consecutive_count
        - If new: create TriggerState with count=1
        Clear triggers that are no longer active (problem resolved).
        For triggers that reach persistence_threshold: generate proposal.
        """
        active_keys: set[str] = set()

        # 1. SIF violations
        if self._sif:
            try:
                report = self._sif.last_report
                if report:
                    for check in report.results:
                        if not check.passed:
                            key = f"sif:{check.name}"
                            active_keys.add(key)
                            self._update_trigger(TriggerSource.SIF, key, check.message or check.name)
            except Exception:
                logger.debug("SIF check skipped", exc_info=True)

        # 2. EmergentDetector — check for significant falling trends
        #    (rising trends are good, falling cooperation is concerning)
        if self._detector:
            try:
                trend_report = self._detector.compute_trends()
                if trend_report:
                    for trend in trend_report.significant_trends:
                        if trend.direction.value == "falling" and trend.metric_name in ("tc_n", "capability_count"):
                            key = f"emergent:{trend.metric_name}_falling"
                            active_keys.add(key)
                            self._update_trigger(
                                TriggerSource.EMERGENT, key,
                                f"{trend.metric_name} falling (slope={trend.slope:.4f})"
                            )
            except Exception:
                logger.debug("EmergentDetector check skipped", exc_info=True)

        # 3. Counselor red/yellow alerts
        if self._counselor_fn:
            try:
                assessments = self._counselor_fn()
                for assessment in assessments:
                    if hasattr(assessment, 'alert_level') and assessment.alert_level in ("red", "yellow"):
                        key = f"counselor:{assessment.agent_id}:{assessment.alert_level}"
                        active_keys.add(key)
                        self._update_trigger(
                            TriggerSource.COUNSELOR, key,
                            f"Agent {assessment.agent_id} alert={assessment.alert_level}"
                        )
            except Exception:
                logger.debug("Counselor check skipped", exc_info=True)

        # Clear resolved triggers
        resolved = [k for k in self._triggers if k not in active_keys]
        for k in resolved:
            del self._triggers[k]

        # Generate proposals for persistent triggers
        for key, trigger in self._triggers.items():
            if trigger.consecutive_count == self._persistence_threshold:
                self._generate_proposal(trigger)

    def _update_trigger(self, source: TriggerSource, key: str, detail: str) -> None:
        """Update or create a trigger state."""
        if key in self._triggers:
            self._triggers[key].consecutive_count += 1
            self._triggers[key].last_seen = time.time()
            self._triggers[key].detail = detail
        else:
            self._triggers[key] = TriggerState(source=source, key=key, detail=detail)

    def _generate_proposal(self, trigger: TriggerState) -> None:
        """Generate a RemediationProposal from a persistent trigger."""
        # Map trigger source to action type and gate
        action_type, gate, severity = self._classify_trigger(trigger)
        proposal = RemediationProposal(
            id=str(uuid.uuid4()),
            trigger_source=trigger.source,
            trigger_detail=trigger.detail,
            action_type=action_type,
            action_detail=self._describe_action(trigger, action_type),
            gate=gate,
            severity=severity,
            persistence_count=trigger.consecutive_count,
        )
        self._proposals.append(proposal)
        # Keep only last 50 proposals
        if len(self._proposals) > 50:
            self._proposals = self._proposals[-50:]
        if self._on_proposal:
            self._on_proposal(proposal)
        if self._on_event:
            self._on_event({"type": "initiative_proposal", "data": proposal.to_dict()})
        logger.info("Initiative proposal: %s → %s (%s)", trigger.key, action_type.value, gate.value)

    def _classify_trigger(self, trigger: TriggerState) -> tuple[ActionType, ActionGate, str]:
        """Classify trigger into action type, gate level, and severity.

        Rules:
        - SIF violations → DIAGNOSE (AUTO), severity=high
        - Emergent falling trends → ALERT_CAPTAIN (AUTO), severity=medium
        - Counselor red alert → RECYCLE (COMMANDER), severity=high
        - Counselor yellow alert → DIAGNOSE (AUTO), severity=medium
        """
        if trigger.source == TriggerSource.SIF:
            return ActionType.DIAGNOSE, ActionGate.AUTO, "high"
        elif trigger.source == TriggerSource.EMERGENT:
            return ActionType.ALERT_CAPTAIN, ActionGate.AUTO, "medium"
        elif trigger.source == TriggerSource.COUNSELOR:
            if "red" in trigger.key:
                return ActionType.RECYCLE, ActionGate.COMMANDER, "high"
            else:
                return ActionType.DIAGNOSE, ActionGate.AUTO, "medium"
        return ActionType.ALERT_CAPTAIN, ActionGate.AUTO, "low"

    def _describe_action(self, trigger: TriggerState, action_type: ActionType) -> str:
        """Generate human-readable action description."""
        descriptions = {
            ActionType.DIAGNOSE: f"Run diagnostics on: {trigger.detail}",
            ActionType.SCALE: f"Adjust pool sizing for: {trigger.detail}",
            ActionType.RECYCLE: f"Recycle degraded agent: {trigger.detail}",
            ActionType.PATCH: f"Trigger self-mod pipeline for: {trigger.detail}",
            ActionType.ALERT_CAPTAIN: f"Captain attention needed: {trigger.detail}",
        }
        return descriptions.get(action_type, trigger.detail)

    @property
    def active_triggers(self) -> list[TriggerState]:
        """Return currently active triggers."""
        return list(self._triggers.values())

    @property
    def recent_proposals(self) -> list[RemediationProposal]:
        """Return recent proposals."""
        return list(self._proposals)

    def approve_proposal(self, proposal_id: str) -> bool:
        """Approve a proposal (Captain action). Returns True if found."""
        for p in self._proposals:
            if p.id == proposal_id:
                p.status = "approved"
                return True
        return False

    def reject_proposal(self, proposal_id: str) -> bool:
        """Reject a proposal (Captain action). Returns True if found."""
        for p in self._proposals:
            if p.id == proposal_id:
                p.status = "rejected"
                return True
        return False
```

Import `uuid` at the top of the file.

## Files to Modify

### `src/probos/runtime.py`

1. Import: `from probos.initiative import InitiativeEngine`
2. In `__init__()`: `self.initiative: InitiativeEngine | None = None`
3. In `start()`, after SIF initialization:
   ```python
   self.initiative = InitiativeEngine(
       on_event=self._emit_event,
       on_proposal=lambda p: logger.info("Initiative: %s", p.action_detail),
   )
   if self.sif:
       self.initiative.set_sif(self.sif)
   if hasattr(self, '_emergent_detector') and self._emergent_detector:
       self.initiative.set_detector(self._emergent_detector)
   await self.initiative.start()
   ```
4. In `stop()`: `if self.initiative: await self.initiative.stop()`

Note: Check the actual attribute name for the EmergentDetector on the runtime — it may be `_emergent_detector` or `emergent_detector`. Read runtime.py to confirm.

## Tests

### Create `tests/test_initiative.py` (~180 lines)

1. **`test_trigger_state_creation`** — Create TriggerState, verify defaults
2. **`test_remediation_proposal_to_dict`** — Verify serialization includes all fields
3. **`test_classify_sif_trigger`** — SIF → DIAGNOSE, AUTO, high
4. **`test_classify_counselor_red`** — Counselor red → RECYCLE, COMMANDER, high
5. **`test_classify_counselor_yellow`** — Counselor yellow → DIAGNOSE, AUTO, medium
6. **`test_classify_emergent`** — Emergent falling → ALERT_CAPTAIN, AUTO, medium
7. **`test_update_trigger_new`** — New trigger creates TriggerState with count=1
8. **`test_update_trigger_existing`** — Existing trigger increments count
9. **`test_resolved_triggers_cleared`** — Trigger no longer active gets removed
10. **`test_proposal_generated_at_threshold`** — Trigger reaching persistence_threshold generates proposal
11. **`test_proposal_not_generated_below_threshold`** — Count < threshold → no proposal
12. **`test_on_proposal_callback`** — Callback is invoked when proposal generated
13. **`test_on_event_emitted`** — Event dict emitted with type "initiative_proposal"
14. **`test_approve_proposal`** — `approve_proposal()` sets status to "approved"
15. **`test_reject_proposal`** — `reject_proposal()` sets status to "rejected"
16. **`test_proposals_capped_at_50`** — Adding 55 proposals, only last 50 retained
17. **`test_sif_not_set_graceful`** — No SIF wired → check loop runs without error
18. **`test_describe_action_coverage`** — Each ActionType has a description

For testing `_run_checks()`, create mock SIF/detector/counselor objects:
- Mock SIF: `last_report` with `results` list containing `SIFCheckResult(name="trust_bounds", passed=False, message="NaN trust")`
- Mock detector: `compute_trends()` returning a TrendReport with a significant falling tc_n trend
- Mock counselor_fn: returns list with assessment objects having `alert_level="red"` and `agent_id`

## Constraints

- Fails-open: every signal check wrapped in try/except
- No LLM calls — purely programmatic classification
- Backward compatible: if no sources wired, engine runs but generates nothing
- Do not modify SIF, EmergentDetector, or Counselor source files
- Import `uuid` for proposal IDs
