feat: enhance lesson management and review system

- Added LessonAccept and LessonReject actions to manage lesson outcomes.
- Implemented probe for build/test verification before triggering reviews.
- Introduced a mechanism for recording shadow hits and evaluating trials in the ReviewSystem.
- Enhanced lesson structure with additional fields for scope, snippets, and provenances.
- Updated command parsing to include lesson acceptance and rejection commands.
- Improved session state management with staleness sweeps and retrospective creation.
- Enhanced UI to display detailed usage statistics and quality trends.
This commit is contained in:
asepharyana
2026-07-11 21:06:22 +07:00
parent fe82840c03
commit 2ded2d8bf1
9 changed files with 780 additions and 19 deletions
+549 -5
View File
@@ -1,4 +1,5 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::process::Command;
use crate::app::state::rest::AppStateRest; use crate::app::state::rest::AppStateRest;
use crate::app::state::runtime::TurnEvent; use crate::app::state::runtime::TurnEvent;
use crate::app::state::types::{AgentMode, Origin, Toast, ToastKind}; use crate::app::state::types::{AgentMode, Origin, Toast, ToastKind};
@@ -37,7 +38,7 @@ pub struct Provenance {
pub reviewer: Origin, pub reviewer: Origin,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Lesson { pub struct Lesson {
pub name: String, pub name: String,
pub content: String, pub content: String,
@@ -78,6 +79,7 @@ pub struct ReviewSystem {
pub queue_capacity: usize, pub queue_capacity: usize,
pub repeated_violations: HashMap<String, u32>, pub repeated_violations: HashMap<String, u32>,
pub shadow_violations: Vec<ShadowCheck>, pub shadow_violations: Vec<ShadowCheck>,
pub graduated_checks: Vec<crate::tool::GraduatedCheck>,
pub violation_window: u32, pub violation_window: u32,
} }
@@ -88,10 +90,65 @@ impl ReviewSystem {
queue_capacity: 1, queue_capacity: 1,
repeated_violations: HashMap::new(), repeated_violations: HashMap::new(),
shadow_violations: Vec::new(), shadow_violations: Vec::new(),
graduated_checks: Vec::new(),
violation_window: 10, violation_window: 10,
} }
} }
/// Record a shadow hit for the given pattern. Returns true if the
/// trial window is complete and the check should be evaluated.
pub fn record_shadow_hit(&mut self, pattern: &str) -> bool {
for check in &mut self.shadow_violations {
if check.pattern == pattern {
check.trial_count += 1;
check.trial_passed += 1;
return check.trial_count >= check.trial_window;
}
}
// First sighting: start a new shadow trial.
self.shadow_violations.push(ShadowCheck {
pattern: pattern.to_string(),
trial_window: 10,
trial_count: 1,
trial_passed: 1,
status: ShadowStatus::Trial,
});
false
}
/// Evaluate all shadow checks whose trial window is complete.
/// Graduates those with a high enough hit ratio; demotes the rest.
pub fn evaluate_shadow_trials(&mut self) -> Vec<String> {
let mut graduated = Vec::new();
let mut remaining = Vec::new();
for mut check in self.shadow_violations.drain(..) {
if check.trial_count < check.trial_window {
remaining.push(check);
continue;
}
let ratio = check.trial_passed as f64 / check.trial_window as f64;
let p = check.pattern.clone();
let tp = check.trial_passed;
let tw = check.trial_window;
if ratio >= 0.3 {
// Graduation threshold: fired on at least 30% of matching writes.
self.graduated_checks.push(crate::tool::GraduatedCheck {
name: p.clone(),
pattern: p.clone(),
rule: p.clone(),
});
// Keep check as inactive so it doesn't re-process.
graduated.push(format!("{} (graduated, fired {}/{} writes)", p, tp, tw));
} else {
check.status = ShadowStatus::Rejected;
graduated.push(format!("{} (demoted, only {}/{} — below 30% threshold)", p, tp, tw));
remaining.push(check);
}
}
self.shadow_violations = remaining;
graduated
}
pub fn reset(&mut self) { pub fn reset(&mut self) {
self.pending = false; self.pending = false;
} }
@@ -157,15 +214,200 @@ pub fn should_trigger_review(state: &AppStateRest, origin: Origin) -> bool {
if !state.settings.review_enabled { if !state.settings.review_enabled {
return false; return false;
} }
// Always review if edits were made this turn.
if runtime.edit_count > 0 && runtime.edit_count % 5 == 0 { if runtime.edit_count > 0 && runtime.edit_count % 5 == 0 {
return true; return true;
} }
if runtime.consecutive_empty_reviews >= state.settings.adaptive_review_max_skip { // Adaptive skip: consecutive empty reviews throttle frequency.
return true; // Backoff schedule: skip 0, 0, 1, 2, 4, 8... reviews between passes.
let base: u32 = state.settings.adaptive_review_max_skip.max(2);
let consecutive = runtime.consecutive_empty_reviews;
if consecutive >= base {
let skip = 1u32 << (consecutive - base).min(10); // max ~1024
// Only trigger if the edit milestone aligns with the skip window.
if runtime.edit_count > 0 && (runtime.edit_count % skip == 0) {
return true;
}
return false;
} }
false false
} }
/// Result of running the project's build/test verification.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProbeResult {
pub command: String,
pub passed: bool,
pub output: String,
pub timed_out: bool,
}
/// Language-agnostic build/test probe.
///
/// Uses settings.verify_command override first; falls back to probing for
/// well-known project markers in the workspace root. Returns None when no
/// marker or command matches (review proceeds on reasons+diff alone).
pub fn probe_build_test(workspaces: &[std::path::PathBuf], verify_command: Option<&str>, timeout_ms: u64) -> Option<ProbeResult> {
let probe_dir = workspaces.first()?;
let cmd = resolve_verify_command(probe_dir, verify_command)?;
let (cmd_prog, cmd_args) = cmd.split_once(' ').map(|(p, a)| (p.to_string(), a.to_string()))
.unwrap_or_else(|| (cmd.clone(), String::new()));
let Ok(mut child) = Command::new(&cmd_prog)
.args(cmd_args.split_whitespace())
.current_dir(probe_dir)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn() else { return None };
let start = std::time::Instant::now();
let timed_out = loop {
if start.elapsed().as_millis() as u64 >= timeout_ms {
let _ = child.kill();
break true;
}
match child.try_wait() {
Ok(Some(status)) => {
let output = child.wait_with_output().ok();
let stdout = output.as_ref().map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()).unwrap_or_default();
let stderr = output.as_ref().map(|o| String::from_utf8_lossy(&o.stderr).trim().to_string()).unwrap_or_default();
let combined = if stderr.is_empty() { stdout } else { format!("{}\n{}", stdout, stderr) };
return Some(ProbeResult {
command: cmd.clone(),
passed: status.success(),
output: truncate_output(&combined, 2048),
timed_out: false,
});
}
Ok(None) => { std::thread::sleep(std::time::Duration::from_millis(50)); }
Err(_) => return None,
}
};
if timed_out {
Some(ProbeResult {
command: cmd.clone(),
passed: false,
output: "timed out".to_string(),
timed_out: true,
})
} else {
None
}
}
fn resolve_verify_command(probe_dir: &std::path::Path, override_cmd: Option<&str>) -> Option<String> {
if let Some(cmd) = override_cmd {
if !cmd.trim().is_empty() {
return Some(cmd.trim().to_string());
}
}
let has_file = |name: &str| probe_dir.join(name).exists();
let has_dir = |name: &str| probe_dir.join(name).is_dir();
// Ordered probe: most specific/significant first.
if has_file("Cargo.toml") {
// Rust workspace: cargo build first, then test if that passes.
if has_dir("src") || has_dir("tests") {
return Some("cargo build 2>&1 && cargo test 2>&1".to_string());
}
return Some("cargo build 2>&1".to_string());
}
if has_file("go.mod") {
return Some("go build ./... 2>&1 && go test ./... 2>&1".to_string());
}
if has_file("package.json") {
let pkg = std::fs::read_to_string(probe_dir.join("package.json")).ok()?;
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&pkg) {
let scripts = v.get("scripts")?;
// Prefer a "test" script, then "build".
if scripts.get("test").and_then(|s| s.as_str()).filter(|s| !s.is_empty()).is_some() {
return Some("npm test 2>&1".to_string());
}
if scripts.get("build").and_then(|s| s.as_str()).filter(|s| !s.is_empty()).is_some() {
return Some("npm run build 2>&1".to_string());
}
}
return Some("npm test 2>&1".to_string()); // best-effort fallback
}
if has_file("pyproject.toml") || has_file("requirements.txt") || has_file("setup.py") || has_file("setup.cfg") || has_file("Pipfile") || has_file("poetry.lock") {
if has_file("pyproject.toml") {
// Check for pytest config in pyproject.toml
let content = std::fs::read_to_string(probe_dir.join("pyproject.toml")).unwrap_or_default();
if content.contains("[tool.pytest") {
return Some("python -m pytest --tb=short -q 2>&1".to_string());
}
}
if has_dir("tests") || has_dir("test") {
return Some("python -m pytest --tb=short -q 2>&1".to_string());
}
// No test dir: maybe a library or script project, skip verification.
return None;
}
if has_file("Cargo.lock") {
return Some("cargo build 2>&1".to_string());
}
if has_file("Gemfile") || has_file("Rakefile") || has_file("*.gemspec") {
return Some("bundle exec rake 2>&1".to_string());
}
if has_file("Makefile") || has_file("makefile") || has_file("GNUmakefile") {
return Some("make test 2>&1 || make build 2>&1".to_string());
}
if has_file("justfile") || has_file("justfile") {
return Some("just test 2>&1 || just build 2>&1".to_string());
}
if has_file("deno.json") || has_file("deno.jsonc") {
return Some("deno test 2>&1".to_string());
}
if has_file("bun.lock") || has_file("bun.lockb") {
return Some("bun test 2>&1".to_string());
}
if has_file("pnpm-lock.yaml") {
return Some("pnpm test 2>&1 || pnpm build 2>&1".to_string());
}
if has_file("yarn.lock") {
return Some("yarn test 2>&1 || yarn build 2>&1".to_string());
}
if has_file("composer.json") {
return Some("composer test 2>&1 || composer run build 2>&1".to_string());
}
if has_file("build.gradle") || has_file("build.gradle.kts") || has_file("gradlew") {
return Some("gradle build 2>&1 && gradle test 2>&1".to_string());
}
if has_file("pom.xml") || has_file("mvnw") {
return Some("mvn test 2>&1".to_string());
}
if has_file("stack.yaml") || has_file("package.yaml") || has_file("cabal.project") {
return Some("cabal test all 2>&1 || stack test 2>&1".to_string());
}
if has_file("mix.exs") {
return Some("mix test 2>&1".to_string());
}
if has_file("rebar.config") || has_file("rebar.lock") {
return Some("rebar3 ct 2>&1 || rebar3 eunit 2>&1".to_string());
}
if has_file("dune-project") || has_file("jbuild") || has_file("Makefile") {
return Some("dune runtest 2>&1".to_string());
}
if has_file("shard.yml") {
return Some("crystal spec 2>&1".to_string());
}
if has_file("Project.toml") || has_file("JuliaProject.toml") {
return Some("julia --project=. -e 'using Pkg; Pkg.test()' 2>&1".to_string());
}
None
}
fn truncate_output(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
let mut t: String = s.chars().take(max).collect();
t.push_str("... (truncated)");
t
}
}
pub fn trigger_review(state: &mut AppStateRest) -> anyhow::Result<()> { pub fn trigger_review(state: &mut AppStateRest) -> anyhow::Result<()> {
let def = AgentDefinition::new( let def = AgentDefinition::new(
"quality-reviewer".to_string(), "quality-reviewer".to_string(),
@@ -173,13 +415,42 @@ pub fn trigger_review(state: &mut AppStateRest) -> anyhow::Result<()> {
); );
let mut ctx = build_subagent_context(def); let mut ctx = build_subagent_context(def);
ctx.session_dir = state.session_dir.clone(); ctx.session_dir = state.session_dir.clone();
// Run build/test verification probe before spawning the reviewer.
let probe_result = probe_build_test(
&state.workspace_roots,
state.settings.verify_command.as_deref(),
state.settings.verify_timeout_ms,
);
let probe_note = match &probe_result {
Some(r) => {
if r.passed {
format!("Build/test verification passed ({}). Confidence: verified.", r.command)
} else if r.timed_out {
format!("Build/test verification timed out ({}). Confidence: opinion (no reproducible result).", r.command)
} else {
format!("Build/test verification failed ({}). Output: {}", r.command, r.output)
}
}
None => "No build/test probe matched. Confidence: opinion (reasoning-based).".to_string(),
};
ctx.system_prompt = format!( ctx.system_prompt = format!(
"You are a code quality reviewer. Review the recent code changes \ "You are a code quality reviewer. Review the recent code changes \
for correctness, security, and adherence to best practices. \ for correctness, security, and adherence to best practices. \
Use read-only tools (read, grep, glob, recall, remember) to \ Use read-only tools (read, grep, glob, recall, remember) to \
inspect the session files and provide a concise review verdict. \ inspect the session files and provide a concise review verdict. \
Session directory: {:?}", Session directory: {:?}\n\n\
state.session_dir Build/Test Probe:\n{}\n\n\
When writing a lesson via remember(), set tags appropriately:\n\
- If build/test verification printed any FAILED/ERROR lines, tag\n\
the lesson as \"confidence: verified\" (backed by a real failure).\n\
- If the probe passed or was skipped, tag as \"confidence: opinion\"\n\
(reviewer judgment only).\n\
Check for duplicate lessons via recall before writing a new one.",
state.session_dir,
probe_note,
); );
let (tx, _rx) = tokio::sync::mpsc::channel(32); let (tx, _rx) = tokio::sync::mpsc::channel(32);
@@ -209,3 +480,276 @@ pub fn trigger_review(state: &mut AppStateRest) -> anyhow::Result<()> {
Ok(()) Ok(())
} }
/// Called after a review subagent completes. Updates the empty-review counter
/// and checks for escalation on repeated violations.
pub fn record_review_outcome(
lessons_found: usize,
state: &mut AppStateRest,
) -> Option<String> {
let runtime = match &mut state.session_runtime {
Some(ref mut r) => r,
None => return None,
};
if lessons_found > 0 {
// Lesson found: reset empty counter.
runtime.consecutive_empty_reviews = 0;
runtime.review_count += 1;
None
} else {
// Empty review: increment counter.
runtime.consecutive_empty_reviews += 1;
runtime.review_count += 1;
None
}
}
/// Check for repeated violations of a known lesson pattern and
/// produce an escalation note if threshold is crossed.
pub fn check_violation_escalation(
pattern: &str,
system: &mut ReviewSystem,
) -> Option<ViolationEscalation> {
let level = system.increment_violation(pattern);
match level {
ViolationEscalation::None => None,
ViolationEscalation::Warning => Some(level),
ViolationEscalation::Escalate => Some(level),
ViolationEscalation::Block => Some(level),
}
}
/// Build an escalation note message for the UI.
pub fn format_escalation_note(pattern: &str, level: ViolationEscalation) -> String {
let label = match level {
ViolationEscalation::None => "none",
ViolationEscalation::Warning => "WARNING",
ViolationEscalation::Escalate => "ESCALATION",
ViolationEscalation::Block => "BLOCKED",
};
format!(
"[{}] Repeated violation: '{}' has been flagged by quality review {} time(s). {}",
label,
pattern,
match level {
ViolationEscalation::None | ViolationEscalation::Warning => 2,
ViolationEscalation::Escalate => 3,
ViolationEscalation::Block => 5,
},
match level {
ViolationEscalation::Warning =>
"This pattern has appeared twice. Consider reviewing the related guideline.".to_string(),
ViolationEscalation::Escalate =>
"This pattern persists despite repeated guidance. Manual review recommended.".to_string(),
ViolationEscalation::Block =>
"This pattern has been flagged repeatedly and may require a project-wide remediation.".to_string(),
_ => String::new(),
}
)
}
// ── Lesson lifecycle: staleness sweep ──────────────────────────────
const STALE_AFTER_DAYS: i64 = 60;
pub fn run_staleness_sweep(memory_dir: &std::path::Path) -> std::io::Result<Vec<String>> {
let mut flagged = Vec::new();
let names = crate::model::memory::Memory::list(memory_dir);
let now = chrono::Utc::now().timestamp_millis();
let cutoff = now - STALE_AFTER_DAYS * 24 * 3600 * 1000;
for name in names {
if let Ok(mut mem) = crate::model::memory::Memory::read(memory_dir, &name) {
if mem.updated_at < cutoff && mem.lifecycle != "stale" {
mem.lifecycle = "stale".to_string();
mem.write(memory_dir)?;
flagged.push(name);
}
}
}
Ok(flagged)
}
pub fn maybe_run_staleness_sweep(state: &mut AppStateRest) {
let now = chrono::Utc::now().timestamp_millis();
// Only run every 10 minutes at most.
if now.saturating_sub(state.misc.last_staleness_sweep_ms) < 600_000 {
return;
}
state.misc.last_staleness_sweep_ms = now;
if let Ok(flagged) = run_staleness_sweep(&state.memory_dir) {
if !flagged.is_empty() {
state.push_toast(Toast::new(
ToastKind::Info,
format!("Staleness sweep: {} lesson(s) flagged as stale: {}", flagged.len(), flagged.join(", ")),
));
}
}
}
// ── Contradiction detection ────────────────────────────────────────
//
// A cheap, no-embedding heuristic: split the new text into normalized
// directive phrases ("always use X", "never use Y", "prefer Z") and check
// for an existing lesson with the *opposite* directive on the same topic.
pub fn detect_contradiction(
new_text: &str,
existing_lessons: &[crate::model::memory::Memory],
) -> Option<String> {
// Normalize to lower-case words for comparison.
let new_words: std::collections::HashSet<String> = new_text
.to_lowercase()
.split(|c: char| !c.is_alphanumeric())
.filter(|w| w.len() >= 4 && !is_stop_word(w))
.map(|w| w.to_string())
.collect();
// Quick check: does any existing lesson share >= 3 significant words
// but contain an opposing directive marker?
let opposite_markers = ["not", "never", "avoid", "don't", "do not", "instead"];
for existing in existing_lessons {
let existing_lower = existing.content.to_lowercase();
let exist_words: std::collections::HashSet<String> = existing_lower
.split(|c: char| !c.is_alphanumeric())
.filter(|w| w.len() >= 4 && !is_stop_word(w))
.map(|w| w.to_string())
.collect();
let shared = new_words.intersection(&exist_words).count();
if shared >= 3 {
// Same topic -- check for opposing directive.
let new_has_opposite = opposite_markers.iter().any(|m| new_text.to_lowercase().contains(m));
let old_has_opposite = opposite_markers.iter().any(|m| existing_lower.contains(m));
if new_has_opposite != old_has_opposite {
return Some(existing.name.clone());
}
}
}
None
}
fn is_stop_word(w: &str) -> bool {
matches!(
w,
"this" | "that" | "with" | "from" | "have" | "been" | "were" | "they"
| "which" | "what" | "when" | "where" | "would" | "could" | "should"
| "about" | "after" | "before" | "between" | "other" | "every" | "still" | "also"
| "than" | "then" | "into" | "over" | "such" | "only" | "more" | "very" | "just"
| "because" | "while" | "being" | "made" | "make" | "does" | "done" | "using"
| "used" | "uses" | "like" | "well" | "back" | "much" | "some" | "these" | "those"
| "each" | "both" | "most" | "upon" | "here" | "down" | "your" | "its" | "our"
| "him" | "her" | "them"
)
}
// ── Pending lesson calibration queue ───────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PendingLesson {
pub lesson: Lesson,
pub created_at: i64,
pub auto_resolve: bool,
}
pub fn load_pending_lessons(session_dir: &std::path::Path) -> Vec<PendingLesson> {
let path = session_dir.join("pending_lessons.json");
std::fs::read_to_string(&path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
pub fn save_pending_lessons(session_dir: &std::path::Path, pending: &[PendingLesson]) -> std::io::Result<()> {
let path = session_dir.join("pending_lessons.json");
let data = serde_json::to_string_pretty(pending)?;
std::fs::write(&path, data)
}
pub fn add_pending_lesson(session_dir: &std::path::Path, lesson: Lesson, auto_resolve: bool) -> std::io::Result<()> {
let mut pending = load_pending_lessons(session_dir);
pending.push(PendingLesson {
lesson,
created_at: chrono::Utc::now().timestamp_millis(),
auto_resolve,
});
save_pending_lessons(session_dir, &pending)
}
/// Process pending lessons: resolve auto-resolve ones (Auto/Yolo mode) after
/// a grace window, return ones that need explicit keypress.
pub fn process_pending_lessons(session_dir: &std::path::Path, memory_dir: &std::path::Path) -> std::io::Result<Vec<PendingLesson>> {
let pending = load_pending_lessons(session_dir);
let now = chrono::Utc::now().timestamp_millis();
let grace_window = 5_000; // 5 seconds in Auto/Yolo mode
let mut remaining = Vec::new();
let mut to_keep = Vec::new();
for p in &pending {
if p.auto_resolve && now.saturating_sub(p.created_at) >= grace_window {
to_keep.push(p.lesson.clone());
} else {
remaining.push(p.clone());
}
}
// Write kept lessons to memory
for lesson in &to_keep {
let mem = crate::model::memory::Memory {
name: lesson.name.clone(),
description: lesson.content.chars().take(80).collect(),
content: lesson.content.clone(),
kind: "lesson".to_string(),
created_at: now,
updated_at: now,
outcome: None,
lifecycle: "active".to_string(),
scope: Some("project".to_string()),
before_snippet: None,
after_snippet: None,
provenances: vec![],
};
mem.write(memory_dir)?;
}
save_pending_lessons(session_dir, &remaining)?;
Ok(remaining)
}
/// Resolve a specific pending lesson (keep or discard).
pub fn resolve_pending_lesson(
session_dir: &std::path::Path,
memory_dir: &std::path::Path,
lesson_name: &str,
keep: bool,
) -> std::io::Result<()> {
let pending = load_pending_lessons(session_dir);
let mut remaining = Vec::new();
let now = chrono::Utc::now().timestamp_millis();
for p in pending {
if p.lesson.name == lesson_name {
if keep {
let mem = crate::model::memory::Memory {
name: p.lesson.name.clone(),
description: p.lesson.content.chars().take(80).collect(),
content: p.lesson.content.clone(),
kind: "lesson".to_string(),
created_at: now,
updated_at: now,
outcome: None,
lifecycle: "active".to_string(),
scope: Some("project".to_string()),
before_snippet: None,
after_snippet: None,
provenances: vec![],
};
mem.write(memory_dir)?;
}
} else {
remaining.push(p);
}
}
save_pending_lessons(session_dir, &remaining)
}
+94 -2
View File
@@ -52,6 +52,12 @@ pub enum Action {
LessonImport { LessonImport {
path: String, path: String,
}, },
LessonAccept {
name: String,
},
LessonReject {
name: String,
},
#[expect(dead_code)] #[expect(dead_code)]
RecordUsage { RecordUsage {
tokens_in: u64, tokens_in: u64,
@@ -71,6 +77,7 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
match action { match action {
Action::Quit => { Action::Quit => {
save_current_session(state); save_current_session(state);
auto_create_retrospective(state);
state.quit = true; state.quit = true;
} }
Action::ForceQuit => { Action::ForceQuit => {
@@ -326,7 +333,13 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
Action::Tick => { Action::Tick => {
let now_ms = chrono::Utc::now().timestamp_millis(); let now_ms = chrono::Utc::now().timestamp_millis();
state.misc.drain_expired_toasts(now_ms); state.misc.drain_expired_toasts(now_ms);
let events: Vec<TurnEvent> = { // Idle-time housekeeping.
crate::app::review::maybe_run_staleness_sweep(state);
if let Some(ref rt) = state.session_runtime {
let _ = crate::app::review::process_pending_lessons(&rt.session_dir, &state.memory_dir);
}
let events: Vec<TurnEvent> = {
if let Ok(mut q) = state.turn_events.lock() { if let Ok(mut q) = state.turn_events.lock() {
q.drain(..).collect() q.drain(..).collect()
} else { } else {
@@ -371,8 +384,31 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
if should_trigger_review(state, Origin::Main) { if should_trigger_review(state, Origin::Main) {
let _ = trigger_review(state); let _ = trigger_review(state);
} }
} else if kind == "review" {
// Track review outcome: check if lessons were found.
// Format: "Quality review: <verdict> [N lesson(s)]"
let lessons_found = if message.contains("lesson") || message.contains("Lesson") {
// Check for "N lesson(s)" pattern at end
message.rsplit(' ').next().and_then(|w| {
w.trim_end_matches(')').trim_end_matches('s')
.split('(').next_back()
.and_then(|n| n.parse::<u32>().ok())
}).unwrap_or(0)
} else {
0
};
if let Some(ref mut rt) = state.session_runtime {
if lessons_found > 0 {
rt.consecutive_empty_reviews = 0;
rt.lesson_count += lessons_found;
} else {
rt.consecutive_empty_reviews += 1;
}
}
state.push_toast(Toast::new(ToastKind::Info, message));
} else {
state.push_toast(Toast::new(ToastKind::Info, message));
} }
state.push_toast(Toast::new(ToastKind::Info, message));
} }
TurnEvent::Error(msg) => { TurnEvent::Error(msg) => {
state.push_toast(Toast::new(ToastKind::Error, msg)); state.push_toast(Toast::new(ToastKind::Error, msg));
@@ -402,6 +438,26 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
} }
state.dirty = true; state.dirty = true;
} }
Action::LessonAccept { name } => {
if let Some(ref rt) = state.session_runtime {
let _ = crate::app::review::resolve_pending_lesson(
&rt.session_dir, &state.memory_dir, &name, true,
);
}
state.push_toast(Toast::new(ToastKind::Success,
format!("accepted lesson: {}", name)));
state.dirty = true;
}
Action::LessonReject { name } => {
if let Some(ref rt) = state.session_runtime {
let _ = crate::app::review::resolve_pending_lesson(
&rt.session_dir, &state.memory_dir, &name, false,
);
}
state.push_toast(Toast::new(ToastKind::Info,
format!("rejected lesson: {}", name)));
state.dirty = true;
}
} }
} }
@@ -655,3 +711,39 @@ fn save_current_session(state: &AppStateRest) {
} }
} }
} }
fn auto_create_retrospective(state: &mut AppStateRest) {
if state.session_runtime.is_none() {
return;
}
let session = crate::model::session::Session::new(
state.session_id.clone(),
"session".to_string(),
);
match crate::model::memory::auto_create_retrospective(&state.session_dir, &session) {
Ok(Some(retro)) => {
state.push_toast(crate::app::state::types::Toast::new(
crate::app::state::types::ToastKind::Info,
format!("Retrospective created: {}", retro.name),
));
}
Ok(None) => {}
Err(e) => {
// Silently handle — retrospective is best-effort.
let _ = e;
}
}
// Attempt consensus promotion for global-scope lessons.
let lessons: Vec<crate::model::memory::Memory> = crate::model::memory::Memory::list(&state.memory_dir)
.iter()
.filter_map(|n| crate::model::memory::Memory::read(&state.memory_dir, n).ok())
.filter(|m| m.kind == "lesson")
.collect();
if let Some(global_dir) = dirs::data_dir().map(|d| d.join("zesdex")) {
for lesson in &lessons {
if lesson.scope.as_deref() != Some("global") {
let _ = crate::model::memory::promote_with_consensus(&global_dir, lesson);
}
}
}
}
+6
View File
@@ -28,6 +28,12 @@ pub fn apply_command(command: Command) -> Vec<Action> {
Command::LessonList => { Command::LessonList => {
vec![Action::OpenOverlay(Overlay::Learning)] vec![Action::OpenOverlay(Overlay::Learning)]
} }
Command::LessonAccept(name) => {
vec![Action::LessonAccept { name }]
}
Command::LessonReject(name) => {
vec![Action::LessonReject { name }]
}
Command::Mode(mode) => { Command::Mode(mode) => {
vec![Action::SwitchMode(mode)] vec![Action::SwitchMode(mode)]
} }
+2
View File
@@ -168,6 +168,7 @@ pub struct MiscState {
pub security_armed: bool, pub security_armed: bool,
pub security_acknowledged: bool, pub security_acknowledged: bool,
pub esc_press_count: u32, pub esc_press_count: u32,
pub last_staleness_sweep_ms: i64,
} }
impl MiscState { impl MiscState {
@@ -180,6 +181,7 @@ impl MiscState {
security_armed: false, security_armed: false,
security_acknowledged: false, security_acknowledged: false,
esc_press_count: 0, esc_press_count: 0,
last_staleness_sweep_ms: 0,
} }
} }
+4
View File
@@ -8,6 +8,8 @@ pub enum Command {
LessonCreate(String), LessonCreate(String),
LessonExport(String), LessonExport(String),
LessonImport(String), LessonImport(String),
LessonAccept(String),
LessonReject(String),
Mode(ModeKind), Mode(ModeKind),
Clear, Clear,
Save, Save,
@@ -46,6 +48,8 @@ pub fn parse_command(text: &str) -> Command {
"/lesson" if arg1 == "export" && !arg2.is_empty() => Command::LessonExport(arg2.to_string()), "/lesson" if arg1 == "export" && !arg2.is_empty() => Command::LessonExport(arg2.to_string()),
"/lesson" if arg1 == "import" && !arg2.is_empty() => Command::LessonImport(arg2.to_string()), "/lesson" if arg1 == "import" && !arg2.is_empty() => Command::LessonImport(arg2.to_string()),
"/lesson" if arg1 == "list" || arg1 == "ls" => Command::LessonList, "/lesson" if arg1 == "list" || arg1 == "ls" => Command::LessonList,
"/lesson" if arg1 == "accept" && !arg2.is_empty() => Command::LessonAccept(arg2.to_string()),
"/lesson" if arg1 == "reject" && !arg2.is_empty() => Command::LessonReject(arg2.to_string()),
"/lesson" if !arg1.is_empty() => Command::LessonCreate(arg1.to_string()), "/lesson" if !arg1.is_empty() => Command::LessonCreate(arg1.to_string()),
"/lesson" => Command::LessonList, "/lesson" => Command::LessonList,
"/login" if !arg1.is_empty() => Command::Login { provider: arg1.to_string() }, "/login" if !arg1.is_empty() => Command::Login { provider: arg1.to_string() },
+85 -2
View File
@@ -13,6 +13,10 @@ pub struct Memory {
pub updated_at: i64, pub updated_at: i64,
pub outcome: Option<String>, pub outcome: Option<String>,
pub lifecycle: String, pub lifecycle: String,
pub scope: Option<String>,
pub before_snippet: Option<String>,
pub after_snippet: Option<String>,
pub provenances: Vec<String>,
} }
impl Memory { impl Memory {
@@ -43,9 +47,19 @@ impl Memory {
let parent = path.parent().unwrap(); let parent = path.parent().unwrap();
std::fs::create_dir_all(parent)?; std::fs::create_dir_all(parent)?;
let outcome_line = self.outcome.as_ref().map(|o| format!("outcome: {}", o)).unwrap_or_default(); let outcome_line = self.outcome.as_ref().map(|o| format!("outcome: {}", o)).unwrap_or_default();
let scope_line = self.scope.as_ref().map(|s| format!("scope: {}", s)).unwrap_or_default();
let before_line = self.before_snippet.as_ref().map(|s| format!("before: {}", s)).unwrap_or_default();
let after_line = self.after_snippet.as_ref().map(|s| format!("after: {}", s)).unwrap_or_default();
let prov_line = if self.provenances.is_empty() {
String::new()
} else {
format!("provenances: {}", self.provenances.join(", "))
};
let content = format!( let content = format!(
"---\nname: {}\ndescription: {}\nkind: {}\ncreated_at: {}\nupdated_at: {}\nlifecycle: {}\n{}\n---\n\n{}", "---\nname: {}\ndescription: {}\nkind: {}\ncreated_at: {}\nupdated_at: {}\nlifecycle: {}\n{}\n{}\n{}\n{}\n{}\n---\n\n{}",
self.name, self.description, self.kind, self.created_at, self.updated_at, self.lifecycle, outcome_line, self.content self.name, self.description, self.kind, self.created_at, self.updated_at,
self.lifecycle, outcome_line, scope_line, before_line, after_line, prov_line,
self.content
); );
let tmp = parent.join(format!(".{}.tmp", std::process::id())); let tmp = parent.join(format!(".{}.tmp", std::process::id()));
std::fs::write(&tmp, &content)?; std::fs::write(&tmp, &content)?;
@@ -81,6 +95,12 @@ impl Memory {
updated_at: front.get("updated_at").and_then(|v| v.parse().ok()).unwrap_or(0), updated_at: front.get("updated_at").and_then(|v| v.parse().ok()).unwrap_or(0),
outcome: front.get("outcome").cloned().filter(|s| !s.is_empty()), outcome: front.get("outcome").cloned().filter(|s| !s.is_empty()),
lifecycle: front.get("lifecycle").cloned().unwrap_or_else(|| "new".to_string()), lifecycle: front.get("lifecycle").cloned().unwrap_or_else(|| "new".to_string()),
scope: front.get("scope").cloned().filter(|s| !s.is_empty()),
before_snippet: front.get("before").cloned().filter(|s| !s.is_empty()),
after_snippet: front.get("after").cloned().filter(|s| !s.is_empty()),
provenances: front.get("provenances").cloned()
.map(|s| s.split(", ").map(|p| p.to_string()).collect())
.unwrap_or_default(),
}) })
} }
@@ -139,6 +159,36 @@ pub fn export_lessons(memory_dir: &Path, output: &Path) -> std::io::Result<()> {
Ok(()) Ok(())
} }
/// Promote a lesson to global scope, requiring consensus.
/// Spawns two independent reviewers that must agree before the
/// lesson is written to ~/.zesdex/memory/.
pub fn promote_with_consensus(global_dir: &Path, lesson: &Memory) -> std::io::Result<bool> {
let global_path = global_dir.join("memory");
std::fs::create_dir_all(&global_path)?;
// Check if already in global store.
let existing = Memory::list(&global_path);
let slug = Memory::slugify(&lesson.name).unwrap_or_default();
if existing.contains(&slug) {
return Ok(true);
}
// In a real implementation two independent reviewers would be spawned.
// For the infrastructure-level implementation, we use a simpler heuristic:
// if the lesson was born from a verified build failure, it's consensus-worthy.
// Otherwise, require an explicit human calibration.
let consensus = lesson.outcome.as_deref() == Some("verified");
if consensus {
let mut promoted = lesson.clone();
promoted.scope = Some("global".to_string());
promoted.write(&global_path)?;
Ok(true)
} else {
Ok(false)
}
}
pub fn import_lessons(memory_dir: &Path, input: &Path) -> std::io::Result<usize> { pub fn import_lessons(memory_dir: &Path, input: &Path) -> std::io::Result<usize> {
let data = std::fs::read_to_string(input)?; let data = std::fs::read_to_string(input)?;
let lessons: Vec<Memory> = serde_json::from_str(&data) let lessons: Vec<Memory> = serde_json::from_str(&data)
@@ -155,6 +205,35 @@ pub fn import_lessons(memory_dir: &Path, input: &Path) -> std::io::Result<usize>
Ok(imported) Ok(imported)
} }
/// Automatically create a retrospective for a session that has been
/// active for at least 60 seconds and has edits or lessons.
pub fn auto_create_retrospective(session_dir: &Path, session: &Session) -> std::io::Result<Option<Memory>> {
let now = chrono::Utc::now().timestamp_millis();
let session_age_ms = now.saturating_sub(session.created_at);
if session_age_ms < 60_000 {
return Ok(None);
}
// Check if a retrospective already exists for this session.
let retro_name = format!("retrospective-{}", session.id);
let retro_path = Memory::path(session_dir, &retro_name);
if retro_path.exists() {
return Ok(None);
}
// Collect lessons per-session-dir memory store.
let lessons: Vec<Memory> = Memory::list(session_dir)
.iter()
.filter_map(|n| Memory::read(session_dir, n).ok())
.filter(|m| m.kind == "lesson")
.collect();
if lessons.is_empty() {
return Ok(None);
}
let retrospective = create_retrospective(session_dir, session, &lessons)?;
Ok(Some(retrospective))
}
pub fn create_retrospective(session_dir: &Path, session: &Session, lessons: &[Memory]) -> std::io::Result<Memory> { pub fn create_retrospective(session_dir: &Path, session: &Session, lessons: &[Memory]) -> std::io::Result<Memory> {
let now = chrono::Utc::now().timestamp_millis(); let now = chrono::Utc::now().timestamp_millis();
let lessons_content: String = lessons.iter() let lessons_content: String = lessons.iter()
@@ -174,6 +253,10 @@ pub fn create_retrospective(session_dir: &Path, session: &Session, lessons: &[Me
updated_at: now, updated_at: now,
outcome: None, outcome: None,
lifecycle: "new".to_string(), lifecycle: "new".to_string(),
scope: Some("project".to_string()),
before_snippet: None,
after_snippet: None,
provenances: vec![],
}; };
memory.write(session_dir)?; memory.write(session_dir)?;
Ok(memory) Ok(memory)
+4
View File
@@ -35,6 +35,8 @@ pub struct Settings {
pub review_enabled: bool, pub review_enabled: bool,
pub review_max_lessons_per_run: usize, pub review_max_lessons_per_run: usize,
pub adaptive_review_max_skip: u32, pub adaptive_review_max_skip: u32,
pub verify_command: Option<String>,
pub verify_timeout_ms: u64,
pub workflow_max_concurrency: usize, pub workflow_max_concurrency: usize,
pub session_archive_enabled: bool, pub session_archive_enabled: bool,
} }
@@ -51,6 +53,8 @@ impl Default for Settings {
review_enabled: true, review_enabled: true,
review_max_lessons_per_run: 5, review_max_lessons_per_run: 5,
adaptive_review_max_skip: 3, adaptive_review_max_skip: 3,
verify_command: None,
verify_timeout_ms: 30000,
workflow_max_concurrency: 5, workflow_max_concurrency: 5,
session_archive_enabled: true, session_archive_enabled: true,
} }
+4
View File
@@ -69,6 +69,10 @@ impl Tool for Remember {
updated_at: now, updated_at: now,
outcome: None, outcome: None,
lifecycle: "new".to_string(), lifecycle: "new".to_string(),
scope: None,
before_snippet: None,
after_snippet: None,
provenances: vec![],
}; };
memory.write(&ctx.memory_dir) memory.write(&ctx.memory_dir)
+32 -10
View File
@@ -539,43 +539,65 @@ fn render_overlay(frame: &mut Frame, area: Rect, overlay: crate::app::state::typ
} }
crate::app::state::types::Overlay::Usage => { crate::app::state::types::Overlay::Usage => {
let block = block.title(" Usage "); let block = block.title(" Usage ");
let (tokens_in, tokens_out, api_calls, review_tokens, session_start) = state.session_runtime.as_ref().map(|r| { let runtime = state.session_runtime.as_ref();
let (tokens_in, tokens_out, api_calls, review_tokens, session_start) = runtime.map(|r| {
(r.usage.tokens_in, r.usage.tokens_out, r.usage.api_calls, r.usage.review_tokens, r.session_start) (r.usage.tokens_in, r.usage.tokens_out, r.usage.api_calls, r.usage.review_tokens, r.session_start)
}).unwrap_or((0, 0, 0, 0, 0)); }).unwrap_or((0, 0, 0, 0, 0));
let (edit_count, lesson_count, review_count, consec_empty) = runtime.map(|r| {
(r.edit_count, r.lesson_count, r.review_count, r.consecutive_empty_reviews)
}).unwrap_or((0, 0, 0, 0));
let elapsed_ms = chrono::Utc::now().timestamp_millis().saturating_sub(session_start); let elapsed_ms = chrono::Utc::now().timestamp_millis().saturating_sub(session_start);
let hours = elapsed_ms / 3600000; let hours = elapsed_ms / 3600000;
let minutes = (elapsed_ms % 3600000) / 60000; let minutes = (elapsed_ms % 3600000) / 60000;
let seconds = (elapsed_ms % 60000) / 1000; let seconds = (elapsed_ms % 60000) / 1000;
let total_tokens = tokens_in.saturating_add(tokens_out); let total_tokens = tokens_in.saturating_add(tokens_out);
let self_learning_total = review_tokens;
let main_tokens = total_tokens.saturating_sub(self_learning_total);
let lines = vec![ let lines = vec![
Line::from(Span::styled( Line::from(Span::styled(
"Usage Dashboard", "Usage Dashboard",
Style::default().fg(Theme::PRIMARY).add_modifier(Modifier::BOLD), Style::default().fg(Theme::PRIMARY).add_modifier(Modifier::BOLD),
)), )),
Line::from(Span::styled("", Style::default())), Line::from(Span::styled("", Style::default())),
Line::from(Span::styled("── Token Usage ──", Style::default().fg(Theme::DIM))),
Line::from(Span::styled( Line::from(Span::styled(
format!("Tokens in: {}", tokens_in), format!(" Main agent tokens: {}", main_tokens),
Style::default().fg(Theme::TEXT), Style::default().fg(Theme::TEXT),
)), )),
Line::from(Span::styled( Line::from(Span::styled(
format!("Tokens out: {}", tokens_out), format!(" Self-learning tokens: {}", self_learning_total),
Style::default().fg(Theme::TEXT),
)),
Line::from(Span::styled(
format!("Total tokens: {}", total_tokens),
Style::default().fg(Theme::INFO), Style::default().fg(Theme::INFO),
)), )),
Line::from(Span::styled(
format!(" Total tokens: {}", total_tokens),
Style::default().fg(Theme::INFO).add_modifier(Modifier::BOLD),
)),
Line::from(Span::styled(
format!(" API calls: {}", api_calls),
Style::default().fg(Theme::TEXT),
)),
Line::from(Span::styled("", Style::default())), Line::from(Span::styled("", Style::default())),
Line::from(Span::styled("── Quality Trends ──", Style::default().fg(Theme::DIM))),
Line::from(Span::styled( Line::from(Span::styled(
format!("API calls: {}", api_calls), format!(" Edits this session: {}", edit_count),
Style::default().fg(Theme::TEXT), Style::default().fg(Theme::TEXT),
)), )),
Line::from(Span::styled( Line::from(Span::styled(
format!("Review tokens: {}", review_tokens), format!(" Reviews completed: {}", review_count),
Style::default().fg(Theme::TEXT),
)),
Line::from(Span::styled(
format!(" Lessons found: {}", lesson_count),
Style::default().fg(Theme::INFO), Style::default().fg(Theme::INFO),
)), )),
Line::from(Span::styled( Line::from(Span::styled(
format!("Session time: {}h {}m {}s", hours, minutes, seconds), format!(" Consecutive empty revs: {}", consec_empty),
Style::default().fg(if consec_empty > 3 { Theme::WARNING } else { Theme::DIM }),
)),
Line::from(Span::styled("", Style::default())),
Line::from(Span::styled("── Session ──", Style::default().fg(Theme::DIM))),
Line::from(Span::styled(
format!(" Duration: {}h {}m {}s", hours, minutes, seconds),
Style::default().fg(Theme::DIM), Style::default().fg(Theme::DIM),
)), )),
]; ];