From 77475b773337c03951d2d28f087230c7faf5a07f Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 1 Jul 2026 17:09:05 +0000 Subject: [PATCH 1/2] fix: widen skill writer evidence scope --- src/automation/runner.rs | 76 +++++++++++++++----- src/automation_cli.rs | 4 ++ src/cli/automation.rs | 10 ++- src/cli/parse_tests.rs | 26 +++++++ src/dashboard/automation_run_api.rs | 4 ++ src/dashboard/automation_run_service.rs | 8 +++ tests/automation_skill_writer_runner_test.rs | 76 ++++++++++++++++++++ 7 files changed, 186 insertions(+), 18 deletions(-) diff --git a/src/automation/runner.rs b/src/automation/runner.rs index 1ed540a3..143a36a0 100644 --- a/src/automation/runner.rs +++ b/src/automation/runner.rs @@ -108,6 +108,10 @@ pub struct SkillWriterAutomationOptions { pub trigger: AutomationTrigger, #[serde(default, skip_serializing_if = "Option::is_none")] pub run_id: Option, + #[serde(default = "default_lcm_storage_scope")] + pub storage_scope: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hermes_home: Option, #[serde(default = "default_skill_writer_provider")] pub provider: String, #[serde(default = "default_skill_writer_query")] @@ -123,6 +127,8 @@ impl Default for SkillWriterAutomationOptions { Self { trigger: AutomationTrigger::ManualCli, run_id: None, + storage_scope: default_lcm_storage_scope(), + hermes_home: None, provider: default_skill_writer_provider(), query: default_skill_writer_query(), evidence_limit: default_skill_writer_evidence_limit(), @@ -154,7 +160,7 @@ enum SkillWriterEvidenceOutcome { }, } -enum SessionReflectorLcmStore { +enum LcmAutomationStore { Available(PathBuf), NotIngested, } @@ -190,9 +196,14 @@ pub async fn run_session_reflector_with_backend( } }; - let sessions_db_path = match session_reflector_lcm_db_path(cg, &storage_scope, &options)? { - SessionReflectorLcmStore::Available(path) => path, - SessionReflectorLcmStore::NotIngested => { + let sessions_db_path = match automation_lcm_db_path( + cg, + &storage_scope, + options.hermes_home.as_ref(), + "session_reflector", + )? { + LcmAutomationStore::Available(path) => path, + LcmAutomationStore::NotIngested => { return skipped_session_reflector_run(&run, "lcm_not_ingested", None).await; } }; @@ -500,12 +511,27 @@ async fn build_skill_writer_evidence( Some(path) => path, None => crate::storage::default_profile_root()?, }; + let storage_scope = + normalized_non_empty(&options.storage_scope).unwrap_or_else(default_lcm_storage_scope); let provider = normalized_non_empty(&options.provider).unwrap_or_else(default_skill_writer_provider); let query = normalized_non_empty(&options.query).unwrap_or_else(default_skill_writer_query); let evidence_limit = options.evidence_limit.clamp(1, 50); - let sessions_db_path = cg.store_layout().sessions_db_path.clone(); + let sessions_db_path = match automation_lcm_db_path( + cg, + &storage_scope, + options.hermes_home.as_ref(), + "skill_writer", + )? { + LcmAutomationStore::Available(path) => path, + LcmAutomationStore::NotIngested => { + return Ok(SkillWriterEvidenceOutcome::Skipped { + reason: "lcm_not_ingested", + evidence_hash: None, + }); + } + }; if !sessions_db_path.is_file() { return Ok(SkillWriterEvidenceOutcome::Skipped { reason: "lcm_not_ingested", @@ -569,6 +595,8 @@ async fn build_skill_writer_evidence( &underused_tool_families, ); let evidence = json!({ + "storage_scope": storage_scope, + "hermes_home": options.hermes_home.as_ref().map(|path| path.display().to_string()), "provider": provider, "query": query, "hits": hits, @@ -650,7 +678,7 @@ async fn skipped_skill_writer_run( fn build_session_reflector_prompt(evidence: &Value) -> String { format!( - "Review these bounded TraceDecay session snippets and propose only durable memory facts. Return only JSON with a facts array. Each fact must include content, category, optional tags, optional entities, trust, source_span, and reason. Category must be one of general, user_pref, project, tool, decision, or code_area. Use trust, not confidence. source_span must cite one bounded evidence hit by session_id plus message_id for raw messages, by store_id for raw messages, or by node_id for summaries. Do not include secrets or ephemeral status.\n{}", + "Review these bounded TraceDecay session snippets and propose only durable memory facts. Return only JSON with a facts array. Each fact must include content, category, optional tags, optional entities, trust, source_span, and reason. Category must be one of general, user_pref, project, tool, decision, or code_area. Use trust, not confidence; trust must be a JSON number from 0.0 to 1.0. Do not use string labels like high, medium, or low. source_span must cite one bounded evidence hit by session_id plus message_id for raw messages, by store_id for raw messages, or by node_id for summaries. Do not include secrets or ephemeral status.\n{}", serde_json::to_string_pretty(evidence).unwrap_or_else(|_| "{}".to_string()) ) } @@ -671,30 +699,46 @@ fn normalized_non_empty(value: &str) -> Option { } } -fn session_reflector_lcm_db_path( +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::build_session_reflector_prompt; + + #[test] + fn session_reflector_prompt_requires_numeric_trust() { + let prompt = build_session_reflector_prompt(&json!({"hits": []})); + + assert!(prompt.contains("trust must be a JSON number from 0.0 to 1.0")); + assert!(prompt.contains("Do not use string labels like high, medium, or low")); + } +} + +fn automation_lcm_db_path( cg: &TraceDecay, storage_scope: &str, - options: &SessionReflectorAutomationOptions, -) -> Result { + hermes_home: Option<&PathBuf>, + task_name: &str, +) -> Result { match storage_scope { - "project_local" => Ok(SessionReflectorLcmStore::Available( + "project_local" => Ok(LcmAutomationStore::Available( cg.store_layout().sessions_db_path.clone(), )), "hermes_profile" => { - let hermes_home = options.hermes_home.as_ref().ok_or_else(|| TraceDecayError::Config { - message: "session_reflector hermes_profile storage requires hermes_home".to_string(), + let hermes_home = hermes_home.ok_or_else(|| TraceDecayError::Config { + message: format!("{task_name} hermes_profile storage requires hermes_home"), })?; match resolve_hermes_profile_session_db_readonly(hermes_home) { - HermesProfileDbReadOnly::Exists(path) => Ok(SessionReflectorLcmStore::Available(path)), - HermesProfileDbReadOnly::NotIngested(_) => Ok(SessionReflectorLcmStore::NotIngested), + HermesProfileDbReadOnly::Exists(path) => Ok(LcmAutomationStore::Available(path)), + HermesProfileDbReadOnly::NotIngested(_) => Ok(LcmAutomationStore::NotIngested), HermesProfileDbReadOnly::ConfigError(message) => Err(TraceDecayError::Config { - message: format!("invalid session_reflector hermes_home: {message}"), + message: format!("invalid {task_name} hermes_home: {message}"), }), } } other => Err(TraceDecayError::Config { message: format!( - "unknown session_reflector storage_scope '{other}'; expected project_local or hermes_profile" + "unknown {task_name} storage_scope '{other}'; expected project_local or hermes_profile" ), }), } diff --git a/src/automation_cli.rs b/src/automation_cli.rs index 539ad049..72ae0e93 100644 --- a/src/automation_cli.rs +++ b/src/automation_cli.rs @@ -522,6 +522,8 @@ async fn handle_automation_run_command( provider, query, evidence_limit, + storage_scope, + hermes_home, path, } => { if !dry_run { @@ -550,6 +552,8 @@ async fn handle_automation_run_command( SkillWriterAutomationOptions { trigger: tracedecay::automation::run_ledger::AutomationTrigger::ManualCli, run_id: None, + storage_scope, + hermes_home: hermes_home.map(Into::into), provider, query, evidence_limit, diff --git a/src/cli/automation.rs b/src/cli/automation.rs index 4e14a936..dc891214 100644 --- a/src/cli/automation.rs +++ b/src/cli/automation.rs @@ -250,8 +250,8 @@ pub enum AutomationRunAction { /// Keep the run non-mutating. This is currently the only supported mode. #[arg(long, default_value_t = true, action = clap::ArgAction::Set)] dry_run: bool, - /// LCM provider to inspect. - #[arg(long, default_value = "cursor")] + /// LCM provider to inspect. Use all for unified cross-provider evidence. + #[arg(long, default_value = "all")] provider: String, /// LCM grep query used to collect bounded evidence. #[arg( @@ -262,6 +262,12 @@ pub enum AutomationRunAction { /// Maximum LCM evidence snippets included in the backend review request. #[arg(long, default_value_t = 20)] evidence_limit: usize, + /// LCM storage scope: project_local or hermes_profile. + #[arg(long, default_value = "project_local")] + storage_scope: String, + /// Absolute Hermes profile home directory when --storage-scope hermes_profile. + #[arg(long)] + hermes_home: Option, /// Project path (default: current directory, with discovery). #[arg(short, long)] path: Option, diff --git a/src/cli/parse_tests.rs b/src/cli/parse_tests.rs index 862ce41a..03023db1 100644 --- a/src/cli/parse_tests.rs +++ b/src/cli/parse_tests.rs @@ -739,6 +739,8 @@ fn automation_run_skill_writing_parses_manual_dry_run_flags() { provider, query, evidence_limit, + storage_scope, + hermes_home, path, } } @@ -746,10 +748,34 @@ fn automation_run_skill_writing_parses_manual_dry_run_flags() { && provider == "cursor" && query == "workflow corrections" && evidence_limit == 9 + && storage_scope == "project_local" + && hermes_home.is_none() && path.as_deref() == Some("/tmp/project") )); } +#[test] +fn automation_run_skill_writing_defaults_to_all_providers() { + let cli = Cli::try_parse_from(["tracedecay", "automation", "run", "skill-writing"]) + .expect("automation skill-writing run should parse with defaults"); + + assert!(matches!( + cli.command, + Some(Commands::Automation { + action: + AutomationAction::Run { + action: + AutomationRunAction::SkillWriting { + provider, + storage_scope, + hermes_home, + .. + } + } + }) if provider == "all" && storage_scope == "project_local" && hermes_home.is_none() + )); +} + #[test] fn automation_runs_commands_parse_history_flags() { let list = Cli::try_parse_from([ diff --git a/src/dashboard/automation_run_api.rs b/src/dashboard/automation_run_api.rs index e7737e3e..45f8e4a8 100644 --- a/src/dashboard/automation_run_api.rs +++ b/src/dashboard/automation_run_api.rs @@ -106,6 +106,8 @@ pub(crate) struct SkillWritingRunBody { provider: Option, query: Option, evidence_limit: Option, + storage_scope: Option, + hermes_home: Option, } impl From for SkillWritingRunRequest { @@ -114,6 +116,8 @@ impl From for SkillWritingRunRequest { provider: body.provider, query: body.query, evidence_limit: body.evidence_limit, + storage_scope: body.storage_scope, + hermes_home: body.hermes_home, } } } diff --git a/src/dashboard/automation_run_service.rs b/src/dashboard/automation_run_service.rs index 593f48e6..59cd55e2 100644 --- a/src/dashboard/automation_run_service.rs +++ b/src/dashboard/automation_run_service.rs @@ -250,6 +250,8 @@ pub(crate) struct SkillWritingRunRequest { pub provider: Option, pub query: Option, pub evidence_limit: Option, + pub storage_scope: Option, + pub hermes_home: Option, } pub(crate) async fn session_reflection_run_payload_with_run_id( @@ -395,6 +397,12 @@ pub(crate) async fn skill_writing_run_payload_with_run_id( if let Some(evidence_limit) = request.evidence_limit { options.evidence_limit = evidence_limit; } + if let Some(storage_scope) = request.storage_scope { + options.storage_scope = storage_scope; + } + if let Some(hermes_home) = request.hermes_home { + options.hermes_home = Some(hermes_home); + } let run = match run_skill_writer_with_backend( &run_context.cg, &run_context.config, diff --git a/tests/automation_skill_writer_runner_test.rs b/tests/automation_skill_writer_runner_test.rs index 02b7ea01..c7dab1c3 100644 --- a/tests/automation_skill_writer_runner_test.rs +++ b/tests/automation_skill_writer_runner_test.rs @@ -89,6 +89,68 @@ async fn skill_writer_default_provider_searches_all_providers() { assert_eq!(run.ledger_record.status, AutomationRunStatus::Succeeded); } +#[tokio::test] +async fn skill_writer_runner_reads_hermes_profile_lcm() { + let temp = tempdir().unwrap(); + let profile_root = temp.path().join("profile"); + let cg = init_project(temp.path()).await; + + let hermes_home = tempdir().unwrap(); + let profile_db_path = resolve_hermes_profile_session_db_path(hermes_home.path()).unwrap(); + let profile_db = GlobalDb::open_at(&profile_db_path) + .await + .expect("hermes profile session db open"); + seed_session_message_in_db( + &profile_db, + hermes_home.path(), + SeedSessionMessage { + provider: "cursor", + session_id: "hermes-skill-writer-1", + message_id: "hermes-skill-writer-1-message-001", + role: "assistant", + timestamp: 1_715_100_005, + text: "Hermes profile-only skill writer evidence should draft reusable workflow guidance.", + source: Some("hermes_profile_lcm"), + }, + ) + .await; + + let backend = SkillJsonBackend::new(json!({"skills": []})); + let config = AutomationConfig { + enabled: true, + backend: AutomationBackend::CodexAppServer, + host_mode: AutomationHostMode::Standalone, + tasks: AutomationTaskSet { + skill_writer: AutomationTaskConfig { + enabled: true, + schedule: Some("manual".to_string()), + ..AutomationTaskConfig::default() + }, + ..AutomationTaskSet::default() + }, + ..AutomationConfig::default() + }; + + let run = run_skill_writer_with_backend( + &cg, + &config, + &backend, + SkillWriterAutomationOptions { + storage_scope: "hermes_profile".to_string(), + hermes_home: Some(hermes_home.path().to_path_buf()), + provider: "cursor".to_string(), + query: "profile-only skill writer evidence".to_string(), + profile_root: Some(profile_root), + ..SkillWriterAutomationOptions::default() + }, + ) + .await + .unwrap(); + + assert_eq!(backend.calls(), 1); + assert_eq!(run.ledger_record.status, AutomationRunStatus::Succeeded); +} + #[tokio::test] async fn skill_writer_runner_creates_pending_skill_drafts_for_approval() { let temp = tempdir().unwrap(); @@ -155,6 +217,8 @@ async fn skill_writer_runner_creates_pending_skill_drafts_for_approval() { evidence_limit: 5, profile_root: Some(profile_root.clone()), run_id: None, + storage_scope: "project_local".to_string(), + hermes_home: None, }, ) .await @@ -372,6 +436,8 @@ async fn skill_writer_evidence_imports_project_skill_usage_analytics_before_summ evidence_limit: 5, profile_root: Some(profile_root), run_id: None, + storage_scope: "project_local".to_string(), + hermes_home: None, }, ) .await @@ -508,6 +574,8 @@ async fn skill_writer_runner_auto_enables_when_config_explicitly_allows() { evidence_limit: 5, profile_root: Some(profile_root.clone()), run_id: None, + storage_scope: "project_local".to_string(), + hermes_home: None, }, ) .await @@ -660,6 +728,8 @@ async fn skill_writer_runner_updates_existing_skills_with_checksum_precondition( evidence_limit: 5, profile_root: Some(profile_root.clone()), run_id: None, + storage_scope: "project_local".to_string(), + hermes_home: None, }, ) .await @@ -795,6 +865,8 @@ async fn skill_writer_runner_ledgers_malformed_backend_output() { evidence_limit: 5, profile_root: Some(profile_root), run_id: None, + storage_scope: "project_local".to_string(), + hermes_home: None, }, ) .await @@ -864,6 +936,8 @@ async fn skill_writer_runner_ledgers_missing_skills_array() { evidence_limit: 5, profile_root: Some(profile_root), run_id: None, + storage_scope: "project_local".to_string(), + hermes_home: None, }, ) .await @@ -929,6 +1003,8 @@ async fn skill_writer_runner_records_noop_fallback_when_backend_run_task_fails() evidence_limit: 5, run_id: None, profile_root: Some(profile_root), + storage_scope: "project_local".to_string(), + hermes_home: None, }, ) .await From aabe8259a6788082cdeec7dcbb07bd9111b32bc4 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 1 Jul 2026 22:00:40 +0000 Subject: [PATCH 2/2] refactor: consolidate lcm store availability in automation resolver Address PR #170 review findings: fold the sessions-db existence check into automation_lcm_db_path so both runner call sites drop their duplicated skip branches, dedupe the skill writer runner test file with shared config/options builders (1035 -> 846 lines), replace the tautological prompt wording test with a behavioral rejection case for string trust labels, parse --hermes-home as PathBuf, and update the dashboard skill-writing API test for the now-supported storage_scope field. --- src/automation/runner.rs | 38 +-- src/automation_cli.rs | 4 +- src/cli/automation.rs | 6 +- src/cli/parse_tests.rs | 4 +- tests/automation_runner_support/mod.rs | 31 +++ ...utomation_session_reflector_runner_test.rs | 16 +- tests/automation_skill_writer_runner_test.rs | 223 ++---------------- tests/dashboard_automation_api_test.rs | 7 +- 8 files changed, 85 insertions(+), 244 deletions(-) diff --git a/src/automation/runner.rs b/src/automation/runner.rs index 143a36a0..31535a4a 100644 --- a/src/automation/runner.rs +++ b/src/automation/runner.rs @@ -207,9 +207,6 @@ pub async fn run_session_reflector_with_backend( return skipped_session_reflector_run(&run, "lcm_not_ingested", None).await; } }; - if !sessions_db_path.is_file() { - return skipped_session_reflector_run(&run, "lcm_not_ingested", None).await; - } let Some(lcm_db) = GlobalDb::open_read_only_at(&sessions_db_path).await else { return skipped_session_reflector_run(&run, "lcm_unavailable", None).await; }; @@ -532,12 +529,6 @@ async fn build_skill_writer_evidence( }); } }; - if !sessions_db_path.is_file() { - return Ok(SkillWriterEvidenceOutcome::Skipped { - reason: "lcm_not_ingested", - evidence_hash: None, - }); - } let Some(lcm_db) = GlobalDb::open_read_only_at(&sessions_db_path).await else { return Ok(SkillWriterEvidenceOutcome::Skipped { reason: "lcm_unavailable", @@ -699,21 +690,9 @@ fn normalized_non_empty(value: &str) -> Option { } } -#[cfg(test)] -mod tests { - use serde_json::json; - - use super::build_session_reflector_prompt; - - #[test] - fn session_reflector_prompt_requires_numeric_trust() { - let prompt = build_session_reflector_prompt(&json!({"hits": []})); - - assert!(prompt.contains("trust must be a JSON number from 0.0 to 1.0")); - assert!(prompt.contains("Do not use string labels like high, medium, or low")); - } -} - +/// Resolves the LCM sessions database for an automation task, reporting +/// `NotIngested` when the store does not exist yet so callers can skip +/// without re-checking the path. fn automation_lcm_db_path( cg: &TraceDecay, storage_scope: &str, @@ -721,9 +700,14 @@ fn automation_lcm_db_path( task_name: &str, ) -> Result { match storage_scope { - "project_local" => Ok(LcmAutomationStore::Available( - cg.store_layout().sessions_db_path.clone(), - )), + "project_local" => { + let path = cg.store_layout().sessions_db_path.clone(); + if path.is_file() { + Ok(LcmAutomationStore::Available(path)) + } else { + Ok(LcmAutomationStore::NotIngested) + } + } "hermes_profile" => { let hermes_home = hermes_home.ok_or_else(|| TraceDecayError::Config { message: format!("{task_name} hermes_profile storage requires hermes_home"), diff --git a/src/automation_cli.rs b/src/automation_cli.rs index 72ae0e93..35642c74 100644 --- a/src/automation_cli.rs +++ b/src/automation_cli.rs @@ -500,7 +500,7 @@ async fn handle_automation_run_command( trigger: tracedecay::automation::run_ledger::AutomationTrigger::ManualCli, run_id: None, storage_scope, - hermes_home: hermes_home.map(Into::into), + hermes_home, provider, query, scope: lcm_scope, @@ -553,7 +553,7 @@ async fn handle_automation_run_command( trigger: tracedecay::automation::run_ledger::AutomationTrigger::ManualCli, run_id: None, storage_scope, - hermes_home: hermes_home.map(Into::into), + hermes_home, provider, query, evidence_limit, diff --git a/src/cli/automation.rs b/src/cli/automation.rs index dc891214..e20ff377 100644 --- a/src/cli/automation.rs +++ b/src/cli/automation.rs @@ -1,3 +1,5 @@ +use std::path::PathBuf; + use clap::{Subcommand, ValueEnum}; #[allow(clippy::large_enum_variant)] @@ -215,7 +217,7 @@ pub enum AutomationRunAction { storage_scope: String, /// Absolute Hermes profile home directory when --storage-scope hermes_profile. #[arg(long)] - hermes_home: Option, + hermes_home: Option, /// LCM grep scope: all, session, or current. #[arg(long, default_value = "all")] scope: String, @@ -267,7 +269,7 @@ pub enum AutomationRunAction { storage_scope: String, /// Absolute Hermes profile home directory when --storage-scope hermes_profile. #[arg(long)] - hermes_home: Option, + hermes_home: Option, /// Project path (default: current directory, with discovery). #[arg(short, long)] path: Option, diff --git a/src/cli/parse_tests.rs b/src/cli/parse_tests.rs index 03023db1..ad6f2076 100644 --- a/src/cli/parse_tests.rs +++ b/src/cli/parse_tests.rs @@ -1,3 +1,5 @@ +use std::path::Path; + use super::{ AutomationAction, AutomationConfigAction, AutomationConfigScope, AutomationRunAction, AutomationRunsAction, AutomationSkillsAction, AutomationSkillsInstallTarget, BranchAction, Cli, @@ -695,7 +697,7 @@ fn automation_run_session_reflection_parses_manual_dry_run_flags() { && query == "remember decisions" && evidence_limit == 12 && storage_scope == "hermes_profile" - && hermes_home.as_deref() == Some("/tmp/hermes-profile") + && hermes_home.as_deref() == Some(Path::new("/tmp/hermes-profile")) && scope == "session" && session_id.as_deref() == Some("session-123") && !include_summaries diff --git a/tests/automation_runner_support/mod.rs b/tests/automation_runner_support/mod.rs index 45503824..e7fdb2bc 100644 --- a/tests/automation_runner_support/mod.rs +++ b/tests/automation_runner_support/mod.rs @@ -740,6 +740,37 @@ pub(crate) async fn read_artifact( .unwrap() } +/// Standalone automation config with only the skill writer task enabled on a +/// manual schedule; override fields with struct update syntax where needed. +pub(crate) fn enabled_skill_writer_config() -> AutomationConfig { + AutomationConfig { + enabled: true, + backend: AutomationBackend::CodexAppServer, + host_mode: AutomationHostMode::Standalone, + tasks: AutomationTaskSet { + skill_writer: AutomationTaskConfig { + enabled: true, + schedule: Some("manual".to_string()), + ..AutomationTaskConfig::default() + }, + ..AutomationTaskSet::default() + }, + ..AutomationConfig::default() + } +} + +/// Manual-trigger skill writer options matching the seeded "automation" fixture +/// evidence, rooted at the given managed-skill profile directory. +pub(crate) fn manual_skill_writer_options(profile_root: &Path) -> SkillWriterAutomationOptions { + SkillWriterAutomationOptions { + provider: "cursor".to_string(), + query: "automation".to_string(), + evidence_limit: 5, + profile_root: Some(profile_root.to_path_buf()), + ..SkillWriterAutomationOptions::default() + } +} + pub(crate) fn scheduler_config( interval_secs: Option, cooldown_secs: Option, diff --git a/tests/automation_session_reflector_runner_test.rs b/tests/automation_session_reflector_runner_test.rs index d6346c28..49a93539 100644 --- a/tests/automation_session_reflector_runner_test.rs +++ b/tests/automation_session_reflector_runner_test.rs @@ -93,6 +93,15 @@ async fn session_reflector_runner_validates_fact_proposals_without_applying() { "source_span": {"session_id": "session-reflect-1", "message_id": "session-reflect-1-message-001"}, "reason": "missing trust should be rejected" }, + { + "content": "Session reflection trust must be a numeric score", + "category": "project", + "tags": ["automation"], + "entities": ["TraceDecay"], + "trust": "high", + "source_span": {"session_id": "session-reflect-1", "message_id": "session-reflect-1-message-001"}, + "reason": "string trust labels should be rejected" + }, { "content": "Session reflection facts require a rationale", "category": "project", @@ -153,7 +162,7 @@ async fn session_reflector_runner_validates_fact_proposals_without_applying() { assert_eq!(run.ledger_record.task, AgentTaskKind::SessionReflector); assert_eq!(run.ledger_record.status, AutomationRunStatus::Succeeded); assert_eq!(run.ledger_record.accepted_count, 2); - assert_eq!(run.ledger_record.rejected_count, 7); + assert_eq!(run.ledger_record.rejected_count, 8); assert_eq!( run.report["accepted_facts"][0]["add_fact_request"]["source"], json!("session_reflector") @@ -189,6 +198,7 @@ async fn session_reflector_runner_validates_fact_proposals_without_applying() { "source_span must cite a bounded session reflection evidence hit" )); assert!(has_rejection_reason("trust is required")); + assert!(has_rejection_reason("trust must be between 0 and 1")); assert!(has_rejection_reason("reason is required")); assert!(has_rejection_reason( "confidence is not supported; use trust" @@ -249,7 +259,7 @@ async fn session_reflector_runner_validates_fact_proposals_without_applying() { ); let eval_payload = read_artifact(&cg, &run.run_id, &run.ledger_record, "generated_evals").await; assert_eq!(eval_payload["task"], json!("session_reflector")); - assert_eq!(eval_payload["summary"]["eval_count"], json!(9)); + assert_eq!(eval_payload["summary"]["eval_count"], json!(10)); assert!(eval_payload["eval_definitions"] .as_array() .unwrap() @@ -329,7 +339,7 @@ async fn session_reflector_runner_validates_fact_proposals_without_applying() { assert_eq!(records.len(), 1); assert_eq!(records[0].run_id, run.run_id); assert_eq!(records[0].accepted_count, 2); - assert_eq!(records[0].rejected_count, 7); + assert_eq!(records[0].rejected_count, 8); assert!(records[0].applied_ops.is_none()); } diff --git a/tests/automation_skill_writer_runner_test.rs b/tests/automation_skill_writer_runner_test.rs index c7dab1c3..8bb038f1 100644 --- a/tests/automation_skill_writer_runner_test.rs +++ b/tests/automation_skill_writer_runner_test.rs @@ -58,20 +58,7 @@ async fn skill_writer_default_provider_searches_all_providers() { ) .await; let backend = SkillJsonBackend::new(json!({"skills": []})); - let config = AutomationConfig { - enabled: true, - backend: AutomationBackend::CodexAppServer, - host_mode: AutomationHostMode::Standalone, - tasks: AutomationTaskSet { - skill_writer: AutomationTaskConfig { - enabled: true, - schedule: Some("manual".to_string()), - ..AutomationTaskConfig::default() - }, - ..AutomationTaskSet::default() - }, - ..AutomationConfig::default() - }; + let config = enabled_skill_writer_config(); let run = run_skill_writer_with_backend( &cg, @@ -116,20 +103,7 @@ async fn skill_writer_runner_reads_hermes_profile_lcm() { .await; let backend = SkillJsonBackend::new(json!({"skills": []})); - let config = AutomationConfig { - enabled: true, - backend: AutomationBackend::CodexAppServer, - host_mode: AutomationHostMode::Standalone, - tasks: AutomationTaskSet { - skill_writer: AutomationTaskConfig { - enabled: true, - schedule: Some("manual".to_string()), - ..AutomationTaskConfig::default() - }, - ..AutomationTaskSet::default() - }, - ..AutomationConfig::default() - }; + let config = enabled_skill_writer_config(); let run = run_skill_writer_with_backend( &cg, @@ -191,35 +165,15 @@ async fn skill_writer_runner_creates_pending_skill_drafts_for_approval() { ] })); let config = AutomationConfig { - enabled: true, - backend: AutomationBackend::CodexAppServer, - host_mode: AutomationHostMode::Standalone, model: Some("configured-model".to_string()), - tasks: AutomationTaskSet { - skill_writer: AutomationTaskConfig { - enabled: true, - schedule: Some("manual".to_string()), - ..AutomationTaskConfig::default() - }, - ..AutomationTaskSet::default() - }, - ..AutomationConfig::default() + ..enabled_skill_writer_config() }; let run = run_skill_writer_with_backend( &cg, &config, &backend, - SkillWriterAutomationOptions { - trigger: AutomationTrigger::ManualCli, - provider: "cursor".to_string(), - query: "automation".to_string(), - evidence_limit: 5, - profile_root: Some(profile_root.clone()), - run_id: None, - storage_scope: "project_local".to_string(), - hermes_home: None, - }, + manual_skill_writer_options(&profile_root), ) .await .unwrap(); @@ -410,35 +364,13 @@ async fn skill_writer_evidence_imports_project_skill_usage_analytics_before_summ .await .unwrap(); let backend = InspectSkillWriterUsageBackend; - let config = AutomationConfig { - enabled: true, - backend: AutomationBackend::CodexAppServer, - host_mode: AutomationHostMode::Standalone, - tasks: AutomationTaskSet { - skill_writer: AutomationTaskConfig { - enabled: true, - schedule: Some("manual".to_string()), - ..AutomationTaskConfig::default() - }, - ..AutomationTaskSet::default() - }, - ..AutomationConfig::default() - }; + let config = enabled_skill_writer_config(); let run = run_skill_writer_with_backend( &cg, &config, &backend, - SkillWriterAutomationOptions { - trigger: AutomationTrigger::ManualCli, - provider: "cursor".to_string(), - query: "automation".to_string(), - evidence_limit: 5, - profile_root: Some(profile_root), - run_id: None, - storage_scope: "project_local".to_string(), - hermes_home: None, - }, + manual_skill_writer_options(&profile_root), ) .await .unwrap(); @@ -461,20 +393,7 @@ async fn skill_writer_evidence_includes_underused_tool_family_summary() { seed_session_evidence(&cg).await; seed_search_underuse_session_evidence(&cg).await; let backend = InspectSkillWriterUnderusedBackend; - let config = AutomationConfig { - enabled: true, - backend: AutomationBackend::CodexAppServer, - host_mode: AutomationHostMode::Standalone, - tasks: AutomationTaskSet { - skill_writer: AutomationTaskConfig { - enabled: true, - schedule: Some("manual".to_string()), - ..AutomationTaskConfig::default() - }, - ..AutomationTaskSet::default() - }, - ..AutomationConfig::default() - }; + let config = enabled_skill_writer_config(); let run = run_skill_writer_with_backend( &cg, @@ -548,35 +467,15 @@ async fn skill_writer_runner_auto_enables_when_config_explicitly_allows() { "auto_enable_after_validation", ); let config = AutomationConfig { - enabled: true, - backend: AutomationBackend::CodexAppServer, - host_mode: AutomationHostMode::Standalone, auto_enable_skills: true, - tasks: AutomationTaskSet { - skill_writer: AutomationTaskConfig { - enabled: true, - schedule: Some("manual".to_string()), - ..AutomationTaskConfig::default() - }, - ..AutomationTaskSet::default() - }, - ..AutomationConfig::default() + ..enabled_skill_writer_config() }; let run = run_skill_writer_with_backend( &cg, &config, &backend, - SkillWriterAutomationOptions { - trigger: AutomationTrigger::ManualCli, - provider: "cursor".to_string(), - query: "automation".to_string(), - evidence_limit: 5, - profile_root: Some(profile_root.clone()), - run_id: None, - storage_scope: "project_local".to_string(), - hermes_home: None, - }, + manual_skill_writer_options(&profile_root), ) .await .unwrap(); @@ -702,35 +601,13 @@ async fn skill_writer_runner_updates_existing_skills_with_checksum_precondition( } ] })); - let config = AutomationConfig { - enabled: true, - backend: AutomationBackend::CodexAppServer, - host_mode: AutomationHostMode::Standalone, - tasks: AutomationTaskSet { - skill_writer: AutomationTaskConfig { - enabled: true, - schedule: Some("manual".to_string()), - ..AutomationTaskConfig::default() - }, - ..AutomationTaskSet::default() - }, - ..AutomationConfig::default() - }; + let config = enabled_skill_writer_config(); let run = run_skill_writer_with_backend( &cg, &config, &backend, - SkillWriterAutomationOptions { - trigger: AutomationTrigger::ManualCli, - provider: "cursor".to_string(), - query: "automation".to_string(), - evidence_limit: 5, - profile_root: Some(profile_root.clone()), - run_id: None, - storage_scope: "project_local".to_string(), - hermes_home: None, - }, + manual_skill_writer_options(&profile_root), ) .await .unwrap(); @@ -839,35 +716,13 @@ async fn skill_writer_runner_ledgers_malformed_backend_output() { let cg = init_project(temp.path()).await; seed_session_evidence(&cg).await; let backend = SkillTextBackend::new("not json"); - let config = AutomationConfig { - enabled: true, - backend: AutomationBackend::CodexAppServer, - host_mode: AutomationHostMode::Standalone, - tasks: AutomationTaskSet { - skill_writer: AutomationTaskConfig { - enabled: true, - schedule: Some("manual".to_string()), - ..AutomationTaskConfig::default() - }, - ..AutomationTaskSet::default() - }, - ..AutomationConfig::default() - }; + let config = enabled_skill_writer_config(); let err = run_skill_writer_with_backend( &cg, &config, &backend, - SkillWriterAutomationOptions { - trigger: AutomationTrigger::ManualCli, - provider: "cursor".to_string(), - query: "automation".to_string(), - evidence_limit: 5, - profile_root: Some(profile_root), - run_id: None, - storage_scope: "project_local".to_string(), - hermes_home: None, - }, + manual_skill_writer_options(&profile_root), ) .await .unwrap_err(); @@ -910,35 +765,13 @@ async fn skill_writer_runner_ledgers_missing_skills_array() { seed_session_evidence(&cg).await; let output = json!({"summary": "no skills"}); let backend = SkillJsonBackend::new(output.clone()); - let config = AutomationConfig { - enabled: true, - backend: AutomationBackend::CodexAppServer, - host_mode: AutomationHostMode::Standalone, - tasks: AutomationTaskSet { - skill_writer: AutomationTaskConfig { - enabled: true, - schedule: Some("manual".to_string()), - ..AutomationTaskConfig::default() - }, - ..AutomationTaskSet::default() - }, - ..AutomationConfig::default() - }; + let config = enabled_skill_writer_config(); let err = run_skill_writer_with_backend( &cg, &config, &backend, - SkillWriterAutomationOptions { - trigger: AutomationTrigger::ManualCli, - provider: "cursor".to_string(), - query: "automation".to_string(), - evidence_limit: 5, - profile_root: Some(profile_root), - run_id: None, - storage_scope: "project_local".to_string(), - hermes_home: None, - }, + manual_skill_writer_options(&profile_root), ) .await .unwrap_err(); @@ -977,35 +810,13 @@ async fn skill_writer_runner_records_noop_fallback_when_backend_run_task_fails() let cg = init_project(temp.path()).await; seed_session_evidence(&cg).await; let backend = FailingBackend::new(AgentTaskKind::SkillWriter); - let config = AutomationConfig { - enabled: true, - backend: AutomationBackend::CodexAppServer, - host_mode: AutomationHostMode::Standalone, - tasks: AutomationTaskSet { - skill_writer: AutomationTaskConfig { - enabled: true, - schedule: Some("manual".to_string()), - ..AutomationTaskConfig::default() - }, - ..AutomationTaskSet::default() - }, - ..AutomationConfig::default() - }; + let config = enabled_skill_writer_config(); let run = run_skill_writer_with_backend( &cg, &config, &backend, - SkillWriterAutomationOptions { - trigger: AutomationTrigger::ManualCli, - provider: "cursor".to_string(), - query: "automation".to_string(), - evidence_limit: 5, - run_id: None, - profile_root: Some(profile_root), - storage_scope: "project_local".to_string(), - hermes_home: None, - }, + manual_skill_writer_options(&profile_root), ) .await .unwrap(); diff --git a/tests/dashboard_automation_api_test.rs b/tests/dashboard_automation_api_test.rs index 9b5a08de..7118a2a5 100644 --- a/tests/dashboard_automation_api_test.rs +++ b/tests/dashboard_automation_api_test.rs @@ -104,7 +104,8 @@ fn curation_agent_plan_skips_when_automation_is_disabled_and_records_history() { "dry_run": true, "provider": "cursor", "query": "workflow corrections", - "evidence_limit": 7 + "evidence_limit": 7, + "storage_scope": "project_local" }), ); assert_eq!(status, 202); @@ -125,7 +126,7 @@ fn curation_agent_plan_skips_when_automation_is_disabled_and_records_history() { .post(&format!("{base_url}/api/automation/run/skill-writing")) .send_json(serde_json::json!({ "dry_run": true, - "storage_scope": "project_local" + "unsupported_field": true })) .expect("skill-writing request with unsupported field should receive response"); let rejected_skill_status = rejected_skill_shape.status().as_u16(); @@ -135,7 +136,7 @@ fn curation_agent_plan_skips_when_automation_is_disabled_and_records_history() { .expect("skill-writing rejection body should be readable"); assert_eq!(rejected_skill_status, 422); assert!( - rejected_skill_body.contains("storage_scope"), + rejected_skill_body.contains("unsupported_field"), "rejection should name the unsupported field: {rejected_skill_body}" );