feat(memory): missions remember their verdicts, per repository

Until now missions wrote no memory. The chat path records every turn into
the claw's .brain, but a mission's crew is minted per mission, so a brain
keyed by agent would be written once and never read. What persists across
missions is the repository: mission_memory keeps one .brain per repo_id,
writes each judge verdict into it (reason when met, sanitized guidance when
not — the operator reason may quote the acceptance text), and recalls
against the next phase's task text into the brief, under a heading all
three executors carry because it rides on the task.

Recall is BM25 over the keyword index, no embedder; the harness asserts the
brief carries the section once the repo has one judged mission behind it,
and says 'first mission' rather than failing before that. OpenClaw's
flush-before-compaction was the other half of this item and is moot here:
the chat loop has no compaction and already remembers both halves of
every turn.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WZb5A2kfVfjpdwSochkuHz
This commit is contained in:
Omar Sobh
2026-09-20 22:11:55 -05:00
co-authored by Claude Opus 5
parent 76ac3714f1
commit 13f7fb3aff
6 changed files with 311 additions and 4 deletions
+240
View File
@@ -0,0 +1,240 @@
//! Project memory: what past missions on a repository learned.
//!
//! Until 2026-09-20 missions wrote no memory at all. The chat path records
//! every turn into the claw's `.brain`, but a mission's crew is minted per
//! mission (`per-mission-crews`: reuse is OFF by operator decision), so a
//! brain keyed by agent would be written once and never read. What persists
//! across missions is the repository. So the memory is keyed by `repo_id`:
//! one `.brain` per repo, holding the judge's verdicts, recalled by the next
//! mission's task text and placed in its brief.
//!
//! What is remembered is the verdict, not the work: for a met phase the
//! judge's `reason` (what it found), for an unmet one its `guidance` — the
//! agent-facing half, already stripped of acceptance literals by
//! `evaluator::sanitize_guidance`, because a verdict quoted verbatim into
//! the next brief is how the 2026-08-01 Goodhart incident happened.
//!
//! Recall is BM25 over the keyword index (`cm_brain::ClawBrain::recall`);
//! there is no embedder. Measured before anything richer is built: the test
//! is a second mission on the same repo recalling the first's verdict.
use std::path::{Path, PathBuf};
use cm_brain::ClawBrain;
use uuid::Uuid;
/// How many past verdicts a brief carries. Three is enough to say "this was
/// tried" without becoming the prompt.
pub const RECALL_K: usize = 3;
/// The heading the recalled lines go under. Named here because the scorer and
/// the prompt-order tests read it back.
pub const SECTION_HEADING: &str = "# What past missions on this repository learned";
fn brain_path(dir: &Path, repo_id: Uuid) -> PathBuf {
dir.join(format!("repo_{repo_id}.h5"))
}
/// One line of memory from a verdict. Pure, so the shape is testable without
/// a brain file.
pub fn verdict_line(
mission_id: Uuid,
phase_kind: &str,
condition: &str,
verdict: &crate::evaluator::Verdict,
) -> Option<String> {
// A judge that could not be reached has not judged; there is no lesson.
if verdict.error.is_some() {
return None;
}
let outcome = if verdict.met { "MET" } else { "UNMET" };
let finding = if verdict.met {
verdict.reason.trim()
} else {
verdict.guidance.trim()
};
if finding.is_empty() {
return None;
}
let short = mission_id.simple().to_string();
Some(format!(
"{outcome}{phase_kind} phase of mission {} — condition: {} — judge: {}",
&short[..8],
head(condition, 200),
head(finding, 400),
))
}
/// Record a verdict in the repo's brain. Best-effort and loud on failure:
/// memory must never fail a phase, and a brain that silently stopped
/// recording is the kind of thing that stays broken for a month.
pub fn remember_verdict(
repo_id: Uuid,
mission_id: Uuid,
phase_kind: &str,
condition: &str,
verdict: &crate::evaluator::Verdict,
) {
remember_in(
&cm_runtime::brain::brain_dir(),
repo_id,
mission_id,
phase_kind,
condition,
verdict,
)
}
fn remember_in(
dir: &Path,
repo_id: Uuid,
mission_id: Uuid,
phase_kind: &str,
condition: &str,
verdict: &crate::evaluator::Verdict,
) {
let Some(line) = verdict_line(mission_id, phase_kind, condition, verdict) else {
return;
};
let path = brain_path(dir, repo_id);
if let Some(dir) = path.parent() {
let _ = std::fs::create_dir_all(dir);
}
match ClawBrain::open_or_create(&path, &format!("repo_{repo_id}")) {
Ok(mut brain) => {
if let Err(e) = brain.remember("judge", &line, &mission_id.to_string()) {
eprintln!("mission_memory: could not record verdict for repo {repo_id}: {e}");
}
}
Err(e) => eprintln!("mission_memory: could not open brain for repo {repo_id}: {e}"),
}
}
/// The past verdicts most relevant to `query` (the phase's task text).
/// Empty when the repo has no brain yet, which is every repo's first mission.
pub fn recall(repo_id: Uuid, query: &str) -> Vec<String> {
recall_in(&cm_runtime::brain::brain_dir(), repo_id, query)
}
fn recall_in(dir: &Path, repo_id: Uuid, query: &str) -> Vec<String> {
let path = brain_path(dir, repo_id);
if !path.exists() {
return Vec::new();
}
match ClawBrain::open_or_create(&path, &format!("repo_{repo_id}")) {
Ok(brain) => brain
.recall(query, RECALL_K)
.into_iter()
// `remember` stores "role: text"; the role is ours and not a lesson.
.map(|m| m.strip_prefix("judge: ").map(str::to_string).unwrap_or(m))
.collect(),
Err(e) => {
eprintln!("mission_memory: could not open brain for repo {repo_id}: {e}");
Vec::new()
}
}
}
/// The section a brief carries, or nothing when there is nothing to say —
/// an empty heading tells the agent there is history and then shows none.
pub fn section(recalled: &[String]) -> Option<String> {
if recalled.is_empty() {
return None;
}
let mut out = String::from(SECTION_HEADING);
out.push_str(
"\n\nJudge verdicts from earlier missions here, most relevant first. \
They say what was checked and what was found; they are not the task.\n",
);
for line in recalled {
out.push_str("- ");
out.push_str(line);
out.push('\n');
}
Some(out)
}
fn head(s: &str, n: usize) -> String {
match s.char_indices().nth(n) {
Some((i, _)) => format!("{}", &s[..i]),
None => s.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::evaluator::{Usage, Verdict};
fn verdict(met: bool, reason: &str, guidance: &str, error: Option<&str>) -> Verdict {
Verdict {
met,
reason: reason.into(),
guidance: guidance.into(),
model: "m".into(),
error: error.map(str::to_string),
checks: Vec::new(),
independent: true,
usage: Usage::default(),
expectation: None,
}
}
/// Unmet carries the sanitized guidance, never the operator reason —
/// the reason may quote the acceptance text the next mission must earn.
#[test]
fn unmet_remembers_guidance_not_reason() {
let v = verdict(false, "token ZZQX-9 is absent", "the required marker is absent", None);
let line = verdict_line(Uuid::nil(), "coding", "cond", &v).unwrap();
assert!(line.starts_with("UNMET — coding phase"));
assert!(line.contains("the required marker is absent"));
assert!(!line.contains("ZZQX-9"));
}
#[test]
fn met_remembers_what_the_judge_found() {
let v = verdict(true, "MICROVM.md holds both lines", "", None);
let line = verdict_line(Uuid::nil(), "coding", "cond", &v).unwrap();
assert!(line.starts_with("MET — "));
assert!(line.contains("MICROVM.md holds both lines"));
}
/// No judgement, no lesson.
#[test]
fn an_unreachable_judge_leaves_no_memory() {
let v = verdict(false, "could not evaluate", "could not evaluate", Some("429"));
assert!(verdict_line(Uuid::nil(), "coding", "cond", &v).is_none());
}
#[test]
fn section_is_absent_when_nothing_was_recalled() {
assert!(section(&[]).is_none());
let s = section(&["MET — x".into()]).unwrap();
assert!(s.starts_with(SECTION_HEADING));
assert!(s.contains("- MET — x\n"));
}
/// Round trip through a real brain file: what one mission's verdict
/// wrote, a query shaped like the next mission's task recalls.
#[test]
fn a_second_mission_recalls_the_first_verdict() {
let dir = std::env::temp_dir().join(format!("cm-mission-memory-{}", Uuid::now_v7()));
let repo = Uuid::now_v7();
let v = verdict(
true,
"BASELINE.md records 0.689 ns/iter from benches/add_bench.rs",
"",
None,
);
remember_in(&dir, repo, Uuid::now_v7(), "benchmark", "a baseline is recorded", &v);
let got = recall_in(&dir, repo, "record a performance baseline for the hot path");
assert_eq!(got.len(), 1, "{got:?}");
assert!(got[0].starts_with("MET — benchmark phase"), "{}", got[0]);
assert!(!got[0].starts_with("judge: "));
// A repo with no history recalls nothing and creates no file.
let other = Uuid::now_v7();
assert!(recall_in(&dir, other, "anything").is_empty());
assert!(!brain_path(&dir, other).exists());
let _ = std::fs::remove_dir_all(&dir);
}
}