use anyhow::Result;
use std::{
future::Future,
path::{Path, PathBuf},
pin::Pin,
};
use tokio::process::Command;
const TRIVIAL_FILENAMES: &[&str] = &[
"Cargo.lock",
"package-lock.json",
"yarn.lock",
"pnpm-lock.yaml",
"poetry.lock",
"Gemfile.lock",
];
const MAX_INLINE_DIFF_BYTES: usize = 60_000;
pub trait HarvestRunner: Send + Sync {
fn run(
&self,
prompt: String,
workspace: PathBuf,
brain_path: PathBuf,
) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
}
pub struct ClaudeHarvestRunner;
impl HarvestRunner for ClaudeHarvestRunner {
fn run(
&self,
prompt: String,
workspace: PathBuf,
brain_path: PathBuf,
) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
Box::pin(async move {
let output = Command::new("claude")
.args(["--dangerously-skip-permissions", "-p", &prompt])
.current_dir(&workspace)
.env("NINOX_BRAIN", &brain_path)
.output()
.await?;
if !output.status.success() {
anyhow::bail!(
"claude -p exited with {}: {}",
output.status,
String::from_utf8_lossy(&output.stderr).trim(),
);
}
Ok(())
})
}
}
async fn detect_default_branch(workspace: &Path) -> String {
let ws = workspace.to_string_lossy();
if let Ok(out) = Command::new("git")
.args(["-C", &ws, "symbolic-ref", "--short", "refs/remotes/origin/HEAD"])
.output()
.await
{
if out.status.success() {
let name = String::from_utf8_lossy(&out.stdout).trim().to_string();
if !name.is_empty() {
return name;
}
}
}
for candidate in ["main", "master"] {
if let Ok(out) = Command::new("git")
.args(["-C", &ws, "rev-parse", "--verify", "--quiet", &format!("refs/heads/{candidate}")])
.output()
.await
{
if out.status.success() {
return candidate.to_string();
}
}
}
"main".to_string()
}
pub async fn compute_nontrivial_diff(workspace: &Path) -> Option<String> {
let default_branch = detect_default_branch(workspace).await;
let range = format!("{default_branch}...HEAD");
let ws = workspace.to_string_lossy();
let names_out = Command::new("git")
.args(["-C", &ws, "diff", "--name-only", &range])
.output()
.await
.ok()?;
if !names_out.status.success() {
return None;
}
let names = String::from_utf8_lossy(&names_out.stdout);
let files: Vec<&str> = names.lines().filter(|l| !l.is_empty()).collect();
if files.is_empty() {
return None;
}
let all_trivial = files.iter().all(|f| {
let base = Path::new(f).file_name().and_then(|n| n.to_str()).unwrap_or(f);
TRIVIAL_FILENAMES.contains(&base)
});
if all_trivial {
return None;
}
let diff_out = Command::new("git")
.args(["-C", &ws, "diff", &range])
.output()
.await
.ok()?;
if !diff_out.status.success() {
return None;
}
let diff = String::from_utf8_lossy(&diff_out.stdout).to_string();
if diff.trim().is_empty() {
return None;
}
Some(diff)
}
pub fn build_harvest_prompt(session_id: &str, diff: &str) -> String {
let diff_body = if diff.len() > MAX_INLINE_DIFF_BYTES {
let shown = String::from_utf8_lossy(&diff.as_bytes()[..MAX_INLINE_DIFF_BYTES]);
format!(
"{shown}\n\n[diff truncated — {} more bytes not shown; the full diff is available via `git diff` in this worktree]",
diff.len() - MAX_INLINE_DIFF_BYTES,
)
} else {
diff.to_string()
};
format!(
r#"You are Ninox's brain-harvest agent, a one-shot background task that just ran after worker session `{session_id}` opened a pull request. Read the diff below and write down anything a future session would otherwise have to rediscover — where something lives, why it's built the way it is, a gotcha, a decision. Skip writing anything if the diff genuinely has nothing worth recording.
The brain is Ninox's persistent, shared knowledge store, already resolved via the `NINOX_BRAIN` environment variable.
Before writing:
1. Run `ninox brain query "<topic>"` for anything the diff touches, to avoid writing a duplicate entry.
2. Write or update Markdown files under the section that fits:
repos/ where repositories live, their purpose, entry points
symbols/ where types, functions, and modules are defined
concepts/ domain terminology and mental models
patterns/ conventions and recurring implementation shapes
decisions/ why something was built a certain way (ADRs)
architecture/ how the system is structured — components, data flows
relationships/ how repos, services, and teams connect
errors/ known failure modes and how to resolve them
3. Each file needs YAML frontmatter (type, name, tags, repos, updated) followed by a Markdown body of facts, not prose. Link related entries with `[[other-entry]]`.
4. Run `ninox brain index` to rebuild the index once you're done writing.
Diff to harvest from:
```diff
{diff_body}
```
"#
)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn init_repo() -> std::path::PathBuf {
let dir = tempdir().unwrap().keep();
let run = |args: &[&str]| {
std::process::Command::new("git")
.args(["-C", dir.to_str().unwrap()])
.args(args)
.output()
.unwrap()
};
run(&["init", "-q", "-b", "main"]);
run(&["config", "user.email", "test@example.com"]);
run(&["config", "user.name", "Test"]);
std::fs::write(dir.join("README.md"), "x").unwrap();
run(&["add", "."]);
run(&["commit", "-q", "-m", "init"]);
dir
}
fn checkout_feature_branch(repo: &std::path::Path, name: &str) {
std::process::Command::new("git")
.args(["-C", repo.to_str().unwrap(), "checkout", "-q", "-b", name])
.output()
.unwrap();
}
fn write_and_commit(repo: &std::path::Path, file: &str, contents: &str, message: &str) {
std::fs::write(repo.join(file), contents).unwrap();
std::process::Command::new("git")
.args(["-C", repo.to_str().unwrap(), "add", file])
.output()
.unwrap();
std::process::Command::new("git")
.args(["-C", repo.to_str().unwrap(), "commit", "-q", "-m", message])
.output()
.unwrap();
}
#[tokio::test]
async fn no_diff_from_default_branch_returns_none() {
let repo = init_repo();
checkout_feature_branch(&repo, "feature-1");
assert!(compute_nontrivial_diff(&repo).await.is_none());
}
#[tokio::test]
async fn nontrivial_diff_is_returned() {
let repo = init_repo();
checkout_feature_branch(&repo, "feature-2");
write_and_commit(&repo, "src.rs", "fn main() {}\n", "add source file");
let diff = compute_nontrivial_diff(&repo).await.expect("non-trivial diff");
assert!(diff.contains("src.rs"));
assert!(diff.contains("fn main()"));
}
#[tokio::test]
async fn lockfile_only_diff_is_skipped() {
let repo = init_repo();
checkout_feature_branch(&repo, "feature-3");
write_and_commit(&repo, "Cargo.lock", "version = 3\n", "bump lockfile");
assert!(
compute_nontrivial_diff(&repo).await.is_none(),
"a diff touching only a lockfile must not trigger a harvest",
);
}
#[tokio::test]
async fn mixed_lockfile_and_source_diff_is_not_skipped() {
let repo = init_repo();
checkout_feature_branch(&repo, "feature-4");
write_and_commit(&repo, "Cargo.lock", "version = 3\n", "bump lockfile");
write_and_commit(&repo, "src.rs", "fn main() {}\n", "add source file");
let diff = compute_nontrivial_diff(&repo).await.expect("non-trivial diff");
assert!(diff.contains("src.rs"));
}
#[test]
fn harvest_prompt_includes_session_and_diff_and_workflow() {
let prompt = build_harvest_prompt("sess-1", "diff --git a/x b/x\n+hello\n");
assert!(prompt.contains("sess-1"));
assert!(prompt.contains("+hello"));
assert!(prompt.contains("ninox brain query"));
assert!(prompt.contains("ninox brain index"));
}
#[test]
fn harvest_prompt_truncates_oversized_diffs() {
let huge_diff = "x".repeat(MAX_INLINE_DIFF_BYTES + 500);
let prompt = build_harvest_prompt("sess-1", &huge_diff);
assert!(prompt.contains("truncated"));
assert!(prompt.len() < huge_diff.len() + 2000);
}
}