use crate::render::Server;
use anyhow::{Context, Result};
use serde::Deserialize;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Manifest {
pub version: String,
#[serde(default, rename = "entry")]
pub entries: Vec<Entry>,
#[serde(default)]
pub rejected: Vec<Rejected>,
#[serde(skip)]
pub path: Option<PathBuf>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Entry {
pub name: String,
pub kind: Kind,
pub since: String,
pub because: String,
pub remove: String,
#[serde(default)]
pub command: Option<String>,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub measured: Vec<Measured>,
#[serde(default)]
pub instead_of: Vec<Alternative>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Kind {
Mcp,
Hook,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Measured {
pub what: String,
pub value: String,
pub how: String,
pub on: String,
}
pub fn parse_ym(s: &str) -> Option<(u32, u32)> {
let mut parts = s.split(['.', '-']);
let year: u32 = parts.next()?.parse().ok()?;
let month: u32 = parts.next()?.parse().ok()?;
(year >= 2000 && (1..=12).contains(&month)).then_some((year, month))
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Alternative {
pub name: String,
pub why: String,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Rejected {
pub name: String,
pub considered: String,
pub because: String,
}
impl Manifest {
pub fn load_dir(dir: &Path) -> Result<Self> {
let mut newest: Option<((u32, u32), PathBuf, Self)> = None;
for entry in std::fs::read_dir(dir)
.with_context(|| format!("reading {}", dir.display()))?
.flatten()
{
let path = entry.path();
if !path.extension().is_some_and(|x| x == "toml") {
continue;
}
let raw = std::fs::read_to_string(&path)
.with_context(|| format!("reading {}", path.display()))?;
let manifest: Self =
toml::from_str(&raw).with_context(|| format!("parsing {}", path.display()))?;
let Some(version) = parse_ym(&manifest.version) else {
continue;
};
if newest.as_ref().is_none_or(|(best, _, _)| version > *best) {
newest = Some((version, path, manifest));
}
}
let (_, path, mut manifest) = newest.with_context(|| {
format!(
"no usable base manifest in {} — run `omh init`",
dir.display()
)
})?;
if manifest.entries.is_empty() {
anyhow::bail!("{} declares no base-set entries", path.display());
}
manifest.path = Some(path);
Ok(manifest)
}
pub fn source(&self) -> String {
match &self.path {
Some(p) => format!("{} · {}", p.display(), self.version),
None => format!("(unsaved) · {}", self.version),
}
}
pub fn servers(&self) -> BTreeMap<String, Server> {
self.entries
.iter()
.filter(|e| e.kind == Kind::Mcp)
.filter_map(|e| {
Some((
e.name.clone(),
Server {
command: e.command.clone()?,
args: e.args.clone(),
env: BTreeMap::new(),
},
))
})
.collect()
}
pub fn rationale(&self) -> Vec<(&str, &str)> {
self.entries
.iter()
.filter(|e| e.kind == Kind::Mcp)
.map(|e| (e.name.as_str(), e.because.as_str()))
.collect()
}
pub fn entry(&self, name: &str) -> Option<&Entry> {
self.entries.iter().find(|e| e.name == name)
}
pub fn rejection(&self, name: &str) -> Option<&Rejected> {
self.rejected.iter().find(|r| r.name == name)
}
}
pub const GRAPH_CACHE: &str = "/home/agent/.cache/codebase-memory-mcp";
pub const GRAPH_VERSION: &str = "0.9.0";
pub const GRAPH_UI_PORT: u16 = 9749;
pub const GRAPH_UI_INTERNAL: u16 = 9748;
pub const GRAPH_BIN: &str = "codebase-memory-mcp";
pub fn ui_container(repo: &str) -> String {
format!("omh-graph-{repo}")
}
pub fn ui_port(container: &str) -> u16 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
container.hash(&mut h);
"graph-ui".hash(&mut h);
const LOW: u32 = 49152;
(LOW + (h.finish() % (65535 - LOW) as u64) as u32) as u16
}
pub fn graph_install() -> String {
format!(
"set -eu; \
ARCH=${{TARGETARCH:-$(dpkg --print-architecture)}}; \
A=codebase-memory-mcp-ui-linux-$ARCH-portable.tar.gz; \
B=https://github.com/DeusData/codebase-memory-mcp/releases/download/v{GRAPH_VERSION}; \
cd /tmp && curl -sSLO \"$B/$A\" && curl -sSLO \"$B/checksums.txt\" && \
grep \" $A$\" checksums.txt | sha256sum -c - && \
tar xzf \"$A\" && \
install -m 0755 \"$(find /tmp -maxdepth 2 -name {GRAPH_BIN} -type f | head -1)\" \
/usr/local/bin/{GRAPH_BIN} && \
rm -rf /tmp/*"
)
}
pub fn ui_command(port: u16) -> String {
format!(
"sleep infinity | {GRAPH_BIN} --ui=true --port={GRAPH_UI_INTERNAL} & \
socat TCP-LISTEN:{port},fork,reuseaddr TCP:127.0.0.1:{GRAPH_UI_INTERNAL}"
)
}
pub fn ui_run_args(image: &str, container: &str, cache_volume: &str, port: u16) -> Vec<String> {
vec![
"run".into(),
"-d".into(),
"--name".into(),
container.into(),
"-p".into(),
format!("127.0.0.1:{port}:{GRAPH_UI_PORT}"),
"-v".into(),
format!("{cache_volume}:{GRAPH_CACHE}"),
image.into(),
"sh".into(),
"-c".into(),
ui_command(GRAPH_UI_PORT),
]
}
pub fn drop_graph_command(project: &str) -> Vec<String> {
vec![
"sh".into(),
"-c".into(),
format!("{GRAPH_BIN} cli delete_project --project '{project}' >/dev/null 2>&1 || true"),
]
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Hook {
pub name: &'static str,
pub event: &'static str,
pub matcher: &'static str,
pub command: String,
}
pub const PROJECT_ENV: &str = "OMH_GRAPH_PROJECT";
pub fn project_name(repo: &str, session: &str) -> String {
format!("{repo}-{session}")
}
const GREP_NUDGE: [&str; 3] = [
"This repo has a code graph: project ",
". For structural questions — where is X defined, what calls Y, what does \
this module depend on — search_graph --project ",
" answers in one call. Grep is right for literal text.",
];
#[cfg(test)]
pub fn grep_nudge(project: &str) -> String {
format!(
"{}{project}{}{project}{}",
GREP_NUDGE[0], GREP_NUDGE[1], GREP_NUDGE[2]
)
}
fn shell_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', r"'\''"))
}
pub fn hooks() -> Vec<Hook> {
let nudge = |body: &str| {
format!(
"jq -nc --arg p \"${PROJECT_ENV}\" '{{\"hookSpecificOutput\":{{\
\"hookEventName\":\"PreToolUse\",\"additionalContext\":{body}}}}}'"
)
};
vec![
Hook {
name: "graph-refresh",
event: "Stop",
matcher: "",
command: format!(
"{GRAPH_BIN} cli index_repository --repo-path /work \
--name \"${PROJECT_ENV}\" --mode fast >/dev/null 2>&1 || true"
),
},
Hook {
name: "graph-orient",
event: "SessionStart",
matcher: "",
command: format!(
"a=$({GRAPH_BIN} cli get_architecture --project \"${PROJECT_ENV}\" \
--aspects layers --aspects packages --aspects boundaries \
--aspects entry_points 2>/dev/null | tail -1); \
[ -n \"$a\" ] || exit 0; \
jq -nc --arg a \"$a\" --arg p \"${PROJECT_ENV}\" \
'{{\"hookSpecificOutput\":{{\"hookEventName\":\"SessionStart\",\
\"additionalContext\":(\"Code graph for project \" + $p + \
\" — modules, layers, boundaries and entry points. Query it with \
search_graph/trace_path/get_code_snippet rather than exploring by \
hand:\\n\" + $a)}}}}'"
),
},
Hook {
name: "git-unavailable",
event: "PreToolUse",
matcher: "Bash",
command: format!(
"c=$(jq -r '.tool_input.command // empty'); \
case \"$c\" in \
git\\ *|git) ;; \
*[\\;\\&\\|\\(]*git\\ *|*[[:blank:]]git\\ *) ;; \
*) case \"$c\" in *\"\
\"git\\ *) ;; *) exit 0 ;; esac ;; esac; \
jq -nc --arg m {} '{{\"hookSpecificOutput\":{{\"hookEventName\":\"PreToolUse\",\
\"additionalContext\":$m}}}}'",
shell_quote(crate::detect::GIT_ABSENT)
),
},
Hook {
name: "graph-first",
event: "PreToolUse",
matcher: "Grep|Glob",
command: nudge(&format!(
r#"("{}" + $p + "{}" + $p + "{}")"#,
GREP_NUDGE[0], GREP_NUDGE[1], GREP_NUDGE[2]
)),
},
Hook {
name: "graph-read",
event: "PreToolUse",
matcher: "Read",
command: format!(
"f=$(jq -r '.tool_input.file_path // empty'); \
case \"$f\" in \
*.rs|*.ts|*.tsx|*.js|*.jsx|*.py|*.go|*.java|*.rb|*.php|*.c|*.h|*.cc|\
*.cpp|*.hpp|*.cs|*.swift|*.kt|*.scala) ;; *) exit 0 ;; esac; \
[ -f \"$f\" ] || exit 0; \
[ \"$(wc -c < \"$f\")\" -gt 8000 ] || exit 0; \
jq -nc --arg p \"${PROJECT_ENV}\" --arg f \"$f\" \
'{{\"hookSpecificOutput\":{{\"hookEventName\":\"PreToolUse\",\
\"additionalContext\":($f + \" is large. For one symbol rather than the \
whole file: get_code_snippet --project \" + $p + \" --qualified-name \
<name>, and search_graph finds the name.\")}}}}'"
),
},
]
}
pub fn index_args(
image: &str,
cache_volume: &str,
repo: &std::path::Path,
name: &str,
) -> Vec<String> {
vec![
"run".into(),
"--rm".into(),
"-v".into(),
format!("{}:/work:ro", repo.display()),
"-v".into(),
format!("{cache_volume}:{GRAPH_CACHE}"),
"-w".into(),
"/work".into(),
image.into(),
GRAPH_BIN.into(),
"cli".into(),
"index_repository".into(),
"--repo-path".into(),
"/work".into(),
"--name".into(),
name.into(),
"--mode".into(),
"fast".into(),
]
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeSet;
use std::path::Path;
const BUNDLED: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/base");
const FIRST_COMMIT: (u32, u32, u32) = (2026, 8, 5);
fn shipped() -> Manifest {
Manifest::load_dir(Path::new(BUNDLED)).expect("bundled base manifest")
}
#[test]
fn every_base_set_entry_states_its_case() {
let manifest = shipped();
assert!(
!manifest.entries.is_empty(),
"a base set with no entries is not a distribution"
);
for e in &manifest.entries {
assert!(!e.because.trim().is_empty(), "{}: no `because`", e.name);
assert!(
!e.remove.trim().is_empty(),
"{}: no way to remove it",
e.name
);
assert!(
!e.instead_of.is_empty(),
"{}: nothing recorded as considered-instead. An entry with no \
alternatives was not chosen, it was defaulted to.",
e.name
);
assert!(
!e.measured.is_empty(),
"{}: no measured cost. Benefit is argued here, but cost is the \
half that must be measured — it is what creeps.",
e.name
);
assert!(!e.since.trim().is_empty(), "{}: no `since`", e.name);
for m in &e.measured {
for (field, value) in [
("what", &m.what),
("value", &m.value),
("how", &m.how),
("on", &m.on),
] {
assert!(
!value.trim().is_empty(),
"{}: measured `{field}` is blank",
e.name
);
}
parse_ym(&m.on).unwrap_or_else(|| panic!("{}: `{}` is not a date", e.name, m.on));
let day: Vec<u32> = m.on.split('-').filter_map(|p| p.parse().ok()).collect();
assert_eq!(day.len(), 3, "{}: `{}` needs YYYY-MM-DD", e.name, m.on);
assert!(
(day[0], day[1], day[2]) >= FIRST_COMMIT,
"{}: measured {} predates this repository ({}-{:02}-{:02})",
e.name,
m.on,
FIRST_COMMIT.0,
FIRST_COMMIT.1,
FIRST_COMMIT.2
);
}
}
}
#[test]
fn the_grep_nudges_declared_cost_matches_the_string_it_ships() {
let project = project_name("ohmyharness", "s01");
let actual = grep_nudge(&project).len();
let entry = shipped()
.entry("graph-first")
.expect("graph-first in the manifest")
.measured[0]
.value
.clone();
let declared: usize = entry
.trim_end_matches(" B")
.replace(',', "")
.trim()
.parse()
.unwrap_or_else(|_| panic!("graph-first cost `{entry}` is not a byte count"));
assert_eq!(
declared, actual,
"the manifest claims {declared} B; the nudge it ships is {actual} B for project \
`{project}`. Re-measure rather than adjusting the string to fit."
);
}
fn manifest_dir(files: &[(&str, &str)]) -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
for (name, body) in files {
std::fs::write(dir.path().join(name), body).unwrap();
}
dir
}
const ONE_ENTRY: &str = r#"
[[entry]]
name = "codegraph"
kind = "mcp"
since = "2026.06"
because = "b"
remove = "r"
command = "c"
"#;
#[test]
fn a_stray_toml_cannot_become_the_base_set() {
let dir = manifest_dir(&[
("2026.08.toml", &format!("version = \"2026.08\"{ONE_ENTRY}")),
("zz-notes.toml", "version = \"notes\"\n"),
]);
let m = Manifest::load_dir(dir.path()).unwrap();
assert_eq!(m.version, "2026.08");
assert_eq!(m.servers().len(), 1, "the real manifest must win");
}
#[test]
fn versions_are_compared_numerically_not_lexicographically() {
let dir = manifest_dir(&[
("z.toml", &format!("version = \"2027.2\"{ONE_ENTRY}")),
("a.toml", &format!("version = \"2027.10\"{ONE_ENTRY}")),
]);
assert_eq!(Manifest::load_dir(dir.path()).unwrap().version, "2027.10");
}
#[test]
fn a_manifest_naming_nothing_is_an_error_not_an_empty_base_set() {
let dir = manifest_dir(&[("2026.08.toml", "version = \"2026.08\"\n")]);
let err = Manifest::load_dir(dir.path()).unwrap_err().to_string();
assert!(err.contains("no base-set entries"), "got: {err}");
}
#[test]
fn an_empty_directory_says_what_to_do() {
let dir = manifest_dir(&[]);
let err = Manifest::load_dir(dir.path()).unwrap_err().to_string();
assert!(err.contains("omh init"), "got: {err}");
}
#[test]
fn a_loaded_manifest_knows_where_it_came_from() {
let dir = manifest_dir(&[("2026.08.toml", &format!("version = \"2026.08\"{ONE_ENTRY}"))]);
let source = Manifest::load_dir(dir.path()).unwrap().source();
assert!(source.contains("2026.08.toml"), "got: {source}");
assert!(source.contains("2026.08"), "got: {source}");
}
#[test]
fn rejections_say_why_they_were_rejected() {
for r in &shipped().rejected {
assert!(
!r.because.trim().is_empty(),
"{}: rejected with no reason",
r.name
);
}
}
#[test]
fn the_manifest_and_the_code_describe_the_same_base_set() {
let manifest = shipped();
let declared: BTreeSet<&str> = manifest
.entries
.iter()
.filter(|e| e.kind == Kind::Hook)
.map(|e| e.name.as_str())
.collect();
let shipped_hooks: BTreeSet<&str> = hooks().iter().map(|h| h.name).collect();
assert_eq!(
declared, shipped_hooks,
"hooks in the manifest vs hooks in the code"
);
}
#[test]
fn an_mcp_entry_without_a_command_is_not_silently_dropped() {
let manifest = shipped();
let declared = manifest
.entries
.iter()
.filter(|e| e.kind == Kind::Mcp)
.count();
assert_eq!(
declared,
manifest.servers().len(),
"an mcp entry is missing its `command` and would seed nothing"
);
}
#[test]
fn the_document_init_seeds_actually_contains_the_base_set() {
let manifest = shipped();
let body =
serde_json::to_string_pretty(&serde_json::json!({ "mcpServers": manifest.servers() }))
.unwrap();
let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
let servers = parsed["mcpServers"]
.as_object()
.expect("an mcpServers object");
assert!(!servers.is_empty(), "init would seed an empty base set");
assert_eq!(servers["codegraph"]["command"], GRAPH_BIN);
}
#[test]
fn the_base_set_ships_a_code_graph() {
let s = shipped().servers();
assert!(
s.contains_key("codegraph"),
"got: {:?}",
s.keys().collect::<Vec<_>>()
);
assert_eq!(s["codegraph"].command, GRAPH_BIN);
}
#[test]
fn the_memory_server_is_pointed_at_the_directories_omh_mounts() {
let servers = shipped().servers();
let memory = servers
.get(crate::memory::tools::SERVER_KEY)
.expect("the base set must declare the memory server");
assert!(
memory
.args
.iter()
.any(|a| a == crate::memory::GUEST_LOCAL_NOTES),
"the local store is mounted at {}, args say {:?}",
crate::memory::GUEST_LOCAL_NOTES,
memory.args
);
assert!(
memory.args.iter().any(|a| a == "/work/.omh/notes"),
"the team store lives in the checkout: {:?}",
memory.args
);
assert!(
!memory.args.iter().any(|a| a.contains("--session")),
"a session baked into the base set would be wrong for every other one"
);
}
#[test]
fn the_memory_surfaces_declared_cost_matches_what_it_ships() {
let mut server = crate::memory::tools::Server {
team: std::path::PathBuf::from("/nonexistent-team"),
local: std::path::PathBuf::from("/nonexistent-local"),
templates: crate::memory::shipped_templates(),
session: "s01".into(),
client: None,
today: || "2026-08-08".to_string(),
};
let listed = crate::mcp::Tools::list(&mut server);
let actual: usize = listed
.iter()
.map(|t| {
t.name.len()
+ t.description.len()
+ serde_json::to_string(&t.input_schema).unwrap().len()
})
.sum();
let declared = shipped()
.entries
.iter()
.find(|e| e.name == "memory")
.expect("the memory entry")
.measured
.iter()
.find(|m| m.what.contains("injected"))
.expect("an injected-cost measurement")
.value
.trim_end_matches(" B")
.parse::<usize>()
.expect("a byte count");
assert_eq!(
actual, declared,
"re-measure rather than adjusting the surface to fit"
);
}
#[test]
fn base_servers_reference_nothing_on_the_host() {
for (name, server) in shipped().servers() {
assert!(
!server.command.contains('/'),
"{name}: {} is a host path",
server.command
);
for arg in &server.args {
assert!(
!arg.starts_with("/Users") && !arg.starts_with("/home/")
|| arg.starts_with("/home/agent"),
"{name}: {arg} is not a sandbox path"
);
}
}
}
#[test]
fn every_entry_carries_its_argument() {
let manifest = shipped();
let reasons: BTreeMap<_, _> = manifest.rationale().into_iter().collect();
for name in manifest.servers().keys() {
let why = reasons
.get(name.as_str())
.unwrap_or_else(|| panic!("{name} has no rationale"));
assert!(why.len() > 20, "{name}: `{why}` explains nothing");
}
}
#[test]
fn indexing_runs_inside_the_sandbox_with_the_cache_mounted() {
let args = index_args(
"omh/base:x",
"omh-cache-repo",
Path::new("/host/repo"),
"repo",
);
let joined = args.join(" ");
assert!(
joined.contains("omh-cache-repo:"),
"the cache volume must be mounted: {joined}"
);
assert!(
joined.contains(GRAPH_CACHE),
"at the path the server uses: {joined}"
);
assert!(
joined.contains("/host/repo:"),
"the code must be readable: {joined}"
);
}
#[test]
fn indexing_cannot_write_to_the_checkout() {
let joined = index_args("omh/base:x", "vol", Path::new("/host/repo"), "repo").join(" ");
assert!(joined.contains("/host/repo:/work:ro"), "got: {joined}");
}
#[test]
fn every_session_indexes_into_one_named_project() {
let a = index_args("i", "v", Path::new("/host/repo"), "myrepo").join(" ");
let b = index_args("i", "v", Path::new("/host/worktrees/s01"), "myrepo").join(" ");
assert!(a.contains("--name myrepo") && b.contains("--name myrepo"));
}
#[test]
fn indexing_names_the_repository_it_was_given() {
let joined = index_args("i", "v", Path::new("/host/repo"), "r").join(" ");
assert!(joined.contains("--repo-path /work"), "got: {joined}");
}
#[test]
fn indexing_runs_with_the_repo_as_its_working_directory() {
let args = index_args("i", "v", Path::new("/host/repo"), "r");
assert!(
args.windows(2).any(|w| w[0] == "-w" && w[1] == "/work"),
"the project name depends on cwd: {args:?}"
);
}
#[test]
fn a_sessions_graph_is_its_own() {
assert_ne!(project_name("repo", "s01"), project_name("repo", "s02"));
assert_ne!(project_name("alpha", "s01"), project_name("beta", "s01"));
}
#[test]
fn a_sessions_graph_name_is_stable() {
assert_eq!(project_name("repo", "s01"), project_name("repo", "s01"));
}
fn hook(name: &str) -> Hook {
hooks()
.into_iter()
.find(|h| h.name == name)
.unwrap_or_else(|| panic!("no {name} hook"))
}
#[test]
fn the_graph_refreshes_when_a_turn_ends() {
let h = hook("graph-refresh");
assert_eq!(h.event, "Stop");
assert!(h.command.contains("index_repository"), "got: {}", h.command);
assert!(
h.command.contains("/work"),
"it indexes the session, not the checkout"
);
}
#[test]
fn the_agent_is_pointed_at_the_graph_before_it_greps() {
let h = hook("graph-first");
assert_eq!(h.event, "PreToolUse");
assert!(h.matcher.contains("Grep"), "got: {}", h.matcher);
assert!(
h.command.contains("search_graph"),
"the nudge must name the tool to use: {}",
h.command
);
}
#[test]
fn the_nudge_never_blocks_the_tool() {
let h = hook("graph-first");
for forbidden in ["exit 1", "deny", "block"] {
assert!(
!h.command.contains(forbidden),
"must not block: {}",
h.command
);
}
}
#[test]
fn hooks_that_query_the_graph_name_their_project_through_the_environment() {
let querying: Vec<_> = hooks()
.into_iter()
.filter(|h| h.command.contains(GRAPH_BIN))
.collect();
assert!(
!querying.is_empty(),
"the filter must still match something"
);
for h in querying {
assert!(
h.command.contains(PROJECT_ENV),
"{} must name its project: {}",
h.name,
h.command
);
}
}
#[test]
fn the_nudge_names_the_project_to_query() {
let h = hook("graph-first");
assert!(h.command.contains(PROJECT_ENV), "got: {}", h.command);
}
#[test]
fn the_git_notice_fires_on_the_call_that_would_fail() {
let h = hook("git-unavailable");
assert_eq!(h.event, "PreToolUse");
assert_eq!(h.matcher, "Bash", "git arrives as a shell command");
assert!(
h.command.contains("git init"),
"the repair it would otherwise reach for has to be named: {}",
h.command
);
}
#[test]
fn every_hook_command_is_valid_shell() {
for h in hooks() {
let out = std::process::Command::new("sh")
.args(["-n", "-c", &h.command])
.output()
.expect("sh must run");
assert!(
out.status.success(),
"{} is not parseable by sh: {}\n{}",
h.name,
String::from_utf8_lossy(&out.stderr),
h.command
);
}
}
#[test]
fn every_hook_runs_quietly_when_its_tool_says_nothing() {
let stub = tempfile::tempdir().unwrap();
for name in [GRAPH_BIN, "codebase-memory-mcp"] {
let at = stub.path().join(name);
std::fs::write(&at, "#!/bin/sh\nexit 0\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&at, std::fs::Permissions::from_mode(0o755)).unwrap();
}
}
let path = format!(
"{}:{}",
stub.path().display(),
std::env::var("PATH").unwrap_or_default()
);
for h in hooks() {
let out = std::process::Command::new("sh")
.arg("-c")
.arg(&h.command)
.env("PATH", &path)
.env(PROJECT_ENV, "repo-s01")
.stdin(std::process::Stdio::null())
.output()
.expect("sh must run");
assert!(
out.status.success(),
"{} exited {:?}: {}",
h.name,
out.status.code(),
String::from_utf8_lossy(&out.stderr)
);
assert!(
out.stderr.is_empty(),
"{} wrote to stderr, which the harness shows the user: {}",
h.name,
String::from_utf8_lossy(&out.stderr)
);
}
}
fn fire_hook(command: &str) -> String {
use std::io::Write;
let mut child = std::process::Command::new("sh")
.arg("-c")
.arg(&hook("git-unavailable").command)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.expect("sh must run");
let payload = serde_json::json!({ "tool_input": { "command": command } });
child
.stdin
.take()
.unwrap()
.write_all(payload.to_string().as_bytes())
.unwrap();
let out = child.wait_with_output().unwrap();
assert!(
out.stderr.is_empty(),
"the hook must not write to stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8(out.stdout).unwrap()
}
#[test]
fn the_git_notice_reaches_the_agent_verbatim() {
let fired = fire_hook("git status");
let parsed: serde_json::Value =
serde_json::from_str(&fired).unwrap_or_else(|e| panic!("not JSON: {fired} ({e})"));
assert_eq!(
parsed["hookSpecificOutput"]["additionalContext"]
.as_str()
.unwrap(),
crate::detect::GIT_ABSENT,
"the prose has to survive shell quoting intact"
);
}
#[test]
fn the_git_notice_matches_git_wherever_a_command_can_start() {
for command in [
"git status",
"cd /work && git status",
"cd /work; git init",
"cd /work\ngit status",
" git status",
"echo hi | git apply",
] {
assert!(
!fire_hook(command).trim().is_empty(),
"silent on {command:?}, which is a git call"
);
}
}
#[test]
fn the_git_notice_is_silent_on_everything_else() {
for command in ["cargo test", "ls -la", "echo git", "rg digital"] {
assert!(
fire_hook(command).trim().is_empty(),
"fired on {command:?}, which is not a git call"
);
}
}
#[test]
fn the_ui_build_comes_from_the_release_not_npm() {
let cmd = graph_install();
assert!(cmd.contains("-ui-"), "must fetch the UI variant: {cmd}");
assert!(!cmd.contains("npm install"), "npm cannot deliver it: {cmd}");
}
#[test]
fn the_download_is_checksum_verified() {
let cmd = graph_install();
assert!(cmd.contains("checksums.txt"), "got: {cmd}");
assert!(cmd.contains("sha256sum -c"), "got: {cmd}");
}
#[test]
fn the_download_follows_the_build_architecture() {
let cmd = graph_install();
assert!(
cmd.contains("TARGETARCH") || cmd.contains("dpkg --print-architecture"),
"arch must be derived: {cmd}"
);
}
#[test]
fn serving_the_ui_holds_stdin_open() {
let cmd = ui_command(GRAPH_UI_PORT);
assert!(
cmd.contains("sleep infinity |"),
"stdin must stay open: {cmd}"
);
assert!(cmd.contains("--ui=true"), "got: {cmd}");
}
#[test]
fn the_ui_is_bridged_onto_an_interface_the_host_can_reach() {
let cmd = ui_command(GRAPH_UI_PORT);
assert!(cmd.contains("socat"), "got: {cmd}");
assert!(
cmd.contains(&format!("TCP-LISTEN:{GRAPH_UI_PORT}")),
"must listen where docker publishes: {cmd}"
);
assert!(
cmd.contains(&format!("TCP:127.0.0.1:{GRAPH_UI_INTERNAL}")),
"and forward to where the server binds: {cmd}"
);
}
#[test]
fn removing_a_session_drops_its_graph() {
let cmd = drop_graph_command("ohmyharness-s02").join(" ");
assert!(cmd.contains("delete_project"), "got: {cmd}");
assert!(cmd.contains("ohmyharness-s02"), "got: {cmd}");
}
#[test]
fn dropping_a_graph_that_is_not_there_is_forgiving() {
let cmd = drop_graph_command("nope").join(" ");
assert!(cmd.contains("|| true"), "got: {cmd}");
}
#[test]
fn the_ui_is_named_for_the_repo_not_a_session() {
let c = ui_container("ohmyharness");
assert!(c.contains("ohmyharness"));
assert!(!c.contains("s01"), "not session-scoped: {c}");
assert_eq!(c, ui_container("ohmyharness"), "and stable");
}
#[test]
fn the_ui_container_mounts_only_the_index() {
let args = ui_run_args("omh/base:x", "omh-graph-r", "omh-cache-r", 50000);
let mounts: Vec<&String> = args
.iter()
.skip_while(|a| *a != "-v")
.step_by(2)
.skip(1)
.take(1)
.collect();
assert_eq!(mounts.len(), 1, "exactly one mount: {args:?}");
let joined = args.join(" ");
assert!(joined.contains("omh-cache-r"), "the index: {joined}");
assert!(!joined.contains("/work"), "no worktree: {joined}");
assert!(!joined.contains(".claude"), "no credentials: {joined}");
}
#[test]
fn the_ui_container_publishes_on_loopback_only() {
let joined = ui_run_args("i", "c", "v", 50000).join(" ");
assert!(joined.contains("127.0.0.1:50000:"), "got: {joined}");
assert!(!joined.contains("0.0.0.0"), "got: {joined}");
}
#[test]
fn the_ui_runs_detached_under_its_own_name() {
let args = ui_run_args("i", "omh-graph-r", "v", 1);
assert!(args.contains(&"-d".to_string()), "got: {args:?}");
assert!(args
.windows(2)
.any(|w| w[0] == "--name" && w[1] == "omh-graph-r"));
}
#[test]
fn nudges_speak_through_additional_context() {
for h in hooks() {
if h.event == "Stop" {
continue; }
assert!(
h.command.contains("additionalContext"),
"{}: {}",
h.name,
h.command
);
assert!(
h.command.contains("hookSpecificOutput"),
"{}: {}",
h.name,
h.command
);
}
}
#[test]
fn reading_a_file_points_at_the_symbol_lookup() {
let h = hook("graph-read");
assert_eq!(h.event, "PreToolUse");
assert_eq!(h.matcher, "Read");
assert!(h.command.contains("get_code_snippet"), "got: {}", h.command);
}
#[test]
fn the_read_nudge_stays_silent_when_it_has_nothing_to_say() {
let cmd = hook("graph-read").command.clone();
assert!(cmd.contains("file_path"), "must inspect the target: {cmd}");
assert!(cmd.contains("wc -c"), "and its size: {cmd}");
}
#[test]
fn a_session_starts_with_the_module_map() {
let h = hook("graph-orient");
assert_eq!(h.event, "SessionStart");
assert!(h.command.contains("get_architecture"), "got: {}", h.command);
}
#[test]
fn orientation_is_kept_small_because_it_repeats() {
let cmd = hook("graph-orient").command.clone();
assert!(
!cmd.contains("overview"),
"too broad for something that repeats: {cmd}"
);
for aspect in ["layers", "packages", "boundaries", "entry_points"] {
assert!(cmd.contains(aspect), "missing {aspect}: {cmd}");
}
}
#[test]
fn aspects_are_passed_as_repeated_flags() {
let cmd = hook("graph-orient").command.clone();
assert!(
!cmd.contains("layers,packages"),
"comma form returns empty: {cmd}"
);
assert_eq!(cmd.matches("--aspects").count(), 4, "got: {cmd}");
}
}