1use std::collections::HashMap;
25
26use rusqlite::{Connection, OptionalExtension, params};
27use time::OffsetDateTime;
28use time::format_description::well_known::Rfc3339;
29
30use kimetsu_core::KimetsuResult;
31
32pub const CITATION_THRESHOLD: i64 = 3;
39
40#[derive(Debug, Clone)]
46pub struct SynthesisCandidate {
47 pub memory_id: String,
48 pub scope: String,
49 pub kind: String,
50 pub text: String,
51 pub trigger_kind: String,
54 pub trigger_count: i64,
56}
57
58#[derive(Debug, Clone)]
60pub struct SkillProposalRow {
61 pub proposal_id: String,
62 pub skill_name: String,
63 pub description: String,
64 pub draft_content: Option<String>,
66 pub source_memory_ids: Vec<String>,
68 pub trigger_kind: String,
69 pub trigger_count: i64,
70 pub status: String,
72 pub decided_at: Option<String>,
73 pub installed_path: Option<String>,
74 pub created_at: String,
75}
76
77#[derive(Debug, Clone)]
79pub struct StalenessReport {
80 pub proposal_id: String,
81 pub skill_name: String,
82 pub installed_path: Option<String>,
83 pub stale_memory_ids: Vec<String>,
86 pub is_stale: bool,
87}
88
89pub fn graduation_notice(conn: &Connection) -> Option<String> {
117 let pending = conn
118 .query_row(
119 "SELECT COUNT(*) FROM skill_proposals WHERE status = 'pending'",
120 [],
121 |row| row.get::<_, i64>(0),
122 )
123 .unwrap_or(0);
124
125 let undrafted = find_synthesis_candidates(conn)
126 .unwrap_or_default()
127 .into_iter()
128 .filter(|candidate| !has_open_proposal(conn, &candidate.memory_id))
129 .count();
130
131 let mut parts: Vec<String> = Vec::new();
132 if undrafted > 0 {
133 parts.push(format!(
134 "{undrafted} {} earned skill status (`kimetsu brain skills --detect`)",
135 plural(undrafted, "memory has", "memories have"),
136 ));
137 }
138 if pending > 0 {
139 parts.push(format!(
140 "{pending} skill {} awaiting review (`kimetsu brain skills --list`)",
141 plural(pending as usize, "proposal is", "proposals are"),
142 ));
143 }
144 if parts.is_empty() {
145 return None;
146 }
147 Some(parts.join("; "))
148}
149
150fn has_open_proposal(conn: &Connection, memory_id: &str) -> bool {
156 conn.query_row(
157 "SELECT COUNT(*) FROM skill_proposals
158 WHERE status IN ('pending', 'accepted')
159 AND source_memory_ids_json LIKE ?1",
160 params![format!("%{memory_id}%")],
161 |row| row.get::<_, i64>(0),
162 )
163 .unwrap_or(0)
164 > 0
165}
166
167fn plural(n: usize, one: &'static str, many: &'static str) -> &'static str {
168 if n == 1 { one } else { many }
169}
170
171pub fn find_synthesis_candidates(conn: &Connection) -> KimetsuResult<Vec<SynthesisCandidate>> {
184 let mut candidates: HashMap<String, SynthesisCandidate> = HashMap::new();
185
186 let mut stmt = conn.prepare(
188 "SELECT mc.memory_id,
189 COUNT(DISTINCT mc.run_id) AS cite_count,
190 m.scope, m.kind, m.text
191 FROM memory_citations mc
192 JOIN memories m ON m.memory_id = mc.memory_id
193 WHERE m.invalidated_at IS NULL
194 AND m.superseded_by IS NULL
195 GROUP BY mc.memory_id
196 HAVING COUNT(DISTINCT mc.run_id) >= ?1
197 ORDER BY cite_count DESC",
198 )?;
199 let rows = stmt.query_map(params![CITATION_THRESHOLD], |row| {
200 Ok((
201 row.get::<_, String>(0)?,
202 row.get::<_, i64>(1)?,
203 row.get::<_, String>(2)?,
204 row.get::<_, String>(3)?,
205 row.get::<_, String>(4)?,
206 ))
207 })?;
208 for row in rows {
209 let (memory_id, cite_count, scope, kind, text) = row?;
210 candidates
211 .entry(memory_id.clone())
212 .or_insert(SynthesisCandidate {
213 memory_id,
214 scope,
215 kind,
216 text,
217 trigger_kind: "citations".to_string(),
218 trigger_count: cite_count,
219 });
220 }
221
222 if let Ok(by_model) = crate::consolidate::load_embeddable_rows(conn) {
227 let all_rows: Vec<crate::consolidate::ConsolidateRow> =
228 by_model.into_values().flatten().collect();
229 let opts = crate::consolidate::DistillOptions {
230 lo: 0.75,
231 hi: 0.92,
232 min_cluster_size: 3,
233 };
234 let clusters = crate::consolidate::find_distill_clusters(&all_rows, &opts);
235 for cluster in clusters {
236 if let Some(rep) = cluster.memories.first() {
238 if !candidates.contains_key(&rep.memory_id) {
239 candidates.insert(
240 rep.memory_id.clone(),
241 SynthesisCandidate {
242 memory_id: rep.memory_id.clone(),
243 scope: rep.scope.clone(),
244 kind: rep.kind.clone(),
245 text: rep.text.clone(),
246 trigger_kind: "cluster".to_string(),
247 trigger_count: cluster.memories.len() as i64,
248 },
249 );
250 }
251 }
252 }
253 }
254
255 let mut result: Vec<SynthesisCandidate> = candidates.into_values().collect();
256 result.sort_by(|a, b| {
258 a.trigger_kind
259 .cmp(&b.trigger_kind)
260 .then_with(|| b.trigger_count.cmp(&a.trigger_count))
261 });
262 Ok(result)
263}
264
265pub fn load_memory_texts(
272 conn: &Connection,
273 memory_ids: &[String],
274) -> KimetsuResult<Vec<(String, String)>> {
275 if memory_ids.is_empty() {
276 return Ok(Vec::new());
277 }
278 let placeholders: Vec<String> = (1..=memory_ids.len()).map(|i| format!("?{i}")).collect();
279 let sql = format!(
280 "SELECT memory_id, text FROM memories
281 WHERE memory_id IN ({})
282 AND invalidated_at IS NULL
283 AND superseded_by IS NULL",
284 placeholders.join(", ")
285 );
286 let mut stmt = conn.prepare(&sql)?;
287 let params_iter: Vec<&dyn rusqlite::types::ToSql> = memory_ids
288 .iter()
289 .map(|s| s as &dyn rusqlite::types::ToSql)
290 .collect();
291 let rows = stmt.query_map(params_iter.as_slice(), |row| {
292 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
293 })?;
294 let mut out = Vec::new();
295 for row in rows {
296 out.push(row?);
297 }
298 Ok(out)
299}
300
301pub fn load_candidate_with_related(
305 conn: &Connection,
306 memory_id: &str,
307) -> KimetsuResult<(i64, Vec<(String, String)>)> {
308 let cite_count: i64 = conn
310 .query_row(
311 "SELECT COUNT(DISTINCT run_id) FROM memory_citations WHERE memory_id = ?1",
312 params![memory_id],
313 |r| r.get(0),
314 )
315 .unwrap_or(0);
316
317 let texts = load_memory_texts(conn, &[memory_id.to_string()])?;
320 Ok((cite_count, texts))
321}
322
323pub fn insert_skill_proposal(
329 conn: &Connection,
330 skill_name: &str,
331 description: &str,
332 draft_content: Option<&str>,
333 source_memory_ids: &[String],
334 trigger_kind: &str,
335 trigger_count: i64,
336) -> KimetsuResult<String> {
337 use ulid::Ulid;
338
339 let proposal_id = Ulid::new().to_string();
340 let source_ids_json =
341 serde_json::to_string(source_memory_ids).unwrap_or_else(|_| "[]".to_string());
342 let now = OffsetDateTime::now_utc()
343 .format(&Rfc3339)
344 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
345
346 conn.execute(
347 "INSERT INTO skill_proposals
348 (proposal_id, skill_name, description, draft_content,
349 source_memory_ids_json, trigger_kind, trigger_count,
350 status, created_at)
351 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 'pending', ?8)",
352 params![
353 proposal_id,
354 skill_name,
355 description,
356 draft_content,
357 source_ids_json,
358 trigger_kind,
359 trigger_count,
360 now,
361 ],
362 )?;
363 Ok(proposal_id)
364}
365
366pub fn list_skill_proposals(
368 conn: &Connection,
369 status_filter: Option<&str>,
370) -> KimetsuResult<Vec<SkillProposalRow>> {
371 let sql = if status_filter.is_some() {
372 "SELECT proposal_id, skill_name, description, draft_content,
373 source_memory_ids_json, trigger_kind, trigger_count,
374 status, decided_at, installed_path, created_at
375 FROM skill_proposals
376 WHERE status = ?1
377 ORDER BY created_at DESC"
378 } else {
379 "SELECT proposal_id, skill_name, description, draft_content,
380 source_memory_ids_json, trigger_kind, trigger_count,
381 status, decided_at, installed_path, created_at
382 FROM skill_proposals
383 ORDER BY created_at DESC"
384 };
385
386 let mut stmt = conn.prepare(sql)?;
387 let rows = if let Some(sf) = status_filter {
388 stmt.query_map(params![sf], parse_proposal_row)?
389 } else {
390 stmt.query_map([], parse_proposal_row)?
391 };
392
393 let mut out = Vec::new();
394 for row in rows {
395 out.push(row?);
396 }
397 Ok(out)
398}
399
400pub fn load_skill_proposal(
402 conn: &Connection,
403 proposal_id: &str,
404) -> KimetsuResult<Option<SkillProposalRow>> {
405 conn.query_row(
406 "SELECT proposal_id, skill_name, description, draft_content,
407 source_memory_ids_json, trigger_kind, trigger_count,
408 status, decided_at, installed_path, created_at
409 FROM skill_proposals
410 WHERE proposal_id = ?1",
411 params![proposal_id],
412 parse_proposal_row,
413 )
414 .optional()
415 .map_err(Into::into)
416}
417
418pub fn accept_skill_proposal(
420 conn: &Connection,
421 proposal_id: &str,
422 installed_path: &str,
423) -> KimetsuResult<()> {
424 let now = OffsetDateTime::now_utc()
425 .format(&Rfc3339)
426 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
427 let updated = conn.execute(
428 "UPDATE skill_proposals
429 SET status = 'accepted', decided_at = ?1, installed_path = ?2
430 WHERE proposal_id = ?3 AND status = 'pending'",
431 params![now, installed_path, proposal_id],
432 )?;
433 if updated == 0 {
434 return Err(format!("proposal `{proposal_id}` not found or already decided").into());
435 }
436 Ok(())
437}
438
439pub fn reject_skill_proposal(conn: &Connection, proposal_id: &str) -> KimetsuResult<()> {
441 let now = OffsetDateTime::now_utc()
442 .format(&Rfc3339)
443 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
444 let updated = conn.execute(
445 "UPDATE skill_proposals
446 SET status = 'rejected', decided_at = ?1
447 WHERE proposal_id = ?2 AND status = 'pending'",
448 params![now, proposal_id],
449 )?;
450 if updated == 0 {
451 return Err(format!("proposal `{proposal_id}` not found or already decided").into());
452 }
453 Ok(())
454}
455
456pub fn check_staleness(conn: &Connection) -> KimetsuResult<Vec<StalenessReport>> {
466 let accepted = list_skill_proposals(conn, Some("accepted"))?;
467 let mut reports = Vec::new();
468
469 for proposal in accepted {
470 if proposal.source_memory_ids.is_empty() {
471 reports.push(StalenessReport {
472 proposal_id: proposal.proposal_id.clone(),
473 skill_name: proposal.skill_name.clone(),
474 installed_path: proposal.installed_path.clone(),
475 stale_memory_ids: Vec::new(),
476 is_stale: false,
477 });
478 continue;
479 }
480
481 let mut stale_ids = Vec::new();
482 for mid in &proposal.source_memory_ids {
483 let is_stale: bool = conn
484 .query_row(
485 "SELECT (superseded_by IS NOT NULL OR invalidated_at IS NOT NULL)
486 FROM memories WHERE memory_id = ?1",
487 params![mid],
488 |r| r.get::<_, bool>(0),
489 )
490 .unwrap_or(false); if is_stale {
492 stale_ids.push(mid.clone());
493 }
494 }
495
496 let is_stale = !stale_ids.is_empty();
497 reports.push(StalenessReport {
498 proposal_id: proposal.proposal_id,
499 skill_name: proposal.skill_name,
500 installed_path: proposal.installed_path,
501 stale_memory_ids: stale_ids,
502 is_stale,
503 });
504 }
505
506 Ok(reports)
507}
508
509fn parse_proposal_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<SkillProposalRow> {
514 let source_ids_json: String = row.get(4)?;
515 let source_memory_ids: Vec<String> = serde_json::from_str(&source_ids_json).unwrap_or_default();
516 Ok(SkillProposalRow {
517 proposal_id: row.get(0)?,
518 skill_name: row.get(1)?,
519 description: row.get(2)?,
520 draft_content: row.get(3)?,
521 source_memory_ids,
522 trigger_kind: row.get(5)?,
523 trigger_count: row.get(6)?,
524 status: row.get(7)?,
525 decided_at: row.get(8)?,
526 installed_path: row.get(9)?,
527 created_at: row.get(10)?,
528 })
529}
530
531#[cfg(test)]
536mod tests {
537 use super::*;
538
539 fn init_conn() -> Connection {
540 let conn = Connection::open_in_memory().expect("open_in_memory");
541 crate::schema::initialize(&conn).expect("initialize");
542 conn
543 }
544
545 #[test]
550 fn insert_and_list_pending_proposals() {
551 let conn = init_conn();
552 let id = insert_skill_proposal(
553 &conn,
554 "my-skill",
555 "A test skill",
556 Some("# My Skill\nDo the thing."),
557 &["mem-1".to_string(), "mem-2".to_string()],
558 "citations",
559 4,
560 )
561 .expect("insert");
562 assert!(!id.is_empty());
563
564 let all = list_skill_proposals(&conn, None).expect("list all");
565 assert_eq!(all.len(), 1);
566 let row = &all[0];
567 assert_eq!(row.proposal_id, id);
568 assert_eq!(row.skill_name, "my-skill");
569 assert_eq!(row.description, "A test skill");
570 assert_eq!(
571 row.draft_content.as_deref(),
572 Some("# My Skill\nDo the thing.")
573 );
574 assert_eq!(row.source_memory_ids, vec!["mem-1", "mem-2"]);
575 assert_eq!(row.trigger_kind, "citations");
576 assert_eq!(row.trigger_count, 4);
577 assert_eq!(row.status, "pending");
578 assert!(row.installed_path.is_none());
579 }
580
581 #[test]
582 fn accept_proposal_records_installed_path() {
583 let conn = init_conn();
584 let id = insert_skill_proposal(
585 &conn,
586 "install-me",
587 "Install test",
588 Some("# Install Me"),
589 &["mem-a".to_string()],
590 "citations",
591 3,
592 )
593 .expect("insert");
594
595 accept_skill_proposal(&conn, &id, "/path/to/.kimetsu/skills/install-me").expect("accept");
596
597 let row = load_skill_proposal(&conn, &id)
598 .expect("load")
599 .expect("must exist");
600 assert_eq!(row.status, "accepted");
601 assert_eq!(
602 row.installed_path.as_deref(),
603 Some("/path/to/.kimetsu/skills/install-me")
604 );
605 assert!(row.decided_at.is_some());
606 }
607
608 #[test]
609 fn reject_proposal_marks_rejected() {
610 let conn = init_conn();
611 let id = insert_skill_proposal(&conn, "reject-me", "Reject test", None, &[], "cluster", 3)
612 .expect("insert");
613 reject_skill_proposal(&conn, &id).expect("reject");
614
615 let row = load_skill_proposal(&conn, &id)
616 .expect("load")
617 .expect("must exist");
618 assert_eq!(row.status, "rejected");
619 }
620
621 #[test]
622 fn accept_already_decided_proposal_errors() {
623 let conn = init_conn();
624 let id =
625 insert_skill_proposal(&conn, "dup", "dup", None, &[], "citations", 3).expect("insert");
626 accept_skill_proposal(&conn, &id, "/some/path").expect("first accept");
627 let err = accept_skill_proposal(&conn, &id, "/other").expect_err("double-accept");
628 assert!(
629 err.to_string().contains("already decided"),
630 "unexpected: {err}"
631 );
632 }
633
634 #[test]
635 fn report_only_proposal_has_no_draft_content() {
636 let conn = init_conn();
637 let id = insert_skill_proposal(
638 &conn,
639 "report-only",
640 "Report only",
641 None, &["mem-x".to_string()],
643 "citations",
644 5,
645 )
646 .expect("insert");
647 let row = load_skill_proposal(&conn, &id)
648 .expect("load")
649 .expect("must exist");
650 assert!(
651 row.draft_content.is_none(),
652 "report-only must have no draft"
653 );
654 }
655
656 #[test]
661 fn candidate_detected_at_citation_threshold() {
662 let conn = init_conn();
663
664 conn.execute(
666 "INSERT INTO memories
667 (memory_id, scope, kind, text, normalized_text, confidence,
668 provenance_snapshot_json, created_at, use_count, usefulness_score)
669 VALUES ('hot-mem', 'project', 'convention', 'Always run fmt', 'always run fmt',
670 0.9, '{}', '2026-01-01T00:00:00Z', 3, 3.0)",
671 [],
672 )
673 .expect("insert memory");
674
675 for run_id in ["run-1", "run-2", "run-3"] {
676 conn.execute(
677 "INSERT INTO memory_citations (run_id, memory_id, turn, cited_at)
678 VALUES (?1, 'hot-mem', 1, '2026-01-01T00:00:00Z')",
679 params![run_id],
680 )
681 .expect("insert citation");
682 }
683
684 let candidates = find_synthesis_candidates(&conn).expect("find");
685 assert!(
686 candidates.iter().any(|c| c.memory_id == "hot-mem"),
687 "hot-mem must be a synthesis candidate"
688 );
689 let hot = candidates
690 .iter()
691 .find(|c| c.memory_id == "hot-mem")
692 .unwrap();
693 assert_eq!(hot.trigger_kind, "citations");
694 assert_eq!(hot.trigger_count, 3);
695 }
696
697 fn cite_from_distinct_runs(conn: &Connection, memory_id: &str, n: i64) {
703 conn.execute(
704 "INSERT INTO memories
705 (memory_id, scope, kind, text, normalized_text, confidence,
706 provenance_snapshot_json, created_at)
707 VALUES (?1, 'project', 'convention', 'Always run fmt', 'always run fmt',
708 0.9, '{}', '2026-01-01T00:00:00Z')",
709 params![memory_id],
710 )
711 .expect("insert memory");
712 for run in 0..n {
713 conn.execute(
714 "INSERT INTO memory_citations (run_id, memory_id, turn, cited_at)
715 VALUES (?1, ?2, 1, '2026-01-01T00:00:00Z')",
716 params![format!("run-{memory_id}-{run}"), memory_id],
717 )
718 .expect("insert citation");
719 }
720 }
721
722 #[test]
725 fn a_quiet_brain_gets_no_nudge() {
726 let conn = init_conn();
727 assert!(graduation_notice(&conn).is_none());
728 cite_from_distinct_runs(&conn, "cold-mem", CITATION_THRESHOLD - 1);
729 assert!(
730 graduation_notice(&conn).is_none(),
731 "below the threshold is not a graduation"
732 );
733 }
734
735 #[test]
737 fn an_undrafted_candidate_is_surfaced_with_its_command() {
738 let conn = init_conn();
739 cite_from_distinct_runs(&conn, "hot-mem", CITATION_THRESHOLD);
740 let notice = graduation_notice(&conn).expect("surfaced");
741 assert!(notice.contains('1'), "got: {notice}");
742 assert!(
743 notice.contains("kimetsu brain skills --detect"),
744 "a nudge without the command is not actionable; got: {notice}"
745 );
746 }
747
748 #[test]
752 fn a_drafted_candidate_is_reported_as_pending_not_as_a_candidate() {
753 let conn = init_conn();
754 cite_from_distinct_runs(&conn, "hot-mem", CITATION_THRESHOLD);
755 insert_skill_proposal(
756 &conn,
757 "always-run-fmt",
758 "Run cargo fmt before committing",
759 None,
760 &["hot-mem".to_string()],
761 "citations",
762 CITATION_THRESHOLD,
763 )
764 .expect("insert proposal");
765
766 let notice = graduation_notice(&conn).expect("surfaced");
767 assert!(
768 !notice.contains("--detect"),
769 "nothing left to detect; got: {notice}"
770 );
771 assert!(
772 notice.contains("awaiting review"),
773 "the draft is what needs a decision now; got: {notice}"
774 );
775 }
776
777 #[test]
779 fn an_accepted_proposal_ends_the_nudge() {
780 let conn = init_conn();
781 cite_from_distinct_runs(&conn, "hot-mem", CITATION_THRESHOLD);
782 let proposal_id = insert_skill_proposal(
783 &conn,
784 "always-run-fmt",
785 "Run cargo fmt before committing",
786 None,
787 &["hot-mem".to_string()],
788 "citations",
789 CITATION_THRESHOLD,
790 )
791 .expect("insert proposal");
792 accept_skill_proposal(&conn, &proposal_id, "/skills/always-run-fmt").expect("accept");
793
794 assert!(
795 graduation_notice(&conn).is_none(),
796 "an installed skill is a closed loop"
797 );
798 }
799
800 #[test]
801 fn below_threshold_not_a_candidate() {
802 let conn = init_conn();
803
804 conn.execute(
805 "INSERT INTO memories
806 (memory_id, scope, kind, text, normalized_text, confidence,
807 provenance_snapshot_json, created_at, use_count, usefulness_score)
808 VALUES ('cold-mem', 'project', 'convention', 'Run tests', 'run tests',
809 0.9, '{}', '2026-01-01T00:00:00Z', 2, 2.0)",
810 [],
811 )
812 .expect("insert memory");
813
814 for run_id in ["run-a", "run-b"] {
816 conn.execute(
817 "INSERT INTO memory_citations (run_id, memory_id, turn, cited_at)
818 VALUES (?1, 'cold-mem', 1, '2026-01-01T00:00:00Z')",
819 params![run_id],
820 )
821 .expect("insert citation");
822 }
823
824 let candidates = find_synthesis_candidates(&conn).expect("find");
825 assert!(
826 !candidates.iter().any(|c| c.memory_id == "cold-mem"),
827 "cold-mem must NOT be a candidate (only 2 citations, threshold=3)"
828 );
829 }
830
831 #[test]
832 fn superseded_memory_excluded_from_candidates() {
833 let conn = init_conn();
834
835 conn.execute(
836 "INSERT INTO memories
837 (memory_id, scope, kind, text, normalized_text, confidence,
838 provenance_snapshot_json, created_at, use_count, usefulness_score,
839 superseded_by)
840 VALUES ('super-mem', 'project', 'convention', 'Old lesson', 'old lesson',
841 0.9, '{}', '2026-01-01T00:00:00Z', 3, 3.0, 'survivor-mem')",
842 [],
843 )
844 .expect("insert superseded memory");
845
846 for run_id in ["run-x", "run-y", "run-z"] {
847 conn.execute(
848 "INSERT INTO memory_citations (run_id, memory_id, turn, cited_at)
849 VALUES (?1, 'super-mem', 1, '2026-01-01T00:00:00Z')",
850 params![run_id],
851 )
852 .expect("insert citation");
853 }
854
855 let candidates = find_synthesis_candidates(&conn).expect("find");
856 assert!(
857 !candidates.iter().any(|c| c.memory_id == "super-mem"),
858 "superseded memory must not be a candidate"
859 );
860 }
861
862 #[test]
867 fn staleness_check_flags_superseded_source() {
868 let conn = init_conn();
869
870 conn.execute(
873 "INSERT INTO memories
874 (memory_id, scope, kind, text, normalized_text, confidence,
875 provenance_snapshot_json, created_at, use_count, usefulness_score,
876 superseded_by)
877 VALUES ('stale-src', 'project', 'convention', 'Old', 'old',
878 0.9, '{}', '2026-01-01T00:00:00Z', 1, 1.0, 'other-mem')",
879 [],
880 )
881 .expect("insert stale source");
882
883 let proposal_id = insert_skill_proposal(
884 &conn,
885 "stale-skill",
886 "Uses stale source",
887 Some("# Stale Skill"),
888 &["stale-src".to_string()],
889 "citations",
890 3,
891 )
892 .expect("insert proposal");
893 accept_skill_proposal(&conn, &proposal_id, "/tmp/stale-skill").expect("accept");
894
895 let reports = check_staleness(&conn).expect("staleness");
896 let stale = reports.iter().find(|r| r.proposal_id == proposal_id);
897 assert!(stale.is_some(), "proposal must appear in staleness report");
898 let stale = stale.unwrap();
899 assert!(
900 stale.is_stale,
901 "skill with superseded source must be flagged stale"
902 );
903 assert!(
904 stale.stale_memory_ids.contains(&"stale-src".to_string()),
905 "stale-src must be in stale_memory_ids"
906 );
907 }
908
909 #[test]
910 fn staleness_check_ok_for_live_source() {
911 let conn = init_conn();
912
913 conn.execute(
914 "INSERT INTO memories
915 (memory_id, scope, kind, text, normalized_text, confidence,
916 provenance_snapshot_json, created_at, use_count, usefulness_score)
917 VALUES ('live-src', 'project', 'convention', 'Current lesson', 'current lesson',
918 0.9, '{}', '2026-01-01T00:00:00Z', 3, 3.0)",
919 [],
920 )
921 .expect("insert live source");
922
923 let proposal_id = insert_skill_proposal(
924 &conn,
925 "live-skill",
926 "Uses live source",
927 Some("# Live Skill"),
928 &["live-src".to_string()],
929 "citations",
930 3,
931 )
932 .expect("insert proposal");
933 accept_skill_proposal(&conn, &proposal_id, "/tmp/live-skill").expect("accept");
934
935 let reports = check_staleness(&conn).expect("staleness");
936 let report = reports.iter().find(|r| r.proposal_id == proposal_id);
937 assert!(report.is_some(), "proposal must appear in staleness report");
938 let report = report.unwrap();
939 assert!(
940 !report.is_stale,
941 "skill with live source must NOT be flagged stale"
942 );
943 }
944}