#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConsolidatedFact {
pub tier: String,
pub text: String,
}
pub fn summarize(
events: &[String],
max_facts: usize,
backend: Option<&str>,
) -> anyhow::Result<Option<(&'static str, Vec<ConsolidatedFact>)>> {
if std::env::var("TJ_CONSOLIDATE_BACKEND").as_deref() == Ok("none") {
return Ok(None);
}
let llm = match crate::llm::backend_from_env(backend)? {
Some(b) => b,
None => return Ok(None),
};
if events.is_empty() {
return Ok(Some((llm.name(), Vec::new())));
}
let prompt = build_prompt(events, max_facts);
let text = llm.complete(&prompt, 512)?;
Ok(Some((llm.name(), parse_facts(&text))))
}
pub fn build_prompt(events: &[String], max_facts: usize) -> String {
let joined = events
.iter()
.map(|e| format!("- {}", e.trim()))
.collect::<Vec<_>>()
.join("\n");
format!(
"You are given decisions and constraints recorded across ONE software \
project. Distil them into at most {max_facts} DURABLE facts — stable \
conventions or architectural truths that hold across the project, not one-off \
details.\n\n\
Rules:\n\
- One fact per line.\n\
- Each line MUST start with `[semantic]` (a durable truth about the system) or \
`[procedural]` (how the team works).\n\
- Keep each fact to one short sentence.\n\
- If nothing is durable enough, output nothing at all.\n\n\
Decisions and constraints:\n{joined}"
)
}
pub fn parse_facts(text: &str) -> Vec<ConsolidatedFact> {
let mut out = Vec::new();
for raw in text.lines() {
let line = raw.trim().trim_start_matches(['-', '*', ' ']).trim();
for tier in ["semantic", "procedural"] {
let tag = format!("[{tier}]");
if let Some(rest) = line.strip_prefix(&tag) {
let fact = rest.trim();
if fact.chars().count() >= 6 {
out.push(ConsolidatedFact {
tier: tier.to_string(),
text: fact.to_string(),
});
}
break;
}
}
}
out
}
const CONV_START: &str = "<!-- task-journal:conventions:start -->";
const CONV_END: &str = "<!-- task-journal:conventions:end -->";
pub fn render_conventions_block(facts: &[ConsolidatedFact]) -> String {
let mut s = String::from(CONV_START);
s.push_str(
"\n## Project conventions (auto-derived by task-journal)\n\
_Regenerate with `task-journal consolidate --write-claude-md`. Lines between the \
markers are overwritten — edit elsewhere._\n\n",
);
for f in facts {
s.push_str(&format!("- ({}) {}\n", f.tier, f.text));
}
s.push_str(CONV_END);
s
}
pub fn upsert_conventions_block(existing: &str, facts: &[ConsolidatedFact]) -> String {
let block = render_conventions_block(facts);
match (existing.find(CONV_START), existing.find(CONV_END)) {
(Some(start), Some(end_idx)) if end_idx >= start => {
let end = end_idx + CONV_END.len();
format!("{}{}{}", &existing[..start], block, &existing[end..])
}
_ => {
let mut out = existing.to_string();
if !out.is_empty() {
if !out.ends_with('\n') {
out.push('\n');
}
out.push('\n');
}
out.push_str(&block);
out.push('\n');
out
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn fact(tier: &str, text: &str) -> ConsolidatedFact {
ConsolidatedFact {
tier: tier.into(),
text: text.into(),
}
}
#[test]
fn conventions_block_appends_then_replaces_idempotently() {
let facts = vec![fact("semantic", "always lock the DB for money")];
let v1 = upsert_conventions_block("# My project\n\nHand rules.\n", &facts);
assert!(v1.contains("# My project"));
assert!(v1.contains("always lock the DB"));
assert!(v1.contains(CONV_START) && v1.contains(CONV_END));
let facts2 = vec![fact("procedural", "PR into main, squash")];
let v2 = upsert_conventions_block(&v1, &facts2);
assert!(v2.contains("# My project"), "hand content preserved");
assert!(v2.contains("PR into main, squash"));
assert!(!v2.contains("always lock the DB"), "old facts replaced");
assert_eq!(
v2.matches(CONV_START).count(),
1,
"exactly one managed block"
);
}
#[test]
fn parse_facts_extracts_tagged_lines() {
let reply = "[semantic] Refunds route through the idempotent ledger\n\
- [procedural] PR into main, squash-merge\n\
some preamble that should be ignored\n\
[bogus] not a real tier";
let facts = parse_facts(reply);
assert_eq!(facts.len(), 2);
assert_eq!(facts[0].tier, "semantic");
assert!(facts[0].text.contains("idempotent ledger"));
assert_eq!(facts[1].tier, "procedural");
assert!(facts[1].text.contains("squash-merge"));
}
#[test]
fn parse_facts_empty_on_no_tagged_lines() {
assert!(parse_facts("nothing durable here").is_empty());
assert!(parse_facts("").is_empty());
}
#[test]
fn build_prompt_includes_events_and_cap() {
let p = build_prompt(&["chose ledger".into(), "PR into main".into()], 5);
assert!(p.contains("at most 5"));
assert!(p.contains("- chose ledger"));
assert!(p.contains("- PR into main"));
assert!(p.contains("[semantic]") && p.contains("[procedural]"));
}
#[test]
fn summarize_skips_when_backend_forced_none() {
std::env::set_var("TJ_CONSOLIDATE_BACKEND", "none");
let r = summarize(&["chose ledger".into()], 5, None).unwrap();
std::env::remove_var("TJ_CONSOLIDATE_BACKEND");
assert!(r.is_none());
}
}