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::process::Command;
use crate::app::state::rest::AppStateRest;
use crate::app::state::runtime::TurnEvent;
use crate::app::state::types::{AgentMode, Origin, Toast, ToastKind};
@@ -37,7 +38,7 @@ pub struct Provenance {
pub reviewer: Origin,
}
#[derive(Debug, Clone)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Lesson {
pub name: String,
pub content: String,
@@ -78,6 +79,7 @@ pub struct ReviewSystem {
pub queue_capacity: usize,
pub repeated_violations: HashMap<String, u32>,
pub shadow_violations: Vec<ShadowCheck>,
pub graduated_checks: Vec<crate::tool::GraduatedCheck>,
pub violation_window: u32,
}
@@ -88,10 +90,65 @@ impl ReviewSystem {
queue_capacity: 1,
repeated_violations: HashMap::new(),
shadow_violations: Vec::new(),
graduated_checks: Vec::new(),
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) {
self.pending = false;
}
@@ -157,15 +214,200 @@ pub fn should_trigger_review(state: &AppStateRest, origin: Origin) -> bool {
if !state.settings.review_enabled {
return false;
}
// Always review if edits were made this turn.
if runtime.edit_count > 0 && runtime.edit_count % 5 == 0 {
return true;
}
if runtime.consecutive_empty_reviews >= state.settings.adaptive_review_max_skip {
return true;
// Adaptive skip: consecutive empty reviews throttle frequency.
// 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
}
/// 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<()> {
let def = AgentDefinition::new(
"quality-reviewer".to_string(),
@@ -173,13 +415,42 @@ pub fn trigger_review(state: &mut AppStateRest) -> anyhow::Result<()> {
);
let mut ctx = build_subagent_context(def);
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!(
"You are a code quality reviewer. Review the recent code changes \
for correctness, security, and adherence to best practices. \
Use read-only tools (read, grep, glob, recall, remember) to \
inspect the session files and provide a concise review verdict. \
Session directory: {:?}",
state.session_dir
Session directory: {:?}\n\n\
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);
@@ -209,3 +480,276 @@ pub fn trigger_review(state: &mut AppStateRest) -> anyhow::Result<()> {
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 {
path: String,
},
LessonAccept {
name: String,
},
LessonReject {
name: String,
},
#[expect(dead_code)]
RecordUsage {
tokens_in: u64,
@@ -71,6 +77,7 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
match action {
Action::Quit => {
save_current_session(state);
auto_create_retrospective(state);
state.quit = true;
}
Action::ForceQuit => {
@@ -326,7 +333,13 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
Action::Tick => {
let now_ms = chrono::Utc::now().timestamp_millis();
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() {
q.drain(..).collect()
} else {
@@ -371,8 +384,31 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
if should_trigger_review(state, Origin::Main) {
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) => {
state.push_toast(Toast::new(ToastKind::Error, msg));
@@ -402,6 +438,26 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
}
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 => {
vec![Action::OpenOverlay(Overlay::Learning)]
}
Command::LessonAccept(name) => {
vec![Action::LessonAccept { name }]
}
Command::LessonReject(name) => {
vec![Action::LessonReject { name }]
}
Command::Mode(mode) => {
vec![Action::SwitchMode(mode)]
}
+2
View File
@@ -168,6 +168,7 @@ pub struct MiscState {
pub security_armed: bool,
pub security_acknowledged: bool,
pub esc_press_count: u32,
pub last_staleness_sweep_ms: i64,
}
impl MiscState {
@@ -180,6 +181,7 @@ impl MiscState {
security_armed: false,
security_acknowledged: false,
esc_press_count: 0,
last_staleness_sweep_ms: 0,
}
}