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
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ pub fn get_evolution_proposal_status(
"evolution",
"proposal-status",
"--json",
"--workspace",
workspace,
proposal_id,
],
)
Expand Down
9 changes: 5 additions & 4 deletions crates/skilllite-commands/src/evolution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -156,17 +156,18 @@ fn query_backlog_rows(
pub fn cmd_backlog(
json: bool,
hide_closed: bool,
workspace: &str,
status: Option<&str>,
risk: Option<&str>,
limit: usize,
) -> Result<()> {
if json && hide_closed {
let rows = query_backlog_desktop(limit)?;
let rows = query_backlog_desktop(workspace, limit)?;
println!("{}", serde_json::to_string_pretty(&rows)?);
return Ok(());
}

let root = paths::chat_root();
let root = crate::evolution_status::chat_root_for_workspace(workspace);
let status_filter = normalize_status_filter(status)?;
let risk_filter = normalize_risk_filter(risk)?;
let rows = query_backlog_rows(
Expand Down Expand Up @@ -537,8 +538,8 @@ pub fn cmd_pending(json: bool, workspace: &str) -> Result<()> {
}

/// `skilllite evolution proposal-status <proposal_id>` — single backlog row for desktop.
pub fn cmd_proposal_status(json: bool, proposal_id: &str) -> Result<()> {
let row = desktop_query_proposal_status(proposal_id)?;
pub fn cmd_proposal_status(json: bool, workspace: &str, proposal_id: &str) -> Result<()> {
let row = desktop_query_proposal_status(workspace, proposal_id)?;
if json {
println!("{}", serde_json::to_string_pretty(&row)?);
} else {
Expand Down
153 changes: 144 additions & 9 deletions crates/skilllite-commands/src/evolution_desktop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use skilllite_core::skill::discovery::resolve_skills_dir_with_legacy_fallback;

use crate::evolution_status::resolve_workspace_root;
use crate::evolution_status::{chat_root_for_workspace, resolve_workspace_root};
use crate::Result;

#[derive(Debug, Clone, Serialize, Deserialize)]
Expand Down Expand Up @@ -68,8 +68,11 @@ fn truncate_utf8(s: &str, max: usize) -> String {
format!("{}…", &s[..end])
}

pub fn query_backlog_desktop(limit: usize) -> Result<Vec<EvolutionBacklogRowSnapshot>> {
let chat_root = skilllite_core::paths::chat_root();
pub fn query_backlog_desktop(
workspace: &str,
limit: usize,
) -> Result<Vec<EvolutionBacklogRowSnapshot>> {
let chat_root = chat_root_for_workspace(workspace);
let conn = skilllite_evolution::feedback::open_evolution_db(&chat_root)?;
let limit = limit.clamp(1, 200);
let mut stmt = conn
Expand Down Expand Up @@ -102,8 +105,11 @@ pub fn query_backlog_desktop(limit: usize) -> Result<Vec<EvolutionBacklogRowSnap
.collect::<Result<Vec<_>>>()
}

pub fn query_proposal_status(proposal_id: &str) -> Result<EvolutionProposalStatusSnapshot> {
let chat_root = skilllite_core::paths::chat_root();
pub fn query_proposal_status(
workspace: &str,
proposal_id: &str,
) -> Result<EvolutionProposalStatusSnapshot> {
let chat_root = chat_root_for_workspace(workspace);
let conn = skilllite_evolution::feedback::open_evolution_db(&chat_root)?;
conn.query_row(
"SELECT proposal_id, status, acceptance_status, updated_at, note
Expand Down Expand Up @@ -167,7 +173,7 @@ pub fn read_pending_skill_md(workspace: &str, skill_name: &str) -> Result<String
pub fn confirm_pending_skill(workspace: &str, skill_name: &str) -> Result<()> {
let skills_root = resolve_skills_root(workspace)?;
skilllite_evolution::skill_synth::confirm_pending_skill(&skills_root, skill_name)?;
let chat_root = skilllite_core::paths::chat_root();
let chat_root = chat_root_for_workspace(workspace);
if let Ok(conn) = skilllite_evolution::feedback::open_evolution_db(&chat_root) {
let _ = skilllite_evolution::log_evolution_event(
&conn,
Expand Down Expand Up @@ -198,8 +204,8 @@ pub fn authorize_capability_evolution(
outcome: &str,
summary: &str,
) -> Result<AuthorizeCapabilitySnapshot> {
let _ = resolve_workspace_root(workspace);
let chat_root = skilllite_core::paths::chat_root();
let workspace_root = resolve_workspace_root(workspace);
let chat_root = workspace_root.join("chat");
let conn = skilllite_evolution::feedback::open_evolution_db(&chat_root)?;
let proposal_id =
skilllite_evolution::enqueue_user_capability_evolution(&conn, tool_name, outcome, summary)?;
Expand All @@ -223,7 +229,7 @@ pub fn log_manual_evolution_trigger(
proposal_id: Option<&str>,
summary: &str,
) -> Result<()> {
let chat_root = skilllite_core::paths::chat_root();
let chat_root = chat_root_for_workspace(workspace);
let conn = skilllite_evolution::feedback::open_evolution_db(&chat_root)?;
let clipped = clip_manual_trigger_summary(summary);
let _ = skilllite_evolution::log_evolution_event(
Expand All @@ -240,6 +246,65 @@ pub fn log_manual_evolution_trigger(
#[cfg(test)]
mod tests {
use super::*;
use skilllite_core::config::env_keys::paths as env_paths;
use std::sync::Mutex;

static ENV_LOCK: Mutex<()> = Mutex::new(());

struct EnvRestore {
key: &'static str,
previous: Option<String>,
}

impl EnvRestore {
fn set(key: &'static str, value: &str) -> Self {
let previous = std::env::var(key).ok();
skilllite_core::config::set_env_var(key, value);
Self { key, previous }
}
}

impl Drop for EnvRestore {
fn drop(&mut self) {
if let Some(value) = &self.previous {
skilllite_core::config::set_env_var(self.key, value);
} else {
skilllite_core::config::remove_env_var(self.key);
}
}
}

fn temp_workspace(label: &str) -> PathBuf {
std::env::temp_dir().join(format!(
"skilllite-evo-desktop-{label}-{}",
uuid::Uuid::new_v4()
))
}

fn seed_backlog_row(workspace: &std::path::Path, proposal_id: &str, note: &str) {
let chat_root = workspace.join("chat");
let conn = skilllite_evolution::feedback::open_evolution_db(&chat_root).expect("open db");
let dedupe_key = format!("dedupe_{proposal_id}");
conn.execute(
"INSERT INTO evolution_backlog
(proposal_id, source, dedupe_key, scope_json, risk_level, roi_score, expected_gain, effort, acceptance_criteria, status, acceptance_status, note)
VALUES (?1, 'active', ?2, '{}', 'low', 0.5, 0.5, 1.0, '[]', 'queued', 'pending_validation', ?3)",
[proposal_id, dedupe_key.as_str(), note],
)
.expect("insert backlog row");
}

fn capability_rows(workspace: &std::path::Path, tool_name: &str) -> i64 {
let chat_root = workspace.join("chat");
let conn = skilllite_evolution::feedback::open_evolution_db(&chat_root).expect("open db");
let dedupe_key = format!("user_capability:{tool_name}:failure");
conn.query_row(
"SELECT COUNT(*) FROM evolution_backlog WHERE dedupe_key = ?1",
[dedupe_key.as_str()],
|row| row.get(0),
)
.expect("count rows")
}

#[test]
fn manual_trigger_summary_clip_is_utf8_boundary_safe() {
Expand All @@ -260,4 +325,74 @@ mod tests {

assert_eq!(clip_manual_trigger_summary(summary), summary);
}

#[test]
fn query_backlog_desktop_uses_workspace_argument_over_env() {
let _lock = ENV_LOCK.lock().expect("env lock");
let env_workspace = temp_workspace("env");
let target_workspace = temp_workspace("target");
let _env_restore = EnvRestore::set(
env_paths::SKILLLITE_WORKSPACE,
env_workspace.to_string_lossy().as_ref(),
);
seed_backlog_row(&env_workspace, "env_only", "ENV_DB_ROW");
seed_backlog_row(&target_workspace, "target_only", "TARGET_DB_ROW");

let rows =
query_backlog_desktop(target_workspace.to_string_lossy().as_ref(), 10).expect("query");

let ids: Vec<_> = rows.into_iter().map(|row| row.proposal_id).collect();
assert_eq!(ids, vec!["target_only"]);
let _ = std::fs::remove_dir_all(env_workspace);
let _ = std::fs::remove_dir_all(target_workspace);
}

#[test]
fn query_proposal_status_uses_workspace_argument_over_env() {
let _lock = ENV_LOCK.lock().expect("env lock");
let env_workspace = temp_workspace("env");
let target_workspace = temp_workspace("target");
let _env_restore = EnvRestore::set(
env_paths::SKILLLITE_WORKSPACE,
env_workspace.to_string_lossy().as_ref(),
);
seed_backlog_row(&env_workspace, "shared_id", "ENV_DB_ROW");
seed_backlog_row(&target_workspace, "shared_id", "TARGET_DB_ROW");

let row = query_proposal_status(target_workspace.to_string_lossy().as_ref(), "shared_id")
.expect("query");

assert_eq!(row.note.as_deref(), Some("TARGET_DB_ROW"));
let _ = std::fs::remove_dir_all(env_workspace);
let _ = std::fs::remove_dir_all(target_workspace);
}

#[test]
fn authorize_capability_uses_workspace_argument_over_env() {
let _lock = ENV_LOCK.lock().expect("env lock");
let env_workspace = temp_workspace("env");
let target_workspace = temp_workspace("target");
let tool_name = "workspace_scope_regression";
let _env_restore = EnvRestore::set(
env_paths::SKILLLITE_WORKSPACE,
env_workspace.to_string_lossy().as_ref(),
);
let _ = skilllite_evolution::feedback::open_evolution_db(&env_workspace.join("chat"))
.expect("open env db");
let _ = skilllite_evolution::feedback::open_evolution_db(&target_workspace.join("chat"))
.expect("open target db");

authorize_capability_evolution(
target_workspace.to_string_lossy().as_ref(),
tool_name,
"failure",
"summary",
)
.expect("authorize");

assert_eq!(capability_rows(&env_workspace, tool_name), 0);
assert_eq!(capability_rows(&target_workspace, tool_name), 1);
let _ = std::fs::remove_dir_all(env_workspace);
let _ = std::fs::remove_dir_all(target_workspace);
}
}
83 changes: 81 additions & 2 deletions crates/skilllite-commands/src/evolution_status.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ pub(crate) fn resolve_workspace_root(workspace: &str) -> PathBuf {
}
}

pub(crate) fn chat_root_for_workspace(workspace: &str) -> PathBuf {
resolve_workspace_root(workspace).join("chat")
}

fn workspace_env_lookup(workspace_root: &Path, key: &str) -> Option<String> {
skilllite_core::config::parse_dotenv_from_dir(workspace_root)
.into_iter()
Expand Down Expand Up @@ -156,7 +160,7 @@ pub fn build_evolution_status_snapshot(params: &EvolutionStatusParams) -> Evolut
skilllite_evolution::skill_synth::list_pending_skills_with_review(&skills_root).len();
}

let chat_root = skilllite_core::paths::chat_root();
let chat_root = workspace_root.join("chat");
let mut db_error = None;
let mut unprocessed_decisions = 0i64;
let mut weighted_signal_sum = 0i64;
Expand Down Expand Up @@ -298,7 +302,7 @@ pub fn cmd_status(json: bool, workspace: &str, periodic_anchor_unix: Option<i64>
fn cmd_status_human(workspace: &str) -> Result<()> {
let workspace_root = resolve_workspace_root(workspace);
skilllite_core::config::load_dotenv_from_dir(&workspace_root);
let root = skilllite_core::paths::chat_root();
let root = workspace_root.join("chat");
let conn = skilllite_evolution::feedback::open_evolution_db(&root)?;
let mode = evolution_mode_from_workspace(&workspace_root);

Expand Down Expand Up @@ -420,6 +424,81 @@ fn cmd_status_human(workspace: &str) -> Result<()> {
Ok(())
}

#[cfg(test)]
mod workspace_scope_tests {
use super::*;
use skilllite_core::config::env_keys::paths as env_paths;
use std::sync::Mutex;

static ENV_LOCK: Mutex<()> = Mutex::new(());

struct EnvRestore {
key: &'static str,
previous: Option<String>,
}

impl EnvRestore {
fn set(key: &'static str, value: &str) -> Self {
let previous = std::env::var(key).ok();
skilllite_core::config::set_env_var(key, value);
Self { key, previous }
}
}

impl Drop for EnvRestore {
fn drop(&mut self) {
if let Some(value) = &self.previous {
skilllite_core::config::set_env_var(self.key, value);
} else {
skilllite_core::config::remove_env_var(self.key);
}
}
}

fn temp_workspace(label: &str) -> PathBuf {
std::env::temp_dir().join(format!(
"skilllite-evo-status-{label}-{}",
uuid::Uuid::new_v4()
))
}

fn seed_decision(workspace: &Path) {
let conn = skilllite_evolution::feedback::open_evolution_db(&workspace.join("chat"))
.expect("open db");
conn.execute(
"INSERT INTO decisions
(evolved, total_tools, failed_tools, replans, task_completed, task_description, ts)
VALUES (0, 1, 0, 0, 1, 'workspace scoped decision', datetime('now'))",
[],
)
.expect("insert decision");
}

#[test]
fn status_snapshot_uses_workspace_argument_for_db_over_env() {
let _lock = ENV_LOCK.lock().expect("env lock");
let env_workspace = temp_workspace("env");
let target_workspace = temp_workspace("target");
let _env_restore = EnvRestore::set(
env_paths::SKILLLITE_WORKSPACE,
env_workspace.to_string_lossy().as_ref(),
);
let _ = skilllite_evolution::feedback::open_evolution_db(&env_workspace.join("chat"))
.expect("open env db");
seed_decision(&target_workspace);

let snapshot = build_evolution_status_snapshot(&EvolutionStatusParams {
workspace: target_workspace.to_string_lossy().to_string(),
periodic_anchor_unix: None,
});

assert_eq!(snapshot.unprocessed_decisions, 1);
assert!(snapshot.db_error.is_none());
let _ = std::fs::remove_dir_all(env_workspace);
let _ = std::fs::remove_dir_all(target_workspace);
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
10 changes: 5 additions & 5 deletions docs/en/ASSISTANT-SPLIT-ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,16 +121,16 @@ Priority commands for parity with today’s Desktop bridge:
| Command | JSON output | Notes |
|---------|-------------|-------|
| `skilllite evolution status --json` | `EvolutionStatusSnapshot` | **Shipped**; `--workspace`, `--periodic-anchor-unix` |
| `skilllite evolution backlog --json --hide-closed` | `EvolutionBacklogRowSnapshot[]` | **Shipped** (desktop filter) |
| `skilllite evolution pending --json` | `PendingSkillSnapshot[]` | **Shipped** |
| `skilllite evolution proposal-status --json <id>` | `EvolutionProposalStatusSnapshot` | **Shipped** |
| `skilllite evolution confirm/reject --json` | `EvolutionOpSnapshot` | **Shipped** |
| `skilllite evolution backlog --json --hide-closed` | `EvolutionBacklogRowSnapshot[]` | **Shipped**; `--workspace` (desktop filter) |
| `skilllite evolution pending --json` | `PendingSkillSnapshot[]` | **Shipped**; `--workspace` |
| `skilllite evolution proposal-status --json <id>` | `EvolutionProposalStatusSnapshot` | **Shipped**; `--workspace` |
| `skilllite evolution confirm/reject --json` | `EvolutionOpSnapshot` | **Shipped**; `--workspace` |
| `skilllite evolution run --json` | `NodeResult` | **Shipped**; `--workspace`, `--proposal-id`, `--log-manual-trigger` |
| `skilllite runtime probe --json` | `RuntimeUiSnapshot` | **Shipped** |
| `skilllite runtime provision --json` | stderr progress JSON lines + `ProvisionRuntimesResult` on stdout | **Shipped**; `--python` / `--node` / `--force` |
| `skilllite skills list --json --workspace` | `DesktopSkillSnapshot[]` (desktop `DesktopSkillInfo`) | **Shipped** |
| `skilllite suggest-followup --json` | `{ "suggestions": string[] }` | **Shipped** |
| `skilllite evolution authorize-capability --json` | `{ "proposal_id": string }` | **Shipped** |
| `skilllite evolution authorize-capability --json` | `{ "proposal_id": string }` | **Shipped**; `--workspace` |

**Convention:** `--json` always prints a single JSON document on stdout; human text on stderr only.

Expand Down
Loading
Loading