diff --git a/hive-ag3nt/prompts/system.md b/hive-ag3nt/prompts/system.md index 1cee7ae1..659b17fb 100644 --- a/hive-ag3nt/prompts/system.md +++ b/hive-ag3nt/prompts/system.md @@ -127,6 +127,8 @@ Claude session (OAuth credentials) lives at `/root/.claude/` and persists across **Shared space**: `/shared` is accessible to all agents (read/write). Only put things here you're willing to lose — other agents may delete them. Use for explicit cross-agent communication or shared artifacts when appropriate. + +**Hive knowledge**: `/knowledge` is a read-only bind-mount of the `internal/knowledge` repo on the forge. It contains hive-wide reference documents (conventions, service endpoints, shared runbooks). Read files there for context; to contribute, fork `internal/knowledge` on the forge and open a PR — do not try to write to `/knowledge` directly. **Code forge**: a private Forgejo at `http://localhost:3000` is available when `/agents/{label}/state/forge-token` exists. You have your own user account (named `{label}`). Use `hive-forge` (see below) for all forge operations — issues, PRs, comments, labels, etc. For git operations use plain `git` directly against `http://localhost:3000//.git` (credentials are pre-configured). diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 5165fafe..0174f269 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -94,6 +94,7 @@ pub async fn serve(port: u16, coord: Arc) -> Result<()> { ) .route("/dashboard/stream", get(dashboard_stream)) .route("/dashboard/history", get(dashboard_history)) + .route("/webhook/knowledge", post(post_webhook_knowledge)) // Anything not matched by the dynamic routes above falls // through to the bundled dashboard dist (GET / → // dist/index.html, /favicon.svg → dist/favicon.svg, @@ -2969,3 +2970,57 @@ async fn get_approval_diff( fn plain_text(body: String) -> Response { (StatusCode::OK, body).into_response() } + +/// Minimal Forgejo push-webhook payload — only the fields we care about. +#[derive(Deserialize)] +struct PushWebhookPayload { + #[serde(rename = "ref")] + git_ref: Option, + repository: Option, +} + +#[derive(Deserialize)] +struct PushWebhookRepo { + full_name: Option, +} + +/// POST `/webhook/knowledge` — Forgejo push webhook for +/// `internal/hive-knowledge`. Runs `git pull` on the local clone so +/// agents see up-to-date documents on their next turn. +/// +/// Expected Forgejo webhook configuration: +/// - URL: `http://127.0.0.1:/webhook/knowledge` +/// - Event: "Push" (fires on merge commits to main as well) +/// +/// No signature verification for now; the endpoint is loopback-only +/// and only triggers a read-only `git pull` on an operator-curated repo. +async fn post_webhook_knowledge( + axum::extract::Json(payload): axum::extract::Json, +) -> Response { + let expected_repo = format!( + "{}/{}", + crate::knowledge::ORG, + crate::knowledge::REPO + ); + let full_name = payload + .repository + .as_ref() + .and_then(|r| r.full_name.as_deref()) + .unwrap_or(""); + if full_name != expected_repo { + tracing::debug!(full_name, "webhook/knowledge: ignoring push from unexpected repo"); + return (StatusCode::OK, "ignored").into_response(); + } + let git_ref = payload.git_ref.as_deref().unwrap_or(""); + if git_ref != "refs/heads/main" { + tracing::debug!(git_ref, "webhook/knowledge: ignoring non-main push"); + return (StatusCode::OK, "ignored").into_response(); + } + tracing::info!("webhook/knowledge: pull triggered by push to {expected_repo}"); + tokio::spawn(async { + if let Err(e) = crate::knowledge::pull().await { + tracing::warn!(error = ?e, "webhook/knowledge: pull failed"); + } + }); + (StatusCode::OK, "ok").into_response() +} diff --git a/hive-c0re/src/forge.rs b/hive-c0re/src/forge.rs index 48a11b9f..287be933 100644 --- a/hive-c0re/src/forge.rs +++ b/hive-c0re/src/forge.rs @@ -55,6 +55,11 @@ const SHARED_ORG: &str = "internal"; /// The shared docs repo inside `SHARED_ORG`. Cloneable by every agent /// at `{FORGE_HTTP}/internal/docs.git`. const SHARED_DOCS_REPO: &str = "docs"; +/// The hive-wide knowledge repo inside `SHARED_ORG`. Bind-mounted +/// read-only into every container at `/hive-knowledge`. Agents +/// contribute by forking + opening PRs; direct writes to main are +/// not granted. See `hive-c0re/src/knowledge.rs`. +const KNOWLEDGE_REPO: &str = crate::knowledge::REPO; /// Forgejo orgs hive-c0re ensures on startup. The meta repo lives at /// `core/meta` (the `core` user's own namespace — no org needed). const SEEDED_ORGS: &[&str] = &[CONFIG_ORG, SHARED_ORG]; @@ -606,6 +611,34 @@ pub async fn shared_docs_access(name: &str, core_token: &str) -> Result<()> { } } +/// Ensure the `internal/hive-knowledge` repo exists. Called once at +/// startup after `ensure_org(SHARED_ORG)`. Idempotent — `ensure_org_repo` +/// treats 409 as success. +pub async fn ensure_knowledge_repo(core_token: &str) -> Result<()> { + ensure_org_repo(SHARED_ORG, KNOWLEDGE_REPO, core_token).await +} + +/// Grant agent `name` read-only collaborator access to +/// `internal/hive-knowledge`. Agents read documents from the bind-mounted +/// clone and contribute by forking + opening PRs — no write to main. +/// Idempotent: HTTP 204 (already a collaborator) is treated as success. +pub async fn knowledge_access(name: &str, core_token: &str) -> Result<()> { + let url = format!( + "{FORGE_HTTP}/api/v1/repos/{SHARED_ORG}/{KNOWLEDGE_REPO}/collaborators/{name}" + ); + let body = r#"{"permission":"read"}"#; + let status = forge_http(reqwest::Method::PUT, &url, core_token, body).await?; + match status.as_u16() { + 204 => { + tracing::info!(%name, "forge: granted hive-knowledge read access"); + Ok(()) + } + other => anyhow::bail!( + "PUT {SHARED_ORG}/{KNOWLEDGE_REPO}/collaborators/{name} returned HTTP {other}" + ), + } +} + /// Grant agent `name` read-only collaborator access to `core/meta` on /// the forge so the agent can clone/fetch the meta flake. Idempotent: /// HTTP 204 (already a collaborator) is treated as success. @@ -785,6 +818,13 @@ pub async fn sync_agent(name: &str, core_token: Option<&str>) { { tracing::warn!(%name, error = ?e, "forge: shared_docs_access failed"); } + // Grant read-only access to internal/hive-knowledge so the agent can + // read the bind-mounted knowledge repo and fork it to open PRs. + if let Some(token) = core_token + && let Err(e) = knowledge_access(name, token).await + { + tracing::warn!(%name, error = ?e, "forge: knowledge_access failed"); + } } /// Sweep every existing container (manager + sub-agents) and ensure @@ -823,6 +863,14 @@ pub async fn ensure_all() { if let Err(e) = ensure_shared_docs_repo(token).await { tracing::warn!(error = ?e, "forge: ensure_shared_docs_repo failed"); } + // Seed the hive-wide knowledge repo. + if let Err(e) = ensure_knowledge_repo(token).await { + tracing::warn!(error = ?e, "forge: ensure_knowledge_repo failed"); + } + // Clone knowledge repo locally so it can be bind-mounted into agents. + if let Err(e) = crate::knowledge::ensure_local_clone(token).await { + tracing::warn!(error = ?e, "knowledge: ensure_local_clone failed"); + } if let Err(e) = ensure_core_avatar(token).await { tracing::warn!(error = ?e, "forge: ensure_core_avatar failed"); } diff --git a/hive-c0re/src/knowledge.rs b/hive-c0re/src/knowledge.rs new file mode 100644 index 00000000..013ee459 --- /dev/null +++ b/hive-c0re/src/knowledge.rs @@ -0,0 +1,161 @@ +//! Hive-wide knowledge repository management. +//! +//! `internal/hive-knowledge` on the forge is cloned to +//! [`LOCAL_DIR`] and bind-mounted read-only into every agent container +//! at `/hive-knowledge`. Agents read documents from it directly and +//! contribute by forking the repo and opening PRs — they never write +//! to the bind-mounted path inside the container. +//! +//! hive-c0re maintains the local clone. A Forgejo webhook notifies it +//! on push to main so agents always see an up-to-date snapshot. + +use anyhow::{Context, Result}; + +pub const ORG: &str = "internal"; +pub const REPO: &str = "knowledge"; + +/// Host-side path for the local clone. Also referenced from +/// `lifecycle.rs` (bind-mount source) and the dashboard webhook handler. +pub const LOCAL_DIR: &str = "/var/lib/hyperhive/knowledge"; + +/// In-container mount point for the knowledge repo. Consistent, +/// predictable path documented in the agent system prompt. +pub const CONTAINER_MOUNT: &str = "/knowledge"; + +/// Default README pushed to a freshly created `internal/knowledge` repo. +/// Short explanation + empty ToC with an HTML comment instructing contributors +/// to add entries when they create new files. +const README_CONTENT: &str = "\ +# knowledge + +Hive-wide reference documents: conventions, service endpoints, runbooks, and \ +anything that every agent should know but does not belong in an individual \ +agent's system prompt. + +## How to contribute + +1. Fork this repo into your own namespace on the forge. +2. Create a branch, add or update a document. +3. Open a pull request — the operator reviews and merges. +4. Every agent container updates automatically on merge. + +Do **not** push directly to `main` — agents have read-only access. + +## Contents + + +"; + +/// Clone `internal/knowledge` to [`LOCAL_DIR`] if it is not already a git +/// repository. `core_token` authenticates the HTTPS clone so private repos +/// work. Idempotent — skips if `LOCAL_DIR/.git` exists. +/// +/// When the upstream repo is empty (freshly created), seeds it with a +/// README.md before returning so the local clone is always non-empty and +/// agents see a useful starting document. +/// +/// Called once at hive-c0re startup after `forge::ensure_all`. +pub async fn ensure_local_clone(core_token: &str) -> Result<()> { + let git_dir = std::path::Path::new(LOCAL_DIR).join(".git"); + if git_dir.exists() { + tracing::debug!("knowledge: local clone already present at {LOCAL_DIR}"); + return Ok(()); + } + std::fs::create_dir_all(LOCAL_DIR).context("create knowledge local dir")?; + // Embed credentials in the URL — safe for localhost-only forge. + let url = format!("http://core:{core_token}@localhost:3000/{ORG}/{REPO}.git"); + let out = tokio::process::Command::new("git") + .args(["clone", &url, LOCAL_DIR]) + .output() + .await + .context("git clone knowledge repo")?; + if !out.status.success() { + let stderr = String::from_utf8_lossy(&out.stderr).trim().to_owned(); + anyhow::bail!("git clone {ORG}/{REPO} failed: {stderr}"); + } + tracing::info!("knowledge: cloned {ORG}/{REPO} to {LOCAL_DIR}"); + // If the repo is brand new (no commits), seed it with a README. + let head_out = tokio::process::Command::new("git") + .args(["-C", LOCAL_DIR, "rev-parse", "HEAD"]) + .output() + .await + .context("git rev-parse HEAD")?; + if !head_out.status.success() { + seed_readme(core_token).await?; + } + Ok(()) +} + +/// Write the initial README.md, commit, and push to `internal/knowledge`. +/// Called only when the upstream repo is empty. +async fn seed_readme(core_token: &str) -> Result<()> { + let readme = std::path::Path::new(LOCAL_DIR).join("README.md"); + std::fs::write(&readme, README_CONTENT).context("write README.md")?; + // Set a minimal git identity for the seed commit. + for (k, v) in [("user.email", "core@hive"), ("user.name", "hive-c0re")] { + let out = tokio::process::Command::new("git") + .args(["-C", LOCAL_DIR, "config", k, v]) + .output() + .await + .with_context(|| format!("git config {k}"))?; + if !out.status.success() { + let stderr = String::from_utf8_lossy(&out.stderr).trim().to_owned(); + anyhow::bail!("git config {k} failed: {stderr}"); + } + } + for args in [ + vec!["add", "README.md"], + vec!["commit", "-m", "init: seed README"], + ] { + let out = tokio::process::Command::new("git") + .args(["-C", LOCAL_DIR].iter().chain(args.iter())) + .output() + .await + .with_context(|| format!("git {:?}", args))?; + if !out.status.success() { + let stderr = String::from_utf8_lossy(&out.stderr).trim().to_owned(); + anyhow::bail!("git {:?} failed: {stderr}", args); + } + } + let url = format!("http://core:{core_token}@localhost:3000/{ORG}/{REPO}.git"); + let out = tokio::process::Command::new("git") + .args(["-C", LOCAL_DIR, "push", &url, "HEAD:main"]) + .output() + .await + .context("git push knowledge README")?; + if out.status.success() { + tracing::info!("knowledge: seeded README.md and pushed to {ORG}/{REPO}"); + Ok(()) + } else { + let stderr = String::from_utf8_lossy(&out.stderr).trim().to_owned(); + anyhow::bail!("git push {ORG}/{REPO} failed: {stderr}") + } +} + +/// Pull the latest changes in the local clone. Called from the webhook +/// handler on every push to `internal/hive-knowledge` main. Uses +/// `--ff-only` so a force-push to the knowledge repo never wedges the +/// local copy silently; an error triggers a re-clone fallback. +pub async fn pull() -> Result<()> { + // Sanity: if the clone is missing (e.g. storage was wiped), refuse + // to pull and let the caller decide whether to re-clone. + let git_dir = std::path::Path::new(LOCAL_DIR).join(".git"); + if !git_dir.exists() { + anyhow::bail!("knowledge: {LOCAL_DIR}/.git not found — clone first"); + } + let out = tokio::process::Command::new("git") + .args(["-C", LOCAL_DIR, "pull", "--ff-only"]) + .output() + .await + .context("git pull hive-knowledge")?; + if out.status.success() { + let stdout = String::from_utf8_lossy(&out.stdout).trim().to_owned(); + tracing::info!(result = %stdout, "knowledge: pull succeeded"); + Ok(()) + } else { + let stderr = String::from_utf8_lossy(&out.stderr).trim().to_owned(); + anyhow::bail!("git pull {ORG}/{REPO} failed: {stderr}") + } +} diff --git a/hive-c0re/src/lib.rs b/hive-c0re/src/lib.rs index 833cd7c8..4ed8b412 100644 --- a/hive-c0re/src/lib.rs +++ b/hive-c0re/src/lib.rs @@ -32,6 +32,7 @@ pub mod events_vacuum; pub mod flake_check; pub mod forge; pub mod gateway_nginx; +pub mod knowledge; pub mod lifecycle; pub mod limits; pub mod loose_ends; diff --git a/hive-c0re/src/lifecycle.rs b/hive-c0re/src/lifecycle.rs index cd4c0555..247d1950 100644 --- a/hive-c0re/src/lifecycle.rs +++ b/hive-c0re/src/lifecycle.rs @@ -1128,6 +1128,10 @@ async fn set_nspawn_flags( // Ensure /shared directory exists before binding. systemd-nspawn requires the bind source to exist. std::fs::create_dir_all(HOST_SHARED_ROOT) .with_context(|| format!("create {HOST_SHARED_ROOT}"))?; + // Ensure /knowledge dir exists. It may be empty until forge seeds it; + // nspawn refuses to start if the bind source is missing entirely. + std::fs::create_dir_all(crate::knowledge::LOCAL_DIR) + .with_context(|| format!("create {}", crate::knowledge::LOCAL_DIR))?; // Logical agent name — strip the `h-` prefix. // For the manager: `h-ruth` → `ruth`. For sub-agents: `h-iris` → `iris`. @@ -1156,6 +1160,11 @@ async fn set_nspawn_flags( container_path: CONTAINER_SHARED_MOUNT.to_owned(), read_only: false, }, + BindMount { + host_path: crate::knowledge::LOCAL_DIR.to_owned(), + container_path: crate::knowledge::CONTAINER_MOUNT.to_owned(), + read_only: true, + }, ]; // Own state, harness, and config dirs — same for every agent including