Skip to content

Commit 6c9ea8b

Browse files
Merge pull request #216 from ScriptedAlchemy/codex/hermes-parity-r6-consolidation
feat: stage managed skill consolidations
2 parents 429d5fa + 1d4a0c5 commit 6c9ea8b

12 files changed

Lines changed: 1371 additions & 41 deletions

File tree

src/automation/managed_skill_model.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,16 @@ pub struct ManagedSkillPendingUpdate {
192192
pub body_markdown: String,
193193
#[serde(default)]
194194
pub support_files: Vec<ManagedSupportFile>,
195+
/// Lifecycle state the skill transitions to when this staged change is
196+
/// approved. `None` keeps the historical behavior (promote to `Active`).
197+
/// Staged consolidations set `Some(Archived)`; skill content is always
198+
/// preserved on disk (archive, never delete).
199+
#[serde(default, skip_serializing_if = "Option::is_none")]
200+
pub resulting_state: Option<ManagedSkillState>,
201+
/// Reviewer-facing reason recorded when the change was staged (used by
202+
/// consolidation proposals).
203+
#[serde(default, skip_serializing_if = "Option::is_none")]
204+
pub staged_reason: Option<String>,
195205
}
196206

197207
impl ManagedSkillPendingUpdate {

src/automation/managed_skill_validation.rs

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@ use crate::errors::{Result, TraceDecayError};
55

66
use super::managed_skill_format::target_key;
77
use super::managed_skill_model::{
8-
ManagedSkill, ManagedSkillPendingUpdate, ManagedSkillUpdate, ManagedSupportFile,
9-
SkillInstallTarget, MAX_MANAGED_SKILL_BODY_BYTES, MAX_MANAGED_SUPPORT_FILES,
10-
MAX_MANAGED_SUPPORT_FILE_BYTES,
8+
ManagedSkill, ManagedSkillPendingUpdate, ManagedSkillState, ManagedSkillUpdate,
9+
ManagedSupportFile, SkillInstallTarget, MAX_MANAGED_SKILL_BODY_BYTES,
10+
MAX_MANAGED_SUPPORT_FILES, MAX_MANAGED_SUPPORT_FILE_BYTES,
1111
};
1212

1313
const ALLOWED_SUPPORT_ROOTS: &[&str] = &["references", "templates", "scripts", "assets"];
@@ -241,6 +241,13 @@ pub(crate) fn validate_managed_pending_update(
241241
"managed skill staged_at must be a positive timestamp".to_string(),
242242
));
243243
}
244+
if let Some(resulting_state) = pending.resulting_state {
245+
if resulting_state != ManagedSkillState::Archived {
246+
return Err(config_error(
247+
"managed skill pending update resulting_state must be archived".to_string(),
248+
));
249+
}
250+
}
244251
let skill = ManagedSkill {
245252
metadata: pending.metadata.clone(),
246253
body_markdown: pending.body_markdown.clone(),

src/automation/managed_skills.rs

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -351,12 +351,65 @@ pub async fn stage_managed_skill_update(
351351
metadata: staged.metadata.clone(),
352352
body_markdown: staged.body_markdown.clone(),
353353
support_files: staged.support_files.clone(),
354+
resulting_state: None,
355+
staged_reason: None,
354356
};
355357
save_pending_update(profile_root, id, &pending).await?;
356358
record_skill_patch(profile_root, &staged, "staged_update".to_string()).await?;
357359
Ok(pending.into_skill())
358360
}
359361

