ponte 0.1.2

Bridges LangChain OpenWiki into the pleme-io fleet: reads the target repo's typescape IR + zoekt hits as context, invokes OpenWiki unmodified as a subprocess, routes the result through the fleet's doc/compliance layers. Never forks OpenWiki.
//! `ContextAssembleJob` — reads the target repo's typescape IR (when
//! present) plus a best-effort zoekt stats lookup, and writes a fresh
//! `docs/typescape-summary.md` into the repo every run.
//!
//! This is the one lever that actually works against OpenWiki: it has
//! no plugin API (`tools: []` is hardcoded in its own `src/agent/
//! index.ts`), but its system prompt treats `docs/` content as primary
//! source material during its own shell-based repo exploration. So
//! instead of linking OpenWiki as a library, ponte shapes what its
//! shell-exploring agent will find.

use std::path::PathBuf;
use std::sync::Arc;

use serde::{Deserialize, Serialize};
use shigoto_types::{JobScope, JobSubject, OutputSink, RecordingJob};
use tokio::sync::Mutex as AsyncMutex;
use zoekt_mcp::client::{ListRequest, ZoektClient};

use crate::state::{self, RunState};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContextOutcome {
    /// Whether the computed content hash differs from the last
    /// persisted run. `false` means downstream jobs skip their own
    /// work — nothing changed to regenerate docs from.
    pub changed: bool,
    pub content_hash: String,
    pub summary_path: PathBuf,
    pub typescape_present: bool,
    pub zoekt_reachable: bool,
}

#[derive(Debug, thiserror::Error)]
pub enum ContextError {
    #[error("failed to write {path:?}: {source}")]
    Write {
        path: PathBuf,
        #[source]
        source: std::io::Error,
    },
    #[error("failed to persist ponte state: {0}")]
    State(#[from] anyhow::Error),
}

pub struct ContextAssembleJob {
    pub repo_path: PathBuf,
    pub repo_name: String,
    pub zoekt: ZoektClient,
    pub state_dir: PathBuf,
    /// Shared with `InvokeOpenWikiJob` — shigoto v0.1 doesn't wire
    /// inter-Job Input/Output passing yet ("jobs hold their input in
    /// their own state (self)" per `shigoto_types::Job`'s doc comment),
    /// so a shared cell is the minimal correct way to hand this job's
    /// result to the next one in the Dag. The DAG edge already
    /// guarantees this job reaches a terminal phase before the next
    /// one's gate passes.
    pub outcome_cell: Arc<AsyncMutex<Option<ContextOutcome>>>,
    pub output_sink: Option<Arc<dyn OutputSink<ContextOutcome>>>,
}

impl ContextAssembleJob {
    async fn read_typescape(&self) -> Option<serde_yaml::Value> {
        let path = self.repo_path.join(".typescape.yaml");
        let bytes = tokio::fs::read(&path).await.ok()?;
        serde_yaml::from_slice(&bytes).ok()
    }

    async fn zoekt_stats(&self) -> (bool, String) {
        let req = ListRequest {
            q: format!("repo:{}", self.repo_name),
        };
        match self.zoekt.list_repos(&req).await {
            Ok(resp) => {
                let repos = resp.list.repos.unwrap_or_default();
                if repos.is_empty() {
                    (true, "not indexed by zoekt".to_string())
                } else {
                    let r = &repos[0];
                    (
                        true,
                        format!(
                            "{} files, {:.1} MB indexed",
                            r.stats.documents,
                            r.stats.content_bytes as f64 / 1_048_576.0
                        ),
                    )
                }
            }
            Err(e) => (false, format!("zoekt unreachable: {e}")),
        }
    }

    fn render_summary(&self, typescape: &Option<serde_yaml::Value>, zoekt_note: &str) -> String {
        let mut out = String::new();
        out.push_str("<!-- Auto-regenerated by ponte on every run. Do not hand-edit. -->\n");
        out.push_str(&format!("# {} — typescape summary\n\n", self.repo_name));
        match typescape {
            Some(v) => {
                out.push_str("## Typescape\n\n```yaml\n");
                out.push_str(&serde_yaml::to_string(v).unwrap_or_default());
                out.push_str("```\n\n");
            }
            None => {
                out.push_str("## Typescape\n\nNo `.typescape.yaml` in this repo.\n\n");
            }
        }
        out.push_str("## zoekt\n\n");
        out.push_str(zoekt_note);
        out.push('\n');
        out
    }
}

#[async_trait::async_trait]
impl RecordingJob for ContextAssembleJob {
    type Output = ContextOutcome;
    type Error = ContextError;
    const KIND: &'static str = "ponte.context-assemble";

    fn scope(&self) -> JobScope {
        JobScope::Repo {
            workspace: "ponte".into(),
            repo: self.repo_name.clone(),
        }
    }

    fn subject(&self) -> JobSubject {
        JobSubject::Path(self.repo_path.clone())
    }

    fn output_sink(&self) -> Option<&Arc<dyn OutputSink<Self::Output>>> {
        self.output_sink.as_ref()
    }

