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
10 changes: 10 additions & 0 deletions src/automation/managed_skill_model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,16 @@ pub struct ManagedSkillPendingUpdate {
pub body_markdown: String,
#[serde(default)]
pub support_files: Vec<ManagedSupportFile>,
/// Lifecycle state the skill transitions to when this staged change is
/// approved. `None` keeps the historical behavior (promote to `Active`).
/// Staged consolidations set `Some(Archived)`; skill content is always
/// preserved on disk (archive, never delete).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resulting_state: Option<ManagedSkillState>,
/// Reviewer-facing reason recorded when the change was staged (used by
/// consolidation proposals).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub staged_reason: Option<String>,
}

impl ManagedSkillPendingUpdate {
Expand Down
13 changes: 10 additions & 3 deletions src/automation/managed_skill_validation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ use crate::errors::{Result, TraceDecayError};

use super::managed_skill_format::target_key;
use super::managed_skill_model::{
ManagedSkill, ManagedSkillPendingUpdate, ManagedSkillUpdate, ManagedSupportFile,
SkillInstallTarget, MAX_MANAGED_SKILL_BODY_BYTES, MAX_MANAGED_SUPPORT_FILES,
MAX_MANAGED_SUPPORT_FILE_BYTES,
ManagedSkill, ManagedSkillPendingUpdate, ManagedSkillState, ManagedSkillUpdate,
ManagedSupportFile, SkillInstallTarget, MAX_MANAGED_SKILL_BODY_BYTES,
MAX_MANAGED_SUPPORT_FILES, MAX_MANAGED_SUPPORT_FILE_BYTES,
};

const ALLOWED_SUPPORT_ROOTS: &[&str] = &["references", "templates", "scripts", "assets"];
Expand Down Expand Up @@ -241,6 +241,13 @@ pub(crate) fn validate_managed_pending_update(
"managed skill staged_at must be a positive timestamp".to_string(),
));
}
if let Some(resulting_state) = pending.resulting_state {
if resulting_state != ManagedSkillState::Archived {
return Err(config_error(
"managed skill pending update resulting_state must be archived".to_string(),
));
}
}
let skill = ManagedSkill {
metadata: pending.metadata.clone(),
body_markdown: pending.body_markdown.clone(),
Expand Down
63 changes: 60 additions & 3 deletions src/automation/managed_skills.rs
Original file line number Diff line number Diff line change
Expand Up @@ -351,12 +351,65 @@ pub async fn stage_managed_skill_update(
metadata: staged.metadata.clone(),
body_markdown: staged.body_markdown.clone(),
support_files: staged.support_files.clone(),
resulting_state: None,
staged_reason: None,
};
save_pending_update(profile_root, id, &pending).await?;
record_skill_patch(profile_root, &staged, "staged_update".to_string()).await?;
Ok(pending.into_skill())
}

/// Stages an archive transition for a managed skill as a pending update that
/// must be approved (or discarded) through the normal review lifecycle.
/// Skill content is untouched: approving only flips the state to `Archived`,
/// keeping the body and support files recoverable on disk. Pinned skills are
/// exempt, matching the Hermes curator.
pub async fn stage_managed_skill_archive(
profile_root: &Path,
id: &str,
base_checksum: &str,
reason: Option<String>,
) -> Result<ManagedSkill> {
let skill = load_managed_skill(profile_root, id).await?;
if base_checksum != skill.metadata.checksum {
return Err(config_error(format!(
"base_checksum for managed skill id '{id}' is stale"
)));
}
if skill.pending_update.is_some() {
return Err(config_error(format!(
"managed skill '{id}' already has a pending update"
)));
}
if skill.metadata.pinned {
return Err(config_error(format!(
"managed skill '{id}' is pinned and exempt from staged archive"
)));
}
if skill.metadata.state == ManagedSkillState::Archived {
return Err(config_error(format!(
"managed skill '{id}' is already archived"
)));
}

let mut staged = skill.clone();
staged.pending_update = None;
staged.set_state(ManagedSkillState::PendingApproval);
staged.touch();
let pending = ManagedSkillPendingUpdate {
base_checksum: base_checksum.to_string(),
staged_at: current_metadata_timestamp(),
metadata: staged.metadata.clone(),
body_markdown: staged.body_markdown.clone(),
support_files: staged.support_files.clone(),
resulting_state: Some(ManagedSkillState::Archived),
staged_reason: reason,
};
save_pending_update(profile_root, id, &pending).await?;
record_skill_patch(profile_root, &staged, "staged_archive".to_string()).await?;
Ok(pending.into_skill())
}

