872 lines
30 KiB
Rust
872 lines
30 KiB
Rust
//! Failure lessons: capture, normalise, match, materialise.
|
|||
|
|
//!
|
||
|
|
//! Follows Claude Code's file conventions on purpose. Lessons materialise as
|
||
|
|
//! `SKILL.md` files and a `CLAUDE.md` fragment, so the filesystem is the API and
|
||
|
|
//! no client integration is required -- Claude Code, pi and anything else that
|
||
|
|
//! reads those conventions get the memory for free.
|
||
|
|
//!
|
||
|
|
//! What we add over hand-written CLAUDE.md is authorship: lessons are captured
|
||
|
|
//! from real failures and their observed resolutions, and they carry provenance.
|
||
|
|
|
||
|
|
use serde::{Deserialize, Serialize};
|
||
|
|
use sha2::{Digest, Sha256};
|
||
|
|
use std::collections::HashSet;
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Events -- the authoritative append-only record
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
/// One observed command execution. Appended to the JSONL log, never mutated.
|
||
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
|
|
pub struct Event {
|
||
|
|
pub ts: String,
|
||
|
|
pub cwd: String,
|
||
|
|
pub cmd: String,
|
||
|
|
pub exit: i32,
|
||
|
|
/// Tail of combined output. Capped at capture time.
|
||
|
|
pub output: String,
|
||
|
|
}
|
||
|
|
|
||
|
|
impl Event {
|
||
|
|
/// Commands are compared after dropping volatile arguments, so that
|
||
|
|
/// `kubectl apply -f /tmp/abc123.yaml` pairs with a later retry.
|
||
|
|
pub fn cmd_key(&self) -> String {
|
||
|
|
normalise_cmd(&self.cmd)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Normalisation
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
/// Remove ANSI SGR sequences. Coloured output otherwise hashes differently
|
||
|
|
/// depending on whether a TTY was attached.
|
||
|
|
pub fn strip_ansi(s: &str) -> String {
|
||
|
|
let mut out = String::with_capacity(s.len());
|
||
|
|
let mut chars = s.chars().peekable();
|
||
|
|
while let Some(c) = chars.next() {
|
||
|
|
if c == '\u{1b}' {
|
||
|
|
// CSI introducer '[' is itself inside the final-byte range, so it
|
||
|
|
// must be consumed before scanning for the terminator.
|
||
|
|
if chars.peek() == Some(&'[') {
|
||
|
|
chars.next();
|
||
|
|
}
|
||
|
|
// parameter bytes 0x30-0x3f, intermediates 0x20-0x2f, final 0x40-0x7e
|
||
|
|
for c2 in chars.by_ref() {
|
||
|
|
if ('\u{40}'..='\u{7e}').contains(&c2) {
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
out.push(c);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
out
|
||
|
|
}
|
||
|
|
|
||
|
|
fn is_hex_sha(s: &str) -> bool {
|
||
|
|
// Bias against matching: require length and at least two digits, so that
|
||
|
|
// English words made of hex letters ("deadbeef" is rare, "facade" is not)
|
||
|
|
// are left alone. Under-normalising costs a tier-1 miss; over-normalising
|
||
|
|
// costs a confident wrong answer.
|
||
|
|
let n = s.len();
|
||
|
|
if !(7..=40).contains(&n) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
if !s.chars().all(|c| c.is_ascii_hexdigit()) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
s.chars().filter(|c| c.is_ascii_digit()).count() >= 2
|
||
|
|
}
|
||
|
|
|
||
|
|
fn is_timestamp(s: &str) -> bool {
|
||
|
|
let b = s.as_bytes();
|
||
|
|
// ISO-8601-ish: 4 digits, '-', ... with a 'T'
|
||
|
|
if b.len() >= 10
|
||
|
|
&& b[..4].iter().all(|c| c.is_ascii_digit())
|
||
|
|
&& b[4] == b'-'
|
||
|
|
&& s.contains('T')
|
||
|
|
{
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
// bare epoch seconds / millis
|
||
|
|
if (10 == b.len() || 13 == b.len()) && b.iter().all(|c| c.is_ascii_digit()) {
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
false
|
||
|
|
}
|
||
|
|
|
||
|
|
fn is_duration(s: &str) -> bool {
|
||
|
|
let b = s.as_bytes();
|
||
|
|
if b.is_empty() || !b[0].is_ascii_digit() {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
let unit_tail = s.ends_with("ms")
|
||
|
|
|| s.ends_with('s')
|
||
|
|
|| s.ends_with('m')
|
||
|
|
|| s.ends_with('h')
|
||
|
|
|| s.ends_with("\u{b5}s");
|
||
|
|
if !unit_tail {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
s.chars()
|
||
|
|
.all(|c| c.is_ascii_digit() || c == '.' || c.is_ascii_alphabetic())
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Split a trailing `:LINE` or `:LINE:COL` off a token.
|
||
|
|
fn split_line_col(tok: &str) -> (&str, Option<String>) {
|
||
|
|
let parts: Vec<&str> = tok.rsplitn(3, ':').collect();
|
||
|
|
match parts.as_slice() {
|
||
|
|
[c, l, head] if c.chars().all(|x| x.is_ascii_digit())
|
||
|
|
&& l.chars().all(|x| x.is_ascii_digit())
|
||
|
|
&& !c.is_empty()
|
||
|
|
&& !l.is_empty() =>
|
||
|
|
{
|
||
|
|
(head, Some(":<LINE>:<COL>".into()))
|
||
|
|
}
|
||
|
|
[l, head] if l.chars().all(|x| x.is_ascii_digit()) && !l.is_empty() => {
|
||
|
|
(head, Some(":<LINE>".into()))
|
||
|
|
}
|
||
|
|
_ => (tok, None),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
fn normalise_token(tok: &str) -> String {
|
||
|
|
let (core, suffix) = split_line_col(tok);
|
||
|
|
let repl = if core.starts_with("0x") && core.len() > 2 {
|
||
|
|
"<ADDR>".to_string()
|
||
|
|
} else if is_timestamp(core) {
|
||
|
|
"<TS>".to_string()
|
||
|
|
} else if is_duration(core) {
|
||
|
|
"<DUR>".to_string()
|
||
|
|
} else if is_hex_sha(core) {
|
||
|
|
"<SHA>".to_string()
|
||
|
|
} else if core.contains('/') && core.len() > 3 {
|
||
|
|
// Keep the basename: which file failed is meaningful, the workspace
|
||
|
|
// prefix it sat under is not.
|
||
|
|
match core.rsplit_once('/') {
|
||
|
|
Some((_, base)) if !base.is_empty() => format!("<PATH>/{base}"),
|
||
|
|
_ => "<PATH>".to_string(),
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
core.to_string()
|
||
|
|
};
|
||
|
|
match suffix {
|
||
|
|
Some(s) => format!("{repl}{s}"),
|
||
|
|
None => repl,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Reduce a line to a form that is stable across runs of the same failure.
|
||
|
|
///
|
||
|
|
/// Deliberately does NOT touch bare integers: `exit status 1` and
|
||
|
|
/// `exit status 137` must stay distinguishable, or OOM collides with a test
|
||
|
|
/// failure.
|
||
|
|
pub fn normalise(raw: &str) -> String {
|
||
|
|
strip_ansi(raw)
|
||
|
|
.split_whitespace()
|
||
|
|
.map(normalise_token)
|
||
|
|
.collect::<Vec<_>>()
|
||
|
|
.join(" ")
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Normalise a *command line* for identity comparison.
|
||
|
|
///
|
||
|
|
/// Harsher than [`normalise`], which keeps basenames because knowing which file
|
||
|
|
/// failed to compile is meaningful. For a command, the opposite holds: applying
|
||
|
|
/// `np-x7f2.yaml` then `np-a91c.yaml` is the same action on a regenerated temp
|
||
|
|
/// file, and keeping the basename stops the pair from ever being found.
|
||
|
|
pub fn normalise_cmd(cmd: &str) -> String {
|
||
|
|
strip_ansi(cmd)
|
||
|
|
.split_whitespace()
|
||
|
|
.map(|tok| {
|
||
|
|
let (core, _) = split_line_col(tok);
|
||
|
|
if core.contains('/') && core.len() > 3 {
|
||
|
|
"<PATH>".to_string()
|
||
|
|
} else {
|
||
|
|
normalise_token(tok)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
.collect::<Vec<_>>()
|
||
|
|
.join(" ")
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Signature extraction
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||
|
|
pub struct Signature {
|
||
|
|
pub tool: String,
|
||
|
|
/// The original error line, for display.
|
||
|
|
pub raw: String,
|
||
|
|
pub normalised: String,
|
||
|
|
pub sig_sha: String,
|
||
|
|
/// Which rule fired. The debugging surface for the whole tier.
|
||
|
|
pub rule: String,
|
||
|
|
}
|
||
|
|
|
||
|
|
fn markers(tool: &str) -> &'static [&'static str] {
|
||
|
|
match tool {
|
||
|
|
"npm" | "pnpm" | "yarn" => &["npm ERR!", "ERR_", "error "],
|
||
|
|
"cargo" | "rust" => &["error[", "error:", "panicked at"],
|
||
|
|
"go" => &["panic:", "undefined:", "cannot use", "error:"],
|
||
|
|
"kubectl" | "k8s" => &["error:", "Error from server", "Unable to connect"],
|
||
|
|
"github-actions" | "gha" => &["##[error]", "Error:", "error:"],
|
||
|
|
"docker" => &["ERROR:", "failed to", "Error response from daemon"],
|
||
|
|
"terraform" => &["Error:", "\u{2502} Error:"],
|
||
|
|
_ => &[],
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const GENERIC_MARKERS: &[&str] = &[
|
||
|
|
"error:", "Error:", "ERROR", "ERR!", "FAILED", "fatal:", "panic:", "Exception",
|
||
|
|
];
|
||
|
|
|
||
|
|
/// A bare error-code declaration such as `npm ERR! code ERESOLVE`, which
|
||
|
|
/// prefixes the descriptive line rather than replacing it.
|
||
|
|
///
|
||
|
|
/// Found by fixture: some runs emit it and some do not, so anchoring here
|
||
|
|
/// splits one failure into two signatures and dilutes `seen`.
|
||
|
|
fn is_code_declaration(line: &str) -> bool {
|
||
|
|
let t = line.trim();
|
||
|
|
if let Some(idx) = t.find(" code ") {
|
||
|
|
// "<prefix> code <TOKEN>" with nothing after the token
|
||
|
|
let rest = t[idx + 6..].trim();
|
||
|
|
return !rest.is_empty() && !rest.contains(' ');
|
||
|
|
}
|
||
|
|
false
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Lines that are consequences of an earlier failure. Anchoring on these keys
|
||
|
|
/// the lesson to a symptom of a symptom -- and the last line of a GitHub
|
||
|
|
/// Actions log is identical across every failure it has ever produced.
|
||
|
|
fn is_cascade(line: &str) -> bool {
|
||
|
|
const SUPPRESS: &[&str] = &[
|
||
|
|
"##[error]Process completed with exit code",
|
||
|
|
"make: ***",
|
||
|
|
"npm ERR! A complete log of this run",
|
||
|
|
"error: could not compile",
|
||
|
|
"error: build failed",
|
||
|
|
"FAILED (",
|
||
|
|
"Error: Process completed",
|
||
|
|
"exit status",
|
||
|
|
];
|
||
|
|
let t = line.trim();
|
||
|
|
SUPPRESS.iter().any(|s| t.starts_with(s) || t.contains(s))
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Extract the first error that is not a consequence of another.
|
||
|
|
///
|
||
|
|
/// Falls back to the last non-empty line for unknown tools: a worse signature
|
||
|
|
/// is still a signature, and failing because a tool is unrecognised is useless
|
||
|
|
/// in exactly the moment someone needs an answer.
|
||
|
|
pub fn extract(tool: &str, output: &str) -> Option<Signature> {
|
||
|
|
let clean = strip_ansi(output);
|
||
|
|
let lines: Vec<&str> = clean.lines().map(|l| l.trim_end()).collect();
|
||
|
|
|
||
|
|
let tool_markers = markers(tool);
|
||
|
|
let mut found: Option<(String, &'static str)> = None;
|
||
|
|
|
||
|
|
let skip = |l: &str| l.trim().is_empty() || is_cascade(l) || is_code_declaration(l);
|
||
|
|
|
||
|
|
for line in lines.iter() {
|
||
|
|
if skip(line) {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
if tool_markers.iter().any(|m| line.contains(m)) {
|
||
|
|
found = Some((line.trim().to_string(), "tool-rule"));
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if found.is_none() {
|
||
|
|
for line in lines.iter() {
|
||
|
|
if skip(line) {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
if GENERIC_MARKERS.iter().any(|m| line.contains(m)) {
|
||
|
|
found = Some((line.trim().to_string(), "generic-marker"));
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if found.is_none() {
|
||
|
|
let last = lines.iter().rev().find(|l| !l.trim().is_empty())?;
|
||
|
|
found = Some((last.trim().to_string(), "last-line-fallback"));
|
||
|
|
}
|
||
|
|
|
||
|
|
let (raw, rule) = found?;
|
||
|
|
let normalised = normalise(&raw);
|
||
|
|
if normalised.is_empty() {
|
||
|
|
return None;
|
||
|
|
}
|
||
|
|
// Tool is part of identity: `exit status 1` means different things
|
||
|
|
// in different tools.
|
||
|
|
let mut h = Sha256::new();
|
||
|
|
h.update(tool.as_bytes());
|
||
|
|
h.update(b"\n");
|
||
|
|
h.update(normalised.as_bytes());
|
||
|
|
let sig_sha = hex(&h.finalize());
|
||
|
|
|
||
|
|
Some(Signature {
|
||
|
|
tool: tool.to_string(),
|
||
|
|
raw,
|
||
|
|
normalised,
|
||
|
|
sig_sha,
|
||
|
|
rule: rule.to_string(),
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
fn hex(bytes: &[u8]) -> String {
|
||
|
|
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Similarity -- tier 2 without embeddings
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
fn trigrams(s: &str) -> HashSet<[char; 3]> {
|
||
|
|
let cs: Vec<char> = s.to_lowercase().chars().collect();
|
||
|
|
let mut set = HashSet::new();
|
||
|
|
for w in cs.windows(3) {
|
||
|
|
set.insert([w[0], w[1], w[2]]);
|
||
|
|
}
|
||
|
|
set
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Jaccard similarity over character trigrams. Deterministic, no model, no
|
||
|
|
/// index. Good enough to decide whether embeddings are worth adding -- if this
|
||
|
|
/// never misses, the vector store is unjustified.
|
||
|
|
pub fn similarity(a: &str, b: &str) -> f32 {
|
||
|
|
let (ta, tb) = (trigrams(a), trigrams(b));
|
||
|
|
if ta.is_empty() || tb.is_empty() {
|
||
|
|
return 0.0;
|
||
|
|
}
|
||
|
|
let inter = ta.intersection(&tb).count() as f32;
|
||
|
|
let union = ta.union(&tb).count() as f32;
|
||
|
|
inter / union
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Lessons
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||
|
|
#[serde(rename_all = "lowercase")]
|
||
|
|
pub enum Confidence {
|
||
|
|
/// Derived mechanically from a fail -> success pair. Might be coincidence.
|
||
|
|
Inferred,
|
||
|
|
/// A human kept it. Outranks inferred at equal similarity.
|
||
|
|
Confirmed,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
|
|
pub struct Lesson {
|
||
|
|
pub sig_sha: String,
|
||
|
|
pub tool: String,
|
||
|
|
pub raw: String,
|
||
|
|
pub normalised: String,
|
||
|
|
/// Commands observed between the failure and the next success.
|
||
|
|
pub resolution: Vec<String>,
|
||
|
|
pub seen: u32,
|
||
|
|
pub last_seen: String,
|
||
|
|
pub cwd: String,
|
||
|
|
pub confidence: Confidence,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Debug, Clone, PartialEq)]
|
||
|
|
pub enum Tier {
|
||
|
|
/// Exact signature match: this precise failure happened here before.
|
||
|
|
Exact,
|
||
|
|
/// Similar signature: something like it happened.
|
||
|
|
Similar(f32),
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Debug, Clone)]
|
||
|
|
pub struct Hit {
|
||
|
|
pub lesson: Lesson,
|
||
|
|
pub tier: Tier,
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Look a failure up against known lessons.
|
||
|
|
///
|
||
|
|
/// Abstention is a first-class outcome. An agent acts on the top result, so
|
||
|
|
/// a plausible-but-wrong lesson is worse than silence -- it turns a confused
|
||
|
|
/// agent into a confident one going the wrong way.
|
||
|
|
pub fn lookup(sig: &Signature, lessons: &[Lesson], floor: f32) -> Option<Hit> {
|
||
|
|
if let Some(l) = lessons.iter().find(|l| l.sig_sha == sig.sig_sha) {
|
||
|
|
return Some(Hit {
|
||
|
|
lesson: l.clone(),
|
||
|
|
tier: Tier::Exact,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
let mut best: Option<(f32, &Lesson)> = None;
|
||
|
|
for l in lessons.iter().filter(|l| l.tool == sig.tool) {
|
||
|
|
let s = similarity(&sig.normalised, &l.normalised);
|
||
|
|
if s >= floor && best.map_or(true, |(bs, _)| s > bs) {
|
||
|
|
best = Some((s, l));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
best.map(|(s, l)| Hit {
|
||
|
|
lesson: l.clone(),
|
||
|
|
tier: Tier::Similar(s),
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Pair failures with the next success of the same command in the same
|
||
|
|
/// directory. The commands in between are the candidate resolution.
|
||
|
|
///
|
||
|
|
/// Mechanical and self-labelling: no model, no human prompt. Noisy, which is
|
||
|
|
/// why everything it produces is `Inferred`.
|
||
|
|
pub fn derive_lessons(events: &[Event], tool_of: impl Fn(&str) -> String) -> Vec<Lesson> {
|
||
|
|
let mut out: Vec<Lesson> = Vec::new();
|
||
|
|
|
||
|
|
for (i, ev) in events.iter().enumerate() {
|
||
|
|
if ev.exit == 0 {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
let key = ev.cmd_key();
|
||
|
|
// find the next success of the same command in the same cwd
|
||
|
|
let Some(succ_idx) = events
|
||
|
|
.iter()
|
||
|
|
.enumerate()
|
||
|
|
.skip(i + 1)
|
||
|
|
.find(|(_, e)| e.exit == 0 && e.cwd == ev.cwd && e.cmd_key() == key)
|
||
|
|
.map(|(j, _)| j)
|
||
|
|
else {
|
||
|
|
continue;
|
||
|
|
};
|
||
|
|
let resolution: Vec<String> = events[i + 1..succ_idx]
|
||
|
|
.iter()
|
||
|
|
.filter(|e| e.cwd == ev.cwd && e.exit == 0)
|
||
|
|
.map(|e| e.cmd.clone())
|
||
|
|
.filter(|c| !is_opaque_action(c))
|
||
|
|
.collect();
|
||
|
|
if resolution.is_empty() {
|
||
|
|
// Either a bare retry (flaky, not a lesson) or a delta consisting
|
||
|
|
// only of opaque actions, which teaches nothing.
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
let tool = tool_of(&ev.cmd);
|
||
|
|
let Some(sig) = extract(&tool, &ev.output) else {
|
||
|
|
continue;
|
||
|
|
};
|
||
|
|
|
||
|
|
if let Some(existing) = out.iter_mut().find(|l| l.sig_sha == sig.sig_sha) {
|
||
|
|
existing.seen += 1;
|
||
|
|
existing.last_seen = ev.ts.clone();
|
||
|
|
// Prefer the most recent resolution: if the same failure recurred,
|
||
|
|
// whatever was done last is the version that stuck.
|
||
|
|
existing.resolution = resolution;
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
out.push(Lesson {
|
||
|
|
sig_sha: sig.sig_sha,
|
||
|
|
tool,
|
||
|
|
raw: sig.raw,
|
||
|
|
normalised: sig.normalised,
|
||
|
|
resolution,
|
||
|
|
seen: 1,
|
||
|
|
last_seen: ev.ts.clone(),
|
||
|
|
cwd: ev.cwd.clone(),
|
||
|
|
confidence: Confidence::Inferred,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
out
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Commands that record that a human did something, without recording what.
|
||
|
|
///
|
||
|
|
/// `vim package.json` is a true observation and a useless lesson. Filtering
|
||
|
|
/// these is the difference between "someone edited a file" and an actionable
|
||
|
|
/// resolution. A pair whose entire delta is opaque yields no lesson at all --
|
||
|
|
/// abstention again, at write time.
|
||
|
|
fn is_opaque_action(cmd: &str) -> bool {
|
||
|
|
let first = cmd.split_whitespace().next().unwrap_or("");
|
||
|
|
let base = first.rsplit('/').next().unwrap_or(first);
|
||
|
|
matches!(
|
||
|
|
base,
|
||
|
|
"vim" | "vi" | "nvim" | "nano" | "emacs" | "code" | "subl" | "open"
|
||
|
|
| "cd" | "ls" | "cat" | "less" | "tail" | "head" | "pwd" | "echo"
|
||
|
|
| "clear" | "which" | "man"
|
||
|
|
) || cmd.trim() == "git status"
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Guess the tool from a command line.
|
||
|
|
pub fn tool_of_cmd(cmd: &str) -> String {
|
||
|
|
let first = cmd.split_whitespace().next().unwrap_or("");
|
||
|
|
let base = first.rsplit('/').next().unwrap_or(first);
|
||
|
|
match base {
|
||
|
|
"npm" | "pnpm" | "yarn" => "npm".into(),
|
||
|
|
"cargo" => "cargo".into(),
|
||
|
|
"go" => "go".into(),
|
||
|
|
"kubectl" | "k" => "kubectl".into(),
|
||
|
|
"docker" | "podman" => "docker".into(),
|
||
|
|
"terraform" | "tofu" => "terraform".into(),
|
||
|
|
other if other.is_empty() => "unknown".into(),
|
||
|
|
other => other.to_string(),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Materialisation -- Claude Code conventions
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
/// Render lessons for one tool as a SKILL.md.
|
||
|
|
///
|
||
|
|
/// The `description` field is the load-bearing part: it lists the error strings
|
||
|
|
/// this skill explains, so a harness doing progressive disclosure matches on
|
||
|
|
/// symptoms rather than on prose. This is a hand-rule symptom projection --
|
||
|
|
/// the same job M3.7.8 gives an LLM, done for free at materialise time.
|
||
|
|
pub fn render_skill(tool: &str, lessons: &[Lesson]) -> String {
|
||
|
|
let mut triggers: Vec<String> = lessons
|
||
|
|
.iter()
|
||
|
|
.map(|l| {
|
||
|
|
let t = l.raw.trim();
|
||
|
|
let t: String = t.chars().take(90).collect();
|
||
|
|
t.replace('"', "'")
|
||
|
|
})
|
||
|
|
.collect();
|
||
|
|
triggers.sort();
|
||
|
|
triggers.dedup();
|
||
|
|
|
||
|
|
let mut s = String::new();
|
||
|
|
s.push_str("---\n");
|
||
|
|
s.push_str(&format!("name: {tool}-failures\n"));
|
||
|
|
s.push_str("description: >\n");
|
||
|
|
s.push_str(&format!(
|
||
|
|
" Past {tool} failures seen in this workspace and what resolved them.\n"
|
||
|
|
));
|
||
|
|
s.push_str(" Use when a ");
|
||
|
|
s.push_str(tool);
|
||
|
|
s.push_str(" command fails, or when output contains any of:\n");
|
||
|
|
for t in triggers.iter().take(12) {
|
||
|
|
s.push_str(&format!(" \"{t}\";\n"));
|
||
|
|
}
|
||
|
|
s.push_str("---\n\n");
|
||
|
|
s.push_str(&format!("# {tool} failures\n\n"));
|
||
|
|
s.push_str("Generated by `mem materialize`. Edit freely -- edits mark a lesson\n");
|
||
|
|
s.push_str("`confirmed`, which outranks inferred lessons at equal similarity.\n\n");
|
||
|
|
|
||
|
|
let mut sorted: Vec<&Lesson> = lessons.iter().collect();
|
||
|
|
sorted.sort_by(|a, b| b.seen.cmp(&a.seen));
|
||
|
|
|
||
|
|
for l in sorted {
|
||
|
|
s.push_str(&format!("## {}\n\n", l.raw.trim()));
|
||
|
|
s.push_str(&format!(
|
||
|
|
"- seen: {} | last: {} | confidence: {:?}\n",
|
||
|
|
l.seen, l.last_seen, l.confidence
|
||
|
|
));
|
||
|
|
s.push_str(&format!("- signature: `{}`\n", l.sig_sha[..12].to_string()));
|
||
|
|
s.push_str("- resolved by:\n");
|
||
|
|
for r in &l.resolution {
|
||
|
|
s.push_str(&format!(" ```\n {r}\n ```\n"));
|
||
|
|
}
|
||
|
|
if l.seen >= 3 {
|
||
|
|
s.push_str(
|
||
|
|
"- **recurring** -- this has bitten us repeatedly. Prefer fixing the\n root cause over reapplying the workaround.\n",
|
||
|
|
);
|
||
|
|
}
|
||
|
|
s.push('\n');
|
||
|
|
}
|
||
|
|
s
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Render the compact block for injection at failure time.
|
||
|
|
///
|
||
|
|
/// Hard-capped, because every injected token displaces the task. Two hundred
|
||
|
|
/// tokens of "you hit this in July, fix was X" beats a page of adjacent docs.
|
||
|
|
pub fn render_injection(hit: &Hit, max_chars: usize) -> String {
|
||
|
|
let l = &hit.lesson;
|
||
|
|
let header = match hit.tier {
|
||
|
|
Tier::Exact => format!(
|
||
|
|
"MEMORY (exact match, seen {}x, last {}):",
|
||
|
|
l.seen, l.last_seen
|
||
|
|
),
|
||
|
|
Tier::Similar(s) => format!("MEMORY (similar failure, {:.0}% match):", s * 100.0),
|
||
|
|
};
|
||
|
|
let mut s = format!("{header}\n {}\n resolved by:\n", l.raw.trim());
|
||
|
|
for r in &l.resolution {
|
||
|
|
s.push_str(&format!(" {r}\n"));
|
||
|
|
}
|
||
|
|
if l.seen >= 3 {
|
||
|
|
s.push_str(" NOTE: recurring - consider fixing the root cause.\n");
|
||
|
|
}
|
||
|
|
if s.len() > max_chars {
|
||
|
|
s.truncate(max_chars);
|
||
|
|
s.push_str("...\n");
|
||
|
|
}
|
||
|
|
s
|
||
|
|
}
|
||
|
|
|
||
|
|
#[cfg(test)]
|
||
|
|
mod tests {
|
||
|
|
use super::*;
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn strips_ansi() {
|
||
|
|
assert_eq!(strip_ansi("\u{1b}[31merror\u{1b}[0m: x"), "error: x");
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn normalises_volatiles_but_keeps_exit_codes() {
|
||
|
|
let a = normalise("at 2026-08-21T10:02:11.482Z /home/runner/work/o/r/src/main.rs:42:5 took 4m21s sha 9f3ab12c4d");
|
||
|
|
assert!(a.contains("<TS>"), "{a}");
|
||
|
|
assert!(a.contains("<PATH>/main.rs:<LINE>:<COL>"), "{a}");
|
||
|
|
assert!(a.contains("<DUR>"), "{a}");
|
||
|
|
assert!(a.contains("<SHA>"), "{a}");
|
||
|
|
// meaning-bearing numbers survive
|
||
|
|
let b = normalise("exit status 137");
|
||
|
|
assert!(b.contains("137"), "{b}");
|
||
|
|
assert_ne!(normalise("exit status 137"), normalise("exit status 1"));
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn same_failure_different_runs_same_hash() {
|
||
|
|
let run1 = "2026-08-01T10:00:00Z Run 4821\nnpm ERR! ERESOLVE unable to resolve dependency tree\nnpm ERR! A complete log of this run can be found in: /home/runner/.npm/_logs/x.log\n##[error]Process completed with exit code 1";
|
||
|
|
let run2 = "2026-09-14T22:31:07Z Run 9903\nnpm ERR! ERESOLVE unable to resolve dependency tree\nnpm ERR! A complete log of this run can be found in: /Users/rock/.npm/_logs/y.log\n##[error]Process completed with exit code 1";
|
||
|
|
let a = extract("npm", run1).unwrap();
|
||
|
|
let b = extract("npm", run2).unwrap();
|
||
|
|
assert_eq!(a.sig_sha, b.sig_sha);
|
||
|
|
assert_eq!(a.rule, "tool-rule");
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn different_failures_differ() {
|
||
|
|
let a = extract("npm", "npm ERR! ERESOLVE unable to resolve dependency tree").unwrap();
|
||
|
|
let b = extract("npm", "npm ERR! 404 Not Found - GET https://registry.npmjs.org/nope").unwrap();
|
||
|
|
assert_ne!(a.sig_sha, b.sig_sha);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn cascade_lines_are_skipped() {
|
||
|
|
let log = "##[error]Process completed with exit code 1\nerror: could not compile `foo`\nerror[E0308]: mismatched types";
|
||
|
|
let s = extract("cargo", log).unwrap();
|
||
|
|
assert!(s.raw.contains("E0308"), "picked cascade line: {}", s.raw);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn code_declaration_does_not_split_a_failure() {
|
||
|
|
// Found by fixture: run A emits the `code` line, run C does not.
|
||
|
|
let with = "npm ERR! code ERESOLVE\nnpm ERR! ERESOLVE unable to resolve dependency tree";
|
||
|
|
let without = "npm ERR! ERESOLVE unable to resolve dependency tree";
|
||
|
|
assert_eq!(
|
||
|
|
extract("npm", with).unwrap().sig_sha,
|
||
|
|
extract("npm", without).unwrap().sig_sha
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn cmd_key_ignores_temp_file_names() {
|
||
|
|
let a = Event {
|
||
|
|
ts: "t".into(),
|
||
|
|
cwd: "/w".into(),
|
||
|
|
cmd: "kubectl apply -f /tmp/np-x7f2.yaml".into(),
|
||
|
|
exit: 1,
|
||
|
|
output: String::new(),
|
||
|
|
};
|
||
|
|
let b = Event {
|
||
|
|
cmd: "kubectl apply -f /tmp/np-a91c.yaml".into(),
|
||
|
|
..a.clone()
|
||
|
|
};
|
||
|
|
assert_eq!(a.cmd_key(), b.cmd_key());
|
||
|
|
// but a genuinely different action must not collide
|
||
|
|
let c = Event {
|
||
|
|
cmd: "kubectl delete -f /tmp/np-a91c.yaml".into(),
|
||
|
|
..a.clone()
|
||
|
|
};
|
||
|
|
assert_ne!(a.cmd_key(), c.cmd_key());
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn error_lines_still_keep_basenames() {
|
||
|
|
// The cmd_key fix must not leak into error normalisation: which file
|
||
|
|
// failed to compile is meaningful.
|
||
|
|
assert!(normalise("error at /w/src/main.rs:4:2").contains("main.rs"));
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn tool_is_part_of_identity() {
|
||
|
|
let a = extract("npm", "error: boom").unwrap();
|
||
|
|
let b = extract("cargo", "error: boom").unwrap();
|
||
|
|
assert_ne!(a.sig_sha, b.sig_sha);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn unknown_tool_falls_back() {
|
||
|
|
let s = extract("frobnicate", "something went sideways").unwrap();
|
||
|
|
assert_eq!(s.rule, "last-line-fallback");
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn derives_lesson_from_fail_then_success() {
|
||
|
|
let ev = |ts: &str, cmd: &str, exit: i32, out: &str| Event {
|
||
|
|
ts: ts.into(),
|
||
|
|
cwd: "/w".into(),
|
||
|
|
cmd: cmd.into(),
|
||
|
|
exit,
|
||
|
|
output: out.into(),
|
||
|
|
};
|
||
|
|
let events = vec![
|
||
|
|
ev("t1", "npm ci", 1, "npm ERR! ERESOLVE unable to resolve dependency tree"),
|
||
|
|
ev("t2", "npm pkg set overrides.react=19", 0, ""),
|
||
|
|
ev("t3", "npm ci", 0, "ok"),
|
||
|
|
];
|
||
|
|
let ls = derive_lessons(&events, |c| tool_of_cmd(c));
|
||
|
|
assert_eq!(ls.len(), 1);
|
||
|
|
assert_eq!(ls[0].resolution, vec!["npm pkg set overrides.react=19"]);
|
||
|
|
assert_eq!(ls[0].confidence, Confidence::Inferred);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn opaque_edits_do_not_become_a_resolution() {
|
||
|
|
let ev = |cmd: &str, exit: i32, out: &str| Event {
|
||
|
|
ts: "t".into(),
|
||
|
|
cwd: "/w".into(),
|
||
|
|
cmd: cmd.into(),
|
||
|
|
exit,
|
||
|
|
output: out.into(),
|
||
|
|
};
|
||
|
|
// only an editor between fail and success -> no lesson
|
||
|
|
let only_vim = vec![
|
||
|
|
ev("npm ci", 1, "npm ERR! ERESOLVE unable to resolve dependency tree"),
|
||
|
|
ev("vim package.json", 0, ""),
|
||
|
|
ev("npm ci", 0, "ok"),
|
||
|
|
];
|
||
|
|
assert!(derive_lessons(&only_vim, tool_of_cmd).is_empty());
|
||
|
|
|
||
|
|
// a real command survives, and the editor is dropped from it
|
||
|
|
let mixed = vec![
|
||
|
|
ev("npm ci", 1, "npm ERR! ERESOLVE unable to resolve dependency tree"),
|
||
|
|
ev("vim package.json", 0, ""),
|
||
|
|
ev("npm pkg set overrides.react=19", 0, ""),
|
||
|
|
ev("npm ci", 0, "ok"),
|
||
|
|
];
|
||
|
|
let ls = derive_lessons(&mixed, tool_of_cmd);
|
||
|
|
assert_eq!(ls[0].resolution, vec!["npm pkg set overrides.react=19"]);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn failed_attempts_are_not_the_resolution() {
|
||
|
|
let ev = |cmd: &str, exit: i32, out: &str| Event {
|
||
|
|
ts: "t".into(),
|
||
|
|
cwd: "/w".into(),
|
||
|
|
cmd: cmd.into(),
|
||
|
|
exit,
|
||
|
|
output: out.into(),
|
||
|
|
};
|
||
|
|
let events = vec![
|
||
|
|
ev("cargo build", 1, "error[E0308]: mismatched types"),
|
||
|
|
ev("cargo fix --broken", 1, "error: no"),
|
||
|
|
ev("cargo add serde", 0, ""),
|
||
|
|
ev("cargo build", 0, "ok"),
|
||
|
|
];
|
||
|
|
let ls = derive_lessons(&events, tool_of_cmd);
|
||
|
|
assert_eq!(ls[0].resolution, vec!["cargo add serde"]);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn bare_retry_is_not_a_lesson() {
|
||
|
|
let ev = |cmd: &str, exit: i32| Event {
|
||
|
|
ts: "t".into(),
|
||
|
|
cwd: "/w".into(),
|
||
|
|
cmd: cmd.into(),
|
||
|
|
exit,
|
||
|
|
output: "error: flaky".into(),
|
||
|
|
};
|
||
|
|
let events = vec![ev("npm ci", 1), ev("npm ci", 0)];
|
||
|
|
assert!(derive_lessons(&events, |c| tool_of_cmd(c)).is_empty());
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn lookup_prefers_exact_then_abstains() {
|
||
|
|
let l = Lesson {
|
||
|
|
sig_sha: "abc".into(),
|
||
|
|
tool: "npm".into(),
|
||
|
|
raw: "npm ERR! ERESOLVE unable to resolve dependency tree".into(),
|
||
|
|
normalised: "npm ERR! ERESOLVE unable to resolve dependency tree".into(),
|
||
|
|
resolution: vec!["npm ci --legacy-peer-deps".into()],
|
||
|
|
seen: 2,
|
||
|
|
last_seen: "t".into(),
|
||
|
|
cwd: "/w".into(),
|
||
|
|
confidence: Confidence::Inferred,
|
||
|
|
};
|
||
|
|
let exact = Signature {
|
||
|
|
tool: "npm".into(),
|
||
|
|
raw: "x".into(),
|
||
|
|
normalised: "x".into(),
|
||
|
|
sig_sha: "abc".into(),
|
||
|
|
rule: "r".into(),
|
||
|
|
};
|
||
|
|
assert_eq!(lookup(&exact, &[l.clone()], 0.5).unwrap().tier, Tier::Exact);
|
||
|
|
|
||
|
|
let unrelated = Signature {
|
||
|
|
tool: "npm".into(),
|
||
|
|
raw: "y".into(),
|
||
|
|
normalised: "totally different disk full message".into(),
|
||
|
|
sig_sha: "zzz".into(),
|
||
|
|
rule: "r".into(),
|
||
|
|
};
|
||
|
|
assert!(
|
||
|
|
lookup(&unrelated, &[l], 0.5).is_none(),
|
||
|
|
"must abstain rather than return a weak match"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn similar_wording_still_matches() {
|
||
|
|
let l = Lesson {
|
||
|
|
sig_sha: "abc".into(),
|
||
|
|
tool: "npm".into(),
|
||
|
|
raw: "npm ERR! ERESOLVE unable to resolve dependency tree".into(),
|
||
|
|
normalised: "npm ERR! ERESOLVE unable to resolve dependency tree".into(),
|
||
|
|
resolution: vec!["npm ci --legacy-peer-deps".into()],
|
||
|
|
seen: 1,
|
||
|
|
last_seen: "t".into(),
|
||
|
|
cwd: "/w".into(),
|
||
|
|
confidence: Confidence::Inferred,
|
||
|
|
};
|
||
|
|
let sig = extract("npm", "npm ERR! ERESOLVE could not resolve dependency tree").unwrap();
|
||
|
|
let hit = lookup(&sig, &[l], 0.5).expect("should match on wording drift");
|
||
|
|
assert!(matches!(hit.tier, Tier::Similar(s) if s > 0.5));
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn skill_description_lists_symptoms_not_summary() {
|
||
|
|
let l = Lesson {
|
||
|
|
sig_sha: "abc123def456".into(),
|
||
|
|
tool: "npm".into(),
|
||
|
|
raw: "npm ERR! ERESOLVE unable to resolve dependency tree".into(),
|
||
|
|
normalised: "n".into(),
|
||
|
|
resolution: vec!["npm ci --legacy-peer-deps".into()],
|
||
|
|
seen: 3,
|
||
|
|
last_seen: "t".into(),
|
||
|
|
cwd: "/w".into(),
|
||
|
|
confidence: Confidence::Inferred,
|
||
|
|
};
|
||
|
|
let md = render_skill("npm", &[l]);
|
||
|
|
assert!(md.starts_with("---\n"));
|
||
|
|
assert!(md.contains("name: npm-failures"));
|
||
|
|
// the trigger string, not a paraphrase
|
||
|
|
assert!(md.contains("ERESOLVE unable to resolve dependency tree"));
|
||
|
|
assert!(md.contains("recurring"));
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn injection_is_capped() {
|
||
|
|
let l = Lesson {
|
||
|
|
sig_sha: "a".into(),
|
||
|
|
tool: "npm".into(),
|
||
|
|
raw: "npm ERR! boom".into(),
|
||
|
|
normalised: "n".into(),
|
||
|
|
resolution: vec!["x".repeat(500)],
|
||
|
|
seen: 1,
|
||
|
|
last_seen: "t".into(),
|
||
|
|
cwd: "/w".into(),
|
||
|
|
confidence: Confidence::Inferred,
|
||
|
|
};
|
||
|
|
let out = render_injection(&Hit { lesson: l, tier: Tier::Exact }, 200);
|
||
|
|
assert!(out.len() <= 204, "len {}", out.len());
|
||
|
|
}
|
||
|
|
}
|