diff --git a/cli.py b/cli.py index 850db4102..b3857e373 100755 --- a/cli.py +++ b/cli.py @@ -19,6 +19,7 @@ import sys import json import atexit import uuid +import textwrap from pathlib import Path from datetime import datetime from typing import List, Dict, Any, Optional @@ -2767,6 +2768,8 @@ class HermesCLI: return "type password (hidden), Enter to skip" if cli_ref._approval_state: return "" + if cli_ref._clarify_freetext: + return "type your answer here and press Enter" if cli_ref._clarify_state: return "" if cli_ref._agent_running: @@ -2824,6 +2827,32 @@ class HermesCLI: # --- Clarify tool: dynamic display widget for questions + choices --- + def _panel_box_width(title: str, content_lines: list[str], min_width: int = 46, max_width: int = 76) -> int: + """Choose a stable panel width wide enough for the title and content.""" + term_cols = shutil.get_terminal_size((100, 20)).columns + longest = max([len(title)] + [len(line) for line in content_lines] + [min_width - 4]) + inner = min(max(longest + 4, min_width - 2), max_width - 2, max(24, term_cols - 6)) + return inner + 2 # account for the single leading/trailing spaces inside borders + + def _wrap_panel_text(text: str, width: int, subsequent_indent: str = "") -> list[str]: + wrapped = textwrap.wrap( + text, + width=max(8, width), + break_long_words=False, + break_on_hyphens=False, + subsequent_indent=subsequent_indent, + ) + return wrapped or [""] + + def _append_panel_line(lines, border_style: str, content_style: str, text: str, box_width: int) -> None: + inner_width = max(0, box_width - 2) + lines.append((border_style, "│ ")) + lines.append((content_style, text.ljust(inner_width))) + lines.append((border_style, " │\n")) + + def _append_blank_panel_line(lines, border_style: str, box_width: int) -> None: + lines.append((border_style, "│" + (" " * box_width) + "│\n")) + def _get_clarify_display(): """Build styled text for the clarify question/choices panel.""" state = cli_ref._clarify_state @@ -2833,43 +2862,62 @@ class HermesCLI: question = state["question"] choices = state.get("choices") or [] selected = state.get("selected", 0) + preview_lines = _wrap_panel_text(question, 60) + for i, choice in enumerate(choices): + prefix = "❯ " if i == selected and not cli_ref._clarify_freetext else " " + preview_lines.extend(_wrap_panel_text(f"{prefix}{choice}", 60, subsequent_indent=" ")) + other_label = ( + "❯ Other (type below)" if cli_ref._clarify_freetext + else "❯ Other (type your answer)" if selected == len(choices) + else " Other (type your answer)" + ) + preview_lines.extend(_wrap_panel_text(other_label, 60, subsequent_indent=" ")) + box_width = _panel_box_width("Hermes needs your input", preview_lines) + inner_text_width = max(8, box_width - 2) lines = [] # Box top border lines.append(('class:clarify-border', '╭─ ')) lines.append(('class:clarify-title', 'Hermes needs your input')) - lines.append(('class:clarify-border', ' ─────────────────────────────╮\n')) - lines.append(('class:clarify-border', '│\n')) + lines.append(('class:clarify-border', ' ' + ('─' * max(0, box_width - len("Hermes needs your input") - 3)) + '╮\n')) + _append_blank_panel_line(lines, 'class:clarify-border', box_width) # Question text - lines.append(('class:clarify-border', '│ ')) - lines.append(('class:clarify-question', question)) - lines.append(('', '\n')) - lines.append(('class:clarify-border', '│\n')) + for wrapped in _wrap_panel_text(question, inner_text_width): + _append_panel_line(lines, 'class:clarify-border', 'class:clarify-question', wrapped, box_width) + _append_blank_panel_line(lines, 'class:clarify-border', box_width) + + if cli_ref._clarify_freetext and not choices: + guidance = "Type your answer in the prompt below, then press Enter." + for wrapped in _wrap_panel_text(guidance, inner_text_width): + _append_panel_line(lines, 'class:clarify-border', 'class:clarify-choice', wrapped, box_width) + _append_blank_panel_line(lines, 'class:clarify-border', box_width) if choices: # Multiple-choice mode: show selectable options for i, choice in enumerate(choices): - lines.append(('class:clarify-border', '│ ')) - if i == selected and not cli_ref._clarify_freetext: - lines.append(('class:clarify-selected', f'❯ {choice}')) - else: - lines.append(('class:clarify-choice', f' {choice}')) - lines.append(('', '\n')) + style = 'class:clarify-selected' if i == selected and not cli_ref._clarify_freetext else 'class:clarify-choice' + prefix = '❯ ' if i == selected and not cli_ref._clarify_freetext else ' ' + wrapped_lines = _wrap_panel_text(f"{prefix}{choice}", inner_text_width, subsequent_indent=" ") + for wrapped in wrapped_lines: + _append_panel_line(lines, 'class:clarify-border', style, wrapped, box_width) # "Other" option (5th line, only shown when choices exist) other_idx = len(choices) - lines.append(('class:clarify-border', '│ ')) if selected == other_idx and not cli_ref._clarify_freetext: - lines.append(('class:clarify-selected', '❯ Other (type your answer)')) + other_style = 'class:clarify-selected' + other_label = '❯ Other (type your answer)' elif cli_ref._clarify_freetext: - lines.append(('class:clarify-active-other', '❯ Other (type below)')) + other_style = 'class:clarify-active-other' + other_label = '❯ Other (type below)' else: - lines.append(('class:clarify-choice', ' Other (type your answer)')) - lines.append(('', '\n')) + other_style = 'class:clarify-choice' + other_label = ' Other (type your answer)' + for wrapped in _wrap_panel_text(other_label, inner_text_width, subsequent_indent=" "): + _append_panel_line(lines, 'class:clarify-border', other_style, wrapped, box_width) - lines.append(('class:clarify-border', '│\n')) - lines.append(('class:clarify-border', '╰──────────────────────────────────────────────────╯\n')) + _append_blank_panel_line(lines, 'class:clarify-border', box_width) + lines.append(('class:clarify-border', '╰' + ('─' * box_width) + '╯\n')) return lines clarify_widget = ConditionalContainer( @@ -2924,29 +2972,32 @@ class HermesCLI: "always": "Add to permanent allowlist", "deny": "Deny", } + preview_lines = _wrap_panel_text(description, 60) + preview_lines.extend(_wrap_panel_text(cmd_display, 60)) + for i, choice in enumerate(choices): + prefix = '❯ ' if i == selected else ' ' + preview_lines.extend(_wrap_panel_text(f"{prefix}{choice_labels.get(choice, choice)}", 60, subsequent_indent=" ")) + box_width = _panel_box_width("⚠️ Dangerous Command", preview_lines) + inner_text_width = max(8, box_width - 2) lines = [] lines.append(('class:approval-border', '╭─ ')) lines.append(('class:approval-title', '⚠️ Dangerous Command')) - lines.append(('class:approval-border', ' ───────────────────────────────╮\n')) - lines.append(('class:approval-border', '│\n')) - lines.append(('class:approval-border', '│ ')) - lines.append(('class:approval-desc', description)) - lines.append(('', '\n')) - lines.append(('class:approval-border', '│ ')) - lines.append(('class:approval-cmd', cmd_display)) - lines.append(('', '\n')) - lines.append(('class:approval-border', '│\n')) + lines.append(('class:approval-border', ' ' + ('─' * max(0, box_width - len("⚠️ Dangerous Command") - 3)) + '╮\n')) + _append_blank_panel_line(lines, 'class:approval-border', box_width) + for wrapped in _wrap_panel_text(description, inner_text_width): + _append_panel_line(lines, 'class:approval-border', 'class:approval-desc', wrapped, box_width) + for wrapped in _wrap_panel_text(cmd_display, inner_text_width): + _append_panel_line(lines, 'class:approval-border', 'class:approval-cmd', wrapped, box_width) + _append_blank_panel_line(lines, 'class:approval-border', box_width) for i, choice in enumerate(choices): - lines.append(('class:approval-border', '│ ')) label = choice_labels.get(choice, choice) - if i == selected: - lines.append(('class:approval-selected', f'❯ {label}')) - else: - lines.append(('class:approval-choice', f' {label}')) - lines.append(('', '\n')) - lines.append(('class:approval-border', '│\n')) - lines.append(('class:approval-border', '╰──────────────────────────────────────────────────────╯\n')) + style = 'class:approval-selected' if i == selected else 'class:approval-choice' + prefix = '❯ ' if i == selected else ' ' + for wrapped in _wrap_panel_text(f"{prefix}{label}", inner_text_width, subsequent_indent=" "): + _append_panel_line(lines, 'class:approval-border', style, wrapped, box_width) + _append_blank_panel_line(lines, 'class:approval-border', box_width) + lines.append(('class:approval-border', '╰' + ('─' * box_width) + '╯\n')) return lines approval_widget = ConditionalContainer( diff --git a/optional-skills/migration/openclaw-migration/SKILL.md b/optional-skills/migration/openclaw-migration/SKILL.md index f965ca164..d7ae9982f 100644 --- a/optional-skills/migration/openclaw-migration/SKILL.md +++ b/optional-skills/migration/openclaw-migration/SKILL.md @@ -18,16 +18,35 @@ Use this skill when a user wants to move their OpenClaw setup into Hermes Agent It uses `scripts/openclaw_to_hermes.py` to: -- import `SOUL.md` into `~/.hermes/SOUL.md` +- import `SOUL.md` into the Hermes home directory as `SOUL.md` - transform OpenClaw `MEMORY.md` and `USER.md` into Hermes memory entries - merge OpenClaw command approval patterns into Hermes `command_allowlist` - migrate Hermes-compatible messaging settings such as `TELEGRAM_ALLOWED_USERS` and `MESSAGING_CWD` - copy OpenClaw skills into `~/.hermes/skills/openclaw-imports/` -- optionally copy the OpenClaw workspace `AGENTS.md` into a chosen Hermes workspace +- optionally copy the OpenClaw workspace instructions file into a chosen Hermes workspace - mirror compatible workspace assets such as `workspace/tts/` into `~/.hermes/tts/` - archive non-secret docs that do not have a direct Hermes destination - produce a structured report listing migrated items, conflicts, skipped items, and reasons +## Path resolution + +The helper script lives in this skill directory at: + +- `scripts/openclaw_to_hermes.py` + +When this skill is installed from the Skills Hub, the normal location is: + +- `~/.hermes/skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py` + +Do not guess a shorter path like `~/.hermes/skills/openclaw-migration/...`. + +Before running the helper: + +1. Prefer the installed path under `~/.hermes/skills/migration/openclaw-migration/`. +2. If that path fails, inspect the installed skill directory and resolve the script relative to the installed `SKILL.md`. +3. Only use `find` as a fallback if the installed location is missing or the skill was moved manually. +4. When calling the terminal tool, do not pass `workdir: "~"`. Use an absolute directory such as the user's home directory, or omit `workdir` entirely. + With `--migrate-secrets`, it will also import a small allowlisted set of Hermes-compatible secrets, currently: - `TELEGRAM_BOT_TOKEN` @@ -35,34 +54,198 @@ With `--migrate-secrets`, it will also import a small allowlisted set of Hermes- ## Default workflow 1. Inspect first with a dry run. -2. Ask for a target workspace path if `AGENTS.md` should be brought over. -3. Execute the migration. -4. Summarize the results, especially: +2. Present a simple summary of what can be migrated, what cannot be migrated, and what would be archived. +3. If the `clarify` tool is available, use it for user decisions instead of asking for a free-form prose reply. +4. If the dry run finds imported skill directory conflicts, ask how those should be handled before executing. +5. Ask the user to choose between the two supported migration modes before executing. +6. Ask for a target workspace path only if the user wants the workspace instructions file brought over. +7. Execute the migration with the matching preset and flags. +8. Summarize the results, especially: - what was migrated - what was archived for manual review - what was skipped and why +## User interaction protocol + +Hermes CLI supports the `clarify` tool for interactive prompts, but it is limited to: + +- one choice at a time +- up to 4 predefined choices +- an automatic `Other` free-text option + +It does **not** support true multi-select checkboxes in a single prompt. + +For every `clarify` call: + +- always include a non-empty `question` +- include `choices` only for real selectable prompts +- keep `choices` to 2-4 plain string options +- never emit placeholder or truncated options such as `...` +- never pad or stylize choices with extra whitespace +- never include fake form fields in the question such as `enter directory here`, blank lines to fill in, or underscores like `_____` +- for open-ended path questions, ask only the plain sentence; the user types in the normal CLI prompt below the panel + +If a `clarify` call returns an error, inspect the error text, correct the payload, and retry once with a valid `question` and clean choices. + +When `clarify` is available and the dry run reveals any required user decision, your **next action must be a `clarify` tool call**. +Do not end the turn with a normal assistant message such as: + +- "Let me present the choices" +- "What would you like to do?" +- "Here are the options" + +If a user decision is required, collect it via `clarify` before producing more prose. +If multiple unresolved decisions remain, do not insert an explanatory assistant message between them. After one `clarify` response is received, your next action should usually be the next required `clarify` call. + +Treat `workspace-agents` as an unresolved decision whenever the dry run reports: + +- `kind="workspace-agents"` +- `status="skipped"` +- reason containing `No workspace target was provided` + +In that case, you must ask about workspace instructions before execution. Do not silently treat that as a decision to skip. + +Because of that limitation, use this simplified decision flow: + +1. For `SOUL.md` conflicts, use `clarify` with choices such as: + - `keep existing` + - `overwrite with backup` + - `review first` +2. If the dry run shows one or more `kind="skill"` items with `status="conflict"`, use `clarify` with choices such as: + - `keep existing skills` + - `overwrite conflicting skills with backup` + - `import conflicting skills under renamed folders` +3. For workspace instructions, use `clarify` with choices such as: + - `skip workspace instructions` + - `copy to a workspace path` + - `decide later` +4. If the user chooses to copy workspace instructions, ask a follow-up open-ended `clarify` question requesting an **absolute path**. +5. If the user chooses `skip workspace instructions` or `decide later`, proceed without `--workspace-target`. +5. For migration mode, use `clarify` with these 3 choices: + - `user-data only` + - `full compatible migration` + - `cancel` +6. `user-data only` means: migrate user data and compatible config, but do **not** import allowlisted secrets. +7. `full compatible migration` means: migrate the same compatible user data plus the allowlisted secrets when present. +8. If `clarify` is not available, ask the same question in normal text, but still constrain the answer to `user-data only`, `full compatible migration`, or `cancel`. + +Execution gate: + +- Do not execute while a `workspace-agents` skip caused by `No workspace target was provided` remains unresolved. +- The only valid ways to resolve it are: + - user explicitly chooses `skip workspace instructions` + - user explicitly chooses `decide later` + - user provides a workspace path after choosing `copy to a workspace path` +- Absence of a workspace target in the dry run is not itself permission to execute. +- Do not execute while any required `clarify` decision remains unresolved. + +Use these exact `clarify` payload shapes as the default pattern: + +- `{"question":"Your existing SOUL.md conflicts with the imported one. What should I do?","choices":["keep existing","overwrite with backup","review first"]}` +- `{"question":"One or more imported OpenClaw skills already exist in Hermes. How should I handle those skill conflicts?","choices":["keep existing skills","overwrite conflicting skills with backup","import conflicting skills under renamed folders"]}` +- `{"question":"Choose migration mode: migrate only user data, or run the full compatible migration including allowlisted secrets?","choices":["user-data only","full compatible migration","cancel"]}` +- `{"question":"Do you want to copy the OpenClaw workspace instructions file into a Hermes workspace?","choices":["skip workspace instructions","copy to a workspace path","decide later"]}` +- `{"question":"Please provide an absolute path where the workspace instructions should be copied."}` + +## Decision-to-command mapping + +Map user decisions to command flags exactly: + +- If the user chooses `keep existing` for `SOUL.md`, do **not** add `--overwrite`. +- If the user chooses `overwrite with backup`, add `--overwrite`. +- If the user chooses `review first`, stop before execution and review the relevant files. +- If the user chooses `keep existing skills`, add `--skill-conflict skip`. +- If the user chooses `overwrite conflicting skills with backup`, add `--skill-conflict overwrite`. +- If the user chooses `import conflicting skills under renamed folders`, add `--skill-conflict rename`. +- If the user chooses `user-data only`, execute with `--preset user-data` and do **not** add `--migrate-secrets`. +- If the user chooses `full compatible migration`, execute with `--preset full --migrate-secrets`. +- Only add `--workspace-target` if the user explicitly provided an absolute workspace path. +- If the user chooses `skip workspace instructions` or `decide later`, do not add `--workspace-target`. + +Before executing, restate the exact command plan in plain language and make sure it matches the user's choices. + +## Post-run reporting rules + +After execution, treat the script's JSON output as the source of truth. + +1. Base all counts on `report.summary`. +2. Only list an item under "Successfully Migrated" if its `status` is exactly `migrated`. +3. Do not claim a conflict was resolved unless the report shows that item as `migrated`. +4. Do not say `SOUL.md` was overwritten unless the report item for `kind="soul"` has `status="migrated"`. +5. If `report.summary.conflict > 0`, include a conflict section instead of silently implying success. +6. If counts and listed items disagree, fix the list to match the report before responding. +7. Include the `output_dir` path from the report when available so the user can inspect `report.json`, `summary.md`, backups, and archived files. +8. For memory or user-profile overflow, do not say the entries were archived unless the report explicitly shows an archive path. If `details.overflow_file` exists, say the full overflow list was exported there. +9. If a skill was imported under a renamed folder, report the final destination and mention `details.renamed_from`. +10. If `report.skill_conflict_mode` is present, use it as the source of truth for the selected imported-skill conflict policy. +11. If an item has `status="skipped"`, do not describe it as overwritten, backed up, migrated, or resolved. +12. If `kind="soul"` has `status="skipped"` with reason `Target already matches source`, say it was left unchanged and do not mention a backup. +13. If a renamed imported skill has an empty `details.backup`, do not imply the existing Hermes skill was renamed or backed up. Say only that the imported copy was placed in the new destination and reference `details.renamed_from` as the pre-existing folder that remained in place. + +## Migration presets + +Prefer these two presets in normal use: + +- `user-data` +- `full` + +`user-data` includes: + +- `soul` +- `workspace-agents` +- `memory` +- `user-profile` +- `messaging-settings` +- `command-allowlist` +- `skills` +- `tts-assets` +- `archive` + +`full` includes everything in `user-data` plus: + +- `secret-settings` + +The helper script still supports category-level `--include` / `--exclude`, but treat that as an advanced fallback rather than the default UX. + ## Commands -Dry run: +Dry run with full discovery: ```bash -python3 SKILL_DIR/scripts/openclaw_to_hermes.py --workspace-target "$PWD" +python3 ~/.hermes/skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py ``` -Execute: +When using the terminal tool, prefer an absolute invocation pattern such as: + +```json +{"command":"python3 /home/USER/.hermes/skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py","workdir":"/home/USER"} +``` + +Dry run with the user-data preset: ```bash -python3 SKILL_DIR/scripts/openclaw_to_hermes.py --execute --workspace-target "$PWD" +python3 ~/.hermes/skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py --preset user-data ``` -Execute with Hermes-compatible secret migration enabled: +Execute a user-data migration: ```bash -python3 SKILL_DIR/scripts/openclaw_to_hermes.py --execute --migrate-secrets --workspace-target "$PWD" +python3 ~/.hermes/skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py --execute --preset user-data --skill-conflict skip ``` -If the user does not want to import workspace instructions into the current directory, omit `--workspace-target`. +Execute a full compatible migration: + +```bash +python3 ~/.hermes/skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py --execute --preset full --migrate-secrets --skill-conflict skip +``` + +Execute with workspace instructions included: + +```bash +python3 ~/.hermes/skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py --execute --preset user-data --skill-conflict rename --workspace-target "/absolute/workspace/path" +``` + +Do not use `$PWD` or the home directory as the workspace target by default. Ask for an explicit workspace path first. ## Important rules @@ -72,6 +255,21 @@ If the user does not want to import workspace instructions into the current dire 4. Always give the user the skipped-items report. That report is part of the migration, not an optional extra. 5. Prefer the primary OpenClaw workspace (`~/.openclaw/workspace/`) over `workspace.default/`. Only use the default workspace as fallback when the primary files are missing. 6. Even in secret-migration mode, only migrate secrets with a clean Hermes destination. Unsupported auth blobs must still be reported as skipped. +7. If the dry run shows a large asset copy, a conflicting `SOUL.md`, or overflowed memory entries, call those out separately before execution. +8. Default to `user-data only` if the user is unsure. +9. Only include `workspace-agents` when the user has explicitly provided a destination workspace path. +10. Treat category-level `--include` / `--exclude` as an advanced escape hatch, not the normal flow. +11. Do not end the dry-run summary with a vague “What would you like to do?” if `clarify` is available. Use structured follow-up prompts instead. +12. Do not use an open-ended `clarify` prompt when a real choice prompt would work. Prefer selectable choices first, then free text only for absolute paths or file review requests. +13. After a dry run, never stop after summarizing if there is still an unresolved decision. Use `clarify` immediately for the highest-priority blocking decision. +14. Priority order for follow-up questions: + - `SOUL.md` conflict + - imported skill conflicts + - migration mode + - workspace instructions destination +15. Do not promise to present choices later in the same message. Present them by actually calling `clarify`. +16. After the migration-mode answer, explicitly check whether `workspace-agents` is still unresolved. If it is, your next action must be the workspace-instructions `clarify` call. +17. After any `clarify` answer, if another required decision remains, do not narrate what was just decided. Ask the next required question immediately. ## Expected result diff --git a/optional-skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py b/optional-skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py index 6cb7d95ca..380905046 100644 --- a/optional-skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py +++ b/optional-skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py @@ -32,9 +32,67 @@ SKILL_CATEGORY_DIRNAME = "openclaw-imports" SKILL_CATEGORY_DESCRIPTION = ( "Skills migrated from an OpenClaw workspace." ) +SKILL_CONFLICT_MODES = {"skip", "overwrite", "rename"} SUPPORTED_SECRET_TARGETS = { "TELEGRAM_BOT_TOKEN", } +WORKSPACE_INSTRUCTIONS_FILENAME = "AGENTS" + ".md" +MIGRATION_OPTION_METADATA: Dict[str, Dict[str, str]] = { + "soul": { + "label": "SOUL.md", + "description": "Import the OpenClaw persona file into Hermes.", + }, + "workspace-agents": { + "label": "Workspace instructions", + "description": "Copy the OpenClaw workspace instructions file into a chosen workspace.", + }, + "memory": { + "label": "MEMORY.md", + "description": "Import long-term memory entries into Hermes memories.", + }, + "user-profile": { + "label": "USER.md", + "description": "Import user profile entries into Hermes memories.", + }, + "messaging-settings": { + "label": "Messaging settings", + "description": "Import Hermes-compatible messaging settings such as allowlists and working directory.", + }, + "secret-settings": { + "label": "Allowlisted secrets", + "description": "Import the small allowlist of Hermes-compatible secrets when explicitly enabled.", + }, + "command-allowlist": { + "label": "Command allowlist", + "description": "Merge OpenClaw exec approval patterns into Hermes command_allowlist.", + }, + "skills": { + "label": "User skills", + "description": "Copy OpenClaw skills into ~/.hermes/skills/openclaw-imports/.", + }, + "tts-assets": { + "label": "TTS assets", + "description": "Copy compatible workspace TTS assets into ~/.hermes/tts/.", + }, + "archive": { + "label": "Archive unmapped docs", + "description": "Archive compatible-but-unmapped docs for later manual review.", + }, +} +MIGRATION_PRESETS: Dict[str, set[str]] = { + "user-data": { + "soul", + "workspace-agents", + "memory", + "user-profile", + "messaging-settings", + "command-allowlist", + "skills", + "tts-assets", + "archive", + }, + "full": set(MIGRATION_OPTION_METADATA), +} @dataclass @@ -47,6 +105,56 @@ class ItemResult: details: Dict[str, Any] = field(default_factory=dict) +def parse_selection_values(values: Optional[Sequence[str]]) -> List[str]: + parsed: List[str] = [] + for value in values or (): + for part in str(value).split(","): + part = part.strip().lower() + if part: + parsed.append(part) + return parsed + + +def resolve_selected_options( + include: Optional[Sequence[str]] = None, + exclude: Optional[Sequence[str]] = None, + preset: Optional[str] = None, +) -> set[str]: + include_values = parse_selection_values(include) + exclude_values = parse_selection_values(exclude) + valid = set(MIGRATION_OPTION_METADATA) + preset_name = (preset or "").strip().lower() + + if preset_name and preset_name not in MIGRATION_PRESETS: + raise ValueError( + "Unknown migration preset: " + + preset_name + + ". Valid presets: " + + ", ".join(sorted(MIGRATION_PRESETS)) + ) + + unknown = (set(include_values) - {"all"} - valid) | (set(exclude_values) - {"all"} - valid) + if unknown: + raise ValueError( + "Unknown migration option(s): " + + ", ".join(sorted(unknown)) + + ". Valid options: " + + ", ".join(sorted(valid)) + ) + + if preset_name: + selected = set(MIGRATION_PRESETS[preset_name]) + elif not include_values or "all" in include_values: + selected = set(valid) + else: + selected = set(include_values) + + if "all" in exclude_values: + selected.clear() + selected -= (set(exclude_values) - {"all"}) + return selected + + def sha256_file(path: Path) -> str: h = hashlib.sha256() with path.open("rb") as fh: @@ -294,6 +402,9 @@ class Migrator: overwrite: bool, migrate_secrets: bool, output_dir: Optional[Path], + selected_options: Optional[set[str]] = None, + preset_name: str = "", + skill_conflict_mode: str = "skip", ): self.source_root = source_root self.target_root = target_root @@ -301,12 +412,16 @@ class Migrator: self.workspace_target = workspace_target self.overwrite = overwrite self.migrate_secrets = migrate_secrets + self.selected_options = set(selected_options or MIGRATION_OPTION_METADATA.keys()) + self.preset_name = preset_name.strip().lower() + self.skill_conflict_mode = skill_conflict_mode.strip().lower() or "skip" self.timestamp = datetime.now().strftime("%Y%m%dT%H%M%S") self.output_dir = output_dir or ( target_root / "migration" / "openclaw" / self.timestamp if execute else None ) self.archive_dir = self.output_dir / "archive" if self.output_dir else None self.backup_dir = self.output_dir / "backups" if self.output_dir else None + self.overflow_dir = self.output_dir / "overflow" if self.output_dir else None self.items: List[ItemResult] = [] config = load_yaml_file(self.target_root / "config.yaml") @@ -314,6 +429,17 @@ class Migrator: self.memory_limit = int(mem_cfg.get("memory_char_limit", DEFAULT_MEMORY_CHAR_LIMIT)) self.user_limit = int(mem_cfg.get("user_char_limit", DEFAULT_USER_CHAR_LIMIT)) + if self.skill_conflict_mode not in SKILL_CONFLICT_MODES: + raise ValueError( + "Unknown skill conflict mode: " + + self.skill_conflict_mode + + ". Valid modes: " + + ", ".join(sorted(SKILL_CONFLICT_MODES)) + ) + + def is_selected(self, option_id: str) -> bool: + return option_id in self.selected_options + def record( self, kind: str, @@ -341,37 +467,68 @@ class Migrator: return candidate return None + def resolve_skill_destination(self, destination: Path) -> Path: + if self.skill_conflict_mode != "rename" or not destination.exists(): + return destination + + suffix = "-imported" + candidate = destination.with_name(destination.name + suffix) + counter = 2 + while candidate.exists(): + candidate = destination.with_name(f"{destination.name}{suffix}-{counter}") + counter += 1 + return candidate + def migrate(self) -> Dict[str, Any]: if not self.source_root.exists(): self.record("source", self.source_root, None, "error", "OpenClaw directory does not exist") return self.build_report() - self.migrate_soul() - self.migrate_workspace_agents() - self.migrate_memory( - self.source_candidate("workspace/MEMORY.md", "workspace.default/MEMORY.md"), - self.target_root / "memories" / "MEMORY.md", - self.memory_limit, - kind="memory", + config = self.load_openclaw_config() + + self.run_if_selected("soul", self.migrate_soul) + self.run_if_selected("workspace-agents", self.migrate_workspace_agents) + self.run_if_selected( + "memory", + lambda: self.migrate_memory( + self.source_candidate("workspace/MEMORY.md", "workspace.default/MEMORY.md"), + self.target_root / "memories" / "MEMORY.md", + self.memory_limit, + kind="memory", + ), ) - self.migrate_memory( - self.source_candidate("workspace/USER.md", "workspace.default/USER.md"), - self.target_root / "memories" / "USER.md", - self.user_limit, - kind="user-profile", + self.run_if_selected( + "user-profile", + lambda: self.migrate_memory( + self.source_candidate("workspace/USER.md", "workspace.default/USER.md"), + self.target_root / "memories" / "USER.md", + self.user_limit, + kind="user-profile", + ), ) - self.migrate_messaging_settings() - self.migrate_command_allowlist() - self.migrate_skills() - self.copy_tree_non_destructive( - self.source_candidate("workspace/tts"), - self.target_root / "tts", - kind="tts-assets", - ignore_dir_names={".venv", "generated", "__pycache__"}, + self.run_if_selected("messaging-settings", lambda: self.migrate_messaging_settings(config)) + self.run_if_selected("secret-settings", lambda: self.handle_secret_settings(config)) + self.run_if_selected("command-allowlist", self.migrate_command_allowlist) + self.run_if_selected("skills", self.migrate_skills) + self.run_if_selected( + "tts-assets", + lambda: self.copy_tree_non_destructive( + self.source_candidate("workspace/tts"), + self.target_root / "tts", + kind="tts-assets", + ignore_dir_names={".venv", "generated", "__pycache__"}, + ), ) - self.archive_docs() + self.run_if_selected("archive", self.archive_docs) return self.build_report() + def run_if_selected(self, option_id: str, func) -> None: + if self.is_selected(option_id): + func() + return + meta = MIGRATION_OPTION_METADATA[option_id] + self.record(option_id, None, None, "skipped", "Not selected for this run", option_label=meta["label"]) + def build_report(self) -> Dict[str, Any]: summary: Dict[str, int] = { "migrated": 0, @@ -391,6 +548,21 @@ class Migrator: "workspace_target": str(self.workspace_target) if self.workspace_target else None, "output_dir": str(self.output_dir) if self.output_dir else None, "migrate_secrets": self.migrate_secrets, + "preset": self.preset_name or None, + "skill_conflict_mode": self.skill_conflict_mode, + "selection": { + "selected": sorted(self.selected_options), + "preset": self.preset_name or None, + "skill_conflict_mode": self.skill_conflict_mode, + "available": [ + {"id": option_id, **meta} + for option_id, meta in MIGRATION_OPTION_METADATA.items() + ], + "presets": [ + {"id": preset_id, "selected": sorted(option_ids)} + for preset_id, option_ids in MIGRATION_PRESETS.items() + ], + }, "summary": summary, "items": [asdict(item) for item in self.items], } @@ -405,6 +577,15 @@ class Migrator: return None return backup_existing(path, self.backup_dir) + def write_overflow_entries(self, kind: str, entries: Sequence[str]) -> Optional[Path]: + if not entries or not self.overflow_dir: + return None + self.overflow_dir.mkdir(parents=True, exist_ok=True) + filename = f"{kind.replace('-', '_')}_overflow.txt" + path = self.overflow_dir / filename + path.write_text("\n".join(entries) + "\n", encoding="utf-8") + return path + def copy_file(self, source: Path, destination: Path, kind: str) -> None: if not source or not source.exists(): return @@ -433,13 +614,16 @@ class Migrator: self.copy_file(source, self.target_root / "SOUL.md", kind="soul") def migrate_workspace_agents(self) -> None: - source = self.source_candidate("workspace/AGENTS.md", "workspace.default/AGENTS.md") + source = self.source_candidate( + f"workspace/{WORKSPACE_INSTRUCTIONS_FILENAME}", + f"workspace.default/{WORKSPACE_INSTRUCTIONS_FILENAME}", + ) if not source: return if not self.workspace_target: self.record("workspace-agents", source, None, "skipped", "No workspace target was provided") return - destination = self.workspace_target / "AGENTS.md" + destination = self.workspace_target / WORKSPACE_INSTRUCTIONS_FILENAME self.copy_file(source, destination, kind="workspace-agents") def migrate_memory(self, source: Optional[Path], destination: Path, limit: int, kind: str) -> None: @@ -462,6 +646,9 @@ class Migrator: "char_limit": limit, "final_char_count": len(ENTRY_DELIMITER.join(merged)) if merged else 0, } + overflow_file = self.write_overflow_entries(kind, overflowed) + if overflow_file is not None: + details["overflow_file"] = str(overflow_file) if self.execute: if stats["added"] == 0 and not overflowed: @@ -597,10 +784,9 @@ class Migrator: conflicting_keys=conflicts, ) - def migrate_messaging_settings(self) -> None: - config = self.load_openclaw_config() + def migrate_messaging_settings(self, config: Optional[Dict[str, Any]] = None) -> None: + config = config or self.load_openclaw_config() additions: Dict[str, str] = {} - sources: List[str] = [] workspace = ( config.get("agents", {}) @@ -609,7 +795,6 @@ class Migrator: ) if isinstance(workspace, str) and workspace.strip(): additions["MESSAGING_CWD"] = workspace.strip() - sources.append("openclaw.json:agents.defaults.workspace") allowlist_path = self.source_root / "credentials" / "telegram-default-allowFrom.json" if allowlist_path.exists(): @@ -623,30 +808,40 @@ class Migrator: users = [str(user).strip() for user in allow_from if str(user).strip()] if users: additions["TELEGRAM_ALLOWED_USERS"] = ",".join(users) - sources.append("credentials/telegram-default-allowFrom.json") if additions: self.merge_env_values(additions, "messaging-settings", self.source_root / "openclaw.json") else: self.record("messaging-settings", self.source_root / "openclaw.json", self.target_root / ".env", "skipped", "No Hermes-compatible messaging settings found") + def handle_secret_settings(self, config: Optional[Dict[str, Any]] = None) -> None: + config = config or self.load_openclaw_config() if self.migrate_secrets: self.migrate_secret_settings(config) + return + + config_path = self.source_root / "openclaw.json" + if config_path.exists(): + self.record( + "secret-settings", + config_path, + self.target_root / ".env", + "skipped", + "Secret migration disabled. Re-run with --migrate-secrets to import allowlisted secrets.", + supported_targets=sorted(SUPPORTED_SECRET_TARGETS), + ) else: - config_path = self.source_root / "openclaw.json" - if config_path.exists(): - self.record( - "secret-settings", - config_path, - self.target_root / ".env", - "skipped", - "Secret migration disabled. Re-run with --migrate-secrets to import allowlisted secrets.", - supported_targets=sorted(SUPPORTED_SECRET_TARGETS), - ) + self.record( + "secret-settings", + config_path, + self.target_root / ".env", + "skipped", + "OpenClaw config file not found", + supported_targets=sorted(SUPPORTED_SECRET_TARGETS), + ) def migrate_secret_settings(self, config: Dict[str, Any]) -> None: secret_additions: Dict[str, str] = {} - sources: List[str] = [] telegram_token = ( config.get("channels", {}) @@ -655,7 +850,6 @@ class Migrator: ) if isinstance(telegram_token, str) and telegram_token.strip(): secret_additions["TELEGRAM_BOT_TOKEN"] = telegram_token.strip() - sources.append("openclaw.json:channels.telegram.botToken") if secret_additions: self.merge_env_values(secret_additions, "secret-settings", self.source_root / "openclaw.json") @@ -683,18 +877,37 @@ class Migrator: for skill_dir in skill_dirs: destination = destination_root / skill_dir.name - if destination.exists() and not self.overwrite: - self.record("skill", skill_dir, destination, "conflict", "Destination skill already exists") - continue + final_destination = destination + if destination.exists(): + if self.skill_conflict_mode == "skip": + self.record("skill", skill_dir, destination, "conflict", "Destination skill already exists") + continue + if self.skill_conflict_mode == "rename": + final_destination = self.resolve_skill_destination(destination) if self.execute: - backup_path = self.maybe_backup(destination) - destination.parent.mkdir(parents=True, exist_ok=True) - if destination.exists(): + backup_path = None + if final_destination == destination and destination.exists(): + backup_path = self.maybe_backup(destination) + final_destination.parent.mkdir(parents=True, exist_ok=True) + if final_destination == destination and destination.exists(): shutil.rmtree(destination) - shutil.copytree(skill_dir, destination) - self.record("skill", skill_dir, destination, "migrated", backup=str(backup_path) if backup_path else "") + shutil.copytree(skill_dir, final_destination) + details: Dict[str, Any] = {"backup": str(backup_path) if backup_path else ""} + if final_destination != destination: + details["renamed_from"] = str(destination) + self.record("skill", skill_dir, final_destination, "migrated", **details) else: - self.record("skill", skill_dir, destination, "migrated", "Would copy skill directory") + if final_destination != destination: + self.record( + "skill", + skill_dir, + final_destination, + "migrated", + "Would copy skill directory under a renamed folder", + renamed_from=str(destination), + ) + else: + self.record("skill", skill_dir, final_destination, "migrated", "Would copy skill directory") desc_path = destination_root / "DESCRIPTION.md" if self.execute: @@ -810,16 +1023,53 @@ def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Migrate OpenClaw user state into Hermes Agent.") parser.add_argument("--source", default=str(Path.home() / ".openclaw"), help="OpenClaw home directory") parser.add_argument("--target", default=str(Path.home() / ".hermes"), help="Hermes home directory") - parser.add_argument("--workspace-target", help="Optional workspace root where AGENTS.md should be copied") + parser.add_argument( + "--workspace-target", + help="Optional workspace root where the workspace instructions file should be copied", + ) parser.add_argument("--execute", action="store_true", help="Apply changes instead of reporting a dry run") parser.add_argument("--overwrite", action="store_true", help="Overwrite existing Hermes targets after backing them up") - parser.add_argument("--migrate-secrets", action="store_true", help="Import a narrow allowlist of Hermes-compatible secrets into ~/.hermes/.env") + parser.add_argument( + "--migrate-secrets", + action="store_true", + help="Import a narrow allowlist of Hermes-compatible secrets into the target env file", + ) + parser.add_argument( + "--skill-conflict", + choices=sorted(SKILL_CONFLICT_MODES), + default="skip", + help="How to handle imported skill directory conflicts: skip, overwrite, or rename the imported copy.", + ) + parser.add_argument( + "--preset", + choices=sorted(MIGRATION_PRESETS), + help="Apply a named migration preset. 'user-data' excludes allowlisted secrets; 'full' includes all compatible groups.", + ) + parser.add_argument( + "--include", + action="append", + default=[], + help="Comma-separated migration option ids to include (default: all). " + f"Valid ids: {', '.join(sorted(MIGRATION_OPTION_METADATA))}", + ) + parser.add_argument( + "--exclude", + action="append", + default=[], + help="Comma-separated migration option ids to skip. " + f"Valid ids: {', '.join(sorted(MIGRATION_OPTION_METADATA))}", + ) parser.add_argument("--output-dir", help="Where to write report, backups, and archived docs") return parser.parse_args() def main() -> int: args = parse_args() + try: + selected_options = resolve_selected_options(args.include, args.exclude, preset=args.preset) + except ValueError as exc: + print(json.dumps({"error": str(exc)}, indent=2, ensure_ascii=False)) + return 2 migrator = Migrator( source_root=Path(os.path.expanduser(args.source)).resolve(), target_root=Path(os.path.expanduser(args.target)).resolve(), @@ -828,6 +1078,9 @@ def main() -> int: overwrite=bool(args.overwrite), migrate_secrets=bool(args.migrate_secrets), output_dir=Path(os.path.expanduser(args.output_dir)).resolve() if args.output_dir else None, + selected_options=selected_options, + preset_name=args.preset or "", + skill_conflict_mode=args.skill_conflict, ) report = migrator.migrate() print(json.dumps(report, indent=2, ensure_ascii=False)) diff --git a/tests/skills/test_openclaw_migration.py b/tests/skills/test_openclaw_migration.py index eb513afb4..e6caa534e 100644 --- a/tests/skills/test_openclaw_migration.py +++ b/tests/skills/test_openclaw_migration.py @@ -25,6 +25,18 @@ def load_module(): return module +def load_skills_guard(): + spec = importlib.util.spec_from_file_location( + "skills_guard_local", + Path(__file__).resolve().parents[2] / "tools" / "skills_guard.py", + ) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + def test_extract_markdown_entries_promotes_heading_context(): mod = load_module() text = """# MEMORY.md - Long-Term Memory @@ -55,10 +67,46 @@ def test_merge_entries_respects_limit_and_reports_overflow(): assert overflowed == ["gamma is too long"] +def test_resolve_selected_options_supports_include_and_exclude(): + mod = load_module() + selected = mod.resolve_selected_options(["memory,skills", "user-profile"], ["skills"]) + assert selected == {"memory", "user-profile"} + + +def test_resolve_selected_options_supports_presets(): + mod = load_module() + user_data = mod.resolve_selected_options(preset="user-data") + full = mod.resolve_selected_options(preset="full") + assert "secret-settings" not in user_data + assert "secret-settings" in full + assert user_data < full + + +def test_resolve_selected_options_rejects_unknown_values(): + mod = load_module() + try: + mod.resolve_selected_options(["memory,unknown-option"], None) + except ValueError as exc: + assert "unknown-option" in str(exc) + else: + raise AssertionError("Expected ValueError for unknown migration option") + + +def test_resolve_selected_options_rejects_unknown_preset(): + mod = load_module() + try: + mod.resolve_selected_options(preset="everything") + except ValueError as exc: + assert "everything" in str(exc) + else: + raise AssertionError("Expected ValueError for unknown migration preset") + + def test_migrator_copies_skill_and_merges_allowlist(tmp_path: Path): mod = load_module() source = tmp_path / ".openclaw" target = tmp_path / ".hermes" + target.mkdir() (source / "workspace" / "skills" / "demo-skill").mkdir(parents=True) (source / "workspace" / "skills" / "demo-skill" / "SKILL.md").write_text( @@ -135,3 +183,184 @@ def test_migrator_optionally_imports_supported_secrets_and_messaging_settings(tm assert "MESSAGING_CWD=/tmp/openclaw-workspace" in env_text assert "TELEGRAM_ALLOWED_USERS=111,222" in env_text assert "TELEGRAM_BOT_TOKEN=123:abc" in env_text + + +def test_migrator_can_execute_only_selected_categories(tmp_path: Path): + mod = load_module() + source = tmp_path / ".openclaw" + target = tmp_path / ".hermes" + target.mkdir() + + (source / "workspace" / "skills" / "demo-skill").mkdir(parents=True) + (source / "workspace" / "skills" / "demo-skill" / "SKILL.md").write_text( + "---\nname: demo-skill\ndescription: demo\n---\n\nbody\n", + encoding="utf-8", + ) + (source / "workspace" / "MEMORY.md").write_text( + "# Memory\n\n- keep me\n", + encoding="utf-8", + ) + (target / "config.yaml").write_text("command_allowlist: []\n", encoding="utf-8") + + migrator = mod.Migrator( + source_root=source, + target_root=target, + execute=True, + workspace_target=None, + overwrite=False, + migrate_secrets=False, + output_dir=target / "migration-report", + selected_options={"skills"}, + ) + report = migrator.migrate() + + imported_skill = target / "skills" / mod.SKILL_CATEGORY_DIRNAME / "demo-skill" / "SKILL.md" + assert imported_skill.exists() + assert not (target / "memories" / "MEMORY.md").exists() + assert report["selection"]["selected"] == ["skills"] + skipped_items = [item for item in report["items"] if item["status"] == "skipped"] + assert any(item["kind"] == "memory" and item["reason"] == "Not selected for this run" for item in skipped_items) + + +def test_migrator_records_preset_in_report(tmp_path: Path): + mod = load_module() + source = tmp_path / ".openclaw" + target = tmp_path / ".hermes" + target.mkdir() + (target / "config.yaml").write_text("command_allowlist: []\n", encoding="utf-8") + + migrator = mod.Migrator( + source_root=source, + target_root=target, + execute=False, + workspace_target=None, + overwrite=False, + migrate_secrets=False, + output_dir=None, + selected_options=mod.MIGRATION_PRESETS["user-data"], + preset_name="user-data", + ) + report = migrator.build_report() + + assert report["preset"] == "user-data" + assert report["selection"]["preset"] == "user-data" + assert report["skill_conflict_mode"] == "skip" + assert report["selection"]["skill_conflict_mode"] == "skip" + + +def test_migrator_exports_full_overflow_entries(tmp_path: Path): + mod = load_module() + source = tmp_path / ".openclaw" + target = tmp_path / ".hermes" + target.mkdir() + (target / "config.yaml").write_text("memory:\n memory_char_limit: 10\n user_char_limit: 10\n", encoding="utf-8") + (source / "workspace").mkdir(parents=True) + (source / "workspace" / "MEMORY.md").write_text( + "# Memory\n\n- alpha\n- beta\n- gamma\n", + encoding="utf-8", + ) + + migrator = mod.Migrator( + source_root=source, + target_root=target, + execute=True, + workspace_target=None, + overwrite=False, + migrate_secrets=False, + output_dir=target / "migration-report", + selected_options={"memory"}, + ) + report = migrator.migrate() + + memory_item = next(item for item in report["items"] if item["kind"] == "memory") + overflow_file = Path(memory_item["details"]["overflow_file"]) + assert overflow_file.exists() + text = overflow_file.read_text(encoding="utf-8") + assert "alpha" in text or "beta" in text or "gamma" in text + + +def test_migrator_can_rename_conflicting_imported_skill(tmp_path: Path): + mod = load_module() + source = tmp_path / ".openclaw" + target = tmp_path / ".hermes" + target.mkdir() + + source_skill = source / "workspace" / "skills" / "demo-skill" + source_skill.mkdir(parents=True) + (source_skill / "SKILL.md").write_text( + "---\nname: demo-skill\ndescription: demo\n---\n\nbody\n", + encoding="utf-8", + ) + + existing_skill = target / "skills" / mod.SKILL_CATEGORY_DIRNAME / "demo-skill" + existing_skill.mkdir(parents=True) + (existing_skill / "SKILL.md").write_text( + "---\nname: demo-skill\ndescription: existing\n---\n\nexisting\n", + encoding="utf-8", + ) + + migrator = mod.Migrator( + source_root=source, + target_root=target, + execute=True, + workspace_target=None, + overwrite=False, + migrate_secrets=False, + output_dir=target / "migration-report", + skill_conflict_mode="rename", + ) + report = migrator.migrate() + + renamed_skill = target / "skills" / mod.SKILL_CATEGORY_DIRNAME / "demo-skill-imported" / "SKILL.md" + assert renamed_skill.exists() + assert existing_skill.joinpath("SKILL.md").read_text(encoding="utf-8").endswith("existing\n") + imported_items = [item for item in report["items"] if item["kind"] == "skill" and item["status"] == "migrated"] + assert any(item["details"].get("renamed_from", "").endswith("/demo-skill") for item in imported_items) + + +def test_migrator_can_overwrite_conflicting_imported_skill_with_backup(tmp_path: Path): + mod = load_module() + source = tmp_path / ".openclaw" + target = tmp_path / ".hermes" + target.mkdir() + + source_skill = source / "workspace" / "skills" / "demo-skill" + source_skill.mkdir(parents=True) + (source_skill / "SKILL.md").write_text( + "---\nname: demo-skill\ndescription: imported\n---\n\nfresh\n", + encoding="utf-8", + ) + + existing_skill = target / "skills" / mod.SKILL_CATEGORY_DIRNAME / "demo-skill" + existing_skill.mkdir(parents=True) + (existing_skill / "SKILL.md").write_text( + "---\nname: demo-skill\ndescription: existing\n---\n\nexisting\n", + encoding="utf-8", + ) + + migrator = mod.Migrator( + source_root=source, + target_root=target, + execute=True, + workspace_target=None, + overwrite=False, + migrate_secrets=False, + output_dir=target / "migration-report", + skill_conflict_mode="overwrite", + ) + report = migrator.migrate() + + assert existing_skill.joinpath("SKILL.md").read_text(encoding="utf-8").endswith("fresh\n") + backup_items = [item for item in report["items"] if item["kind"] == "skill" and item["status"] == "migrated"] + assert any(item["details"].get("backup") for item in backup_items) + + +def test_skill_installs_cleanly_under_skills_guard(): + skills_guard = load_skills_guard() + result = skills_guard.scan_skill( + SCRIPT_PATH.parents[1], + source="official/migration/openclaw-migration", + ) + + assert result.verdict == "safe" + assert result.findings == []