From cee82417db49bbd733be0302c2c1f70929d03067 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 10 Apr 2026 11:41:36 -0700 Subject: [PATCH] feat: add ecc2 legacy skill migration import --- docs/HERMES-OPENCLAW-MIGRATION.md | 1 + docs/HERMES-SETUP.md | 2 +- ecc2/src/main.rs | 400 +++++++++++++++++++++++++++++- 3 files changed, 401 insertions(+), 2 deletions(-) diff --git a/docs/HERMES-OPENCLAW-MIGRATION.md b/docs/HERMES-OPENCLAW-MIGRATION.md index 42b9bcb6..a386f951 100644 --- a/docs/HERMES-OPENCLAW-MIGRATION.md +++ b/docs/HERMES-OPENCLAW-MIGRATION.md @@ -188,6 +188,7 @@ ECC 2.0 now ships a bounded migration audit entrypoint: - `ecc migrate audit --source ~/.hermes` - `ecc migrate plan --source ~/.hermes --output migration-plan.md` - `ecc migrate scaffold --source ~/.hermes --output-dir migration-artifacts` +- `ecc migrate import-skills --source ~/.hermes --output-dir migration-artifacts/skills` - `ecc migrate import-schedules --source ~/.hermes --dry-run` - `ecc migrate import-remote --source ~/.hermes --dry-run` - `ecc migrate import-env --source ~/.hermes --dry-run` diff --git a/docs/HERMES-SETUP.md b/docs/HERMES-SETUP.md index a0905e5e..83af8abc 100644 --- a/docs/HERMES-SETUP.md +++ b/docs/HERMES-SETUP.md @@ -83,7 +83,7 @@ These stay local and should be configured per operator: ## Suggested Bring-Up Order 0. Run `ecc migrate audit --source ~/.hermes` first to inventory the legacy workspace and see which parts already map onto ECC2. -0.5. Generate and review artifacts with `ecc migrate plan` / `ecc migrate scaffold`, preview recurring jobs with `ecc migrate import-schedules --dry-run`, preview gateway dispatch with `ecc migrate import-remote --dry-run`, preview safe env/service context with `ecc migrate import-env --dry-run`, then import sanitized workspace memory with `ecc migrate import-memory`. +0.5. Generate and review artifacts with `ecc migrate plan` / `ecc migrate scaffold`, scaffold reusable legacy skills with `ecc migrate import-skills --output-dir migration-artifacts/skills`, preview recurring jobs with `ecc migrate import-schedules --dry-run`, preview gateway dispatch with `ecc migrate import-remote --dry-run`, preview safe env/service context with `ecc migrate import-env --dry-run`, then import sanitized workspace memory with `ecc migrate import-memory`. 1. Install ECC and verify the baseline harness setup. 2. Install Hermes and point it at ECC-imported skills. 3. Register the MCP servers you actually use every day. diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index 59252f19..63b5c87f 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -647,6 +647,18 @@ enum MigrationCommands { #[arg(long)] json: bool, }, + /// Scaffold ECC-native orchestration templates from legacy skill markdown + ImportSkills { + /// Path to the legacy Hermes/OpenClaw workspace root + #[arg(long)] + source: PathBuf, + /// Directory where imported ECC2 skill artifacts should be written + #[arg(long)] + output_dir: PathBuf, + /// Emit machine-readable JSON instead of the human summary + #[arg(long)] + json: bool, + }, /// Import legacy gateway/dispatch tasks into the ECC2 remote queue ImportRemote { /// Path to the legacy Hermes/OpenClaw workspace root @@ -1093,6 +1105,29 @@ struct LegacyEnvImportReport { sources: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +struct LegacySkillImportEntry { + source_path: String, + template_name: String, + title: String, + summary: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +struct LegacySkillImportReport { + source: String, + output_dir: String, + skills_detected: usize, + templates_generated: usize, + files_written: Vec, + skills: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +struct LegacySkillTemplateFile { + orchestration_templates: BTreeMap, +} + #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] enum LegacyRemoteImportRequestStatus { @@ -1944,6 +1979,18 @@ async fn main() -> Result<()> { println!("{}", format_legacy_env_import_human(&report)); } } + MigrationCommands::ImportSkills { + source, + output_dir, + json, + } => { + let report = import_legacy_skills(&source, &output_dir)?; + if json { + println!("{}", serde_json::to_string_pretty(&report)?); + } else { + println!("{}", format_legacy_skill_import_human(&report)); + } + } MigrationCommands::ImportRemote { source, dry_run, @@ -5106,7 +5153,7 @@ fn build_legacy_migration_next_steps(artifacts: &[LegacyMigrationArtifact]) -> V } if categories.contains("skills") { steps.push( - "Translate reusable Hermes/OpenClaw skills into ECC skills or orchestration templates one lane at a time instead of bulk-copying them." + "Scaffold translated legacy skills with `ecc migrate import-skills --source --output-dir `, then promote the reusable ones into ECC skills or orchestration templates one lane at a time instead of bulk-copying them." .to_string(), ); } @@ -5966,6 +6013,239 @@ fn build_legacy_env_connector( )) } +fn import_legacy_skills(source: &Path, output_dir: &Path) -> Result { + let source = source + .canonicalize() + .with_context(|| format!("Legacy workspace not found: {}", source.display()))?; + if !source.is_dir() { + anyhow::bail!( + "Legacy workspace source must be a directory: {}", + source.display() + ); + } + + let skills_dir = source.join("skills"); + let mut report = LegacySkillImportReport { + source: source.display().to_string(), + output_dir: output_dir.display().to_string(), + skills_detected: 0, + templates_generated: 0, + files_written: Vec::new(), + skills: Vec::new(), + }; + if !skills_dir.is_dir() { + return Ok(report); + } + + let skill_paths = collect_markdown_paths(&skills_dir, true)?; + if skill_paths.is_empty() { + return Ok(report); + } + + fs::create_dir_all(output_dir) + .with_context(|| format!("create legacy skill output dir {}", output_dir.display()))?; + + let mut templates = BTreeMap::new(); + for path in skill_paths { + let draft = build_legacy_skill_draft(&source, &skills_dir, &path)?; + report.skills_detected += 1; + report.templates_generated += 1; + report.skills.push(LegacySkillImportEntry { + source_path: draft.source_path.clone(), + template_name: draft.template_name.clone(), + title: draft.title.clone(), + summary: draft.summary.clone(), + }); + templates.insert( + draft.template_name.clone(), + config::OrchestrationTemplateConfig { + description: Some(format!( + "Migrated legacy skill scaffold from {}", + draft.source_path + )), + project: Some("legacy-migration".to_string()), + task_group: Some("legacy skill".to_string()), + agent: Some("claude".to_string()), + profile: None, + worktree: Some(false), + steps: vec![config::OrchestrationTemplateStepConfig { + name: Some("operator".to_string()), + task: format!( + "Use the migrated legacy skill context from {}.\nLegacy skill title: {}\nLegacy summary: {}\nLegacy excerpt:\n{}\nTranslate and run that workflow for {{{{task}}}}.", + draft.source_path, draft.title, draft.summary, draft.excerpt + ), + agent: None, + profile: None, + worktree: Some(false), + project: Some("legacy-migration".to_string()), + task_group: Some("legacy skill".to_string()), + }], + }, + ); + } + + let templates_path = output_dir.join("ecc2.imported-skills.toml"); + fs::write( + &templates_path, + toml::to_string_pretty(&LegacySkillTemplateFile { + orchestration_templates: templates, + })?, + ) + .with_context(|| { + format!( + "write imported skill templates {}", + templates_path.display() + ) + })?; + report + .files_written + .push(templates_path.display().to_string()); + + let summary_path = output_dir.join("imported-skills.md"); + fs::write( + &summary_path, + format_legacy_skill_import_summary_markdown(&report), + ) + .with_context(|| format!("write imported skill summary {}", summary_path.display()))?; + report + .files_written + .push(summary_path.display().to_string()); + + Ok(report) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct LegacySkillDraft { + source_path: String, + template_name: String, + title: String, + summary: String, + excerpt: String, +} + +fn build_legacy_skill_draft( + source: &Path, + skills_dir: &Path, + path: &Path, +) -> Result { + let body = fs::read_to_string(path) + .with_context(|| format!("read legacy skill file {}", path.display()))?; + let source_path = path + .strip_prefix(source) + .unwrap_or(path) + .display() + .to_string(); + let relative_to_skills = path.strip_prefix(skills_dir).unwrap_or(path); + let title = extract_legacy_skill_title(relative_to_skills, &body); + let summary = extract_legacy_skill_summary(&body).unwrap_or_else(|| title.clone()); + let excerpt = extract_legacy_skill_excerpt(&body, 8, 600).unwrap_or_else(|| summary.clone()); + let template_name = slugify_legacy_skill_template_name(relative_to_skills); + + Ok(LegacySkillDraft { + source_path, + template_name, + title, + summary, + excerpt, + }) +} + +fn extract_legacy_skill_title(relative_path: &Path, body: &str) -> String { + for line in body.lines() { + let trimmed = line.trim(); + if let Some(title) = trimmed.strip_prefix("#") { + let title = title.trim(); + if !title.is_empty() { + return title.to_string(); + } + } + } + relative_path + .file_stem() + .and_then(|value| value.to_str()) + .map(|value| value.replace(['-', '_'], " ")) + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| "legacy skill".to_string()) +} + +fn extract_legacy_skill_summary(body: &str) -> Option { + body.lines() + .map(str::trim) + .find(|line| !line.is_empty() && !line.starts_with('#')) + .map(ToString::to_string) +} + +fn extract_legacy_skill_excerpt(body: &str, max_lines: usize, max_chars: usize) -> Option { + let mut lines = Vec::new(); + let mut chars = 0usize; + for line in body.lines().map(str::trim).filter(|line| !line.is_empty()) { + if chars >= max_chars || lines.len() >= max_lines { + break; + } + let remaining = max_chars.saturating_sub(chars); + if remaining == 0 { + break; + } + let truncated = truncate_connector_text(line, remaining); + chars += truncated.len(); + lines.push(truncated); + } + if lines.is_empty() { + None + } else { + Some(lines.join("\n")) + } +} + +fn slugify_legacy_skill_template_name(relative_path: &Path) -> String { + relative_path + .to_string_lossy() + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() { + ch.to_ascii_lowercase() + } else { + '_' + } + }) + .collect::() + .trim_matches('_') + .split('_') + .filter(|segment| !segment.is_empty()) + .collect::>() + .join("_") +} + +fn format_legacy_skill_import_summary_markdown(report: &LegacySkillImportReport) -> String { + let mut lines = vec![ + "# Imported legacy skills".to_string(), + String::new(), + format!("- Source: `{}`", report.source), + format!("- Output dir: `{}`", report.output_dir), + format!("- Skills detected: {}", report.skills_detected), + format!("- Templates generated: {}", report.templates_generated), + String::new(), + ]; + + if report.skills.is_empty() { + lines.push("No legacy skill markdown files were detected.".to_string()); + return lines.join("\n"); + } + + lines.push("## Skills".to_string()); + lines.push(String::new()); + for skill in &report.skills { + lines.push(format!( + "- `{}` -> `{}`", + skill.source_path, skill.template_name + )); + lines.push(format!(" - Title: {}", skill.title)); + lines.push(format!(" - Summary: {}", skill.summary)); + } + + lines.join("\n") +} + fn build_legacy_remote_add_command(draft: &LegacyRemoteDispatchDraft) -> Option { match draft.request_kind { session::RemoteDispatchKind::Standard => { @@ -6373,6 +6653,10 @@ fn build_legacy_migration_plan_report( target_surface: "ECC skills / orchestration templates".to_string(), source_paths: artifact.source_paths.clone(), command_snippets: vec![ + format!( + "ecc migrate import-skills --source {} --output-dir migration-artifacts/skills", + shell_quote_double(&audit.source) + ), "ecc template --task \"\"".to_string(), ], config_snippets: vec![ @@ -6767,6 +7051,36 @@ fn format_legacy_env_import_human(report: &LegacyEnvImportReport) -> String { lines.join("\n") } +fn format_legacy_skill_import_human(report: &LegacySkillImportReport) -> String { + let mut lines = vec![ + format!("Legacy skill import complete for {}", report.source), + format!("- output dir {}", report.output_dir), + format!("- skills detected {}", report.skills_detected), + format!("- templates generated {}", report.templates_generated), + ]; + + if !report.files_written.is_empty() { + lines.push("Files".to_string()); + for path in &report.files_written { + lines.push(format!("- {}", path)); + } + } + + if !report.skills.is_empty() { + lines.push("Skills".to_string()); + for skill in &report.skills { + lines.push(format!( + "- {} -> {}", + skill.source_path, skill.template_name + )); + lines.push(format!(" title {}", skill.title)); + lines.push(format!(" summary {}", skill.summary)); + } + } + + lines.join("\n") +} + fn format_legacy_remote_import_human(report: &LegacyRemoteImportReport) -> String { let mut lines = vec![ format!( @@ -9433,6 +9747,37 @@ mod tests { } } + #[test] + fn cli_parses_migrate_import_skills_command() { + let cli = Cli::try_parse_from([ + "ecc", + "migrate", + "import-skills", + "--source", + "/tmp/hermes", + "--output-dir", + "/tmp/out", + "--json", + ]) + .expect("migrate import-skills should parse"); + + match cli.command { + Some(Commands::Migrate { + command: + MigrationCommands::ImportSkills { + source, + output_dir, + json, + }, + }) => { + assert_eq!(source, PathBuf::from("/tmp/hermes")); + assert_eq!(output_dir, PathBuf::from("/tmp/out")); + assert!(json); + } + _ => panic!("expected migrate import-skills subcommand"), + } + } + #[test] fn legacy_migration_audit_report_maps_detected_artifacts() -> Result<()> { let tempdir = TestDir::new("legacy-migration-audit")?; @@ -9507,6 +9852,7 @@ mod tests { fs::create_dir_all(root.join("cron"))?; fs::create_dir_all(root.join("gateway"))?; fs::create_dir_all(root.join("workspace/notes"))?; + fs::create_dir_all(root.join("skills/ecc-imports"))?; fs::write(root.join("config.yaml"), "model: claude\n")?; fs::write( root.join("cron/jobs.json"), @@ -9563,6 +9909,7 @@ mod tests { .join("\n"), )?; fs::write(root.join("workspace/notes/recovery.md"), "# recovery\n")?; + fs::write(root.join("skills/ecc-imports/research.md"), "# research\n")?; let audit = build_legacy_migration_audit_report(root)?; let plan = build_legacy_migration_plan_report(&audit); @@ -9636,6 +9983,15 @@ mod tests { .command_snippets .iter() .any(|command| command.contains("ecc migrate import-env --source"))); + let skills_step = plan + .steps + .iter() + .find(|step| step.category == "skills") + .expect("skills step"); + assert!(skills_step + .command_snippets + .iter() + .any(|command| command.contains("ecc migrate import-skills --source"))); Ok(()) } @@ -10091,6 +10447,48 @@ Route existing installs to portal first before checkout. Ok(()) } + #[test] + fn import_legacy_skills_writes_template_artifacts() -> Result<()> { + let tempdir = TestDir::new("legacy-skill-import")?; + let root = tempdir.path(); + fs::create_dir_all(root.join("skills/ecc-imports"))?; + fs::create_dir_all(root.join("skills/ops"))?; + fs::write( + root.join("skills/ecc-imports/research.md"), + "# Recovery research\nGather billing/account context before touching checkout logic.\n", + )?; + fs::write( + root.join("skills/ops/recovery.markdown"), + "# Portal repair\nRoute wiped installs toward repair before presenting new checkout.\n", + )?; + + let output_dir = root.join("out"); + let report = import_legacy_skills(root, &output_dir)?; + + assert_eq!(report.skills_detected, 2); + assert_eq!(report.templates_generated, 2); + assert_eq!(report.files_written.len(), 2); + assert!(report + .skills + .iter() + .any(|skill| skill.template_name == "ecc_imports_research_md")); + assert!(report + .skills + .iter() + .any(|skill| skill.template_name == "ops_recovery_markdown")); + + let config_text = fs::read_to_string(output_dir.join("ecc2.imported-skills.toml"))?; + assert!(config_text.contains("[orchestration_templates.ecc_imports_research_md]")); + assert!(config_text.contains("[orchestration_templates.ops_recovery_markdown]")); + assert!(config_text.contains("Translate and run that workflow for {{task}}.")); + + let summary_text = fs::read_to_string(output_dir.join("imported-skills.md"))?; + assert!(summary_text.contains("skills/ecc-imports/research.md")); + assert!(summary_text.contains("skills/ops/recovery.markdown")); + + Ok(()) + } + #[test] fn legacy_migration_scaffold_writes_plan_and_config_files() -> Result<()> { let tempdir = TestDir::new("legacy-migration-scaffold")?;