Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 44 additions & 14 deletions crates/skilllite-agent/src/chat_session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1161,27 +1161,28 @@ fn apply_message_window_to_cache(cache: &mut TranscriptCache, paths: &[PathBuf],

// ─── A9: evolution triggers (periodic + decision-count) ─────────────────────

fn resolve_evolution_skills_root(workspace: &str) -> Option<PathBuf> {
if workspace.is_empty() {
return None;
}
let workspace_root = skilllite_core::paths::resolve_workspace_filesystem_root(workspace);
Some(
skilllite_core::skill::discovery::resolve_skills_dir_with_legacy_fallback(
&workspace_root,
"skills",
)
.effective_path,
)
}

async fn run_evolution_and_emit_summary(
data_root: &Path,
workspace: &str,
api_base: &str,
api_key: &str,
model: &str,
) {
let skills_root = if workspace.is_empty() {
None
} else {
let ws = std::path::Path::new(workspace);
let sr = if ws.is_absolute() {
ws.join(".skills")
} else {
std::env::current_dir()
.unwrap_or_else(|_| std::path::PathBuf::from("."))
.join(workspace)
.join(".skills")
};
Some(sr)
};
let skills_root = resolve_evolution_skills_root(workspace);
let llm = match LlmClient::new(api_base, api_key) {
Ok(c) => c,
Err(e) => {
Expand Down Expand Up @@ -1372,6 +1373,35 @@ fn transcript_entry_to_message(entry: &transcript::TranscriptEntry) -> Option<Ch
}
}

#[cfg(test)]
mod evolution_workspace_tests {
use super::*;

#[test]
fn evolution_skills_root_prefers_workspace_skills_dir() {
let workspace = tempfile::tempdir().expect("workspace");
let skills_dir = workspace.path().join("skills");
let legacy_dir = workspace.path().join(".skills");
std::fs::create_dir_all(&skills_dir).expect("create skills");
std::fs::create_dir_all(&legacy_dir).expect("create legacy skills");

let resolved = resolve_evolution_skills_root(workspace.path().to_string_lossy().as_ref());

assert_eq!(resolved.as_deref(), Some(skills_dir.as_path()));
}

#[test]
fn evolution_skills_root_uses_legacy_fallback_when_default_missing() {
let workspace = tempfile::tempdir().expect("workspace");
let legacy_dir = workspace.path().join(".skills");
std::fs::create_dir_all(&legacy_dir).expect("create legacy skills");

let resolved = resolve_evolution_skills_root(workspace.path().to_string_lossy().as_ref());

assert_eq!(resolved.as_deref(), Some(legacy_dir.as_path()));
}
}

#[cfg(test)]
mod history_window_tests {
use super::*;
Expand Down
37 changes: 36 additions & 1 deletion crates/skilllite-assistant/src-tauri/src/life_pulse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -161,21 +161,35 @@ fn check_schedule_due(workspace: &std::path::Path) -> bool {

// ─── Subprocess helpers ─────────────────────────────────────────────────────

fn evolution_growth_args(workspace: &str) -> Vec<String> {
vec![
"evolution".to_string(),
"run".to_string(),
"--workspace".to_string(),
workspace.to_string(),
]
}

fn spawn_growth(
skilllite_path: &std::path::Path,
workspace: &str,
env_pairs: &[(String, String)],
running: Arc<AtomicBool>,
app: tauri::AppHandle,
) {
let path = skilllite_path.to_path_buf();
let workspace = workspace.to_string();
let env: Vec<(String, String)> = env_pairs.to_vec();
std::thread::spawn(move || {
emit(&app, "growth-started", None);
let args = evolution_growth_args(&workspace);
let root = skilllite_bridge::find_project_root(&workspace);
let mut growth_cmd = Command::new(&path);
crate::windows_spawn::hide_child_console(&mut growth_cmd);
let result = growth_cmd
.args(["evolution", "run"])
.args(&args)
.envs(env.iter().map(|(k, v)| (k.as_str(), v.as_str())))
.current_dir(root)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.status();
Expand Down Expand Up @@ -281,6 +295,7 @@ pub fn start(state: LifePulseState, skilllite_path: PathBuf, app: tauri::AppHand
s.growth_running.store(true, Ordering::SeqCst);
spawn_growth(
&skilllite_path,
&workspace,
&child_env,
s.growth_running.clone(),
app.clone(),
Expand Down Expand Up @@ -326,3 +341,23 @@ pub fn start(state: LifePulseState, skilllite_path: PathBuf, app: tauri::AppHand
pub fn stop(state: &LifePulseState) {
state.alive.store(false, Ordering::SeqCst);
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn growth_args_include_active_workspace() {
let args = evolution_growth_args("/tmp/skilllite workspace");

assert_eq!(
args,
vec![
"evolution",
"run",
"--workspace",
"/tmp/skilllite workspace",
]
);
}
}
31 changes: 31 additions & 0 deletions crates/skilllite-assistant/src-tauri/src/skilllite_bridge/chat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,18 @@ pub fn merge_dotenv_with_chat_overrides(
pairs
}

fn child_env_with_workspace(
mut env_pairs: Vec<(String, String)>,
workspace: &str,
) -> Vec<(String, String)> {
env_pairs.retain(|(key, _)| key != super::local::env_keys::paths::SKILLLITE_WORKSPACE);
env_pairs.push((
super::local::env_keys::paths::SKILLLITE_WORKSPACE.to_string(),
workspace.to_string(),
));
env_pairs
}

fn apply_chat_overrides_env(
m: &mut std::collections::HashMap<String, EnvValueWithSource>,
cfg: &ChatConfigOverrides,
Expand Down Expand Up @@ -369,6 +381,7 @@ pub fn chat_stream(
load_dotenv_for_child(&raw_workspace),
config_overrides.as_ref(),
);
let env_pairs = child_env_with_workspace(env_pairs, &workspace_str);
let provenance = summarize_env_provenance(&env_sources);
if !provenance.is_empty() {
eprintln!(
Expand Down Expand Up @@ -774,4 +787,22 @@ mod tests {
Some("https://ui.base")
);
}

#[test]
fn child_env_with_workspace_overrides_dotenv_workspace() {
let env_pairs = vec![
("SKILLLITE_WORKSPACE".to_string(), "/tmp/wrong".to_string()),
("OPENAI_MODEL".to_string(), "dotenv-model".to_string()),
];

let merged = child_env_with_workspace(env_pairs, "/tmp/project");
let map: std::collections::HashMap<_, _> = merged.into_iter().collect();

assert_eq!(
map.get(super::super::local::env_keys::paths::SKILLLITE_WORKSPACE)
.map(String::as_str),
Some("/tmp/project")
);
assert_eq!(map.get("OPENAI_MODEL").map(String::as_str), Some("dotenv-model"));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@ use crate::skilllite_bridge::local::engine_types::AuthorizeCapabilityResponse;
use crate::skilllite_bridge::local::env_keys::evolution as evo_keys;
use crate::skilllite_bridge::paths::{find_project_root, load_dotenv_for_child};

fn authorized_evolution_run_args(workspace: &str) -> Vec<String> {
vec![
"evolution".to_string(),
"run".to_string(),
"--json".to_string(),
"--workspace".to_string(),
workspace.to_string(),
]
}

pub fn authorize_capability_evolution(
workspace: &str,
tool_name: &str,
Expand Down Expand Up @@ -36,11 +46,10 @@ pub fn authorize_capability_evolution(
let skilllite_path_owned = skilllite_path.to_path_buf();
std::thread::spawn(move || {
let root = find_project_root(&workspace_owned);
let args = authorized_evolution_run_args(&workspace_owned);
let mut cmd = std::process::Command::new(&skilllite_path_owned);
crate::windows_spawn::hide_child_console(&mut cmd);
cmd.arg("evolution")
.arg("run")
.arg("--json")
cmd.args(&args)
.current_dir(&root)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
Expand All @@ -52,3 +61,24 @@ pub fn authorize_capability_evolution(
});
Ok(proposal_id)
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn authorized_run_args_include_target_workspace() {
let args = authorized_evolution_run_args("/tmp/skilllite workspace");

assert_eq!(
args,
vec![
"evolution",
"run",
"--json",
"--workspace",
"/tmp/skilllite workspace",
]
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ pub use chat::{
pub use followup_suggestions::followup_chat_suggestions;
pub use integrations::*;
pub use llm_routing_error::{classify_llm_routing_error_message, LlmInvokeResult};
pub(crate) use paths::load_dotenv_for_child;
pub(crate) use paths::{find_project_root, load_dotenv_for_child};
pub use paths::{
default_writable_workspace_dir, ensure_skilllite_version, resolve_skilllite_path_app,
MIN_SKILLLITE_VERSION,
Expand Down
34 changes: 32 additions & 2 deletions crates/skilllite-commands/src/evolution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,10 @@ use crate::Result;
use skilllite_core::config::env_keys::paths as env_paths;
use skilllite_core::paths;
use skilllite_core::protocol::{NewSkill, NodeResult};
use skilllite_core::skill::discovery::resolve_skills_dir_with_legacy_fallback;
use skilllite_core::skill::manifest;

/// Resolve workspace for project-level skill evolution.
/// Resolve workspace for legacy project-level skill commands.
/// Uses SKILLLITE_WORKSPACE env or current_dir. Returns workspace/.skills.
fn resolve_skills_root(workspace: Option<&str>) -> Option<PathBuf> {
let ws: PathBuf = workspace
Expand All @@ -50,6 +51,11 @@ fn resolve_skills_root(workspace: Option<&str>) -> Option<PathBuf> {
Some(ws.join(".skills"))
}

fn resolve_run_skills_root(workspace: &str) -> PathBuf {
let ws = crate::evolution_status::resolve_workspace_root(workspace);
resolve_skills_dir_with_legacy_fallback(&ws, "skills").effective_path
}

#[derive(Debug)]
struct BacklogRow {
proposal_id: String,
Expand Down Expand Up @@ -569,7 +575,7 @@ pub fn cmd_run(
);

let root = paths::chat_root();
let skills_root = resolve_skills_root(Some(workspace));
let skills_root = Some(resolve_run_skills_root(workspace));
skilllite_core::config::ensure_default_output_dir();

let force_key = skilllite_core::config::env_keys::evolution::SKILLLITE_EVO_FORCE_PROPOSAL_ID;
Expand Down Expand Up @@ -1060,4 +1066,28 @@ mod tests {

let _ = std::fs::remove_dir_all(&root);
}

#[test]
fn run_skills_root_prefers_workspace_skills_dir() {
let workspace = tempfile::tempdir().expect("workspace");
let skills_dir = workspace.path().join("skills");
let legacy_dir = workspace.path().join(".skills");
std::fs::create_dir_all(&skills_dir).expect("create skills");
std::fs::create_dir_all(&legacy_dir).expect("create legacy skills");

let resolved = resolve_run_skills_root(workspace.path().to_string_lossy().as_ref());

assert_eq!(resolved, skills_dir);
}

#[test]
fn run_skills_root_keeps_legacy_fallback_when_default_missing() {
let workspace = tempfile::tempdir().expect("workspace");
let legacy_dir = workspace.path().join(".skills");
std::fs::create_dir_all(&legacy_dir).expect("create legacy skills");

let resolved = resolve_run_skills_root(workspace.path().to_string_lossy().as_ref());

assert_eq!(resolved, legacy_dir);
}
}
2 changes: 1 addition & 1 deletion crates/skilllite-evolution/src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ fn try_log_evolution_run_outcome(chat_root: &Path, reason: &str) {
///
/// Returns [EvolutionRunResult]: SkippedBusy if another run in progress, NoScope if nothing to evolve, Completed(txn_id) otherwise.
/// When force=true (manual trigger), bypass decision thresholds.
/// skills_root: project-level dir (workspace/.skills). When None, skips skill evolution.
/// skills_root: project-level skills dir. When None, skips skill evolution.
pub async fn run_evolution<L: EvolutionLlm>(
chat_root: &Path,
skills_root: Option<&Path>,
Expand Down
56 changes: 56 additions & 0 deletions tasks/TASK-2026-069-evolution-workspace-run-scope/CONTEXT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Technical Context

## Current State

- Relevant crates/files:
- `crates/skilllite-commands/src/evolution.rs`
- `crates/skilllite-commands/src/evolution_desktop.rs`
- `crates/skilllite-agent/src/chat_session.rs`
- `crates/skilllite-assistant/src-tauri/src/skilllite_bridge/chat.rs`
- `crates/skilllite-assistant/src-tauri/src/skilllite_bridge/integrations/evolution_ui/authorize.rs`
- `crates/skilllite-assistant/src-tauri/src/life_pulse.rs`
- Current behavior:
- `evolution_desktop` pending/confirm/status use `resolve_skills_dir_with_legacy_fallback`, preferring `skills/` with `.skills` fallback.
- `evolution::cmd_run` resolves generated skill output as `workspace/.skills`.
- Desktop `agent-rpc` receives workspace in JSON config, but `chat_root()` is environment based and can still resolve `~/.skilllite/chat`.
- Agent in-process A9 evolution resolves generated skill output as `workspace/.skills`.
- Desktop manual trigger includes `--workspace`; authorize follow-up and Life Pulse growth currently start `evolution run` without `--workspace`.

## Architecture Fit

- Layer boundaries involved:
- Desktop assistant bridge starts CLI subprocesses.
- CLI entry dispatch calls `skilllite-commands`.
- `skilllite-commands` calls `skilllite-evolution` with explicit chat and skills roots.
- Interfaces to preserve:
- Existing `skilllite evolution run --workspace <path>` CLI.
- Existing desktop pending/confirm/reject JSON contract.
- Existing legacy `.skills` fallback behavior.

## Dependency and Compatibility

- New dependencies:
- None.
- Backward compatibility notes:
- Workspaces containing only `.skills` should continue to use `.skills`.
- Workspaces containing `skills` should use `skills` consistently for generated pending skills and UI review.

## Design Decisions

- Decision: Reuse the existing core skill discovery fallback helper for `cmd_run`.
- Rationale: It is already the source of truth for desktop pending/confirm/status paths.
- Alternatives considered: Keep `.skills` for run and teach pending to search both roots.
- Why rejected: It preserves the split write/read model and risks duplicate or ambiguous pending skills.
- Decision: Set `SKILLLITE_WORKSPACE` explicitly for desktop `agent-rpc` children after resolving the UI workspace.
- Rationale: `skilllite_core::paths::chat_root()` uses that env var, while the L2 evolution UI reads `<workspace>/chat`.
- Alternatives considered: Change L2 UI to read the global chat root.
- Why rejected: It would undo the workspace scoping fixed in `TASK-2026-068` and reintroduce cross-workspace leakage.
- Decision: Test subprocess run arguments through pure helper functions.
- Rationale: It avoids spawning an LLM-backed evolution run while still locking the high-risk contract.
- Alternatives considered: Full desktop integration tests.
- Why rejected: Current Tauri/LLM environment makes full integration tests heavy and brittle for this narrow fix.

## Open Questions

- [x] Should this change add a new CLI flag? No; the existing `--workspace` flag is sufficient.
- [x] Should force/manual policy be changed now? No; that is a broader governance behavior change outside the minimal critical fix.
Loading
Loading