    async fn execute_body(&self) -> Result<ContextOutcome, ContextError> {
        let prior = state::load(&self.state_dir, &self.repo_path);

        let typescape = self.read_typescape().await;
        let typescape_present = typescape.is_some();
        let (zoekt_reachable, zoekt_note) = self.zoekt_stats().await;

        let summary = self.render_summary(&typescape, &zoekt_note);
        let content_hash = blake3::hash(summary.as_bytes()).to_hex().to_string();

        let summary_path = self.repo_path.join("docs").join("typescape-summary.md");
        if let Some(parent) = summary_path.parent() {
            tokio::fs::create_dir_all(parent)
                .await
                .map_err(|source| ContextError::Write {
                    path: summary_path.clone(),
                    source,
                })?;
        }
        tokio::fs::write(&summary_path, &summary)
            .await
            .map_err(|source| ContextError::Write {
                path: summary_path.clone(),
                source,
            })?;

        let changed = prior.last_content_hash.as_deref() != Some(content_hash.as_str());

        let new_state = RunState {
            last_content_hash: Some(content_hash.clone()),
            last_run_at: Some(chrono::Utc::now()),
        };
        state::save(&self.state_dir, &self.repo_path, &new_state)?;

        let outcome = ContextOutcome {
            changed,
            content_hash,
            summary_path,
            typescape_present,
            zoekt_reachable,
        };

        *self.outcome_cell.lock().await = Some(outcome.clone());
        Ok(outcome)
    }
}

/// Test-only helper: a `ZoektClient` pointed at a deliberately
/// unreachable port so `zoekt_stats()` takes the `Err` branch
/// deterministically, in bounded time, without a running daemon.
#[cfg(test)]
fn unreachable_zoekt_client() -> ZoektClient {
    ZoektClient::with_timeout("http://127.0.0.1:1", std::time::Duration::from_millis(200))
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::path::Path;

    fn job(dir: &Path, cell: Arc<AsyncMutex<Option<ContextOutcome>>>) -> ContextAssembleJob {
        ContextAssembleJob {
            repo_path: dir.to_path_buf(),
            repo_name: "test-repo".into(),
            zoekt: unreachable_zoekt_client(),
            state_dir: dir.join(".ponte-state"),
            outcome_cell: cell,
            output_sink: None,
        }
    }

    #[tokio::test]
    async fn writes_summary_and_marks_changed_on_first_run() {
        let dir = tempfile::tempdir().unwrap();
        let outcome = job(dir.path(), Arc::new(AsyncMutex::new(None)))
            .execute_body()
            .await
            .unwrap();

        assert!(outcome.changed);
        assert!(!outcome.typescape_present);
        assert!(!outcome.zoekt_reachable);
        assert!(outcome.summary_path.exists());
        let contents = std::fs::read_to_string(&outcome.summary_path).unwrap();
        assert!(contents.contains("test-repo"));
        assert!(contents.contains("No `.typescape.yaml`"));
    }

    #[tokio::test]
    async fn second_run_with_no_change_is_unchanged() {
        let dir = tempfile::tempdir().unwrap();
        let j = job(dir.path(), Arc::new(AsyncMutex::new(None)));

        let first = j.execute_body().await.unwrap();
        assert!(first.changed);
        let second = j.execute_body().await.unwrap();
        assert!(!second.changed, "identical rerun must not report changed");
        assert_eq!(first.content_hash, second.content_hash);
    }

    #[tokio::test]
    async fn reads_typescape_yaml_when_present() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::write(dir.path().join(".typescape.yaml"), "vocabulary: [foo, bar]\n").unwrap();

        let outcome = job(dir.path(), Arc::new(AsyncMutex::new(None)))
            .execute_body()
            .await
            .unwrap();

        assert!(outcome.typescape_present);
        let contents = std::fs::read_to_string(&outcome.summary_path).unwrap();
        assert!(contents.contains("foo"));
    }

    #[tokio::test]
    async fn changing_typescape_content_flips_changed_on_third_run() {
        let dir = tempfile::tempdir().unwrap();
        let state_dir = dir.path().join(".ponte-state");

        let mk = || ContextAssembleJob {
            repo_path: dir.path().to_path_buf(),
            repo_name: "test-repo".into(),
            zoekt: unreachable_zoekt_client(),
            state_dir: state_dir.clone(),
            outcome_cell: Arc::new(AsyncMutex::new(None)),
            output_sink: None,
        };

        let first = mk().execute_body().await.unwrap();
        assert!(first.changed);
        let second = mk().execute_body().await.unwrap();
        assert!(!second.changed);

        std::fs::write(dir.path().join(".typescape.yaml"), "vocabulary: [changed]\n").unwrap();
        let third = mk().execute_body().await.unwrap();
        assert!(third.changed, "editing .typescape.yaml must flip changed");
        assert_ne!(first.content_hash, third.content_hash);
    }

    #[tokio::test]
    async fn outcome_cell_is_populated_after_execute() {
        let dir = tempfile::tempdir().unwrap();
        let cell = Arc::new(AsyncMutex::new(None));
        job(dir.path(), cell.clone()).execute_body().await.unwrap();
        assert!(cell.lock().await.is_some());
    }
}