362+
/// Stages an archive transition for a managed skill as a pending update that
363+
/// must be approved (or discarded) through the normal review lifecycle.
364+
/// Skill content is untouched: approving only flips the state to `Archived`,
365+
/// keeping the body and support files recoverable on disk. Pinned skills are
366+
/// exempt, matching the Hermes curator.
367+
pub async fn stage_managed_skill_archive(
368+
profile_root: &Path,
369+
id: &str,
370+
base_checksum: &str,
371+
reason: Option<String>,
372+
) -> Result<ManagedSkill> {
373+
let skill = load_managed_skill(profile_root, id).await?;
374+
if base_checksum != skill.metadata.checksum {
375+
return Err(config_error(format!(
376+
"base_checksum for managed skill id '{id}' is stale"
377+
)));
378+
}
379+
if skill.pending_update.is_some() {
380+
return Err(config_error(format!(
381+
"managed skill '{id}' already has a pending update"
382+
)));
383+
}
384+
if skill.metadata.pinned {
385+
return Err(config_error(format!(
386+
"managed skill '{id}' is pinned and exempt from staged archive"
387+
)));
388+
}
389+
if skill.metadata.state == ManagedSkillState::Archived {
390+
return Err(config_error(format!(
391+
"managed skill '{id}' is already archived"
392+
)));
393+
}
394+
395+
let mut staged = skill.clone();
396+
staged.pending_update = None;
397+
staged.set_state(ManagedSkillState::PendingApproval);
398+
staged.touch();
399+
let pending = ManagedSkillPendingUpdate {
400+
base_checksum: base_checksum.to_string(),
401+
staged_at: current_metadata_timestamp(),
402+
metadata: staged.metadata.clone(),
403+
body_markdown: staged.body_markdown.clone(),
404+
support_files: staged.support_files.clone(),
405+
resulting_state: Some(ManagedSkillState::Archived),
406+
staged_reason: reason,
407+
};
408+
save_pending_update(profile_root, id, &pending).await?;
409+
record_skill_patch(profile_root, &staged, "staged_archive".to_string()).await?;
410+
Ok(pending.into_skill())
411+
}
412+
360413
pub async fn discard_pending_managed_skill_update(
361414
profile_root: &Path,
362415
id: &str,
@@ -433,13 +486,17 @@ pub async fn approve_managed_skill(profile_root: &Path, id: &str) -> Result<Mana
433486
let approved = match skill.pending_update {
434487
None => set_managed_skill_state(profile_root, id, ManagedSkillState::Active).await?,
435488
Some(pending) => {
489+
let resulting_state = pending.resulting_state.unwrap_or(ManagedSkillState::Active);
490+
let patch_target = match resulting_state {
491+
ManagedSkillState::Archived => "approve_staged_archive",
492+
_ => "approve_staged_update",
493+
};
436494
let mut promoted = pending.into_skill();
437-
promoted.set_state(ManagedSkillState::Active);
495+
promoted.set_state(resulting_state);
438496
promoted.refresh_checksum();
439497
remove_pending_update(profile_root, id).await?;
440498
save_managed_skill(profile_root, &promoted).await?;
441-
record_skill_patch(profile_root, &promoted, "approve_staged_update".to_string())
442-
.await?;
499+
record_skill_patch(profile_root, &promoted, patch_target.to_string()).await?;
443500
promoted
444501
}
445502
};

src/automation/runner.rs

Lines changed: 38 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ use super::managed_skills::list_managed_skills;
2020
use super::run_ledger::{AutomationRunLedgerRecord, AutomationTrigger};
2121
use super::session_reflector::validate_fact_proposals;
2222
use super::skill_usage::{
23-
ingest_project_analytics_events, stale_skill_recommendations, summarize_skill_usage,
23+
ingest_project_analytics_events, skill_overlap_candidates, stale_skill_recommendations,
24+
summarize_skill_usage, DEFAULT_SKILL_OVERLAP_LIMIT,
2425
};
2526
use super::skill_writer::{
2627
activation_policy as skill_writer_activation_policy, skill_improvement_recommendations,
@@ -578,39 +579,41 @@ async fn finalize_skill_writer_success(
578579
proposed_ops: &Value,
579580
proposals: &[Value],
580581
) -> Result<(Value, AutomationRunLedgerRecord)> {
581-
let (created_skills, updated_skills, rejected_skills) =
582-
match validate_and_apply_skill_proposals(
583-
profile_root,
584-
run_id,
585-
proposals,
586-
config.auto_enable_skills,
587-
)
588-
.await
589-
{
590-
Ok(result) => result,
591-
Err(err) => {
592-
finalizer
593-
.append_failed_record(
594-
response.model.clone(),
595-
evidence_hash,
596-
Some(proposed_ops.clone()),
597-
err.to_string(),
598-
)
599-
.await?;
600-
return Err(err);
601-
}
602-
};
603-
let accepted_count = created_skills.len() + updated_skills.len();
604-
let rejected_count = rejected_skills.len();
582+
let proposal_outcome = match validate_and_apply_skill_proposals(
583+
profile_root,
584+
run_id,
585+
proposals,
586+
config.auto_enable_skills,
587+
)
588+
.await
589+
{
590+
Ok(result) => result,
591+
Err(err) => {
592+
finalizer
593+
.append_failed_record(
594+
response.model.clone(),
595+
evidence_hash,
596+
Some(proposed_ops.clone()),
597+
err.to_string(),
598+
)
599+
.await?;
600+
return Err(err);
601+
}
602+
};
603+
let accepted_count = proposal_outcome.created.len()
604+
+ proposal_outcome.updated.len()
605+
+ proposal_outcome.consolidations.len();
606+
let rejected_count = proposal_outcome.rejected.len();
605607
let report = json!({
606608
"status": if config.auto_enable_skills { "auto_enabled" } else { "needs_approval" },
607609
"dry_run": true,
608610
"task": "skill_writer",
609611
"evidence_hash": evidence_hash,
610612
"activation_policy": activation_policy,
611-
"created_skills": created_skills,
612-
"updated_skills": updated_skills,
613-
"rejected_skills": rejected_skills,
613+
"created_skills": proposal_outcome.created,
614+
"updated_skills": proposal_outcome.updated,
615+
"staged_consolidations": proposal_outcome.consolidations,
616+
"rejected_skills": proposal_outcome.rejected,
614617
"skill_improvement_recommendations": evidence
615618
.get("skill_improvement_recommendations")
616619
.cloned()
@@ -626,6 +629,7 @@ async fn finalize_skill_writer_success(
626629
"skills": proposed_ops.get("skills").cloned().unwrap_or_else(|| json!([])),
627630
"created_skills": report.get("created_skills").cloned().unwrap_or_else(|| json!([])),
628631
"updated_skills": report.get("updated_skills").cloned().unwrap_or_else(|| json!([])),
632+
"staged_consolidations": report.get("staged_consolidations").cloned().unwrap_or_else(|| json!([])),
629633
"rejected_skills": report.get("rejected_skills").cloned().unwrap_or_else(|| json!([])),
630634
})),
631635
accepted_count,
@@ -634,6 +638,7 @@ async fn finalize_skill_writer_success(
634638
record.applied_ops = Some(json!({
635639
"created_skills": report.get("created_skills").cloned().unwrap_or_else(|| json!([])),
636640
"updated_skills": report.get("updated_skills").cloned().unwrap_or_else(|| json!([])),
641+
"staged_consolidations": report.get("staged_consolidations").cloned().unwrap_or_else(|| json!([])),
637642
}));
638643
record.rejected_ops = report.get("rejected_skills").cloned();
639644
record.validation_report = Some(json!({
@@ -865,11 +870,14 @@ async fn build_skill_writer_evidence(
865870
}))
866871
})
867872
.unwrap_or_default();
873+
let overlap_candidates =
874+
skill_overlap_candidates(&existing_skills, DEFAULT_SKILL_OVERLAP_LIMIT);
868875
let skill_improvement_recommendations = skill_improvement_recommendations(
869876
&hits,
870877
&skill_usage_summaries,
871878
&stale_recommendations,
872879
&underused_tool_families,
880+
&overlap_candidates,
873881
);
874882
let evidence = json!({
875883
"evidence_mode": evidence_mode_label(recent_session_slices.is_some()),
@@ -882,6 +890,7 @@ async fn build_skill_writer_evidence(
882890
"skill_usage_summaries": skill_usage_summaries,
883891
"stale_recommendations": stale_recommendations,
884892
"underused_tool_families": underused_tool_families,
893+
"skill_overlap_candidates": overlap_candidates,
885894
"skill_improvement_recommendations": skill_improvement_recommendations,
886895
"existing_managed_skills": existing_skills
887896
.iter()
@@ -1360,7 +1369,7 @@ fn build_skill_writer_prompt(evidence: &Value) -> String {
13601369
"\n",
13611370
"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",
13621371
"\n",
1363-
"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",
1372+
"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",
13641373
);
13651374
format!(
13661375
"{POLICY}{}",

src/automation/skill_usage.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,15 @@ use crate::errors::{Result, TraceDecayError};
88
use crate::tracedecay::current_timestamp;
99

1010
mod analytics;
11+
mod overlap;
1112
mod recommendations;
1213

1314
pub(crate) use analytics::analytics_import_key_for_request;
1415
pub use analytics::{ingest_analytics_events, ingest_project_analytics_events};
16+
pub use overlap::{
17+
skill_overlap_candidates, SkillOverlapCandidate, DEFAULT_SKILL_OVERLAP_LIMIT,
18+
SKILL_OVERLAP_CONTENT_THRESHOLD, SKILL_OVERLAP_TITLE_THRESHOLD,
19+
};
1520
pub use recommendations::{skill_improvement_recommendations, stale_skill_recommendations};
1621

1722
const SKILL_USAGE_LEDGER_FILENAME: &str = "skill_usage.json";

0 commit comments

Comments
 (0)