pub async fn discard_pending_managed_skill_update(
profile_root: &Path,
id: &str,
Expand Down Expand Up @@ -433,13 +486,17 @@ pub async fn approve_managed_skill(profile_root: &Path, id: &str) -> Result<Mana
let approved = match skill.pending_update {
None => set_managed_skill_state(profile_root, id, ManagedSkillState::Active).await?,
Some(pending) => {
let resulting_state = pending.resulting_state.unwrap_or(ManagedSkillState::Active);
let patch_target = match resulting_state {
ManagedSkillState::Archived => "approve_staged_archive",
_ => "approve_staged_update",
};
let mut promoted = pending.into_skill();
promoted.set_state(ManagedSkillState::Active);
promoted.set_state(resulting_state);
promoted.refresh_checksum();
remove_pending_update(profile_root, id).await?;
save_managed_skill(profile_root, &promoted).await?;
record_skill_patch(profile_root, &promoted, "approve_staged_update".to_string())
.await?;
record_skill_patch(profile_root, &promoted, patch_target.to_string()).await?;
promoted
}
};
Expand Down
67 changes: 38 additions & 29 deletions src/automation/runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ use super::managed_skills::list_managed_skills;
use super::run_ledger::{AutomationRunLedgerRecord, AutomationTrigger};
use super::session_reflector::validate_fact_proposals;
use super::skill_usage::{
ingest_project_analytics_events, stale_skill_recommendations, summarize_skill_usage,
ingest_project_analytics_events, skill_overlap_candidates, stale_skill_recommendations,
summarize_skill_usage, DEFAULT_SKILL_OVERLAP_LIMIT,
};
use super::skill_writer::{
activation_policy as skill_writer_activation_policy, skill_improvement_recommendations,
Expand Down Expand Up @@ -539,39 +540,41 @@ async fn finalize_skill_writer_success(
proposed_ops: &Value,
proposals: &[Value],
) -> Result<(Value, AutomationRunLedgerRecord)> {
let (created_skills, updated_skills, rejected_skills) =
match validate_and_apply_skill_proposals(
profile_root,
run_id,
proposals,
config.auto_enable_skills,
)
.await
{
Ok(result) => result,
Err(err) => {
finalizer
.append_failed_record(
response.model.clone(),
evidence_hash,
Some(proposed_ops.clone()),
err.to_string(),
)
.await?;
return Err(err);
}
};
let accepted_count = created_skills.len() + updated_skills.len();
let rejected_count = rejected_skills.len();
let proposal_outcome = match validate_and_apply_skill_proposals(
profile_root,
run_id,
proposals,
config.auto_enable_skills,
)
.await
{
Ok(result) => result,
Err(err) => {
finalizer
.append_failed_record(
response.model.clone(),
evidence_hash,
Some(proposed_ops.clone()),
err.to_string(),
)
.await?;
return Err(err);
}
};
let accepted_count = proposal_outcome.created.len()
+ proposal_outcome.updated.len()
+ proposal_outcome.consolidations.len();
let rejected_count = proposal_outcome.rejected.len();
let report = json!({
"status": if config.auto_enable_skills { "auto_enabled" } else { "needs_approval" },
"dry_run": true,
"task": "skill_writer",
"evidence_hash": evidence_hash,
"activation_policy": activation_policy,
"created_skills": created_skills,
"updated_skills": updated_skills,
"rejected_skills": rejected_skills,
"created_skills": proposal_outcome.created,
"updated_skills": proposal_outcome.updated,
"staged_consolidations": proposal_outcome.consolidations,
"rejected_skills": proposal_outcome.rejected,
"skill_improvement_recommendations": evidence
.get("skill_improvement_recommendations")
.cloned()
Expand All @@ -587,6 +590,7 @@ async fn finalize_skill_writer_success(
"skills": proposed_ops.get("skills").cloned().unwrap_or_else(|| json!([])),
"created_skills": report.get("created_skills").cloned().unwrap_or_else(|| json!([])),
"updated_skills": report.get("updated_skills").cloned().unwrap_or_else(|| json!([])),
"staged_consolidations": report.get("staged_consolidations").cloned().unwrap_or_else(|| json!([])),
"rejected_skills": report.get("rejected_skills").cloned().unwrap_or_else(|| json!([])),
})),
accepted_count,
Expand All @@ -595,6 +599,7 @@ async fn finalize_skill_writer_success(
record.applied_ops = Some(json!({
"created_skills": report.get("created_skills").cloned().unwrap_or_else(|| json!([])),
"updated_skills": report.get("updated_skills").cloned().unwrap_or_else(|| json!([])),
"staged_consolidations": report.get("staged_consolidations").cloned().unwrap_or_else(|| json!([])),
}));
record.rejected_ops = report.get("rejected_skills").cloned();
record.validation_report = Some(json!({
Expand Down Expand Up @@ -788,11 +793,14 @@ async fn build_skill_writer_evidence(
}))
})
.unwrap_or_default();
let overlap_candidates =
skill_overlap_candidates(&existing_skills, DEFAULT_SKILL_OVERLAP_LIMIT);
let skill_improvement_recommendations = skill_improvement_recommendations(
&hits,
&skill_usage_summaries,
&stale_recommendations,
&underused_tool_families,
&overlap_candidates,
);
let evidence = json!({
"storage_scope": storage_scope,
Expand All @@ -803,6 +811,7 @@ async fn build_skill_writer_evidence(
"skill_usage_summaries": skill_usage_summaries,
"stale_recommendations": stale_recommendations,
"underused_tool_families": underused_tool_families,
"skill_overlap_candidates": overlap_candidates,
"skill_improvement_recommendations": skill_improvement_recommendations,
"existing_managed_skills": existing_skills
.iter()
Expand Down Expand Up @@ -1275,7 +1284,7 @@ fn build_skill_writer_prompt(evidence: &Value) -> String {
"\n",
"An empty skills array is a real option when the session ran smoothly with no corrections and produced no new technique, but do not reach for it as a default.\n",
"\n",
"Response contract: Return only JSON with a skills array of managed skill creates or updates. New skills may omit action or use action=create and must include id, title, summary, category, body_markdown, optional targets, optional support_files with text content, and reason. Targets, when present, must be an array using cursor, codex, claude, agents, opencode, kimi, or kiro; Hermes is host-owned and must not be targeted. Updates must use action=update or action=patch, include id and base_checksum, and include at least one changed field among title, summary, category, targets, body_markdown/body, support_files, or pinned. For updates, support_files is a complete replacement list, not a partial file patch. Activation is controlled only by the runner policy; do not assume activation from your response.\n",
"Response contract: Return only JSON with a skills array of managed skill creates or updates. New skills may omit action or use action=create and must include id, title, summary, category, body_markdown, optional targets, optional support_files with text content, and reason. Targets, when present, must be an array using cursor, codex, claude, agents, opencode, kimi, or kiro; Hermes is host-owned and must not be targeted. Updates must use action=update or action=patch, include id and base_checksum, and include at least one changed field among title, summary, category, targets, body_markdown/body, support_files, or pinned. For updates, support_files is a complete replacement list, not a partial file patch. Consolidations: when skill_overlap_candidates shows overlapping managed skills, you may propose action=merge (include id for the surviving skill, base_checksum, source_skill_id, source_base_checksum, reason, and optional merged title/summary/category/targets/body_markdown/support_files) or action=archive (include id, base_checksum, reason). Consolidations are always staged for human approval and archive-only; content is never deleted. Never propose merge or archive for pinned or user-authored skills. Activation is controlled only by the runner policy; do not assume activation from your response.\n",
);
format!(
"{POLICY}{}",
Expand Down
5 changes: 5 additions & 0 deletions src/automation/skill_usage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,15 @@ use crate::errors::{Result, TraceDecayError};
use crate::tracedecay::current_timestamp;

mod analytics;
mod overlap;
mod recommendations;

pub(crate) use analytics::analytics_import_key_for_request;
pub use analytics::{ingest_analytics_events, ingest_project_analytics_events};
pub use overlap::{
skill_overlap_candidates, SkillOverlapCandidate, DEFAULT_SKILL_OVERLAP_LIMIT,
SKILL_OVERLAP_CONTENT_THRESHOLD, SKILL_OVERLAP_TITLE_THRESHOLD,
};
pub use recommendations::{skill_improvement_recommendations, stale_skill_recommendations};

const SKILL_USAGE_LEDGER_FILENAME: &str = "skill_usage.json";
Expand Down
Loading
Loading