Skip to content
Draft
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
55 changes: 41 additions & 14 deletions crates/skilllite-agent/src/chat_session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ use std::path::{Path, PathBuf};
use skilllite_executor::{memory as executor_memory, session, transcript};

use skilllite_core::config::env_keys::evolution as evo_env_keys;
use skilllite_core::skill::discovery::resolve_skills_dir_with_legacy_fallback;

use super::agent_loop;
use super::evolution;
Expand Down Expand Up @@ -1161,27 +1162,30 @@ fn apply_message_window_to_cache(cache: &mut TranscriptCache, paths: &[PathBuf],

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

fn resolve_workspace_skills_root(workspace: &str) -> Option<PathBuf> {
if workspace.is_empty() {
return None;
}

let ws = Path::new(workspace);
let workspace_root = if ws.is_absolute() {
ws.to_path_buf()
} else {
std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join(ws)
};
Some(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_workspace_skills_root(workspace);
let llm = match LlmClient::new(api_base, api_key) {
Ok(c) => c,
Err(e) => {
Expand Down Expand Up @@ -1376,6 +1380,29 @@ fn transcript_entry_to_message(entry: &transcript::TranscriptEntry) -> Option<Ch
mod history_window_tests {
use super::*;

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

let resolved = resolve_workspace_skills_root(tmp.path().to_string_lossy().as_ref())
.expect("resolve skills root");

assert_eq!(resolved, tmp.path().join("skills"));
}

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

let resolved = resolve_workspace_skills_root(tmp.path().to_string_lossy().as_ref())
.expect("resolve skills root");

assert_eq!(resolved, tmp.path().join(".skills"));
}

fn msg(content: &str) -> transcript::TranscriptEntry {
transcript::TranscriptEntry::Message {
id: uuid::Uuid::new_v4().to_string(),
Expand Down
35 changes: 34 additions & 1 deletion crates/skilllite-assistant/src-tauri/src/life_pulse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,15 @@ fn emit(app: &tauri::AppHandle, kind: &str, detail: Option<String>) {
let _ = app.emit("life-pulse", &evt);
}

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

// ─── Rhythm check (in-process, no LLM) ─────────────────────────────────────

fn check_schedule_due(workspace: &std::path::Path) -> bool {
Expand All @@ -163,18 +172,23 @@ fn check_schedule_due(workspace: &std::path::Path) -> bool {

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 root = skilllite_bridge::find_project_root(&workspace);
let args = growth_command_args(&workspace);
let env: Vec<(String, String)> = env_pairs.to_vec();
std::thread::spawn(move || {
emit(&app, "growth-started", None);
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.iter().map(String::as_str))
.current_dir(&root)
.envs(env.iter().map(|(k, v)| (k.as_str(), v.as_str())))
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
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,21 @@ 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_command_args_include_workspace() {
assert_eq!(
growth_command_args("/tmp/workspace"),
vec![
"evolution".to_string(),
"run".to_string(),
"--workspace".to_string(),
"/tmp/workspace".to_string(),
]
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ pub fn authorize_capability_evolution(
cmd.arg("evolution")
.arg("run")
.arg("--json")
.arg("--workspace")
.arg(&workspace_owned)
.arg("--proposal-id")
.arg(&proposal_id_owned)
.current_dir(&root)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
Expand Down
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: 31 additions & 3 deletions crates/skilllite-commands/src/evolution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +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::manifest;
use skilllite_core::skill::{discovery::resolve_skills_dir_with_legacy_fallback, manifest};

/// Resolve workspace for project-level skill evolution.
/// Uses SKILLLITE_WORKSPACE env or current_dir. Returns workspace/.skills.
/// Uses SKILLLITE_WORKSPACE env or current_dir. Returns `skills/` with legacy `.skills/` fallback.
fn resolve_skills_root(workspace: Option<&str>) -> Option<PathBuf> {
let ws: PathBuf = workspace
.filter(|s| !s.is_empty())
Expand All @@ -47,7 +47,7 @@ fn resolve_skills_root(workspace: Option<&str>) -> Option<PathBuf> {
} else {
std::env::current_dir().ok()?.join(ws)
};
Some(ws.join(".skills"))
Some(resolve_skills_dir_with_legacy_fallback(&ws, "skills").effective_path)
}

#[derive(Debug)]
Expand Down Expand Up @@ -731,6 +731,34 @@ fn build_new_skill(skills_root: &Path, skill_name: &str, txn_id: &str) -> Option
})
}

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

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

let resolved = resolve_skills_root(Some(tmp.path().to_string_lossy().as_ref()))
.expect("resolve skills root");

assert_eq!(resolved, tmp.path().join("skills"));
}

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

let resolved = resolve_skills_root(Some(tmp.path().to_string_lossy().as_ref()))
.expect("resolve skills root");

assert_eq!(resolved, tmp.path().join(".skills"));
}
}

/// 判断 source 是否为远程(非本地路径),用于区分「下载的技能」与本地/进化技能
fn is_remote_source(source: &str) -> bool {
let s = source.trim();
Expand Down
35 changes: 30 additions & 5 deletions crates/skilllite-commands/src/evolution_status.rs
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,21 @@ pub fn cmd_status(json: bool, workspace: &str, periodic_anchor_unix: Option<i64>
cmd_status_human(workspace)
}

fn reason_preview(reason: &str) -> String {
const DISPLAY_LIMIT_BYTES: usize = 50;
const PREFIX_LIMIT_BYTES: usize = 47;

if reason.len() <= DISPLAY_LIMIT_BYTES {
return reason.to_string();
}

let mut end = PREFIX_LIMIT_BYTES.min(reason.len());
while end > 0 && !reason.is_char_boundary(end) {
end -= 1;
}
format!("{}...", &reason[..end])
}

fn cmd_status_human(workspace: &str) -> Result<()> {
let workspace_root = resolve_workspace_root(workspace);
skilllite_core::config::load_dotenv_from_dir(&workspace_root);
Expand Down Expand Up @@ -405,11 +420,7 @@ fn cmd_status_human(workspace: &str) -> Result<()> {
t if t.contains("rolled_back") => "🔙",
_ => " ",
};
let reason_short = if reason.len() > 50 {
format!("{}...", &reason[..47])
} else {
reason
};
let reason_short = reason_preview(&reason);
println!(" {} {} {} {}", icon, date, etype, reason_short);
if !target.is_empty() {
println!(" └─ target: {}", target);
Expand Down Expand Up @@ -503,6 +514,20 @@ mod workspace_scope_tests {
mod tests {
use super::*;

#[test]
fn reason_preview_truncates_on_utf8_boundary() {
let reason = "界".repeat(17);

let preview = reason_preview(&reason);

assert_eq!(preview, format!("{}...", "界".repeat(15)));
}

#[test]
fn reason_preview_keeps_short_text_unchanged() {
assert_eq!(reason_preview("short reason"), "short reason");
}

#[test]
fn evolution_status_snapshot_serializes_required_fields() {
let snap = EvolutionStatusSnapshot {
Expand Down
48 changes: 48 additions & 0 deletions tasks/TASK-2026-069-critical-evolution-run-fixes/CONTEXT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Technical Context

## Current State

- Relevant crates/files:
- `crates/skilllite-commands/src/evolution_status.rs`
- `crates/skilllite-commands/src/evolution.rs`
- `crates/skilllite-commands/src/evolution_desktop.rs`
- `crates/skilllite-assistant/src-tauri/src/life_pulse.rs`
- `crates/skilllite-core/src/skill/discovery.rs`
- Current behavior:
- Human status display truncates event `reason` with byte slicing.
- `evolution run` resolves evolved skill output under `.skills`.
- Desktop pending-skill paths resolve `skills/` with `.skills` fallback.
- Life Pulse growth due checks use the selected workspace, but the growth run
subprocess omits `--workspace`.

## Architecture Fit

- Layer boundaries involved:
- `skilllite-commands` may depend on `skilllite-core` discovery helpers.
- Desktop bridge invokes CLI behavior through subprocess arguments.
- Interfaces to preserve:
- Existing CLI flags and JSON contracts.
- Existing `resolve_skills_dir_with_legacy_fallback` behavior.

## Dependency and Compatibility

- New dependencies: None.
- Backward compatibility notes:
- Workspaces with only `.skills/` must continue to receive evolved skills there.
- Workspaces with `skills/` should receive evolved skills under `skills/`.

## Design Decisions

- Decision: Introduce small local helpers instead of new abstractions.
- Rationale: The fix is narrow and can reuse existing core path resolution.
- Alternatives considered: Refactor all evolution workspace/path handling.
- Why rejected: Too broad for a critical bug-fix PR and higher regression risk.
- Decision: Pass `--workspace` from Life Pulse growth execution.
- Rationale: The due check and run should operate on the same workspace.
- Alternatives considered: Rely on `SKILLLITE_WORKSPACE` env only.
- Why rejected: Explicit CLI arguments already define the scoped desktop pattern.

## Open Questions

- [x] Are docs required? No CLI contract or user-facing flag semantics change.
- [x] Are security specs required? No sandbox, auth, or policy gates change.
51 changes: 51 additions & 0 deletions tasks/TASK-2026-069-critical-evolution-run-fixes/PRD.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# PRD

## Background

The daily critical-bug investigation found concrete failure modes in recent
evolution behavior. The affected paths are visible to desktop and CLI users:
status inspection can crash on Unicode log content, and background evolution can
write generated skills outside the root used by pending-skill UI actions.

## Objective

Evolution status and run paths should be consistent, panic-free, and scoped to
the workspace selected by the user.

## Functional Requirements

- FR-1: Human evolution status output must truncate event reasons without slicing
through UTF-8 code points.
- FR-2: Evolution run skill output must resolve the same `skills/` or legacy
`.skills/` root as desktop pending-skill operations.
- FR-3: Life Pulse growth runs must pass the selected workspace to the child
`skilllite evolution run` command.

## Non-Functional Requirements

- Security: No sandbox or permission policy changes.
- Performance: Path and string handling changes must be constant or linear in
small display strings only.
- Compatibility: Preserve legacy `.skills` fallback when `skills/` does not
exist.

## Constraints

- Technical: Keep crate dependency direction unchanged and reuse existing core
skill discovery helpers.
- Timeline: N/A.

## Success Metrics

- Metric: Unicode evolution status preview does not panic.
- Baseline: Byte slicing `reason[..47]` panics when byte 47 is not a char boundary.
- Target: Regression test passes with multibyte input.
- Metric: Evolved skills root matches desktop pending-skill root.
- Baseline: `evolution run` uses `.skills` while desktop uses `skills/` with
legacy fallback.
- Target: Regression tests pass for primary `skills/` and legacy `.skills`.

## Rollout

- Rollout plan: Ship as a small bug-fix PR.
- Rollback plan: Revert this task's commit if unexpected path behavior appears.
Loading
Loading